Add a panel in Developer Tools for editing profile fields

This commit is contained in:
Ginger 2025-10-06 11:44:41 -04:00
parent 4e7b64eb5f
commit 5bc9654d32
No known key found for this signature in database
5 changed files with 287 additions and 201 deletions

View file

@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
const EDITOR_INTENT_SPACE_COUNT = 2; const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>; export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
export type AccountDataDeleteCallback = (type: string) => Promise<void>;
type AccountDataInfo = { type AccountDataInfo = {
type: string; type: string;
@ -83,8 +84,7 @@ function AccountDataEdit({
if ( if (
!typeStr || !typeStr ||
parsedContent === null || parsedContent === null
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
) { ) {
return; return;
} }
@ -121,7 +121,7 @@ function AccountDataEdit({
aria-disabled={submitting} aria-disabled={submitting}
> >
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Account Data</Text> <Text size="L400">Field Name</Text>
<Box gap="300"> <Box gap="300">
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Input <Input
@ -196,8 +196,21 @@ type AccountDataViewProps = {
type: string; type: string;
defaultContent: string; defaultContent: string;
onEdit: () => void; onEdit: () => void;
requestClose: () => 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 ( return (
<Box <Box
direction="Column" direction="Column"
@ -208,7 +221,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
> >
<Box shrink="No" gap="300" alignItems="End"> <Box shrink="No" gap="300" alignItems="End">
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Account Data</Text> <Text size="L400">Field Name</Text>
<Input <Input
variant="SurfaceVariant" variant="SurfaceVariant"
size="400" size="400"
@ -221,6 +234,18 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}> <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text> <Text size="B400">Edit</Text>
</Button> </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>
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text> <Text size="L400">JSON Content</Text>
@ -243,8 +268,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
export type AccountDataEditorProps = { export type AccountDataEditorProps = {
type?: string; type?: string;
content?: object; content?: unknown;
submitChange: AccountDataSubmitCallback; submitChange: AccountDataSubmitCallback;
submitDelete?: AccountDataDeleteCallback;
requestClose: () => void; requestClose: () => void;
}; };
@ -252,6 +278,7 @@ export function AccountDataEditor({
type, type,
content, content,
submitChange, submitChange,
submitDelete,
requestClose, requestClose,
}: AccountDataEditorProps) { }: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({ const [data, setData] = useState<AccountDataInfo>({
@ -314,6 +341,8 @@ export function AccountDataEditor({
type={data.type} type={data.type}
defaultContent={contentJSONStr} defaultContent={contentJSONStr}
onEdit={() => setEdit(true)} onEdit={() => setEdit(true)}
requestClose={requestClose}
submitDelete={submitDelete}
/> />
)} )}
</Box> </Box>

View file

@ -148,7 +148,7 @@ export function Profile() {
// once the profile request completes // once the profile request completes
await refreshExtendedProfile(); await refreshExtendedProfile();
// synthesise a profile update for ourselves to update our name and avatr in the rest // synthesize a profile update for ourselves to update our name and avatar in the rest
// of the UI. code copied from matrix-js-sdk // of the UI. code copied from matrix-js-sdk
const user = mx.getUser(userId); const user = mx.getUser(userId);
if (user) { if (user) {

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,86 @@
import React 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 { CutoutCard } from '../../../components/cutout-card';
type AccountDataListProps = {
title?: string;
description?: string;
expand: boolean;
setExpand: (expand: boolean) => void;
types: string[];
onSelect: (type: string | null) => void;
};
export function AccountDataList({ types, onSelect, expand, setExpand, title, description }: AccountDataListProps) {
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={title}
description={description}
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 && (
<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>
)}
</SequenceCard>
);
}

View file

@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { AccountDataEvents } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@ -8,117 +9,187 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
AccountDataDeleteCallback,
AccountDataEditor, AccountDataEditor,
AccountDataSubmitCallback, AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData'; import { AccountDataList } from './AccountDataList';
import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
type DeveloperToolsPage =
| { name: 'index' }
| { name: 'account-data'; type: string | null }
| { name: 'profile-field'; type: string | null };
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
}; };
export function DeveloperTools({ requestClose }: DeveloperToolsProps) { export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId() as string;
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false); const [page, setPage] = useState<DeveloperToolsPage>({ name: 'index' });
const [accountDataType, setAccountDataType] = useState<string | null>(); const [globalExpand, setGlobalExpand] = useState(false);
const [profileExpand, setProfileExpand] = useState(false);
const submitAccountData: AccountDataSubmitCallback = useCallback( const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => { async (type, content) => {
await mx.setAccountData(type, content); await mx.setAccountData(type as keyof AccountDataEvents, content);
}, },
[mx] [mx]
); );
if (accountDataType !== undefined) { const submitProfileField: AccountDataSubmitCallback = useCallback(
return ( async (type, content) => {
<AccountDataEditor await mx.setExtendedProfileProperty(type, content);
type={accountDataType ?? undefined} await refreshExtendedProfile();
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined} },
submitChange={submitAccountData} [mx, refreshExtendedProfile]
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 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}
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>
<AccountDataList
title="Account"
description="Private data stored in your account."
expand={globalExpand}
setExpand={setGlobalExpand}
types={accountDataTypes}
onSelect={(type) => setPage({ name: 'account-data', type })}
/>
{extendedProfile && (
<AccountDataList
title="Profile"
description="Public data attached to your Matrix profile."
expand={profileExpand}
setExpand={setProfileExpand}
types={Object.keys(extendedProfile)}
onSelect={(type) => setPage({ name: 'profile-field', type })}
/>
)}
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
} }