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,