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 { 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);
}
},

View file

@ -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 <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 {
[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<V, C> = {
defaultValue: V;
value: V;
@ -26,6 +37,7 @@ export type ProfileFieldElementProps<
C
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
// the map of extended profile keys to field element functions
type ProfileFieldElements<C> = {
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
};
@ -42,6 +54,12 @@ type ProfileFieldContextProps<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>({
fieldDefaults,
fieldElements: fieldElementConstructors,
@ -49,11 +67,14 @@ export function ProfileFieldContext<C>({
context,
}: ProfileFieldContextProps<C>): ReactNode {
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(() => {
setFields(fieldDefaults);
}, [fieldDefaults]);
// set the pending values to the defaults on the first render
useEffect(() => {
reset();
}, [reset]);
@ -72,6 +93,7 @@ export function ProfileFieldContext<C>({
() =>
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<C>({
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);
}

View file

@ -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<void>] {
@ -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,