Use a common CollapsibleCard element for collapsible settings cards

This commit is contained in:
Ginger 2025-10-06 12:21:01 -04:00
parent af9460ef8b
commit d42bcc6e3d
No known key found for this signature in database
7 changed files with 322 additions and 390 deletions

View file

@ -195,8 +195,8 @@ function AccountDataEdit({
type AccountDataViewProps = { type AccountDataViewProps = {
type: string; type: string;
defaultContent: string; defaultContent: string;
onEdit: () => void;
requestClose: () => void; requestClose: () => void;
onEdit?: () => void;
submitDelete?: AccountDataDeleteCallback; submitDelete?: AccountDataDeleteCallback;
}; };
function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) { function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) {
@ -231,9 +231,11 @@ function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDel
required required
/> />
</Box> </Box>
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}> {onEdit && (
<Text size="B400">Edit</Text> <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
</Button> <Text size="B400">Edit</Text>
</Button>
)}
{submitDelete && ( {submitDelete && (
<Button <Button
variant="Critical" variant="Critical"
@ -269,7 +271,7 @@ function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDel
export type AccountDataEditorProps = { export type AccountDataEditorProps = {
type?: string; type?: string;
content?: unknown; content?: unknown;
submitChange: AccountDataSubmitCallback; submitChange?: AccountDataSubmitCallback;
submitDelete?: AccountDataDeleteCallback; submitDelete?: AccountDataDeleteCallback;
requestClose: () => void; requestClose: () => void;
}; };
@ -328,7 +330,7 @@ export function AccountDataEditor({
</Box> </Box>
</PageHeader> </PageHeader>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{edit ? ( {(edit && submitChange) ? (
<AccountDataEdit <AccountDataEdit
type={data.type} type={data.type}
defaultContent={contentJSONStr} defaultContent={contentJSONStr}
@ -340,8 +342,8 @@ export function AccountDataEditor({
<AccountDataView <AccountDataView
type={data.type} type={data.type}
defaultContent={contentJSONStr} defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
requestClose={requestClose} requestClose={requestClose}
onEdit={submitChange ? () => setEdit(true) : undefined}
submitDelete={submitDelete} submitDelete={submitDelete}
/> />
)} )}

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

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

View file

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

View file

@ -1,86 +1,54 @@
import React from 'react'; import React from 'react';
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; import { Box, Text, Icon, Icons, 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'; import { CutoutCard } from '../../../components/cutout-card';
type AccountDataListProps = { type AccountDataListProps = {
title?: string;
description?: string;
expand: boolean;
setExpand: (expand: boolean) => void;
types: string[]; types: string[];
onSelect: (type: string | null) => void; onSelect: (type: string | null) => void;
}; };
export function AccountDataList({ types, onSelect, expand, setExpand, title, description }: AccountDataListProps) { export function AccountDataList({
types,
onSelect,
}: AccountDataListProps) {
return ( return (
<SequenceCard <Box direction="Column" gap="100">
className={SequenceCardStyle} <Box justifyContent="SpaceBetween">
variant="SurfaceVariant" <Text size="L400">Fields</Text>
direction="Column" <Text size="L400">Total: {types.length}</Text>
gap="400" </Box>
> <CutoutCard>
<SettingTile <MenuItem
title={title} variant="Surface"
description={description} fill="None"
after={ size="300"
<Button radii="0"
onClick={() => setExpand(!expand)} before={<Icon size="50" src={Icons.Plus} />}
variant="Secondary" onClick={() => onSelect(null)}
fill="Soft" >
size="300" <Box grow="Yes">
radii="300" <Text size="T200" truncate>
outlined Add New
before={ </Text>
<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> </Box>
<CutoutCard> </MenuItem>
<MenuItem {types.sort().map((type) => (
variant="Surface" <MenuItem
fill="None" key={type}
size="300" variant="Surface"
radii="0" fill="None"
before={<Icon size="50" src={Icons.Plus} />} size="300"
onClick={() => onSelect(null)} radii="0"
> after={<Icon size="50" src={Icons.ChevronRight} />}
<Box grow="Yes"> onClick={() => onSelect(type)}
<Text size="T200" truncate> >
Add New <Box grow="Yes">
</Text> <Text size="T200" truncate>
</Box> {type}
</MenuItem> </Text>
{types.sort().map((type) => ( </Box>
<MenuItem </MenuItem>
key={type} ))}
variant="Surface" </CutoutCard>
fill="None" </Box>
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

@ -17,6 +17,7 @@ import { copyToClipboard } from '../../../utils/dom';
import { AccountDataList } from './AccountDataList'; import { AccountDataList } from './AccountDataList';
import { useExtendedProfile } from '../../../hooks/useExtendedProfile'; import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type DeveloperToolsPage = type DeveloperToolsPage =
| { name: 'index' } | { name: 'index' }
@ -165,23 +166,29 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
{developerTools && ( {developerTools && (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Account Data</Text> <Text size="L400">Account Data</Text>
<AccountDataList <CollapsibleCard
title="Account"
description="Private data stored in your account."
expand={globalExpand} expand={globalExpand}
setExpand={setGlobalExpand} setExpand={setGlobalExpand}
types={accountDataTypes} title="Account"
onSelect={(type) => setPage({ name: 'account-data', type })} description="Private data stored in your account."
/> >
{extendedProfile && (
<AccountDataList <AccountDataList
title="Profile" types={accountDataTypes}
description="Public data attached to your Matrix profile." onSelect={(type) => setPage({ name: 'account-data', type })}
/>
</CollapsibleCard>
{extendedProfile && (
<CollapsibleCard
expand={profileExpand} expand={profileExpand}
setExpand={setProfileExpand} setExpand={setProfileExpand}
types={Object.keys(extendedProfile)} title="Profile"
onSelect={(type) => setPage({ name: 'profile-field', type })} description="Public data attached to your Matrix profile."
/> >
<AccountDataList
types={Object.keys(extendedProfile)}
onSelect={(type) => setPage({ name: 'profile-field', type })}
/>
</CollapsibleCard>
)} )}
</Box> </Box>
)} )}

View file

@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys'; import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { useFilePicker } from '../../../hooks/useFilePicker'; import { useFilePicker } from '../../../hooks/useFilePicker';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
function ExportKeys() { function ExportKeys() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -121,37 +122,18 @@ function ExportKeys() {
); );
} }
function ExportKeysTile() { function ExportKeysCard() {
const [expand, setExpand] = useState(false); const [expand, setExpand] = useState(false);
return ( return (
<> <CollapsibleCard
<SettingTile expand={expand}
title="Export Messages Data" setExpand={setExpand}
description="Save password protected copy of encryption data on your device to decrypt messages later." title="Export Messages Data"
after={ description="Save password protected copy of encryption data on your device to decrypt messages later."
<Box> >
<Button <ExportKeys />
type="button" </CollapsibleCard>
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 />}
</>
); );
} }
@ -304,14 +286,7 @@ export function LocalBackup() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Local Backup</Text> <Text size="L400">Local Backup</Text>
<SequenceCard <ExportKeysCard />
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ExportKeysTile />
</SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"