Skip to content

Commit

Permalink
Add timezone to user profile (#20)
Browse files Browse the repository at this point in the history
* [create-pull-request] automated change (#12966)

Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com>

* 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 <releases@riot.im>
Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 12, 2024
1 parent f317763 commit eae9d9e
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 60 deletions.
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

0 comments on commit eae9d9e

Please sign in to comment.