This commit is contained in:
Ginger 2025-10-16 16:46:47 +02:00 committed by GitHub
commit 97891864e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1734 additions and 868 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",
@ -12112,6 +12113,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

@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
export type AccountDataDeleteCallback = (type: string) => Promise<void>;
type AccountDataInfo = {
type: string;
@ -83,8 +84,7 @@ function AccountDataEdit({
if (
!typeStr ||
parsedContent === null ||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
parsedContent === null
) {
return;
}
@ -121,7 +121,7 @@ function AccountDataEdit({
aria-disabled={submitting}
>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<Text size="L400">Field Name</Text>
<Box gap="300">
<Box grow="Yes" direction="Column">
<Input
@ -195,9 +195,22 @@ function AccountDataEdit({
type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
requestClose: () => void;
onEdit?: () => void;
submitDelete?: AccountDataDeleteCallback;
};
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) {
const [deleteState, deleteCallback] = useAsyncCallback<void, MatrixError, []>(useCallback(
async () => {
if (submitDelete !== undefined) {
await submitDelete(type);
requestClose();
}
},
[type, submitDelete, requestClose],
));
const deleting = deleteState.status === AsyncStatus.Loading;
return (
<Box
direction="Column"
@ -208,7 +221,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
>
<Box shrink="No" gap="300" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<Text size="L400">Field Name</Text>
<Input
variant="SurfaceVariant"
size="400"
@ -218,9 +231,23 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
required
/>
</Box>
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
{onEdit && (
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
)}
{submitDelete && (
<Button
variant="Critical"
size="400"
radii="300"
disabled={deleting}
before={deleting && <Spinner variant="Critical" fill="Solid" size="300" />}
onClick={deleteCallback}
>
<Text size="B400">Delete</Text>
</Button>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text>
@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
content?: unknown;
submitChange?: AccountDataSubmitCallback;
submitDelete?: AccountDataDeleteCallback;
requestClose: () => void;
};
@ -252,6 +280,7 @@ export function AccountDataEditor({
type,
content,
submitChange,
submitDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
@ -301,7 +330,7 @@ export function AccountDataEditor({
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
{edit ? (
{(edit && submitChange) ? (
<AccountDataEdit
type={data.type}
defaultContent={contentJSONStr}
@ -313,7 +342,9 @@ export function AccountDataEditor({
<AccountDataView
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
requestClose={requestClose}
onEdit={submitChange ? () => setEdit(true) : undefined}
submitDelete={submitDelete}
/>
)}
</Box>

View file

@ -0,0 +1,54 @@
import React, { ReactNode } from 'react';
import { Button, Icon, Icons, Text } from 'folds';
import { SequenceCard } from './sequence-card';
import { SequenceCardStyle } from '../features/settings/styles.css';
import { SettingTile } from './setting-tile';
type CollapsibleCardProps = {
expand: boolean;
setExpand: (expand: boolean) => void;
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
children?: ReactNode;
};
export function CollapsibleCard({
expand,
setExpand,
title,
description,
before,
children,
}: CollapsibleCardProps) {
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={title}
description={description}
before={before}
after={
<Button
onClick={() => setExpand(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expand && children}
</SequenceCard>
);
}

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
@ -19,6 +19,13 @@ import {
Box,
Scroll,
Avatar,
TooltipProvider,
Tooltip,
Badge,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdServer } from '../../utils/matrix';
@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
import { useInterval } from '../../hooks/useInterval';
import { TextViewer } from '../text-viewer';
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
export function ServerChip({ server }: { server: string }) {
const mx = useMatrixClient();
@ -436,15 +448,24 @@ export function IgnoredUserAlert() {
);
}
export function OptionsChip({ userId }: { userId: string }) {
export function OptionsChip({
userId,
extendedProfile,
}: {
userId: string;
extendedProfile: ExtendedProfile | null;
}) {
const mx = useMatrixClient();
const [cords, setCords] = useState<RectCords>();
const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools');
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
const [profileFieldsOpen, setProfileFieldsOpen] = useState(false);
const [menuCoords, setMenuCoords] = useState<RectCords>();
const openMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCoords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const closeMenu = () => setMenuCoords(undefined);
const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId);
@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) {
const ignoring = ignoreState.status === AsyncStatus.Loading;
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Critical"
fill="None"
size="300"
radii="300"
onClick={() => {
toggleIgnore();
close();
}}
before={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
)}
</Chip>
</PopOut>
<>
{extendedProfile && (
<Overlay open={profileFieldsOpen} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
clickOutsideDeactivates: true,
onDeactivate: () => setProfileFieldsOpen(false),
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<TextViewer
name="Profile Fields"
langName="json"
text={JSON.stringify(extendedProfile, null, 2)}
requestClose={() => setProfileFieldsOpen(false)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<PopOut
anchor={menuCoords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Critical"
fill="None"
size="300"
radii="300"
onClick={() => {
toggleIgnore();
closeMenu();
}}
before={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
</MenuItem>
{extendedProfile && developerToolsEnabled && (
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
setProfileFieldsOpen(true);
closeMenu();
}}
before={<Icon size="50" src={Icons.BlockCode} />}
>
<Text size="B300">View Profile Fields</Text>
</MenuItem>
)}
</div>
</Menu>
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={openMenu} aria-pressed={!!menuCoords}>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
)}
</Chip>
</PopOut>
</>
);
}
export function TimezoneChip({ timezone }: { timezone: string }) {
const shortFormat = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: undefined,
timeStyle: 'short',
timeZone: timezone,
}),
[timezone]
);
const longFormat = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
timeZone: timezone,
}),
[timezone]
);
const [shortTime, setShortTime] = useState(shortFormat.format());
const [longTime, setLongTime] = useState(longFormat.format());
const updateTime = useCallback(() => {
setShortTime(shortFormat.format());
setLongTime(longFormat.format());
}, [setShortTime, setLongTime, shortFormat, longFormat]);
useEffect(() => {
updateTime();
}, [timezone, updateTime]);
useInterval(updateTime, 1000);
return (
<TooltipProvider
position="Top"
offset={5}
align="Center"
tooltip={
<Tooltip variant="SurfaceVariant" style={{ maxWidth: toRem(280) }}>
<Box direction="Column" alignItems="Start" gap="100">
<Box gap="100">
<Text size="L400">Timezone:</Text>
<Badge size="400" variant="Primary">
<Text size="T200">{timezone}</Text>
</Badge>
</Box>
<Text size="T200">{longTime}</Text>
</Box>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="SurfaceVariant"
radii="Pill"
style={{ cursor: 'initial' }}
before={<Icon size="50" src={Icons.RecentClock} />}
>
<Text size="B300" truncate>
{shortTime}
</Text>
</Chip>
)}
</TooltipProvider>
);
}

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 { ExtendedProfile } 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?: ExtendedProfile;
};
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, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
@ -22,6 +22,7 @@ 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';
type UserRoomProfileProps = {
userId: string;
@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const timezone = useMemo(() => {
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[];
const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz'];
if (profileTimezone && supportedTimezones.includes(profileTimezone)) {
return profileTimezone;
}
return undefined;
}, [extendedProfile]);
const presence = useUserPresence(userId);
useEffect(() => {
refreshExtendedProfile();
}, [refreshExtendedProfile]);
const handleMessage = () => {
closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = {
@ -77,7 +93,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 ?? undefined} />
{userId !== myUserId && (
<Box shrink="No">
<Button
@ -96,9 +112,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{timezone && <TimezoneChip timezone={timezone} />}
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} extendedProfile={extendedProfile ?? null} />}
</Box>
</Box>
{ignored && <IgnoredUserAlert />}

View file

@ -30,6 +30,7 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type DeveloperToolsProps = {
requestClose: () => void;
@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
<CollapsibleCard
expand={expandState}
setExpand={setExpandState}
title="Room State"
description="State events of the room."
>
<SettingTile
title="Room State"
description="State events of the room."
after={
<Button
onClick={() => setExpandState(!expandState)}
variant="Secondary"
fill="Soft"
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {roomState.size}</Text>
</Box>
<CutoutCard>
<MenuItem
onClick={() => setComposeEvent({ stateKey: '' })}
variant="Surface"
fill="None"
size="300"
radii="300"
outlined
before={
<Icon
src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expandState && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {roomState.size}</Text>
</Box>
<CutoutCard>
<MenuItem
onClick={() => setComposeEvent({ stateKey: '' })}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(roomState.keys())
.sort()
.map((eventType) => {
const expanded = eventType === expandStateType;
const stateKeyToEvents = roomState.get(eventType);
if (!stateKeyToEvents) return null;
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(roomState.keys())
.sort()
.map((eventType) => {
const expanded = eventType === expandStateType;
const stateKeyToEvents = roomState.get(eventType);
if (!stateKeyToEvents) return null;
return (
<Box id={eventType} key={eventType} direction="Column" gap="100">
<MenuItem
onClick={() =>
setExpandStateType(expanded ? undefined : eventType)
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={
<Icon
size="50"
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
/>
}
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
return (
<Box id={eventType} key={eventType} direction="Column" gap="100">
<MenuItem
onClick={() =>
setExpandStateType(expanded ? undefined : eventType)
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={
<Icon
size="50"
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
/>
}
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
>
<Box grow="Yes">
<Text size="T200" truncate>
{eventType}
</Text>
</Box>
</MenuItem>
{expanded && (
<div
style={{
marginLeft: config.space.S400,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Box grow="Yes">
<Text size="T200" truncate>
{eventType}
</Text>
</Box>
</MenuItem>
{expanded && (
<div
style={{
marginLeft: config.space.S400,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
<MenuItem
onClick={() =>
setComposeEvent({ type: eventType, stateKey: '' })
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<MenuItem
onClick={() =>
setComposeEvent({ type: eventType, stateKey: '' })
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(stateKeyToEvents.keys())
.sort()
.map((stateKey) => (
<MenuItem
onClick={() => {
setOpenStateEvent({
type: eventType,
stateKey,
});
}}
key={stateKey}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
{stateKey ? `"${stateKey}"` : 'Default'}
</Text>
</Box>
</MenuItem>
))}
</div>
)}
</Box>
);
})}
</CutoutCard>
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(stateKeyToEvents.keys())
.sort()
.map((stateKey) => (
<MenuItem
onClick={() => {
setOpenStateEvent({
type: eventType,
stateKey,
});
}}
key={stateKey}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
{stateKey ? `"${stateKey}"` : 'Default'}
</Text>
</Box>
</MenuItem>
))}
</div>
)}
</Box>
);
})}
</CutoutCard>
</Box>
</CollapsibleCard>
<CollapsibleCard
expand={expandAccountData}
setExpand={setExpandAccountData}
title="Account Data"
description="Private personalization data stored within room"
>
<SettingTile
title="Account Data"
description="Private personalization data stored within room."
after={
<Button
onClick={() => setExpandAccountData(!expandAccountData)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon
src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
>
<Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expandAccountData && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountData.size}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => setAccountDataType(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(accountData.keys())
.sort()
.map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => setAccountDataType(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountData.size}</Text>
</Box>
)}
</SequenceCard>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => setAccountDataType(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(accountData.keys())
.sort()
.map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => setAccountDataType(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</CollapsibleCard>
</Box>
)}
</Box>

View file

@ -7,8 +7,6 @@ import {
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Text,
@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { StateEvent } from '../../../../types/matrix/room';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { getMxIdServer } from '../../../utils/matrix';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type RoomPublishedAddressesProps = {
permissions: RoomPermissionsAPI;
@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio
const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
<CollapsibleCard
expand={expand}
setExpand={setExpand}
title="Local Addresses"
description="Set local address so users can join through your homeserver."
>
<SettingTile
title="Local Addresses"
description="Set local address so users can join through your homeserver."
after={
<Button
type="button"
onClick={() => setExpand(!expand)}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
before={
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
}
>
<Text as="span" size="B300" truncate>
{expand ? 'Collapse' : 'Expand'}
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
{localAliasesState.status === AsyncStatus.Loading && (
<Box gap="100">
<Spinner variant="Secondary" size="100" />
<Text size="T200">Loading...</Text>
</Box>
)}
{localAliasesState.status === AsyncStatus.Success &&
(localAliasesState.data.length === 0 ? (
<Box direction="Column" gap="100">
<Text size="L400">No Addresses</Text>
</Box>
) : (
<LocalAddressesList
localAliases={localAliasesState.data}
removeLocalAlias={removeLocalAlias}
canEditCanonical={canEditCanonical}
/>
))}
{localAliasesState.status === AsyncStatus.Error && (
<Box gap="100">
<Text size="T200" style={{ color: color.Critical.Main }}>
{localAliasesState.error.message}
</Text>
</Button>
}
/>
{expand && (
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
{localAliasesState.status === AsyncStatus.Loading && (
<Box gap="100">
<Spinner variant="Secondary" size="100" />
<Text size="T200">Loading...</Text>
</Box>
)}
{localAliasesState.status === AsyncStatus.Success &&
(localAliasesState.data.length === 0 ? (
<Box direction="Column" gap="100">
<Text size="L400">No Addresses</Text>
</Box>
) : (
<LocalAddressesList
localAliases={localAliasesState.data}
removeLocalAlias={removeLocalAlias}
canEditCanonical={canEditCanonical}
/>
))}
{localAliasesState.status === AsyncStatus.Error && (
<Box gap="100">
<Text size="T200" style={{ color: color.Critical.Main }}>
{localAliasesState.error.message}
</Text>
</Box>
)}
</CutoutCard>
)}
</Box>
)}
</CutoutCard>
{expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
</SequenceCard>
</CollapsibleCard>
);
}

View file

@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications';
import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
@ -99,9 +98,8 @@ type SettingsProps = {
export function Settings({ initialPage, requestClose }: SettingsProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const userId = mx.getUserId() as string;
const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<Text size="H4" truncate>

View file

@ -1,324 +1,283 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { Box, Text, Button, config, Spinner, Line } from 'folds';
import { UserEvent, ValidatedAuthMetadata } 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 { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
import {
ExtendedProfile,
profileEditsAllowed,
useExtendedProfile,
} from '../../../hooks/useExtendedProfile';
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
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 { CutoutCard } from '../../../components/cutout-card';
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
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';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { ProfileAvatar } from './fields/ProfileAvatar';
import { ProfileTextField } from './fields/ProfileTextField';
import { ProfilePronouns } from './fields/ProfilePronouns';
import { ProfileTimezone } from './fields/ProfileTimezone';
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
const accountManagementActions = useAccountManagementActions();
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const openProviderProfileSettings = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.profile,
}),
'_blank'
);
}, [authMetadata, accountManagementActions]);
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>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<CutoutCard style={{ padding: config.space.S200 }} variant="Surface">
<SettingTile
after={
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
outlined
onClick={openProviderProfileSettings}
>
<Text size="B300">Upload</Text>
<Text size="B300">Open</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</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>
}
>
<Text size="T200">Change profile settings in your homeserver&apos;s account dashboard.</Text>
</SettingTile>
</CutoutCard>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
/// Context props which are passed to every field element.
/// Right now this is only a flag for if the profile is being saved.
export type FieldContext = { busy: boolean };
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
/// Field editor elements for the pre-MSC4133 profile fields. This should only
/// ever contain keys for `displayname` and `avatar_url`.
const LEGACY_FIELD_ELEMENTS = {
avatar_url: ProfileAvatar,
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
<ProfileTextField label="Display Name" {...props} />
),
};
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
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
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</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>
);
}
/// Field editor elements for MSC4133 extended profile fields.
/// These will appear in the UI in the order they are defined in this map.
const EXTENDED_FIELD_ELEMENTS = {
'io.fsky.nyx.pronouns': ProfilePronouns,
'us.cloke.msc4175.tz': ProfileTimezone,
};
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const userId = mx.getUserId() as string;
const server = getMxIdServer(userId);
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfileSupported = extendedProfile !== null;
const legacyProfile = useUserProfile(userId);
// next-gen auth identity providers may provide profile settings if they want
const profileEditableThroughIDP =
authMetadata !== undefined &&
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
const entries = Object.entries({
...LEGACY_FIELD_ELEMENTS,
// don't show the MSC4133 elements if the HS doesn't support them
...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}),
}).filter(([key]) =>
// don't show fields if the HS blocks them with capabilities
profileEditsAllowed(key, capabilities, extendedProfileSupported)
);
return [Object.fromEntries(entries), entries.length > 0];
}, [capabilities, extendedProfileSupported]);
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
displayname: legacyProfile.displayName,
avatar_url: legacyProfile.avatarUrl,
});
// this updates the field defaults when the extended profile data is (re)loaded.
// it has to be a layout effect to prevent flickering on saves.
// if MSC4133 isn't supported by the HS this does nothing
useLayoutEffect(() => {
// `extendedProfile` includes the old dn/av fields, so
// we don't have to add those here
if (extendedProfile) {
setFieldDefaults(extendedProfile);
}
}, [setFieldDefaults, extendedProfile]);
const [saveState, handleSave] = useAsyncCallback(
useCallback(
async (fields: ExtendedProfile) => {
if (extendedProfileSupported) {
await Promise.all(
Object.entries(fields).map(async ([key, value]) => {
if (value === undefined) {
await mx.deleteExtendedProfileProperty(key);
} else {
await mx.setExtendedProfileProperty(key, value);
}
})
);
// calling this will trigger the layout effect to update the defaults
// once the profile request completes
await refreshExtendedProfile();
// synthesize a profile update for ourselves to update our name and avatar in the rest
// of the UI. code copied from matrix-js-sdk
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);
}
} else {
await mx.setDisplayName(fields.displayname ?? '');
await mx.setAvatarUrl(fields.avatar_url ?? '');
// layout effect does nothing because `extendedProfile` is undefined
// so we have to update the defaults explicitly here
setFieldDefaults(fields);
}
},
[mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults]
)
);
const saving = saveState.status === AsyncStatus.Loading;
const loadingExtendedProfile = extendedProfile === undefined;
const busy = saving || loadingExtendedProfile;
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
variant="Surface"
outlined
direction="Column"
gap="400"
style={{
overflow: 'hidden',
}}
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
<ProfileFieldContext
fieldDefaults={fieldDefaults}
fieldElements={fieldElementConstructors}
context={{ busy }}
>
{(reset, hasChanges, fields, fieldElements) => {
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>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{fields['us.cloke.msc4175.tz'] && (
<TimezoneChip timezone={fields['us.cloke.msc4175.tz']} />
)}
</Box>
</Box>
<Line />
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
radii="0"
>
{profileEditableThroughIDP && (
<IdentityProviderSettings authMetadata={authMetadata} />
)}
{profileEditableThroughClient && (
<>
<Box gap="300" direction="Column">
{fieldElements}
</Box>
<Box gap="300" alignItems="Center">
<Button
type="submit"
size="300"
variant={!busy && hasChanges ? 'Success' : 'Secondary'}
fill={!busy && hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || busy}
onClick={() => handleSave(fields)}
>
<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>
{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>
</>
);
}}
</ProfileFieldContext>
</SequenceCard>
</Box>
);

View file

@ -0,0 +1,118 @@
import FocusTrap from 'focus-trap-react';
import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
import React, { useState, useMemo, useCallback } from 'react';
import { ImageEditor } from '../../../../components/image-editor';
import { SettingTile } from '../../../../components/setting-tile';
import { CompactUploadCardRenderer } from '../../../../components/upload-card';
import { useFilePicker } from '../../../../hooks/useFilePicker';
import { useMatrixClient } from '../../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
import { useObjectURL } from '../../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../../state/upload';
import { stopPropagation } from '../../../../utils/keyboard';
import { mxcUrlToHttp } from '../../../../utils/matrix';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
import { ModalWide } from '../../../../styles/Modal.css';
export function ProfileAvatar({
busy, value, setValue,
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const disabled = busy;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
setValue(mxc);
handleRemoveUpload();
},
[setValue, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
setValue('');
};
return (
<SettingTile
title={<Text as="span" size="L400">
Avatar
</Text>}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded} />
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disabled}
>
<Text size="B300">Upload Avatar</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={handleRemoveAvatar}
>
<Text size="B300">Remove Avatar</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
);
}

View file

@ -0,0 +1,127 @@
import React, {
FunctionComponent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { deepCompare } from 'matrix-js-sdk/lib/utils';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
/// These types ensure the element functions are actually able to manipulate
/// the profile fields they're mapped to. The <C> generic parameter represents
/// extra "context" props which are passed to every element.
// strip the index signature from ExtendedProfile using mapped type magic.
// keeping the index signature causes weird typechecking issues further down the line
// plus there should never be field elements passed with keys which don't exist in ExtendedProfile.
type ExtendedProfileKeys = keyof {
[Property in keyof ExtendedProfile as string extends Property
? never
: Property]: ExtendedProfile[Property];
};
// these are the props which all field elements must accept.
// this is split into `RawProps` and `Props` so we can type `V` instead of
// spraying `ExtendedProfile[K]` all over the place.
// don't use this directly, use the `ProfileFieldElementProps` type instead
type ProfileFieldElementRawProps<V, C> = {
defaultValue: V;
value: V;
setValue: (value: V) => void;
} & C;
export type ProfileFieldElementProps<
K extends ExtendedProfileKeys,
C
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
// the map of extended profile keys to field element functions
type ProfileFieldElements<C> = {
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
};
type ProfileFieldContextProps<C> = {
fieldDefaults: ExtendedProfile;
fieldElements: ProfileFieldElements<C>;
children: (
reset: () => void,
hasChanges: boolean,
fields: ExtendedProfile,
fieldElements: ReactNode
) => ReactNode;
context: C;
};
/// This element manages the pending state of the profile field widgets.
/// It takes the default values of each field, as well as a map associating a profile field key
/// with an element _function_ (not a rendered element!) that will be used to edit that field.
/// It renders the editor elements internally using React.createElement and passes the rendered
/// elements into the child UI. This allows it to handle the pending state entirely by itself,
/// and provides strong typechecking.
export function ProfileFieldContext<C>({
fieldDefaults,
fieldElements: fieldElementConstructors,
children,
context,
}: ProfileFieldContextProps<C>): ReactNode {
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
// this callback also runs when fieldDefaults changes,
// which happens when the profile is saved and the pending fields become the new defaults
const reset = useCallback(() => {
setFields(fieldDefaults);
}, [fieldDefaults]);
// set the pending values to the defaults on the first render
useEffect(() => {
reset();
}, [reset]);
const setField = useCallback(
(key: string, value: unknown) => {
setFields({
...fields,
[key]: value,
});
},
[fields]
);
const hasChanges = useMemo(
() =>
Object.entries(fields).find(
([key, value]) =>
// deep comparison is necessary here because field values can be any JSON type
!deepCompare(fieldDefaults[key as keyof ExtendedProfile], value)
) !== undefined,
[fields, fieldDefaults]
);
const createElement = useCallback(
<K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
...context,
defaultValue: fieldDefaults[key],
value: fields[key],
setValue: (value) => setField(key, value),
key,
};
// element can be undefined if the field defaults didn't include its key,
// which means the HS doesn't support setting that field
if (element !== undefined) {
return React.createElement(element, props);
}
return undefined;
},
[context, fieldDefaults, fields, setField]
);
const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) =>
// @ts-expect-error TypeScript doesn't quite understand the magic going on here
createElement(key, element)
);
return children(reset, hasChanges, fields, fieldElements);
}

View file

@ -0,0 +1,125 @@
import FocusTrap from 'focus-trap-react';
import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react';
import { SettingTile } from '../../../../components/setting-tile';
import { stopPropagation } from '../../../../utils/keyboard';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfilePronouns({
value, setValue, busy,
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
const disabled = busy;
const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState('');
const handleRemovePronoun = (index: number) => {
const newPronouns = [...(value ?? [])];
newPronouns.splice(index, 1);
if (newPronouns.length > 0) {
setValue(newPronouns);
} else {
setValue(undefined);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setMenuCords(undefined);
if (pendingPronoun.length > 0) {
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setMenuCords(undefined);
}
};
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
setPendingPronoun('');
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
return (
<SettingTile
title={<Text as="span" size="L400">
Pronouns
</Text>}
>
<Box alignItems="Center" gap="200" wrap="Wrap">
{value?.map(({ summary }, index) => (
<Chip
// eslint-disable-next-line react/no-array-index-key
key={index}
variant="Secondary"
radii="Pill"
after={<Icon src={Icons.Cross} size="100" />}
onClick={() => handleRemovePronoun(index)}
disabled={disabled}
>
<Text size="T200" truncate>
{summary}
</Text>
</Chip>
))}
<Chip
variant="Secondary"
radii="Pill"
disabled={disabled}
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
onClick={handleOpenMenu}
>
<Text size="T200">Add</Text>
</Chip>
</Box>
<PopOut
anchor={menuCords}
offset={5}
position="Right"
align="Center"
content={<FocusTrap
focusTrapOptions={{
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
variant="SurfaceVariant"
style={{
padding: config.space.S200,
}}
>
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
<Input
variant="Secondary"
placeholder="they/them"
inputSize={10}
radii="300"
size="300"
outlined
value={pendingPronoun}
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
onKeyDown={handleKeyDown} />
<Button
type="submit"
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
</Menu>
</FocusTrap>} />
</SettingTile>
);
}

View file

@ -0,0 +1,63 @@
import { Text, Box, Input, IconButton, Icon, Icons } from 'folds';
import React, { ChangeEventHandler } from 'react';
import { FilterByValues } from '../../../../../types/utils';
import { SettingTile } from '../../../../components/setting-tile';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
label, defaultValue, value, setValue, busy,
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
const disabled = busy;
const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const content = evt.currentTarget.value;
if (content.length > 0) {
setValue(evt.currentTarget.value);
} else {
setValue(undefined);
}
};
const handleReset = () => {
setValue(defaultValue);
};
return (
<SettingTile
title={<Text as="span" size="L400">
{label}
</Text>}
>
<Box direction="Column" grow="Yes" gap="100">
<Box gap="200" aria-disabled={disabled}>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={value ?? ''}
onChange={handleChange}
variant="Secondary"
radii="300"
disabled={disabled}
readOnly={disabled}
after={hasChanges &&
!busy && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)} />
</Box>
</Box>
</Box>
</SettingTile>
);
}

View file

@ -0,0 +1,160 @@
import FocusTrap from 'focus-trap-react';
import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds';
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
import { CutoutCard } from '../../../../components/cutout-card';
import { SettingTile } from '../../../../components/setting-tile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTimezone({
value, setValue, busy,
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
const disabled = busy;
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const [query, setQuery] = useState('');
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
const filteredTimezones = timezones.filter(
(timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
);
const handleSelect = useCallback(
(timezone: string) => {
setOverlayOpen(false);
setValue(timezone);
},
[setOverlayOpen, setValue]
);
useEffect(() => {
if (overlayOpen) {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
if (value && focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}
}, [scrollRef, value, overlayOpen]);
return (
<SettingTile
title={<Text as="span" size="L400">
Timezone
</Text>}
>
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: () => setOverlayOpen(false),
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<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">Choose a Timezone</Text>
</Box>
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Input
ref={inputRef}
size="500"
variant="Background"
radii="400"
outlined
placeholder="Search"
before={<Icon size="200" src={Icons.Search} />}
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)} />
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
{filteredTimezones.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
No Results
</Text>
</Box>
)}
{filteredTimezones.map((timezone) => (
<MenuItem
key={timezone}
data-tz={timezone}
variant={timezone === value ? 'Success' : 'Surface'}
fill={timezone === value ? 'Soft' : 'None'}
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => handleSelect(timezone)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{timezone}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Box gap="200">
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={disabled}
onClick={() => setOverlayOpen(true)}
after={<Icon size="100" src={Icons.ChevronRight} />}
>
<Text size="B300">{value ?? 'Set Timezone'}</Text>
</Button>
{value && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={() => setValue(undefined)}
>
<Text size="B300">Remove Timezone</Text>
</Button>
)}
</Box>
</SettingTile>
);
}

View file

@ -1,100 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CutoutCard } from '../../../components/cutout-card';
type AccountDataProps = {
expand: boolean;
onExpandToggle: (expand: boolean) => void;
onSelect: (type: string | null) => void;
};
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
const mx = useMatrixClient();
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Global"
description="Data stored in your global account data."
after={
<Button
onClick={() => onExpandToggle(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expand && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountDataTypes.length}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{accountDataTypes.sort().map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => onSelect(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
)}
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import React from 'react';
import { Box, Text, Icon, Icons, MenuItem } from 'folds';
import { CutoutCard } from '../../../components/cutout-card';
type AccountDataListProps = {
types: string[];
onSelect: (type: string | null) => void;
};
export function AccountDataList({
types,
onSelect,
}: AccountDataListProps) {
return (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Fields</Text>
<Text size="L400">Total: {types.length}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{types.sort().map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => onSelect(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
);
}

View file

@ -1,5 +1,7 @@
import React, { useCallback, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { AccountDataEvents } from 'matrix-js-sdk';
import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
AccountDataDeleteCallback,
AccountDataEditor,
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
import { AccountDataList } from './AccountDataList';
import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type DeveloperToolsPage =
| { name: 'index' }
| { name: 'account-data'; type: string | null }
| { name: 'profile-field'; type: string | null };
type DeveloperToolsProps = {
requestClose: () => void;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const mx = useMatrixClient();
const userId = mx.getUserId() as string;
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
const accountDataDeletionSupported =
(mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !==
ServerSupport.Unsupported;
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
const [page, setPage] = useState<DeveloperToolsPage>({ name: 'index' });
const [globalExpand, setGlobalExpand] = useState(false);
const [profileExpand, setProfileExpand] = useState(false);
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
await mx.setAccountData(type, content);
await mx.setAccountData(type as keyof AccountDataEvents, content);
},
[mx]
);
if (accountDataType !== undefined) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
);
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Access Token"
description="Copy access token to clipboard."
after={
<Button
onClick={() =>
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Copy</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<AccountData
expand={expand}
onExpandToggle={setExpend}
onSelect={setAccountDataType}
/>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
const deleteAccountData: AccountDataDeleteCallback = useCallback(
async (type) => {
await mx.deleteAccountData(type as keyof AccountDataEvents);
},
[mx]
);
const submitProfileField: AccountDataSubmitCallback = useCallback(
async (type, content) => {
await mx.setExtendedProfileProperty(type, content);
await refreshExtendedProfile();
},
[mx, refreshExtendedProfile]
);
const deleteProfileField: AccountDataDeleteCallback = useCallback(
async (type) => {
await mx.deleteExtendedProfileProperty(type);
await refreshExtendedProfile();
},
[mx, refreshExtendedProfile]
);
const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]);
switch (page.name) {
case 'account-data':
return (
<AccountDataEditor
type={page.type ?? undefined}
content={
page.type
? mx.getAccountData(page.type as keyof AccountDataEvents)?.getContent()
: undefined
}
submitChange={submitAccountData}
submitDelete={accountDataDeletionSupported ? deleteAccountData : undefined}
requestClose={handleClose}
/>
);
case 'profile-field':
return (
<AccountDataEditor
type={page.type ?? undefined}
content={page.type ? extendedProfile?.[page.type] : undefined}
submitChange={submitProfileField}
submitDelete={deleteProfileField}
requestClose={handleClose}
/>
);
default:
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Access Token"
description="Copy access token to clipboard."
after={
<Button
onClick={() =>
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Copy</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<CollapsibleCard
expand={globalExpand}
setExpand={setGlobalExpand}
title="Account"
description="Private data stored in your account."
>
<AccountDataList
types={accountDataTypes}
onSelect={(type) => setPage({ name: 'account-data', type })}
/>
</CollapsibleCard>
{extendedProfile && (
<CollapsibleCard
expand={profileExpand}
setExpand={setProfileExpand}
title="Profile"
description="Public data attached to your Matrix profile."
>
<AccountDataList
types={Object.keys(extendedProfile)}
onSelect={(type) => setPage({ name: 'profile-field', type })}
/>
</CollapsibleCard>
)}
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
}

View file

@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
import { useAlive } from '../../../hooks/useAlive';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
function ExportKeys() {
const mx = useMatrixClient();
@ -121,37 +122,18 @@ function ExportKeys() {
);
}
function ExportKeysTile() {
function ExportKeysCard() {
const [expand, setExpand] = useState(false);
return (
<>
<SettingTile
title="Export Messages Data"
description="Save password protected copy of encryption data on your device to decrypt messages later."
after={
<Box>
<Button
type="button"
onClick={() => setExpand(!expand)}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
before={
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
}
>
<Text as="span" size="B300" truncate>
{expand ? 'Collapse' : 'Expand'}
</Text>
</Button>
</Box>
}
/>
{expand && <ExportKeys />}
</>
<CollapsibleCard
expand={expand}
setExpand={setExpand}
title="Export Messages Data"
description="Save password protected copy of encryption data on your device to decrypt messages later."
>
<ExportKeys />
</CollapsibleCard>
);
}
@ -304,14 +286,7 @@ export function LocalBackup() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Local Backup</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ExportKeysTile />
</SequenceCard>
<ExportKeysCard />
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"

View file

@ -0,0 +1,112 @@
import { useCallback } from 'react';
import z from 'zod';
import { useQuery } from '@tanstack/react-query';
import { Capabilities } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useSpecVersions } from './useSpecVersions';
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');
}
/// Returns the user's MSC4133 extended profile, if our homeserver supports it.
/// This will return `undefined` while the request is in flight and `null` if the HS lacks support.
export function useExtendedProfile(
userId: string
): [ExtendedProfile | undefined | null, () => Promise<void>] {
const mx = useMatrixClient();
const extendedProfileSupported = useExtendedProfileSupported();
const { data, refetch } = useQuery({
queryKey: ['extended-profile', userId],
queryFn: useCallback(async () => {
if (extendedProfileSupported) {
return extendedProfile.parse(await mx.getExtendedProfile(userId));
}
return null;
}, [mx, userId, extendedProfileSupported]),
refetchOnMount: false,
});
return [
data,
async () => {
await refetch();
},
];
}
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
/// Returns whether the given profile field may be edited by the user.
export function profileEditsAllowed(
field: string,
capabilities: Capabilities,
extendedProfileSupported: boolean
): boolean {
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 only supports legacy fields
return true;
}
}
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

@ -1,10 +1,9 @@
import React, { useState } from 'react';
import { Text } from 'folds';
import { Icon, Icons } from 'folds';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile';
@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500';
export function SettingsTab() {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const userId = mx.getUserId() as string;
const profile = useUserProfile(userId);
const [settings, setSettings] = useState(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
@ -34,7 +32,7 @@ export function SettingsTab() {
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
/>
</SidebarAvatar>
)}

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];
};