useState,
} from 'react';
import {
- as,
Box,
- Header,
Text,
Icon,
Icons,
TextArea as TextAreaComponent,
color,
Spinner,
+ Chip,
+ Scroll,
+ config,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { MatrixError } from 'matrix-js-sdk';
import { syntaxErrorPosition } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { Page, PageHeader } from '../../../components/page';
+import { useAlive } from '../../../hooks/useAlive';
+import { SequenceCard } from '../../../components/sequence-card';
+import { TextViewerContent } from '../../../components/text-viewer';
const EDITOR_INTENT_SPACE_COUNT = 2;
-export type AccountDataEditorProps = {
- type?: string;
- content?: object;
- requestClose: () => void;
+type AccountDataInfo = {
+ type: string;
+ content: object;
};
-export const AccountDataEditor = as<'div', AccountDataEditorProps>(
- ({ type, content, requestClose, ...props }, ref) => {
- const mx = useMatrixClient();
- const defaultContent = useMemo(
- () => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
- [content]
- );
- const textAreaRef = useRef<HTMLTextAreaElement>(null);
- const [jsonError, setJSONError] = useState<SyntaxError>();
-
- const getTarget: GetTarget = useCallback(() => {
- const target = textAreaRef.current;
- if (!target) throw new Error('TextArea element not found!');
- return target;
- }, []);
-
- const { textArea, operations, intent } = useMemo(() => {
- const ta = new TextArea(getTarget);
- const op = new TextAreaOperations(getTarget);
- return {
- textArea: ta,
- operations: op,
- intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
- };
- }, [getTarget]);
-
- const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
-
- const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
- intentHandler(evt);
- if (isKeyHotkey('escape', evt)) {
- const cursor = Cursor.fromTextAreaElement(getTarget());
- operations.deselect(cursor);
- }
- };
+type AccountDataEditProps = {
+ type: string;
+ defaultContent: string;
+ onCancel: () => void;
+ onSave: (info: AccountDataInfo) => void;
+};
+function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
+ const mx = useMatrixClient();
+ const alive = useAlive();
- const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
- useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
- );
- const submitting = submitState.status === AsyncStatus.Loading;
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- if (submitting) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const typeInput = target?.typeInput as HTMLInputElement | undefined;
- const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
- if (!typeInput || !contentTextArea) return;
-
- const typeStr = typeInput.value.trim();
- const contentStr = contentTextArea.value.trim();
-
- let parsedContent: object;
- try {
- parsedContent = JSON.parse(contentStr);
- } catch (e) {
- setJSONError(e as SyntaxError);
- return;
- }
- setJSONError(undefined);
-
- if (
- !typeStr ||
- parsedContent === null ||
- defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
- ) {
- return;
- }
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
+ const [jsonError, setJSONError] = useState<SyntaxError>();
+
+ const getTarget: GetTarget = useCallback(() => {
+ const target = textAreaRef.current;
+ if (!target) throw new Error('TextArea element not found!');
+ return target;
+ }, []);
- submit(typeStr, parsedContent);
+ const { textArea, operations, intent } = useMemo(() => {
+ const ta = new TextArea(getTarget);
+ const op = new TextAreaOperations(getTarget);
+ return {
+ textArea: ta,
+ operations: op,
+ intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
};
+ }, [getTarget]);
- useEffect(() => {
- if (jsonError) {
- const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
- const cursor = new Cursor(errorPosition, errorPosition, 'none');
- operations.select(cursor);
- getTarget()?.focus();
- }
- }, [jsonError, operations, getTarget]);
+ const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
+
+ const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
+ intentHandler(evt);
+ if (isKeyHotkey('escape', evt)) {
+ const cursor = Cursor.fromTextAreaElement(getTarget());
+ operations.deselect(cursor);
+ }
+ };
+
+ const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
+ useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
- useEffect(() => {
- if (submitState.status === AsyncStatus.Success) {
- requestClose();
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (submitting) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const typeInput = target?.typeInput as HTMLInputElement | undefined;
+ const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+ if (!typeInput || !contentTextArea) return;
+
+ const typeStr = typeInput.value.trim();
+ const contentStr = contentTextArea.value.trim();
+
+ let parsedContent: object;
+ try {
+ parsedContent = JSON.parse(contentStr);
+ } catch (e) {
+ setJSONError(e as SyntaxError);
+ return;
+ }
+ setJSONError(undefined);
+
+ if (
+ !typeStr ||
+ parsedContent === null ||
+ defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
+ ) {
+ return;
+ }
+
+ submit(typeStr, parsedContent).then(() => {
+ if (alive()) {
+ onSave({
+ type: typeStr,
+ content: parsedContent,
+ });
}
- }, [submitState, requestClose]);
-
- return (
- <Box grow="Yes" direction="Column" {...props} ref={ref}>
- <Header className={css.EditorHeader} size="600">
- <Box grow="Yes" gap="200">
- <Box grow="Yes" alignItems="Center" gap="200">
- <Text size="H3" truncate>
- Account Data
- </Text>
- </Box>
- <Box shrink="No">
- <IconButton onClick={requestClose} variant="Surface">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Box>
- </Box>
- </Header>
- <Box
- as="form"
- onSubmit={handleSubmit}
- grow="Yes"
- className={css.EditorContent}
- direction="Column"
- gap="400"
- aria-disabled={submitting}
- >
- <Box shrink="No" direction="Column" gap="100">
- <Text size="L400">Type</Text>
- <Box gap="300">
- <Box grow="Yes" direction="Column">
- <Input
- name="typeInput"
- size="400"
- readOnly={!!type || submitting}
- defaultValue={type}
- required
- />
- </Box>
- <Button
- variant="Primary"
- size="400"
- type="submit"
- disabled={submitting}
- before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
- >
- <Text size="B400">Save</Text>
- </Button>
- </Box>
-
- {submitState.status === AsyncStatus.Error && (
- <Text size="T200" style={{ color: color.Critical.Main }}>
- <b>{submitState.error.message}</b>
- </Text>
- )}
- </Box>
- <Box grow="Yes" direction="Column" gap="100">
- <Box shrink="No">
- <Text size="L400">JSON Content</Text>
- </Box>
- <TextAreaComponent
- ref={textAreaRef}
- name="contentTextArea"
- className={css.EditorTextArea}
- onKeyDown={handleKeyDown}
- defaultValue={defaultContent}
- resize="None"
- spellCheck="false"
+ });
+ };
+
+ useEffect(() => {
+ if (jsonError) {
+ const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
+ const cursor = new Cursor(errorPosition, errorPosition, 'none');
+ operations.select(cursor);
+ getTarget()?.focus();
+ }
+ }, [jsonError, operations, getTarget]);
+
+ return (
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ grow="Yes"
+ className={css.EditorContent}
+ direction="Column"
+ gap="400"
+ aria-disabled={submitting}
+ >
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">Account Data</Text>
+ <Box gap="300">
+ <Box grow="Yes" direction="Column">
+ <Input
+ variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
+ name="typeInput"
+ size="400"
+ radii="300"
+ readOnly={type.length > 0 || submitting}
+ defaultValue={type}
required
- readOnly={submitting}
/>
- {jsonError && (
- <Text size="T200" style={{ color: color.Critical.Main }}>
- <b>
- {jsonError.name}: {jsonError.message}
- </b>
- </Text>
- )}
+ </Box>
+ <Button
+ variant="Success"
+ size="400"
+ radii="300"
+ type="submit"
+ disabled={submitting}
+ before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+ >
+ <Text size="B400">Save</Text>
+ </Button>
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="400"
+ radii="300"
+ onClick={onCancel}
+ disabled={submitting}
+ >
+ <Text size="B400">Cancel</Text>
+ </Button>
+ </Box>
+
+ {submitState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>{submitState.error.message}</b>
+ </Text>
+ )}
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box shrink="No">
+ <Text size="L400">JSON Content</Text>
+ </Box>
+ <TextAreaComponent
+ ref={textAreaRef}
+ name="contentTextArea"
+ className={css.EditorTextArea}
+ onKeyDown={handleKeyDown}
+ defaultValue={defaultContent}
+ resize="None"
+ spellCheck="false"
+ required
+ readOnly={submitting}
+ />
+ {jsonError && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>
+ {jsonError.name}: {jsonError.message}
+ </b>
+ </Text>
+ )}
+ </Box>
+ </Box>
+ );
+}
+
+type AccountDataViewProps = {
+ type: string;
+ defaultContent: string;
+ onEdit: () => void;
+};
+function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+ return (
+ <Box direction="Column" className={css.EditorContent} gap="400">
+ <Box shrink="No" gap="300" alignItems="End">
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Account Data</Text>
+ <Input
+ variant="SurfaceVariant"
+ size="400"
+ radii="300"
+ readOnly
+ defaultValue={type}
+ required
+ />
+ </Box>
+ <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
+ <Text size="B400">Edit</Text>
+ </Button>
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">JSON Content</Text>
+ <SequenceCard variant="SurfaceVariant">
+ <Scroll visibility="Always" size="300" hideTrack>
+ <TextViewerContent
+ size="T300"
+ style={{
+ padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
+ }}
+ text={defaultContent}
+ langName="JSON"
+ />
+ </Scroll>
+ </SequenceCard>
+ </Box>
+ </Box>
+ );
+}
+
+export type AccountDataEditorProps = {
+ type?: string;
+ requestClose: () => void;
+};
+
+export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
+ const mx = useMatrixClient();
+
+ const [data, setData] = useState<AccountDataInfo>({
+ type: type ?? '',
+ content: mx.getAccountData(type ?? '')?.getContent() ?? {},
+ });
+
+ const [edit, setEdit] = useState(!type);
+
+ const closeEdit = useCallback(() => {
+ if (!type) {
+ requestClose();
+ return;
+ }
+ setEdit(false);
+ }, [type, requestClose]);
+
+ const handleSave = useCallback((info: AccountDataInfo) => {
+ setData(info);
+ setEdit(false);
+ }, []);
+
+ const contentJSONStr = useMemo(
+ () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
+ [data.content]
+ );
+
+ return (
+ <Page>
+ <PageHeader outlined={false} balance>
+ <Box alignItems="Center" grow="Yes" gap="200">
+ <Box alignItems="Inherit" grow="Yes" gap="200">
+ <Chip
+ size="500"
+ radii="Pill"
+ onClick={requestClose}
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ >
+ <Text size="T300">Developer Tools</Text>
+ </Chip>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
</Box>
</Box>
+ </PageHeader>
+ <Box grow="Yes" direction="Column">
+ {edit ? (
+ <AccountDataEdit
+ type={data.type}
+ defaultContent={contentJSONStr}
+ onCancel={closeEdit}
+ onSave={handleSave}
+ />
+ ) : (
+ <AccountDataView
+ type={data.type}
+ defaultContent={contentJSONStr}
+ onEdit={() => setEdit(true)}
+ />
+ )}
</Box>
- );
- }
-);
+ </Page>
+ );
+}
-import React, { MouseEventHandler, useCallback, useState } from 'react';
-import {
- Box,
- Text,
- IconButton,
- Icon,
- Icons,
- Scroll,
- Switch,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Modal,
- Chip,
- Button,
- PopOut,
- RectCords,
- Menu,
- config,
- MenuItem,
-} from 'folds';
-import { MatrixEvent } from 'matrix-js-sdk';
-import FocusTrap from 'focus-trap-react';
+import React, { useState } from 'react';
+import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
-import { TextViewer } from '../../../components/text-viewer';
-import { stopPropagation } from '../../../utils/keyboard';
import { AccountDataEditor } from './AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
-
-function AccountData() {
- const mx = useMatrixClient();
- const [view, setView] = useState(false);
- const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
- const [selectedEvent, selectEvent] = useState<MatrixEvent>();
- const [menuCords, setMenuCords] = useState<RectCords>();
- const [selectedOption, selectOption] = useState<'edit' | 'inspect'>();
-
- useAccountDataCallback(
- mx,
- useCallback(
- () => setAccountData(Array.from(mx.store.accountData.values())),
- [mx, setAccountData]
- )
- );
-
- const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
- const target = evt.currentTarget;
- const eventType = target.getAttribute('data-event-type');
- if (eventType) {
- const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType);
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- selectEvent(mEvent);
- }
- };
-
- const handleMenuClose = () => setMenuCords(undefined);
-
- const handleEdit = () => {
- selectOption('edit');
- setMenuCords(undefined);
- };
- const handleInspect = () => {
- selectOption('inspect');
- setMenuCords(undefined);
- };
- const handleClose = useCallback(() => {
- selectEvent(undefined);
- selectOption(undefined);
- }, []);
-
- 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={() => setView(!view)}
- variant="Secondary"
- fill="Soft"
- size="300"
- radii="300"
- outlined
- before={
- <Icon src={view ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
- }
- >
- <Text size="B300">{view ? 'Collapse' : 'Expand'}</Text>
- </Button>
- }
- />
- {view && (
- <SettingTile>
- <Box direction="Column" gap="200">
- <Text size="L400">Types</Text>
- <Box gap="200" wrap="Wrap">
- <Chip
- variant="Secondary"
- fill="Soft"
- radii="Pill"
- onClick={handleEdit}
- before={<Icon size="50" src={Icons.Plus} />}
- >
- <Text size="T200" truncate>
- Add New
- </Text>
- </Chip>
- {accountData.map((mEvent) => (
- <Chip
- key={mEvent.getType()}
- variant="Secondary"
- fill="Soft"
- radii="Pill"
- aria-pressed={menuCords && selectedEvent?.getType() === mEvent.getType()}
- onClick={handleMenu}
- data-event-type={mEvent.getType()}
- >
- <Text size="T200" truncate>
- {mEvent.getType()}
- </Text>
- </Chip>
- ))}
- </Box>
- </Box>
- </SettingTile>
- )}
- <PopOut
- anchor={menuCords}
- offset={5}
- position="Bottom"
- content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleMenuClose,
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
- <Menu>
- <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
- <MenuItem size="300" variant="Surface" radii="300" onClick={handleInspect}>
- <Text size="T300">Inspect</Text>
- </MenuItem>
- <MenuItem size="300" variant="Surface" radii="300" onClick={handleEdit}>
- <Text size="T300">Edit</Text>
- </MenuItem>
- </Box>
- </Menu>
- </FocusTrap>
- }
- />
- </SequenceCard>
- {selectedEvent && selectedOption === 'inspect' && (
- <Overlay open backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- <Modal variant="Surface" size="500">
- <TextViewer
- name={selectedEvent.getType() ?? 'Source Code'}
- langName="json"
- text={JSON.stringify(selectedEvent.getContent(), null, 2)}
- requestClose={handleClose}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- {selectedOption === 'edit' && (
- <Overlay open backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- <Modal variant="Surface" size="500">
- <AccountDataEditor
- type={selectedEvent?.getType()}
- content={selectedEvent?.getContent()}
- requestClose={handleClose}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- </Box>
- );
-}
+import { AccountData } from './AccountData';
type DeveloperToolsProps = {
requestClose: () => void;
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const mx = useMatrixClient();
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
+ const [expand, setExpend] = useState(false);
+ const [accountDataType, setAccountDataType] = useState<string | null>();
+
+ if (accountDataType !== undefined) {
+ return (
+ <AccountDataEditor
+ type={accountDataType ?? undefined}
+ requestClose={() => setAccountDataType(undefined)}
+ />
+ );
+ }
return (
<Page>
</SequenceCard>
)}
</Box>
- {developerTools && <AccountData />}
+ {developerTools && (
+ <AccountData
+ expand={expand}
+ onExpandToggle={setExpend}
+ onSelect={setAccountDataType}
+ />
+ )}
</Box>
</PageContent>
</Scroll>