diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 186eb78a32f..3a5dfe3b053 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -132,6 +132,10 @@ limitations under the License. } } + .mx_UserInfo_timezone { + margin: var(--cpd-space-1x) 0; + } + .mx_PresenceLabel { font: var(--cpd-font-body-sm-regular); opacity: 1; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 755d2c11568..cba172a0715 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -139,6 +139,7 @@ class LoggedInView extends React.Component { protected layoutWatcherRef?: string; protected compactLayoutWatcherRef?: string; protected backgroundImageWatcherRef?: string; + protected timezoneProfileUpdateRef?: string[]; protected resizer?: Resizer; public constructor(props: IProps) { @@ -190,6 +191,11 @@ class LoggedInView extends React.Component { this.refreshBackgroundImage, ); + this.timezoneProfileUpdateRef = [ + SettingsStore.watchSetting("userTimezonePublish", null, this.onTimezoneUpdate), + SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate), + ]; + this.resizer = this.createResizer(); this.resizer.attach(); @@ -198,6 +204,31 @@ class LoggedInView extends React.Component { this.refreshBackgroundImage(); } + private onTimezoneUpdate = async (): Promise => { + if (!SettingsStore.getValue("userTimezonePublish")) { + // Ensure it's deleted + try { + await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz"); + } catch (ex) { + console.warn("Failed to delete timezone from user profile", ex); + } + return; + } + const currentTimezone = + SettingsStore.getValue("userTimezone") || + // If the timezone is empty, then use the browser timezone. + // eslint-disable-next-line new-cap + Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!currentTimezone || typeof currentTimezone !== "string") { + return; + } + try { + await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone); + } catch (ex) { + console.warn("Failed to update user profile with current timezone", ex); + } + }; + public componentWillUnmount(): void { document.removeEventListener("keydown", this.onNativeKeyDown, false); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); @@ -208,6 +239,7 @@ class LoggedInView extends React.Component { if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef); if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef); + this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s)); this.resizer?.detach(); } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 9cc50fa9287..d03182caa0e 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -34,7 +34,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; -import { Heading, MenuItem, Text } from "@vector-im/compound-web"; +import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web"; import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; @@ -93,7 +93,7 @@ import { SdkContextClass } from "../../../contexts/SDKContext"; import { asyncSome } from "../../../utils/arrays"; import { Flex } from "../../utils/Flex"; import CopyableText from "../elements/CopyableText"; - +import { useUserTimezone } from "../../../hooks/useUserTimezone"; export interface IDevice extends Device { ambiguous?: boolean; } @@ -1702,6 +1702,8 @@ export const UserInfoHeader: React.FC<{ ); } + const timezoneInfo = useUserTimezone(cli, member.userId); + const e2eIcon = e2eStatus ? : null; const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { roomId, @@ -1735,6 +1737,15 @@ export const UserInfoHeader: React.FC<{ {presenceLabel} + {timezoneInfo && ( + + + + {timezoneInfo?.friendly ?? ""} + + + + )} userIdentifier} border={false}> {userIdentifier} diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index d74ea346620..39ebb092ec6 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -310,6 +310,7 @@ export default class PreferencesUserSettingsTab extends React.Component {this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)} + { + const [timezone, setTimezone] = useState(); + const [updateInterval, setUpdateInterval] = useState(); + const [friendly, setFriendly] = useState(); + const [supported, setSupported] = useState(); + + useEffect(() => { + if (!cli || supported !== undefined) { + return; + } + cli.doesServerSupportExtendedProfiles() + .then(setSupported) + .catch((ex) => { + console.warn("Unable to determine if extended profiles are supported", ex); + }); + }, [supported, cli]); + + useEffect(() => { + return () => { + if (updateInterval) { + clearInterval(updateInterval); + } + }; + }, [updateInterval]); + + useEffect(() => { + if (supported !== true) { + return; + } + (async () => { + console.log("Trying to fetch TZ"); + try { + const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz"); + if (typeof tz !== "string") { + // Err, definitely not a tz. + throw Error("Timezone value was not a string"); + } + // This will validate the timezone for us. + // eslint-disable-next-line new-cap + Intl.DateTimeFormat(undefined, { timeZone: tz }); + + const updateTime = (): void => { + const currentTime = new Date(); + const friendly = currentTime.toLocaleString(undefined, { + timeZone: tz, + hour12: true, + hour: "2-digit", + minute: "2-digit", + timeZoneName: "shortOffset", + }); + setTimezone(tz); + setFriendly(friendly); + setUpdateInterval(setTimeout(updateTime, (60 - currentTime.getSeconds()) * 1000)); + }; + updateTime(); + } catch (ex) { + setTimezone(undefined); + setFriendly(undefined); + setUpdateInterval(undefined); + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + // No timezone set, ignore. + return; + } + console.error("Could not render current timezone for user", ex); + } + })(); + }, [supported, userId, cli]); + + if (!timezone || !friendly) { + return null; + } + + return { + friendly, + timezone, + }; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e1e44efc61d..d394b93b5ed 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1426,6 +1426,7 @@ "element_call_video_rooms": "Element Call video rooms", "experimental_description": "Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. Learn more.", "experimental_section": "Early previews", + "extended_profiles_msc_support": "Requires your server to support MSC4133", "feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call", "feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.", "group_calls": "New group call experience", @@ -2721,6 +2722,7 @@ "keyboard_view_shortcuts_button": "To view all keyboard shortcuts, click here.", "media_heading": "Images, GIFs and videos", "presence_description": "Share your activity and status with others.", + "publish_timezone": "Publish timezone on public profile", "rm_lifetime": "Read Marker lifetime (ms)", "rm_lifetime_offscreen": "Read Marker off-screen lifetime (ms)", "room_directory_heading": "Room directory", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 7cd58c9355d..b9e432fd55a 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; import { _t, _td, TranslationKey } from "../languageHandler"; import { @@ -654,6 +655,19 @@ export const SETTINGS: { [setting: string]: ISetting } = { displayName: _td("settings|preferences|user_timezone"), default: "", }, + "userTimezonePublish": { + // This is per-device so you can avoid having devices overwrite each other. + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + displayName: _td("settings|preferences|publish_timezone"), + default: false, + controller: new ServerSupportUnstableFeatureController( + "userTimezonePublish", + defaultWatchManager, + [[UNSTABLE_MSC4133_EXTENDED_PROFILES]], + undefined, + _td("labs|extended_profiles_msc_support"), + ), + }, "autoplayGifs": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("settings|autoplay_gifs"), diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index a12ce75b2ea..ff4514cdfcb 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -100,6 +100,7 @@ let mockRoom: Mocked; let mockSpace: Mocked; let mockClient: Mocked; let mockCrypto: Mocked; +const origDate = global.Date.prototype.toLocaleString; beforeEach(() => { mockRoom = mocked({ @@ -158,6 +159,8 @@ beforeEach(() => { isSynapseAdministrator: jest.fn().mockResolvedValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false), doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false), + doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false), + getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")), mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), removeListener: jest.fn(), currentState: { @@ -237,6 +240,17 @@ describe("", () => { expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument(); }); + it("renders user timezone if set", async () => { + // For timezone, force a consistent locale. + jest.spyOn(global.Date.prototype, "toLocaleString").mockImplementation(function (_locale, opts) { + return origDate.call(this, "en-US", opts); // eslint-disable-line @typescript-eslint/no-invalid-this + }); + mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true); + mockClient.getExtendedProfileProperty.mockResolvedValue("Europe/London"); + renderComponent(); + await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument(); + }); + it("renders encryption info panel without pending verification", () => { renderComponent({ phase: RightPanelPhases.EncryptionPanel }); expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument(); diff --git a/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap index 74b5375ebc1..ddbbe2e000a 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap @@ -307,6 +307,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` /> +
+ +
+
+
+