import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState, } from '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; setValue: (value: V) => void; } & C; export type ProfileFieldElementProps< K extends ExtendedProfileKeys, C > = ProfileFieldElementRawProps; // the map of extended profile keys to field element functions type ProfileFieldElements = { [Property in ExtendedProfileKeys]?: FunctionComponent>; }; type ProfileFieldContextProps = { fieldDefaults: ExtendedProfile; fieldElements: ProfileFieldElements; children: ( reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode ) => ReactNode; 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, children, 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]); const setField = useCallback( (key: string, value: unknown) => { setFields({ ...fields, [key]: value, }); }, [fields] ); const hasChanges = useMemo( () => 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] ); const createElement = useCallback( (key: K, element: ProfileFieldElements[K]) => { const props: ProfileFieldElementRawProps = { ...context, defaultValue: fieldDefaults[key], value: fields[key], 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); } 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) ); return children(reset, hasChanges, fields, fieldElements); }