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

Add timezone to user profile #20

Merged
merged 27 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
33c9c04
[create-pull-request] automated change (#12966)
RiotRobot Sep 6, 2024
05b1826
Add timezone to right panel profile.
Half-Shot Sep 6, 2024
c103109
Add setting to publish timezone
Half-Shot Sep 6, 2024
d9238c7
Add string for timezone publish
Half-Shot Sep 6, 2024
e78dfc2
Automatically update timezone when setting changes.
Half-Shot Sep 6, 2024
6989162
Refactor to using a hook
Half-Shot Sep 8, 2024
34d3176
Check for feature support for extended profiles.
Half-Shot Sep 8, 2024
8e39e06
lint
Half-Shot Sep 9, 2024
6fc5450
Add timezone
Half-Shot Sep 9, 2024
96d7fda
Remove unintentional changes
Half-Shot Sep 9, 2024
8ec6294
Use browser default timezone.
Half-Shot Sep 9, 2024
45285f2
lint
Half-Shot Sep 9, 2024
5ae6e2e
tweaks
Half-Shot Sep 9, 2024
b12e4cd
Set timezone publish at the device level to prevent all devices writi…
Half-Shot Sep 9, 2024
8c18fce
Update hook to use external client.
Half-Shot Sep 10, 2024
4e11e7d
Add test for user timezone.
Half-Shot Sep 10, 2024
b176ca8
Update snapshot for preferences tab.
Half-Shot Sep 10, 2024
2a14aee
Hide timezone info if not provided.
Half-Shot Sep 10, 2024
c43048d
Stablize test
Half-Shot Sep 10, 2024
5ffe3d4
Fix date test types.
Half-Shot Sep 10, 2024
ea26d0a
prettier
Half-Shot Sep 10, 2024
2703d55
Merge branch 'develop' into hs/timezone-publish-to-profile
Half-Shot Sep 10, 2024
b40eee7
Add timezone tests
Half-Shot Sep 10, 2024
f365143
Add test for invalid timezone.
Half-Shot Sep 10, 2024
256cd49
Update screenshot
Half-Shot Sep 12, 2024
ea3cad1
Remove check for profile.
Half-Shot Sep 12, 2024
c218b35
Merge branch 'develop' into hs/timezone-publish-to-profile
Half-Shot Sep 12, 2024
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions res/css/views/right_panel/_UserInfo.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ Please see LICENSE files in the repository root for full details.
}
}

.mx_UserInfo_timezone {
margin: var(--cpd-space-1x) 0;
}

.mx_PresenceLabel {
font: var(--cpd-font-body-sm-regular);
opacity: 1;
Expand Down
32 changes: 32 additions & 0 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class LoggedInView extends React.Component<IProps, IState> {
protected layoutWatcherRef?: string;
protected compactLayoutWatcherRef?: string;
protected backgroundImageWatcherRef?: string;
protected timezoneProfileUpdateRef?: string[];
protected resizer?: Resizer<ICollapseConfig, CollapseItem>;

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

this.timezoneProfileUpdateRef = [
SettingsStore.watchSetting("userTimezonePublish", null, this.onTimezoneUpdate),
SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate),
];

this.resizer = this.createResizer();
this.resizer.attach();

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

private onTimezoneUpdate = async (): Promise<void> => {
if (!SettingsStore.getValue("userTimezonePublish")) {
// Ensure it's deleted
try {
await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz");
} catch (ex) {
console.warn("Failed to delete timezone from user profile", ex);
}
return;
}
const currentTimezone =
SettingsStore.getValue("userTimezone") ||
// If the timezone is empty, then use the browser timezone.
// eslint-disable-next-line new-cap
Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!currentTimezone || typeof currentTimezone !== "string") {
return;
}
try {
await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone);
} catch (ex) {
console.warn("Failed to update user profile with current timezone", ex);
}
};

public componentWillUnmount(): void {
document.removeEventListener("keydown", this.onNativeKeyDown, false);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
Expand All @@ -200,6 +231,7 @@ class LoggedInView extends React.Component<IProps, IState> {
if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef);
if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s));
this.resizer?.detach();
}

Expand Down
15 changes: 13 additions & 2 deletions src/components/views/right_panel/UserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types";
import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { Heading, MenuItem, Text } from "@vector-im/compound-web";
import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
Expand Down Expand Up @@ -85,7 +85,7 @@ import { SdkContextClass } from "../../../contexts/SDKContext";
import { asyncSome } from "../../../utils/arrays";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";

import { useUserTimezone } from "../../../hooks/useUserTimezone";
export interface IDevice extends Device {
ambiguous?: boolean;
}
Expand Down Expand Up @@ -1694,6 +1694,8 @@ export const UserInfoHeader: React.FC<{
);
}

const timezoneInfo = useUserTimezone(cli, member.userId);

