From 3c1aa0e6996678e19c5016edc35e9946e0acb818 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 15 Sep 2025 10:47:21 -0400 Subject: [PATCH] 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]; +};