Fix support for MSC4133-less homeservers, add OIDC profile link

This commit is contained in:
Ginger 2025-09-18 10:20:52 -04:00
parent 984803c52c
commit aafd028af4
No known key found for this signature in database
2 changed files with 165 additions and 64 deletions

View file

@ -36,7 +36,7 @@ import {
Line, Line,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { UserEvent } from 'matrix-js-sdk'; import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@ -54,7 +54,7 @@ import { UserHero, UserHeroName } from '../../../components/user-profile/UserHer
import { import {
ExtendedProfile, ExtendedProfile,
useExtendedProfile, useExtendedProfile,
useProfileFieldAllowed, useProfileEditsAllowed,
} from '../../../hooks/useExtendedProfile'; } from '../../../hooks/useExtendedProfile';
import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext'; import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@ -62,6 +62,10 @@ import { FilterByValues } from '../../../../types/utils';
import { CutoutCard } from '../../../components/cutout-card'; import { CutoutCard } from '../../../components/cutout-card';
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips'; import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { withSearchParam } from '../../../pages/pathUtils';
function ProfileAvatar() { function ProfileAvatar() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -70,7 +74,7 @@ function ProfileAvatar() {
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 = !useProfileFieldAllowed('avatar_url') || busy; const disabled = !useProfileEditsAllowed('avatar_url') || busy;
const [imageFile, setImageFile] = useState<File>(); const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile); const imageFileURL = useObjectURL(imageFile);
@ -178,7 +182,7 @@ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string
label, label,
}: ProfileTextFieldProps<K>) { }: ProfileTextFieldProps<K>) {
const { busy, defaultValue, value, setValue } = useProfileField<K>(field); const { busy, defaultValue, value, setValue } = useProfileField<K>(field);
const disabled = !useProfileFieldAllowed(field) || busy; const disabled = !useProfileEditsAllowed(field) || busy;
const hasChanges = defaultValue !== value; const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
@ -212,6 +216,7 @@ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string
onChange={handleChange} onChange={handleChange}
variant="Secondary" variant="Secondary"
radii="300" radii="300"
disabled={disabled}
readOnly={disabled} readOnly={disabled}
after={ after={
hasChanges && hasChanges &&
@ -237,7 +242,7 @@ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string
function ProfilePronouns() { function ProfilePronouns() {
const { busy, value, setValue } = useProfileField('io.fsky.nyx.pronouns'); const { busy, value, setValue } = useProfileField('io.fsky.nyx.pronouns');
const disabled = !useProfileFieldAllowed('io.fsky.nyx.pronouns') || busy; const disabled = !useProfileEditsAllowed('io.fsky.nyx.pronouns') || busy;
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState(''); const [pendingPronoun, setPendingPronoun] = useState('');
@ -361,7 +366,7 @@ function ProfilePronouns() {
function ProfileTimezone() { function ProfileTimezone() {
const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz'); const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz');
const disabled = !useProfileFieldAllowed('us.cloke.msc4175.tz') || busy; const disabled = !useProfileEditsAllowed('us.cloke.msc4175.tz') || busy;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -515,45 +520,99 @@ function ProfileTimezone() {
); );
} }
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
const accountManagementActions = useAccountManagementActions();
const openProviderProfileSettings = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.profile,
}),
'_blank'
);
}, [authMetadata, accountManagementActions]);
return (
<CutoutCard style={{ padding: config.space.S200 }} variant="Surface">
<SettingTile
description="Change profile settings in your homeserver's account dashboard."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={openProviderProfileSettings}
>
<Text size="B300">Open</Text>
</Button>
}
/>
</CutoutCard>
);
}
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 accountManagementActions = useAccountManagementActions();
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfileSupported = extendedProfile !== null;
const legacyProfile = useUserProfile(userId);
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({}); const profileEditableThroughIDP =
authMetadata !== undefined &&
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
const profileEditableThroughClient = useProfileEditsAllowed(null);
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
displayname: legacyProfile.displayName,
avatar_url: legacyProfile.avatarUrl,
});
useLayoutEffect(() => { useLayoutEffect(() => {
if (extendedProfile !== undefined) { if (extendedProfile) {
setFieldDefaults(extendedProfile); setFieldDefaults(extendedProfile);
} }
}, [userId, setFieldDefaults, extendedProfile]); }, [setFieldDefaults, extendedProfile]);
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [saveState, handleSave] = useAsyncCallback( const [saveState, handleSave] = useAsyncCallback(
useCallback( useCallback(
async (fields: ExtendedProfile) => { async (fields: ExtendedProfile) => {
await Promise.all( if (extendedProfileSupported) {
Object.entries(fields).map(async ([key, value]) => { await Promise.all(
if (value === undefined) { Object.entries(fields).map(async ([key, value]) => {
await mx.deleteExtendedProfileProperty(key); if (value === undefined) {
} else { await mx.deleteExtendedProfileProperty(key);
await mx.setExtendedProfileProperty(key, value); } else {
} await mx.setExtendedProfileProperty(key, value);
}) }
); })
await refreshExtendedProfile(); );
// XXX: synthesise a profile update for ourselves because Synapse is broken and won't await refreshExtendedProfile();
const user = mx.getUser(userId); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
if (user) { const user = mx.getUser(userId);
user.displayName = fields.displayname; if (user) {
user.avatarUrl = fields.avatar_url; user.displayName = fields.displayname;
user.emit(UserEvent.DisplayName, user.events.presence, user); user.avatarUrl = fields.avatar_url;
user.emit(UserEvent.AvatarUrl, user.events.presence, user); user.emit(UserEvent.DisplayName, user.events.presence, user);
user.emit(UserEvent.AvatarUrl, user.events.presence, user);
}
} else {
await mx.setDisplayName(fields.displayname ?? '');
await mx.setAvatarUrl(fields.avatar_url ?? '');
setFieldDefaults(fields);
} }
}, },
[mx, userId, refreshExtendedProfile] [mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults]
) )
); );
@ -604,39 +663,66 @@ export function Profile() {
gap="400" gap="400"
radii="0" radii="0"
> >
<Box gap="300" direction='Column'> {profileEditableThroughIDP && (
<ProfileAvatar /> <IdentityProviderSettings authMetadata={authMetadata} />
<ProfileTextField field="displayname" label="Display Name" /> )}
<ProfilePronouns /> {profileEditableThroughClient && (
<ProfileTimezone /> <>
</Box> <Box gap="300" direction="Column">
<Box gap="300" alignItems="Center"> <ProfileAvatar />
<Button <ProfileTextField field="displayname" label="Display Name" />
type="submit" {extendedProfileSupported && (
size="300" <>
variant={!busy && hasChanges ? 'Success' : 'Secondary'} <ProfilePronouns />
fill={!busy && hasChanges ? 'Solid' : 'Soft'} <ProfileTimezone />
outlined </>
radii="300" )}
disabled={!hasChanges || busy} </Box>
onClick={save} <Box gap="300" alignItems="Center">
> <Button
<Text size="B300">Save</Text> type="submit"
</Button> size="300"
<Button variant={!busy && hasChanges ? 'Success' : 'Secondary'}
type="reset" fill={!busy && hasChanges ? 'Solid' : 'Soft'}
size="300" outlined
variant="Secondary" radii="300"
fill="Soft" disabled={!hasChanges || busy}
outlined onClick={save}
radii="300" >
onClick={reset} <Text size="B300">Save</Text>
disabled={!hasChanges || busy} </Button>
> <Button
<Text size="B300">Cancel</Text> type="reset"
</Button> size="300"
{saving && <Spinner size="300" />} variant="Secondary"
</Box> fill="Soft"
outlined
radii="300"
onClick={reset}
disabled={!hasChanges || busy}
>
<Text size="B300">Cancel</Text>
</Button>
{saving && <Spinner size="300" />}
</Box>
</>
)}
{!(profileEditableThroughClient || profileEditableThroughIDP) && (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Profile Editing Disabled</Text>
</Box>
<Box direction="Column">
<Text size="T200">
Your homeserver does not allow you to edit your profile.
</Text>
</Box>
</Box>
</SettingTile>
</CutoutCard>
)}
</SequenceCard> </SequenceCard>
</> </>
); );

