Add some explanatory comments

This commit is contained in:
Ginger 2025-09-24 10:44:17 -04:00
parent 458b1c0172
commit f9b0d8c86f
No known key found for this signature in database
3 changed files with 56 additions and 5 deletions

View file

@ -27,8 +27,6 @@ import { ProfileTextField } from './fields/ProfileTextField';
import { ProfilePronouns } from './fields/ProfilePronouns'; import { ProfilePronouns } from './fields/ProfilePronouns';
import { ProfileTimezone } from './fields/ProfileTimezone'; import { ProfileTimezone } from './fields/ProfileTimezone';
export type FieldContext = { busy: boolean };
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
const accountManagementActions = useAccountManagementActions(); 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 = { const LEGACY_FIELD_ELEMENTS = {
avatar_url: ProfileAvatar, avatar_url: ProfileAvatar,
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( 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 = { const EXTENDED_FIELD_ELEMENTS = {
'io.fsky.nyx.pronouns': ProfilePronouns, 'io.fsky.nyx.pronouns': ProfilePronouns,
'us.cloke.msc4175.tz': ProfileTimezone, 'us.cloke.msc4175.tz': ProfileTimezone,
@ -91,6 +97,7 @@ export function Profile() {
const extendedProfileSupported = extendedProfile !== null; const extendedProfileSupported = extendedProfile !== null;
const legacyProfile = useUserProfile(userId); const legacyProfile = useUserProfile(userId);
// next-gen auth identity providers may provide profile settings if they want
const profileEditableThroughIDP = const profileEditableThroughIDP =
authMetadata !== undefined && authMetadata !== undefined &&
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
@ -98,8 +105,12 @@ export function Profile() {
const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
const entries = Object.entries({ const entries = Object.entries({
...LEGACY_FIELD_ELEMENTS, ...LEGACY_FIELD_ELEMENTS,
// don't show the MSC4133 elements if the HS doesn't support them
...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), ...(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]; return [Object.fromEntries(entries), entries.length > 0];
}, [capabilities, extendedProfileSupported]); }, [capabilities, extendedProfileSupported]);
@ -107,7 +118,13 @@ export function Profile() {
displayname: legacyProfile.displayName, displayname: legacyProfile.displayName,
avatar_url: legacyProfile.avatarUrl, 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(() => { useLayoutEffect(() => {
// `extendedProfile` includes the old dn/av fields, so
// we don't have to add those here
if (extendedProfile) { if (extendedProfile) {
setFieldDefaults(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(); 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); const user = mx.getUser(userId);
if (user) { if (user) {
user.displayName = fields.displayname; user.displayName = fields.displayname;
@ -138,6 +160,8 @@ export function Profile() {
} else { } else {
await mx.setDisplayName(fields.displayname ?? ''); await mx.setDisplayName(fields.displayname ?? '');
await mx.setAvatarUrl(fields.avatar_url ?? ''); 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); setFieldDefaults(fields);
} }
}, },

View file

@ -9,12 +9,23 @@ import React, {
import { deepCompare } from 'matrix-js-sdk/lib/utils'; import { deepCompare } from 'matrix-js-sdk/lib/utils';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
/// These types ensure the element functions are actually able to manipulate
/// the profile fields they're mapped to. The <C> 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 { type ExtendedProfileKeys = keyof {
[Property in keyof ExtendedProfile as string extends Property [Property in keyof ExtendedProfile as string extends Property
? never ? never
: Property]: ExtendedProfile[Property]; : 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<V, C> = { type ProfileFieldElementRawProps<V, C> = {
defaultValue: V; defaultValue: V;
value: V; value: V;
@ -26,6 +37,7 @@ export type ProfileFieldElementProps<
C C
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>; > = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
// the map of extended profile keys to field element functions
type ProfileFieldElements<C> = { type ProfileFieldElements<C> = {
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>; [Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
}; };
@ -42,6 +54,12 @@ type ProfileFieldContextProps<C> = {
context: C; 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<C>({ export function ProfileFieldContext<C>({
fieldDefaults, fieldDefaults,
fieldElements: fieldElementConstructors, fieldElements: fieldElementConstructors,
@ -50,10 +68,13 @@ export function ProfileFieldContext<C>({
}: ProfileFieldContextProps<C>): ReactNode { }: ProfileFieldContextProps<C>): ReactNode {
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults); const [fields, setFields] = useState<ExtendedProfile>(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(() => { const reset = useCallback(() => {
setFields(fieldDefaults); setFields(fieldDefaults);
}, [fieldDefaults]); }, [fieldDefaults]);
// set the pending values to the defaults on the first render
useEffect(() => { useEffect(() => {
reset(); reset();
}, [reset]); }, [reset]);
@ -72,6 +93,7 @@ export function ProfileFieldContext<C>({
() => () =>
Object.entries(fields).find( Object.entries(fields).find(
([key, value]) => ([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, ) !== undefined,
[fields, fieldDefaults] [fields, fieldDefaults]
@ -86,6 +108,8 @@ export function ProfileFieldContext<C>({
setValue: (value) => setField(key, value), setValue: (value) => setField(key, value),
key, 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) { if (element !== undefined) {
return React.createElement(element, props); return React.createElement(element, props);
} }

View file

@ -28,6 +28,8 @@ export function useExtendedProfileSupported(): boolean {
return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15'); 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( export function useExtendedProfile(
userId: string userId: string
): [ExtendedProfile | undefined | null, () => Promise<void>] { ): [ExtendedProfile | undefined | null, () => Promise<void>] {
@ -54,6 +56,7 @@ export function useExtendedProfile(
const LEGACY_FIELDS = ['displayname', 'avatar_url']; const LEGACY_FIELDS = ['displayname', 'avatar_url'];
/// Returns whether the given profile field may be edited by the user.
export function profileEditsAllowed( export function profileEditsAllowed(
field: string, field: string,
capabilities: Capabilities, capabilities: Capabilities,