Skip to content

Commit

Permalink
Allow user to set timezone (#12775)
Browse files Browse the repository at this point in the history
* Allow user to set timezone

* Update test snapshots

---------

Co-authored-by: Florian Duros <[email protected]>
  • Loading branch information
Timshel and florianduros authored Sep 2, 2024
1 parent acc7342 commit ae15bbe
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 9 deletions.
24 changes: 22 additions & 2 deletions playwright/e2e/settings/preferences-user-settings-tab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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");
Expand All @@ -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();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,8 @@ limitations under the License.
gap: var(--cpd-space-6x);
margin-top: 0;
}

.mx_SettingsSubsection_dropdown {
min-width: 360px;
}
}
17 changes: 14 additions & 3 deletions src/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand All @@ -104,6 +107,7 @@ export function formatFullDateNoTime(date: Date, locale?: string): string {
month: "short",
day: "numeric",
year: "numeric",
timeZone: getUserTimezone(),
}).format(date);
}

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

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

Expand All @@ -178,6 +184,7 @@ export function formatTime(date: Date, showTwelveHour = false, locale?: string):
...getTwelveHourOptions(showTwelveHour),
hour: "numeric",
minute: "2-digit",
timeZone: getUserTimezone(),
}).format(date);
}

Expand Down Expand Up @@ -285,6 +292,7 @@ export function formatFullDateNoDayNoTime(date: Date, locale?: string): string {
year: "numeric",
month: "numeric",
day: "numeric",
timeZone: getUserTimezone(),
}).format(date);
}

Expand Down Expand Up @@ -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 changes: 55 additions & 0 deletions src/TimezoneHandler.ts
Original file line number Diff line number Diff line change
@@ -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"
);
}
6 changes: 6 additions & 0 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -228,6 +229,7 @@ export interface IRoomState {
lowBandwidth: boolean;
alwaysShowTimestamps: boolean;
showTwelveHourTimestamps: boolean;
userTimezone: string | undefined;
readMarkerInViewThresholdMs: number;
readMarkerOutOfViewThresholdMs: number;
showHiddenEvents: boolean;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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 }),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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">
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>

Expand Down
1 change: 1 addition & 0 deletions src/contexts/RoomContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const RoomContext = createContext<
lowBandwidth: false,
alwaysShowTimestamps: false,
showTwelveHourTimestamps: false,
userTimezone: undefined,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEvents: false,
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading

0 comments on commit ae15bbe

Please sign in to comment.