diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 618be81f..d90b8e4b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -3,7 +3,6 @@ import React, { FormEventHandler, KeyboardEventHandler, MouseEventHandler, - ReactNode, useCallback, useEffect, useLayoutEffect, @@ -53,10 +52,10 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; import { ExtendedProfile, + profileEditsAllowed, useExtendedProfile, - useProfileEditsAllowed, } from '../../../hooks/useExtendedProfile'; -import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext'; +import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { FilterByValues } from '../../../../types/utils'; import { CutoutCard } from '../../../components/cutout-card'; @@ -66,15 +65,21 @@ import { useUserProfile } from '../../../hooks/useUserProfile'; import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; import { withSearchParam } from '../../../pages/pathUtils'; +import { useCapabilities } from '../../../hooks/useCapabilities'; -function ProfileAvatar() { +type FieldContext = { busy: boolean }; + +function ProfileAvatar({ + busy, + value, + setValue, +}: ProfileFieldElementProps<'avatar_url', FieldContext>) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const { busy, value, setValue } = useProfileField('avatar_url'); const avatarUrl = value ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const disabled = !useProfileEditsAllowed('avatar_url') || busy; + const disabled = busy; const [imageFile, setImageFile] = useState(); const imageFileURL = useObjectURL(imageFile); @@ -172,17 +177,14 @@ function ProfileAvatar() { ); } -type ProfileTextFieldProps = { - field: K; - label: ReactNode; -}; - function ProfileTextField>({ - field, label, -}: ProfileTextFieldProps) { - const { busy, defaultValue, value, setValue } = useProfileField(field); - const disabled = !useProfileEditsAllowed(field) || busy; + defaultValue, + value, + setValue, + busy, +}: ProfileFieldElementProps & { label: string }) { + const disabled = busy; const hasChanges = defaultValue !== value; const handleChange: ChangeEventHandler = (evt) => { @@ -240,9 +242,12 @@ function ProfileTextField) { + const disabled = busy; const [menuCords, setMenuCords] = useState(); const [pendingPronoun, setPendingPronoun] = useState(''); @@ -364,9 +369,12 @@ function ProfilePronouns() { ); } -function ProfileTimezone() { - const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz'); - const disabled = !useProfileEditsAllowed('us.cloke.msc4175.tz') || busy; +function ProfileTimezone({ + value, + setValue, + busy, +}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { + const disabled = busy; const inputRef = useRef(null); const scrollRef = useRef(null); @@ -556,12 +564,26 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut ); } +const LEGACY_FIELD_ELEMENTS = { + avatar_url: ProfileAvatar, + displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( + + ), +}; + +const EXTENDED_FIELD_ELEMENTS = { + 'io.fsky.nyx.pronouns': ProfilePronouns, + 'us.cloke.msc4175.tz': ProfileTimezone, +}; + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId() as string; const server = getMxIdServer(userId); const authMetadata = useAuthMetadata(); const accountManagementActions = useAccountManagementActions(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const extendedProfileSupported = extendedProfile !== null; @@ -570,8 +592,15 @@ export function Profile() { const profileEditableThroughIDP = authMetadata !== undefined && authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); - const profileEditableThroughClient = useProfileEditsAllowed(null); + const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { + const entries = Object.entries({ + ...LEGACY_FIELD_ELEMENTS, + ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), + }).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported)); + return [Object.fromEntries(entries), entries.length > 0]; + }, [capabilities, extendedProfileSupported]); + const [fieldDefaults, setFieldDefaults] = useState({ displayname: legacyProfile.displayName, avatar_url: legacyProfile.avatarUrl, @@ -582,8 +611,6 @@ export function Profile() { } }, [setFieldDefaults, extendedProfile]); - const useAuthentication = useMediaAuthentication(); - const [saveState, handleSave] = useAsyncCallback( useCallback( async (fields: ExtendedProfile) => { @@ -631,8 +658,12 @@ export function Profile() { overflow: 'hidden', }} > - - {(save, reset, hasChanges, fields) => { + + {(reset, hasChanges, fields, fieldElements) => { const heroAvatarUrl = (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? undefined; @@ -669,14 +700,7 @@ export function Profile() { {profileEditableThroughClient && ( <> - - - {extendedProfileSupported && ( - <> - - - - )} + {fieldElements} @@ -727,7 +751,7 @@ export function Profile() { ); }} - + ); diff --git a/src/app/features/settings/account/ProfileFieldContext.tsx b/src/app/features/settings/account/ProfileFieldContext.tsx index 8c5f4e5f..31241052 100644 --- a/src/app/features/settings/account/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/ProfileFieldContext.tsx @@ -1,26 +1,35 @@ -import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { FunctionComponent, ReactNode, useCallback, 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); +type ExtendedProfileKeys = keyof { + [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property] +} -export type ProfileFieldContextProviderProps = { +type ProfileFieldElementRawProps = { + defaultValue: V, + value: V, + setValue: (value: V) => void, +} & C + +export type ProfileFieldElementProps = ProfileFieldElementRawProps; + +type ProfileFieldElements = { + [Property in ExtendedProfileKeys]?: FunctionComponent>; +} + +type ProfileFieldContextProps = { fieldDefaults: ExtendedProfile; - save: (fields: ExtendedProfile) => void; - busy: boolean; - children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode; + fieldElements: ProfileFieldElements; + children: (reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode) => ReactNode; + context: C; }; -export function ProfileFieldContextProvider({ +export function ProfileFieldContext({ fieldDefaults, - save, - busy, + fieldElements: fieldElementConstructors, children, -}: ProfileFieldContextProviderProps) { + context +}: ProfileFieldContextProps): ReactNode { const [fields, setFields] = useState(fieldDefaults); const reset = useCallback(() => { @@ -41,35 +50,28 @@ export function ProfileFieldContextProvider({ [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)} - + const createElement = useCallback((key: K, element: ProfileFieldElements[K]) => { + const props: ProfileFieldElementRawProps = { + ...context, + defaultValue: fieldDefaults[key], + value: fields[key], + setValue: (value) => setField(key, value), + }; + 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) ); -} -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); - }, - }; + return children(reset, hasChanges, fields, fieldElements); } \ No newline at end of file diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index 50de4f88..a3608f0c 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react'; import z from 'zod'; import { useQuery } from '@tanstack/react-query'; +import { Capabilities } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useSpecVersions } from './useSpecVersions'; -import { useCapabilities } from './useCapabilities'; import { IProfileFieldsCapability } from '../../types/matrix/common'; const extendedProfile = z.looseObject({ @@ -54,11 +54,12 @@ export function useExtendedProfile( const LEGACY_FIELDS = ['displayname', 'avatar_url']; -export function useProfileEditsAllowed(field: string | null): boolean { - const capabilities = useCapabilities(); - const extendedProfileSupported = useExtendedProfileSupported(); - - if (field && LEGACY_FIELDS.includes(field)) { +export function profileEditsAllowed( + field: string, + capabilities: Capabilities, + extendedProfileSupported: boolean +): boolean { + 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; @@ -86,11 +87,6 @@ export function useProfileEditsAllowed(field: string | null): boolean { return false; } - if (field === null) { - // profile field modifications are not completely disabled - return true; - } - if ( extendedProfileCapability.allowed !== undefined && !extendedProfileCapability.allowed.includes(field) @@ -108,11 +104,6 @@ export function useProfileEditsAllowed(field: string | null): boolean { return true; } - if (field === null) { - // the homeserver only supports legacy fields. assume profile editing is generally allowed - return true; - } - // `field` is an extended profile key and the homeserver lacks msc4133 support return false; }