mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 06:50:28 +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-dom": "0.112.2",
|
||||||
"slate-history": "0.110.3",
|
"slate-history": "0.110.3",
|
||||||
"slate-react": "0.112.1",
|
"slate-react": "0.112.1",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35",
|
||||||
|
"zod": "4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
|
@ -12119,6 +12120,15 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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-dom": "0.112.2",
|
||||||
"slate-history": "0.110.3",
|
"slate-history": "0.110.3",
|
||||||
"slate-react": "0.112.1",
|
"slate-react": "0.112.1",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35",
|
||||||
|
"zod": "4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
import { ImageViewer } from '../image-viewer';
|
import { ImageViewer } from '../image-viewer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { extendedProfileFields } from '../../hooks/useExtendedProfile';
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||||
type UserHeroNameProps = {
|
type UserHeroNameProps = {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
extendedProfile?: extendedProfileFields;
|
||||||
};
|
};
|
||||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
|
const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="0">
|
<Box grow="Yes" direction="Column" gap="0">
|
||||||
|
|
@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||||
{displayName ?? username ?? userId}
|
{displayName ?? username ?? userId}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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}>
|
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
||||||
@{username}
|
@{username}
|
||||||
|
{pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { UserHero, UserHeroName } from './UserHero';
|
import { UserHero, UserHeroName } from './UserHero';
|
||||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
|
@ -22,6 +22,8 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||||
import { CreatorChip } from './CreatorChip';
|
import { CreatorChip } from './CreatorChip';
|
||||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||||
|
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||||
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
|
|
||||||
type UserRoomProfileProps = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -56,9 +58,15 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||||
const displayName = getMemberDisplayName(room, userId);
|
const displayName = getMemberDisplayName(room, userId);
|
||||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
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);
|
const presence = useUserPresence(userId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshExtendedProfile();
|
||||||
|
}, [refreshExtendedProfile]);
|
||||||
|
|
||||||
const handleMessage = () => {
|
const handleMessage = () => {
|
||||||
closeUserRoomProfile();
|
closeUserRoomProfile();
|
||||||
const directSearchParam: DirectCreateSearchParams = {
|
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="500" style={{ padding: config.space.S400 }}>
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Box gap="400" alignItems="Start">
|
<Box gap="400" alignItems="Start">
|
||||||
<UserHeroName displayName={displayName} userId={userId} />
|
<UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile} />
|
||||||
{userId !== myUserId && (
|
{userId !== myUserId && (
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import React, {
|
import React, { ChangeEventHandler, ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
ChangeEventHandler,
|
|
||||||
FormEventHandler,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -13,28 +6,21 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Input,
|
Input,
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
Modal,
|
Modal,
|
||||||
Dialog,
|
|
||||||
Header,
|
|
||||||
config,
|
config,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { UserEvent } from 'matrix-js-sdk';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { nameInitials } from '../../../utils/common';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
|
@ -42,23 +28,24 @@ import { ImageEditor } from '../../../components/image-editor';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
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 = {
|
function ProfileAvatar() {
|
||||||
profile: UserProfile;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const capabilities = useCapabilities();
|
const { busy, value, setValue } = useProfileField('avatar_url');
|
||||||
const [alertRemove, setAlertRemove] = useState(false);
|
const avatarUrl = value
|
||||||
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
|
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
|
|
||||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
|
||||||
const avatarUrl = profile.avatarUrl
|
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const disabled = !useProfileFieldAllowed('avatar_url') || busy;
|
||||||
|
|
||||||
const [imageFile, setImageFile] = useState<File>();
|
const [imageFile, setImageFile] = useState<File>();
|
||||||
const imageFileURL = useObjectURL(imageFile);
|
const imageFileURL = useObjectURL(imageFile);
|
||||||
|
|
@ -76,34 +63,18 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||||
const handleUploaded = useCallback(
|
const handleUploaded = useCallback(
|
||||||
(upload: UploadSuccess) => {
|
(upload: UploadSuccess) => {
|
||||||
const { mxc } = upload;
|
const { mxc } = upload;
|
||||||
mx.setAvatarUrl(mxc);
|
setValue(mxc);
|
||||||
handleRemoveUpload();
|
handleRemoveUpload();
|
||||||
},
|
},
|
||||||
[mx, handleRemoveUpload]
|
[setValue, handleRemoveUpload]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveAvatar = () => {
|
const handleRemoveAvatar = () => {
|
||||||
mx.setAvatarUrl('');
|
setValue('');
|
||||||
setAlertRemove(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<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>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{uploadAtom ? (
|
{uploadAtom ? (
|
||||||
<Box gap="200" direction="Column">
|
<Box gap="200" direction="Column">
|
||||||
<CompactUploadCardRenderer
|
<CompactUploadCardRenderer
|
||||||
|
|
@ -121,9 +92,9 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
outlined
|
outlined
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableSetAvatar}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Text size="B300">Upload</Text>
|
<Text size="B300">Upload Avatar</Text>
|
||||||
</Button>
|
</Button>
|
||||||
{avatarUrl && (
|
{avatarUrl && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -131,10 +102,10 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableSetAvatar}
|
disabled={disabled}
|
||||||
onClick={() => setAlertRemove(true)}
|
onClick={handleRemoveAvatar}
|
||||||
>
|
>
|
||||||
<Text size="B300">Remove</Text>
|
<Text size="B300">Remove Avatar</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -162,116 +133,54 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</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>
|
</SettingTile>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
type ProfileTextFieldProps<K> = {
|
||||||
const mx = useMatrixClient();
|
field: K;
|
||||||
const capabilities = useCapabilities();
|
label: ReactNode;
|
||||||
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
|
};
|
||||||
|
|
||||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
||||||
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
|
field,
|
||||||
|
label,
|
||||||
const [changeState, changeDisplayName] = useAsyncCallback(
|
}: ProfileTextFieldProps<K>) {
|
||||||
useCallback((name: string) => mx.setDisplayName(name), [mx])
|
const { busy, defaultValue, value, setValue } = useProfileField<K>(field);
|
||||||
);
|
const disabled = !useProfileFieldAllowed(field) || busy;
|
||||||
const changingDisplayName = changeState.status === AsyncStatus.Loading;
|
const hasChanges = defaultValue !== value;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayName(defaultDisplayName);
|
|
||||||
}, [defaultDisplayName]);
|
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
const name = evt.currentTarget.value;
|
setValue(evt.currentTarget.value);
|
||||||
setDisplayName(name);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
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 (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title={
|
title={
|
||||||
<Text as="span" size="L400">
|
<Text as="span" size="L400">
|
||||||
Display Name
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box direction="Column" grow="Yes" gap="100">
|
<Box direction="Column" grow="Yes" gap="100">
|
||||||
<Box
|
<Box gap="200" aria-disabled={disabled}>
|
||||||
as="form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
gap="200"
|
|
||||||
aria-disabled={changingDisplayName || disableSetDisplayname}
|
|
||||||
>
|
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
name="displayNameInput"
|
name="displayNameInput"
|
||||||
value={displayName}
|
value={value ?? ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={{ paddingRight: config.space.S200 }}
|
style={{ paddingRight: config.space.S200 }}
|
||||||
readOnly={changingDisplayName || disableSetDisplayname}
|
readOnly={disabled}
|
||||||
after={
|
after={
|
||||||
hasChanges &&
|
hasChanges &&
|
||||||
!changingDisplayName && (
|
!busy && (
|
||||||
<IconButton
|
<IconButton
|
||||||
type="reset"
|
type="reset"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
|
@ -285,18 +194,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
</SettingTile>
|
</SettingTile>
|
||||||
|
|
@ -305,20 +202,113 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId() as string;
|
||||||
const profile = useUserProfile(userId);
|
|
||||||
|
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 (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Profile</Text>
|
<Text size="L400">Profile</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ProfileAvatar userId={userId} profile={profile} />
|
<ProfileFieldContextProvider fieldDefaults={fieldDefaults} save={handleSave} busy={busy}>
|
||||||
<ProfileDisplayName userId={userId} profile={profile} />
|
{(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>
|
</SequenceCard>
|
||||||
</Box>
|
</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_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
|
||||||
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
|
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 = {
|
export type IImageInfo = {
|
||||||
w?: number;
|
w?: number;
|
||||||
|
|
@ -88,3 +89,9 @@ export type ILocationContent = {
|
||||||
geo_uri?: string;
|
geo_uri?: string;
|
||||||
info?: IThumbnailContent;
|
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 & {
|
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
|
||||||
[Property in Key]-?: Type[Property];
|
[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