Add timezone to user profile (#20)

* [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>
This commit is contained in:
Will Hunt
2024-09-12 14:18:25 +01:00
committed by GitHub
parent f31776378d
commit eae9d9e248
11 changed files with 426 additions and 156 deletions

View File

@@ -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) {
@@ -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();
@@ -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);
@@ -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();
}

View File

@@ -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";
@@ -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;
}
@@ -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,
@@ -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}

View File

@@ -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

View File

@@ -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,
};
};

View File

@@ -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",
@@ -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",

View File

@@ -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 {
@@ -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"),