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

Commit eae9d9e

Browse files
Half-ShotRiotRobotgithub-merge-queue
authored
Add timezone to user profile (#20)
* [create-pull-request] automated change (#12966) Co-authored-by: github-merge-queue <[email protected]> * Add timezone to right panel profile. * Add setting to publish timezone * Add string for timezone publish * Automatically update timezone when setting changes. * Refactor to using a hook And automatically refresh the timezone every minute. * Check for feature support for extended profiles. * lint * Add timezone * Remove unintentional changes * Use browser default timezone. * lint * tweaks * Set timezone publish at the device level to prevent all devices writing to the timezone field. * Update hook to use external client. * Add test for user timezone. * Update snapshot for preferences tab. * Hide timezone info if not provided. * Stablize test * Fix date test types. * prettier * Add timezone tests * Add test for invalid timezone. * Update screenshot * Remove check for profile. --------- Co-authored-by: ElementRobot <[email protected]> Co-authored-by: github-merge-queue <[email protected]>
1 parent f317763 commit eae9d9e

File tree

11 files changed

+330
-60
lines changed

11 files changed

+330
-60
lines changed

res/css/views/right_panel/_UserInfo.pcss

+4
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ Please see LICENSE files in the repository root for full details.
124124
}
125125
}
126126

127+
.mx_UserInfo_timezone {
128+
margin: var(--cpd-space-1x) 0;
129+
}
130+
127131
.mx_PresenceLabel {
128132
font: var(--cpd-font-body-sm-regular);
129133
opacity: 1;

src/components/structures/LoggedInView.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class LoggedInView extends React.Component<IProps, IState> {
131131
protected layoutWatcherRef?: string;
132132
protected compactLayoutWatcherRef?: string;
133133
protected backgroundImageWatcherRef?: string;
134+
protected timezoneProfileUpdateRef?: string[];
134135
protected resizer?: Resizer<ICollapseConfig, CollapseItem>;
135136

136137
public constructor(props: IProps) {
@@ -182,6 +183,11 @@ class LoggedInView extends React.Component<IProps, IState> {
182183
this.refreshBackgroundImage,
183184
);
184185

186+
this.timezoneProfileUpdateRef = [
187+
SettingsStore.watchSetting("userTimezonePublish", null, this.onTimezoneUpdate),
188+
SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate),
189+
];
190+
185191
this.resizer = this.createResizer();
186192
this.resizer.attach();
187193

@@ -190,6 +196,31 @@ class LoggedInView extends React.Component<IProps, IState> {
190196
this.refreshBackgroundImage();
191197
}
192198

199+
private onTimezoneUpdate = async (): Promise<void> => {
200+
if (!SettingsStore.getValue("userTimezonePublish")) {
201+
// Ensure it's deleted
202+
try {
203+
await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz");
204+
} catch (ex) {
205+
console.warn("Failed to delete timezone from user profile", ex);
206+
}
207+
return;
208+
}
209+
const currentTimezone =
210+
SettingsStore.getValue("userTimezone") ||
211+
// If the timezone is empty, then use the browser timezone.
212+
// eslint-disable-next-line new-cap
213+
Intl.DateTimeFormat().resolvedOptions().timeZone;
214+
if (!currentTimezone || typeof currentTimezone !== "string") {
215+
return;
216+
}
217+
try {
218+
await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone);
219+
} catch (ex) {
220+
console.warn("Failed to update user profile with current timezone", ex);
221+
}
222+
};
223+
193224
public componentWillUnmount(): void {
194225
document.removeEventListener("keydown", this.onNativeKeyDown, false);
195226
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
@@ -200,6 +231,7 @@ class LoggedInView extends React.Component<IProps, IState> {
200231
if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef);
201232
if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
202233
if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
234+
this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s));
203235
this.resizer?.detach();
204236
}
205237

src/components/views/right_panel/UserInfo.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types";
2626
import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
2727
import { logger } from "matrix-js-sdk/src/logger";
2828
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
29-
import { Heading, MenuItem, Text } from "@vector-im/compound-web";
29+
import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
3030
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
3131
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
3232
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
@@ -85,7 +85,7 @@ import { SdkContextClass } from "../../../contexts/SDKContext";
8585
import { asyncSome } from "../../../utils/arrays";
8686
import { Flex } from "../../utils/Flex";
8787
import CopyableText from "../elements/CopyableText";
88-
88+
import { useUserTimezone } from "../../../hooks/useUserTimezone";
8989
export interface IDevice extends Device {
9090
ambiguous?: boolean;
9191
}
@@ -1694,6 +1694,8 @@ export const UserInfoHeader: React.FC<{
16941694
);
16951695
}
16961696

