Rework profile settings to show a preview and support more fields

This commit is contained in:
Ginger 2025-09-15 10:47:21 -04:00
parent 7f40605bfe
commit 3c1aa0e699
No known key found for this signature in database
9 changed files with 362 additions and 159 deletions

12
package-lock.json generated
View file

@ -62,7 +62,8 @@
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"ua-parser-js": "1.0.35"
"ua-parser-js": "1.0.35",
"zod": "4.1.8"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
@ -12119,6 +12120,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -73,7 +73,8 @@
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"ua-parser-js": "1.0.35"
"ua-parser-js": "1.0.35",
"zod": "4.1.8"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",

View file

@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { extendedProfileFields } from '../../hooks/useExtendedProfile';
type UserHeroProps = {
userId: string;
@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = {
displayName?: string;
userId: string;
extendedProfile?: extendedProfileFields;
};
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
return (
<Box grow="Yes" direction="Column" gap="0">
@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
{displayName ?? username ?? userId}
</Text>
</Box>
<Box alignItems="Center" gap="100" wrap="Wrap">
<Box alignItems="Start" gap="100" wrap="Wrap" direction='Column'>
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
{pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
</Text>
</Box>
</Box>

View file

@ -1,5 +1,5 @@
import { Box, Button, config, Icon, Icons, Text } from 'folds';
import React from 'react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
@ -22,6 +22,8 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
type UserRoomProfileProps = {
userId: string;
@ -56,9 +58,15 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined;
const presence = useUserPresence(userId);
useEffect(() => {
refreshExtendedProfile();
}, [refreshExtendedProfile]);
const handleMessage = () => {
closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = {
@ -77,7 +85,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Start">
<UserHeroName displayName={displayName} userId={userId} />
<UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile} />
{userId !== myUserId && (
<Box shrink="No">
<Button

View file

@ -1,11 +1,4 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { ChangeEventHandler, ReactNode, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
@ -13,28 +6,21 @@ import {
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { UserEvent } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
@ -42,23 +28,24 @@ import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
import {
ExtendedProfile,
useExtendedProfile,
useProfileFieldAllowed,
} from '../../../hooks/useExtendedProfile';
import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { FilterByValues } from '../../../../types/utils';
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
function ProfileAvatar() {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
const { busy, value, setValue } = useProfileField('avatar_url');
const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const disabled = !useProfileFieldAllowed('avatar_url') || busy;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
@ -76,34 +63,18 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
setValue(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
[setValue, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
setValue('');
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
<SettingTile>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
@ -121,9 +92,9 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
disabled={disabled}
>
<Text size="B300">Upload</Text>
<Text size="B300">Upload Avatar</Text>
</Button>
{avatarUrl && (
<Button
@ -131,10 +102,10 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
disabled={disabled}
onClick={handleRemoveAvatar}
>
<Text size="B300">Remove</Text>
<Text size="B300">Remove Avatar</Text>
</Button>
)}
</Box>
@ -162,116 +133,54 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
type ProfileTextFieldProps<K> = {
field: K;
label: ReactNode;
};
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
field,
label,
}: ProfileTextFieldProps<K>) {
const { busy, defaultValue, value, setValue } = useProfileField<K>(field);
const disabled = !useProfileFieldAllowed(field) || busy;
const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
setValue(evt.currentTarget.value);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
setValue(defaultValue);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
{label}
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box gap="200" aria-disabled={disabled}>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
value={value ?? ''}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
readOnly={disabled}
after={
hasChanges &&
!changingDisplayName && (
!busy && (
<IconButton
type="reset"
onClick={handleReset}
@ -285,18 +194,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
@ -305,20 +202,113 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const userId = mx.getUserId() as string;
const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfile =
extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined;
const fieldDefaults = useMemo<ExtendedProfile>(
() =>
extendedProfile !== undefined
? {
...extendedProfile,
displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId,
}
: {},
[userId, extendedProfile]
);
const useAuthentication = useMediaAuthentication();
const [saveState, handleSave] = useAsyncCallback(
useCallback(
async (fields: ExtendedProfile) => {
await Promise.all(
Object.entries(fields).map(async ([key, value]) => {
if (value !== undefined) {
await mx.setExtendedProfileProperty(key, value);
}
})
);
await refreshExtendedProfile();
// XXX: synthesise a profile update for ourselves because Synapse is broken and won't
const user = mx.getUser(userId);
if (user) {
user.displayName = fields.displayname;
user.avatarUrl = fields.avatar_url;
user.emit(UserEvent.DisplayName, user.events.presence, user);
user.emit(UserEvent.AvatarUrl, user.events.presence, user);
}
},
[mx, userId, refreshExtendedProfile]
)
);
const saving = saveState.status === AsyncStatus.Loading;
const loadingExtendedProfile = extendedProfileState.status === AsyncStatus.Loading;
const busy = saving || loadingExtendedProfile;
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
style={{
overflow: 'hidden',
}}
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
<ProfileFieldContextProvider fieldDefaults={fieldDefaults} save={handleSave} busy={busy}>
{(save, reset, hasChanges, fields) => {
const heroAvatarUrl =
(fields.avatar_url &&
mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
undefined;
return (
<>
<UserHero userId={userId} avatarUrl={heroAvatarUrl} />
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Box gap="400" alignItems="Start">
<UserHeroName
userId={userId}
displayName={fields.displayname as string}
extendedProfile={fields}
/>
</Box>
<ProfileAvatar />
<ProfileTextField field="displayname" label="Display Name" />
<Box gap="300">
<Button
type="submit"
size="300"
variant={!busy && hasChanges ? 'Success' : 'Secondary'}
fill={!busy && hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || busy}
onClick={save}
>
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
onClick={reset}
disabled={!hasChanges || busy}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</>
);
}}
</ProfileFieldContextProvider>
</SequenceCard>
</Box>
);

View file

@ -0,0 +1,75 @@
import React, { createContext, ReactNode, useCallback, useContext, 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);
export type ProfileFieldContextProviderProps = {
fieldDefaults: ExtendedProfile;
save: (fields: ExtendedProfile) => void;
busy: boolean;
children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode;
};
export function ProfileFieldContextProvider({
fieldDefaults,
save,
busy,
children,
}: ProfileFieldContextProviderProps) {
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
const reset = useCallback(() => {
setFields(fieldDefaults);
}, [fieldDefaults]);
useEffect(() => {
reset()
}, [reset]);
const setField = useCallback(
(key: string, value: unknown) => {
setFields({
...fields,
[key]: value,
});
},
[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 (
<ProfileFieldContext.Provider value={providerValue}>
{children(() => save(fields), reset, hasChanges, fields)}
</ProfileFieldContext.Provider>
);
}
export function useProfileField<K extends keyof ExtendedProfile>(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);
},
};
}

View file

@ -0,0 +1,103 @@
import { useCallback, useEffect } from 'react';
import z from 'zod';
import { AsyncCallback, AsyncState, useAsyncCallback } from './useAsyncCallback';
import { useMatrixClient } from './useMatrixClient';
import { useSpecVersions } from './useSpecVersions';
import { useCapabilities } from './useCapabilities';
import { IProfileFieldsCapability } from '../../types/matrix/common';
const extendedProfile = z.looseObject({
displayname: z.string().optional(),
avatar_url: z.string().optional(),
'io.fsky.nyx.pronouns': z
.object({
language: z.string(),
summary: z.string(),
})
.array()
.optional()
.catch(undefined),
'us.cloke.msc4175.tz': z.string().optional().catch(undefined),
});
export type ExtendedProfile = z.infer<typeof extendedProfile>;
export function useExtendedProfileSupported(): boolean {
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15');
}
export function useExtendedProfile(
userId: string
): [
AsyncState<ExtendedProfile | undefined, unknown>,
AsyncCallback<[], ExtendedProfile | undefined>
] {
const mx = useMatrixClient();
const extendedProfileSupported = useExtendedProfileSupported();
const [extendedProfileData, refresh] = useAsyncCallback(
useCallback(async () => {
if (extendedProfileSupported) {
return extendedProfile.parse(await mx.getExtendedProfile(userId));
}
return undefined;
}, [mx, userId, extendedProfileSupported])
);
useEffect(() => {
refresh();
}, [refresh]);
return [extendedProfileData, refresh];
}
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
export function useProfileFieldAllowed(field: string): boolean {
const capabilities = useCapabilities();
const extendedProfileSupported = useExtendedProfileSupported();
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;
}
}
if (extendedProfileSupported) {
// the homeserver has msc4133 support
const extendedProfileCapability = capabilities[
'uk.tcpip.msc4133.profile_fields'
] as IProfileFieldsCapability;
if (extendedProfileCapability === undefined) {
// the capability is missing, assume modification is allowed
return true;
}
if (!extendedProfileCapability.enabled) {
// the capability is set to disable profile modifications
return false;
}
if (
extendedProfileCapability.allowed !== undefined &&
!extendedProfileCapability.allowed.includes(field)
) {
// the capability includes an allowlist and `field` isn't in it
return false;
}
if (extendedProfileCapability.disallowed?.includes(field)) {
// the capability includes an blocklist and `field` is in it
return false;
}
// the capability is enabled and `field` isn't blocked
return true;
}
// `field` is an extended profile key and the homeserver lacks msc4133 support
return false;
}

View file

@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
'page.codeberg.everypizza.msc4193.spoiler.reason';
export type IImageInfo = {
w?: number;
@ -88,3 +89,9 @@ export type ILocationContent = {
geo_uri?: string;
info?: IThumbnailContent;
};
export type IProfileFieldsCapability = {
enabled?: boolean;
allowed?: string[];
disallowed?: string[];
};

View file

@ -1,3 +1,8 @@
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
// Represents a subset of T containing only the keys whose values extend V
export type FilterByValues<T extends object, V> = {
[Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
};