Allow user to set timezone (#12775)

* Allow user to set timezone

* Update test snapshots

---------

Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
Timshel 2024-09-02 11:07:07 +02:00 committed by GitHub
parent acc7342758
commit ae15bbe6e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 256 additions and 9 deletions

View file

@ -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();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 195 KiB

View file

@ -64,4 +64,8 @@ limitations under the License.
gap: var(--cpd-space-6x);
margin-top: 0;
}
.mx_SettingsSubsection_dropdown {
min-width: 360px;
}
}

View file

@ -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);

55
src/TimezoneHandler.ts Normal file
View file

@ -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<void> {
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"
);
}

View file

@ -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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
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 }),
),

View file

@ -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 (
<div className="mx_SettingsSubsection_contentStretch">
<div className="mx_SettingsSubsection_dropdown">
{_t("settings|general|application_language")}
<LanguageDropdown onOptionChange={onLanguageChange} value={language} />
<div className="mx_PreferencesUserSettingsTab_section_hint">
@ -173,6 +179,9 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
super(props);
this.state = {
timezone: TimezoneHandler.getUserTimezone(),
timezones: TimezoneHandler.getAllTimezones(),
timezoneSearch: undefined,
autocompleteDelay: SettingsStore.getValueAt(SettingLevel.DEVICE, "autocompleteDelay").toString(10),
readMarkerInViewThresholdMs: SettingsStore.getValueAt(
SettingLevel.DEVICE,
@ -185,6 +194,25 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
};
}
private onTimezoneChange = (tz: string): void => {
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<HTMLInputElement>): 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<IProps,
// Only show the user onboarding setting if the user should see the user onboarding page
.filter((it) => 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 <div key={tz}>{tz}</div>;
});
timezones.unshift(<div key="">{browserTimezoneLabel}</div>);
return (
<SettingsTab data-testid="mx_PreferencesUserSettingsTab">
<SettingsSection>
@ -254,6 +292,23 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|time_heading")}>
<div className="mx_SettingsSubsection_dropdown">
{_t("settings|preferences|user_timezone")}
<Dropdown
id="mx_dropdownUserTimezone"
className="mx_dropdownUserTimezone"
data-testid="mx_dropdownUserTimezone"
searchEnabled={true}
value={this.state.timezone}
label={_t("settings|preferences|user_timezone")}
placeholder={browserTimezoneLabel}
onOptionChange={this.onTimezoneChange}
onSearchChange={this.onTimezoneSearchChange}
>
{timezones as NonEmptyArray<ReactElement & { key: string }>}
</Dropdown>
</div>
{this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)}
</SettingsSubsection>

View file

@ -55,6 +55,7 @@ const RoomContext = createContext<
lowBandwidth: false,
alwaysShowTimestamps: false,
showTwelveHourTimestamps: false,
userTimezone: undefined,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEvents: false,

View file

@ -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": "<strong>Settings:</strong> 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",

View file

@ -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"),

View file

@ -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);
});
});

View file

@ -66,6 +66,7 @@ describe("<SendMessageComposer/>", () => {
lowBandwidth: false,
alwaysShowTimestamps: false,
showTwelveHourTimestamps: false,
userTimezone: undefined,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEvents: false,

View file

@ -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);

View file

@ -31,7 +31,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
class="mx_SettingsSubsection_content"
>
<div
class="mx_SettingsSubsection_contentStretch"
class="mx_SettingsSubsection_dropdown"
>
Application language
<div
@ -224,6 +224,35 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_SettingsSubsection_dropdown"
>
Set timezone
<div
class="mx_Dropdown mx_dropdownUserTimezone"
>
<div
aria-describedby="mx_dropdownUserTimezone_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Set timezone"
aria-owns="mx_dropdownUserTimezone_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_dropdownUserTimezone_value"
>
Browser default (UTC)
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
<div
class="mx_SettingsFlag"
>

View file

@ -72,6 +72,7 @@ export function getRoomContext(room: Room, override: Partial<IRoomState>): IRoom
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,
userTimezone: undefined,
showTwelveHourTimestamps: false,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,