diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 9d47c993a4..e3e54f11e5 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index e7dd5eb2f5..0deb3d3708 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -124,6 +124,10 @@ Please see LICENSE files in the repository root for full details. } } + .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 929de80437..84c43fc19d 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -131,6 +131,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) { @@ -182,6 +183,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(); @@ -190,6 +196,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); @@ -200,6 +231,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 1cb31d858f..e2d928b1d7 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -26,7 +26,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"; @@ -85,7 +85,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; } @@ -1694,6 +1694,8 @@ export const UserInfoHeader: React.FC<{ ); } + const timezoneInfo = useUserTimezone(cli, member.userId); + const e2eIcon = e2eStatus ? : null; const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { roomId, @@ -1727,6 +1729,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 85b2bdfd5c..d95b0894d9 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -302,6 +302,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 3130de7a76..21addb3b98 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", @@ -2719,6 +2720,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 5e5c9a1535..a82fdef1ba 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { ReactNode } from "react"; +import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; import { _t, _td, TranslationKey } from "../languageHandler"; import { @@ -646,6 +647,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/structures/LoggedInView-test.tsx b/test/components/structures/LoggedInView-test.tsx index 771f3c729e..c08812e46f 100644 --- a/test/components/structures/LoggedInView-test.tsx +++ b/test/components/structures/LoggedInView-test.tsx @@ -24,6 +24,7 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Action } from "../../../src/dispatcher/actions"; import Modal from "../../../src/Modal"; +import { SETTINGS } from "../../../src/settings/Settings"; describe("", () => { const userId = "@alice:domain.org"; @@ -37,6 +38,9 @@ describe("", () => { setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), getCrypto: jest.fn().mockReturnValue(undefined), + setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined), + deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined), + doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true), }); const mediaHandler = new MediaHandler(mockClient); const mockSdkContext = new TestSdkContext(); @@ -409,4 +413,48 @@ describe("", () => { await userEvent.keyboard("{Control>}{Alt>}h{/Control}"); expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage }); }); + + describe("timezone updates", () => { + const userTimezone = "Europe/London"; + const originalController = SETTINGS["userTimezonePublish"].controller; + + beforeEach(async () => { + SETTINGS["userTimezonePublish"].controller = undefined; + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone); + }); + + afterEach(() => { + SETTINGS["userTimezonePublish"].controller = originalController; + }); + + it("does not update the timezone when userTimezonePublish is off", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz"); + expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + it("should set the user timezone when userTimezonePublish is enabled", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + }); + + it("should set the user timezone when the timezone is changed", async () => { + const newTimezone = "Europe/Paris"; + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone); + }); + + it("should clear the timezone when the publish feature is turned off", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz"); + }); + }); }); diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index 06944629b6..8c21246fa5 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -92,6 +92,7 @@ let mockRoom: Mocked; let mockSpace: Mocked; let mockClient: Mocked; let mockCrypto: Mocked; +const origDate = global.Date.prototype.toLocaleString; beforeEach(() => { mockRoom = mocked({ @@ -150,6 +151,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: { @@ -229,6 +232,28 @@ 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 ( + this: Date, + _locale, + opts, + ) { + return origDate.call(this, "en-US", opts); + }); + mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true); + mockClient.getExtendedProfileProperty.mockResolvedValue("Europe/London"); + renderComponent(); + await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument(); + }); + + it("does not renders user timezone if timezone is invalid", async () => { + mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true); + mockClient.getExtendedProfileProperty.mockResolvedValue("invalid-tz"); + renderComponent(); + expect(screen.queryByText(/\d\d:\d\d (AM|PM)/)).not.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 74b5375ebc..ddbbe2e000 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`] = ` /> +
+ +
+
+
+
-
- -
- Use Ctrl + Enter to send a message + Enable Emoji suggestions while typing
- Surround selected text when typing special characters + Use Ctrl + Enter to send a message
- Show stickers button + Surround selected text when typing special characters
+ + Show stickers button + + +
+
+
+
+
+
-
- -
+ + Expand code blocks by default + + +
+
+
+
+
+
-
- -
- Autoplay videos + Autoplay GIFs
+ + Autoplay videos + + +
+
+
+
+
+
-
- -
- Show read receipts sent by other users + Show a placeholder for removed messages
- Show join/leave messages (invites/removes/bans unaffected) + Show read receipts sent by other users
- Show display name changes + Show join/leave messages (invites/removes/bans unaffected)
- Show chat effects (animations when receiving e.g. confetti) + Show display name changes
- Show profile picture changes + Show chat effects (animations when receiving e.g. confetti)
- Show avatars in user, room and event mentions + Show profile picture changes
- Enable big emoji in chat + Show avatars in user, room and event mentions
- Jump to the bottom of the timeline when you send a message + Enable big emoji in chat
+ + Jump to the bottom of the timeline when you send a message + + +
+
+
+
+
+