Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit ae15bbe

Browse files
Allow user to set timezone (#12775)
* Allow user to set timezone * Update test snapshots --------- Co-authored-by: Florian Duros <[email protected]>
1 parent acc7342 commit ae15bbe

File tree

15 files changed

+256
-9
lines changed

15 files changed

+256
-9
lines changed

playwright/e2e/settings/preferences-user-settings-tab.spec.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ limitations under the License.
1717

1818
import { test, expect } from "../../element-web-test";
1919

20+
test.use({
21+
locale: "en-GB",
22+
timezoneId: "Europe/London",
23+
});
24+
2025
test.describe("Preferences user settings tab", () => {
2126
test.use({
2227
displayName: "Bob",
@@ -26,9 +31,9 @@ test.describe("Preferences user settings tab", () => {
2631
},
2732
});
2833

29-
test("should be rendered properly", async ({ app, user }) => {
34+
test("should be rendered properly", async ({ app, page, user }) => {
35+
page.setViewportSize({ width: 1024, height: 3300 });
3036
const tab = await app.settings.openUserSettings("Preferences");
31-
3237
// Assert that the top heading is rendered
3338
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
3439
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
@@ -53,4 +58,19 @@ test.describe("Preferences user settings tab", () => {
5358
// Assert that the default value is rendered again
5459
await expect(languageInput.getByText("English")).toBeVisible();
5560
});
61+
62+
test("should be able to change the timezone", async ({ uut, user }) => {
63+
// Check language and region setting dropdown
64+
const timezoneInput = uut.locator(".mx_dropdownUserTimezone");
65+
const timezoneValue = uut.locator("#mx_dropdownUserTimezone_value");
66+
await timezoneInput.scrollIntoViewIfNeeded();
67+
// Check the default value
68+
await expect(timezoneValue.getByText("Browser default")).toBeVisible();
69+
// Click the button to display the dropdown menu
70+
await timezoneInput.getByRole("button", { name: "Set timezone" }).click();
71+
// Select a different value
72+
timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click();
73+
// Check the new value
74+
await expect(timezoneValue.getByText("Africa/Abidjan")).toBeVisible();
75+
});
5676
});

res/css/components/views/settings/shared/_SettingsSubsection.pcss

+4
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,8 @@ limitations under the License.
6464
gap: var(--cpd-space-6x);
6565
margin-top: 0;
6666
}
67+
68+
.mx_SettingsSubsection_dropdown {
69+
min-width: 360px;
70+
}
6771
}

src/DateUtils.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ limitations under the License.
1919
import { Optional } from "matrix-events-sdk";
2020

2121
import { _t, getUserLanguage } from "./languageHandler";
22+
import { getUserTimezone } from "./TimezoneHandler";
2223

2324
export const MINUTE_MS = 60000;
2425
export const HOUR_MS = MINUTE_MS * 60;
@@ -77,6 +78,7 @@ export function formatDate(date: Date, showTwelveHour = false, locale?: string):
7778
weekday: "short",
7879
hour: "numeric",
7980
minute: "2-digit",
81+
timeZone: getUserTimezone(),
8082
}).format(date);
8183
} else if (now.getFullYear() === date.getFullYear()) {
8284
return new Intl.DateTimeFormat(_locale, {
@@ -86,6 +88,7 @@ export function formatDate(date: Date, showTwelveHour = false, locale?: string):
8688
day: "numeric",
8789
hour: "numeric",
8890
minute: "2-digit",
91+
timeZone: getUserTimezone(),
8992
}).format(date);
9093
}
9194
return formatFullDate(date, showTwelveHour, false, _locale);
@@ -104,6 +107,7 @@ export function formatFullDateNoTime(date: Date, locale?: string): string {
104107
month: "short",
105108
day: "numeric",
106109
year: "numeric",
110+
timeZone: getUserTimezone(),
107111
}).format(date);
108112
}
109113

