Hide profile fields which are blocked by a capability

This commit is contained in:
Ginger 2025-09-18 12:29:51 -04:00
parent aafd028af4
commit 317cd366c3
No known key found for this signature in database
3 changed files with 108 additions and 91 deletions

View file

@ -3,7 +3,6 @@ import React, {
FormEventHandler, FormEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -53,10 +52,10 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
import { import {
ExtendedProfile, ExtendedProfile,
profileEditsAllowed,
useExtendedProfile, useExtendedProfile,
useProfileEditsAllowed,
} from '../../../hooks/useExtendedProfile'; } from '../../../hooks/useExtendedProfile';
import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext'; import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { FilterByValues } from '../../../../types/utils'; import { FilterByValues } from '../../../../types/utils';
import { CutoutCard } from '../../../components/cutout-card'; import { CutoutCard } from '../../../components/cutout-card';
@ -66,15 +65,21 @@ import { useUserProfile } from '../../../hooks/useUserProfile';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { withSearchParam } from '../../../pages/pathUtils'; 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 mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { busy, value, setValue } = useProfileField('avatar_url');
const avatarUrl = value const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const disabled = !useProfileEditsAllowed('avatar_url') || busy; const disabled = busy;
const [imageFile, setImageFile] = useState<File>(); const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile); const imageFileURL = useObjectURL(imageFile);
@ -172,17 +177,14 @@ function ProfileAvatar() {
); );
} }
type ProfileTextFieldProps<K> = {
field: K;
label: ReactNode;
};
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
field,
label, label,
}: ProfileTextFieldProps<K>) { defaultValue,
const { busy, defaultValue, value, setValue } = useProfileField<K>(field); value,
const disabled = !useProfileEditsAllowed(field) || busy; setValue,
busy,
}: ProfileFieldElementProps<K, FieldContext> & { label: string }) {
const disabled = busy;
const hasChanges = defaultValue !== value; const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
@ -240,9 +242,12 @@ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string
); );
} }
function ProfilePronouns() { function ProfilePronouns({
const { busy, value, setValue } = useProfileField('io.fsky.nyx.pronouns'); value,
const disabled = !useProfileEditsAllowed('io.fsky.nyx.pronouns') || busy; setValue,
busy,
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
const disabled = busy;
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState(''); const [pendingPronoun, setPendingPronoun] = useState('');
@ -364,9 +369,12 @@ function ProfilePronouns() {
); );
} }
function ProfileTimezone() { function ProfileTimezone({
const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz'); value,
const disabled = !useProfileEditsAllowed('us.cloke.msc4175.tz') || busy; setValue,
busy,
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
const disabled = busy;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -556,12 +564,26 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut
); );
} }
const LEGACY_FIELD_ELEMENTS = {
avatar_url: ProfileAvatar,
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
<ProfileTextField label="Display Name" {...props} />
),
};
const EXTENDED_FIELD_ELEMENTS = {
'io.fsky.nyx.pronouns': ProfilePronouns,
'us.cloke.msc4175.tz': ProfileTimezone,
};
export function Profile() { export function Profile() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId() as string; const userId = mx.getUserId() as string;
const server = getMxIdServer(userId); const server = getMxIdServer(userId);
const authMetadata = useAuthMetadata(); const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions(); const accountManagementActions = useAccountManagementActions();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfileSupported = extendedProfile !== null; const extendedProfileSupported = extendedProfile !== null;
@ -570,7 +592,14 @@ export function Profile() {
const profileEditableThroughIDP = const profileEditableThroughIDP =
authMetadata !== undefined && authMetadata !== undefined &&
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); 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<ExtendedProfile>({ const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
displayname: legacyProfile.displayName, displayname: legacyProfile.displayName,
@ -582,8 +611,6 @@ export function Profile() {
} }
}, [setFieldDefaults, extendedProfile]); }, [setFieldDefaults, extendedProfile]);
const useAuthentication = useMediaAuthentication();
const [saveState, handleSave] = useAsyncCallback( const [saveState, handleSave] = useAsyncCallback(
useCallback( useCallback(
async (fields: ExtendedProfile) => { async (fields: ExtendedProfile) => {
@ -631,8 +658,12 @@ export function Profile() {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<ProfileFieldContextProvider fieldDefaults={fieldDefaults} save={handleSave} busy={busy}> <ProfileFieldContext
{(save, reset, hasChanges, fields) => { fieldDefaults={fieldDefaults}
fieldElements={fieldElementConstructors}
context={{ busy }}
>
{(reset, hasChanges, fields, fieldElements) => {
const heroAvatarUrl = const heroAvatarUrl =
(fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
undefined; undefined;
@ -669,14 +700,7 @@ export function Profile() {
{profileEditableThroughClient && ( {profileEditableThroughClient && (
<> <>
<Box gap="300" direction="Column"> <Box gap="300" direction="Column">
<ProfileAvatar /> {fieldElements}
<ProfileTextField field="displayname" label="Display Name" />
{extendedProfileSupported && (
<>
<ProfilePronouns />
<ProfileTimezone />
</>
)}
</Box> </Box>
<Box gap="300" alignItems="Center"> <Box gap="300" alignItems="Center">
<Button <Button
@ -687,7 +711,7 @@ export function Profile() {
outlined outlined
radii="300" radii="300"
disabled={!hasChanges || busy} disabled={!hasChanges || busy}
onClick={save} onClick={() => handleSave(fields)}
> >
<Text size="B300">Save</Text> <Text size="B300">Save</Text>
</Button> </Button>
@ -727,7 +751,7 @@ export function Profile() {
</> </>
); );
}} }}
</ProfileFieldContextProvider> </ProfileFieldContext>
</SequenceCard> </SequenceCard>
</Box> </Box>
); );

View file

@ -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'; import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
const ProfileFieldContext = createContext<{ type ExtendedProfileKeys = keyof {
busy: boolean; [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]
fieldDefaults: ExtendedProfile; }
fields: ExtendedProfile;
setField: (key: string, value: unknown) => void;
} | null>(null);
export type ProfileFieldContextProviderProps = { type ProfileFieldElementRawProps<V, C> = {
defaultValue: V,
value: V,
setValue: (value: V) => void,
} & C
export type ProfileFieldElementProps<K extends ExtendedProfileKeys, C> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
type ProfileFieldElements<C> = {
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
}
type ProfileFieldContextProps<C> = {
fieldDefaults: ExtendedProfile; fieldDefaults: ExtendedProfile;
save: (fields: ExtendedProfile) => void; fieldElements: ProfileFieldElements<C>;
busy: boolean; children: (reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode) => ReactNode;
children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode; context: C;
}; };
export function ProfileFieldContextProvider({ export function ProfileFieldContext<C>({
fieldDefaults, fieldDefaults,
save, fieldElements: fieldElementConstructors,
busy,
children, children,
}: ProfileFieldContextProviderProps) { context
}: ProfileFieldContextProps<C>): ReactNode {
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults); const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
const reset = useCallback(() => { const reset = useCallback(() => {
@ -41,35 +50,28 @@ export function ProfileFieldContextProvider({
[fields] [fields]
); );
const providerValue = useMemo(
() => ({ busy, fieldDefaults, fields, setField }),
[busy, fieldDefaults, fields, setField]
);
const hasChanges = useMemo( const hasChanges = useMemo(
() => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined, () => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined,
[fields, fieldDefaults] [fields, fieldDefaults]
); );
return ( const createElement = useCallback(<K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
<ProfileFieldContext.Provider value={providerValue}> const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
{children(() => save(fields), reset, hasChanges, fields)} ...context,
</ProfileFieldContext.Provider> 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<K extends keyof ExtendedProfile>(field: K): { busy: boolean, defaultValue: ExtendedProfile[K], value: ExtendedProfile[K], setValue: (value: ExtendedProfile[K]) => void } { return children(reset, hasChanges, fields, fieldElements);
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);
},
};
} }

View file

@ -1,9 +1,9 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import z from 'zod'; import z from 'zod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Capabilities } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useSpecVersions } from './useSpecVersions'; import { useSpecVersions } from './useSpecVersions';
import { useCapabilities } from './useCapabilities';
import { IProfileFieldsCapability } from '../../types/matrix/common'; import { IProfileFieldsCapability } from '../../types/matrix/common';
const extendedProfile = z.looseObject({ const extendedProfile = z.looseObject({
@ -54,11 +54,12 @@ export function useExtendedProfile(
const LEGACY_FIELDS = ['displayname', 'avatar_url']; const LEGACY_FIELDS = ['displayname', 'avatar_url'];
export function useProfileEditsAllowed(field: string | null): boolean { export function profileEditsAllowed(
const capabilities = useCapabilities(); field: string,
const extendedProfileSupported = useExtendedProfileSupported(); capabilities: Capabilities,
extendedProfileSupported: boolean
if (field && LEGACY_FIELDS.includes(field)) { ): boolean {
if (LEGACY_FIELDS.includes(field)) {
// this field might have a pre-msc4133 capability. check that first // this field might have a pre-msc4133 capability. check that first
if (capabilities[`m.set_${field}`]?.enabled === false) { if (capabilities[`m.set_${field}`]?.enabled === false) {
return false; return false;
@ -86,11 +87,6 @@ export function useProfileEditsAllowed(field: string | null): boolean {
return false; return false;
} }
if (field === null) {
// profile field modifications are not completely disabled
return true;
}
if ( if (
extendedProfileCapability.allowed !== undefined && extendedProfileCapability.allowed !== undefined &&
!extendedProfileCapability.allowed.includes(field) !extendedProfileCapability.allowed.includes(field)
@ -108,11 +104,6 @@ export function useProfileEditsAllowed(field: string | null): boolean {
return true; 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 // `field` is an extended profile key and the homeserver lacks msc4133 support
return false; return false;
} }