1697+
const timezoneInfo = useUserTimezone(cli, member.userId);
1698+
16971699
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
16981700
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
16991701
roomId,
@@ -1727,6 +1729,15 @@ export const UserInfoHeader: React.FC<{
17271729
</Flex>
17281730
</Heading>
17291731
{presenceLabel}
1732+
{timezoneInfo && (
1733+
<Tooltip label={timezoneInfo?.timezone ?? ""}>
1734+
<span className="mx_UserInfo_timezone">
1735+
<Text size="sm" weight="regular">
1736+
{timezoneInfo?.friendly ?? ""}
1737+
</Text>
1738+
</span>
1739+
</Tooltip>
1740+
)}
17301741
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
17311742
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
17321743
{userIdentifier}

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

+1
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
302302
</div>
303303

304304
{this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)}
305+
<SettingsFlag name="userTimezonePublish" level={SettingLevel.DEVICE} />
305306
</SettingsSubsection>
306307

307308
<SettingsSubsection

src/hooks/useUserTimezone.ts

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright 2024 New Vector Ltd
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+
import { useEffect, useState } from "react";
17+
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
18+
19+
/**
20+
* Fetch a user's delclared timezone through their profile, and return
21+
* a friendly string of the current time for that user. This will keep
22+
* in sync with the current time, and will be refreshed once a minute.
23+
*
24+
* @param cli The Matrix Client instance.
25+
* @param userId The userID to fetch the timezone for.
26+
* @returns A timezone name and friendly string for the user's timezone, or
27+
* null if the user has no timezone or the timezone was not recognised
28+
* by the browser.
29+
*/
30+
export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone: string; friendly: string } | null => {
31+
const [timezone, setTimezone] = useState<string>();
32+
const [updateInterval, setUpdateInterval] = useState<number>();
33+
const [friendly, setFriendly] = useState<string>();
34+
const [supported, setSupported] = useState<boolean>();
35+
36+
useEffect(() => {
37+
if (!cli || supported !== undefined) {
38+
return;
39+
}
40+
cli.doesServerSupportExtendedProfiles()
41+
.then(setSupported)
42+
.catch((ex) => {
43+
console.warn("Unable to determine if extended profiles are supported", ex);
44+
});
45+
}, [supported, cli]);
46+
47+
useEffect(() => {
48+
return () => {
49+
if (updateInterval) {
50+
clearInterval(updateInterval);
51+
}
52+
};
53+
}, [updateInterval]);
54+
55+
useEffect(() => {
56+
if (supported !== true) {
57+
return;
58+
}
59+
(async () => {
60+
console.log("Trying to fetch TZ");
61+
try {
62+
const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz");
63+
if (typeof tz !== "string") {
64+
// Err, definitely not a tz.
65+
throw Error("Timezone value was not a string");
66+
}
67+
// This will validate the timezone for us.
68+
// eslint-disable-next-line new-cap
69+
Intl.DateTimeFormat(undefined, { timeZone: tz });
70+
71+
const updateTime = (): void => {
72+
const currentTime = new Date();
73+
const friendly = currentTime.toLocaleString(undefined, {
74+
timeZone: tz,
75+
hour12: true,
76+
hour: "2-digit",
77+
minute: "2-digit",
78+
timeZoneName: "shortOffset",
79+
});
80+
setTimezone(tz);
81+
setFriendly(friendly);
82+
setUpdateInterval(setTimeout(updateTime, (60 - currentTime.getSeconds()) * 1000));
83+
};
84+
updateTime();
85+
} catch (ex) {
86+
setTimezone(undefined);
87+
setFriendly(undefined);
88+
setUpdateInterval(undefined);
89+
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
90+
// No timezone set, ignore.
91+
return;
92+
}
93+
console.error("Could not render current timezone for user", ex);
94+
}
95+
})();
96+
}, [supported, userId, cli]);
97+
98+
if (!timezone || !friendly) {
99+
return null;
100+
}
101+
102+
return {
103+
friendly,
104+
timezone,
105+
};
106+
};

src/i18n/strings/en_EN.json

+2
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,7 @@
14261426
"element_call_video_rooms": "Element Call video rooms",
14271427
"experimental_description": "Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. <a>Learn more</a>.",
14281428
"experimental_section": "Early previews",
1429+
"extended_profiles_msc_support": "Requires your server to support MSC4133",
14291430
"feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call",
14301431
"feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.",
14311432
"group_calls": "New group call experience",
@@ -2719,6 +2720,7 @@
27192720
"keyboard_view_shortcuts_button": "To view all keyboard shortcuts, <a>click here</a>.",
27202721
"media_heading": "Images, GIFs and videos",
27212722
"presence_description": "Share your activity and status with others.",
2723+
"publish_timezone": "Publish timezone on public profile",
27222724
"rm_lifetime": "Read Marker lifetime (ms)",
27232725
"rm_lifetime_offscreen": "Read Marker off-screen lifetime (ms)",
27242726
"room_directory_heading": "Room directory",

src/settings/Settings.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
88
*/
99

1010
import React, { ReactNode } from "react";
11+
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
1112

