mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 14:30:29 +03:00
Rework profile settings to show a preview and support more fields
This commit is contained in:
parent
7f40605bfe
commit
3c1aa0e699
9 changed files with 362 additions and 159 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
75
src/app/features/settings/account/ProfileFieldContext.tsx
Normal file
75
src/app/features/settings/account/ProfileFieldContext.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
103
src/app/hooks/useExtendedProfile.ts
Normal file
103
src/app/hooks/useExtendedProfile.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue