From c3901804c02322e6e18b54933130a48a2236325d Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 13:46:27 -0400 Subject: [PATCH] Add a chip and setting for user timezones --- src/app/components/user-profile/UserChips.tsx | 67 ++++++ src/app/components/user-profile/UserHero.tsx | 4 +- .../user-profile/UserRoomProfile.tsx | 15 +- src/app/features/settings/account/Profile.tsx | 218 +++++++++++++++++- 4 files changed, 289 insertions(+), 15 deletions(-) diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 53e6618b..d06750dd 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -19,6 +19,9 @@ import { Box, Scroll, Avatar, + TooltipProvider, + Tooltip, + Badge, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdServer } from '../../utils/matrix'; @@ -41,6 +44,7 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; +import { useInterval } from '../../hooks/useInterval'; export function ServerChip({ server }: { server: string }) { const mx = useMatrixClient(); @@ -512,3 +516,66 @@ export function OptionsChip({ userId }: { userId: string }) { ); } + +export function TimezoneChip({ timezone }: { timezone: string }) { + const shortFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: undefined, + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const longFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: 'long', + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const [shortTime, setShortTime] = useState(shortFormat.format()); + const [longTime, setLongTime] = useState(longFormat.format()); + + useInterval(() => { + setShortTime(shortFormat.format()); + setLongTime(longFormat.format()); + }, 1000); + + return ( + + + + Timezone: + + {timezone} + + + {longTime} + + + } + > + {(triggerRef) => ( + } + > + + {shortTime} + + + )} + + ); +} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index c042b3ec..54e40402 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -21,7 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; -import { extendedProfileFields } from '../../hooks/useExtendedProfile'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; type UserHeroProps = { userId: string; @@ -96,7 +96,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { type UserHeroNameProps = { displayName?: string; userId: string; - extendedProfile?: extendedProfileFields; + extendedProfile?: ExtendedProfile; }; export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 26b687eb..ce985e94 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,5 +1,5 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { UserHero, UserHeroName } from './UserHero'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; @@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; @@ -60,6 +60,16 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; + const timezone = useMemo(() => { + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[]; + const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz']; + if (profileTimezone && supportedTimezones.includes(profileTimezone)) { + return profileTimezone; + } + return undefined; + + }, [extendedProfile]); const presence = useUserPresence(userId); @@ -107,6 +117,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {creator ? : } {userId !== myUserId && } {userId !== myUserId && } + {timezone && } {ignored && } diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index d00ab076..fbff9f7a 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,4 +1,12 @@ -import React, { ChangeEventHandler, ReactNode, useCallback, useMemo, useState } from 'react'; +import React, { + ChangeEventHandler, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Box, Text, @@ -13,13 +21,17 @@ import { Modal, config, Spinner, + toRem, + Dialog, + Header, + MenuItem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { UserEvent } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; @@ -37,6 +49,9 @@ import { import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { FilterByValues } from '../../../../types/utils'; +import { CutoutCard } from '../../../components/cutout-card'; +import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips'; +import { SequenceCardStyle } from '../styles.css'; function ProfileAvatar() { const mx = useMatrixClient(); @@ -74,7 +89,13 @@ function ProfileAvatar() { }; return ( - + + Avatar + + } + > {uploadAtom ? ( (null); + const scrollRef = useRef(null); + const [overlayOpen, setOverlayOpen] = useState(false); + const [query, setQuery] = useState(''); + + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []); + const filteredTimezones = timezones.filter( + (timezone) => + query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase()) + ); + + const handleSelect = useCallback( + (timezone: string) => { + setOverlayOpen(false); + setValue(timezone); + }, + [setOverlayOpen, setValue] + ); + + useEffect(() => { + if (overlayOpen) { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`); + + if (value && focusedItem && scrollView) { + focusedItem.scrollIntoView({ + block: 'center', + }); + } + } + }, [scrollRef, value, overlayOpen]); + + return ( + + Timezone + + } + > + }> + + inputRef.current, + allowOutsideClick: true, + clickOutsideDeactivates: true, + onDeactivate: () => setOverlayOpen(false), + escapeDeactivates: (evt) => { + evt.stopPropagation(); + return true; + }, + }} + > + +
+ + Choose a Timezone + + setOverlayOpen(false)} radii="300"> + + +
+ + } + value={query} + onChange={(evt) => setQuery(evt.currentTarget.value)} + /> + + {filteredTimezones.length === 0 && ( + + + No Results + + + )} + {filteredTimezones.map((timezone) => ( + } + onClick={() => handleSelect(timezone)} + > + + + {timezone} + + + + ))} + + +
+
+
+
+ + + {value && ( + + )} + +
+ ); +} + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId() as string; + const server = getMxIdServer(userId); const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); const extendedProfile = @@ -234,10 +412,10 @@ export function Profile() { // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = mx.getUser(userId); if (user) { - user.displayName = fields.displayname; - user.avatarUrl = fields.avatar_url; - user.emit(UserEvent.DisplayName, user.events.presence, user); - user.emit(UserEvent.AvatarUrl, user.events.presence, user); + user.displayName = fields.displayname; + user.avatarUrl = fields.avatar_url; + user.emit(UserEvent.DisplayName, user.events.presence, user); + user.emit(UserEvent.AvatarUrl, user.events.presence, user); } }, [mx, userId, refreshExtendedProfile] @@ -252,7 +430,8 @@ export function Profile() { Profile {(save, reset, hasChanges, fields) => { const heroAvatarUrl = - (fields.avatar_url && - mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? + (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? undefined; return ( <> @@ -275,8 +453,26 @@ export function Profile() { extendedProfile={fields} /> + + {server && } + + {fields['us.cloke.msc4175.tz'] && ( + + )} + +
+ + - + ); }}