View file

@ -30,7 +30,7 @@ export function useExtendedProfileSupported(): boolean {
export function useExtendedProfile( export function useExtendedProfile(
userId: string userId: string
): [ExtendedProfile | undefined, () => Promise<void>] { ): [ExtendedProfile | undefined | null, () => Promise<void>] {
const mx = useMatrixClient(); const mx = useMatrixClient();
const extendedProfileSupported = useExtendedProfileSupported(); const extendedProfileSupported = useExtendedProfileSupported();
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
@ -39,7 +39,7 @@ export function useExtendedProfile(
if (extendedProfileSupported) { if (extendedProfileSupported) {
return extendedProfile.parse(await mx.getExtendedProfile(userId)); return extendedProfile.parse(await mx.getExtendedProfile(userId));
} }
return undefined; return null;
}, [mx, userId, extendedProfileSupported]), }, [mx, userId, extendedProfileSupported]),
refetchOnMount: false, refetchOnMount: false,
}); });
@ -54,15 +54,20 @@ export function useExtendedProfile(
const LEGACY_FIELDS = ['displayname', 'avatar_url']; const LEGACY_FIELDS = ['displayname', 'avatar_url'];
export function useProfileFieldAllowed(field: string): boolean { export function useProfileEditsAllowed(field: string | null): boolean {
const capabilities = useCapabilities(); const capabilities = useCapabilities();
const extendedProfileSupported = useExtendedProfileSupported(); const extendedProfileSupported = useExtendedProfileSupported();
if (LEGACY_FIELDS.includes(field)) { if (field && 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;
} }
if (!extendedProfileSupported) {
// the homeserver only supports legacy fields
return true;
}
} }
if (extendedProfileSupported) { if (extendedProfileSupported) {
@ -81,6 +86,11 @@ export function useProfileFieldAllowed(field: string): 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)
@ -98,6 +108,11 @@ export function useProfileFieldAllowed(field: string): 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;
} }