const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
Expand Down Expand Up @@ -1727,6 +1729,15 @@ export const UserInfoHeader: React.FC<{
</Flex>
</Heading>
{presenceLabel}
{timezoneInfo && (
<Tooltip label={timezoneInfo?.timezone ?? ""}>
<span className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
{timezoneInfo?.friendly ?? ""}
</Text>
</span>
</Tooltip>
)}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
{userIdentifier}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
</div>

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

<SettingsSubsection
Expand Down
106 changes: 106 additions & 0 deletions src/hooks/useUserTimezone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
Copyright 2024 New Vector Ltd

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 { useEffect, useState } from "react";
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";

/**
* Fetch a user's delclared timezone through their profile, and return
* a friendly string of the current time for that user. This will keep
* in sync with the current time, and will be refreshed once a minute.
*
* @param cli The Matrix Client instance.
* @param userId The userID to fetch the timezone for.
* @returns A timezone name and friendly string for the user's timezone, or
* null if the user has no timezone or the timezone was not recognised
* by the browser.
*/
export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone: string; friendly: string } | null => {
const [timezone, setTimezone] = useState<string>();
const [updateInterval, setUpdateInterval] = useState<number>();
const [friendly, setFriendly] = useState<string>();
const [supported, setSupported] = useState<boolean>();

useEffect(() => {
if (!cli || supported !== undefined) {
return;
}
cli.doesServerSupportExtendedProfiles()
.then(setSupported)
.catch((ex) => {
console.warn("Unable to determine if extended profiles are supported", ex);
});
}, [supported, cli]);

useEffect(() => {
return () => {
if (updateInterval) {
clearInterval(updateInterval);
}
};
}, [updateInterval]);

useEffect(() => {
if (supported !== true) {
return;
}
(async () => {
console.log("Trying to fetch TZ");
try {
const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz");
if (typeof tz !== "string") {
// Err, definitely not a tz.
throw Error("Timezone value was not a string");
}
// This will validate the timezone for us.
// eslint-disable-next-line new-cap
Intl.DateTimeFormat(undefined, { timeZone: tz });

const updateTime = (): void => {
const currentTime = new Date();
const friendly = currentTime.toLocaleString(undefined, {
timeZone: tz,
hour12: true,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "shortOffset",
});
setTimezone(tz);
setFriendly(friendly);
setUpdateInterval(setTimeout(updateTime, (60 - currentTime.getSeconds()) * 1000));
};
updateTime();
} catch (ex) {
setTimezone(undefined);
setFriendly(undefined);
setUpdateInterval(undefined);
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
// No timezone set, ignore.
return;
}
console.error("Could not render current timezone for user", ex);
}
})();
}, [supported, userId, cli]);

if (!timezone || !friendly) {
return null;
}

return {
friendly,
timezone,
};
};
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@
"element_call_video_rooms": "Element Call video rooms",
"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>.",
"experimental_section": "Early previews",
"extended_profiles_msc_support": "Requires your server to support MSC4133",
"feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call",
"feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.",
"group_calls": "New group call experience",
Expand Down Expand Up @@ -2719,6 +2720,7 @@
"keyboard_view_shortcuts_button": "To view all keyboard shortcuts, <a>click here</a>.",
"media_heading": "Images, GIFs and videos",
"presence_description": "Share your activity and status with others.",
"publish_timezone": "Publish timezone on public profile",
"rm_lifetime": "Read Marker lifetime (ms)",
"rm_lifetime_offscreen": "Read Marker off-screen lifetime (ms)",
"room_directory_heading": "Room directory",
Expand Down
14 changes: 14 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/

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

import { _t, _td, TranslationKey } from "../languageHandler";
import {
Expand Down Expand Up @@ -646,6 +647,19 @@ export const SETTINGS: { [setting: string]: ISetting } = {
displayName: _td("settings|preferences|user_timezone"),
default: "",
},
"userTimezonePublish": {
// This is per-device so you can avoid having devices overwrite each other.
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td("settings|preferences|publish_timezone"),
default: false,
controller: new ServerSupportUnstableFeatureController(
"userTimezonePublish",
defaultWatchManager,
[[UNSTABLE_MSC4133_EXTENDED_PROFILES]],
undefined,
_td("labs|extended_profiles_msc_support"),
),
},
"autoplayGifs": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|autoplay_gifs"),
Expand Down
48 changes: 48 additions & 0 deletions test/components/structures/LoggedInView-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Action } from "../../../src/dispatcher/actions";
import Modal from "../../../src/Modal";
import { SETTINGS } from "../../../src/settings/Settings";

describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
Expand All @@ -37,6 +38,9 @@ describe("<LoggedInView />", () => {
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSdkContext();
Expand Down Expand Up @@ -409,4 +413,48 @@ describe("<LoggedInView />", () => {
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
});

describe("timezone updates", () => {
const userTimezone = "Europe/London";
const originalController = SETTINGS["userTimezonePublish"].controller;

beforeEach(async () => {
SETTINGS["userTimezonePublish"].controller = undefined;
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone);
});

afterEach(() => {
SETTINGS["userTimezonePublish"].controller = originalController;
});

it("does not update the timezone when userTimezonePublish is off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled();
});
it("should set the user timezone when userTimezonePublish is enabled", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
});

it("should set the user timezone when the timezone is changed", async () => {
const newTimezone = "Europe/Paris";
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone);
});

it("should clear the timezone when the publish feature is turned off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
});
});
});
25 changes: 25 additions & 0 deletions test/components/views/right_panel/UserInfo-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
const origDate = global.Date.prototype.toLocaleString;

beforeEach(() => {
mockRoom = mocked({
Expand Down Expand Up @@ -150,6 +151,8 @@ beforeEach(() => {
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
Expand Down Expand Up @@ -229,6 +232,28 @@ describe("<UserInfo />", () => {
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
});

it("renders user timezone if set", async () => {
// For timezone, force a consistent locale.
jest.spyOn(global.Date.prototype, "toLocaleString").mockImplementation(function (
this: Date,
_locale,
opts,
) {
return origDate.call(this, "en-US", opts);
});
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
mockClient.getExtendedProfileProperty.mockResolvedValue("Europe/London");
renderComponent();
await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument();
});

it("does not renders user timezone if timezone is invalid", async () => {
mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true);
mockClient.getExtendedProfileProperty.mockResolvedValue("invalid-tz");
renderComponent();
expect(screen.queryByText(/\d\d:\d\d (AM|PM)/)).not.toBeInTheDocument();
});

it("renders encryption info panel without pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel });
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();
Expand Down
Loading
Loading