@@ -127,6 +131,7 @@ export function formatFullDate(date: Date, showTwelveHour = false, showSeconds =
127131
hour: "numeric",
128132
minute: "2-digit",
129133
second: showSeconds ? "2-digit" : undefined,
134+
timeZone: getUserTimezone(),
130135
}).format(date);
131136
}
132137

@@ -160,6 +165,7 @@ export function formatFullTime(date: Date, showTwelveHour = false, locale?: stri
160165
hour: "numeric",
161166
minute: "2-digit",
162167
second: "2-digit",
168+
timeZone: getUserTimezone(),
163169
}).format(date);
164170
}
165171

@@ -178,6 +184,7 @@ export function formatTime(date: Date, showTwelveHour = false, locale?: string):
178184
...getTwelveHourOptions(showTwelveHour),
179185
hour: "numeric",
180186
minute: "2-digit",
187+
timeZone: getUserTimezone(),
181188
}).format(date);
182189
}
183190

@@ -285,6 +292,7 @@ export function formatFullDateNoDayNoTime(date: Date, locale?: string): string {
285292
year: "numeric",
286293
month: "numeric",
287294
day: "numeric",
295+
timeZone: getUserTimezone(),
288296
}).format(date);
289297
}
290298

@@ -354,6 +362,9 @@ export function formatPreciseDuration(durationMs: number): string {
354362
* @returns {string} formattedDate
355363
*/
356364
export const formatLocalDateShort = (timestamp: number, locale?: string): string =>
357-
new Intl.DateTimeFormat(locale ?? getUserLanguage(), { day: "2-digit", month: "2-digit", year: "2-digit" }).format(
358-
timestamp,
359-
);
365+
new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
366+
day: "2-digit",
367+
month: "2-digit",
368+
year: "2-digit",
369+
timeZone: getUserTimezone(),
370+
}).format(timestamp);

src/TimezoneHandler.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { SettingLevel } from "./settings/SettingLevel";
18+
import SettingsStore from "./settings/SettingsStore";
19+
20+
export const USER_TIMEZONE_KEY = "userTimezone";
21+
22+
/**
23+
* Returning `undefined` ensure that if unset the browser default will be used in `DateTimeFormat`.
24+
* @returns The user specified timezone or `undefined`
25+
*/
26+
export function getUserTimezone(): string | undefined {
27+
const tz = SettingsStore.getValueAt(SettingLevel.DEVICE, USER_TIMEZONE_KEY);
28+
return tz || undefined;
29+
}
30+
31+
/**
32+
* Set in the settings the given timezone
33+
* @timezone
34+
*/
35+
export function setUserTimezone(timezone: string): Promise<void> {
36+
return SettingsStore.setValue(USER_TIMEZONE_KEY, null, SettingLevel.DEVICE, timezone);
37+
}
38+
39+
/**
40+
* Return all the available timezones
41+
*/
42+
export function getAllTimezones(): string[] {
43+
return Intl.supportedValuesOf("timeZone");
44+
}
45+
46+
/**
47+
* Return the current timezone in a short human readable way
48+
*/
49+
export function shortBrowserTimezone(): string {
50+
return (
51+
new Intl.DateTimeFormat(undefined, { timeZoneName: "short" })
52+
.formatToParts(new Date())
53+
.find((x) => x.type === "timeZoneName")?.value ?? "GMT"
54+
);
55+
}

src/components/structures/RoomView.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/Ro
4747

