From 3c1aa0e6996678e19c5016edc35e9946e0acb818 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 10:47:21 -0400 Subject: [PATCH 01/21] Rework profile settings to show a preview and support more fields --- package-lock.json | 12 +- package.json | 3 +- src/app/components/user-profile/UserHero.tsx | 8 +- .../user-profile/UserRoomProfile.tsx | 12 +- src/app/features/settings/account/Profile.tsx | 294 +++++++++--------- .../settings/account/ProfileFieldContext.tsx | 75 +++++ src/app/hooks/useExtendedProfile.ts | 103 ++++++ src/types/matrix/common.ts | 9 +- src/types/utils.ts | 5 + 9 files changed, 362 insertions(+), 159 deletions(-) create mode 100644 src/app/features/settings/account/ProfileFieldContext.tsx create mode 100644 src/app/hooks/useExtendedProfile.ts diff --git a/package-lock.json b/package-lock.json index 34a390f7..3a446325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,8 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", @@ -12119,6 +12120,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0c06993e..57986714 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb748..c042b3ec 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -21,6 +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'; type UserHeroProps = { userId: string; @@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { type UserHeroNameProps = { displayName?: string; userId: string; + extendedProfile?: extendedProfileFields; }; -export function UserHeroName({ displayName, userId }: UserHeroNameProps) { +export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); + const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"]; return ( @@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) { {displayName ?? username ?? userId} - + @{username} + {pronouns && ยท {pronouns.map(({ summary }) => summary).join(", ")}} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201ec..26b687eb 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 from 'react'; +import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { UserHero, UserHeroName } from './UserHero'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; @@ -22,6 +22,8 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; +import { useExtendedProfile } from '../../hooks/useExtendedProfile'; +import { AsyncStatus } from '../../hooks/useAsyncCallback'; type UserRoomProfileProps = { userId: string; @@ -56,9 +58,15 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); + const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; const presence = useUserPresence(userId); + useEffect(() => { + refreshExtendedProfile(); + }, [refreshExtendedProfile]); + const handleMessage = () => { closeUserRoomProfile(); const directSearchParam: DirectCreateSearchParams = { @@ -77,7 +85,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( {avatarUrl && ( )} @@ -162,116 +133,54 @@ function ProfileAvatar({ profile, userId }: ProfileProps) { )} - - }> - - setAlertRemove(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
); } -function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const capabilities = useCapabilities(); - const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; +type ProfileTextFieldProps = { + field: K; + label: ReactNode; +}; - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const [displayName, setDisplayName] = useState(defaultDisplayName); - - const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) - ); - const changingDisplayName = changeState.status === AsyncStatus.Loading; - - useEffect(() => { - setDisplayName(defaultDisplayName); - }, [defaultDisplayName]); +function ProfileTextField>({ + field, + label, +}: ProfileTextFieldProps) { + const { busy, defaultValue, value, setValue } = useProfileField(field); + const disabled = !useProfileFieldAllowed(field) || busy; + const hasChanges = defaultValue !== value; const handleChange: ChangeEventHandler = (evt) => { - const name = evt.currentTarget.value; - setDisplayName(name); + setValue(evt.currentTarget.value); }; const handleReset = () => { - setDisplayName(defaultDisplayName); + setValue(defaultValue); }; - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (changingDisplayName) return; - - const target = evt.target as HTMLFormElement | undefined; - const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; - const name = displayNameInput?.value; - if (!name) return; - - changeDisplayName(name); - }; - - const hasChanges = displayName !== defaultDisplayName; return ( - Display Name + {label} } > - + - @@ -305,20 +202,113 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { export function Profile() { const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const profile = useUserProfile(userId); + const userId = mx.getUserId() as string; + + const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); + const extendedProfile = + extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; + const fieldDefaults = useMemo( + () => + extendedProfile !== undefined + ? { + ...extendedProfile, + displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId, + } + : {}, + [userId, extendedProfile] + ); + + const useAuthentication = useMediaAuthentication(); + + const [saveState, handleSave] = useAsyncCallback( + useCallback( + async (fields: ExtendedProfile) => { + await Promise.all( + Object.entries(fields).map(async ([key, value]) => { + if (value !== undefined) { + await mx.setExtendedProfileProperty(key, value); + } + }) + ); + await refreshExtendedProfile(); + // 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); + } + }, + [mx, userId, refreshExtendedProfile] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + const loadingExtendedProfile = extendedProfileState.status === AsyncStatus.Loading; + const busy = saving || loadingExtendedProfile; return ( Profile - - + + {(save, reset, hasChanges, fields) => { + const heroAvatarUrl = + (fields.avatar_url && + mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? + undefined; + return ( + <> + + + + + + + + + + + + + + ); + }} + ); diff --git a/src/app/features/settings/account/ProfileFieldContext.tsx b/src/app/features/settings/account/ProfileFieldContext.tsx new file mode 100644 index 00000000..8c5f4e5f --- /dev/null +++ b/src/app/features/settings/account/ProfileFieldContext.tsx @@ -0,0 +1,75 @@ +import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { ExtendedProfile } from '../../../hooks/useExtendedProfile'; + +const ProfileFieldContext = createContext<{ + busy: boolean; + fieldDefaults: ExtendedProfile; + fields: ExtendedProfile; + setField: (key: string, value: unknown) => void; +} | null>(null); + +export type ProfileFieldContextProviderProps = { + fieldDefaults: ExtendedProfile; + save: (fields: ExtendedProfile) => void; + busy: boolean; + children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode; +}; + +export function ProfileFieldContextProvider({ + fieldDefaults, + save, + busy, + children, +}: ProfileFieldContextProviderProps) { + const [fields, setFields] = useState(fieldDefaults); + + const reset = useCallback(() => { + setFields(fieldDefaults); + }, [fieldDefaults]); + + useEffect(() => { + reset() + }, [reset]); + + const setField = useCallback( + (key: string, value: unknown) => { + setFields({ + ...fields, + [key]: value, + }); + }, + [fields] + ); + + const providerValue = useMemo( + () => ({ busy, fieldDefaults, fields, setField }), + [busy, fieldDefaults, fields, setField] + ); + + const hasChanges = useMemo( + () => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined, + [fields, fieldDefaults] + ); + + return ( + + {children(() => save(fields), reset, hasChanges, fields)} + + ); +} + +export function useProfileField(field: K): { busy: boolean, defaultValue: ExtendedProfile[K], value: ExtendedProfile[K], setValue: (value: ExtendedProfile[K]) => void } { + const context = useContext(ProfileFieldContext); + if (context === null) { + throw new Error("useProfileField() called without context"); + } + + return { + busy: context.busy, + defaultValue: context.fieldDefaults[field], + value: context.fields[field], + setValue(value) { + context.setField(field, value); + }, + }; +} \ No newline at end of file diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts new file mode 100644 index 00000000..22e11acb --- /dev/null +++ b/src/app/hooks/useExtendedProfile.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect } from 'react'; +import z from 'zod'; +import { AsyncCallback, AsyncState, useAsyncCallback } from './useAsyncCallback'; +import { useMatrixClient } from './useMatrixClient'; +import { useSpecVersions } from './useSpecVersions'; +import { useCapabilities } from './useCapabilities'; +import { IProfileFieldsCapability } from '../../types/matrix/common'; + +const extendedProfile = z.looseObject({ + displayname: z.string().optional(), + avatar_url: z.string().optional(), + 'io.fsky.nyx.pronouns': z + .object({ + language: z.string(), + summary: z.string(), + }) + .array() + .optional() + .catch(undefined), + 'us.cloke.msc4175.tz': z.string().optional().catch(undefined), +}); + +export type ExtendedProfile = z.infer; + +export function useExtendedProfileSupported(): boolean { + const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + + return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15'); +} + +export function useExtendedProfile( + userId: string +): [ + AsyncState, + AsyncCallback<[], ExtendedProfile | undefined> +] { + const mx = useMatrixClient(); + const extendedProfileSupported = useExtendedProfileSupported(); + const [extendedProfileData, refresh] = useAsyncCallback( + useCallback(async () => { + if (extendedProfileSupported) { + return extendedProfile.parse(await mx.getExtendedProfile(userId)); + } + return undefined; + }, [mx, userId, extendedProfileSupported]) + ); + + useEffect(() => { + refresh(); + }, [refresh]); + + return [extendedProfileData, refresh]; +} + +const LEGACY_FIELDS = ['displayname', 'avatar_url']; + +export function useProfileFieldAllowed(field: string): boolean { + const capabilities = useCapabilities(); + const extendedProfileSupported = useExtendedProfileSupported(); + + if (LEGACY_FIELDS.includes(field)) { + // this field might have a pre-msc4133 capability. check that first + if (capabilities[`m.set_${field}`]?.enabled === false) { + return false; + } + } + + if (extendedProfileSupported) { + // the homeserver has msc4133 support + const extendedProfileCapability = capabilities[ + 'uk.tcpip.msc4133.profile_fields' + ] as IProfileFieldsCapability; + + if (extendedProfileCapability === undefined) { + // the capability is missing, assume modification is allowed + return true; + } + + if (!extendedProfileCapability.enabled) { + // the capability is set to disable profile modifications + return false; + } + + if ( + extendedProfileCapability.allowed !== undefined && + !extendedProfileCapability.allowed.includes(field) + ) { + // the capability includes an allowlist and `field` isn't in it + return false; + } + + if (extendedProfileCapability.disallowed?.includes(field)) { + // the capability includes an blocklist and `field` is in it + return false; + } + + // the capability is enabled and `field` isn't blocked + return true; + } + + // `field` is an extended profile key and the homeserver lacks msc4133 support + return false; +} diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts index 210c711f..2764d6c3 100644 --- a/src/types/matrix/common.ts +++ b/src/types/matrix/common.ts @@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; -export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; +export const MATRIX_SPOILER_REASON_PROPERTY_NAME = + 'page.codeberg.everypizza.msc4193.spoiler.reason'; export type IImageInfo = { w?: number; @@ -88,3 +89,9 @@ export type ILocationContent = { geo_uri?: string; info?: IThumbnailContent; }; + +export type IProfileFieldsCapability = { + enabled?: boolean; + allowed?: string[]; + disallowed?: string[]; +}; diff --git a/src/types/utils.ts b/src/types/utils.ts index 353ace6e..84632c99 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,3 +1,8 @@ export type WithRequiredProp = Type & { [Property in Key]-?: Type[Property]; }; + +// Represents a subset of T containing only the keys whose values extend V +export type FilterByValues = { + [Property in keyof T as T[Property] extends V ? Property : never]: T[Property]; +}; From c3901804c02322e6e18b54933130a48a2236325d Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 13:46:27 -0400 Subject: [PATCH 02/21] 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'] && ( + + )} + +
+ + -
+ ); }} From 5c2c8984aad081ce34fa35562d269cc87fe6cbb3 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 14:03:08 -0400 Subject: [PATCH 03/21] Fix flickering issues when updating profile fields --- src/app/components/user-profile/UserChips.tsx | 13 ++++++--- src/app/features/settings/account/Profile.tsx | 29 ++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index d06750dd..a40c93cd 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -538,11 +538,16 @@ export function TimezoneChip({ timezone }: { timezone: string }) { ); const [shortTime, setShortTime] = useState(shortFormat.format()); const [longTime, setLongTime] = useState(longFormat.format()); - - useInterval(() => { + const updateTime = useCallback(() => { setShortTime(shortFormat.format()); setLongTime(longFormat.format()); - }, 1000); + }, [setShortTime, setLongTime, shortFormat, longFormat]); + + useEffect(() => { + updateTime(); + }, [timezone, updateTime]); + + useInterval(updateTime, 1000); return ( ( - () => - extendedProfile !== undefined - ? { - ...extendedProfile, - displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId, - } - : {}, - [userId, extendedProfile] - ); + + const [fieldDefaults, setFieldDefaults] = useState({}) + useLayoutEffect(() => { + if (extendedProfile !== undefined) { + setFieldDefaults( + { + ...extendedProfile, + displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId, + } + ); + } + }, [userId, setFieldDefaults, extendedProfile]); const useAuthentication = useMediaAuthentication(); @@ -473,7 +476,7 @@ export function Profile() { - + + {saving && } From c7f6e33a2b8b4f1ed10aaa5886b8ea8d3810ccb5 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 14:06:41 -0400 Subject: [PATCH 04/21] Propery delete blank profile fields --- src/app/features/settings/account/Profile.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 1bb0b2c7..2a380505 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -32,7 +32,7 @@ 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, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; +import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; @@ -173,7 +173,12 @@ function ProfileTextField = (evt) => { - setValue(evt.currentTarget.value); + const content = evt.currentTarget.value; + if (content.length > 0) { + setValue(evt.currentTarget.value); + } else { + setValue(undefined); + } }; const handleReset = () => { @@ -390,12 +395,7 @@ export function Profile() { const [fieldDefaults, setFieldDefaults] = useState({}) useLayoutEffect(() => { if (extendedProfile !== undefined) { - setFieldDefaults( - { - ...extendedProfile, - displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId, - } - ); + setFieldDefaults(extendedProfile); } }, [userId, setFieldDefaults, extendedProfile]); @@ -406,7 +406,9 @@ export function Profile() { async (fields: ExtendedProfile) => { await Promise.all( Object.entries(fields).map(async ([key, value]) => { - if (value !== undefined) { + if (value === undefined) { + await mx.deleteExtendedProfileProperty(key); + } else { await mx.setExtendedProfileProperty(key, value); } }) From c5b59ea12223a247e8b7d7fde7befa5789ae64c3 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 14:46:08 -0400 Subject: [PATCH 05/21] Add a setting for user pronouns --- src/app/features/settings/account/Profile.tsx | 144 +++++++++++++++++- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 2a380505..2a542964 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,5 +1,8 @@ import React, { ChangeEventHandler, + FormEventHandler, + KeyboardEventHandler, + MouseEventHandler, ReactNode, useCallback, useEffect, @@ -26,9 +29,14 @@ import { Dialog, Header, MenuItem, + Chip, + PopOut, + RectCords, + Menu, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { UserEvent } from 'matrix-js-sdk'; +import { isKeyHotkey } from 'is-hotkey'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -203,7 +211,6 @@ function ProfileTextField(); + const [pendingPronoun, setPendingPronoun] = useState(''); + + const handleRemovePronoun = (index: number) => { + const newPronouns = [...(value ?? [])]; + newPronouns.splice(index, 1); + if (newPronouns.length > 0) { + setValue(newPronouns); + } else { + setValue(undefined); + } + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + setMenuCords(undefined); + if (pendingPronoun.length > 0) { + setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setMenuCords(undefined); + } + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setPendingPronoun(''); + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + Pronouns + + } + > + + {value?.map(({ summary }, index) => ( + } + onClick={() => handleRemovePronoun(index)} + disabled={disabled} + > + + {summary} + + + ))} + } + onClick={handleOpenMenu} + > + Add + + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + setPendingPronoun(evt.currentTarget.value)} + onKeyDown={handleKeyDown} + /> + + + + + } + /> + + ); +} + function ProfileTimezone() { const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz'); const disabled = !useProfileFieldAllowed('us.cloke.msc4175.tz') || busy; @@ -392,7 +523,7 @@ export function Profile() { const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; - const [fieldDefaults, setFieldDefaults] = useState({}) + const [fieldDefaults, setFieldDefaults] = useState({}); useLayoutEffect(() => { if (extendedProfile !== undefined) { setFieldDefaults(extendedProfile); @@ -471,14 +602,15 @@ export function Profile() { variant="SurfaceVariant" direction="Column" gap="300" - radii='0' + radii="0" outlined - style={{ borderLeftWidth: "0", borderRightWidth: "0", borderBottomWidth: "0" }} + style={{ borderLeftWidth: '0', borderRightWidth: '0', borderBottomWidth: '0' }} > + - + {saving && } From d4deba60745668516d7a7312bd746bbf481fb1c7 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 14:50:29 -0400 Subject: [PATCH 06/21] Use a consistent fallback icon in settings for users with no avatar --- src/app/features/settings/Settings.tsx | 8 +++----- src/app/pages/client/sidebar/SettingsTab.tsx | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..fc8e1a7f 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { Account } from './account'; import { useUserProfile } from '../../hooks/useUserProfile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; -import { nameInitials } from '../../utils/common'; import { Notifications } from './notifications'; import { Devices } from './devices'; import { EmojisStickers } from './emojis-stickers'; @@ -99,9 +98,8 @@ type SettingsProps = { export function Settings({ initialPage, requestClose }: SettingsProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const userId = mx.getUserId() as string; const profile = useUserProfile(userId); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {nameInitials(displayName)}} + renderFallback={() => } /> diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index bb212184..df0515a2 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; -import { Text } from 'folds'; +import { Icon, Icons } from 'folds'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; -import { nameInitials } from '../../../utils/common'; +import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; @@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500'; export function SettingsTab() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const userId = mx.getUserId() as string; const profile = useUserProfile(userId); const [settings, setSettings] = useState(false); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -34,7 +32,7 @@ export function SettingsTab() { {nameInitials(displayName)}} + renderFallback={() => } /> )} From 07df0c2c7953b85247a82a21db970eefcace9451 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 16 Sep 2025 09:23:06 -0400 Subject: [PATCH 07/21] Use Tanstack Query when fetching extended profiles to improve caching --- .../user-profile/UserRoomProfile.tsx | 4 +-- src/app/features/settings/account/Profile.tsx | 6 ++-- src/app/hooks/useExtendedProfile.ts | 30 +++++++++---------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index ce985e94..6b9b24c6 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -23,7 +23,6 @@ import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; import { useExtendedProfile } from '../../hooks/useExtendedProfile'; -import { AsyncStatus } from '../../hooks/useAsyncCallback'; type UserRoomProfileProps = { userId: string; @@ -58,8 +57,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; - const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); - const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const timezone = useMemo(() => { // @ts-expect-error Intl.supportedValuesOf isn't in the types yet const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[]; diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 2a542964..1c866102 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -519,9 +519,7 @@ export function Profile() { const userId = mx.getUserId() as string; const server = getMxIdServer(userId); - const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId); - const extendedProfile = - extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined; + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const [fieldDefaults, setFieldDefaults] = useState({}); useLayoutEffect(() => { @@ -559,7 +557,7 @@ export function Profile() { ); const saving = saveState.status === AsyncStatus.Loading; - const loadingExtendedProfile = extendedProfileState.status === AsyncStatus.Loading; + const loadingExtendedProfile = extendedProfile === undefined; const busy = saving || loadingExtendedProfile; return ( diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index 22e11acb..00a8c1be 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import z from 'zod'; -import { AsyncCallback, AsyncState, useAsyncCallback } from './useAsyncCallback'; +import { useQuery } from '@tanstack/react-query'; import { useMatrixClient } from './useMatrixClient'; import { useSpecVersions } from './useSpecVersions'; import { useCapabilities } from './useCapabilities'; @@ -30,26 +30,26 @@ export function useExtendedProfileSupported(): boolean { export function useExtendedProfile( userId: string -): [ - AsyncState, - AsyncCallback<[], ExtendedProfile | undefined> -] { +): [ExtendedProfile | undefined, () => Promise] { const mx = useMatrixClient(); const extendedProfileSupported = useExtendedProfileSupported(); - const [extendedProfileData, refresh] = useAsyncCallback( - useCallback(async () => { + const { data, refetch } = useQuery({ + queryKey: ['extended-profile', userId], + queryFn: useCallback(async () => { if (extendedProfileSupported) { return extendedProfile.parse(await mx.getExtendedProfile(userId)); } return undefined; - }, [mx, userId, extendedProfileSupported]) - ); + }, [mx, userId, extendedProfileSupported]), + refetchOnMount: false, + }); - useEffect(() => { - refresh(); - }, [refresh]); - - return [extendedProfileData, refresh]; + return [ + data, + async () => { + await refetch(); + }, + ]; } const LEGACY_FIELDS = ['displayname', 'avatar_url']; From 984803c52c26eaa2e4421aa3bf73062ad799f19d Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 16 Sep 2025 09:36:07 -0400 Subject: [PATCH 08/21] Add slightly more padding above the profile save and cancel buttons --- src/app/features/settings/account/Profile.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 1c866102..331c463a 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -33,6 +33,7 @@ import { PopOut, RectCords, Menu, + Line, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { UserEvent } from 'matrix-js-sdk'; @@ -595,19 +596,20 @@ export function Profile() { )} + - - - - + + + + + + + } + /> + + ); +} + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId() as string; const server = getMxIdServer(userId); + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const extendedProfileSupported = extendedProfile !== null; + const legacyProfile = useUserProfile(userId); - const [fieldDefaults, setFieldDefaults] = useState({}); + const profileEditableThroughIDP = + authMetadata !== undefined && + authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); + const profileEditableThroughClient = useProfileEditsAllowed(null); + + const [fieldDefaults, setFieldDefaults] = useState({ + displayname: legacyProfile.displayName, + avatar_url: legacyProfile.avatarUrl, + }); useLayoutEffect(() => { - if (extendedProfile !== undefined) { + if (extendedProfile) { setFieldDefaults(extendedProfile); } - }, [userId, setFieldDefaults, extendedProfile]); + }, [setFieldDefaults, extendedProfile]); const useAuthentication = useMediaAuthentication(); const [saveState, handleSave] = useAsyncCallback( useCallback( async (fields: ExtendedProfile) => { - await Promise.all( - Object.entries(fields).map(async ([key, value]) => { - if (value === undefined) { - await mx.deleteExtendedProfileProperty(key); - } else { - await mx.setExtendedProfileProperty(key, value); - } - }) - ); - await refreshExtendedProfile(); - // 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); + if (extendedProfileSupported) { + await Promise.all( + Object.entries(fields).map(async ([key, value]) => { + if (value === undefined) { + await mx.deleteExtendedProfileProperty(key); + } else { + await mx.setExtendedProfileProperty(key, value); + } + }) + ); + await refreshExtendedProfile(); + // 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); + } + } else { + await mx.setDisplayName(fields.displayname ?? ''); + await mx.setAvatarUrl(fields.avatar_url ?? ''); + setFieldDefaults(fields); } }, - [mx, userId, refreshExtendedProfile] + [mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults] ) ); @@ -604,39 +663,66 @@ export function Profile() { gap="400" radii="0" > - - - - - - - - - - {saving && } - + {profileEditableThroughIDP && ( + + )} + {profileEditableThroughClient && ( + <> + + + + {extendedProfileSupported && ( + <> + + + + )} + + + + + {saving && } + + + )} + {!(profileEditableThroughClient || profileEditableThroughIDP) && ( + + + + + Profile Editing Disabled + + + + Your homeserver does not allow you to edit your profile. + + + + + + )} ); diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index 00a8c1be..50de4f88 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -30,7 +30,7 @@ export function useExtendedProfileSupported(): boolean { export function useExtendedProfile( userId: string -): [ExtendedProfile | undefined, () => Promise] { +): [ExtendedProfile | undefined | null, () => Promise] { const mx = useMatrixClient(); const extendedProfileSupported = useExtendedProfileSupported(); const { data, refetch } = useQuery({ @@ -39,7 +39,7 @@ export function useExtendedProfile( if (extendedProfileSupported) { return extendedProfile.parse(await mx.getExtendedProfile(userId)); } - return undefined; + return null; }, [mx, userId, extendedProfileSupported]), refetchOnMount: false, }); @@ -54,15 +54,20 @@ export function useExtendedProfile( const LEGACY_FIELDS = ['displayname', 'avatar_url']; -export function useProfileFieldAllowed(field: string): boolean { +export function useProfileEditsAllowed(field: string | null): boolean { const capabilities = useCapabilities(); const extendedProfileSupported = useExtendedProfileSupported(); - if (LEGACY_FIELDS.includes(field)) { + if (field && LEGACY_FIELDS.includes(field)) { // this field might have a pre-msc4133 capability. check that first if (capabilities[`m.set_${field}`]?.enabled === false) { return false; } + + if (!extendedProfileSupported) { + // the homeserver only supports legacy fields + return true; + } } if (extendedProfileSupported) { @@ -81,6 +86,11 @@ export function useProfileFieldAllowed(field: string): boolean { return false; } + if (field === null) { + // profile field modifications are not completely disabled + return true; + } + if ( extendedProfileCapability.allowed !== undefined && !extendedProfileCapability.allowed.includes(field) @@ -98,6 +108,11 @@ export function useProfileFieldAllowed(field: string): boolean { return true; } + if (field === null) { + // the homeserver only supports legacy fields. assume profile editing is generally allowed + return true; + } + // `field` is an extended profile key and the homeserver lacks msc4133 support return false; } From 317cd366c383047015e005b8f8efb5b24fa46d8e Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 18 Sep 2025 12:29:51 -0400 Subject: [PATCH 10/21] Hide profile fields which are blocked by a capability --- src/app/features/settings/account/Profile.tsx | 96 ++++++++++++------- .../settings/account/ProfileFieldContext.tsx | 80 ++++++++-------- src/app/hooks/useExtendedProfile.ts | 23 ++--- 3 files changed, 108 insertions(+), 91 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 618be81f..d90b8e4b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -3,7 +3,6 @@ import React, { FormEventHandler, KeyboardEventHandler, MouseEventHandler, - ReactNode, useCallback, useEffect, useLayoutEffect, @@ -53,10 +52,10 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; import { ExtendedProfile, + profileEditsAllowed, useExtendedProfile, - useProfileEditsAllowed, } from '../../../hooks/useExtendedProfile'; -import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext'; +import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { FilterByValues } from '../../../../types/utils'; import { CutoutCard } from '../../../components/cutout-card'; @@ -66,15 +65,21 @@ import { useUserProfile } from '../../../hooks/useUserProfile'; import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; import { withSearchParam } from '../../../pages/pathUtils'; +import { useCapabilities } from '../../../hooks/useCapabilities'; -function ProfileAvatar() { +type FieldContext = { busy: boolean }; + +function ProfileAvatar({ + busy, + value, + setValue, +}: ProfileFieldElementProps<'avatar_url', FieldContext>) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const { busy, value, setValue } = useProfileField('avatar_url'); const avatarUrl = value ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const disabled = !useProfileEditsAllowed('avatar_url') || busy; + const disabled = busy; const [imageFile, setImageFile] = useState(); const imageFileURL = useObjectURL(imageFile); @@ -172,17 +177,14 @@ function ProfileAvatar() { ); } -type ProfileTextFieldProps = { - field: K; - label: ReactNode; -}; - function ProfileTextField>({ - field, label, -}: ProfileTextFieldProps) { - const { busy, defaultValue, value, setValue } = useProfileField(field); - const disabled = !useProfileEditsAllowed(field) || busy; + defaultValue, + value, + setValue, + busy, +}: ProfileFieldElementProps & { label: string }) { + const disabled = busy; const hasChanges = defaultValue !== value; const handleChange: ChangeEventHandler = (evt) => { @@ -240,9 +242,12 @@ function ProfileTextField) { + const disabled = busy; const [menuCords, setMenuCords] = useState(); const [pendingPronoun, setPendingPronoun] = useState(''); @@ -364,9 +369,12 @@ function ProfilePronouns() { ); } -function ProfileTimezone() { - const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz'); - const disabled = !useProfileEditsAllowed('us.cloke.msc4175.tz') || busy; +function ProfileTimezone({ + value, + setValue, + busy, +}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { + const disabled = busy; const inputRef = useRef(null); const scrollRef = useRef(null); @@ -556,12 +564,26 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut ); } +const LEGACY_FIELD_ELEMENTS = { + avatar_url: ProfileAvatar, + displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( + + ), +}; + +const EXTENDED_FIELD_ELEMENTS = { + 'io.fsky.nyx.pronouns': ProfilePronouns, + 'us.cloke.msc4175.tz': ProfileTimezone, +}; + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId() as string; const server = getMxIdServer(userId); const authMetadata = useAuthMetadata(); const accountManagementActions = useAccountManagementActions(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const extendedProfileSupported = extendedProfile !== null; @@ -570,8 +592,15 @@ export function Profile() { const profileEditableThroughIDP = authMetadata !== undefined && authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); - const profileEditableThroughClient = useProfileEditsAllowed(null); + const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { + const entries = Object.entries({ + ...LEGACY_FIELD_ELEMENTS, + ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), + }).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported)); + return [Object.fromEntries(entries), entries.length > 0]; + }, [capabilities, extendedProfileSupported]); + const [fieldDefaults, setFieldDefaults] = useState({ displayname: legacyProfile.displayName, avatar_url: legacyProfile.avatarUrl, @@ -582,8 +611,6 @@ export function Profile() { } }, [setFieldDefaults, extendedProfile]); - const useAuthentication = useMediaAuthentication(); - const [saveState, handleSave] = useAsyncCallback( useCallback( async (fields: ExtendedProfile) => { @@ -631,8 +658,12 @@ export function Profile() { overflow: 'hidden', }} > - - {(save, reset, hasChanges, fields) => { + + {(reset, hasChanges, fields, fieldElements) => { const heroAvatarUrl = (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? undefined; @@ -669,14 +700,7 @@ export function Profile() { {profileEditableThroughClient && ( <> - - - {extendedProfileSupported && ( - <> - - - - )} + {fieldElements} @@ -727,7 +751,7 @@ export function Profile() { ); }} - + ); diff --git a/src/app/features/settings/account/ProfileFieldContext.tsx b/src/app/features/settings/account/ProfileFieldContext.tsx index 8c5f4e5f..31241052 100644 --- a/src/app/features/settings/account/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/ProfileFieldContext.tsx @@ -1,26 +1,35 @@ -import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { ExtendedProfile } from '../../../hooks/useExtendedProfile'; -const ProfileFieldContext = createContext<{ - busy: boolean; - fieldDefaults: ExtendedProfile; - fields: ExtendedProfile; - setField: (key: string, value: unknown) => void; -} | null>(null); +type ExtendedProfileKeys = keyof { + [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property] +} -export type ProfileFieldContextProviderProps = { +type ProfileFieldElementRawProps = { + defaultValue: V, + value: V, + setValue: (value: V) => void, +} & C + +export type ProfileFieldElementProps = ProfileFieldElementRawProps; + +type ProfileFieldElements = { + [Property in ExtendedProfileKeys]?: FunctionComponent>; +} + +type ProfileFieldContextProps = { fieldDefaults: ExtendedProfile; - save: (fields: ExtendedProfile) => void; - busy: boolean; - children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode; + fieldElements: ProfileFieldElements; + children: (reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode) => ReactNode; + context: C; }; -export function ProfileFieldContextProvider({ +export function ProfileFieldContext({ fieldDefaults, - save, - busy, + fieldElements: fieldElementConstructors, children, -}: ProfileFieldContextProviderProps) { + context +}: ProfileFieldContextProps): ReactNode { const [fields, setFields] = useState(fieldDefaults); const reset = useCallback(() => { @@ -41,35 +50,28 @@ export function ProfileFieldContextProvider({ [fields] ); - const providerValue = useMemo( - () => ({ busy, fieldDefaults, fields, setField }), - [busy, fieldDefaults, fields, setField] - ); - const hasChanges = useMemo( () => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined, [fields, fieldDefaults] ); - return ( - - {children(() => save(fields), reset, hasChanges, fields)} - + const createElement = useCallback((key: K, element: ProfileFieldElements[K]) => { + const props: ProfileFieldElementRawProps = { + ...context, + defaultValue: fieldDefaults[key], + value: fields[key], + setValue: (value) => setField(key, value), + }; + if (element !== undefined) { + return React.createElement(element, props); + } + return undefined; + }, [context, fieldDefaults, fields, setField]); + + const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) => + // @ts-expect-error TypeScript doesn't quite understand the magic going on here + createElement(key, element) ); -} -export function useProfileField(field: K): { busy: boolean, defaultValue: ExtendedProfile[K], value: ExtendedProfile[K], setValue: (value: ExtendedProfile[K]) => void } { - const context = useContext(ProfileFieldContext); - if (context === null) { - throw new Error("useProfileField() called without context"); - } - - return { - busy: context.busy, - defaultValue: context.fieldDefaults[field], - value: context.fields[field], - setValue(value) { - context.setField(field, value); - }, - }; + return children(reset, hasChanges, fields, fieldElements); } \ No newline at end of file diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index 50de4f88..a3608f0c 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react'; import z from 'zod'; import { useQuery } from '@tanstack/react-query'; +import { Capabilities } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useSpecVersions } from './useSpecVersions'; -import { useCapabilities } from './useCapabilities'; import { IProfileFieldsCapability } from '../../types/matrix/common'; const extendedProfile = z.looseObject({ @@ -54,11 +54,12 @@ export function useExtendedProfile( const LEGACY_FIELDS = ['displayname', 'avatar_url']; -export function useProfileEditsAllowed(field: string | null): boolean { - const capabilities = useCapabilities(); - const extendedProfileSupported = useExtendedProfileSupported(); - - if (field && LEGACY_FIELDS.includes(field)) { +export function profileEditsAllowed( + field: string, + capabilities: Capabilities, + extendedProfileSupported: boolean +): boolean { + if (LEGACY_FIELDS.includes(field)) { // this field might have a pre-msc4133 capability. check that first if (capabilities[`m.set_${field}`]?.enabled === false) { return false; @@ -86,11 +87,6 @@ export function useProfileEditsAllowed(field: string | null): boolean { return false; } - if (field === null) { - // profile field modifications are not completely disabled - return true; - } - if ( extendedProfileCapability.allowed !== undefined && !extendedProfileCapability.allowed.includes(field) @@ -108,11 +104,6 @@ export function useProfileEditsAllowed(field: string | null): boolean { return true; } - if (field === null) { - // the homeserver only supports legacy fields. assume profile editing is generally allowed - return true; - } - // `field` is an extended profile key and the homeserver lacks msc4133 support return false; } From 8a8443bda418c612b5f8a972e41f86c838400dfe Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 18 Sep 2025 12:34:34 -0400 Subject: [PATCH 11/21] Move profile field elements into their own files --- src/app/features/settings/account/Profile.tsx | 501 +----------------- .../settings/account/fields/ProfileAvatar.tsx | 118 +++++ .../{ => fields}/ProfileFieldContext.tsx | 3 +- .../account/fields/ProfilePronouns.tsx | 125 +++++ .../account/fields/ProfileTextField.tsx | 63 +++ .../account/fields/ProfileTimezone.tsx | 160 ++++++ 6 files changed, 475 insertions(+), 495 deletions(-) create mode 100644 src/app/features/settings/account/fields/ProfileAvatar.tsx rename src/app/features/settings/account/{ => fields}/ProfileFieldContext.tsx (96%) create mode 100644 src/app/features/settings/account/fields/ProfilePronouns.tsx create mode 100644 src/app/features/settings/account/fields/ProfileTextField.tsx create mode 100644 src/app/features/settings/account/fields/ProfileTimezone.tsx diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index d90b8e4b..396e8e8f 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,63 +1,31 @@ import React, { - ChangeEventHandler, - FormEventHandler, - KeyboardEventHandler, - MouseEventHandler, useCallback, - useEffect, useLayoutEffect, useMemo, - useRef, useState, } from 'react'; import { Box, Text, - IconButton, - Icon, - Icons, - Input, Button, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, config, Spinner, - toRem, - Dialog, - Header, - MenuItem, - Chip, - PopOut, - RectCords, - Menu, Line, } from 'folds'; -import FocusTrap from 'focus-trap-react'; import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk'; -import { isKeyHotkey } from 'is-hotkey'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { useFilePicker } from '../../../hooks/useFilePicker'; -import { useObjectURL } from '../../../hooks/useObjectURL'; -import { stopPropagation } from '../../../utils/keyboard'; -import { ImageEditor } from '../../../components/image-editor'; -import { ModalWide } from '../../../styles/Modal.css'; -import { createUploadAtom, UploadSuccess } from '../../../state/upload'; -import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; import { ExtendedProfile, profileEditsAllowed, useExtendedProfile, } from '../../../hooks/useExtendedProfile'; -import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext'; +import { ProfileFieldContext, ProfileFieldElementProps } from './fields/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'; @@ -66,467 +34,12 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; import { withSearchParam } from '../../../pages/pathUtils'; import { useCapabilities } from '../../../hooks/useCapabilities'; +import { ProfileAvatar } from './fields/ProfileAvatar'; +import { ProfileTextField } from './fields/ProfileTextField'; +import { ProfilePronouns } from './fields/ProfilePronouns'; +import { ProfileTimezone } from './fields/ProfileTimezone'; -type FieldContext = { busy: boolean }; - -function ProfileAvatar({ - busy, - value, - setValue, -}: ProfileFieldElementProps<'avatar_url', FieldContext>) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const avatarUrl = value - ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; - const disabled = busy; - - const [imageFile, setImageFile] = useState(); - const imageFileURL = useObjectURL(imageFile); - const uploadAtom = useMemo(() => { - if (imageFile) return createUploadAtom(imageFile); - return undefined; - }, [imageFile]); - - const pickFile = useFilePicker(setImageFile, false); - - const handleRemoveUpload = useCallback(() => { - setImageFile(undefined); - }, []); - - const handleUploaded = useCallback( - (upload: UploadSuccess) => { - const { mxc } = upload; - setValue(mxc); - handleRemoveUpload(); - }, - [setValue, handleRemoveUpload] - ); - - const handleRemoveAvatar = () => { - setValue(''); - }; - - return ( - - Avatar - - } - > - {uploadAtom ? ( - - - - ) : ( - - - {avatarUrl && ( - - )} - - )} - - {imageFileURL && ( - }> - - - - - - - - - )} - - ); -} - -function ProfileTextField>({ - label, - defaultValue, - value, - setValue, - busy, -}: ProfileFieldElementProps & { label: string }) { - const disabled = busy; - const hasChanges = defaultValue !== value; - - const handleChange: ChangeEventHandler = (evt) => { - const content = evt.currentTarget.value; - if (content.length > 0) { - setValue(evt.currentTarget.value); - } else { - setValue(undefined); - } - }; - - const handleReset = () => { - setValue(defaultValue); - }; - - return ( - - {label} - - } - > - - - - - - - ) - } - /> - - - - - ); -} - -function ProfilePronouns({ - value, - setValue, - busy, -}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) { - const disabled = busy; - - const [menuCords, setMenuCords] = useState(); - const [pendingPronoun, setPendingPronoun] = useState(''); - - const handleRemovePronoun = (index: number) => { - const newPronouns = [...(value ?? [])]; - newPronouns.splice(index, 1); - if (newPronouns.length > 0) { - setValue(newPronouns); - } else { - setValue(undefined); - } - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - setMenuCords(undefined); - if (pendingPronoun.length > 0) { - setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]); - } - }; - - const handleKeyDown: KeyboardEventHandler = (evt) => { - if (isKeyHotkey('escape', evt)) { - evt.stopPropagation(); - setMenuCords(undefined); - } - }; - - const handleOpenMenu: MouseEventHandler = (evt) => { - setPendingPronoun(''); - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - return ( - - Pronouns - - } - > - - {value?.map(({ summary }, index) => ( - } - onClick={() => handleRemovePronoun(index)} - disabled={disabled} - > - - {summary} - - - ))} - } - onClick={handleOpenMenu} - > - Add - - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - setPendingPronoun(evt.currentTarget.value)} - onKeyDown={handleKeyDown} - /> - - - - - } - /> - - ); -} - -function ProfileTimezone({ - value, - setValue, - busy, -}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { - const disabled = busy; - - const inputRef = useRef(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 type FieldContext = { busy: boolean }; function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { const accountManagementActions = useAccountManagementActions(); @@ -600,7 +113,7 @@ export function Profile() { }).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported)); return [Object.fromEntries(entries), entries.length > 0]; }, [capabilities, extendedProfileSupported]); - + const [fieldDefaults, setFieldDefaults] = useState({ displayname: legacyProfile.displayName, avatar_url: legacyProfile.avatarUrl, diff --git a/src/app/features/settings/account/fields/ProfileAvatar.tsx b/src/app/features/settings/account/fields/ProfileAvatar.tsx new file mode 100644 index 00000000..5c668c56 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileAvatar.tsx @@ -0,0 +1,118 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds'; +import React, { useState, useMemo, useCallback } from 'react'; +import { ImageEditor } from '../../../../components/image-editor'; +import { SettingTile } from '../../../../components/setting-tile'; +import { CompactUploadCardRenderer } from '../../../../components/upload-card'; +import { useFilePicker } from '../../../../hooks/useFilePicker'; +import { useMatrixClient } from '../../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication'; +import { useObjectURL } from '../../../../hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '../../../../state/upload'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { mxcUrlToHttp } from '../../../../utils/matrix'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; +import { ModalWide } from '../../../../styles/Modal.css'; + +export function ProfileAvatar({ + busy, value, setValue, +}: ProfileFieldElementProps<'avatar_url', FieldContext>) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const avatarUrl = value + ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + const disabled = busy; + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + setValue(mxc); + handleRemoveUpload(); + }, + [setValue, handleRemoveUpload] + ); + + const handleRemoveAvatar = () => { + setValue(''); + }; + + return ( + + Avatar + } + > + {uploadAtom ? ( + + + + ) : ( + + + {avatarUrl && ( + + )} + + )} + + {imageFileURL && ( + }> + + + + + + + + + )} + + ); +} diff --git a/src/app/features/settings/account/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx similarity index 96% rename from src/app/features/settings/account/ProfileFieldContext.tsx rename to src/app/features/settings/account/fields/ProfileFieldContext.tsx index 31241052..0e5da3e6 100644 --- a/src/app/features/settings/account/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import { ExtendedProfile } from '../../../hooks/useExtendedProfile'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; type ExtendedProfileKeys = keyof { [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property] @@ -61,6 +61,7 @@ export function ProfileFieldContext({ defaultValue: fieldDefaults[key], value: fields[key], setValue: (value) => setField(key, value), + key, }; if (element !== undefined) { return React.createElement(element, props); diff --git a/src/app/features/settings/account/fields/ProfilePronouns.tsx b/src/app/features/settings/account/fields/ProfilePronouns.tsx new file mode 100644 index 00000000..963a7a94 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfilePronouns.tsx @@ -0,0 +1,125 @@ +import FocusTrap from 'focus-trap-react'; +import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'; +import { SettingTile } from '../../../../components/setting-tile'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfilePronouns({ + value, setValue, busy, +}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) { + const disabled = busy; + + const [menuCords, setMenuCords] = useState(); + const [pendingPronoun, setPendingPronoun] = useState(''); + + const handleRemovePronoun = (index: number) => { + const newPronouns = [...(value ?? [])]; + newPronouns.splice(index, 1); + if (newPronouns.length > 0) { + setValue(newPronouns); + } else { + setValue(undefined); + } + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + setMenuCords(undefined); + if (pendingPronoun.length > 0) { + setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setMenuCords(undefined); + } + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setPendingPronoun(''); + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + Pronouns + } + > + + {value?.map(({ summary }, index) => ( + } + onClick={() => handleRemovePronoun(index)} + disabled={disabled} + > + + {summary} + + + ))} + } + onClick={handleOpenMenu} + > + Add + + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + setPendingPronoun(evt.currentTarget.value)} + onKeyDown={handleKeyDown} /> + + + + } /> + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTextField.tsx b/src/app/features/settings/account/fields/ProfileTextField.tsx new file mode 100644 index 00000000..fc96cb69 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTextField.tsx @@ -0,0 +1,63 @@ +import { Text, Box, Input, IconButton, Icon, Icons } from 'folds'; +import React, { ChangeEventHandler } from 'react'; +import { FilterByValues } from '../../../../../types/utils'; +import { SettingTile } from '../../../../components/setting-tile'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTextField>({ + label, defaultValue, value, setValue, busy, +}: ProfileFieldElementProps & { label: string; }) { + const disabled = busy; + const hasChanges = defaultValue !== value; + + const handleChange: ChangeEventHandler = (evt) => { + const content = evt.currentTarget.value; + if (content.length > 0) { + setValue(evt.currentTarget.value); + } else { + setValue(undefined); + } + }; + + const handleReset = () => { + setValue(defaultValue); + }; + + return ( + + {label} + } + > + + + + + + + )} /> + + + + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTimezone.tsx b/src/app/features/settings/account/fields/ProfileTimezone.tsx new file mode 100644 index 00000000..6ebea2ac --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTimezone.tsx @@ -0,0 +1,160 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds'; +import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react'; +import { CutoutCard } from '../../../../components/cutout-card'; +import { SettingTile } from '../../../../components/setting-tile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTimezone({ + value, setValue, busy, +}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { + const disabled = busy; + + const inputRef = useRef(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 && ( + + )} + +
+ ); +} From 4c515bb72e1dba2f69618448b99ae5f7cc6c15d2 Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 18 Sep 2025 12:36:08 -0400 Subject: [PATCH 12/21] Move timezone chip to a better position --- src/app/components/user-profile/UserRoomProfile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 6b9b24c6..bd7fd34f 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -93,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( } - /> + > + Change profile settings in your homeserver's account dashboard. + ); } From cfee62ffe6214e0e6b7c38c742c1053e783bba5a Mon Sep 17 00:00:00 2001 From: Ginger Date: Sat, 20 Sep 2025 14:40:13 -0400 Subject: [PATCH 14/21] Fix profile field comparison --- src/app/features/settings/account/Profile.tsx | 2 +- .../account/fields/ProfileFieldContext.tsx | 81 ++++++++++++------- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 60150827..7cf1214e 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -60,7 +60,7 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut } > - Change profile settings in your homeserver's account dashboard. + Change profile settings in your homeserver's account dashboard. ); diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx index 0e5da3e6..4045d3cb 100644 --- a/src/app/features/settings/account/fields/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -1,26 +1,43 @@ -import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + FunctionComponent, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; type ExtendedProfileKeys = keyof { - [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property] -} + [Property in keyof ExtendedProfile as string extends Property + ? never + : Property]: ExtendedProfile[Property]; +}; type ProfileFieldElementRawProps = { - defaultValue: V, - value: V, - setValue: (value: V) => void, -} & C + defaultValue: V; + value: V; + setValue: (value: V) => void; +} & C; -export type ProfileFieldElementProps = ProfileFieldElementRawProps; +export type ProfileFieldElementProps< + K extends ExtendedProfileKeys, + C +> = ProfileFieldElementRawProps; type ProfileFieldElements = { [Property in ExtendedProfileKeys]?: FunctionComponent>; -} +}; type ProfileFieldContextProps = { fieldDefaults: ExtendedProfile; fieldElements: ProfileFieldElements; - children: (reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode) => ReactNode; + children: ( + reset: () => void, + hasChanges: boolean, + fields: ExtendedProfile, + fieldElements: ReactNode + ) => ReactNode; context: C; }; @@ -28,7 +45,7 @@ export function ProfileFieldContext({ fieldDefaults, fieldElements: fieldElementConstructors, children, - context + context, }: ProfileFieldContextProps): ReactNode { const [fields, setFields] = useState(fieldDefaults); @@ -37,7 +54,7 @@ export function ProfileFieldContext({ }, [fieldDefaults]); useEffect(() => { - reset() + reset(); }, [reset]); const setField = useCallback( @@ -51,28 +68,36 @@ export function ProfileFieldContext({ ); const hasChanges = useMemo( - () => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined, + () => + Object.entries(fields).find( + ([key, value]) => + // this is a hack but ExtendedProfile is always valid JSON anyway + JSON.stringify(fieldDefaults[key as keyof ExtendedProfile]) !== JSON.stringify(value) + ) !== undefined, [fields, fieldDefaults] ); - const createElement = useCallback((key: K, element: ProfileFieldElements[K]) => { - const props: ProfileFieldElementRawProps = { - ...context, - defaultValue: fieldDefaults[key], - value: fields[key], - setValue: (value) => setField(key, value), - key, - }; - if (element !== undefined) { - return React.createElement(element, props); - } - return undefined; - }, [context, fieldDefaults, fields, setField]); + const createElement = useCallback( + (key: K, element: ProfileFieldElements[K]) => { + const props: ProfileFieldElementRawProps = { + ...context, + defaultValue: fieldDefaults[key], + value: fields[key], + setValue: (value) => setField(key, value), + key, + }; + if (element !== undefined) { + return React.createElement(element, props); + } + return undefined; + }, + [context, fieldDefaults, fields, setField] + ); - const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) => + const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) => // @ts-expect-error TypeScript doesn't quite understand the magic going on here createElement(key, element) ); return children(reset, hasChanges, fields, fieldElements); -} \ No newline at end of file +} From 4c5acc1940e8a4b42618ec8821adc7902eeda41c Mon Sep 17 00:00:00 2001 From: Ginger Date: Sat, 20 Sep 2025 16:40:18 -0400 Subject: [PATCH 15/21] Use proper deep comparison for hasChanged --- .../features/settings/account/fields/ProfileFieldContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx index 4045d3cb..57447a59 100644 --- a/src/app/features/settings/account/fields/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState, } from 'react'; +import { deepCompare } from 'matrix-js-sdk/lib/utils'; import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; type ExtendedProfileKeys = keyof { @@ -71,8 +72,7 @@ export function ProfileFieldContext({ () => Object.entries(fields).find( ([key, value]) => - // this is a hack but ExtendedProfile is always valid JSON anyway - JSON.stringify(fieldDefaults[key as keyof ExtendedProfile]) !== JSON.stringify(value) + deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) ) !== undefined, [fields, fieldDefaults] ); From f9b0d8c86fc505bc498bfaf52b3741231aeb52e3 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 24 Sep 2025 10:44:17 -0400 Subject: [PATCH 16/21] Add some explanatory comments --- src/app/features/settings/account/Profile.tsx | 32 ++++++++++++++++--- .../account/fields/ProfileFieldContext.tsx | 26 ++++++++++++++- src/app/hooks/useExtendedProfile.ts | 3 ++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 7cf1214e..f3fcadbf 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -27,8 +27,6 @@ import { ProfileTextField } from './fields/ProfileTextField'; import { ProfilePronouns } from './fields/ProfilePronouns'; import { ProfileTimezone } from './fields/ProfileTimezone'; -export type FieldContext = { busy: boolean }; - function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { const accountManagementActions = useAccountManagementActions(); @@ -66,6 +64,12 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut ); } +/// Context props which are passed to every field element. +/// Right now this is only a flag for if the profile is being saved. +export type FieldContext = { busy: boolean }; + +/// Field editor elements for the pre-MSC4133 profile fields. This should only +/// ever contain keys for `displayname` and `avatar_url`. const LEGACY_FIELD_ELEMENTS = { avatar_url: ProfileAvatar, displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( @@ -73,6 +77,8 @@ const LEGACY_FIELD_ELEMENTS = { ), }; +/// Field editor elements for MSC4133 extended profile fields. +/// These will appear in the UI in the order they are defined in this map. const EXTENDED_FIELD_ELEMENTS = { 'io.fsky.nyx.pronouns': ProfilePronouns, 'us.cloke.msc4175.tz': ProfileTimezone, @@ -91,6 +97,7 @@ export function Profile() { const extendedProfileSupported = extendedProfile !== null; const legacyProfile = useUserProfile(userId); + // next-gen auth identity providers may provide profile settings if they want const profileEditableThroughIDP = authMetadata !== undefined && authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); @@ -98,8 +105,12 @@ export function Profile() { const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { const entries = Object.entries({ ...LEGACY_FIELD_ELEMENTS, + // don't show the MSC4133 elements if the HS doesn't support them ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), - }).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported)); + }).filter(([key]) => + // don't show fields if the HS blocks them with capabilities + profileEditsAllowed(key, capabilities, extendedProfileSupported) + ); return [Object.fromEntries(entries), entries.length > 0]; }, [capabilities, extendedProfileSupported]); @@ -107,7 +118,13 @@ export function Profile() { displayname: legacyProfile.displayName, avatar_url: legacyProfile.avatarUrl, }); + + // this updates the field defaults when the extended profile data is (re)loaded. + // it has to be a layout effect to prevent flickering on saves. + // if MSC4133 isn't supported by the HS this does nothing useLayoutEffect(() => { + // `extendedProfile` includes the old dn/av fields, so + // we don't have to add those here if (extendedProfile) { setFieldDefaults(extendedProfile); } @@ -126,8 +143,13 @@ export function Profile() { } }) ); + + // calling this will trigger the layout effect to update the defaults + // once the profile request completes await refreshExtendedProfile(); - // XXX: synthesise a profile update for ourselves because Synapse is broken and won't + + // synthesise a profile update for ourselves to update our name and avatr in the rest + // of the UI. code copied from matrix-js-sdk const user = mx.getUser(userId); if (user) { user.displayName = fields.displayname; @@ -138,6 +160,8 @@ export function Profile() { } else { await mx.setDisplayName(fields.displayname ?? ''); await mx.setAvatarUrl(fields.avatar_url ?? ''); + // layout effect does nothing because `extendedProfile` is undefined + // so we have to update the defaults explicitly here setFieldDefaults(fields); } }, diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx index 57447a59..b2d65bf2 100644 --- a/src/app/features/settings/account/fields/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -9,12 +9,23 @@ import React, { import { deepCompare } from 'matrix-js-sdk/lib/utils'; import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; +/// These types ensure the element functions are actually able to manipulate +/// the profile fields they're mapped to. The generic parameter represents +/// extra "context" props which are passed to every element. + +// strip the index signature from ExtendedProfile using mapped type magic. +// keeping the index signature causes weird typechecking issues further down the line +// plus there should never be field elements passed with keys which don't exist in ExtendedProfile. type ExtendedProfileKeys = keyof { [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]; }; +// these are the props which all field elements must accept. +// this is split into `RawProps` and `Props` so we can type `V` instead of +// spraying `ExtendedProfile[K]` all over the place. +// don't use this directly, use the `ProfileFieldElementProps` type instead type ProfileFieldElementRawProps = { defaultValue: V; value: V; @@ -26,6 +37,7 @@ export type ProfileFieldElementProps< C > = ProfileFieldElementRawProps; +// the map of extended profile keys to field element functions type ProfileFieldElements = { [Property in ExtendedProfileKeys]?: FunctionComponent>; }; @@ -42,6 +54,12 @@ type ProfileFieldContextProps = { context: C; }; +/// This element manages the pending state of the profile field widgets. +/// It takes the default values of each field, as well as a map associating a profile field key +/// with an element _function_ (not a rendered element!) that will be used to edit that field. +/// It renders the editor elements internally using React.createElement and passes the rendered +/// elements into the child UI. This allows it to handle the pending state entirely by itself, +/// and provides strong typechecking. export function ProfileFieldContext({ fieldDefaults, fieldElements: fieldElementConstructors, @@ -49,11 +67,14 @@ export function ProfileFieldContext({ context, }: ProfileFieldContextProps): ReactNode { const [fields, setFields] = useState(fieldDefaults); - + + // this callback also runs when fieldDefaults changes, + // which happens when the profile is saved and the pending fields become the new defaults const reset = useCallback(() => { setFields(fieldDefaults); }, [fieldDefaults]); + // set the pending values to the defaults on the first render useEffect(() => { reset(); }, [reset]); @@ -72,6 +93,7 @@ export function ProfileFieldContext({ () => Object.entries(fields).find( ([key, value]) => + // deep comparison is necessary here because field values can be any JSON type deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) ) !== undefined, [fields, fieldDefaults] @@ -86,6 +108,8 @@ export function ProfileFieldContext({ setValue: (value) => setField(key, value), key, }; + // element can be undefined if the field defaults didn't include its key, + // which means the HS doesn't support setting that field if (element !== undefined) { return React.createElement(element, props); } diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index a3608f0c..9ef05fa3 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -28,6 +28,8 @@ export function useExtendedProfileSupported(): boolean { return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15'); } +/// Returns the user's MSC4133 extended profile, if our homeserver supports it. +/// This will return `undefined` while the request is in flight and `null` if the HS lacks support. export function useExtendedProfile( userId: string ): [ExtendedProfile | undefined | null, () => Promise] { @@ -54,6 +56,7 @@ export function useExtendedProfile( const LEGACY_FIELDS = ['displayname', 'avatar_url']; +/// Returns whether the given profile field may be edited by the user. export function profileEditsAllowed( field: string, capabilities: Capabilities, From 5bc9654d3263b7ea0bf5152ade223a3635dc1770 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 6 Oct 2025 11:44:41 -0400 Subject: [PATCH 17/21] Add a panel in Developer Tools for editing profile fields --- src/app/components/AccountDataEditor.tsx | 41 ++- src/app/features/settings/account/Profile.tsx | 2 +- .../settings/developer-tools/AccountData.tsx | 100 ------- .../developer-tools/AccountDataList.tsx | 86 ++++++ .../settings/developer-tools/DevelopTools.tsx | 259 +++++++++++------- 5 files changed, 287 insertions(+), 201 deletions(-) delete mode 100644 src/app/features/settings/developer-tools/AccountData.tsx create mode 100644 src/app/features/settings/developer-tools/AccountDataList.tsx diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..3be3911c 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor'; const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; +export type AccountDataDeleteCallback = (type: string) => Promise; type AccountDataInfo = { type: string; @@ -83,8 +84,7 @@ function AccountDataEdit({ if ( !typeStr || - parsedContent === null || - defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + parsedContent === null ) { return; } @@ -121,7 +121,7 @@ function AccountDataEdit({ aria-disabled={submitting} > - Account Data + Field Name void; + requestClose: () => void; + submitDelete?: AccountDataDeleteCallback; }; -function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { +function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) { + const [deleteState, deleteCallback] = useAsyncCallback(useCallback( + async () => { + if (submitDelete !== undefined) { + await submitDelete(type); + requestClose(); + } + }, + [type, submitDelete, requestClose], + )); + const deleting = deleteState.status === AsyncStatus.Loading; + return ( - Account Data + Field Name Edit + {submitDelete && ( + + )} JSON Content @@ -243,8 +268,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) export type AccountDataEditorProps = { type?: string; - content?: object; + content?: unknown; submitChange: AccountDataSubmitCallback; + submitDelete?: AccountDataDeleteCallback; requestClose: () => void; }; @@ -252,6 +278,7 @@ export function AccountDataEditor({ type, content, submitChange, + submitDelete, requestClose, }: AccountDataEditorProps) { const [data, setData] = useState({ @@ -314,6 +341,8 @@ export function AccountDataEditor({ type={data.type} defaultContent={contentJSONStr} onEdit={() => setEdit(true)} + requestClose={requestClose} + submitDelete={submitDelete} /> )} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index f3fcadbf..71f5773f 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -148,7 +148,7 @@ export function Profile() { // once the profile request completes await refreshExtendedProfile(); - // synthesise a profile update for ourselves to update our name and avatr in the rest + // synthesize a profile update for ourselves to update our name and avatar in the rest // of the UI. code copied from matrix-js-sdk const user = mx.getUser(userId); if (user) { diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx deleted file mode 100644 index 8bccb62e..00000000 --- a/src/app/features/settings/developer-tools/AccountData.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; -import { CutoutCard } from '../../../components/cutout-card'; - -type AccountDataProps = { - expand: boolean; - onExpandToggle: (expand: boolean) => void; - onSelect: (type: string | null) => void; -}; -export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) { - const mx = useMatrixClient(); - const [accountDataTypes, setAccountDataKeys] = useState(() => - Array.from(mx.store.accountData.keys()) - ); - - useAccountDataCallback( - mx, - useCallback(() => { - setAccountDataKeys(Array.from(mx.store.accountData.keys())); - }, [mx]) - ); - - return ( - - Account Data - - onExpandToggle(!expand)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expand ? 'Collapse' : 'Expand'} - - } - /> - {expand && ( - - - Events - Total: {accountDataTypes.length} - - - } - onClick={() => onSelect(null)} - > - - - Add New - - - - {accountDataTypes.sort().map((type) => ( - } - onClick={() => onSelect(type)} - > - - - {type} - - - - ))} - - - )} - - - ); -} diff --git a/src/app/features/settings/developer-tools/AccountDataList.tsx b/src/app/features/settings/developer-tools/AccountDataList.tsx new file mode 100644 index 00000000..71b1cf76 --- /dev/null +++ b/src/app/features/settings/developer-tools/AccountDataList.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { CutoutCard } from '../../../components/cutout-card'; + +type AccountDataListProps = { + title?: string; + description?: string; + expand: boolean; + setExpand: (expand: boolean) => void; + types: string[]; + onSelect: (type: string | null) => void; +}; +export function AccountDataList({ types, onSelect, expand, setExpand, title, description }: AccountDataListProps) { + return ( + + setExpand(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && ( + + + Fields + Total: {types.length} + + + } + onClick={() => onSelect(null)} + > + + + Add New + + + + {types.sort().map((type) => ( + } + onClick={() => onSelect(type)} + > + + + {type} + + + + ))} + + + )} + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..5ee86820 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; +import { AccountDataEvents } from 'matrix-js-sdk'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -8,117 +9,187 @@ import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { + AccountDataDeleteCallback, AccountDataEditor, AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; -import { AccountData } from './AccountData'; +import { AccountDataList } from './AccountDataList'; +import { useExtendedProfile } from '../../../hooks/useExtendedProfile'; +import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; + +type DeveloperToolsPage = + | { name: 'index' } + | { name: 'account-data'; type: string | null } + | { name: 'profile-field'; type: string | null }; type DeveloperToolsProps = { requestClose: () => void; }; export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); + const userId = mx.getUserId() as string; + + const [accountDataTypes, setAccountDataKeys] = useState(() => + Array.from(mx.store.accountData.keys()) + ); + + useAccountDataCallback( + mx, + useCallback(() => { + setAccountDataKeys(Array.from(mx.store.accountData.keys())); + }, [mx]) + ); + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); - const [expand, setExpend] = useState(false); - const [accountDataType, setAccountDataType] = useState(); + const [page, setPage] = useState({ name: 'index' }); + const [globalExpand, setGlobalExpand] = useState(false); + const [profileExpand, setProfileExpand] = useState(false); const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { - await mx.setAccountData(type, content); + await mx.setAccountData(type as keyof AccountDataEvents, content); }, [mx] ); - if (accountDataType !== undefined) { - return ( - setAccountDataType(undefined)} - /> - ); - } - - return ( - - - - - - Developer Tools - - - - - - - - - - - - - - - Options - - - } - /> - - {developerTools && ( - - - copyToClipboard(mx.getAccessToken() ?? '') - } - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - > - Copy - - } - /> - - )} - - {developerTools && ( - - )} - - - - - + const submitProfileField: AccountDataSubmitCallback = useCallback( + async (type, content) => { + await mx.setExtendedProfileProperty(type, content); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] ); + + const deleteProfileField: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteExtendedProfileProperty(type); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] + ); + + const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]); + + switch (page.name) { + case 'account-data': + return ( + + ); + + case 'profile-field': + return ( + + ); + + default: + return ( + + + + + + Developer Tools + + + + + + + + + + + + + + + Options + + + } + /> + + {developerTools && ( + + + copyToClipboard(mx.getAccessToken() ?? '') + } + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Copy + + } + /> + + )} + + {developerTools && ( + + Account Data + setPage({ name: 'account-data', type })} + /> + {extendedProfile && ( + setPage({ name: 'profile-field', type })} + /> + )} + + )} + + + + + + ); + } } From af9460ef8b3df59461ea3be166b2b6d252f3d37c Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 6 Oct 2025 11:45:32 -0400 Subject: [PATCH 18/21] Fix incorrect logic when checking for profile field changes --- .../features/settings/account/fields/ProfileFieldContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx index b2d65bf2..18f78627 100644 --- a/src/app/features/settings/account/fields/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -94,7 +94,7 @@ export function ProfileFieldContext({ Object.entries(fields).find( ([key, value]) => // deep comparison is necessary here because field values can be any JSON type - deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) + !deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) ) !== undefined, [fields, fieldDefaults] ); From d42bcc6e3d58f4a14c3e8d60aba5905a05f58bdb Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 6 Oct 2025 12:21:01 -0400 Subject: [PATCH 19/21] Use a common CollapsibleCard element for collapsible settings cards --- src/app/components/AccountDataEditor.tsx | 16 +- src/app/components/CollapsibleCard.tsx | 54 +++ .../developer-tools/DevelopTools.tsx | 355 ++++++++---------- .../common-settings/general/RoomAddress.tsx | 91 ++--- .../developer-tools/AccountDataList.tsx | 118 +++--- .../settings/developer-tools/DevelopTools.tsx | 31 +- .../features/settings/devices/LocalBackup.tsx | 47 +-- 7 files changed, 322 insertions(+), 390 deletions(-) create mode 100644 src/app/components/CollapsibleCard.tsx diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 3be3911c..ef8d01a1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -195,8 +195,8 @@ function AccountDataEdit({ type AccountDataViewProps = { type: string; defaultContent: string; - onEdit: () => void; requestClose: () => void; + onEdit?: () => void; submitDelete?: AccountDataDeleteCallback; }; function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) { @@ -231,9 +231,11 @@ function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDel required /> - + {onEdit && ( + + )} {submitDelete && ( + } + /> + {expand && children} + + ); +} diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..4125f6d2 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,6 +30,7 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type DeveloperToolsProps = { requestClose: () => void; @@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { } /> - - setExpandState(!expandState)} - variant="Secondary" - fill="Soft" + + + Events + Total: {roomState.size} + + + setComposeEvent({ stateKey: '' })} + variant="Surface" + fill="None" size="300" - radii="300" - outlined - before={ - - } + radii="0" + before={} > - {expandState ? 'Collapse' : 'Expand'} - - } - /> - {expandState && ( - - - Events - Total: {roomState.size} - - - setComposeEvent({ stateKey: '' })} - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(roomState.keys()) - .sort() - .map((eventType) => { - const expanded = eventType === expandStateType; - const stateKeyToEvents = roomState.get(eventType); - if (!stateKeyToEvents) return null; + + + Add New + + + + {Array.from(roomState.keys()) + .sort() + .map((eventType) => { + const expanded = eventType === expandStateType; + const stateKeyToEvents = roomState.get(eventType); + if (!stateKeyToEvents) return null; - return ( - - - setExpandStateType(expanded ? undefined : eventType) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={ - - } - after={{stateKeyToEvents.size}} + return ( + + + setExpandStateType(expanded ? undefined : eventType) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={ + + } + after={{stateKeyToEvents.size}} + > + + + {eventType} + + + + {expanded && ( +
- - - {eventType} - - - - {expanded && ( -
+ setComposeEvent({ type: eventType, stateKey: '' }) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={} > - - setComposeEvent({ type: eventType, stateKey: '' }) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(stateKeyToEvents.keys()) - .sort() - .map((stateKey) => ( - { - setOpenStateEvent({ - type: eventType, - stateKey, - }); - }} - key={stateKey} - variant="Surface" - fill="None" - size="300" - radii="0" - after={} - > - - - {stateKey ? `"${stateKey}"` : 'Default'} - - - - ))} -
- )} - - ); - })} - - - )} - - + + Add New + + + + {Array.from(stateKeyToEvents.keys()) + .sort() + .map((stateKey) => ( + { + setOpenStateEvent({ + type: eventType, + stateKey, + }); + }} + key={stateKey} + variant="Surface" + fill="None" + size="300" + radii="0" + after={} + > + + + {stateKey ? `"${stateKey}"` : 'Default'} + + + + ))} +
+ )} +
+ ); + })} +
+
+ + - setExpandAccountData(!expandAccountData)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expandAccountData ? 'Collapse' : 'Expand'} - - } - /> - {expandAccountData && ( - - - Events - Total: {accountData.size} - - - } - onClick={() => setAccountDataType(null)} - > - - - Add New - - - - {Array.from(accountData.keys()) - .sort() - .map((type) => ( - } - onClick={() => setAccountDataType(type)} - > - - - {type} - - - - ))} - + + + Events + Total: {accountData.size} - )} -
+ + } + onClick={() => setAccountDataType(null)} + > + + + Add New + + + + {Array.from(accountData.keys()) + .sort() + .map((type) => ( + } + onClick={() => setAccountDataType(type)} + > + + + {type} + + + + ))} + +
+
)}
diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 767a967e..1a618a68 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -7,8 +7,6 @@ import { Chip, color, config, - Icon, - Icons, Input, Spinner, Text, @@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { StateEvent } from '../../../../types/matrix/room'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { getMxIdServer } from '../../../utils/matrix'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type RoomPublishedAddressesProps = { permissions: RoomPermissionsAPI; @@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); return ( - - setExpand(!expand)} - size="300" - variant="Secondary" - fill="Soft" - outlined - radii="300" - before={ - - } - > - - {expand ? 'Collapse' : 'Expand'} + + {localAliasesState.status === AsyncStatus.Loading && ( + + + Loading... + + )} + {localAliasesState.status === AsyncStatus.Success && + (localAliasesState.data.length === 0 ? ( + + No Addresses + + ) : ( + + ))} + {localAliasesState.status === AsyncStatus.Error && ( + + + {localAliasesState.error.message} - - } - /> - {expand && ( - - {localAliasesState.status === AsyncStatus.Loading && ( - - - Loading... - - )} - {localAliasesState.status === AsyncStatus.Success && - (localAliasesState.data.length === 0 ? ( - - No Addresses - - ) : ( - - ))} - {localAliasesState.status === AsyncStatus.Error && ( - - - {localAliasesState.error.message} - - - )} - - )} + + )} + {expand && } - + ); } diff --git a/src/app/features/settings/developer-tools/AccountDataList.tsx b/src/app/features/settings/developer-tools/AccountDataList.tsx index 71b1cf76..9c9cbe6a 100644 --- a/src/app/features/settings/developer-tools/AccountDataList.tsx +++ b/src/app/features/settings/developer-tools/AccountDataList.tsx @@ -1,86 +1,54 @@ import React from 'react'; -import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; +import { Box, Text, Icon, Icons, MenuItem } from 'folds'; import { CutoutCard } from '../../../components/cutout-card'; type AccountDataListProps = { - title?: string; - description?: string; - expand: boolean; - setExpand: (expand: boolean) => void; types: string[]; onSelect: (type: string | null) => void; }; -export function AccountDataList({ types, onSelect, expand, setExpand, title, description }: AccountDataListProps) { +export function AccountDataList({ + types, + onSelect, +}: AccountDataListProps) { return ( - - setExpand(!expand)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expand ? 'Collapse' : 'Expand'} - - } - /> - {expand && ( - - - Fields - Total: {types.length} + + + Fields + Total: {types.length} + + + } + onClick={() => onSelect(null)} + > + + + Add New + - - } - onClick={() => onSelect(null)} - > - - - Add New - - - - {types.sort().map((type) => ( - } - onClick={() => onSelect(type)} - > - - - {type} - - - - ))} - - - )} - + + {types.sort().map((type) => ( + } + onClick={() => onSelect(type)} + > + + + {type} + + + + ))} + +
); } diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 5ee86820..4ac58f46 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -17,6 +17,7 @@ import { copyToClipboard } from '../../../utils/dom'; import { AccountDataList } from './AccountDataList'; import { useExtendedProfile } from '../../../hooks/useExtendedProfile'; import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type DeveloperToolsPage = | { name: 'index' } @@ -165,23 +166,29 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { {developerTools && ( Account Data - setPage({ name: 'account-data', type })} - /> - {extendedProfile && ( + title="Account" + description="Private data stored in your account." + > setPage({ name: 'account-data', type })} + /> + + {extendedProfile && ( + setPage({ name: 'profile-field', type })} - /> + title="Profile" + description="Public data attached to your Matrix profile." + > + setPage({ name: 'profile-field', type })} + /> + )} )} diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..35baa9cb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys'; import { useAlive } from '../../../hooks/useAlive'; import { useFilePicker } from '../../../hooks/useFilePicker'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; function ExportKeys() { const mx = useMatrixClient(); @@ -121,37 +122,18 @@ function ExportKeys() { ); } -function ExportKeysTile() { +function ExportKeysCard() { const [expand, setExpand] = useState(false); return ( - <> - - -
- } - /> - {expand && } - + + + ); } @@ -304,14 +286,7 @@ export function LocalBackup() { return ( Local Backup - - - + Date: Mon, 6 Oct 2025 14:02:50 -0400 Subject: [PATCH 20/21] Add a context menu option to view a user's raw extended profile fields --- src/app/components/user-profile/UserChips.tsx | 174 ++++++++++++------ .../user-profile/UserRoomProfile.tsx | 2 +- 2 files changed, 116 insertions(+), 60 deletions(-) diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index a40c93cd..d65006bd 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -22,6 +22,10 @@ import { TooltipProvider, Tooltip, Badge, + Overlay, + OverlayBackdrop, + OverlayCenter, + Modal, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdServer } from '../../utils/matrix'; @@ -45,6 +49,10 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; import { useInterval } from '../../hooks/useInterval'; +import { TextViewer } from '../text-viewer'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; +import { settingsAtom } from '../../state/settings'; +import { useSetting } from '../../state/hooks/settings'; export function ServerChip({ server }: { server: string }) { const mx = useMatrixClient(); @@ -440,15 +448,24 @@ export function IgnoredUserAlert() { ); } -export function OptionsChip({ userId }: { userId: string }) { +export function OptionsChip({ + userId, + extendedProfile, +}: { + userId: string; + extendedProfile: ExtendedProfile | null; +}) { const mx = useMatrixClient(); - const [cords, setCords] = useState(); + const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools'); - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); + const [profileFieldsOpen, setProfileFieldsOpen] = useState(false); + const [menuCoords, setMenuCoords] = useState(); + + const openMenu: MouseEventHandler = (evt) => { + setMenuCoords(evt.currentTarget.getBoundingClientRect()); }; - const close = () => setCords(undefined); + const closeMenu = () => setMenuCoords(undefined); const ignoredUsers = useIgnoredUsers(); const ignored = ignoredUsers.includes(userId); @@ -463,57 +480,96 @@ export function OptionsChip({ userId }: { userId: string }) { const ignoring = ignoreState.status === AsyncStatus.Loading; return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - toggleIgnore(); - close(); - }} - before={ - ignoring ? ( - - ) : ( - - ) - } - disabled={ignoring} - > - {ignored ? 'Unblock User' : 'Block User'} - -
-
- - } - > - - {ignoring ? ( - - ) : ( - - )} - -
+ <> + {extendedProfile && ( + }> + + setProfileFieldsOpen(false), + escapeDeactivates: stopPropagation, + }} + > + + setProfileFieldsOpen(false)} + /> + + + + + )} + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + toggleIgnore(); + closeMenu(); + }} + before={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + {ignored ? 'Unblock User' : 'Block User'} + + {extendedProfile && developerToolsEnabled && ( + { + setProfileFieldsOpen(true); + closeMenu(); + }} + before={} + > + View Profile Fields + + )} +
+
+ + } + > + + {ignoring ? ( + + ) : ( + + )} + +
+ ); } @@ -555,8 +611,8 @@ export function TimezoneChip({ timezone }: { timezone: string }) { offset={5} align="Center" tooltip={ - - + + Timezone: @@ -573,7 +629,7 @@ export function TimezoneChip({ timezone }: { timezone: string }) { ref={triggerRef} variant="SurfaceVariant" radii="Pill" - style={{ cursor: "initial" }} + style={{ cursor: 'initial' }} before={} > diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index bd7fd34f..cb54083e 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -115,7 +115,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {timezone && } {creator ? : } {userId !== myUserId && } - {userId !== myUserId && } + {userId !== myUserId && } {ignored && } From 13dd8fcc0696d18e2af0f8eed234ff64660f402e Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 6 Oct 2025 14:18:41 -0400 Subject: [PATCH 21/21] Allow account data to be deleted if the homeserver supports it --- .../settings/developer-tools/DevelopTools.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 4ac58f46..01e6e6a4 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { AccountDataEvents } from 'matrix-js-sdk'; +import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -34,13 +35,16 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [accountDataTypes, setAccountDataKeys] = useState(() => Array.from(mx.store.accountData.keys()) ); - + const accountDataDeletionSupported = + (mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !== + ServerSupport.Unsupported; useAccountDataCallback( mx, useCallback(() => { setAccountDataKeys(Array.from(mx.store.accountData.keys())); }, [mx]) ); + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); @@ -55,6 +59,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { [mx] ); + const deleteAccountData: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteAccountData(type as keyof AccountDataEvents); + }, + [mx] + ); + const submitProfileField: AccountDataSubmitCallback = useCallback( async (type, content) => { await mx.setExtendedProfileProperty(type, content); @@ -78,8 +89,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { return ( );