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

Allow user to set timezone #12775

Merged
merged 3 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Timshel marked this conversation as resolved.
Show resolved Hide resolved
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
Timshel marked this conversation as resolved.
Show resolved Hide resolved
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 }>}
Timshel marked this conversation as resolved.
Show resolved Hide resolved
</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
Loading