4848
import shouldHideEvent from "../../shouldHideEvent";
4949
import { _t } from "../../languageHandler";
50+
import * as TimezoneHandler from "../../TimezoneHandler";
5051
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
5152
import ResizeNotifier from "../../utils/ResizeNotifier";
5253
import ContentMessages from "../../ContentMessages";
@@ -228,6 +229,7 @@ export interface IRoomState {
228229
lowBandwidth: boolean;
229230
alwaysShowTimestamps: boolean;
230231
showTwelveHourTimestamps: boolean;
232+
userTimezone: string | undefined;
231233
readMarkerInViewThresholdMs: number;
232234
readMarkerOutOfViewThresholdMs: number;
233235
showHiddenEvents: boolean;
@@ -455,6 +457,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
455457
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
456458
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
457459
showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"),
460+
userTimezone: TimezoneHandler.getUserTimezone(),
458461
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
459462
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
460463
showHiddenEvents: SettingsStore.getValue("showHiddenEventsInTimeline"),
@@ -512,6 +515,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
512515
SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[, , , value]) =>
513516
this.setState({ showTwelveHourTimestamps: value as boolean }),
514517
),
518+
SettingsStore.watchSetting(TimezoneHandler.USER_TIMEZONE_KEY, null, (...[, , , value]) =>
519+
this.setState({ userTimezone: value as string }),
520+
),
515521
SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[, , , value]) =>
516522
this.setState({ readMarkerInViewThresholdMs: value as number }),
517523
),

src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx

+57-2
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ See the License for the specific language governing permissions and
1515
limitations under the License.
1616
*/
1717

18-
import React, { useCallback, useEffect, useState } from "react";
18+
import React, { ReactElement, useCallback, useEffect, useState } from "react";
1919

20+
import { NonEmptyArray } from "../../../../../@types/common";
2021
import { _t, getCurrentLanguage } from "../../../../../languageHandler";
2122
import { UseCase } from "../../../../../settings/enums/UseCase";
2223
import SettingsStore from "../../../../../settings/SettingsStore";
2324
import Field from "../../../elements/Field";
25+
import Dropdown from "../../../elements/Dropdown";
2426
import { SettingLevel } from "../../../../../settings/SettingLevel";
2527
import SettingsFlag from "../../../elements/SettingsFlag";
2628
import AccessibleButton from "../../../elements/AccessibleButton";
@@ -38,12 +40,16 @@ import PlatformPeg from "../../../../../PlatformPeg";
3840
import { IS_MAC } from "../../../../../Keyboard";
3941
import SpellCheckSettings from "../../SpellCheckSettings";
4042
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
43+
import * as TimezoneHandler from "../../../../../TimezoneHandler";
4144

4245
interface IProps {
4346
closeSettingsFn(success: boolean): void;
4447
}
4548