1213
import { _t, _td, TranslationKey } from "../languageHandler";
1314
import {
@@ -646,6 +647,19 @@ export const SETTINGS: { [setting: string]: ISetting } = {
646647
displayName: _td("settings|preferences|user_timezone"),
647648
default: "",
648649
},
650+
"userTimezonePublish": {
651+
// This is per-device so you can avoid having devices overwrite each other.
652+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
653+
displayName: _td("settings|preferences|publish_timezone"),
654+
default: false,
655+
controller: new ServerSupportUnstableFeatureController(
656+
"userTimezonePublish",
657+
defaultWatchManager,
658+
[[UNSTABLE_MSC4133_EXTENDED_PROFILES]],
659+
undefined,
660+
_td("labs|extended_profiles_msc_support"),
661+
),
662+
},
649663
"autoplayGifs": {
650664
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
651665
displayName: _td("settings|autoplay_gifs"),

test/components/structures/LoggedInView-test.tsx

+48
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import SettingsStore from "../../../src/settings/SettingsStore";
2424
import { SettingLevel } from "../../../src/settings/SettingLevel";
2525
import { Action } from "../../../src/dispatcher/actions";
2626
import Modal from "../../../src/Modal";
27+
import { SETTINGS } from "../../../src/settings/Settings";
2728

2829
describe("<LoggedInView />", () => {
2930
const userId = "@alice:domain.org";
@@ -37,6 +38,9 @@ describe("<LoggedInView />", () => {
3738
setPushRuleEnabled: jest.fn(),
3839
setPushRuleActions: jest.fn(),
3940
getCrypto: jest.fn().mockReturnValue(undefined),
41+
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
42+
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
43+
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
4044
});
4145
const mediaHandler = new MediaHandler(mockClient);
4246
const mockSdkContext = new TestSdkContext();
@@ -409,4 +413,48 @@ describe("<LoggedInView />", () => {
409413
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
410414
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
411415
});
416+
417+
describe("timezone updates", () => {
418+
const userTimezone = "Europe/London";
419+
const originalController = SETTINGS["userTimezonePublish"].controller;
420+
421+
beforeEach(async () => {
422+
SETTINGS["userTimezonePublish"].controller = undefined;
423+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
424+
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone);
425+
});
426+
427+
afterEach(() => {
428+
SETTINGS["userTimezonePublish"].controller = originalController;
429+
});
430+
431+
it("does not update the timezone when userTimezonePublish is off", async () => {
432+
getComponent();
433+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
434+
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
435+
expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled();
436+
});
437+
it("should set the user timezone when userTimezonePublish is enabled", async () => {
438+
getComponent();
439+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
440+
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
441+
});
442+
443+
it("should set the user timezone when the timezone is changed", async () => {
444+
const newTimezone = "Europe/Paris";
445+
getComponent();
446+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
447+
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
448+
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone);
449+
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone);
450+
});
451+
452+
it("should clear the timezone when the publish feature is turned off", async () => {
453+
getComponent();
454+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
455+
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
456+
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
457+
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
458+
});
459+
});
412460
});

test/components/views/right_panel/UserInfo-test.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ let mockRoom: Mocked<Room>;
9292
let mockSpace: Mocked<Room>;
9393
let mockClient: Mocked<MatrixClient>;
9494
let mockCrypto: Mocked<CryptoApi>;
95+
const origDate = global.Date.prototype.toLocaleString;
9596

9697
beforeEach(() => {
9798
mockRoom = mocked({
@@ -150,6 +151,8 @@ beforeEach(() => {
150151
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
151152
isRoomEncrypted: jest.fn().mockReturnValue(false),
152153
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
154+
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
155+
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
153156
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
154157
removeListener: jest.fn(),
155158
currentState: {
@@ -229,6 +232,28 @@ describe("<UserInfo />", () => {
229232
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
230233
});
231234

235+
it("renders user timezone if set", async () => {
236+
// For timezone, force a consistent locale.
237+
jest.spyOn(global.Date.prototype, "toLocaleString").mockImplementation(function (
238+
this: Date,
239+
_locale,
240+
opts,
241+
) {
242+
return origDate.call(this, "en-US", opts);
243+
});
244+
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
245+
mockClient.getExtendedProfileProperty.mockResolvedValue("Europe/London");
246+
renderComponent();
247+
await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument();
248+
});
249+
250+
it("does not renders user timezone if timezone is invalid", async () => {
251+
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
252+
mockClient.getExtendedProfileProperty.mockResolvedValue("invalid-tz");
253+
renderComponent();
254+
expect(screen.queryByText(/\d\d:\d\d (AM|PM)/)).not.toBeInTheDocument();
255+
});
256+
232257
it("renders encryption info panel without pending verification", () => {
233258
renderComponent({ phase: RightPanelPhases.EncryptionPanel });
234259
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();

0 commit comments

Comments
 (0)