diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts index 368935e91c..ed56ba5e62 100644 --- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -17,6 +17,11 @@ limitations under the License. import { test, expect } from "../../element-web-test"; +test.use({ + locale: "en-GB", + timezoneId: "Europe/London", +}); + test.describe("Preferences user settings tab", () => { test.use({ displayName: "Bob", @@ -26,9 +31,9 @@ test.describe("Preferences user settings tab", () => { }, }); - test("should be rendered properly", async ({ app, user }) => { + test("should be rendered properly", async ({ app, page, user }) => { + page.setViewportSize({ width: 1024, height: 3300 }); const tab = await app.settings.openUserSettings("Preferences"); - // Assert that the top heading is rendered await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png"); @@ -53,4 +58,19 @@ test.describe("Preferences user settings tab", () => { // Assert that the default value is rendered again await expect(languageInput.getByText("English")).toBeVisible(); }); + + test("should be able to change the timezone", async ({ uut, user }) => { + // Check language and region setting dropdown + const timezoneInput = uut.locator(".mx_dropdownUserTimezone"); + const timezoneValue = uut.locator("#mx_dropdownUserTimezone_value"); + await timezoneInput.scrollIntoViewIfNeeded(); + // Check the default value + await expect(timezoneValue.getByText("Browser default")).toBeVisible(); + // Click the button to display the dropdown menu + await timezoneInput.getByRole("button", { name: "Set timezone" }).click(); + // Select a different value + timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click(); + // Check the new value + await expect(timezoneValue.getByText("Africa/Abidjan")).toBeVisible(); + }); }); 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 56745f9a19..9d47c993a4 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/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index f0e31285cb..aae26466ac 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -64,4 +64,8 @@ limitations under the License. gap: var(--cpd-space-6x); margin-top: 0; } + + .mx_SettingsSubsection_dropdown { + min-width: 360px; + } } diff --git a/src/DateUtils.ts b/src/DateUtils.ts index f955299682..ceabdb0236 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -19,6 +19,7 @@ limitations under the License. import { Optional } from "matrix-events-sdk"; import { _t, getUserLanguage } from "./languageHandler"; +import { getUserTimezone } from "./TimezoneHandler"; export const MINUTE_MS = 60000; export const HOUR_MS = MINUTE_MS * 60; @@ -77,6 +78,7 @@ export function formatDate(date: Date, showTwelveHour = false, locale?: string): weekday: "short", hour: "numeric", minute: "2-digit", + timeZone: getUserTimezone(), }).format(date); } else if (now.getFullYear() === date.getFullYear()) { return new Intl.DateTimeFormat(_locale, { @@ -86,6 +88,7 @@ export function formatDate(date: Date, showTwelveHour = false, locale?: string): day: "numeric", hour: "numeric", minute: "2-digit", + timeZone: getUserTimezone(), }).format(date); } return formatFullDate(date, showTwelveHour, false, _locale); @@ -104,6 +107,7 @@ export function formatFullDateNoTime(date: Date, locale?: string): string { month: "short", day: "numeric", year: "numeric", + timeZone: getUserTimezone(), }).format(date); } @@ -127,6 +131,7 @@ export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = hour: "numeric", minute: "2-digit", second: showSeconds ? "2-digit" : undefined, + timeZone: getUserTimezone(), }).format(date); } @@ -160,6 +165,7 @@ export function formatFullTime(date: Date, showTwelveHour = false, locale?: stri hour: "numeric", minute: "2-digit", second: "2-digit", + timeZone: getUserTimezone(), }).format(date); } @@ -178,6 +184,7 @@ export function formatTime(date: Date, showTwelveHour = false, locale?: string): ...getTwelveHourOptions(showTwelveHour), hour: "numeric", minute: "2-digit", + timeZone: getUserTimezone(), }).format(date); } @@ -285,6 +292,7 @@ export function formatFullDateNoDayNoTime(date: Date, locale?: string): string { year: "numeric", month: "numeric", day: "numeric", + timeZone: getUserTimezone(), }).format(date); } @@ -354,6 +362,9 @@ export function formatPreciseDuration(durationMs: number): string { * @returns {string} formattedDate */ export const formatLocalDateShort = (timestamp: number, locale?: string): string => - new Intl.DateTimeFormat(locale ?? getUserLanguage(), { day: "2-digit", month: "2-digit", year: "2-digit" }).format( - timestamp, - ); + new Intl.DateTimeFormat(locale ?? getUserLanguage(), { + day: "2-digit", + month: "2-digit", + year: "2-digit", + timeZone: getUserTimezone(), + }).format(timestamp); diff --git a/src/TimezoneHandler.ts b/src/TimezoneHandler.ts new file mode 100644 index 0000000000..49689e4c61 --- /dev/null +++ b/src/TimezoneHandler.ts @@ -0,0 +1,55 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { SettingLevel } from "./settings/SettingLevel"; +import SettingsStore from "./settings/SettingsStore"; + +export const USER_TIMEZONE_KEY = "userTimezone"; + +/** + * Returning `undefined` ensure that if unset the browser default will be used in `DateTimeFormat`. + * @returns The user specified timezone or `undefined` + */ +export function getUserTimezone(): string | undefined { + const tz = SettingsStore.getValueAt(SettingLevel.DEVICE, USER_TIMEZONE_KEY); + return tz || undefined; +} + +/** + * Set in the settings the given timezone + * @timezone + */ +export function setUserTimezone(timezone: string): Promise { + return SettingsStore.setValue(USER_TIMEZONE_KEY, null, SettingLevel.DEVICE, timezone); +} + +/** + * Return all the available timezones + */ +export function getAllTimezones(): string[] { + return Intl.supportedValuesOf("timeZone"); +} + +/** + * Return the current timezone in a short human readable way + */ +export function shortBrowserTimezone(): string { + return ( + new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }) + .formatToParts(new Date()) + .find((x) => x.type === "timeZoneName")?.value ?? "GMT" + ); +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 9c7469346d..51c0db082f 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -47,6 +47,7 @@ import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/Ro import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; +import * as TimezoneHandler from "../../TimezoneHandler"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import ResizeNotifier from "../../utils/ResizeNotifier"; import ContentMessages from "../../ContentMessages"; @@ -228,6 +229,7 @@ export interface IRoomState { lowBandwidth: boolean; alwaysShowTimestamps: boolean; showTwelveHourTimestamps: boolean; + userTimezone: string | undefined; readMarkerInViewThresholdMs: number; readMarkerOutOfViewThresholdMs: number; showHiddenEvents: boolean; @@ -455,6 +457,7 @@ export class RoomView extends React.Component { lowBandwidth: SettingsStore.getValue("lowBandwidth"), alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"), + userTimezone: TimezoneHandler.getUserTimezone(), readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), showHiddenEvents: SettingsStore.getValue("showHiddenEventsInTimeline"), @@ -512,6 +515,9 @@ export class RoomView extends React.Component { SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[, , , value]) => this.setState({ showTwelveHourTimestamps: value as boolean }), ), + SettingsStore.watchSetting(TimezoneHandler.USER_TIMEZONE_KEY, null, (...[, , , value]) => + this.setState({ userTimezone: value as string }), + ), SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[, , , value]) => this.setState({ readMarkerInViewThresholdMs: value as number }), ), diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 0e213de969..d74ea34662 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -15,12 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { ReactElement, useCallback, useEffect, useState } from "react"; +import { NonEmptyArray } from "../../../../../@types/common"; import { _t, getCurrentLanguage } from "../../../../../languageHandler"; import { UseCase } from "../../../../../settings/enums/UseCase"; import SettingsStore from "../../../../../settings/SettingsStore"; import Field from "../../../elements/Field"; +import Dropdown from "../../../elements/Dropdown"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import SettingsFlag from "../../../elements/SettingsFlag"; import AccessibleButton from "../../../elements/AccessibleButton"; @@ -38,12 +40,16 @@ import PlatformPeg from "../../../../../PlatformPeg"; import { IS_MAC } from "../../../../../Keyboard"; import SpellCheckSettings from "../../SpellCheckSettings"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +import * as TimezoneHandler from "../../../../../TimezoneHandler"; interface IProps { closeSettingsFn(success: boolean): void; } interface IState { + timezone: string | undefined; + timezones: string[]; + timezoneSearch: string | undefined; autocompleteDelay: string; readMarkerInViewThresholdMs: string; readMarkerOutOfViewThresholdMs: string; @@ -68,7 +74,7 @@ const LanguageSection: React.FC = () => { ); return ( -
+
{_t("settings|general|application_language")}
@@ -173,6 +179,9 @@ export default class PreferencesUserSettingsTab extends React.Component { + this.setState({ timezone: tz }); + TimezoneHandler.setUserTimezone(tz); + }; + + /** + * If present filter the time zones matching the search term + */ + private onTimezoneSearchChange = (search: string): void => { + const timezoneSearch = search.toLowerCase(); + const timezones = timezoneSearch + ? TimezoneHandler.getAllTimezones().filter((tz) => { + return tz.toLowerCase().includes(timezoneSearch); + }) + : TimezoneHandler.getAllTimezones(); + + this.setState({ timezones, timezoneSearch }); + }; + private onAutocompleteDelayChange = (e: React.ChangeEvent): void => { this.setState({ autocompleteDelay: e.target.value }); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); @@ -217,6 +245,16 @@ export default class PreferencesUserSettingsTab extends React.Component it !== "FTUE.userOnboardingButton" || showUserOnboardingPage(useCase)); + const browserTimezoneLabel: string = _t("settings|preferences|default_timezone", { + timezone: TimezoneHandler.shortBrowserTimezone(), + }); + + // Always Preprend the default option + const timezones = this.state.timezones.map((tz) => { + return
{tz}
; + }); + timezones.unshift(
{browserTimezoneLabel}
); + return ( @@ -254,6 +292,23 @@ export default class PreferencesUserSettingsTab extends React.Component +
+ {_t("settings|preferences|user_timezone")} + + {timezones as NonEmptyArray} + +
+ {this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)}
diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 52a61fe73a..a53448de9d 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -55,6 +55,7 @@ const RoomContext = createContext< lowBandwidth: false, alwaysShowTimestamps: false, showTwelveHourTimestamps: false, + userTimezone: undefined, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000, showHiddenEvents: false, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 352ab43762..e0b85cc4e1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2703,6 +2703,7 @@ "code_blocks_heading": "Code blocks", "compact_modern": "Use a more compact 'Modern' layout", "composer_heading": "Composer", + "default_timezone": "Browser default (%(timezone)s)", "dialog_title": "Settings: Preferences", "enable_hardware_acceleration": "Enable hardware acceleration", "enable_tray_icon": "Show tray icon and minimise window to it on close", @@ -2718,7 +2719,8 @@ "show_checklist_shortcuts": "Show shortcut to welcome checklist above the room list", "show_polls_button": "Show polls button", "surround_text": "Surround selected text when typing special characters", - "time_heading": "Displaying time" + "time_heading": "Displaying time", + "user_timezone": "Set timezone" }, "prompt_invite": "Prompt before sending invites to potentially invalid matrix IDs", "replace_plain_emoji": "Automatically replace plain text Emoji", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 476818a138..7cd58c9355 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -649,6 +649,11 @@ export const SETTINGS: { [setting: string]: ISetting } = { displayName: _td("settings|always_show_message_timestamps"), default: false, }, + "userTimezone": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + displayName: _td("settings|preferences|user_timezone"), + default: "", + }, "autoplayGifs": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("settings|autoplay_gifs"), diff --git a/test/TimezoneHandler-test.ts b/test/TimezoneHandler-test.ts new file mode 100644 index 0000000000..2ac2f9e21e --- /dev/null +++ b/test/TimezoneHandler-test.ts @@ -0,0 +1,31 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as tzh from "../src/TimezoneHandler"; + +describe("TimezoneHandler", () => { + it("should support setting a user timezone", async () => { + const tz = "Europe/Paris"; + await tzh.setUserTimezone(tz); + expect(tzh.getUserTimezone()).toEqual(tz); + }); + it("Return undefined with an empty TZ", async () => { + await tzh.setUserTimezone(""); + expect(tzh.getUserTimezone()).toEqual(undefined); + }); +}); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index cc6eb9ec87..a780650725 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -66,6 +66,7 @@ describe("", () => { lowBandwidth: false, alwaysShowTimestamps: false, showTwelveHourTimestamps: false, + userTimezone: undefined, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000, showHiddenEvents: false, diff --git a/test/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx index 4541720159..94272c93eb 100644 --- a/test/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx @@ -55,6 +55,32 @@ describe("PreferencesUserSettingsTab", () => { expect(reloadStub).toHaveBeenCalled(); }); + it("should search and select a user timezone", async () => { + renderTab(); + + expect(await screen.findByText(/Browser default/)).toBeInTheDocument(); + const timezoneDropdown = await screen.findByRole("button", { name: "Set timezone" }); + await userEvent.click(timezoneDropdown); + + // Without filtering `expect(screen.queryByRole("option" ...` take over 1s. + await fireEvent.change(screen.getByRole("combobox", { name: "Set timezone" }), { + target: { value: "Africa/Abidjan" }, + }); + + expect(screen.queryByRole("option", { name: "Africa/Abidjan" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Europe/Paris" })).not.toBeInTheDocument(); + + await fireEvent.change(screen.getByRole("combobox", { name: "Set timezone" }), { + target: { value: "Europe/Paris" }, + }); + + expect(screen.queryByRole("option", { name: "Africa/Abidjan" })).not.toBeInTheDocument(); + const option = await screen.getByRole("option", { name: "Europe/Paris" }); + await userEvent.click(option); + + expect(await screen.findByText("Europe/Paris")).toBeInTheDocument(); + }); + it("should not show spell check setting if unsupported", async () => { PlatformPeg.get()!.supportsSpellCheckSettings = jest.fn().mockReturnValue(false); 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 18ab66cadf..74b5375ebc 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 @@ -31,7 +31,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` class="mx_SettingsSubsection_content" >
Application language
+
+ Set timezone +
+ +
+
diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index 0a188c7cd5..1b17566c17 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -72,6 +72,7 @@ export function getRoomContext(room: Room, override: Partial): IRoom layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, + userTimezone: undefined, showTwelveHourTimestamps: false, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000,