4649
interface IState {
50+
timezone: string | undefined;
51+
timezones: string[];
52+
timezoneSearch: string | undefined;
4753
autocompleteDelay: string;
4854
readMarkerInViewThresholdMs: string;
4955
readMarkerOutOfViewThresholdMs: string;
@@ -68,7 +74,7 @@ const LanguageSection: React.FC = () => {
6874
);
6975

7076
return (
71-
<div className="mx_SettingsSubsection_contentStretch">
77+
<div className="mx_SettingsSubsection_dropdown">
7278
{_t("settings|general|application_language")}
7379
<LanguageDropdown onOptionChange={onLanguageChange} value={language} />
7480
<div className="mx_PreferencesUserSettingsTab_section_hint">
@@ -173,6 +179,9 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
173179
super(props);
174180

175181
this.state = {
182+
timezone: TimezoneHandler.getUserTimezone(),
183+
timezones: TimezoneHandler.getAllTimezones(),
184+
timezoneSearch: undefined,
176185
autocompleteDelay: SettingsStore.getValueAt(SettingLevel.DEVICE, "autocompleteDelay").toString(10),
177186
readMarkerInViewThresholdMs: SettingsStore.getValueAt(
178187
SettingLevel.DEVICE,
@@ -185,6 +194,25 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
185194
};
186195
}
187196

197+
private onTimezoneChange = (tz: string): void => {
198+
this.setState({ timezone: tz });
199+
TimezoneHandler.setUserTimezone(tz);
200+
};
201+
202+
/**
203+
* If present filter the time zones matching the search term
204+
*/
205+
private onTimezoneSearchChange = (search: string): void => {
206+
const timezoneSearch = search.toLowerCase();
207+
const timezones = timezoneSearch
208+
? TimezoneHandler.getAllTimezones().filter((tz) => {
209+
return tz.toLowerCase().includes(timezoneSearch);
210+
})
211+
: TimezoneHandler.getAllTimezones();
212+
213+
this.setState({ timezones, timezoneSearch });
214+
};
215+
188216
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
189217
this.setState({ autocompleteDelay: e.target.value });
190218
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
@@ -217,6 +245,16 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
217245
// Only show the user onboarding setting if the user should see the user onboarding page
218246
.filter((it) => it !== "FTUE.userOnboardingButton" || showUserOnboardingPage(useCase));
219247

248+
const browserTimezoneLabel: string = _t("settings|preferences|default_timezone", {
249+
timezone: TimezoneHandler.shortBrowserTimezone(),
250+
});
251+
252+
// Always Preprend the default option
253+
const timezones = this.state.timezones.map((tz) => {
254+
return <div key={tz}>{tz}</div>;
255+
});
256+
timezones.unshift(<div key="">{browserTimezoneLabel}</div>);
257+
220258
return (
221259
<SettingsTab data-testid="mx_PreferencesUserSettingsTab">
222260
<SettingsSection>
@@ -254,6 +292,23 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
254292
</SettingsSubsection>
255293

256294
<SettingsSubsection heading={_t("settings|preferences|time_heading")}>
295+
<div className="mx_SettingsSubsection_dropdown">
296+
{_t("settings|preferences|user_timezone")}
297+
<Dropdown
298+
id="mx_dropdownUserTimezone"
299+
className="mx_dropdownUserTimezone"
300+
data-testid="mx_dropdownUserTimezone"
301+
searchEnabled={true}
302+
value={this.state.timezone}
303+
label={_t("settings|preferences|user_timezone")}
304+
placeholder={browserTimezoneLabel}
305+
onOptionChange={this.onTimezoneChange}
306+
onSearchChange={this.onTimezoneSearchChange}
307+
>
308+
{timezones as NonEmptyArray<ReactElement & { key: string }>}
309+
</Dropdown>
310+
</div>
311+
257312
{this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)}
258313
</SettingsSubsection>
259314

src/contexts/RoomContext.ts

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const RoomContext = createContext<
5555
lowBandwidth: false,
5656
alwaysShowTimestamps: false,
5757
showTwelveHourTimestamps: false,
58+
userTimezone: undefined,
5859
readMarkerInViewThresholdMs: 3000,
5960
readMarkerOutOfViewThresholdMs: 30000,
6061
showHiddenEvents: false,

src/i18n/strings/en_EN.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -2703,6 +2703,7 @@
27032703
"code_blocks_heading": "Code blocks",
27042704
"compact_modern": "Use a more compact 'Modern' layout",
27052705
"composer_heading": "Composer",
2706+
"default_timezone": "Browser default (%(timezone)s)",
27062707
"dialog_title": "<strong>Settings:</strong> Preferences",
27072708
"enable_hardware_acceleration": "Enable hardware acceleration",
27082709
"enable_tray_icon": "Show tray icon and minimise window to it on close",
@@ -2718,7 +2719,8 @@
27182719
"show_checklist_shortcuts": "Show shortcut to welcome checklist above the room list",
27192720
"show_polls_button": "Show polls button",
27202721
"surround_text": "Surround selected text when typing special characters",
2721-
"time_heading": "Displaying time"
2722+
"time_heading": "Displaying time",
2723+
"user_timezone": "Set timezone"
27222724
},
27232725
"prompt_invite": "Prompt before sending invites to potentially invalid matrix IDs",
27242726
"replace_plain_emoji": "Automatically replace plain text Emoji",

src/settings/Settings.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,11 @@ export const SETTINGS: { [setting: string]: ISetting } = {
649649
displayName: _td("settings|always_show_message_timestamps"),
650650
default: false,
651651
},
652+
"userTimezone": {
653+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
654+
displayName: _td("settings|preferences|user_timezone"),
655+
default: "",
656+
},
652657
"autoplayGifs": {
653658
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
654659
displayName: _td("settings|autoplay_gifs"),

0 commit comments

Comments
 (0)