Add new space settings (#2293)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Thu, 27 Mar 2025 08:54:13 +0000 (19:54 +1100)
committerGitHub <noreply@github.com>
Thu, 27 Mar 2025 08:54:13 +0000 (19:54 +1100)
63 files changed:
src/app/components/JoinRulesSwitcher.tsx
src/app/features/common-settings/developer-tools/DevelopTools.tsx [new file with mode: 0644]
src/app/features/common-settings/developer-tools/SendRoomEvent.tsx [new file with mode: 0644]
src/app/features/common-settings/developer-tools/StateEventEditor.tsx [new file with mode: 0644]
src/app/features/common-settings/developer-tools/index.ts [new file with mode: 0644]
src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx [new file with mode: 0644]
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx [new file with mode: 0644]
src/app/features/common-settings/emojis-stickers/index.ts [new file with mode: 0644]
src/app/features/common-settings/general/RoomAddress.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomEncryption.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomHistoryVisibility.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomJoinRules.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomProfile.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomPublish.tsx [new file with mode: 0644]
src/app/features/common-settings/general/RoomUpgrade.tsx [new file with mode: 0644]
src/app/features/common-settings/general/index.ts [new file with mode: 0644]
src/app/features/common-settings/members/Members.tsx [new file with mode: 0644]
src/app/features/common-settings/members/index.ts [new file with mode: 0644]
src/app/features/common-settings/permissions/PermissionGroups.tsx [new file with mode: 0644]
src/app/features/common-settings/permissions/Powers.tsx [new file with mode: 0644]
src/app/features/common-settings/permissions/PowersEditor.tsx [new file with mode: 0644]
src/app/features/common-settings/permissions/index.ts [new file with mode: 0644]
src/app/features/common-settings/permissions/types.ts [new file with mode: 0644]
src/app/features/common-settings/styles.css.ts [new file with mode: 0644]
src/app/features/lobby/HierarchyItemMenu.tsx
src/app/features/lobby/LobbyHeader.tsx
src/app/features/room-settings/RoomSettings.tsx
src/app/features/room-settings/developer-tools/DevelopTools.tsx [deleted file]
src/app/features/room-settings/developer-tools/SendRoomEvent.tsx [deleted file]
src/app/features/room-settings/developer-tools/StateEventEditor.tsx [deleted file]
src/app/features/room-settings/developer-tools/index.ts [deleted file]
src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx [deleted file]
src/app/features/room-settings/emojis-stickers/RoomPacks.tsx [deleted file]
src/app/features/room-settings/emojis-stickers/index.ts [deleted file]
src/app/features/room-settings/general/General.tsx
src/app/features/room-settings/general/RoomAddress.tsx [deleted file]
src/app/features/room-settings/general/RoomEncryption.tsx [deleted file]
src/app/features/room-settings/general/RoomHistoryVisibility.tsx [deleted file]
src/app/features/room-settings/general/RoomJoinRules.tsx [deleted file]
src/app/features/room-settings/general/RoomProfile.tsx [deleted file]
src/app/features/room-settings/general/RoomPublish.tsx [deleted file]
src/app/features/room-settings/general/RoomUpgrade.tsx [deleted file]
src/app/features/room-settings/members/Members.tsx [deleted file]
src/app/features/room-settings/members/index.ts [deleted file]
src/app/features/room-settings/permissions/PermissionGroups.tsx [deleted file]
src/app/features/room-settings/permissions/Permissions.tsx
src/app/features/room-settings/permissions/Powers.tsx [deleted file]
src/app/features/room-settings/permissions/PowersEditor.tsx [deleted file]
src/app/features/room-settings/permissions/usePermissionItems.ts
src/app/features/space-settings/SpaceSettings.tsx [new file with mode: 0644]
src/app/features/space-settings/SpaceSettingsRenderer.tsx [new file with mode: 0644]
src/app/features/space-settings/general/General.tsx [new file with mode: 0644]
src/app/features/space-settings/general/index.ts [new file with mode: 0644]
src/app/features/space-settings/index.ts [new file with mode: 0644]
src/app/features/space-settings/permissions/Permissions.tsx [new file with mode: 0644]
src/app/features/space-settings/permissions/index.ts [new file with mode: 0644]
src/app/features/space-settings/permissions/usePermissionItems.ts [new file with mode: 0644]
src/app/features/space-settings/styles.css.ts [new file with mode: 0644]
src/app/pages/Router.tsx
src/app/pages/client/sidebar/SpaceTabs.tsx
src/app/pages/client/space/Space.tsx
src/app/state/hooks/spaceSettings.ts [new file with mode: 0644]
src/app/state/spaceSettings.ts [new file with mode: 0644]

index ddd1903f610cf2f3cfa8a8fc2a64f2025ffe9705..e78c19cec3639183cb0a407aaf211c3e1dadd848 100644 (file)
@@ -29,6 +29,17 @@ export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
     }),
     []
   );
+export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
+  useMemo(
+    () => ({
+      [JoinRule.Invite]: Icons.SpaceLock,
+      [JoinRule.Knock]: Icons.SpaceLock,
+      [JoinRule.Restricted]: Icons.Space,
+      [JoinRule.Public]: Icons.SpaceGlobe,
+      [JoinRule.Private]: Icons.SpaceLock,
+    }),
+    []
+  );
 
 type JoinRuleLabels = Record<JoinRule, string>;
 export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
new file mode 100644 (file)
index 0000000..29b6aa5
--- /dev/null
@@ -0,0 +1,396 @@
+import React, { useCallback, useState } from 'react';
+import {
+  Box,
+  Text,
+  IconButton,
+  Icon,
+  Icons,
+  Scroll,
+  Switch,
+  Button,
+  MenuItem,
+  config,
+  color,
+} from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { copyToClipboard } from '../../../utils/dom';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomState } from '../../../hooks/useRoomState';
+import { StateEventEditor, StateEventInfo } from './StateEventEditor';
+import { SendRoomEvent } from './SendRoomEvent';
+import { useRoomAccountData } from '../../../hooks/useRoomAccountData';
+import { CutoutCard } from '../../../components/cutout-card';
+import {
+  AccountDataEditor,
+  AccountDataSubmitCallback,
+} from '../../../components/AccountDataEditor';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+type DeveloperToolsProps = {
+  requestClose: () => void;
+};
+export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
+  const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
+  const mx = useMatrixClient();
+  const room = useRoom();
+
+  const roomState = useRoomState(room);
+  const accountData = useRoomAccountData(room);
+
+  const [expandState, setExpandState] = useState(false);
+  const [expandStateType, setExpandStateType] = useState<string>();
+  const [openStateEvent, setOpenStateEvent] = useState<StateEventInfo>();
+  const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>();
+
+  const [expandAccountData, setExpandAccountData] = useState(false);
+  const [accountDataType, setAccountDataType] = useState<string | null>();
+
+  const handleClose = useCallback(() => {
+    setOpenStateEvent(undefined);
+    setComposeEvent(undefined);
+    setAccountDataType(undefined);
+  }, []);
+
+  const submitAccountData: AccountDataSubmitCallback = useCallback(
+    async (type, content) => {
+      await mx.setRoomAccountData(room.roomId, type, content);
+    },
+    [mx, room.roomId]
+  );
+
+  if (accountDataType !== undefined) {
+    return (
+      <AccountDataEditor
+        type={accountDataType ?? undefined}
+        content={accountDataType ? accountData.get(accountDataType) : undefined}
+        submitChange={submitAccountData}
+        requestClose={handleClose}
+      />
+    );
+  }
+
+  if (composeEvent) {
+    return <SendRoomEvent {...composeEvent} requestClose={handleClose} />;
+  }
+
+  if (openStateEvent) {
+    return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
+  }
+
+  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="Room ID"
+                      description={`Copy room ID to clipboard. ("${room.roomId}")`}
+                      after={
+                        <Button
+                          onClick={() => copyToClipboard(room.roomId ?? '<NO_ROOM_ID_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">Data</Text>
+
+                  <SequenceCard
+                    className={SequenceCardStyle}
+                    variant="SurfaceVariant"
+                    direction="Column"
+                    gap="400"
+                  >
+                    <SettingTile
+                      title="New Message Event"
+                      description="Create and send a new message event within the room."
+                      after={
+                        <Button
+                          onClick={() => setComposeEvent({})}
+                          variant="Secondary"
+                          fill="Soft"
+                          size="300"
+                          radii="300"
+                          outlined
+                        >
+                          <Text size="B300">Compose</Text>
+                        </Button>
+                      }
+                    />
+                  </SequenceCard>
+                  <SequenceCard
+                    className={SequenceCardStyle}
+                    variant="SurfaceVariant"
+                    direction="Column"
+                    gap="400"
+                  >
+                    <SettingTile
+                      title="Room State"
+                      description="State events of the room."
+                      after={
+                        <Button
+                          onClick={() => setExpandState(!expandState)}
+                          variant="Secondary"
+                          fill="Soft"
+                          size="300"
+                          radii="300"
+                          outlined
+                          before={
+                            <Icon
+                              src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
+                              size="100"
+                              filled
+                            />
+                          }
+                        >
+                          <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;
+
+                              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}`,
+                                      }}
+                                    >
+                                      <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"
+                  >
+                    <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>
+                    )}
+                  </SequenceCard>
+                </Box>
+              )}
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
new file mode 100644 (file)
index 0000000..f25ba7c
--- /dev/null
@@ -0,0 +1,208 @@
+import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import {
+  Box,
+  Chip,
+  Icon,
+  Icons,
+  IconButton,
+  Text,
+  config,
+  Button,
+  Spinner,
+  color,
+  TextArea as TextAreaComponent,
+  Input,
+} from 'folds';
+import { Page, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { useAlive } from '../../../hooks/useAlive';
+import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { syntaxErrorPosition } from '../../../utils/dom';
+import { Cursor } from '../../../plugins/text-area';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+export type SendRoomEventProps = {
+  type?: string;
+  stateKey?: string;
+  requestClose: () => void;
+};
+export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const alive = useAlive();
+  const composeStateEvent = typeof stateKey === 'string';
+
+  const textAreaRef = useRef<HTMLTextAreaElement>(null);
+  const [jsonError, setJSONError] = useState<SyntaxError>();
+  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+    textAreaRef,
+    EDITOR_INTENT_SPACE_COUNT
+  );
+
+  const [submitState, submit] = useAsyncCallback<
+    object,
+    MatrixError,
+    [string, string | undefined, object]
+  >(
+    useCallback(
+      (evtType, evtStateKey, evtContent) => {
+        if (typeof evtStateKey === 'string') {
+          return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
+        }
+        return mx.sendEvent(room.roomId, evtType as any, evtContent);
+      },
+      [mx, room]
+    )
+  );
+  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 stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
+    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+    if (!typeInput || !contentTextArea) return;
+
+    const evtType = typeInput.value;
+    const evtStateKey = stateKeyInput?.value;
+    const contentStr = contentTextArea.value.trim();
+
+    let parsedContent: object;
+    try {
+      parsedContent = JSON.parse(contentStr);
+    } catch (e) {
+      setJSONError(e as SyntaxError);
+      return;
+    }
+    setJSONError(undefined);
+
+    if (parsedContent === null) {
+      return;
+    }
+
+    submit(evtType, evtStateKey, parsedContent).then(() => {
+      if (alive()) {
+        requestClose();
+      }
+    });
+  };
+
+  useEffect(() => {
+    if (jsonError) {
+      const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
+      const cursor = new Cursor(errorPosition, errorPosition, 'none');
+      operations.select(cursor);
+      getTarget()?.focus();
+    }
+  }, [jsonError, operations, getTarget]);
+
+  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">
+        <Box
+          as="form"
+          onSubmit={handleSubmit}
+          grow="Yes"
+          style={{ padding: config.space.S400 }}
+          direction="Column"
+          gap="400"
+          aria-disabled={submitting}
+        >
+          <Box shrink="No" direction="Column" gap="100">
+            <Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
+            <Box gap="300">
+              <Box grow="Yes" direction="Column">
+                <Input
+                  variant="Background"
+                  name="typeInput"
+                  size="400"
+                  radii="300"
+                  readOnly={submitting}
+                  defaultValue={type}
+                  required
+                />
+              </Box>
+              <Button
+                variant="Success"
+                size="400"
+                radii="300"
+                type="submit"
+                disabled={submitting}
+                before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+              >
+                <Text size="B400">Send</Text>
+              </Button>
+            </Box>
+
+            {submitState.status === AsyncStatus.Error && (
+              <Text size="T200" style={{ color: color.Critical.Main }}>
+                <b>{submitState.error.message}</b>
+              </Text>
+            )}
+          </Box>
+          {composeStateEvent && (
+            <Box shrink="No" direction="Column" gap="100">
+              <Text size="L400">State Key (Optional)</Text>
+              <Input
+                variant="Background"
+                name="stateKeyInput"
+                size="400"
+                radii="300"
+                readOnly={submitting}
+                defaultValue={stateKey}
+              />
+            </Box>
+          )}
+          <Box grow="Yes" direction="Column" gap="100">
+            <Box shrink="No">
+              <Text size="L400">JSON Content</Text>
+            </Box>
+            <TextAreaComponent
+              ref={textAreaRef}
+              name="contentTextArea"
+              style={{ fontFamily: 'monospace' }}
+              onKeyDown={handleKeyDown}
+              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>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
new file mode 100644 (file)
index 0000000..6ee19be
--- /dev/null
@@ -0,0 +1,298 @@
+import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+  Box,
+  Text,
+  Icon,
+  Icons,
+  IconButton,
+  Chip,
+  Scroll,
+  config,
+  TextArea as TextAreaComponent,
+  color,
+  Spinner,
+  Button,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { Page, PageHeader } from '../../../components/page';
+import { SequenceCard } from '../../../components/sequence-card';
+import { TextViewerContent } from '../../../components/text-viewer';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useAlive } from '../../../hooks/useAlive';
+import { Cursor } from '../../../plugins/text-area';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { syntaxErrorPosition } from '../../../utils/dom';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCardStyle } from '../styles.css';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+type StateEventEditProps = {
+  type: string;
+  stateKey: string;
+  content: object;
+  requestClose: () => void;
+};
+function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const alive = useAlive();
+
+  const defaultContentStr = useMemo(
+    () => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
+    [content]
+  );
+
+  const textAreaRef = useRef<HTMLTextAreaElement>(null);
+  const [jsonError, setJSONError] = useState<SyntaxError>();
+  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+    textAreaRef,
+    EDITOR_INTENT_SPACE_COUNT
+  );
+
+  const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
+    useCallback(
+      (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
+      [mx, room, type, stateKey]
+    )
+  );
+  const submitting = submitState.status === AsyncStatus.Loading;
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    if (submitting) return;
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+    if (!contentTextArea) return;
+
+    const contentStr = contentTextArea.value.trim();
+
+    let parsedContent: object;
+    try {
+      parsedContent = JSON.parse(contentStr);
+    } catch (e) {
+      setJSONError(e as SyntaxError);
+      return;
+    }
+    setJSONError(undefined);
+
+    if (
+      parsedContent === null ||
+      defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
+    ) {
+      return;
+    }
+
+    submit(parsedContent).then(() => {
+      if (alive()) {
+        requestClose();
+      }
+    });
+  };
+
+  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"
+      style={{ padding: config.space.S400 }}
+      direction="Column"
+      gap="400"
+      aria-disabled={submitting}
+    >
+      <Box shrink="No" direction="Column" gap="100">
+        <Text size="L400">State Event</Text>
+        <SequenceCard
+          className={SequenceCardStyle}
+          variant="SurfaceVariant"
+          direction="Column"
+          gap="400"
+        >
+          <SettingTile
+            title={type}
+            description={stateKey}
+            after={
+              <Box gap="200">
+                <Button
+                  variant="Success"
+                  size="300"
+                  radii="300"
+                  type="submit"
+                  disabled={submitting}
+                  before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+                >
+                  <Text size="B300">Save</Text>
+                </Button>
+                <Button
+                  variant="Secondary"
+                  fill="Soft"
+                  size="300"
+                  radii="300"
+                  onClick={requestClose}
+                  disabled={submitting}
+                >
+                  <Text size="B300">Cancel</Text>
+                </Button>
+              </Box>
+            }
+          />
+        </SequenceCard>
+
+        {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"
+          style={{ fontFamily: 'monospace' }}
+          onKeyDown={handleKeyDown}
+          defaultValue={defaultContentStr}
+          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 StateEventViewProps = {
+  content: object;
+  eventJSONStr: string;
+  onEditContent?: (content: object) => void;
+};
+function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
+  return (
+    <Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
+      <Box grow="Yes" direction="Column" gap="100">
+        <Box gap="200" alignItems="End">
+          <Box grow="Yes">
+            <Text size="L400">State Event</Text>
+          </Box>
+          {onEditContent && (
+            <Box shrink="No" gap="200">
+              <Chip
+                variant="Secondary"
+                fill="Soft"
+                radii="300"
+                outlined
+                onClick={() => onEditContent(content)}
+              >
+                <Text size="B300">Edit</Text>
+              </Chip>
+            </Box>
+          )}
+        </Box>
+        <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={eventJSONStr}
+              langName="JSON"
+            />
+          </Scroll>
+        </SequenceCard>
+      </Box>
+    </Box>
+  );
+}
+
+export type StateEventInfo = {
+  type: string;
+  stateKey: string;
+};
+export type StateEventEditorProps = StateEventInfo & {
+  requestClose: () => void;
+};
+
+export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
+  const [editContent, setEditContent] = useState<object>();
+  const powerLevels = usePowerLevels(room);
+  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+  const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
+
+  const eventJSONStr = useMemo(() => {
+    if (!stateEvent) return '';
+    return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
+  }, [stateEvent]);
+
+  const handleCloseEdit = useCallback(() => {
+    setEditContent(undefined);
+  }, []);
+
+  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">
+        {editContent ? (
+          <StateEventEdit
+            type={type}
+            stateKey={stateKey}
+            content={editContent}
+            requestClose={handleCloseEdit}
+          />
+        ) : (
+          <StateEventView
+            content={stateEvent?.getContent() ?? {}}
+            onEditContent={canEdit ? setEditContent : undefined}
+            eventJSONStr={eventJSONStr}
+          />
+        )}
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/developer-tools/index.ts b/src/app/features/common-settings/developer-tools/index.ts
new file mode 100644 (file)
index 0000000..1fcceff
--- /dev/null
@@ -0,0 +1 @@
+export * from './DevelopTools';
diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
new file mode 100644 (file)
index 0000000..ad8ffae
--- /dev/null
@@ -0,0 +1,49 @@
+import React, { useState } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { ImagePack } from '../../../plugins/custom-emoji';
+import { ImagePackView } from '../../../components/image-pack-view';
+import { RoomPacks } from './RoomPacks';
+
+type EmojisStickersProps = {
+  requestClose: () => void;
+};
+export function EmojisStickers({ requestClose }: EmojisStickersProps) {
+  const [imagePack, setImagePack] = useState<ImagePack>();
+
+  const handleImagePackViewClose = () => {
+    setImagePack(undefined);
+  };
+
+  if (imagePack) {
+    return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
+  }
+
+  return (
+    <Page>
+      <PageHeader outlined={false}>
+        <Box grow="Yes" gap="200">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <Text size="H3" truncate>
+              Emojis & Stickers
+            </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">
+              <RoomPacks onViewPack={setImagePack} />
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
new file mode 100644 (file)
index 0000000..56dda54
--- /dev/null
@@ -0,0 +1,349 @@
+import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+  Box,
+  Text,
+  Button,
+  Icon,
+  Icons,
+  Avatar,
+  AvatarImage,
+  AvatarFallback,
+  toRem,
+  config,
+  Input,
+  Spinner,
+  color,
+  IconButton,
+  Menu,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import {
+  ImagePack,
+  ImageUsage,
+  PackAddress,
+  packAddressEqual,
+  PackContent,
+} from '../../../plugins/custom-emoji';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomImagePacks } from '../../../hooks/useImagePacks';
+import { LineClamp2 } from '../../../styles/Text.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCardStyle } from '../styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { suffixRename } from '../../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+type CreatePackTileProps = {
+  packs: ImagePack[];
+  roomId: string;
+};
+function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
+  const mx = useMatrixClient();
+  const alive = useAlive();
+
+  const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
+    useCallback(
+      async (stateKey, name) => {
+        const content: PackContent = {
+          pack: {
+            display_name: name,
+          },
+        };
+        await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
+      },
+      [mx, roomId]
+    )
+  );
+
+  const creating = addState.status === AsyncStatus.Loading;
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    if (creating) return;
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const nameInput = target?.nameInput as HTMLInputElement | undefined;
+    if (!nameInput) return;
+    const name = nameInput?.value.trim();
+    if (!name) return;
+
+    let packKey = name.replace(/\s/g, '-');
+
+    const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
+    if (hasPack(packKey)) {
+      packKey = suffixRename(packKey, hasPack);
+    }
+
+    addPack(packKey, name).then(() => {
+      if (alive()) {
+        nameInput.value = '';
+      }
+    });
+  };
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title="New Pack"
+        description="Add your own emoji and sticker pack to use in room."
+      >
+        <Box
+          style={{ marginTop: config.space.S200 }}
+          as="form"
+          onSubmit={handleSubmit}
+          gap="200"
+          alignItems="End"
+        >
+          <Box direction="Column" gap="100" grow="Yes">
+            <Text size="L400">Name</Text>
+            <Input
+              name="nameInput"
+              required
+              size="400"
+              variant="Secondary"
+              radii="300"
+              readOnly={creating}
+            />
+            {addState.status === AsyncStatus.Error && (
+              <Text style={{ color: color.Critical.Main }} size="T300">
+                {addState.error.message}
+              </Text>
+            )}
+          </Box>
+          <Button
+            variant="Success"
+            radii="300"
+            type="submit"
+            disabled={creating}
+            before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
+          >
+            <Text size="B400">Create</Text>
+          </Button>
+        </Box>
+      </SettingTile>
+    </SequenceCard>
+  );
+}
+
+type RoomPacksProps = {
+  onViewPack: (imagePack: ImagePack) => void;
+};
+export function RoomPacks({ onViewPack }: RoomPacksProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const alive = useAlive();
+
+  const powerLevels = usePowerLevels(room);
+  const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
+  const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
+
+  const unfilteredPacks = useRoomImagePacks(room);
+  const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
+
+  const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
+  const hasChanges = removedPacks.length > 0;
+
+  const [applyState, applyChanges] = useAsyncCallback(
+    useCallback(async () => {
+      for (let i = 0; i < removedPacks.length; i += 1) {
+        const addr = removedPacks[i];
+        // eslint-disable-next-line no-await-in-loop
+        await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
+      }
+    }, [mx, room, removedPacks])
+  );
+  const applyingChanges = applyState.status === AsyncStatus.Loading;
+
+  const handleRemove = (address: PackAddress) => {
+    setRemovedPacks((addresses) => [...addresses, address]);
+  };
+
+  const handleUndoRemove = (address: PackAddress) => {
+    setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
+  };
+
+  const handleCancelChanges = () => setRemovedPacks([]);
+
+  const handleApplyChanges = () => {
+    applyChanges().then(() => {
+      if (alive()) {
+        setRemovedPacks([]);
+      }
+    });
+  };
+
+  const renderPack = (pack: ImagePack) => {
+    const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
+    const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
+    const { address } = pack;
+    if (!address) return null;
+    const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
+
+    return (
+      <SequenceCard
+        key={pack.id}
+        className={SequenceCardStyle}
+        variant={removed ? 'Critical' : 'SurfaceVariant'}
+        direction="Column"
+        gap="400"
+      >
+        <SettingTile
+          title={
+            <span style={{ textDecoration: removed ? 'line-through' : undefined }}>
+              {pack.meta.name ?? 'Unknown'}
+            </span>
+          }
+          description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
+          before={
+            <Box alignItems="Center" gap="300">
+              {canEdit &&
+                (removed ? (
+                  <IconButton
+                    size="300"
+                    radii="Pill"
+                    variant="Critical"
+                    onClick={() => handleUndoRemove(address)}
+                    disabled={applyingChanges}
+                  >
+                    <Icon src={Icons.Plus} size="100" />
+                  </IconButton>
+                ) : (
+                  <IconButton
+                    size="300"
+                    radii="Pill"
+                    variant="Secondary"
+                    onClick={() => handleRemove(address)}
+                    disabled={applyingChanges}
+                  >
+                    <Icon src={Icons.Cross} size="100" />
+                  </IconButton>
+                ))}
+              <Avatar size="300" radii="300">
+                {avatarUrl ? (
+                  <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
+                ) : (
+                  <AvatarFallback>
+                    <Icon size="400" src={Icons.Sticker} filled />
+                  </AvatarFallback>
+                )}
+              </Avatar>
+            </Box>
+          }
+          after={
+            !removed && (
+              <Button
+                variant="Secondary"
+                fill="Soft"
+                size="300"
+                radii="300"
+                outlined
+                onClick={() => onViewPack(pack)}
+              >
+                <Text size="B300">View</Text>
+              </Button>
+            )
+          }
+        />
+      </SequenceCard>
+    );
+  };
+
+  return (
+    <>
+      <Box direction="Column" gap="100">
+        <Text size="L400">Packs</Text>
+        {canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
+        {packs.map(renderPack)}
+        {packs.length === 0 && (
+          <SequenceCard
+            className={SequenceCardStyle}
+            variant="SurfaceVariant"
+            direction="Column"
+            gap="400"
+          >
+            <Box
+              justifyContent="Center"
+              direction="Column"
+              gap="200"
+              style={{
+                padding: `${config.space.S700} ${config.space.S400}`,
+                maxWidth: toRem(300),
+                margin: 'auto',
+              }}
+            >
+              <Text size="H5" align="Center">
+                No Packs
+              </Text>
+              <Text size="T200" align="Center">
+                There are no emoji or sticker packs to display at the moment.
+              </Text>
+            </Box>
+          </SequenceCard>
+        )}
+      </Box>
+
+      {hasChanges && (
+        <Menu
+          style={{
+            position: 'sticky',
+            padding: config.space.S200,
+            paddingLeft: config.space.S400,
+            bottom: config.space.S400,
+            left: config.space.S400,
+            right: 0,
+            zIndex: 1,
+          }}
+          variant="Critical"
+        >
+          <Box alignItems="Center" gap="400">
+            <Box grow="Yes" direction="Column">
+              {applyState.status === AsyncStatus.Error ? (
+                <Text size="T200">
+                  <b>Failed to remove packs! Please try again.</b>
+                </Text>
+              ) : (
+                <Text size="T200">
+                  <b>Delete selected packs. ({removedPacks.length} selected)</b>
+                </Text>
+              )}
+            </Box>
+            <Box shrink="No" gap="200">
+              <Button
+                size="300"
+                variant="Critical"
+                fill="None"
+                radii="300"
+                disabled={applyingChanges}
+                onClick={handleCancelChanges}
+              >
+                <Text size="B300">Cancel</Text>
+              </Button>
+              <Button
+                size="300"
+                variant="Critical"
+                radii="300"
+                disabled={applyingChanges}
+                before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
+                onClick={handleApplyChanges}
+              >
+                <Text size="B300">Delete</Text>
+              </Button>
+            </Box>
+          </Box>
+        </Menu>
+      )}
+    </>
+  );
+}
diff --git a/src/app/features/common-settings/emojis-stickers/index.ts b/src/app/features/common-settings/emojis-stickers/index.ts
new file mode 100644 (file)
index 0000000..9c9e9f5
--- /dev/null
@@ -0,0 +1 @@
+export * from './EmojisStickers';
diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx
new file mode 100644 (file)
index 0000000..9e1f1a9
--- /dev/null
@@ -0,0 +1,438 @@
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import {
+  Badge,
+  Box,
+  Button,
+  Checkbox,
+  Chip,
+  color,
+  config,
+  Icon,
+  Icons,
+  Input,
+  Spinner,
+  Text,
+  toRem,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+  useLocalAliases,
+  usePublishedAliases,
+  usePublishUnpublishAliases,
+  useSetMainAlias,
+} from '../../../hooks/useRoomAliases';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { CutoutCard } from '../../../components/cutout-card';
+import { getIdServer } from '../../../../util/matrixUtil';
+import { replaceSpaceWithDash } from '../../../utils/common';
+import { useAlive } from '../../../hooks/useAlive';
+import { StateEvent } from '../../../../types/matrix/room';
+
+type RoomPublishedAddressesProps = {
+  powerLevels: IPowerLevels;
+};
+
+export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEditCanonical = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomCanonicalAlias,
+    userPowerLevel
+  );
+
+  const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
+  const setMainAlias = useSetMainAlias(room);
+
+  const [mainState, setMain] = useAsyncCallback(setMainAlias);
+  const loading = mainState.status === AsyncStatus.Loading;
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title="Published Addresses"
+        description={
+          <span>
+            If access is <b>Public</b>, Published addresses will be used to join by anyone.
+          </span>
+        }
+      />
+      <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
+        {publishedAliases.length === 0 ? (
+          <Box direction="Column" gap="100">
+            <Text size="L400">No Addresses</Text>
+            <Text size="T200">
+              To publish an address, it needs to be set as a local address first
+            </Text>
+          </Box>
+        ) : (
+          <Box direction="Column" gap="300">
+            {publishedAliases.map((alias) => (
+              <Box key={alias} as="span" gap="200" alignItems="Center">
+                <Box grow="Yes" gap="Inherit" alignItems="Center">
+                  <Text size="T300" truncate>
+                    {alias === canonicalAlias ? <b>{alias}</b> : alias}
+                  </Text>
+                  {alias === canonicalAlias && (
+                    <Badge variant="Success" fill="Solid" size="500">
+                      <Text size="L400">Main</Text>
+                    </Badge>
+                  )}
+                </Box>
+                {canEditCanonical && (
+                  <Box shrink="No" gap="100">
+                    {alias === canonicalAlias ? (
+                      <Chip
+                        variant="Warning"
+                        radii="Pill"
+                        fill="None"
+                        disabled={loading}
+                        onClick={() => setMain(undefined)}
+                      >
+                        <Text size="B300">Unset Main</Text>
+                      </Chip>
+                    ) : (
+                      <Chip
+                        variant="Success"
+                        radii="Pill"
+                        fill={canonicalAlias ? 'None' : 'Soft'}
+                        disabled={loading}
+                        onClick={() => setMain(alias)}
+                      >
+                        <Text size="B300">Set Main</Text>
+                      </Chip>
+                    )}
+                  </Box>
+                )}
+              </Box>
+            ))}
+
+            {mainState.status === AsyncStatus.Error && (
+              <Text size="T200" style={{ color: color.Critical.Main }}>
+                {(mainState.error as MatrixError).message}
+              </Text>
+            )}
+          </Box>
+        )}
+      </CutoutCard>
+    </SequenceCard>
+  );
+}
+
+function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
+  const mx = useMatrixClient();
+  const userId = mx.getSafeUserId();
+  const server = getIdServer(userId);
+  const alive = useAlive();
+
+  const [addState, addAlias] = useAsyncCallback(addLocalAlias);
+  const adding = addState.status === AsyncStatus.Loading;
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    if (adding) return;
+    evt.preventDefault();
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const aliasInput = target?.aliasInput as HTMLInputElement | undefined;
+    if (!aliasInput) return;
+    const alias = replaceSpaceWithDash(aliasInput.value.trim());
+    if (!alias) return;
+
+    addAlias(`#${alias}:${server}`).then(() => {
+      if (alive()) {
+        aliasInput.value = '';
+      }
+    });
+  };
+
+  return (
+    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
+      <Box gap="200">
+        <Box grow="Yes" direction="Column">
+          <Input
+            name="aliasInput"
+            variant="Secondary"
+            size="400"
+            radii="300"
+            before={<Text size="T200">#</Text>}
+            readOnly={adding}
+            after={
+              <Text style={{ maxWidth: toRem(300) }} size="T200" truncate>
+                :{server}
+              </Text>
+            }
+          />
+        </Box>
+        <Box shrink="No">
+          <Button
+            variant="Success"
+            size="400"
+            radii="300"
+            type="submit"
+            disabled={adding}
+            before={adding && <Spinner size="100" variant="Success" fill="Solid" />}
+          >
+            <Text size="B400">Save</Text>
+          </Button>
+        </Box>
+      </Box>
+      {addState.status === AsyncStatus.Error && (
+        <Text style={{ color: color.Critical.Main }} size="T200">
+          {(addState.error as MatrixError).httpStatus === 409
+            ? 'Address is already in use!'
+            : (addState.error as MatrixError).message}
+        </Text>
+      )}
+    </Box>
+  );
+}
+
+function LocalAddressesList({
+  localAliases,
+  removeLocalAlias,
+  canEditCanonical,
+}: {
+  localAliases: string[];
+  removeLocalAlias: (alias: string) => Promise<void>;
+  canEditCanonical?: boolean;
+}) {
+  const room = useRoom();
+  const alive = useAlive();
+
+  const [, publishedAliases] = usePublishedAliases(room);
+  const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room);
+
+  const [selectedAliases, setSelectedAliases] = useState<string[]>([]);
+  const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias));
+
+  const toggleSelect = (alias: string) => {
+    setSelectedAliases((aliases) => {
+      if (aliases.includes(alias)) {
+        return aliases.filter((a) => a !== alias);
+      }
+      const newAliases = [...aliases];
+      newAliases.push(alias);
+      return newAliases;
+    });
+  };
+  const clearSelected = () => {
+    if (alive()) {
+      setSelectedAliases([]);
+    }
+  };
+
+  const [deleteState, deleteAliases] = useAsyncCallback(
+    useCallback(
+      async (aliases: string[]) => {
+        for (let i = 0; i < aliases.length; i += 1) {
+          const alias = aliases[i];
+          // eslint-disable-next-line no-await-in-loop
+          await removeLocalAlias(alias);
+        }
+      },
+      [removeLocalAlias]
+    )
+  );
+  const [publishState, publish] = useAsyncCallback(publishAliases);
+  const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases);
+
+  const handleDelete = () => {
+    deleteAliases(selectedAliases).then(clearSelected);
+  };
+  const handlePublish = () => {
+    publish(selectedAliases).then(clearSelected);
+  };
+  const handleUnpublish = () => {
+    unpublish(selectedAliases).then(clearSelected);
+  };
+
+  const loading =
+    deleteState.status === AsyncStatus.Loading ||
+    publishState.status === AsyncStatus.Loading ||
+    unpublishState.status === AsyncStatus.Loading;
+  let error: MatrixError | undefined;
+  if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError;
+  if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError;
+  if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError;
+
+  return (
+    <Box direction="Column" gap="300">
+      {selectedAliases.length > 0 && (
+        <Box gap="200">
+          <Box grow="Yes">
+            <Text size="L400">{selectedAliases.length} Selected</Text>
+          </Box>
+          <Box shrink="No" gap="Inherit">
+            {canEditCanonical &&
+              (selectHasPublished ? (
+                <Chip
+                  variant="Warning"
+                  radii="Pill"
+                  disabled={loading}
+                  onClick={handleUnpublish}
+                  before={
+                    unpublishState.status === AsyncStatus.Loading && (
+                      <Spinner size="100" variant="Warning" />
+                    )
+                  }
+                >
+                  <Text size="B300">Unpublish</Text>
+                </Chip>
+              ) : (
+                <Chip
+                  variant="Success"
+                  radii="Pill"
+                  disabled={loading}
+                  onClick={handlePublish}
+                  before={
+                    publishState.status === AsyncStatus.Loading && (
+                      <Spinner size="100" variant="Success" />
+                    )
+                  }
+                >
+                  <Text size="B300">Publish</Text>
+                </Chip>
+              ))}
+            <Chip
+              variant="Critical"
+              radii="Pill"
+              disabled={loading}
+              onClick={handleDelete}
+              before={
+                deleteState.status === AsyncStatus.Loading && (
+                  <Spinner size="100" variant="Critical" />
+                )
+              }
+            >
+              <Text size="B300">Delete</Text>
+            </Chip>
+          </Box>
+        </Box>
+      )}
+      {localAliases.map((alias) => {
+        const published = publishedAliases.includes(alias);
+        const selected = selectedAliases.includes(alias);
+
+        return (
+          <Box key={alias} as="span" alignItems="Center" gap="200">
+            <Box shrink="No">
+              <Checkbox
+                checked={selected}
+                onChange={() => toggleSelect(alias)}
+                size="50"
+                variant="Primary"
+                disabled={loading}
+              />
+            </Box>
+            <Box grow="Yes">
+              <Text size="T300" truncate>
+                {alias}
+              </Text>
+            </Box>
+            <Box shrink="No" gap="100">
+              {published && (
+                <Badge variant="Success" fill="Soft" size="500">
+                  <Text size="L400">Published</Text>
+                </Badge>
+              )}
+            </Box>
+          </Box>
+        );
+      })}
+      {error && (
+        <Text size="T200" style={{ color: color.Critical.Main }}>
+          {error.message}
+        </Text>
+      )}
+    </Box>
+  );
+}
+
+export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEditCanonical = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomCanonicalAlias,
+    userPowerLevel
+  );
+
+  const [expand, setExpand] = useState(false);
+
+  const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <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'}
+            </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>
+      )}
+      {expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx
new file mode 100644 (file)
index 0000000..1bb7339
--- /dev/null
@@ -0,0 +1,150 @@
+import {
+  Badge,
+  Box,
+  Button,
+  color,
+  config,
+  Dialog,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+} from 'folds';
+import React, { useCallback, useState } from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useRoom } from '../../../hooks/useRoom';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { stopPropagation } from '../../../utils/keyboard';
+
+const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
+
+type RoomEncryptionProps = {
+  powerLevels: IPowerLevels;
+};
+export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEnable = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomEncryption,
+    userPowerLevel
+  );
+  const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
+    algorithm: string;
+  }>();
+  const enabled = content?.algorithm === ROOM_ENC_ALGO;
+
+  const [enableState, enable] = useAsyncCallback(
+    useCallback(async () => {
+      await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
+        algorithm: ROOM_ENC_ALGO,
+      });
+    }, [mx, room.roomId])
+  );
+
+  const enabling = enableState.status === AsyncStatus.Loading;
+
+  const [prompt, setPrompt] = useState(false);
+
+  const handleEnable = () => {
+    enable();
+    setPrompt(false);
+  };
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title="Room Encryption"
+        description={
+          enabled
+            ? 'Messages in this room are protected by end-to-end encryption.'
+            : 'Once enabled, encryption cannot be disabled!'
+        }
+        after={
+          enabled ? (
+            <Badge size="500" variant="Success" fill="Solid" radii="300">
+              <Text size="L400">Enabled</Text>
+            </Badge>
+          ) : (
+            <Button
+              size="300"
+              variant="Primary"
+              fill="Solid"
+              radii="300"
+              disabled={!canEnable}
+              onClick={() => setPrompt(true)}
+              before={enabling && <Spinner size="100" variant="Primary" fill="Solid" />}
+            >
+              <Text size="B300">Enable</Text>
+            </Button>
+          )
+        }
+      >
+        {enableState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(enableState.error as MatrixError).message}
+          </Text>
+        )}
+        {prompt && (
+          <Overlay open backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setPrompt(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">Enable Encryption</Text>
+                    </Box>
+                    <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
+                      <Icon src={Icons.Cross} />
+                    </IconButton>
+                  </Header>
+                  <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+                    <Text priority="400">
+                      Are you sure? Once enabled, encryption cannot be disabled!
+                    </Text>
+                    <Button type="submit" variant="Primary" onClick={handleEnable}>
+                      <Text size="B400">Enable E2E Encryption</Text>
+                    </Button>
+                  </Box>
+                </Dialog>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+      </SettingTile>
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx
new file mode 100644 (file)
index 0000000..7b329b1
--- /dev/null
@@ -0,0 +1,169 @@
+import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+  Button,
+  color,
+  config,
+  Icon,
+  Icons,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Spinner,
+  Text,
+} from 'folds';
+import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
+import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { stopPropagation } from '../../../utils/keyboard';
+
+const useVisibilityStr = () =>
+  useMemo(
+    () => ({
+      [HistoryVisibility.Invited]: 'After Invite',
+      [HistoryVisibility.Joined]: 'After Join',
+      [HistoryVisibility.Shared]: 'All Messages',
+      [HistoryVisibility.WorldReadable]: 'All Messages (Guests)',
+    }),
+    []
+  );
+
+const useVisibilityMenu = () =>
+  useMemo(
+    () => [
+      HistoryVisibility.Shared,
+      HistoryVisibility.Invited,
+      HistoryVisibility.Joined,
+      HistoryVisibility.WorldReadable,
+    ],
+    []
+  );
+
+type RoomHistoryVisibilityProps = {
+  powerLevels: IPowerLevels;
+};
+export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEdit = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomHistoryVisibility,
+    userPowerLevel
+  );
+
+  const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
+  const historyVisibility: HistoryVisibility =
+    visibilityEvent?.getContent<RoomHistoryVisibilityEventContent>().history_visibility ??
+    HistoryVisibility.Shared;
+  const visibilityMenu = useVisibilityMenu();
+  const visibilityStr = useVisibilityStr();
+
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const [submitState, submit] = useAsyncCallback(
+    useCallback(
+      async (visibility: HistoryVisibility) => {
+        const content: RoomHistoryVisibilityEventContent = {
+          history_visibility: visibility,
+        };
+        await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
+      },
+      [mx, room.roomId]
+    )
+  );
+  const submitting = submitState.status === AsyncStatus.Loading;
+
+  const handleChange = (visibility: HistoryVisibility) => {
+    submit(visibility);
+    setMenuAnchor(undefined);
+  };
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title="Message History Visibility"
+        description="Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect."
+        after={
+          <PopOut
+            anchor={menuAnchor}
+            position="Bottom"
+            align="End"
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setMenuAnchor(undefined),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                  escapeDeactivates: stopPropagation,
+                }}
+              >
+                <Menu style={{ padding: config.space.S100 }}>
+                  {visibilityMenu.map((visibility) => (
+                    <MenuItem
+                      key={visibility}
+                      size="300"
+                      radii="300"
+                      onClick={() => handleChange(visibility)}
+                      aria-pressed={visibility === historyVisibility}
+                    >
+                      <Text as="span" size="T300" truncate>
+                        {visibilityStr[visibility]}
+                      </Text>
+                    </MenuItem>
+                  ))}
+                </Menu>
+              </FocusTrap>
+            }
+          >
+            <Button
+              variant="Secondary"
+              fill="Soft"
+              size="300"
+              radii="300"
+              outlined
+              disabled={!canEdit || submitting}
+              onClick={handleOpenMenu}
+              after={
+                submitting ? (
+                  <Spinner size="100" variant="Secondary" />
+                ) : (
+                  <Icon size="100" src={Icons.ChevronBottom} />
+                )
+              }
+            >
+              <Text size="B300">{visibilityStr[historyVisibility]}</Text>
+            </Button>
+          </PopOut>
+        }
+      >
+        {submitState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(submitState.error as MatrixError).message}
+          </Text>
+        )}
+      </SettingTile>
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx
new file mode 100644 (file)
index 0000000..158ca25
--- /dev/null
@@ -0,0 +1,130 @@
+import React, { useCallback, useMemo } from 'react';
+import { color, Text } from 'folds';
+import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
+import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import {
+  JoinRulesSwitcher,
+  useRoomJoinRuleIcon,
+  useRoomJoinRuleLabel,
+  useSpaceJoinRuleIcon,
+} from '../../../components/JoinRulesSwitcher';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getStateEvents } from '../../../utils/room';
+
+type RestrictedRoomAllowContent = {
+  room_id: string;
+  type: RestrictedAllowType;
+};
+
+type RoomJoinRulesProps = {
+  powerLevels: IPowerLevels;
+};
+export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const roomVersion = parseInt(room.getVersion(), 10);
+  const allowRestricted = roomVersion >= 8;
+  const allowKnock = roomVersion >= 7;
+  const space = useSpaceOptionally();
+
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEdit = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomHistoryVisibility,
+    userPowerLevel
+  );
+
+  const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
+  const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
+  const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
+
+  const joinRules: Array<JoinRule> = useMemo(() => {
+    const r: JoinRule[] = [JoinRule.Invite];
+    if (allowKnock) {
+      r.push(JoinRule.Knock);
+    }
+    if (allowRestricted && space) {
+      r.push(JoinRule.Restricted);
+    }
+    r.push(JoinRule.Public);
+
+    return r;
+  }, [allowRestricted, allowKnock, space]);
+
+  const icons = useRoomJoinRuleIcon();
+  const spaceIcons = useSpaceJoinRuleIcon();
+  const labels = useRoomJoinRuleLabel();
+
+  const [submitState, submit] = useAsyncCallback(
+    useCallback(
+      async (joinRule: JoinRule) => {
+        const allow: RestrictedRoomAllowContent[] = [];
+        if (joinRule === JoinRule.Restricted) {
+          const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
+            event.getStateKey()
+          );
+          parents.forEach((parentRoomId) => {
+            if (!parentRoomId) return;
+            allow.push({
+              type: RestrictedAllowType.RoomMembership,
+              room_id: parentRoomId,
+            });
+          });
+        }
+
+        const c: RoomJoinRulesEventContent = {
+          join_rule: joinRule,
+        };
+        if (allow.length > 0) c.allow = allow;
+        await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
+      },
+      [mx, room]
+    )
+  );
+
+  const submitting = submitState.status === AsyncStatus.Loading;
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title={room.isSpaceRoom() ? 'Space Access' : 'Room Access'}
+        description={
+          room.isSpaceRoom()
+            ? 'Change how people can join the space.'
+            : 'Change how people can join the room.'
+        }
+        after={
+          <JoinRulesSwitcher
+            icons={room.isSpaceRoom() ? spaceIcons : icons}
+            labels={labels}
+            rules={joinRules}
+            value={rule}
+            onChange={submit}
+            disabled={!canEdit || submitting}
+            changing={submitting}
+          />
+        }
+      >
+        {submitState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(submitState.error as MatrixError).message}
+          </Text>
+        )}
+      </SettingTile>
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx
new file mode 100644 (file)
index 0000000..a3a62e1
--- /dev/null
@@ -0,0 +1,361 @@
+import {
+  Avatar,
+  Box,
+  Button,
+  Chip,
+  color,
+  Icon,
+  Icons,
+  Input,
+  Spinner,
+  Text,
+  TextArea,
+} from 'folds';
+import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import Linkify from 'linkify-react';
+import classNames from 'classnames';
+import { JoinRule, MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+  useRoomAvatar,
+  useRoomJoinRule,
+  useRoomName,
+  useRoomTopic,
+} from '../../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
+import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
+import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { useObjectURL } from '../../../hooks/useObjectURL';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+type RoomProfileEditProps = {
+  canEditAvatar: boolean;
+  canEditName: boolean;
+  canEditTopic: boolean;
+  avatar?: string;
+  name: string;
+  topic: string;
+  onClose: () => void;
+};
+export function RoomProfileEdit({
+  canEditAvatar,
+  canEditName,
+  canEditTopic,
+  avatar,
+  name,
+  topic,
+  onClose,
+}: RoomProfileEditProps) {
+  const room = useRoom();
+  const mx = useMatrixClient();
+  const alive = useAlive();
+  const useAuthentication = useMediaAuthentication();
+  const joinRule = useRoomJoinRule(room);
+  const [roomAvatar, setRoomAvatar] = useState(avatar);
+
+  const avatarUrl = roomAvatar
+    ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
+    : undefined;
+
+  const [imageFile, setImageFile] = useState<File>();
+  const avatarFileUrl = useObjectURL(imageFile);
+  const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
+  const uploadAtom = useMemo(() => {
+    if (imageFile) return createUploadAtom(imageFile);
+    return undefined;
+  }, [imageFile]);
+
+  const pickFile = useFilePicker(setImageFile, false);
+
+  const handleRemoveUpload = useCallback(() => {
+    setImageFile(undefined);
+    setRoomAvatar(avatar);
+  }, [avatar]);
+
+  const handleUploaded = useCallback((upload: UploadSuccess) => {
+    setRoomAvatar(upload.mxc);
+  }, []);
+
+  const [submitState, submit] = useAsyncCallback(
+    useCallback(
+      async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
+        if (roomAvatarMxc !== undefined) {
+          await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
+            url: roomAvatarMxc,
+          });
+        }
+        if (roomName !== undefined) {
+          await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
+        }
+        if (roomTopic !== undefined) {
+          await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
+        }
+      },
+      [mx, room.roomId]
+    )
+  );
+  const submitting = submitState.status === AsyncStatus.Loading;
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    if (uploadingAvatar) return;
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const nameInput = target?.nameInput as HTMLInputElement | undefined;
+    const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
+    if (!nameInput || !topicTextArea) return;
+
+    const roomName = nameInput.value.trim();
+    const roomTopic = topicTextArea.value.trim();
+
+    if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
+      return;
+    }
+
+    submit(
+      roomAvatar === avatar ? undefined : roomAvatar || null,
+      roomName === name ? undefined : roomName,
+      roomTopic === topic ? undefined : roomTopic
+    ).then(() => {
+      if (alive()) {
+        onClose();
+      }
+    });
+  };
+
+  return (
+    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
+      <Box gap="400">
+        <Box grow="Yes" direction="Column" gap="100">
+          <Text size="L400">Avatar</Text>
+          {uploadAtom ? (
+            <Box gap="200" direction="Column">
+              <CompactUploadCardRenderer
+                uploadAtom={uploadAtom}
+                onRemove={handleRemoveUpload}
+                onComplete={handleUploaded}
+              />
+            </Box>
+          ) : (
+            <Box gap="200">
+              <Button
+                type="button"
+                size="300"
+                variant="Secondary"
+                fill="Soft"
+                radii="300"
+                disabled={!canEditAvatar || submitting}
+                onClick={() => pickFile('image/*')}
+              >
+                <Text size="B300">Upload</Text>
+              </Button>
+              {!roomAvatar && avatar && (
+                <Button
+                  type="button"
+                  size="300"
+                  variant="Success"
+                  fill="None"
+                  radii="300"
+                  disabled={!canEditAvatar || submitting}
+                  onClick={() => setRoomAvatar(avatar)}
+                >
+                  <Text size="B300">Reset</Text>
+                </Button>
+              )}
+              {roomAvatar && (
+                <Button
+                  type="button"
+                  size="300"
+                  variant="Critical"
+                  fill="None"
+                  radii="300"
+                  disabled={!canEditAvatar || submitting}
+                  onClick={() => setRoomAvatar(undefined)}
+                >
+                  <Text size="B300">Remove</Text>
+                </Button>
+              )}
+            </Box>
+          )}
+        </Box>
+        <Box shrink="No">
+          <Avatar size="500" radii="300">
+            <RoomAvatar
+              roomId={room.roomId}
+              src={avatarUrl}
+              alt={name}
+              renderFallback={() => (
+                <RoomIcon
+                  space={room.isSpaceRoom()}
+                  size="400"
+                  joinRule={joinRule?.join_rule ?? JoinRule.Invite}
+                  filled
+                />
+              )}
+            />
+          </Avatar>
+        </Box>
+      </Box>
+      <Box direction="Inherit" gap="100">
+        <Text size="L400">Name</Text>
+        <Input
+          name="nameInput"
+          defaultValue={name}
+          variant="Secondary"
+          radii="300"
+          readOnly={!canEditName || submitting}
+        />
+      </Box>
+      <Box direction="Inherit" gap="100">
+        <Text size="L400">Topic</Text>
+        <TextArea
+          name="topicTextArea"
+          defaultValue={topic}
+          variant="Secondary"
+          radii="300"
+          readOnly={!canEditTopic || submitting}
+        />
+      </Box>
+      {submitState.status === AsyncStatus.Error && (
+        <Text size="T200" style={{ color: color.Critical.Main }}>
+          {(submitState.error as MatrixError).message}
+        </Text>
+      )}
+      <Box gap="300">
+        <Button
+          type="submit"
+          variant="Success"
+          size="300"
+          radii="300"
+          disabled={uploadingAvatar || submitting}
+          before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
+        >
+          <Text size="B300">Save</Text>
+        </Button>
+        <Button
+          type="reset"
+          onClick={onClose}
+          variant="Secondary"
+          fill="Soft"
+          size="300"
+          radii="300"
+        >
+          <Text size="B300">Cancel</Text>
+        </Button>
+      </Box>
+    </Box>
+  );
+}
+
+type RoomProfileProps = {
+  powerLevels: IPowerLevels;
+};
+export function RoomProfile({ powerLevels }: RoomProfileProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const directs = useAtomValue(mDirectAtom);
+  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+  const userPowerLevel = getPowerLevel(mx.getSafeUserId());
+
+  const avatar = useRoomAvatar(room, directs.has(room.roomId));
+  const name = useRoomName(room);
+  const topic = useRoomTopic(room);
+  const joinRule = useRoomJoinRule(room);
+
+  const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
+  const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
+  const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
+  const canEdit = canEditAvatar || canEditName || canEditTopic;
+
+  const avatarUrl = avatar
+    ? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
+    : undefined;
+
+  const [edit, setEdit] = useState(false);
+
+  const handleCloseEdit = useCallback(() => setEdit(false), []);
+
+  return (
+    <Box direction="Column" gap="100">
+      <Text size="L400">Profile</Text>
+      <SequenceCard
+        className={SequenceCardStyle}
+        variant="SurfaceVariant"
+        direction="Column"
+        gap="400"
+      >
+        {edit ? (
+          <RoomProfileEdit
+            canEditAvatar={canEditAvatar}
+            canEditName={canEditName}
+            canEditTopic={canEditTopic}
+            avatar={avatar}
+            name={name ?? ''}
+            topic={topic ?? ''}
+            onClose={handleCloseEdit}
+          />
+        ) : (
+          <Box gap="400">
+            <Box grow="Yes" direction="Column" gap="300">
+              <Box direction="Column" gap="100">
+                <Text className={BreakWord} size="H5">
+                  {name ?? 'Unknown'}
+                </Text>
+                {topic && (
+                  <Text className={classNames(BreakWord, LineClamp3)} size="T200">
+                    <Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
+                  </Text>
+                )}
+              </Box>
+              {canEdit && (
+                <Box gap="200">
+                  <Chip
+                    variant="Secondary"
+                    fill="Soft"
+                    radii="300"
+                    before={<Icon size="50" src={Icons.Pencil} />}
+                    onClick={() => setEdit(true)}
+                    outlined
+                  >
+                    <Text size="B300">Edit</Text>
+                  </Chip>
+                </Box>
+              )}
+            </Box>
+            <Box shrink="No">
+              <Avatar size="500" radii="300">
+                <RoomAvatar
+                  roomId={room.roomId}
+                  src={avatarUrl}
+                  alt={name}
+                  renderFallback={() => (
+                    <RoomIcon
+                      space={room.isSpaceRoom()}
+                      size="400"
+                      joinRule={joinRule?.join_rule ?? JoinRule.Invite}
+                      filled
+                    />
+                  )}
+                />
+              </Avatar>
+            </Box>
+          </Box>
+        )}
+      </SequenceCard>
+    </Box>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomPublish.tsx b/src/app/features/common-settings/general/RoomPublish.tsx
new file mode 100644 (file)
index 0000000..e27c687
--- /dev/null
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Box, color, Spinner, Switch, Text } from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+type RoomPublishProps = {
+  powerLevels: IPowerLevels;
+};
+export function RoomPublish({ powerLevels }: RoomPublishProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canEditCanonical = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomCanonicalAlias,
+    userPowerLevel
+  );
+
+  const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
+
+  const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility);
+
+  const loading =
+    visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title="Publish To Directory"
+        after={
+          <Box gap="200" alignItems="Center">
+            {loading && <Spinner variant="Secondary" />}
+            {!loading && visibilityState.status === AsyncStatus.Success && (
+              <Switch
+                value={visibilityState.data}
+                onChange={toggleVisibility}
+                disabled={!canEditCanonical}
+              />
+            )}
+          </Box>
+        }
+      >
+        {visibilityState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(visibilityState.error as MatrixError).message}
+          </Text>
+        )}
+
+        {toggleState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(toggleState.error as MatrixError).message}
+          </Text>
+        )}
+      </SettingTile>
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx
new file mode 100644 (file)
index 0000000..5d6bc5e
--- /dev/null
@@ -0,0 +1,228 @@
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import {
+  Button,
+  color,
+  Spinner,
+  Text,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Dialog,
+  Header,
+  config,
+  Box,
+  IconButton,
+  Icon,
+  Icons,
+  Input,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { MatrixError } from 'matrix-js-sdk';
+import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useRoom } from '../../../hooks/useRoom';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useCapabilities } from '../../../hooks/useCapabilities';
+import { stopPropagation } from '../../../utils/keyboard';
+
+type RoomUpgradeProps = {
+  powerLevels: IPowerLevels;
+  requestClose: () => void;
+};
+export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+  const createContent = useStateEvent(
+    room,
+    StateEvent.RoomCreate
+  )?.getContent<RoomCreateEventContent>();
+  const roomVersion = createContent?.room_version ?? 1;
+  const predecessorRoomId = createContent?.predecessor?.room_id;
+
+  const capabilities = useCapabilities();
+  const defaultRoomVersion = capabilities['m.room_versions']?.default;
+
+  const tombstoneContent = useStateEvent(
+    room,
+    StateEvent.RoomTombstone
+  )?.getContent<RoomTombstoneEventContent>();
+  const replacementRoom = tombstoneContent?.replacement_room;
+
+  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+  const canUpgrade = powerLevelAPI.canSendStateEvent(
+    powerLevels,
+    StateEvent.RoomTombstone,
+    userPowerLevel
+  );
+
+  const handleOpenRoom = () => {
+    if (replacementRoom) {
+      requestClose();
+      if (room.isSpaceRoom()) {
+        navigateSpace(replacementRoom);
+      } else {
+        navigateRoom(replacementRoom);
+      }
+    }
+  };
+
+  const handleOpenOldRoom = () => {
+    if (predecessorRoomId) {
+      requestClose();
+      if (room.isSpaceRoom()) {
+        navigateSpace(predecessorRoomId);
+      } else {
+        navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
+      }
+    }
+  };
+
+  const [upgradeState, upgrade] = useAsyncCallback(
+    useCallback(
+      async (version: string) => {
+        await mx.upgradeRoom(room.roomId, version);
+      },
+      [mx, room]
+    )
+  );
+
+  const upgrading = upgradeState.status === AsyncStatus.Loading;
+
+  const [prompt, setPrompt] = useState(false);
+
+  const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const versionInput = target?.versionInput as HTMLInputElement | undefined;
+    const version = versionInput?.value.trim();
+    if (!version) return;
+
+    upgrade(version);
+    setPrompt(false);
+  };
+
+  return (
+    <SequenceCard
+      className={SequenceCardStyle}
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="400"
+    >
+      <SettingTile
+        title={room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
+        description={
+          replacementRoom
+            ? tombstoneContent.body ||
+              `This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
+            : `Current room version: ${roomVersion}.`
+        }
+        after={
+          <Box alignItems="Center" gap="200">
+            {predecessorRoomId && (
+              <Button
+                size="300"
+                variant="Secondary"
+                fill="Soft"
+                outlined
+                radii="300"
+                onClick={handleOpenOldRoom}
+              >
+                <Text size="B300">{room.isSpaceRoom() ? 'Old Space' : 'Old Room'}</Text>
+              </Button>
+            )}
+            {replacementRoom ? (
+              <Button
+                size="300"
+                variant="Success"
+                fill="Solid"
+                radii="300"
+                onClick={handleOpenRoom}
+              >
+                <Text size="B300">{room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'}</Text>
+              </Button>
+            ) : (
+              <Button
+                size="300"
+                variant="Secondary"
+                fill="Solid"
+                radii="300"
+                disabled={upgrading || !canUpgrade}
+                before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
+                onClick={() => setPrompt(true)}
+              >
+                <Text size="B300">Upgrade</Text>
+              </Button>
+            )}
+          </Box>
+        }
+      >
+        {upgradeState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(upgradeState.error as MatrixError).message}
+          </Text>
+        )}
+
+        {prompt && (
+          <Overlay open backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setPrompt(false),
+                  clickOutsideDeactivates: true,
+                  escapeDeactivates: stopPropagation,
+                }}
+              >
+                <Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
+                  <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">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
+                    </Box>
+                    <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
+                      <Icon src={Icons.Cross} />
+                    </IconButton>
+                  </Header>
+                  <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+                    <Text priority="400" style={{ color: color.Critical.Main }}>
+                      <b>This action is irreversible!</b>
+                    </Text>
+                    <Box direction="Column" gap="100">
+                      <Text size="L400">Version</Text>
+                      <Input
+                        defaultValue={defaultRoomVersion}
+                        name="versionInput"
+                        variant="Background"
+                        required
+                      />
+                    </Box>
+                    <Button type="submit" variant="Secondary">
+                      <Text size="B400">
+                        {room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
+                      </Text>
+                    </Button>
+                  </Box>
+                </Dialog>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+      </SettingTile>
+    </SequenceCard>
+  );
+}
diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts
new file mode 100644 (file)
index 0000000..80804b0
--- /dev/null
@@ -0,0 +1,7 @@
+export * from './RoomAddress';
+export * from './RoomEncryption';
+export * from './RoomHistoryVisibility';
+export * from './RoomJoinRules';
+export * from './RoomProfile';
+export * from './RoomPublish';
+export * from './RoomUpgrade';
diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx
new file mode 100644 (file)
index 0000000..40e055b
--- /dev/null
@@ -0,0 +1,353 @@
+import React, {
+  ChangeEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Box,
+  Chip,
+  config,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  PopOut,
+  RectCords,
+  Scroll,
+  Spinner,
+  Text,
+  toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { RoomMember } from 'matrix-js-sdk';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomMembers } from '../../../hooks/useRoomMembers';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import {
+  useFlattenPowerLevelTagMembers,
+  usePowerLevelTags,
+} from '../../../hooks/usePowerLevelTags';
+import { VirtualTile } from '../../../components/virtualizer';
+import { MemberTile } from '../../../components/member-tile';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
+import { ServerBadge } from '../../../components/server-badge';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useDebounce } from '../../../hooks/useDebounce';
+import {
+  SearchItemStrGetter,
+  useAsyncSearch,
+  UseAsyncSearchOptions,
+} from '../../../hooks/useAsyncSearch';
+import { getMemberSearchStr } from '../../../utils/room';
+import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
+import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
+import { settingsAtom } from '../../../state/settings';
+import { useSetting } from '../../../state/hooks/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
+import { MemberSortMenu } from '../../../components/MemberSortMenu';
+import { ScrollTopContainer } from '../../../components/scroll-top-container';
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  limit: 1000,
+  matchOptions: {
+    contain: true,
+  },
+  normalizeOptions: {
+    ignoreWhitespace: false,
+  },
+};
+
+const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
+const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
+  getMemberSearchStr(m, query, mxIdToName);
+
+type MembersProps = {
+  requestClose: () => void;
+};
+export function Members({ requestClose }: MembersProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const members = useRoomMembers(mx, room.roomId);
+  const fetchingMembers = members.length < room.getJoinedMemberCount();
+
+  const powerLevels = usePowerLevels(room);
+  const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
+  const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+
+  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
+  const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
+  const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+
+  const sortedMembers = useMemo(
+    () =>
+      Array.from(members)
+        .filter(membershipFilter.filterFn)
+        .sort(memberSort.sortFn)
+        .sort((a, b) => b.powerLevel - a.powerLevel),
+    [members, membershipFilter, memberSort]
+  );
+
+  const [result, search, resetSearch] = useAsyncSearch(
+    sortedMembers,
+    getRoomMemberStr,
+    SEARCH_OPTIONS
+  );
+  if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
+
+  const flattenTagMembers = useFlattenPowerLevelTagMembers(
+    result?.items ?? sortedMembers,
+    getPowerLevel,
+    getPowerLevelTag
+  );
+
+  const virtualizer = useVirtualizer({
+    count: flattenTagMembers.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 40,
+    overscan: 10,
+  });
+
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
+    useCallback(
+      (evt) => {
+        if (evt.target.value) search(evt.target.value);
+        else resetSearch();
+      },
+      [search, resetSearch]
+    ),
+    { wait: 200 }
+  );
+
+  const handleSearchReset = () => {
+    if (searchInputRef.current) {
+      searchInputRef.current.value = '';
+      searchInputRef.current.focus();
+    }
+    resetSearch();
+  };
+
+  const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const btn = evt.currentTarget as HTMLButtonElement;
+    const userId = btn.getAttribute('data-user-id');
+    openProfileViewer(userId, room.roomId);
+    requestClose();
+  };
+
+  return (
+    <Page>
+      <PageHeader outlined={false}>
+        <Box grow="Yes" gap="200">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <Text size="H3" truncate>
+              {room.getJoinedMemberCount()} Members
+            </Text>
+          </Box>
+          <Box shrink="No">
+            <IconButton onClick={requestClose} variant="Surface">
+              <Icon src={Icons.Cross} />
+            </IconButton>
+          </Box>
+        </Box>
+      </PageHeader>
+      <Box grow="Yes" style={{ position: 'relative' }}>
+        <Scroll ref={scrollRef} hideTrack visibility="Hover">
+          <PageContent>
+            <Box direction="Column" gap="200">
+              <Box
+                style={{ position: 'sticky', top: config.space.S100, zIndex: 1 }}
+                direction="Column"
+                gap="100"
+              >
+                <Input
+                  ref={searchInputRef}
+                  onChange={handleSearchChange}
+                  before={<Icon size="200" src={Icons.Search} />}
+                  variant="SurfaceVariant"
+                  size="500"
+                  placeholder="Search"
+                  outlined
+                  after={
+                    result && (
+                      <Chip
+                        variant={result.items.length > 0 ? 'Success' : 'Critical'}
+                        outlined
+                        size="400"
+                        radii="Pill"
+                        aria-pressed
+                        onClick={handleSearchReset}
+                        after={<Icon size="50" src={Icons.Cross} />}
+                      >
+                        <Text size="B300">
+                          {result.items.length === 0
+                            ? 'No Results'
+                            : `${result.items.length} Results`}
+                        </Text>
+                      </Chip>
+                    )
+                  }
+                />
+              </Box>
+              <Box ref={scrollTopAnchorRef} alignItems="Center" justifyContent="End" gap="200">
+                <UseStateProvider initial={undefined}>
+                  {(anchor: RectCords | undefined, setAnchor) => (
+                    <PopOut
+                      anchor={anchor}
+                      position="Bottom"
+                      align="Start"
+                      offset={4}
+                      content={
+                        <MembershipFilterMenu
+                          selected={membershipFilterIndex}
+                          onSelect={setMembershipFilterIndex}
+                          requestClose={() => setAnchor(undefined)}
+                        />
+                      }
+                    >
+                      <Chip
+                        onClick={
+                          ((evt) =>
+                            setAnchor(
+                              evt.currentTarget.getBoundingClientRect()
+                            )) as MouseEventHandler<HTMLButtonElement>
+                        }
+                        variant="SurfaceVariant"
+                        size="400"
+                        radii="300"
+                        before={<Icon src={Icons.Filter} size="50" />}
+                      >
+                        <Text size="T200">{membershipFilter.name}</Text>
+                      </Chip>
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+                <UseStateProvider initial={undefined}>
+                  {(anchor: RectCords | undefined, setAnchor) => (
+                    <PopOut
+                      anchor={anchor}
+                      position="Bottom"
+                      align="End"
+                      offset={4}
+                      content={
+                        <MemberSortMenu
+                          selected={sortFilterIndex}
+                          onSelect={setSortFilterIndex}
+                          requestClose={() => setAnchor(undefined)}
+                        />
+                      }
+                    >
+                      <Chip
+                        onClick={
+                          ((evt) =>
+                            setAnchor(
+                              evt.currentTarget.getBoundingClientRect()
+                            )) as MouseEventHandler<HTMLButtonElement>
+                        }
+                        variant="SurfaceVariant"
+                        size="400"
+                        radii="300"
+                        after={<Icon src={Icons.Sort} size="50" />}
+                      >
+                        <Text size="T200">{memberSort.name}</Text>
+                      </Chip>
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+              </Box>
+              <ScrollTopContainer
+                style={{ top: toRem(64) }}
+                scrollRef={scrollRef}
+                anchorRef={scrollTopAnchorRef}
+              >
+                <IconButton
+                  onClick={() => virtualizer.scrollToOffset(0)}
+                  variant="Surface"
+                  radii="Pill"
+                  outlined
+                  size="300"
+                  aria-label="Scroll to Top"
+                >
+                  <Icon src={Icons.ChevronTop} size="300" />
+                </IconButton>
+              </ScrollTopContainer>
+              {fetchingMembers && (
+                <Box justifyContent="Center">
+                  <Spinner />
+                </Box>
+              )}
+              <Box
+                style={{
+                  position: 'relative',
+                  height: virtualizer.getTotalSize(),
+                }}
+                direction="Column"
+                gap="100"
+              >
+                {virtualizer.getVirtualItems().map((vItem) => {
+                  const tagOrMember = flattenTagMembers[vItem.index];
+
+                  if ('userId' in tagOrMember) {
+                    const server = getMxIdServer(tagOrMember.userId);
+                    return (
+                      <VirtualTile
+                        virtualItem={vItem}
+                        key={`${tagOrMember.userId}-${vItem.index}`}
+                        ref={virtualizer.measureElement}
+                      >
+                        <div style={{ paddingTop: config.space.S200 }}>
+                          <MemberTile
+                            data-user-id={tagOrMember.userId}
+                            onClick={handleMemberClick}
+                            mx={mx}
+                            room={room}
+                            member={tagOrMember}
+                            useAuthentication={useAuthentication}
+                            after={
+                              server && (
+                                <Box as="span" shrink="No" alignSelf="End">
+                                  <ServerBadge server={server} fill="None" />
+                                </Box>
+                              )
+                            }
+                          />
+                        </div>
+                      </VirtualTile>
+                    );
+                  }
+
+                  return (
+                    <VirtualTile
+                      virtualItem={vItem}
+                      key={vItem.index}
+                      ref={virtualizer.measureElement}
+                    >
+                      <div
+                        style={{
+                          paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+                        }}
+                      >
+                        <Text size="L400">{tagOrMember.name}</Text>
+                      </div>
+                    </VirtualTile>
+                  );
+                })}
+              </Box>
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/members/index.ts b/src/app/features/common-settings/members/index.ts
new file mode 100644 (file)
index 0000000..9e0df78
--- /dev/null
@@ -0,0 +1 @@
+export * from './Members';
diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx
new file mode 100644 (file)
index 0000000..54c9c5d
--- /dev/null
@@ -0,0 +1,286 @@
+/* eslint-disable react/no-array-index-key */
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
+import produce from 'immer';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import {
+  applyPermissionPower,
+  getPermissionPower,
+  IPowerLevels,
+  PermissionLocation,
+  usePowerLevelsAPI,
+} from '../../../hooks/usePowerLevels';
+import { PermissionGroup } from './types';
+import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { useRoom } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PowerSwitcher } from '../../../components/power';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+const USER_DEFAULT_LOCATION: PermissionLocation = {
+  user: true,
+};
+
+type PermissionGroupsProps = {
+  powerLevels: IPowerLevels;
+  permissionGroups: PermissionGroup[];
+};
+export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGroupsProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const alive = useAlive();
+  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+  const canChangePermission = canSendStateEvent(
+    StateEvent.RoomPowerLevels,
+    getPowerLevel(mx.getSafeUserId())
+  );
+  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+  const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
+
+  const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
+    new Map()
+  );
+
+  useEffect(() => {
+    // reset permission update if component rerender
+    // as permission location object reference has changed
+    setPermissionUpdate(new Map());
+  }, [permissionGroups]);
+
+  const handleChangePermission = (
+    location: PermissionLocation,
+    newPower: number,
+    currentPower: number
+  ) => {
+    setPermissionUpdate((p) => {
+      const up: typeof p = new Map();
+      p.forEach((value, key) => {
+        up.set(key, value);
+      });
+      if (newPower === currentPower) {
+        up.delete(location);
+      } else {
+        up.set(location, newPower);
+      }
+      return up;
+    });
+  };
+
+  const [applyState, applyChanges] = useAsyncCallback(
+    useCallback(async () => {
+      const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => {
+        permissionGroups.forEach((group) =>
+          group.items.forEach((item) => {
+            const power = getPermissionPower(powerLevels, item.location);
+            applyPermissionPower(draftPowerLevels, item.location, power);
+          })
+        );
+        permissionUpdate.forEach((power, location) =>
+          applyPermissionPower(draftPowerLevels, location, power)
+        );
+        return draftPowerLevels;
+      });
+      await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
+    }, [mx, room, powerLevels, permissionUpdate, permissionGroups])
+  );
+
+  const resetChanges = useCallback(() => {
+    setPermissionUpdate(new Map());
+  }, []);
+
+  const handleApplyChanges = () => {
+    applyChanges().then(() => {
+      if (alive()) {
+        resetChanges();
+      }
+    });
+  };
+
+  const applyingChanges = applyState.status === AsyncStatus.Loading;
+  const hasChanges = permissionUpdate.size > 0;
+
+  const renderUserGroup = () => {
+    const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION);
+    const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
+    const value = powerUpdate ?? power;
+
+    const tag = getPowerLevelTag(value);
+    const powerChanges = value !== power;
+
+    return (
+      <Box direction="Column" gap="100">
+        <Text size="L400">Users</Text>
+        <SequenceCard
+          variant="SurfaceVariant"
+          className={SequenceCardStyle}
+          direction="Column"
+          gap="400"
+        >
+          <SettingTile
+            title="Default Power"
+            description="Default power level for all users."
+            after={
+              <PowerSwitcher
+                powerLevelTags={powerLevelTags}
+                value={value}
+                onChange={(v) => handleChangePermission(USER_DEFAULT_LOCATION, v, power)}
+              >
+                {(handleOpen, opened) => (
+                  <Chip
+                    variant={powerChanges ? 'Success' : 'Secondary'}
+                    outlined={powerChanges}
+                    fill="Soft"
+                    radii="Pill"
+                    aria-selected={opened}
+                    disabled={!canChangePermission || applyingChanges}
+                    after={
+                      powerChanges && (
+                        <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+                      )
+                    }
+                    before={
+                      canChangePermission && (
+                        <Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
+                      )
+                    }
+                    onClick={handleOpen}
+                  >
+                    <Text size="B300" truncate>
+                      {tag.name}
+                    </Text>
+                  </Chip>
+                )}
+              </PowerSwitcher>
+            }
+          />
+        </SequenceCard>
+      </Box>
+    );
+  };
+
+  return (
+    <>
+      {renderUserGroup()}
+      {permissionGroups.map((group, groupIndex) => (
+        <Box key={groupIndex} direction="Column" gap="100">
+          <Text size="L400">{group.name}</Text>
+          {group.items.map((item, itemIndex) => {
+            const power = getPermissionPower(powerLevels, item.location);
+            const powerUpdate = permissionUpdate.get(item.location);
+            const value = powerUpdate ?? power;
+
+            const tag = getPowerLevelTag(value);
+            const powerChanges = value !== power;
+
+            return (
+              <SequenceCard
+                key={itemIndex}
+                variant="SurfaceVariant"
+                className={SequenceCardStyle}
+                direction="Column"
+                gap="400"
+              >
+                <SettingTile
+                  title={item.name}
+                  description={item.description}
+                  after={
+                    <PowerSwitcher
+                      powerLevelTags={powerLevelTags}
+                      value={value}
+                      onChange={(v) => handleChangePermission(item.location, v, power)}
+                    >
+                      {(handleOpen, opened) => (
+                        <Chip
+                          variant={powerChanges ? 'Success' : 'Secondary'}
+                          outlined={powerChanges}
+                          fill="Soft"
+                          radii="Pill"
+                          aria-selected={opened}
+                          disabled={!canChangePermission || applyingChanges}
+                          after={
+                            powerChanges && (
+                              <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+                            )
+                          }
+                          before={
+                            canChangePermission && (
+                              <Icon
+                                size="50"
+                                src={opened ? Icons.ChevronTop : Icons.ChevronBottom}
+                              />
+                            )
+                          }
+                          onClick={handleOpen}
+                        >
+                          <Text size="B300" truncate>
+                            {tag.name}
+                          </Text>
+                          {value < maxPower && <Text size="T200">& Above</Text>}
+                        </Chip>
+                      )}
+                    </PowerSwitcher>
+                  }
+                />
+              </SequenceCard>
+            );
+          })}
+        </Box>
+      ))}
+
+      {hasChanges && (
+        <Menu
+          style={{
+            position: 'sticky',
+            padding: config.space.S200,
+            paddingLeft: config.space.S400,
+            bottom: config.space.S400,
+            left: config.space.S400,
+            right: 0,
+            zIndex: 1,
+          }}
+          variant="Success"
+        >
+          <Box alignItems="Center" gap="400">
+            <Box grow="Yes" direction="Column">
+              {applyState.status === AsyncStatus.Error ? (
+                <Text size="T200">
+                  <b>Failed to apply changes! Please try again.</b>
+                </Text>
+              ) : (
+                <Text size="T200">
+                  <b>Changes saved! Apply when ready.</b>
+                </Text>
+              )}
+            </Box>
+            <Box shrink="No" gap="200">
+              <Button
+                size="300"
+                variant="Success"
+                fill="None"
+                radii="300"
+                disabled={applyingChanges}
+                onClick={resetChanges}
+              >
+                <Text size="B300">Reset</Text>
+              </Button>
+              <Button
+                size="300"
+                variant="Success"
+                radii="300"
+                disabled={applyingChanges}
+                before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
+                onClick={handleApplyChanges}
+              >
+                <Text size="B300">Apply Changes</Text>
+              </Button>
+            </Box>
+          </Box>
+        </Menu>
+      )}
+    </>
+  );
+}
diff --git a/src/app/features/common-settings/permissions/Powers.tsx b/src/app/features/common-settings/permissions/Powers.tsx
new file mode 100644 (file)
index 0000000..b7dadf5
--- /dev/null
@@ -0,0 +1,176 @@
+/* eslint-disable react/no-array-index-key */
+import React, { useState, MouseEventHandler, ReactNode } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+  Box,
+  Button,
+  Chip,
+  Text,
+  RectCords,
+  PopOut,
+  Menu,
+  Scroll,
+  toRem,
+  config,
+  color,
+} from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { SettingTile } from '../../../components/setting-tile';
+import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
+import { useRoom } from '../../../hooks/useRoom';
+import { PowerColorBadge, PowerIcon } from '../../../components/power';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { stopPropagation } from '../../../utils/keyboard';
+import { PermissionGroup } from './types';
+
+type PeekPermissionsProps = {
+  powerLevels: IPowerLevels;
+  power: number;
+  permissionGroups: PermissionGroup[];
+  children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
+};
+function PeekPermissions({ powerLevels, power, permissionGroups, children }: PeekPermissionsProps) {
+  const [menuCords, setMenuCords] = useState<RectCords>();
+
+  const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={menuCords}
+      offset={5}
+      position="Bottom"
+      align="Center"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuCords(undefined),
+            clickOutsideDeactivates: true,
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <Menu
+            style={{
+              maxHeight: '75vh',
+              maxWidth: toRem(300),
+              display: 'flex',
+            }}
+          >
+            <Box grow="Yes" tabIndex={0}>
+              <Scroll size="0" hideTrack visibility="Hover">
+                <Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
+                  {permissionGroups.map((group, groupIndex) => (
+                    <Box key={groupIndex} direction="Column" gap="100">
+                      <Text size="L400">{group.name}</Text>
+                      <div>
+                        {group.items.map((item, itemIndex) => {
+                          const requiredPower = getPermissionPower(powerLevels, item.location);
+                          const hasPower = requiredPower <= power;
+
+                          return (
+                            <Text
+                              key={itemIndex}
+                              size="T200"
+                              style={{
+                                color: hasPower ? undefined : color.Critical.Main,
+                              }}
+                            >
+                              {hasPower ? '✅' : '❌'} {item.name}
+                            </Text>
+                          );
+                        })}
+                      </div>
+                    </Box>
+                  ))}
+                </Box>
+              </Scroll>
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      {children(handleOpen, !!menuCords)}
+    </PopOut>
+  );
+}
+
+type PowersProps = {
+  powerLevels: IPowerLevels;
+  permissionGroups: PermissionGroup[];
+  onEdit?: () => void;
+};
+export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
+
+  return (
+    <Box direction="Column" gap="100">
+      <SequenceCard
+        variant="SurfaceVariant"
+        className={SequenceCardStyle}
+        direction="Column"
+        gap="400"
+      >
+        <SettingTile
+          title="Power Levels"
+          description="Manage and customize incremental power levels for users."
+          after={
+            onEdit && (
+              <Box gap="200">
+                <Button
+                  variant="Secondary"
+                  fill="Soft"
+                  size="300"
+                  radii="300"
+                  outlined
+                  onClick={onEdit}
+                >
+                  <Text size="B300">Edit</Text>
+                </Button>
+              </Box>
+            )
+          }
+        />
+        <SettingTile>
+          <Box gap="200" wrap="Wrap">
+            {getPowers(powerLevelTags).map((power) => {
+              const tag = powerLevelTags[power];
+              const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+
+              return (
+                <PeekPermissions
+                  key={power}
+                  powerLevels={powerLevels}
+                  power={power}
+                  permissionGroups={permissionGroups}
+                >
+                  {(openMenu, opened) => (
+                    <Chip
+                      onClick={openMenu}
+                      variant="Secondary"
+                      aria-pressed={opened}
+                      radii="300"
+                      before={<PowerColorBadge color={tag.color} />}
+                      after={tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
+                    >
+                      <Text size="T300" truncate>
+                        <b>{tag.name}</b>
+                      </Text>
+                    </Chip>
+                  )}
+                </PeekPermissions>
+              );
+            })}
+          </Box>
+        </SettingTile>
+      </SequenceCard>
+    </Box>
+  );
+}
diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx
new file mode 100644 (file)
index 0000000..25d2ba9
--- /dev/null
@@ -0,0 +1,579 @@
+import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+  Box,
+  Text,
+  Chip,
+  Icon,
+  Icons,
+  IconButton,
+  Scroll,
+  Button,
+  Input,
+  RectCords,
+  PopOut,
+  Menu,
+  config,
+  Spinner,
+  toRem,
+  TooltipProvider,
+  Tooltip,
+} from 'folds';
+import { HexColorPicker } from 'react-colorful';
+import { useAtomValue } from 'jotai';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { IPowerLevels } from '../../../hooks/usePowerLevels';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import {
+  getPowers,
+  getTagIconSrc,
+  getUsedPowers,
+  PowerLevelTag,
+  PowerLevelTagIcon,
+  PowerLevelTags,
+  usePowerLevelTags,
+} from '../../../hooks/usePowerLevelTags';
+import { useRoom } from '../../../hooks/useRoom';
+import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
+import { PowerColorBadge, PowerIcon } from '../../../components/power';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useAlive } from '../../../hooks/useAlive';
+import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
+
+type EditPowerProps = {
+  maxPower: number;
+  power?: number;
+  tag?: PowerLevelTag;
+  onSave: (power: number, tag: PowerLevelTag) => void;
+  onClose: () => void;
+};
+function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const useAuthentication = useMediaAuthentication();
+
+  const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
+
+  const [iconFile, setIconFile] = useState<File>();
+  const pickFile = useFilePicker(setIconFile, false);
+
+  const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
+  const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
+  const uploadingIcon = iconFile && !tagIcon;
+  const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon);
+
+  const iconUploadAtom = useMemo(() => {
+    if (iconFile) return createUploadAtom(iconFile);
+    return undefined;
+  }, [iconFile]);
+
+  const handleRemoveIconUpload = useCallback(() => {
+    setIconFile(undefined);
+  }, []);
+
+  const handleIconUploaded = useCallback((upload: UploadSuccess) => {
+    setTagIcon({
+      key: upload.mxc,
+    });
+    setIconFile(undefined);
+  }, []);
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    if (uploadingIcon) return;
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const powerInput = target?.powerInput as HTMLInputElement | undefined;
+    const nameInput = target?.nameInput as HTMLInputElement | undefined;
+    if (!powerInput || !nameInput) return;
+
+    const tagPower = parseInt(powerInput.value, 10);
+    if (Number.isNaN(tagPower)) return;
+    if (tagPower > maxPower) return;
+    const tagName = nameInput.value.trim();
+    if (!tagName) return;
+
+    const editedTag: PowerLevelTag = {
+      name: tagName,
+      color: tagColor,
+      icon: tagIcon,
+    };
+
+    onSave(power ?? tagPower, editedTag);
+    onClose();
+  };
+
+  return (
+    <Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
+      <Box direction="Column" gap="300">
+        <Box gap="200">
+          <Box shrink="No" direction="Column" gap="100">
+            <Text size="L400">Color</Text>
+            <Box gap="200">
+              <HexColorPickerPopOut
+                picker={<HexColorPicker color={tagColor} onChange={setTagColor} />}
+                onRemove={() => setTagColor(undefined)}
+              >
+                {(openPicker, opened) => (
+                  <Button
+                    aria-pressed={opened}
+                    onClick={openPicker}
+                    size="300"
+                    type="button"
+                    variant="Secondary"
+                    fill="Soft"
+                    radii="300"
+                    before={<PowerColorBadge color={tagColor} />}
+                  >
+                    <Text size="B300">Pick</Text>
+                  </Button>
+                )}
+              </HexColorPickerPopOut>
+            </Box>
+          </Box>
+          <Box grow="Yes" direction="Column" gap="100">
+            <Text size="L400">Name</Text>
+            <Input
+              name="nameInput"
+              defaultValue={tag?.name}
+              placeholder="Bot"
+              size="300"
+              variant="Secondary"
+              radii="300"
+              required
+            />
+          </Box>
+          <Box style={{ maxWidth: toRem(74) }} grow="Yes" direction="Column" gap="100">
+            <Text size="L400">Power</Text>
+            <Input
+              defaultValue={power}
+              name="powerInput"
+              size="300"
+              variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
+              radii="300"
+              type="number"
+              placeholder="75"
+              max={maxPower}
+              outlined={typeof power === 'number'}
+              readOnly={typeof power === 'number'}
+              required
+            />
+          </Box>
+        </Box>
+      </Box>
+      <Box direction="Column" gap="100">
+        <Text size="L400">Icon</Text>
+        {iconUploadAtom && !tagIconSrc ? (
+          <CompactUploadCardRenderer
+            uploadAtom={iconUploadAtom}
+            onRemove={handleRemoveIconUpload}
+            onComplete={handleIconUploaded}
+          />
+        ) : (
+          <Box gap="200" alignItems="Center">
+            {tagIconSrc ? (
+              <>
+                <PowerIcon size="500" iconSrc={tagIconSrc} />
+                <Button
+                  onClick={() => setTagIcon(undefined)}
+                  type="button"
+                  size="300"
+                  variant="Critical"
+                  fill="None"
+                  radii="300"
+                >
+                  <Text size="B300">Remove</Text>
+                </Button>
+              </>
+            ) : (
+              <>
+                <UseStateProvider initial={undefined}>
+                  {(cords: RectCords | undefined, setCords) => (
+                    <PopOut
+                      position="Bottom"
+                      anchor={cords}
+                      content={
+                        <EmojiBoard
+                          imagePackRooms={imagePackRooms}
+                          returnFocusOnDeactivate={false}
+                          allowTextCustomEmoji={false}
+                          addToRecentEmoji={false}
+                          onEmojiSelect={(key) => {
+                            setTagIcon({ key });
+                            setCords(undefined);
+                          }}
+                          onCustomEmojiSelect={(mxc) => {
+                            setTagIcon({ key: mxc });
+                            setCords(undefined);
+                          }}
+                          requestClose={() => {
+                            setCords(undefined);
+                          }}
+                        />
+                      }
+                    >
+                      <Button
+                        onClick={
+                          ((evt) =>
+                            setCords(
+                              evt.currentTarget.getBoundingClientRect()
+                            )) as MouseEventHandler<HTMLButtonElement>
+                        }
+                        type="button"
+                        size="300"
+                        variant="Secondary"
+                        fill="Soft"
+                        radii="300"
+                        before={<Icon size="50" src={Icons.SmilePlus} />}
+                      >
+                        <Text size="B300">Pick</Text>
+                      </Button>
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+                <Button
+                  onClick={() => pickFile('image/*')}
+                  type="button"
+                  size="300"
+                  variant="Secondary"
+                  fill="None"
+                  radii="300"
+                >
+                  <Text size="B300">Import</Text>
+                </Button>
+              </>
+            )}
+          </Box>
+        )}
+      </Box>
+      <Box direction="Row" gap="200" justifyContent="Start">
+        <Button
+          style={{ minWidth: toRem(64) }}
+          type="submit"
+          size="300"
+          variant="Success"
+          radii="300"
+          disabled={uploadingIcon}
+        >
+          <Text size="B300">Save</Text>
+        </Button>
+        <Button
+          type="button"
+          size="300"
+          variant="Secondary"
+          fill="Soft"
+          radii="300"
+          onClick={onClose}
+        >
+          <Text size="B300">Cancel</Text>
+        </Button>
+      </Box>
+    </Box>
+  );
+}
+
+type PowersEditorProps = {
+  powerLevels: IPowerLevels;
+  requestClose: () => void;
+};
+export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const alive = useAlive();
+  const [usedPowers, maxPower] = useMemo(() => {
+    const up = getUsedPowers(powerLevels);
+    return [up, Math.max(...Array.from(up))];
+  }, [powerLevels]);
+
+  const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
+  const [editedPowerTags, setEditedPowerTags] = useState<PowerLevelTags>();
+  const [deleted, setDeleted] = useState<Set<number>>(new Set());
+
+  const [createTag, setCreateTag] = useState(false);
+
+  const handleToggleDelete = useCallback((power: number) => {
+    setDeleted((powers) => {
+      const newIds = new Set(powers);
+      if (newIds.has(power)) {
+        newIds.delete(power);
+      } else {
+        newIds.add(power);
+      }
+      return newIds;
+    });
+  }, []);
+
+  const handleSaveTag = useCallback(
+    (power: number, tag: PowerLevelTag) => {
+      setEditedPowerTags((tags) => {
+        const editedTags = { ...(tags ?? powerLevelTags) };
+        editedTags[power] = tag;
+        return editedTags;
+      });
+    },
+    [powerLevelTags]
+  );
+
+  const [applyState, applyChanges] = useAsyncCallback(
+    useCallback(async () => {
+      const content: PowerLevelTags = { ...(editedPowerTags ?? powerLevelTags) };
+      deleted.forEach((power) => {
+        delete content[power];
+      });
+      await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
+    }, [mx, room, powerLevelTags, editedPowerTags, deleted])
+  );
+
+  const resetChanges = useCallback(() => {
+    setEditedPowerTags(undefined);
+    setDeleted(new Set());
+  }, []);
+
+  const handleApplyChanges = () => {
+    applyChanges().then(() => {
+      if (alive()) {
+        resetChanges();
+      }
+    });
+  };
+
+  const applyingChanges = applyState.status === AsyncStatus.Loading;
+  const hasChanges = editedPowerTags || deleted.size > 0;
+
+  const powerTags = editedPowerTags ?? powerLevelTags;
+  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">Permissions</Text>
+            </Chip>
+          </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">
+                <Box alignItems="Baseline" gap="200" justifyContent="SpaceBetween">
+                  <Text size="L400">Power Levels</Text>
+                  <BetaNoticeBadge />
+                </Box>
+                <SequenceCard
+                  variant="SurfaceVariant"
+                  className={SequenceCardStyle}
+                  direction="Column"
+                  gap="400"
+                >
+                  <SettingTile
+                    title="New Power Level"
+                    description="Create a new power level."
+                    after={
+                      !createTag && (
+                        <Button
+                          onClick={() => setCreateTag(true)}
+                          variant="Secondary"
+                          fill="Soft"
+                          size="300"
+                          radii="300"
+                          outlined
+                          disabled={applyingChanges}
+                        >
+                          <Text size="B300">Create</Text>
+                        </Button>
+                      )
+                    }
+                  />
+                  {createTag && (
+                    <EditPower
+                      maxPower={maxPower}
+                      onSave={handleSaveTag}
+                      onClose={() => setCreateTag(false)}
+                    />
+                  )}
+                </SequenceCard>
+                {getPowers(powerTags).map((power) => {
+                  const tag = powerTags[power];
+                  const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+
+                  return (
+                    <SequenceCard
+                      key={power}
+                      variant={deleted.has(power) ? 'Critical' : 'SurfaceVariant'}
+                      className={SequenceCardStyle}
+                      direction="Column"
+                      gap="400"
+                    >
+                      <UseStateProvider initial={false}>
+                        {(edit, setEdit) =>
+                          edit ? (
+                            <EditPower
+                              maxPower={maxPower}
+                              power={power}
+                              tag={tag}
+                              onSave={handleSaveTag}
+                              onClose={() => setEdit(false)}
+                            />
+                          ) : (
+                            <SettingTile
+                              before={<PowerColorBadge color={tag.color} />}
+                              title={
+                                <Box as="span" alignItems="Center" gap="200">
+                                  <b>{deleted.has(power) ? <s>{tag.name}</s> : tag.name}</b>
+                                  <Box as="span" shrink="No" alignItems="Inherit" gap="Inherit">
+                                    {tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
+                                    <Text as="span" size="T200" priority="300">
+                                      ({power})
+                                    </Text>
+                                  </Box>
+                                </Box>
+                              }
+                              after={
+                                deleted.has(power) ? (
+                                  <Chip
+                                    variant="Critical"
+                                    radii="Pill"
+                                    disabled={applyingChanges}
+                                    onClick={() => handleToggleDelete(power)}
+                                  >
+                                    <Text size="B300">Undo</Text>
+                                  </Chip>
+                                ) : (
+                                  <Box shrink="No" alignItems="Center" gap="200">
+                                    <TooltipProvider
+                                      tooltip={
+                                        <Tooltip style={{ maxWidth: toRem(200) }}>
+                                          {usedPowers.has(power) ? (
+                                            <Box direction="Column">
+                                              <Text size="L400">Used Power Level</Text>
+                                              <Text size="T200">
+                                                You have to remove its use before you can delete it.
+                                              </Text>
+                                            </Box>
+                                          ) : (
+                                            <Text>Delete</Text>
+                                          )}
+                                        </Tooltip>
+                                      }
+                                    >
+                                      {(triggerRef) => (
+                                        <Chip
+                                          ref={triggerRef}
+                                          variant="Secondary"
+                                          fill="None"
+                                          radii="Pill"
+                                          disabled={applyingChanges}
+                                          aria-disabled={usedPowers.has(power)}
+                                          onClick={
+                                            usedPowers.has(power)
+                                              ? undefined
+                                              : () => handleToggleDelete(power)
+                                          }
+                                        >
+                                          <Icon size="50" src={Icons.Delete} />
+                                        </Chip>
+                                      )}
+                                    </TooltipProvider>
+                                    <Chip
+                                      variant="Secondary"
+                                      radii="Pill"
+                                      disabled={applyingChanges}
+                                      onClick={() => setEdit(true)}
+                                    >
+                                      <Text size="B300">Edit</Text>
+                                    </Chip>
+                                  </Box>
+                                )
+                              }
+                            />
+                          )
+                        }
+                      </UseStateProvider>
+                    </SequenceCard>
+                  );
+                })}
+              </Box>
+              {hasChanges && (
+                <Menu
+                  style={{
+                    position: 'sticky',
+                    padding: config.space.S200,
+                    paddingLeft: config.space.S400,
+                    bottom: config.space.S400,
+                    left: config.space.S400,
+                    right: 0,
+                    zIndex: 1,
+                  }}
+                  variant="Success"
+                >
+                  <Box alignItems="Center" gap="400">
+                    <Box grow="Yes" direction="Column">
+                      {applyState.status === AsyncStatus.Error ? (
+                        <Text size="T200">
+                          <b>Failed to apply changes! Please try again.</b>
+                        </Text>
+                      ) : (
+                        <Text size="T200">
+                          <b>Changes saved! Apply when ready.</b>
+                        </Text>
+                      )}
+                    </Box>
+                    <Box shrink="No" gap="200">
+                      <Button
+                        size="300"
+                        variant="Success"
+                        fill="None"
+                        radii="300"
+                        disabled={applyingChanges}
+                        onClick={resetChanges}
+                      >
+                        <Text size="B300">Reset</Text>
+                      </Button>
+                      <Button
+                        size="300"
+                        variant="Success"
+                        radii="300"
+                        disabled={applyingChanges}
+                        before={
+                          applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />
+                        }
+                        onClick={handleApplyChanges}
+                      >
+                        <Text size="B300">Apply Changes</Text>
+                      </Button>
+                    </Box>
+                  </Box>
+                </Menu>
+              )}
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/common-settings/permissions/index.ts b/src/app/features/common-settings/permissions/index.ts
new file mode 100644 (file)
index 0000000..6a6e62c
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './PermissionGroups';
+export * from './Powers';
+export * from './PowersEditor';
+export * from './types';
diff --git a/src/app/features/common-settings/permissions/types.ts b/src/app/features/common-settings/permissions/types.ts
new file mode 100644 (file)
index 0000000..fe70d12
--- /dev/null
@@ -0,0 +1,12 @@
+import { PermissionLocation } from '../../../hooks/usePowerLevels';
+
+export type PermissionItem = {
+  location: PermissionLocation;
+  name: string;
+  description?: string;
+};
+
+export type PermissionGroup = {
+  name: string;
+  items: PermissionItem[];
+};
diff --git a/src/app/features/common-settings/styles.css.ts b/src/app/features/common-settings/styles.css.ts
new file mode 100644 (file)
index 0000000..ce89c16
--- /dev/null
@@ -0,0 +1,6 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const SequenceCardStyle = style({
+  padding: config.space.S300,
+});
index 195d44435aa6718a303bcb0d7c9d8e4a06a5368c..f126e7cb4563c9dc24881ad056eeaa4a64883270 100644 (file)
@@ -18,7 +18,7 @@ import {
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
-import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -26,6 +26,7 @@ import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { stopPropagation } from '../../utils/keyboard';
 import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 import { useSpaceOptionally } from '../../hooks/useSpace';
+import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
 
 type HierarchyItemWithParent = HierarchyItem & {
   parentId: string;
@@ -153,11 +154,12 @@ function SettingsMenuItem({
   disabled?: boolean;
 }) {
   const openRoomSettings = useOpenRoomSettings();
+  const openSpaceSettings = useOpenSpaceSettings();
   const space = useSpaceOptionally();
 
   const handleSettings = () => {
     if ('space' in item) {
-      openSpaceSettings(item.roomId);
+      openSpaceSettings(item.roomId, item.parentId);
     } else {
       openRoomSettings(item.roomId, space?.roomId);
     }
index 6ef0044ba366c5187bfdf0052d34d84c88e8e679..bc4c46fe76d90886c7e2ad2043182da4c4d227a1 100644 (file)
@@ -26,7 +26,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { RoomAvatar } from '../../components/room-avatar';
 import { nameInitials } from '../../utils/common';
 import * as css from './LobbyHeader.css';
-import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
 import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -35,6 +35,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 import { BackRouteHandler } from '../../components/BackRouteHandler';
 import { mxcUrlToHttp } from '../../utils/matrix';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
 
 type LobbyMenuProps = {
   roomId: string;
@@ -46,6 +47,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
     const mx = useMatrixClient();
     const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
     const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+    const openSpaceSettings = useOpenSpaceSettings();
 
     const handleInvite = () => {
       openInviteUser(roomId);
@@ -132,7 +134,9 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
 
   const name = useRoomName(space);
   const avatarMxc = useRoomAvatar(space);
-  const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
+  const avatarUrl = avatarMxc
+    ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
+    : undefined;
 
   const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
     setMenuAnchor(evt.currentTarget.getBoundingClientRect());
index 42192c010607b0b58e5f6cf21650d972d6be3418..32b5df9c6831fbef330dc9e6bf65172b2eb77cd5 100644 (file)
@@ -11,12 +11,12 @@ import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoom
 import { mDirectAtom } from '../../state/mDirectList';
 import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 import { General } from './general';
-import { Members } from './members';
-import { EmojisStickers } from './emojis-stickers';
+import { Members } from '../common-settings/members';
+import { EmojisStickers } from '../common-settings/emojis-stickers';
 import { Permissions } from './permissions';
 import { RoomSettingsPage } from '../../state/roomSettings';
 import { useRoom } from '../../hooks/useRoom';
-import { DeveloperTools } from './developer-tools';
+import { DeveloperTools } from '../common-settings/developer-tools';
 
 type RoomSettingsMenuItem = {
   page: RoomSettingsPage;
diff --git a/src/app/features/room-settings/developer-tools/DevelopTools.tsx b/src/app/features/room-settings/developer-tools/DevelopTools.tsx
deleted file mode 100644 (file)
index 29b6aa5..0000000
+++ /dev/null
@@ -1,396 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import {
-  Box,
-  Text,
-  IconButton,
-  Icon,
-  Icons,
-  Scroll,
-  Switch,
-  Button,
-  MenuItem,
-  config,
-  color,
-} from 'folds';
-import { Page, PageContent, PageHeader } from '../../../components/page';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import { copyToClipboard } from '../../../utils/dom';
-import { useRoom } from '../../../hooks/useRoom';
-import { useRoomState } from '../../../hooks/useRoomState';
-import { StateEventEditor, StateEventInfo } from './StateEventEditor';
-import { SendRoomEvent } from './SendRoomEvent';
-import { useRoomAccountData } from '../../../hooks/useRoomAccountData';
-import { CutoutCard } from '../../../components/cutout-card';
-import {
-  AccountDataEditor,
-  AccountDataSubmitCallback,
-} from '../../../components/AccountDataEditor';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-
-type DeveloperToolsProps = {
-  requestClose: () => void;
-};
-export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
-  const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
-  const mx = useMatrixClient();
-  const room = useRoom();
-
-  const roomState = useRoomState(room);
-  const accountData = useRoomAccountData(room);
-
-  const [expandState, setExpandState] = useState(false);
-  const [expandStateType, setExpandStateType] = useState<string>();
-  const [openStateEvent, setOpenStateEvent] = useState<StateEventInfo>();
-  const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>();
-
-  const [expandAccountData, setExpandAccountData] = useState(false);
-  const [accountDataType, setAccountDataType] = useState<string | null>();
-
-  const handleClose = useCallback(() => {
-    setOpenStateEvent(undefined);
-    setComposeEvent(undefined);
-    setAccountDataType(undefined);
-  }, []);
-
-  const submitAccountData: AccountDataSubmitCallback = useCallback(
-    async (type, content) => {
-      await mx.setRoomAccountData(room.roomId, type, content);
-    },
-    [mx, room.roomId]
-  );
-
-  if (accountDataType !== undefined) {
-    return (
-      <AccountDataEditor
-        type={accountDataType ?? undefined}
-        content={accountDataType ? accountData.get(accountDataType) : undefined}
-        submitChange={submitAccountData}
-        requestClose={handleClose}
-      />
-    );
-  }
-
-  if (composeEvent) {
-    return <SendRoomEvent {...composeEvent} requestClose={handleClose} />;
-  }
-
-  if (openStateEvent) {
-    return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
-  }
-
-  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="Room ID"
-                      description={`Copy room ID to clipboard. ("${room.roomId}")`}
-                      after={
-                        <Button
-                          onClick={() => copyToClipboard(room.roomId ?? '<NO_ROOM_ID_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">Data</Text>
-
-                  <SequenceCard
-                    className={SequenceCardStyle}
-                    variant="SurfaceVariant"
-                    direction="Column"
-                    gap="400"
-                  >
-                    <SettingTile
-                      title="New Message Event"
-                      description="Create and send a new message event within the room."
-                      after={
-                        <Button
-                          onClick={() => setComposeEvent({})}
-                          variant="Secondary"
-                          fill="Soft"
-                          size="300"
-                          radii="300"
-                          outlined
-                        >
-                          <Text size="B300">Compose</Text>
-                        </Button>
-                      }
-                    />
-                  </SequenceCard>
-                  <SequenceCard
-                    className={SequenceCardStyle}
-                    variant="SurfaceVariant"
-                    direction="Column"
-                    gap="400"
-                  >
-                    <SettingTile
-                      title="Room State"
-                      description="State events of the room."
-                      after={
-                        <Button
-                          onClick={() => setExpandState(!expandState)}
-                          variant="Secondary"
-                          fill="Soft"
-                          size="300"
-                          radii="300"
-                          outlined
-                          before={
-                            <Icon
-                              src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
-                              size="100"
-                              filled
-                            />
-                          }
-                        >
-                          <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;
-
-                              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}`,
-                                      }}
-                                    >
-                                      <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"
-                  >
-                    <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>
-                    )}
-                  </SequenceCard>
-                </Box>
-              )}
-            </Box>
-          </PageContent>
-        </Scroll>
-      </Box>
-    </Page>
-  );
-}
diff --git a/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx
deleted file mode 100644 (file)
index f25ba7c..0000000
+++ /dev/null
@@ -1,208 +0,0 @@
-import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
-import { MatrixError } from 'matrix-js-sdk';
-import {
-  Box,
-  Chip,
-  Icon,
-  Icons,
-  IconButton,
-  Text,
-  config,
-  Button,
-  Spinner,
-  color,
-  TextArea as TextAreaComponent,
-  Input,
-} from 'folds';
-import { Page, PageHeader } from '../../../components/page';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRoom } from '../../../hooks/useRoom';
-import { useAlive } from '../../../hooks/useAlive';
-import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { syntaxErrorPosition } from '../../../utils/dom';
-import { Cursor } from '../../../plugins/text-area';
-
-const EDITOR_INTENT_SPACE_COUNT = 2;
-
-export type SendRoomEventProps = {
-  type?: string;
-  stateKey?: string;
-  requestClose: () => void;
-};
-export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const alive = useAlive();
-  const composeStateEvent = typeof stateKey === 'string';
-
-  const textAreaRef = useRef<HTMLTextAreaElement>(null);
-  const [jsonError, setJSONError] = useState<SyntaxError>();
-  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
-    textAreaRef,
-    EDITOR_INTENT_SPACE_COUNT
-  );
-
-  const [submitState, submit] = useAsyncCallback<
-    object,
-    MatrixError,
-    [string, string | undefined, object]
-  >(
-    useCallback(
-      (evtType, evtStateKey, evtContent) => {
-        if (typeof evtStateKey === 'string') {
-          return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
-        }
-        return mx.sendEvent(room.roomId, evtType as any, evtContent);
-      },
-      [mx, room]
-    )
-  );
-  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 stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
-    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
-    if (!typeInput || !contentTextArea) return;
-
-    const evtType = typeInput.value;
-    const evtStateKey = stateKeyInput?.value;
-    const contentStr = contentTextArea.value.trim();
-
-    let parsedContent: object;
-    try {
-      parsedContent = JSON.parse(contentStr);
-    } catch (e) {
-      setJSONError(e as SyntaxError);
-      return;
-    }
-    setJSONError(undefined);
-
-    if (parsedContent === null) {
-      return;
-    }
-
-    submit(evtType, evtStateKey, parsedContent).then(() => {
-      if (alive()) {
-        requestClose();
-      }
-    });
-  };
-
-  useEffect(() => {
-    if (jsonError) {
-      const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
-      const cursor = new Cursor(errorPosition, errorPosition, 'none');
-      operations.select(cursor);
-      getTarget()?.focus();
-    }
-  }, [jsonError, operations, getTarget]);
-
-  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">
-        <Box
-          as="form"
-          onSubmit={handleSubmit}
-          grow="Yes"
-          style={{ padding: config.space.S400 }}
-          direction="Column"
-          gap="400"
-          aria-disabled={submitting}
-        >
-          <Box shrink="No" direction="Column" gap="100">
-            <Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
-            <Box gap="300">
-              <Box grow="Yes" direction="Column">
-                <Input
-                  variant="Background"
-                  name="typeInput"
-                  size="400"
-                  radii="300"
-                  readOnly={submitting}
-                  defaultValue={type}
-                  required
-                />
-              </Box>
-              <Button
-                variant="Success"
-                size="400"
-                radii="300"
-                type="submit"
-                disabled={submitting}
-                before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
-              >
-                <Text size="B400">Send</Text>
-              </Button>
-            </Box>
-
-            {submitState.status === AsyncStatus.Error && (
-              <Text size="T200" style={{ color: color.Critical.Main }}>
-                <b>{submitState.error.message}</b>
-              </Text>
-            )}
-          </Box>
-          {composeStateEvent && (
-            <Box shrink="No" direction="Column" gap="100">
-              <Text size="L400">State Key (Optional)</Text>
-              <Input
-                variant="Background"
-                name="stateKeyInput"
-                size="400"
-                radii="300"
-                readOnly={submitting}
-                defaultValue={stateKey}
-              />
-            </Box>
-          )}
-          <Box grow="Yes" direction="Column" gap="100">
-            <Box shrink="No">
-              <Text size="L400">JSON Content</Text>
-            </Box>
-            <TextAreaComponent
-              ref={textAreaRef}
-              name="contentTextArea"
-              style={{ fontFamily: 'monospace' }}
-              onKeyDown={handleKeyDown}
-              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>
-      </Box>
-    </Page>
-  );
-}
diff --git a/src/app/features/room-settings/developer-tools/StateEventEditor.tsx b/src/app/features/room-settings/developer-tools/StateEventEditor.tsx
deleted file mode 100644 (file)
index 6ee19be..0000000
+++ /dev/null
@@ -1,298 +0,0 @@
-import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
-  Box,
-  Text,
-  Icon,
-  Icons,
-  IconButton,
-  Chip,
-  Scroll,
-  config,
-  TextArea as TextAreaComponent,
-  color,
-  Spinner,
-  Button,
-} from 'folds';
-import { MatrixError } from 'matrix-js-sdk';
-import { Page, PageHeader } from '../../../components/page';
-import { SequenceCard } from '../../../components/sequence-card';
-import { TextViewerContent } from '../../../components/text-viewer';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { useRoom } from '../../../hooks/useRoom';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useAlive } from '../../../hooks/useAlive';
-import { Cursor } from '../../../plugins/text-area';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { syntaxErrorPosition } from '../../../utils/dom';
-import { SettingTile } from '../../../components/setting-tile';
-import { SequenceCardStyle } from '../styles.css';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
-
-const EDITOR_INTENT_SPACE_COUNT = 2;
-
-type StateEventEditProps = {
-  type: string;
-  stateKey: string;
-  content: object;
-  requestClose: () => void;
-};
-function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const alive = useAlive();
-
-  const defaultContentStr = useMemo(
-    () => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
-    [content]
-  );
-
-  const textAreaRef = useRef<HTMLTextAreaElement>(null);
-  const [jsonError, setJSONError] = useState<SyntaxError>();
-  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
-    textAreaRef,
-    EDITOR_INTENT_SPACE_COUNT
-  );
-
-  const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
-    useCallback(
-      (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
-      [mx, room, type, stateKey]
-    )
-  );
-  const submitting = submitState.status === AsyncStatus.Loading;
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    if (submitting) return;
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
-    if (!contentTextArea) return;
-
-    const contentStr = contentTextArea.value.trim();
-
-    let parsedContent: object;
-    try {
-      parsedContent = JSON.parse(contentStr);
-    } catch (e) {
-      setJSONError(e as SyntaxError);
-      return;
-    }
-    setJSONError(undefined);
-
-    if (
-      parsedContent === null ||
-      defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
-    ) {
-      return;
-    }
-
-    submit(parsedContent).then(() => {
-      if (alive()) {
-        requestClose();
-      }
-    });
-  };
-
-  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"
-      style={{ padding: config.space.S400 }}
-      direction="Column"
-      gap="400"
-      aria-disabled={submitting}
-    >
-      <Box shrink="No" direction="Column" gap="100">
-        <Text size="L400">State Event</Text>
-        <SequenceCard
-          className={SequenceCardStyle}
-          variant="SurfaceVariant"
-          direction="Column"
-          gap="400"
-        >
-          <SettingTile
-            title={type}
-            description={stateKey}
-            after={
-              <Box gap="200">
-                <Button
-                  variant="Success"
-                  size="300"
-                  radii="300"
-                  type="submit"
-                  disabled={submitting}
-                  before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
-                >
-                  <Text size="B300">Save</Text>
-                </Button>
-                <Button
-                  variant="Secondary"
-                  fill="Soft"
-                  size="300"
-                  radii="300"
-                  onClick={requestClose}
-                  disabled={submitting}
-                >
-                  <Text size="B300">Cancel</Text>
-                </Button>
-              </Box>
-            }
-          />
-        </SequenceCard>
-
-        {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"
-          style={{ fontFamily: 'monospace' }}
-          onKeyDown={handleKeyDown}
-          defaultValue={defaultContentStr}
-          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 StateEventViewProps = {
-  content: object;
-  eventJSONStr: string;
-  onEditContent?: (content: object) => void;
-};
-function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
-  return (
-    <Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
-      <Box grow="Yes" direction="Column" gap="100">
-        <Box gap="200" alignItems="End">
-          <Box grow="Yes">
-            <Text size="L400">State Event</Text>
-          </Box>
-          {onEditContent && (
-            <Box shrink="No" gap="200">
-              <Chip
-                variant="Secondary"
-                fill="Soft"
-                radii="300"
-                outlined
-                onClick={() => onEditContent(content)}
-              >
-                <Text size="B300">Edit</Text>
-              </Chip>
-            </Box>
-          )}
-        </Box>
-        <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={eventJSONStr}
-              langName="JSON"
-            />
-          </Scroll>
-        </SequenceCard>
-      </Box>
-    </Box>
-  );
-}
-
-export type StateEventInfo = {
-  type: string;
-  stateKey: string;
-};
-export type StateEventEditorProps = StateEventInfo & {
-  requestClose: () => void;
-};
-
-export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
-  const [editContent, setEditContent] = useState<object>();
-  const powerLevels = usePowerLevels(room);
-  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
-  const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
-
-  const eventJSONStr = useMemo(() => {
-    if (!stateEvent) return '';
-    return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
-  }, [stateEvent]);
-
-  const handleCloseEdit = useCallback(() => {
-    setEditContent(undefined);
-  }, []);
-
-  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">
-        {editContent ? (
-          <StateEventEdit
-            type={type}
-            stateKey={stateKey}
-            content={editContent}
-            requestClose={handleCloseEdit}
-          />
-        ) : (
-          <StateEventView
-            content={stateEvent?.getContent() ?? {}}
-            onEditContent={canEdit ? setEditContent : undefined}
-            eventJSONStr={eventJSONStr}
-          />
-        )}
-      </Box>
-    </Page>
-  );
-}
diff --git a/src/app/features/room-settings/developer-tools/index.ts b/src/app/features/room-settings/developer-tools/index.ts
deleted file mode 100644 (file)
index 1fcceff..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './DevelopTools';
diff --git a/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx
deleted file mode 100644 (file)
index ad8ffae..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-import React, { useState } from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
-import { Page, PageContent, PageHeader } from '../../../components/page';
-import { ImagePack } from '../../../plugins/custom-emoji';
-import { ImagePackView } from '../../../components/image-pack-view';
-import { RoomPacks } from './RoomPacks';
-
-type EmojisStickersProps = {
-  requestClose: () => void;
-};
-export function EmojisStickers({ requestClose }: EmojisStickersProps) {
-  const [imagePack, setImagePack] = useState<ImagePack>();
-
-  const handleImagePackViewClose = () => {
-    setImagePack(undefined);
-  };
-
-  if (imagePack) {
-    return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
-  }
-
-  return (
-    <Page>
-      <PageHeader outlined={false}>
-        <Box grow="Yes" gap="200">
-          <Box grow="Yes" alignItems="Center" gap="200">
-            <Text size="H3" truncate>
-              Emojis & Stickers
-            </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">
-              <RoomPacks onViewPack={setImagePack} />
-            </Box>
-          </PageContent>
-        </Scroll>
-      </Box>
-    </Page>
-  );
-}
diff --git a/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx
deleted file mode 100644 (file)
index 56dda54..0000000
+++ /dev/null
@@ -1,349 +0,0 @@
-import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
-import {
-  Box,
-  Text,
-  Button,
-  Icon,
-  Icons,
-  Avatar,
-  AvatarImage,
-  AvatarFallback,
-  toRem,
-  config,
-  Input,
-  Spinner,
-  color,
-  IconButton,
-  Menu,
-} from 'folds';
-import { MatrixError } from 'matrix-js-sdk';
-import { SequenceCard } from '../../../components/sequence-card';
-import {
-  ImagePack,
-  ImageUsage,
-  PackAddress,
-  packAddressEqual,
-  PackContent,
-} from '../../../plugins/custom-emoji';
-import { useRoom } from '../../../hooks/useRoom';
-import { useRoomImagePacks } from '../../../hooks/useImagePacks';
-import { LineClamp2 } from '../../../styles/Text.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { SequenceCardStyle } from '../styles.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { mxcUrlToHttp } from '../../../utils/matrix';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { suffixRename } from '../../../utils/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useAlive } from '../../../hooks/useAlive';
-
-type CreatePackTileProps = {
-  packs: ImagePack[];
-  roomId: string;
-};
-function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
-  const mx = useMatrixClient();
-  const alive = useAlive();
-
-  const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
-    useCallback(
-      async (stateKey, name) => {
-        const content: PackContent = {
-          pack: {
-            display_name: name,
-          },
-        };
-        await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
-      },
-      [mx, roomId]
-    )
-  );
-
-  const creating = addState.status === AsyncStatus.Loading;
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    if (creating) return;
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const nameInput = target?.nameInput as HTMLInputElement | undefined;
-    if (!nameInput) return;
-    const name = nameInput?.value.trim();
-    if (!name) return;
-
-    let packKey = name.replace(/\s/g, '-');
-
-    const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
-    if (hasPack(packKey)) {
-      packKey = suffixRename(packKey, hasPack);
-    }
-
-    addPack(packKey, name).then(() => {
-      if (alive()) {
-        nameInput.value = '';
-      }
-    });
-  };
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="New Pack"
-        description="Add your own emoji and sticker pack to use in room."
-      >
-        <Box
-          style={{ marginTop: config.space.S200 }}
-          as="form"
-          onSubmit={handleSubmit}
-          gap="200"
-          alignItems="End"
-        >
-          <Box direction="Column" gap="100" grow="Yes">
-            <Text size="L400">Name</Text>
-            <Input
-              name="nameInput"
-              required
-              size="400"
-              variant="Secondary"
-              radii="300"
-              readOnly={creating}
-            />
-            {addState.status === AsyncStatus.Error && (
-              <Text style={{ color: color.Critical.Main }} size="T300">
-                {addState.error.message}
-              </Text>
-            )}
-          </Box>
-          <Button
-            variant="Success"
-            radii="300"
-            type="submit"
-            disabled={creating}
-            before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
-          >
-            <Text size="B400">Create</Text>
-          </Button>
-        </Box>
-      </SettingTile>
-    </SequenceCard>
-  );
-}
-
-type RoomPacksProps = {
-  onViewPack: (imagePack: ImagePack) => void;
-};
-export function RoomPacks({ onViewPack }: RoomPacksProps) {
-  const mx = useMatrixClient();
-  const useAuthentication = useMediaAuthentication();
-  const room = useRoom();
-  const alive = useAlive();
-
-  const powerLevels = usePowerLevels(room);
-  const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
-  const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
-
-  const unfilteredPacks = useRoomImagePacks(room);
-  const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
-
-  const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
-  const hasChanges = removedPacks.length > 0;
-
-  const [applyState, applyChanges] = useAsyncCallback(
-    useCallback(async () => {
-      for (let i = 0; i < removedPacks.length; i += 1) {
-        const addr = removedPacks[i];
-        // eslint-disable-next-line no-await-in-loop
-        await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
-      }
-    }, [mx, room, removedPacks])
-  );
-  const applyingChanges = applyState.status === AsyncStatus.Loading;
-
-  const handleRemove = (address: PackAddress) => {
-    setRemovedPacks((addresses) => [...addresses, address]);
-  };
-
-  const handleUndoRemove = (address: PackAddress) => {
-    setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
-  };
-
-  const handleCancelChanges = () => setRemovedPacks([]);
-
-  const handleApplyChanges = () => {
-    applyChanges().then(() => {
-      if (alive()) {
-        setRemovedPacks([]);
-      }
-    });
-  };
-
-  const renderPack = (pack: ImagePack) => {
-    const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
-    const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
-    const { address } = pack;
-    if (!address) return null;
-    const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
-
-    return (
-      <SequenceCard
-        key={pack.id}
-        className={SequenceCardStyle}
-        variant={removed ? 'Critical' : 'SurfaceVariant'}
-        direction="Column"
-        gap="400"
-      >
-        <SettingTile
-          title={
-            <span style={{ textDecoration: removed ? 'line-through' : undefined }}>
-              {pack.meta.name ?? 'Unknown'}
-            </span>
-          }
-          description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
-          before={
-            <Box alignItems="Center" gap="300">
-              {canEdit &&
-                (removed ? (
-                  <IconButton
-                    size="300"
-                    radii="Pill"
-                    variant="Critical"
-                    onClick={() => handleUndoRemove(address)}
-                    disabled={applyingChanges}
-                  >
-                    <Icon src={Icons.Plus} size="100" />
-                  </IconButton>
-                ) : (
-                  <IconButton
-                    size="300"
-                    radii="Pill"
-                    variant="Secondary"
-                    onClick={() => handleRemove(address)}
-                    disabled={applyingChanges}
-                  >
-                    <Icon src={Icons.Cross} size="100" />
-                  </IconButton>
-                ))}
-              <Avatar size="300" radii="300">
-                {avatarUrl ? (
-                  <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
-                ) : (
-                  <AvatarFallback>
-                    <Icon size="400" src={Icons.Sticker} filled />
-                  </AvatarFallback>
-                )}
-              </Avatar>
-            </Box>
-          }
-          after={
-            !removed && (
-              <Button
-                variant="Secondary"
-                fill="Soft"
-                size="300"
-                radii="300"
-                outlined
-                onClick={() => onViewPack(pack)}
-              >
-                <Text size="B300">View</Text>
-              </Button>
-            )
-          }
-        />
-      </SequenceCard>
-    );
-  };
-
-  return (
-    <>
-      <Box direction="Column" gap="100">
-        <Text size="L400">Packs</Text>
-        {canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
-        {packs.map(renderPack)}
-        {packs.length === 0 && (
-          <SequenceCard
-            className={SequenceCardStyle}
-            variant="SurfaceVariant"
-            direction="Column"
-            gap="400"
-          >
-            <Box
-              justifyContent="Center"
-              direction="Column"
-              gap="200"
-              style={{
-                padding: `${config.space.S700} ${config.space.S400}`,
-                maxWidth: toRem(300),
-                margin: 'auto',
-              }}
-            >
-              <Text size="H5" align="Center">
-                No Packs
-              </Text>
-              <Text size="T200" align="Center">
-                There are no emoji or sticker packs to display at the moment.
-              </Text>
-            </Box>
-          </SequenceCard>
-        )}
-      </Box>
-
-      {hasChanges && (
-        <Menu
-          style={{
-            position: 'sticky',
-            padding: config.space.S200,
-            paddingLeft: config.space.S400,
-            bottom: config.space.S400,
-            left: config.space.S400,
-            right: 0,
-            zIndex: 1,
-          }}
-          variant="Critical"
-        >
-          <Box alignItems="Center" gap="400">
-            <Box grow="Yes" direction="Column">
-              {applyState.status === AsyncStatus.Error ? (
-                <Text size="T200">
-                  <b>Failed to remove packs! Please try again.</b>
-                </Text>
-              ) : (
-                <Text size="T200">
-                  <b>Delete selected packs. ({removedPacks.length} selected)</b>
-                </Text>
-              )}
-            </Box>
-            <Box shrink="No" gap="200">
-              <Button
-                size="300"
-                variant="Critical"
-                fill="None"
-                radii="300"
-                disabled={applyingChanges}
-                onClick={handleCancelChanges}
-              >
-                <Text size="B300">Cancel</Text>
-              </Button>
-              <Button
-                size="300"
-                variant="Critical"
-                radii="300"
-                disabled={applyingChanges}
-                before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
-                onClick={handleApplyChanges}
-              >
-                <Text size="B300">Delete</Text>
-              </Button>
-            </Box>
-          </Box>
-        </Menu>
-      )}
-    </>
-  );
-}
diff --git a/src/app/features/room-settings/emojis-stickers/index.ts b/src/app/features/room-settings/emojis-stickers/index.ts
deleted file mode 100644 (file)
index 9c9e9f5..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './EmojisStickers';
index 3d217f32a8d80ca99d12e51c17c61153c8352184..0c3152c013f49ab549603a62f0054edd15deb477 100644 (file)
@@ -1,15 +1,18 @@
 import React from 'react';
 import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
 import { Page, PageContent, PageHeader } from '../../../components/page';
-import { RoomProfile } from './RoomProfile';
 import { usePowerLevels } from '../../../hooks/usePowerLevels';
 import { useRoom } from '../../../hooks/useRoom';
-import { RoomEncryption } from './RoomEncryption';
-import { RoomHistoryVisibility } from './RoomHistoryVisibility';
-import { RoomJoinRules } from './RoomJoinRules';
-import { RoomLocalAddresses, RoomPublishedAddresses } from './RoomAddress';
-import { RoomPublish } from './RoomPublish';
-import { RoomUpgrade } from './RoomUpgrade';
+import {
+  RoomProfile,
+  RoomEncryption,
+  RoomHistoryVisibility,
+  RoomJoinRules,
+  RoomLocalAddresses,
+  RoomPublishedAddresses,
+  RoomPublish,
+  RoomUpgrade,
+} from '../../common-settings/general';
 
 type GeneralProps = {
   requestClose: () => void;
diff --git a/src/app/features/room-settings/general/RoomAddress.tsx b/src/app/features/room-settings/general/RoomAddress.tsx
deleted file mode 100644 (file)
index dfe6645..0000000
+++ /dev/null
@@ -1,438 +0,0 @@
-import React, { FormEventHandler, useCallback, useState } from 'react';
-import {
-  Badge,
-  Box,
-  Button,
-  Checkbox,
-  Chip,
-  color,
-  config,
-  Icon,
-  Icons,
-  Input,
-  Spinner,
-  Text,
-  toRem,
-} from 'folds';
-import { MatrixError } from 'matrix-js-sdk';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { SettingTile } from '../../../components/setting-tile';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRoom } from '../../../hooks/useRoom';
-import {
-  useLocalAliases,
-  usePublishedAliases,
-  usePublishUnpublishAliases,
-  useSetMainAlias,
-} from '../../../hooks/useRoomAliases';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { CutoutCard } from '../../../components/cutout-card';
-import { getIdServer } from '../../../../util/matrixUtil';
-import { replaceSpaceWithDash } from '../../../utils/common';
-import { useAlive } from '../../../hooks/useAlive';
-import { StateEvent } from '../../../../types/matrix/room';
-
-type RoomPublishedAddressesProps = {
-  powerLevels: IPowerLevels;
-};
-
-export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEditCanonical = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomCanonicalAlias,
-    userPowerLevel
-  );
-
-  const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
-  const setMainAlias = useSetMainAlias(room);
-
-  const [mainState, setMain] = useAsyncCallback(setMainAlias);
-  const loading = mainState.status === AsyncStatus.Loading;
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Published Addresses"
-        description={
-          <span>
-            If room access is <b>Public</b>, Published addresses will be used to join by anyone.
-          </span>
-        }
-      />
-      <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
-        {publishedAliases.length === 0 ? (
-          <Box direction="Column" gap="100">
-            <Text size="L400">No Addresses</Text>
-            <Text size="T200">
-              To publish an address, it needs to be set as a local address first
-            </Text>
-          </Box>
-        ) : (
-          <Box direction="Column" gap="300">
-            {publishedAliases.map((alias) => (
-              <Box key={alias} as="span" gap="200" alignItems="Center">
-                <Box grow="Yes" gap="Inherit" alignItems="Center">
-                  <Text size="T300" truncate>
-                    {alias === canonicalAlias ? <b>{alias}</b> : alias}
-                  </Text>
-                  {alias === canonicalAlias && (
-                    <Badge variant="Success" fill="Solid" size="500">
-                      <Text size="L400">Main</Text>
-                    </Badge>
-                  )}
-                </Box>
-                {canEditCanonical && (
-                  <Box shrink="No" gap="100">
-                    {alias === canonicalAlias ? (
-                      <Chip
-                        variant="Warning"
-                        radii="Pill"
-                        fill="None"
-                        disabled={loading}
-                        onClick={() => setMain(undefined)}
-                      >
-                        <Text size="B300">Unset Main</Text>
-                      </Chip>
-                    ) : (
-                      <Chip
-                        variant="Success"
-                        radii="Pill"
-                        fill={canonicalAlias ? 'None' : 'Soft'}
-                        disabled={loading}
-                        onClick={() => setMain(alias)}
-                      >
-                        <Text size="B300">Set Main</Text>
-                      </Chip>
-                    )}
-                  </Box>
-                )}
-              </Box>
-            ))}
-
-            {mainState.status === AsyncStatus.Error && (
-              <Text size="T200" style={{ color: color.Critical.Main }}>
-                {(mainState.error as MatrixError).message}
-              </Text>
-            )}
-          </Box>
-        )}
-      </CutoutCard>
-    </SequenceCard>
-  );
-}
-
-function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
-  const mx = useMatrixClient();
-  const userId = mx.getSafeUserId();
-  const server = getIdServer(userId);
-  const alive = useAlive();
-
-  const [addState, addAlias] = useAsyncCallback(addLocalAlias);
-  const adding = addState.status === AsyncStatus.Loading;
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    if (adding) return;
-    evt.preventDefault();
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const aliasInput = target?.aliasInput as HTMLInputElement | undefined;
-    if (!aliasInput) return;
-    const alias = replaceSpaceWithDash(aliasInput.value.trim());
-    if (!alias) return;
-
-    addAlias(`#${alias}:${server}`).then(() => {
-      if (alive()) {
-        aliasInput.value = '';
-      }
-    });
-  };
-
-  return (
-    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
-      <Box gap="200">
-        <Box grow="Yes" direction="Column">
-          <Input
-            name="aliasInput"
-            variant="Secondary"
-            size="400"
-            radii="300"
-            before={<Text size="T200">#</Text>}
-            readOnly={adding}
-            after={
-              <Text style={{ maxWidth: toRem(300) }} size="T200" truncate>
-                :{server}
-              </Text>
-            }
-          />
-        </Box>
-        <Box shrink="No">
-          <Button
-            variant="Success"
-            size="400"
-            radii="300"
-            type="submit"
-            disabled={adding}
-            before={adding && <Spinner size="100" variant="Success" fill="Solid" />}
-          >
-            <Text size="B400">Save</Text>
-          </Button>
-        </Box>
-      </Box>
-      {addState.status === AsyncStatus.Error && (
-        <Text style={{ color: color.Critical.Main }} size="T200">
-          {(addState.error as MatrixError).httpStatus === 409
-            ? 'Address is already in use!'
-            : (addState.error as MatrixError).message}
-        </Text>
-      )}
-    </Box>
-  );
-}
-
-function LocalAddressesList({
-  localAliases,
-  removeLocalAlias,
-  canEditCanonical,
-}: {
-  localAliases: string[];
-  removeLocalAlias: (alias: string) => Promise<void>;
-  canEditCanonical?: boolean;
-}) {
-  const room = useRoom();
-  const alive = useAlive();
-
-  const [, publishedAliases] = usePublishedAliases(room);
-  const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room);
-
-  const [selectedAliases, setSelectedAliases] = useState<string[]>([]);
-  const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias));
-
-  const toggleSelect = (alias: string) => {
-    setSelectedAliases((aliases) => {
-      if (aliases.includes(alias)) {
-        return aliases.filter((a) => a !== alias);
-      }
-      const newAliases = [...aliases];
-      newAliases.push(alias);
-      return newAliases;
-    });
-  };
-  const clearSelected = () => {
-    if (alive()) {
-      setSelectedAliases([]);
-    }
-  };
-
-  const [deleteState, deleteAliases] = useAsyncCallback(
-    useCallback(
-      async (aliases: string[]) => {
-        for (let i = 0; i < aliases.length; i += 1) {
-          const alias = aliases[i];
-          // eslint-disable-next-line no-await-in-loop
-          await removeLocalAlias(alias);
-        }
-      },
-      [removeLocalAlias]
-    )
-  );
-  const [publishState, publish] = useAsyncCallback(publishAliases);
-  const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases);
-
-  const handleDelete = () => {
-    deleteAliases(selectedAliases).then(clearSelected);
-  };
-  const handlePublish = () => {
-    publish(selectedAliases).then(clearSelected);
-  };
-  const handleUnpublish = () => {
-    unpublish(selectedAliases).then(clearSelected);
-  };
-
-  const loading =
-    deleteState.status === AsyncStatus.Loading ||
-    publishState.status === AsyncStatus.Loading ||
-    unpublishState.status === AsyncStatus.Loading;
-  let error: MatrixError | undefined;
-  if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError;
-  if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError;
-  if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError;
-
-  return (
-    <Box direction="Column" gap="300">
-      {selectedAliases.length > 0 && (
-        <Box gap="200">
-          <Box grow="Yes">
-            <Text size="L400">{selectedAliases.length} Selected</Text>
-          </Box>
-          <Box shrink="No" gap="Inherit">
-            {canEditCanonical &&
-              (selectHasPublished ? (
-                <Chip
-                  variant="Warning"
-                  radii="Pill"
-                  disabled={loading}
-                  onClick={handleUnpublish}
-                  before={
-                    unpublishState.status === AsyncStatus.Loading && (
-                      <Spinner size="100" variant="Warning" />
-                    )
-                  }
-                >
-                  <Text size="B300">Unpublish</Text>
-                </Chip>
-              ) : (
-                <Chip
-                  variant="Success"
-                  radii="Pill"
-                  disabled={loading}
-                  onClick={handlePublish}
-                  before={
-                    publishState.status === AsyncStatus.Loading && (
-                      <Spinner size="100" variant="Success" />
-                    )
-                  }
-                >
-                  <Text size="B300">Publish</Text>
-                </Chip>
-              ))}
-            <Chip
-              variant="Critical"
-              radii="Pill"
-              disabled={loading}
-              onClick={handleDelete}
-              before={
-                deleteState.status === AsyncStatus.Loading && (
-                  <Spinner size="100" variant="Critical" />
-                )
-              }
-            >
-              <Text size="B300">Delete</Text>
-            </Chip>
-          </Box>
-        </Box>
-      )}
-      {localAliases.map((alias) => {
-        const published = publishedAliases.includes(alias);
-        const selected = selectedAliases.includes(alias);
-
-        return (
-          <Box key={alias} as="span" alignItems="Center" gap="200">
-            <Box shrink="No">
-              <Checkbox
-                checked={selected}
-                onChange={() => toggleSelect(alias)}
-                size="50"
-                variant="Primary"
-                disabled={loading}
-              />
-            </Box>
-            <Box grow="Yes">
-              <Text size="T300" truncate>
-                {alias}
-              </Text>
-            </Box>
-            <Box shrink="No" gap="100">
-              {published && (
-                <Badge variant="Success" fill="Soft" size="500">
-                  <Text size="L400">Published</Text>
-                </Badge>
-              )}
-            </Box>
-          </Box>
-        );
-      })}
-      {error && (
-        <Text size="T200" style={{ color: color.Critical.Main }}>
-          {error.message}
-        </Text>
-      )}
-    </Box>
-  );
-}
-
-export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEditCanonical = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomCanonicalAlias,
-    userPowerLevel
-  );
-
-  const [expand, setExpand] = useState(false);
-
-  const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <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'}
-            </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>
-      )}
-      {expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomEncryption.tsx b/src/app/features/room-settings/general/RoomEncryption.tsx
deleted file mode 100644 (file)
index 7d95fe3..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-import {
-  Badge,
-  Box,
-  Button,
-  color,
-  config,
-  Dialog,
-  Header,
-  Icon,
-  IconButton,
-  Icons,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Spinner,
-  Text,
-} from 'folds';
-import React, { useCallback, useState } from 'react';
-import { MatrixError } from 'matrix-js-sdk';
-import FocusTrap from 'focus-trap-react';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { StateEvent } from '../../../../types/matrix/room';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useRoom } from '../../../hooks/useRoom';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { stopPropagation } from '../../../utils/keyboard';
-
-const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
-
-type RoomEncryptionProps = {
-  powerLevels: IPowerLevels;
-};
-export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEnable = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomEncryption,
-    userPowerLevel
-  );
-  const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
-    algorithm: string;
-  }>();
-  const enabled = content?.algorithm === ROOM_ENC_ALGO;
-
-  const [enableState, enable] = useAsyncCallback(
-    useCallback(async () => {
-      await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
-        algorithm: ROOM_ENC_ALGO,
-      });
-    }, [mx, room.roomId])
-  );
-
-  const enabling = enableState.status === AsyncStatus.Loading;
-
-  const [prompt, setPrompt] = useState(false);
-
-  const handleEnable = () => {
-    enable();
-    setPrompt(false);
-  };
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Room Encryption"
-        description={
-          enabled
-            ? 'Messages in this room are protected by end-to-end encryption.'
-            : 'Once enabled, encryption cannot be disabled!'
-        }
-        after={
-          enabled ? (
-            <Badge size="500" variant="Success" fill="Solid" radii="300">
-              <Text size="L400">Enabled</Text>
-            </Badge>
-          ) : (
-            <Button
-              size="300"
-              variant="Primary"
-              fill="Solid"
-              radii="300"
-              disabled={!canEnable}
-              onClick={() => setPrompt(true)}
-              before={enabling && <Spinner size="100" variant="Primary" fill="Solid" />}
-            >
-              <Text size="B300">Enable</Text>
-            </Button>
-          )
-        }
-      >
-        {enableState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(enableState.error as MatrixError).message}
-          </Text>
-        )}
-        {prompt && (
-          <Overlay open backdrop={<OverlayBackdrop />}>
-            <OverlayCenter>
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  onDeactivate: () => setPrompt(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">Enable Encryption</Text>
-                    </Box>
-                    <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
-                      <Icon src={Icons.Cross} />
-                    </IconButton>
-                  </Header>
-                  <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
-                    <Text priority="400">
-                      Are you sure? Once enabled, encryption cannot be disabled!
-                    </Text>
-                    <Button type="submit" variant="Primary" onClick={handleEnable}>
-                      <Text size="B400">Enable E2E Encryption</Text>
-                    </Button>
-                  </Box>
-                </Dialog>
-              </FocusTrap>
-            </OverlayCenter>
-          </Overlay>
-        )}
-      </SettingTile>
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomHistoryVisibility.tsx b/src/app/features/room-settings/general/RoomHistoryVisibility.tsx
deleted file mode 100644 (file)
index d36e312..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
-import {
-  Button,
-  color,
-  config,
-  Icon,
-  Icons,
-  Menu,
-  MenuItem,
-  PopOut,
-  RectCords,
-  Spinner,
-  Text,
-} from 'folds';
-import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
-import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
-import FocusTrap from 'focus-trap-react';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRoom } from '../../../hooks/useRoom';
-import { StateEvent } from '../../../../types/matrix/room';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { stopPropagation } from '../../../utils/keyboard';
-
-const useVisibilityStr = () =>
-  useMemo(
-    () => ({
-      [HistoryVisibility.Invited]: 'After Invite',
-      [HistoryVisibility.Joined]: 'After Join',
-      [HistoryVisibility.Shared]: 'All Messages',
-      [HistoryVisibility.WorldReadable]: 'All Messages (Guests)',
-    }),
-    []
-  );
-
-const useVisibilityMenu = () =>
-  useMemo(
-    () => [
-      HistoryVisibility.Shared,
-      HistoryVisibility.Invited,
-      HistoryVisibility.Joined,
-      HistoryVisibility.WorldReadable,
-    ],
-    []
-  );
-
-type RoomHistoryVisibilityProps = {
-  powerLevels: IPowerLevels;
-};
-export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEdit = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomHistoryVisibility,
-    userPowerLevel
-  );
-
-  const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
-  const historyVisibility: HistoryVisibility =
-    visibilityEvent?.getContent<RoomHistoryVisibilityEventContent>().history_visibility ??
-    HistoryVisibility.Shared;
-  const visibilityMenu = useVisibilityMenu();
-  const visibilityStr = useVisibilityStr();
-
-  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
-
-  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
-    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
-  };
-
-  const [submitState, submit] = useAsyncCallback(
-    useCallback(
-      async (visibility: HistoryVisibility) => {
-        const content: RoomHistoryVisibilityEventContent = {
-          history_visibility: visibility,
-        };
-        await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
-      },
-      [mx, room.roomId]
-    )
-  );
-  const submitting = submitState.status === AsyncStatus.Loading;
-
-  const handleChange = (visibility: HistoryVisibility) => {
-    submit(visibility);
-    setMenuAnchor(undefined);
-  };
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Message History Visibility"
-        description="Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect."
-        after={
-          <PopOut
-            anchor={menuAnchor}
-            position="Bottom"
-            align="End"
-            content={
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  returnFocusOnDeactivate: false,
-                  onDeactivate: () => setMenuAnchor(undefined),
-                  clickOutsideDeactivates: true,
-                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                  escapeDeactivates: stopPropagation,
-                }}
-              >
-                <Menu style={{ padding: config.space.S100 }}>
-                  {visibilityMenu.map((visibility) => (
-                    <MenuItem
-                      key={visibility}
-                      size="300"
-                      radii="300"
-                      onClick={() => handleChange(visibility)}
-                      aria-pressed={visibility === historyVisibility}
-                    >
-                      <Text as="span" size="T300" truncate>
-                        {visibilityStr[visibility]}
-                      </Text>
-                    </MenuItem>
-                  ))}
-                </Menu>
-              </FocusTrap>
-            }
-          >
-            <Button
-              variant="Secondary"
-              fill="Soft"
-              size="300"
-              radii="300"
-              outlined
-              disabled={!canEdit || submitting}
-              onClick={handleOpenMenu}
-              after={
-                submitting ? (
-                  <Spinner size="100" variant="Secondary" />
-                ) : (
-                  <Icon size="100" src={Icons.ChevronBottom} />
-                )
-              }
-            >
-              <Text size="B300">{visibilityStr[historyVisibility]}</Text>
-            </Button>
-          </PopOut>
-        }
-      >
-        {submitState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(submitState.error as MatrixError).message}
-          </Text>
-        )}
-      </SettingTile>
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomJoinRules.tsx b/src/app/features/room-settings/general/RoomJoinRules.tsx
deleted file mode 100644 (file)
index a98fee6..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-import React, { useCallback, useMemo } from 'react';
-import { color, Text } from 'folds';
-import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
-import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import {
-  JoinRulesSwitcher,
-  useRoomJoinRuleIcon,
-  useRoomJoinRuleLabel,
-} from '../../../components/JoinRulesSwitcher';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRoom } from '../../../hooks/useRoom';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { useSpaceOptionally } from '../../../hooks/useSpace';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { getStateEvents } from '../../../utils/room';
-
-type RestrictedRoomAllowContent = {
-  room_id: string;
-  type: RestrictedAllowType;
-};
-
-type RoomJoinRulesProps = {
-  powerLevels: IPowerLevels;
-};
-export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const roomVersion = parseInt(room.getVersion(), 10);
-  const allowRestricted = roomVersion >= 8;
-  const allowKnock = roomVersion >= 7;
-  const space = useSpaceOptionally();
-
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEdit = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomHistoryVisibility,
-    userPowerLevel
-  );
-
-  const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
-  const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
-  const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
-
-  const joinRules: Array<JoinRule> = useMemo(() => {
-    const r: JoinRule[] = [JoinRule.Invite];
-    if (allowKnock) {
-      r.push(JoinRule.Knock);
-    }
-    if (allowRestricted && space) {
-      r.push(JoinRule.Restricted);
-    }
-    r.push(JoinRule.Public);
-
-    return r;
-  }, [allowRestricted, allowKnock, space]);
-
-  const icons = useRoomJoinRuleIcon();
-  const labels = useRoomJoinRuleLabel();
-
-  const [submitState, submit] = useAsyncCallback(
-    useCallback(
-      async (joinRule: JoinRule) => {
-        const allow: RestrictedRoomAllowContent[] = [];
-        if (joinRule === JoinRule.Restricted) {
-          const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
-            event.getStateKey()
-          );
-          parents.forEach((parentRoomId) => {
-            if (!parentRoomId) return;
-            allow.push({
-              type: RestrictedAllowType.RoomMembership,
-              room_id: parentRoomId,
-            });
-          });
-        }
-
-        const c: RoomJoinRulesEventContent = {
-          join_rule: joinRule,
-        };
-        if (allow.length > 0) c.allow = allow;
-        await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
-      },
-      [mx, room]
-    )
-  );
-
-  const submitting = submitState.status === AsyncStatus.Loading;
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Room Access"
-        description="Change how people can join the room."
-        after={
-          <JoinRulesSwitcher
-            icons={icons}
-            labels={labels}
-            rules={joinRules}
-            value={rule}
-            onChange={submit}
-            disabled={!canEdit || submitting}
-            changing={submitting}
-          />
-        }
-      >
-        {submitState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(submitState.error as MatrixError).message}
-          </Text>
-        )}
-      </SettingTile>
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomProfile.tsx b/src/app/features/room-settings/general/RoomProfile.tsx
deleted file mode 100644 (file)
index c59ecf0..0000000
+++ /dev/null
@@ -1,351 +0,0 @@
-import {
-  Avatar,
-  Box,
-  Button,
-  Chip,
-  color,
-  Icon,
-  Icons,
-  Input,
-  Spinner,
-  Text,
-  TextArea,
-} from 'folds';
-import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
-import { useAtomValue } from 'jotai';
-import Linkify from 'linkify-react';
-import classNames from 'classnames';
-import { JoinRule, MatrixError } from 'matrix-js-sdk';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { useRoom } from '../../../hooks/useRoom';
-import {
-  useRoomAvatar,
-  useRoomJoinRule,
-  useRoomName,
-  useRoomTopic,
-} from '../../../hooks/useRoomMeta';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
-import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
-import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
-import { mxcUrlToHttp } from '../../../utils/matrix';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { CompactUploadCardRenderer } from '../../../components/upload-card';
-import { useObjectURL } from '../../../hooks/useObjectURL';
-import { createUploadAtom, UploadSuccess } from '../../../state/upload';
-import { useFilePicker } from '../../../hooks/useFilePicker';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useAlive } from '../../../hooks/useAlive';
-
-type RoomProfileEditProps = {
-  canEditAvatar: boolean;
-  canEditName: boolean;
-  canEditTopic: boolean;
-  avatar?: string;
-  name: string;
-  topic: string;
-  onClose: () => void;
-};
-export function RoomProfileEdit({
-  canEditAvatar,
-  canEditName,
-  canEditTopic,
-  avatar,
-  name,
-  topic,
-  onClose,
-}: RoomProfileEditProps) {
-  const room = useRoom();
-  const mx = useMatrixClient();
-  const alive = useAlive();
-  const useAuthentication = useMediaAuthentication();
-  const joinRule = useRoomJoinRule(room);
-  const [roomAvatar, setRoomAvatar] = useState(avatar);
-
-  const avatarUrl = roomAvatar
-    ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
-    : undefined;
-
-  const [imageFile, setImageFile] = useState<File>();
-  const avatarFileUrl = useObjectURL(imageFile);
-  const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
-  const uploadAtom = useMemo(() => {
-    if (imageFile) return createUploadAtom(imageFile);
-    return undefined;
-  }, [imageFile]);
-
-  const pickFile = useFilePicker(setImageFile, false);
-
-  const handleRemoveUpload = useCallback(() => {
-    setImageFile(undefined);
-    setRoomAvatar(avatar);
-  }, [avatar]);
-
-  const handleUploaded = useCallback((upload: UploadSuccess) => {
-    setRoomAvatar(upload.mxc);
-  }, []);
-
-  const [submitState, submit] = useAsyncCallback(
-    useCallback(
-      async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
-        if (roomAvatarMxc !== undefined) {
-          await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
-            url: roomAvatarMxc,
-          });
-        }
-        if (roomName !== undefined) {
-          await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
-        }
-        if (roomTopic !== undefined) {
-          await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
-        }
-      },
-      [mx, room.roomId]
-    )
-  );
-  const submitting = submitState.status === AsyncStatus.Loading;
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    if (uploadingAvatar) return;
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const nameInput = target?.nameInput as HTMLInputElement | undefined;
-    const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
-    if (!nameInput || !topicTextArea) return;
-
-    const roomName = nameInput.value.trim();
-    const roomTopic = topicTextArea.value.trim();
-
-    if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
-      return;
-    }
-
-    submit(
-      roomAvatar === avatar ? undefined : roomAvatar || null,
-      roomName === name ? undefined : roomName,
-      roomTopic === topic ? undefined : roomTopic
-    ).then(() => {
-      if (alive()) {
-        onClose();
-      }
-    });
-  };
-
-  return (
-    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
-      <Box gap="400">
-        <Box grow="Yes" direction="Column" gap="100">
-          <Text size="L400">Avatar</Text>
-          {uploadAtom ? (
-            <Box gap="200" direction="Column">
-              <CompactUploadCardRenderer
-                uploadAtom={uploadAtom}
-                onRemove={handleRemoveUpload}
-                onComplete={handleUploaded}
-              />
-            </Box>
-          ) : (
-            <Box gap="200">
-              <Button
-                type="button"
-                size="300"
-                variant="Secondary"
-                fill="Soft"
-                radii="300"
-                disabled={!canEditAvatar || submitting}
-                onClick={() => pickFile('image/*')}
-              >
-                <Text size="B300">Upload</Text>
-              </Button>
-              {!roomAvatar && avatar && (
-                <Button
-                  type="button"
-                  size="300"
-                  variant="Success"
-                  fill="None"
-                  radii="300"
-                  disabled={!canEditAvatar || submitting}
-                  onClick={() => setRoomAvatar(avatar)}
-                >
-                  <Text size="B300">Reset</Text>
-                </Button>
-              )}
-              {roomAvatar && (
-                <Button
-                  type="button"
-                  size="300"
-                  variant="Critical"
-                  fill="None"
-                  radii="300"
-                  disabled={!canEditAvatar || submitting}
-                  onClick={() => setRoomAvatar(undefined)}
-                >
-                  <Text size="B300">Remove</Text>
-                </Button>
-              )}
-            </Box>
-          )}
-        </Box>
-        <Box shrink="No">
-          <Avatar size="500" radii="300">
-            <RoomAvatar
-              roomId={room.roomId}
-              src={avatarUrl}
-              alt={name}
-              renderFallback={() => (
-                <RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
-              )}
-            />
-          </Avatar>
-        </Box>
-      </Box>
-      <Box direction="Inherit" gap="100">
-        <Text size="L400">Name</Text>
-        <Input
-          name="nameInput"
-          defaultValue={name}
-          variant="Secondary"
-          radii="300"
-          readOnly={!canEditName || submitting}
-        />
-      </Box>
-      <Box direction="Inherit" gap="100">
-        <Text size="L400">Topic</Text>
-        <TextArea
-          name="topicTextArea"
-          defaultValue={topic}
-          variant="Secondary"
-          radii="300"
-          readOnly={!canEditTopic || submitting}
-        />
-      </Box>
-      {submitState.status === AsyncStatus.Error && (
-        <Text size="T200" style={{ color: color.Critical.Main }}>
-          {(submitState.error as MatrixError).message}
-        </Text>
-      )}
-      <Box gap="300">
-        <Button
-          type="submit"
-          variant="Success"
-          size="300"
-          radii="300"
-          disabled={uploadingAvatar || submitting}
-          before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
-        >
-          <Text size="B300">Save</Text>
-        </Button>
-        <Button
-          type="reset"
-          onClick={onClose}
-          variant="Secondary"
-          fill="Soft"
-          size="300"
-          radii="300"
-        >
-          <Text size="B300">Cancel</Text>
-        </Button>
-      </Box>
-    </Box>
-  );
-}
-
-type RoomProfileProps = {
-  powerLevels: IPowerLevels;
-};
-export function RoomProfile({ powerLevels }: RoomProfileProps) {
-  const mx = useMatrixClient();
-  const useAuthentication = useMediaAuthentication();
-  const room = useRoom();
-  const directs = useAtomValue(mDirectAtom);
-  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
-  const userPowerLevel = getPowerLevel(mx.getSafeUserId());
-
-  const avatar = useRoomAvatar(room, directs.has(room.roomId));
-  const name = useRoomName(room);
-  const topic = useRoomTopic(room);
-  const joinRule = useRoomJoinRule(room);
-
-  const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
-  const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
-  const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
-  const canEdit = canEditAvatar || canEditName || canEditTopic;
-
-  const avatarUrl = avatar
-    ? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
-    : undefined;
-
-  const [edit, setEdit] = useState(false);
-
-  const handleCloseEdit = useCallback(() => setEdit(false), []);
-
-  return (
-    <Box direction="Column" gap="100">
-      <Text size="L400">Profile</Text>
-      <SequenceCard
-        className={SequenceCardStyle}
-        variant="SurfaceVariant"
-        direction="Column"
-        gap="400"
-      >
-        {edit ? (
-          <RoomProfileEdit
-            canEditAvatar={canEditAvatar}
-            canEditName={canEditName}
-            canEditTopic={canEditTopic}
-            avatar={avatar}
-            name={name ?? ''}
-            topic={topic ?? ''}
-            onClose={handleCloseEdit}
-          />
-        ) : (
-          <Box gap="400">
-            <Box grow="Yes" direction="Column" gap="300">
-              <Box direction="Column" gap="100">
-                <Text className={BreakWord} size="H5">
-                  {name ?? 'Unknown'}
-                </Text>
-                {topic && (
-                  <Text className={classNames(BreakWord, LineClamp3)} size="T200">
-                    <Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
-                  </Text>
-                )}
-              </Box>
-              {canEdit && (
-                <Box gap="200">
-                  <Chip
-                    variant="Secondary"
-                    fill="Soft"
-                    radii="300"
-                    before={<Icon size="50" src={Icons.Pencil} />}
-                    onClick={() => setEdit(true)}
-                    outlined
-                  >
-                    <Text size="B300">Edit</Text>
-                  </Chip>
-                </Box>
-              )}
-            </Box>
-            <Box shrink="No">
-              <Avatar size="500" radii="300">
-                <RoomAvatar
-                  roomId={room.roomId}
-                  src={avatarUrl}
-                  alt={name}
-                  renderFallback={() => (
-                    <RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
-                  )}
-                />
-              </Avatar>
-            </Box>
-          </Box>
-        )}
-      </SequenceCard>
-    </Box>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomPublish.tsx b/src/app/features/room-settings/general/RoomPublish.tsx
deleted file mode 100644 (file)
index d17f70e..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { Box, color, Spinner, Switch, Text } from 'folds';
-import { MatrixError } from 'matrix-js-sdk';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useRoom } from '../../../hooks/useRoom';
-import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-
-type RoomPublishProps = {
-  powerLevels: IPowerLevels;
-};
-export function RoomPublish({ powerLevels }: RoomPublishProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canEditCanonical = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomCanonicalAlias,
-    userPowerLevel
-  );
-
-  const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
-
-  const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility);
-
-  const loading =
-    visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Publish To Directory"
-        after={
-          <Box gap="200" alignItems="Center">
-            {loading && <Spinner variant="Secondary" />}
-            {!loading && visibilityState.status === AsyncStatus.Success && (
-              <Switch
-                value={visibilityState.data}
-                onChange={toggleVisibility}
-                disabled={!canEditCanonical}
-              />
-            )}
-          </Box>
-        }
-      >
-        {visibilityState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(visibilityState.error as MatrixError).message}
-          </Text>
-        )}
-
-        {toggleState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(toggleState.error as MatrixError).message}
-          </Text>
-        )}
-      </SettingTile>
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/general/RoomUpgrade.tsx b/src/app/features/room-settings/general/RoomUpgrade.tsx
deleted file mode 100644 (file)
index fa1fa85..0000000
+++ /dev/null
@@ -1,217 +0,0 @@
-import React, { FormEventHandler, useCallback, useState } from 'react';
-import {
-  Button,
-  color,
-  Spinner,
-  Text,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Dialog,
-  Header,
-  config,
-  Box,
-  IconButton,
-  Icon,
-  Icons,
-  Input,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
-import { MatrixError } from 'matrix-js-sdk';
-import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useRoom } from '../../../hooks/useRoom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
-import { useCapabilities } from '../../../hooks/useCapabilities';
-import { stopPropagation } from '../../../utils/keyboard';
-
-type RoomUpgradeProps = {
-  powerLevels: IPowerLevels;
-  requestClose: () => void;
-};
-export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const { navigateRoom } = useRoomNavigate();
-  const createContent = useStateEvent(
-    room,
-    StateEvent.RoomCreate
-  )?.getContent<RoomCreateEventContent>();
-  const roomVersion = createContent?.room_version ?? 1;
-  const predecessorRoomId = createContent?.predecessor?.room_id;
-
-  const capabilities = useCapabilities();
-  const defaultRoomVersion = capabilities['m.room_versions']?.default;
-
-  const tombstoneContent = useStateEvent(
-    room,
-    StateEvent.RoomTombstone
-  )?.getContent<RoomTombstoneEventContent>();
-  const replacementRoom = tombstoneContent?.replacement_room;
-
-  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
-  const canUpgrade = powerLevelAPI.canSendStateEvent(
-    powerLevels,
-    StateEvent.RoomTombstone,
-    userPowerLevel
-  );
-
-  const handleOpenRoom = () => {
-    if (replacementRoom) {
-      requestClose();
-      navigateRoom(replacementRoom);
-    }
-  };
-
-  const handleOpenOldRoom = () => {
-    if (predecessorRoomId) {
-      requestClose();
-      navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
-    }
-  };
-
-  const [upgradeState, upgrade] = useAsyncCallback(
-    useCallback(
-      async (version: string) => {
-        await mx.upgradeRoom(room.roomId, version);
-      },
-      [mx, room]
-    )
-  );
-
-  const upgrading = upgradeState.status === AsyncStatus.Loading;
-
-  const [prompt, setPrompt] = useState(false);
-
-  const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const versionInput = target?.versionInput as HTMLInputElement | undefined;
-    const version = versionInput?.value.trim();
-    if (!version) return;
-
-    upgrade(version);
-    setPrompt(false);
-  };
-
-  return (
-    <SequenceCard
-      className={SequenceCardStyle}
-      variant="SurfaceVariant"
-      direction="Column"
-      gap="400"
-    >
-      <SettingTile
-        title="Upgrade Room"
-        description={
-          replacementRoom
-            ? tombstoneContent.body || 'This room has been replaced!'
-            : `Current room version: ${roomVersion}.`
-        }
-        after={
-          <Box alignItems="Center" gap="200">
-            {predecessorRoomId && (
-              <Button
-                size="300"
-                variant="Secondary"
-                fill="Soft"
-                outlined
-                radii="300"
-                onClick={handleOpenOldRoom}
-              >
-                <Text size="B300">Old Room</Text>
-              </Button>
-            )}
-            {replacementRoom ? (
-              <Button
-                size="300"
-                variant="Success"
-                fill="Solid"
-                radii="300"
-                onClick={handleOpenRoom}
-              >
-                <Text size="B300">Open New Room</Text>
-              </Button>
-            ) : (
-              <Button
-                size="300"
-                variant="Secondary"
-                fill="Solid"
-                radii="300"
-                disabled={upgrading || !canUpgrade}
-                before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
-                onClick={() => setPrompt(true)}
-              >
-                <Text size="B300">Upgrade</Text>
-              </Button>
-            )}
-          </Box>
-        }
-      >
-        {upgradeState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(upgradeState.error as MatrixError).message}
-          </Text>
-        )}
-
-        {prompt && (
-          <Overlay open backdrop={<OverlayBackdrop />}>
-            <OverlayCenter>
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  onDeactivate: () => setPrompt(false),
-                  clickOutsideDeactivates: true,
-                  escapeDeactivates: stopPropagation,
-                }}
-              >
-                <Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
-                  <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">Room Upgrade</Text>
-                    </Box>
-                    <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
-                      <Icon src={Icons.Cross} />
-                    </IconButton>
-                  </Header>
-                  <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
-                    <Text priority="400" style={{ color: color.Critical.Main }}>
-                      <b>This action is irreversible!</b>
-                    </Text>
-                    <Box direction="Column" gap="100">
-                      <Text size="L400">Version</Text>
-                      <Input
-                        defaultValue={defaultRoomVersion}
-                        name="versionInput"
-                        variant="Background"
-                        required
-                      />
-                    </Box>
-                    <Button type="submit" variant="Secondary">
-                      <Text size="B400">Upgrade Room</Text>
-                    </Button>
-                  </Box>
-                </Dialog>
-              </FocusTrap>
-            </OverlayCenter>
-          </Overlay>
-        )}
-      </SettingTile>
-    </SequenceCard>
-  );
-}
diff --git a/src/app/features/room-settings/members/Members.tsx b/src/app/features/room-settings/members/Members.tsx
deleted file mode 100644 (file)
index 40e055b..0000000
+++ /dev/null
@@ -1,353 +0,0 @@
-import React, {
-  ChangeEventHandler,
-  MouseEventHandler,
-  useCallback,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import {
-  Box,
-  Chip,
-  config,
-  Icon,
-  IconButton,
-  Icons,
-  Input,
-  PopOut,
-  RectCords,
-  Scroll,
-  Spinner,
-  Text,
-  toRem,
-} from 'folds';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { RoomMember } from 'matrix-js-sdk';
-import { Page, PageContent, PageHeader } from '../../../components/page';
-import { useRoom } from '../../../hooks/useRoom';
-import { useRoomMembers } from '../../../hooks/useRoomMembers';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import {
-  useFlattenPowerLevelTagMembers,
-  usePowerLevelTags,
-} from '../../../hooks/usePowerLevelTags';
-import { VirtualTile } from '../../../components/virtualizer';
-import { MemberTile } from '../../../components/member-tile';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
-import { ServerBadge } from '../../../components/server-badge';
-import { openProfileViewer } from '../../../../client/action/navigation';
-import { useDebounce } from '../../../hooks/useDebounce';
-import {
-  SearchItemStrGetter,
-  useAsyncSearch,
-  UseAsyncSearchOptions,
-} from '../../../hooks/useAsyncSearch';
-import { getMemberSearchStr } from '../../../utils/room';
-import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
-import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
-import { settingsAtom } from '../../../state/settings';
-import { useSetting } from '../../../state/hooks/settings';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
-import { MemberSortMenu } from '../../../components/MemberSortMenu';
-import { ScrollTopContainer } from '../../../components/scroll-top-container';
-
-const SEARCH_OPTIONS: UseAsyncSearchOptions = {
-  limit: 1000,
-  matchOptions: {
-    contain: true,
-  },
-  normalizeOptions: {
-    ignoreWhitespace: false,
-  },
-};
-
-const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
-const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
-  getMemberSearchStr(m, query, mxIdToName);
-
-type MembersProps = {
-  requestClose: () => void;
-};
-export function Members({ requestClose }: MembersProps) {
-  const mx = useMatrixClient();
-  const useAuthentication = useMediaAuthentication();
-  const room = useRoom();
-  const members = useRoomMembers(mx, room.roomId);
-  const fetchingMembers = members.length < room.getJoinedMemberCount();
-
-  const powerLevels = usePowerLevels(room);
-  const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
-  const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
-
-  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
-  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
-  const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
-  const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
-
-  const scrollRef = useRef<HTMLDivElement>(null);
-  const searchInputRef = useRef<HTMLInputElement>(null);
-  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
-
-  const sortedMembers = useMemo(
-    () =>
-      Array.from(members)
-        .filter(membershipFilter.filterFn)
-        .sort(memberSort.sortFn)
-        .sort((a, b) => b.powerLevel - a.powerLevel),
-    [members, membershipFilter, memberSort]
-  );
-
-  const [result, search, resetSearch] = useAsyncSearch(
-    sortedMembers,
-    getRoomMemberStr,
-    SEARCH_OPTIONS
-  );
-  if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
-
-  const flattenTagMembers = useFlattenPowerLevelTagMembers(
-    result?.items ?? sortedMembers,
-    getPowerLevel,
-    getPowerLevelTag
-  );
-
-  const virtualizer = useVirtualizer({
-    count: flattenTagMembers.length,
-    getScrollElement: () => scrollRef.current,
-    estimateSize: () => 40,
-    overscan: 10,
-  });
-
-  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
-    useCallback(
-      (evt) => {
-        if (evt.target.value) search(evt.target.value);
-        else resetSearch();
-      },
-      [search, resetSearch]
-    ),
-    { wait: 200 }
-  );
-
-  const handleSearchReset = () => {
-    if (searchInputRef.current) {
-      searchInputRef.current.value = '';
-      searchInputRef.current.focus();
-    }
-    resetSearch();
-  };
-
-  const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
-    const btn = evt.currentTarget as HTMLButtonElement;
-    const userId = btn.getAttribute('data-user-id');
-    openProfileViewer(userId, room.roomId);
-    requestClose();
-  };
-
-  return (
-    <Page>
-      <PageHeader outlined={false}>
-        <Box grow="Yes" gap="200">
-          <Box grow="Yes" alignItems="Center" gap="200">
-            <Text size="H3" truncate>
-              {room.getJoinedMemberCount()} Members
-            </Text>
-          </Box>
-          <Box shrink="No">
-            <IconButton onClick={requestClose} variant="Surface">
-              <Icon src={Icons.Cross} />
-            </IconButton>
-          </Box>
-        </Box>
-      </PageHeader>
-      <Box grow="Yes" style={{ position: 'relative' }}>
-        <Scroll ref={scrollRef} hideTrack visibility="Hover">
-          <PageContent>
-            <Box direction="Column" gap="200">
-              <Box
-                style={{ position: 'sticky', top: config.space.S100, zIndex: 1 }}
-                direction="Column"
-                gap="100"
-              >
-                <Input
-                  ref={searchInputRef}
-                  onChange={handleSearchChange}
-                  before={<Icon size="200" src={Icons.Search} />}
-                  variant="SurfaceVariant"
-                  size="500"
-                  placeholder="Search"
-                  outlined
-                  after={
-                    result && (
-                      <Chip
-                        variant={result.items.length > 0 ? 'Success' : 'Critical'}
-                        outlined
-                        size="400"
-                        radii="Pill"
-                        aria-pressed
-                        onClick={handleSearchReset}
-                        after={<Icon size="50" src={Icons.Cross} />}
-                      >
-                        <Text size="B300">
-                          {result.items.length === 0
-                            ? 'No Results'
-                            : `${result.items.length} Results`}
-                        </Text>
-                      </Chip>
-                    )
-                  }
-                />
-              </Box>
-              <Box ref={scrollTopAnchorRef} alignItems="Center" justifyContent="End" gap="200">
-                <UseStateProvider initial={undefined}>
-                  {(anchor: RectCords | undefined, setAnchor) => (
-                    <PopOut
-                      anchor={anchor}
-                      position="Bottom"
-                      align="Start"
-                      offset={4}
-                      content={
-                        <MembershipFilterMenu
-                          selected={membershipFilterIndex}
-                          onSelect={setMembershipFilterIndex}
-                          requestClose={() => setAnchor(undefined)}
-                        />
-                      }
-                    >
-                      <Chip
-                        onClick={
-                          ((evt) =>
-                            setAnchor(
-                              evt.currentTarget.getBoundingClientRect()
-                            )) as MouseEventHandler<HTMLButtonElement>
-                        }
-                        variant="SurfaceVariant"
-                        size="400"
-                        radii="300"
-                        before={<Icon src={Icons.Filter} size="50" />}
-                      >
-                        <Text size="T200">{membershipFilter.name}</Text>
-                      </Chip>
-                    </PopOut>
-                  )}
-                </UseStateProvider>
-                <UseStateProvider initial={undefined}>
-                  {(anchor: RectCords | undefined, setAnchor) => (
-                    <PopOut
-                      anchor={anchor}
-                      position="Bottom"
-                      align="End"
-                      offset={4}
-                      content={
-                        <MemberSortMenu
-                          selected={sortFilterIndex}
-                          onSelect={setSortFilterIndex}
-                          requestClose={() => setAnchor(undefined)}
-                        />
-                      }
-                    >
-                      <Chip
-                        onClick={
-                          ((evt) =>
-                            setAnchor(
-                              evt.currentTarget.getBoundingClientRect()
-                            )) as MouseEventHandler<HTMLButtonElement>
-                        }
-                        variant="SurfaceVariant"
-                        size="400"
-                        radii="300"
-                        after={<Icon src={Icons.Sort} size="50" />}
-                      >
-                        <Text size="T200">{memberSort.name}</Text>
-                      </Chip>
-                    </PopOut>
-                  )}
-                </UseStateProvider>
-              </Box>
-              <ScrollTopContainer
-                style={{ top: toRem(64) }}
-                scrollRef={scrollRef}
-                anchorRef={scrollTopAnchorRef}
-              >
-                <IconButton
-                  onClick={() => virtualizer.scrollToOffset(0)}
-                  variant="Surface"
-                  radii="Pill"
-                  outlined
-                  size="300"
-                  aria-label="Scroll to Top"
-                >
-                  <Icon src={Icons.ChevronTop} size="300" />
-                </IconButton>
-              </ScrollTopContainer>
-              {fetchingMembers && (
-                <Box justifyContent="Center">
-                  <Spinner />
-                </Box>
-              )}
-              <Box
-                style={{
-                  position: 'relative',
-                  height: virtualizer.getTotalSize(),
-                }}
-                direction="Column"
-                gap="100"
-              >
-                {virtualizer.getVirtualItems().map((vItem) => {
-                  const tagOrMember = flattenTagMembers[vItem.index];
-
-                  if ('userId' in tagOrMember) {
-                    const server = getMxIdServer(tagOrMember.userId);
-                    return (
-                      <VirtualTile
-                        virtualItem={vItem}
-                        key={`${tagOrMember.userId}-${vItem.index}`}
-                        ref={virtualizer.measureElement}
-                      >
-                        <div style={{ paddingTop: config.space.S200 }}>
-                          <MemberTile
-                            data-user-id={tagOrMember.userId}
-                            onClick={handleMemberClick}
-                            mx={mx}
-                            room={room}
-                            member={tagOrMember}
-                            useAuthentication={useAuthentication}
-                            after={
-                              server && (
-                                <Box as="span" shrink="No" alignSelf="End">
-                                  <ServerBadge server={server} fill="None" />
-                                </Box>
-                              )
-                            }
-                          />
-                        </div>
-                      </VirtualTile>
-                    );
-                  }
-
-                  return (
-                    <VirtualTile
-                      virtualItem={vItem}
-                      key={vItem.index}
-                      ref={virtualizer.measureElement}
-                    >
-                      <div
-                        style={{
-                          paddingTop: vItem.index === 0 ? 0 : config.space.S500,
-                        }}
-                      >
-                        <Text size="L400">{tagOrMember.name}</Text>
-                      </div>
-                    </VirtualTile>
-                  );
-                })}
-              </Box>
-            </Box>
-          </PageContent>
-        </Scroll>
-      </Box>
-    </Page>
-  );
-}
diff --git a/src/app/features/room-settings/members/index.ts b/src/app/features/room-settings/members/index.ts
deleted file mode 100644 (file)
index 9e0df78..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Members';
diff --git a/src/app/features/room-settings/permissions/PermissionGroups.tsx b/src/app/features/room-settings/permissions/PermissionGroups.tsx
deleted file mode 100644 (file)
index add2f55..0000000
+++ /dev/null
@@ -1,287 +0,0 @@
-/* eslint-disable react/no-array-index-key */
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
-import produce from 'immer';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import {
-  applyPermissionPower,
-  getPermissionPower,
-  IPowerLevels,
-  PermissionLocation,
-  usePowerLevelsAPI,
-} from '../../../hooks/usePowerLevels';
-import { usePermissionGroups } from './usePermissionItems';
-import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
-import { useRoom } from '../../../hooks/useRoom';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { StateEvent } from '../../../../types/matrix/room';
-import { PowerSwitcher } from '../../../components/power';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useAlive } from '../../../hooks/useAlive';
-
-const USER_DEFAULT_LOCATION: PermissionLocation = {
-  user: true,
-};
-
-type PermissionGroupsProps = {
-  powerLevels: IPowerLevels;
-};
-export function PermissionGroups({ powerLevels }: PermissionGroupsProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const alive = useAlive();
-  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
-  const canChangePermission = canSendStateEvent(
-    StateEvent.RoomPowerLevels,
-    getPowerLevel(mx.getSafeUserId())
-  );
-  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
-  const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
-
-  const permissionGroups = usePermissionGroups();
-
-  const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
-    new Map()
-  );
-
-  useEffect(() => {
-    // reset permission update if component rerender
-    // as permission location object reference has changed
-    setPermissionUpdate(new Map());
-  }, [permissionGroups]);
-
-  const handleChangePermission = (
-    location: PermissionLocation,
-    newPower: number,
-    currentPower: number
-  ) => {
-    setPermissionUpdate((p) => {
-      const up: typeof p = new Map();
-      p.forEach((value, key) => {
-        up.set(key, value);
-      });
-      if (newPower === currentPower) {
-        up.delete(location);
-      } else {
-        up.set(location, newPower);
-      }
-      return up;
-    });
-  };
-
-  const [applyState, applyChanges] = useAsyncCallback(
-    useCallback(async () => {
-      const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => {
-        permissionGroups.forEach((group) =>
-          group.items.forEach((item) => {
-            const power = getPermissionPower(powerLevels, item.location);
-            applyPermissionPower(draftPowerLevels, item.location, power);
-          })
-        );
-        permissionUpdate.forEach((power, location) =>
-          applyPermissionPower(draftPowerLevels, location, power)
-        );
-        return draftPowerLevels;
-      });
-      await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
-    }, [mx, room, powerLevels, permissionUpdate, permissionGroups])
-  );
-
-  const resetChanges = useCallback(() => {
-    setPermissionUpdate(new Map());
-  }, []);
-
-  const handleApplyChanges = () => {
-    applyChanges().then(() => {
-      if (alive()) {
-        resetChanges();
-      }
-    });
-  };
-
-  const applyingChanges = applyState.status === AsyncStatus.Loading;
-  const hasChanges = permissionUpdate.size > 0;
-
-  const renderUserGroup = () => {
-    const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION);
-    const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
-    const value = powerUpdate ?? power;
-
-    const tag = getPowerLevelTag(value);
-    const powerChanges = value !== power;
-
-    return (
-      <Box direction="Column" gap="100">
-        <Text size="L400">Users</Text>
-        <SequenceCard
-          variant="SurfaceVariant"
-          className={SequenceCardStyle}
-          direction="Column"
-          gap="400"
-        >
-          <SettingTile
-            title="Default Power"
-            description="Default power level for all users."
-            after={
-              <PowerSwitcher
-                powerLevelTags={powerLevelTags}
-                value={value}
-                onChange={(v) => handleChangePermission(USER_DEFAULT_LOCATION, v, power)}
-              >
-                {(handleOpen, opened) => (
-                  <Chip
-                    variant={powerChanges ? 'Success' : 'Secondary'}
-                    outlined={powerChanges}
-                    fill="Soft"
-                    radii="Pill"
-                    aria-selected={opened}
-                    disabled={!canChangePermission || applyingChanges}
-                    after={
-                      powerChanges && (
-                        <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
-                      )
-                    }
-                    before={
-                      canChangePermission && (
-                        <Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
-                      )
-                    }
-                    onClick={handleOpen}
-                  >
-                    <Text size="B300" truncate>
-                      {tag.name}
-                    </Text>
-                  </Chip>
-                )}
-              </PowerSwitcher>
-            }
-          />
-        </SequenceCard>
-      </Box>
-    );
-  };
-
-  return (
-    <>
-      {renderUserGroup()}
-      {permissionGroups.map((group, groupIndex) => (
-        <Box key={groupIndex} direction="Column" gap="100">
-          <Text size="L400">{group.name}</Text>
-          {group.items.map((item, itemIndex) => {
-            const power = getPermissionPower(powerLevels, item.location);
-            const powerUpdate = permissionUpdate.get(item.location);
-            const value = powerUpdate ?? power;
-
-            const tag = getPowerLevelTag(value);
-            const powerChanges = value !== power;
-
-            return (
-              <SequenceCard
-                key={itemIndex}
-                variant="SurfaceVariant"
-                className={SequenceCardStyle}
-                direction="Column"
-                gap="400"
-              >
-                <SettingTile
-                  title={item.name}
-                  description={item.description}
-                  after={
-                    <PowerSwitcher
-                      powerLevelTags={powerLevelTags}
-                      value={value}
-                      onChange={(v) => handleChangePermission(item.location, v, power)}
-                    >
-                      {(handleOpen, opened) => (
-                        <Chip
-                          variant={powerChanges ? 'Success' : 'Secondary'}
-                          outlined={powerChanges}
-                          fill="Soft"
-                          radii="Pill"
-                          aria-selected={opened}
-                          disabled={!canChangePermission || applyingChanges}
-                          after={
-                            powerChanges && (
-                              <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
-                            )
-                          }
-                          before={
-                            canChangePermission && (
-                              <Icon
-                                size="50"
-                                src={opened ? Icons.ChevronTop : Icons.ChevronBottom}
-                              />
-                            )
-                          }
-                          onClick={handleOpen}
-                        >
-                          <Text size="B300" truncate>
-                            {tag.name}
-                          </Text>
-                          {value < maxPower && <Text size="T200">& Above</Text>}
-                        </Chip>
-                      )}
-                    </PowerSwitcher>
-                  }
-                />
-              </SequenceCard>
-            );
-          })}
-        </Box>
-      ))}
-
-      {hasChanges && (
-        <Menu
-          style={{
-            position: 'sticky',
-            padding: config.space.S200,
-            paddingLeft: config.space.S400,
-            bottom: config.space.S400,
-            left: config.space.S400,
-            right: 0,
-            zIndex: 1,
-          }}
-          variant="Success"
-        >
-          <Box alignItems="Center" gap="400">
-            <Box grow="Yes" direction="Column">
-              {applyState.status === AsyncStatus.Error ? (
-                <Text size="T200">
-                  <b>Failed to apply changes! Please try again.</b>
-                </Text>
-              ) : (
-                <Text size="T200">
-                  <b>Changes saved! Apply when ready.</b>
-                </Text>
-              )}
-            </Box>
-            <Box shrink="No" gap="200">
-              <Button
-                size="300"
-                variant="Success"
-                fill="None"
-                radii="300"
-                disabled={applyingChanges}
-                onClick={resetChanges}
-              >
-                <Text size="B300">Reset</Text>
-              </Button>
-              <Button
-                size="300"
-                variant="Success"
-                radii="300"
-                disabled={applyingChanges}
-                before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
-                onClick={handleApplyChanges}
-              >
-                <Text size="B300">Apply Changes</Text>
-              </Button>
-            </Box>
-          </Box>
-        </Menu>
-      )}
-    </>
-  );
-}
index 802dd6577da68a5ce2be775f09208a069896c948..ae3769bf189d4971d8aa9a3d8356d1fb68394ee9 100644 (file)
@@ -1,13 +1,12 @@
 import React, { useState } from 'react';
 import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
 import { Page, PageContent, PageHeader } from '../../../components/page';
-import { Powers } from './Powers';
 import { useRoom } from '../../../hooks/useRoom';
 import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { StateEvent } from '../../../../types/matrix/room';
-import { PowersEditor } from './PowersEditor';
-import { PermissionGroups } from './PermissionGroups';
+import { usePermissionGroups } from './usePermissionItems';
+import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
 
 type PermissionsProps = {
   requestClose: () => void;
@@ -21,6 +20,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
     StateEvent.PowerLevelTags,
     getPowerLevel(mx.getSafeUserId())
   );
+  const permissionGroups = usePermissionGroups();
 
   const [powerEditor, setPowerEditor] = useState(false);
 
@@ -55,8 +55,9 @@ export function Permissions({ requestClose }: PermissionsProps) {
               <Powers
                 powerLevels={powerLevels}
                 onEdit={canEditPowers ? handleEditPowers : undefined}
+                permissionGroups={permissionGroups}
               />
-              <PermissionGroups powerLevels={powerLevels} />
+              <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
             </Box>
           </PageContent>
         </Scroll>
diff --git a/src/app/features/room-settings/permissions/Powers.tsx b/src/app/features/room-settings/permissions/Powers.tsx
deleted file mode 100644 (file)
index 24a71e7..0000000
+++ /dev/null
@@ -1,170 +0,0 @@
-/* eslint-disable react/no-array-index-key */
-import React, { useState, MouseEventHandler, ReactNode } from 'react';
-import FocusTrap from 'focus-trap-react';
-import {
-  Box,
-  Button,
-  Chip,
-  Text,
-  RectCords,
-  PopOut,
-  Menu,
-  Scroll,
-  toRem,
-  config,
-  color,
-} from 'folds';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
-import { SettingTile } from '../../../components/setting-tile';
-import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
-import { useRoom } from '../../../hooks/useRoom';
-import { PowerColorBadge, PowerIcon } from '../../../components/power';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { stopPropagation } from '../../../utils/keyboard';
-import { usePermissionGroups } from './usePermissionItems';
-
-type PeekPermissionsProps = {
-  powerLevels: IPowerLevels;
-  power: number;
-  children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
-};
-function PeekPermissions({ powerLevels, power, children }: PeekPermissionsProps) {
-  const [menuCords, setMenuCords] = useState<RectCords>();
-  const permissionGroups = usePermissionGroups();
-
-  const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
-    setMenuCords(evt.currentTarget.getBoundingClientRect());
-  };
-
-  return (
-    <PopOut
-      anchor={menuCords}
-      offset={5}
-      position="Bottom"
-      align="Center"
-      content={
-        <FocusTrap
-          focusTrapOptions={{
-            initialFocus: false,
-            onDeactivate: () => setMenuCords(undefined),
-            clickOutsideDeactivates: true,
-            escapeDeactivates: stopPropagation,
-          }}
-        >
-          <Menu
-            style={{
-              maxHeight: '75vh',
-              maxWidth: toRem(300),
-              display: 'flex',
-            }}
-          >
-            <Box grow="Yes" tabIndex={0}>
-              <Scroll size="0" hideTrack visibility="Hover">
-                <Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
-                  {permissionGroups.map((group, groupIndex) => (
-                    <Box key={groupIndex} direction="Column" gap="100">
-                      <Text size="L400">{group.name}</Text>
-                      <div>
-                        {group.items.map((item, itemIndex) => {
-                          const requiredPower = getPermissionPower(powerLevels, item.location);
-                          const hasPower = requiredPower <= power;
-
-                          return (
-                            <Text
-                              key={itemIndex}
-                              size="T200"
-                              style={{
-                                color: hasPower ? undefined : color.Critical.Main,
-                              }}
-                            >
-                              {hasPower ? '✅' : '❌'} {item.name}
-                            </Text>
-                          );
-                        })}
-                      </div>
-                    </Box>
-                  ))}
-                </Box>
-              </Scroll>
-            </Box>
-          </Menu>
-        </FocusTrap>
-      }
-    >
-      {children(handleOpen, !!menuCords)}
-    </PopOut>
-  );
-}
-
-type PowersProps = {
-  powerLevels: IPowerLevels;
-  onEdit?: () => void;
-};
-export function Powers({ powerLevels, onEdit }: PowersProps) {
-  const mx = useMatrixClient();
-  const useAuthentication = useMediaAuthentication();
-  const room = useRoom();
-  const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
-
-  return (
-    <Box direction="Column" gap="100">
-      <SequenceCard
-        variant="SurfaceVariant"
-        className={SequenceCardStyle}
-        direction="Column"
-        gap="400"
-      >
-        <SettingTile
-          title="Power Levels"
-          description="Manage and customize incremental power levels for users."
-          after={
-            onEdit && (
-              <Box gap="200">
-                <Button
-                  variant="Secondary"
-                  fill="Soft"
-                  size="300"
-                  radii="300"
-                  outlined
-                  onClick={onEdit}
-                >
-                  <Text size="B300">Edit</Text>
-                </Button>
-              </Box>
-            )
-          }
-        />
-        <SettingTile>
-          <Box gap="200" wrap="Wrap">
-            {getPowers(powerLevelTags).map((power) => {
-              const tag = powerLevelTags[power];
-              const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
-
-              return (
-                <PeekPermissions key={power} powerLevels={powerLevels} power={power}>
-                  {(openMenu, opened) => (
-                    <Chip
-                      onClick={openMenu}
-                      variant="Secondary"
-                      aria-pressed={opened}
-                      radii="300"
-                      before={<PowerColorBadge color={tag.color} />}
-                      after={tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
-                    >
-                      <Text size="T300" truncate>
-                        <b>{tag.name}</b>
-                      </Text>
-                    </Chip>
-                  )}
-                </PeekPermissions>
-              );
-            })}
-          </Box>
-        </SettingTile>
-      </SequenceCard>
-    </Box>
-  );
-}
diff --git a/src/app/features/room-settings/permissions/PowersEditor.tsx b/src/app/features/room-settings/permissions/PowersEditor.tsx
deleted file mode 100644 (file)
index 25d2ba9..0000000
+++ /dev/null
@@ -1,579 +0,0 @@
-import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
-import {
-  Box,
-  Text,
-  Chip,
-  Icon,
-  Icons,
-  IconButton,
-  Scroll,
-  Button,
-  Input,
-  RectCords,
-  PopOut,
-  Menu,
-  config,
-  Spinner,
-  toRem,
-  TooltipProvider,
-  Tooltip,
-} from 'folds';
-import { HexColorPicker } from 'react-colorful';
-import { useAtomValue } from 'jotai';
-import { Page, PageContent, PageHeader } from '../../../components/page';
-import { IPowerLevels } from '../../../hooks/usePowerLevels';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import {
-  getPowers,
-  getTagIconSrc,
-  getUsedPowers,
-  PowerLevelTag,
-  PowerLevelTagIcon,
-  PowerLevelTags,
-  usePowerLevelTags,
-} from '../../../hooks/usePowerLevelTags';
-import { useRoom } from '../../../hooks/useRoom';
-import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
-import { PowerColorBadge, PowerIcon } from '../../../components/power';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
-import { roomToParentsAtom } from '../../../state/room/roomToParents';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useFilePicker } from '../../../hooks/useFilePicker';
-import { CompactUploadCardRenderer } from '../../../components/upload-card';
-import { createUploadAtom, UploadSuccess } from '../../../state/upload';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useAlive } from '../../../hooks/useAlive';
-import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
-
-type EditPowerProps = {
-  maxPower: number;
-  power?: number;
-  tag?: PowerLevelTag;
-  onSave: (power: number, tag: PowerLevelTag) => void;
-  onClose: () => void;
-};
-function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
-  const mx = useMatrixClient();
-  const room = useRoom();
-  const roomToParents = useAtomValue(roomToParentsAtom);
-  const useAuthentication = useMediaAuthentication();
-
-  const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
-
-  const [iconFile, setIconFile] = useState<File>();
-  const pickFile = useFilePicker(setIconFile, false);
-
-  const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
-  const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
-  const uploadingIcon = iconFile && !tagIcon;
-  const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon);
-
-  const iconUploadAtom = useMemo(() => {
-    if (iconFile) return createUploadAtom(iconFile);
-    return undefined;
-  }, [iconFile]);
-
-  const handleRemoveIconUpload = useCallback(() => {
-    setIconFile(undefined);
-  }, []);
-
-  const handleIconUploaded = useCallback((upload: UploadSuccess) => {
-    setTagIcon({
-      key: upload.mxc,
-    });
-    setIconFile(undefined);
-  }, []);
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    if (uploadingIcon) return;
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const powerInput = target?.powerInput as HTMLInputElement | undefined;
-    const nameInput = target?.nameInput as HTMLInputElement | undefined;
-    if (!powerInput || !nameInput) return;
-
-    const tagPower = parseInt(powerInput.value, 10);
-    if (Number.isNaN(tagPower)) return;
-    if (tagPower > maxPower) return;
-    const tagName = nameInput.value.trim();
-    if (!tagName) return;
-
-    const editedTag: PowerLevelTag = {
-      name: tagName,
-      color: tagColor,
-      icon: tagIcon,
-    };
-
-    onSave(power ?? tagPower, editedTag);
-    onClose();
-  };
-
-  return (
-    <Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
-      <Box direction="Column" gap="300">
-        <Box gap="200">
-          <Box shrink="No" direction="Column" gap="100">
-            <Text size="L400">Color</Text>
-            <Box gap="200">
-              <HexColorPickerPopOut
-                picker={<HexColorPicker color={tagColor} onChange={setTagColor} />}
-                onRemove={() => setTagColor(undefined)}
-              >
-                {(openPicker, opened) => (
-                  <Button
-                    aria-pressed={opened}
-                    onClick={openPicker}
-                    size="300"
-                    type="button"
-                    variant="Secondary"
-                    fill="Soft"
-                    radii="300"
-                    before={<PowerColorBadge color={tagColor} />}
-                  >
-                    <Text size="B300">Pick</Text>
-                  </Button>
-                )}
-              </HexColorPickerPopOut>
-            </Box>
-          </Box>
-          <Box grow="Yes" direction="Column" gap="100">
-            <Text size="L400">Name</Text>
-            <Input
-              name="nameInput"
-              defaultValue={tag?.name}
-              placeholder="Bot"
-              size="300"
-              variant="Secondary"
-              radii="300"
-              required
-            />
-          </Box>
-          <Box style={{ maxWidth: toRem(74) }} grow="Yes" direction="Column" gap="100">
-            <Text size="L400">Power</Text>
-            <Input
-              defaultValue={power}
-              name="powerInput"
-              size="300"
-              variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
-              radii="300"
-              type="number"
-              placeholder="75"
-              max={maxPower}
-              outlined={typeof power === 'number'}
-              readOnly={typeof power === 'number'}
-              required
-            />
-          </Box>
-        </Box>
-      </Box>
-      <Box direction="Column" gap="100">
-        <Text size="L400">Icon</Text>
-        {iconUploadAtom && !tagIconSrc ? (
-          <CompactUploadCardRenderer
-            uploadAtom={iconUploadAtom}
-            onRemove={handleRemoveIconUpload}
-            onComplete={handleIconUploaded}
-          />
-        ) : (
-          <Box gap="200" alignItems="Center">
-            {tagIconSrc ? (
-              <>
-                <PowerIcon size="500" iconSrc={tagIconSrc} />
-                <Button
-                  onClick={() => setTagIcon(undefined)}
-                  type="button"
-                  size="300"
-                  variant="Critical"
-                  fill="None"
-                  radii="300"
-                >
-                  <Text size="B300">Remove</Text>
-                </Button>
-              </>
-            ) : (
-              <>
-                <UseStateProvider initial={undefined}>
-                  {(cords: RectCords | undefined, setCords) => (
-                    <PopOut
-                      position="Bottom"
-                      anchor={cords}
-                      content={
-                        <EmojiBoard
-                          imagePackRooms={imagePackRooms}
-                          returnFocusOnDeactivate={false}
-                          allowTextCustomEmoji={false}
-                          addToRecentEmoji={false}
-                          onEmojiSelect={(key) => {
-                            setTagIcon({ key });
-                            setCords(undefined);
-                          }}
-                          onCustomEmojiSelect={(mxc) => {
-                            setTagIcon({ key: mxc });
-                            setCords(undefined);
-                          }}
-                          requestClose={() => {
-                            setCords(undefined);
-                          }}
-                        />
-                      }
-                    >
-                      <Button
-                        onClick={
-                          ((evt) =>
-                            setCords(
-                              evt.currentTarget.getBoundingClientRect()
-                            )) as MouseEventHandler<HTMLButtonElement>
-                        }
-                        type="button"
-                        size="300"
-                        variant="Secondary"
-                        fill="Soft"
-                        radii="300"
-                        before={<Icon size="50" src={Icons.SmilePlus} />}
-                      >
-                        <Text size="B300">Pick</Text>
-                      </Button>
-                    </PopOut>
-                  )}
-                </UseStateProvider>
-                <Button
-                  onClick={() => pickFile('image/*')}
-                  type="button"
-                  size="300"
-                  variant="Secondary"
-                  fill="None"
-                  radii="300"
-                >
-                  <Text size="B300">Import</Text>
-                </Button>
-              </>
-            )}
-          </Box>
-        )}
-      </Box>
-      <Box direction="Row" gap="200" justifyContent="Start">
-        <Button
-          style={{ minWidth: toRem(64) }}
-          type="submit"
-          size="300"
-          variant="Success"
-          radii="300"
-          disabled={uploadingIcon}
-        >
-          <Text size="B300">Save</Text>
-        </Button>
-        <Button
-          type="button"
-          size="300"
-          variant="Secondary"
-          fill="Soft"
-          radii="300"
-          onClick={onClose}
-        >
-          <Text size="B300">Cancel</Text>
-        </Button>
-      </Box>
-    </Box>
-  );
-}
-
-type PowersEditorProps = {
-  powerLevels: IPowerLevels;
-  requestClose: () => void;
-};
-export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
-  const mx = useMatrixClient();
-  const useAuthentication = useMediaAuthentication();
-  const room = useRoom();
-  const alive = useAlive();
-  const [usedPowers, maxPower] = useMemo(() => {
-    const up = getUsedPowers(powerLevels);
-    return [up, Math.max(...Array.from(up))];
-  }, [powerLevels]);
-
-  const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
-  const [editedPowerTags, setEditedPowerTags] = useState<PowerLevelTags>();
-  const [deleted, setDeleted] = useState<Set<number>>(new Set());
-
-  const [createTag, setCreateTag] = useState(false);
-
-  const handleToggleDelete = useCallback((power: number) => {
-    setDeleted((powers) => {
-      const newIds = new Set(powers);
-      if (newIds.has(power)) {
-        newIds.delete(power);
-      } else {
-        newIds.add(power);
-      }
-      return newIds;
-    });
-  }, []);
-
-  const handleSaveTag = useCallback(
-    (power: number, tag: PowerLevelTag) => {
-      setEditedPowerTags((tags) => {
-        const editedTags = { ...(tags ?? powerLevelTags) };
-        editedTags[power] = tag;
-        return editedTags;
-      });
-    },
-    [powerLevelTags]
-  );
-
-  const [applyState, applyChanges] = useAsyncCallback(
-    useCallback(async () => {
-      const content: PowerLevelTags = { ...(editedPowerTags ?? powerLevelTags) };
-      deleted.forEach((power) => {
-        delete content[power];
-      });
-      await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
-    }, [mx, room, powerLevelTags, editedPowerTags, deleted])
-  );
-
-  const resetChanges = useCallback(() => {
-    setEditedPowerTags(undefined);
-    setDeleted(new Set());
-  }, []);
-
-  const handleApplyChanges = () => {
-    applyChanges().then(() => {
-      if (alive()) {
-        resetChanges();
-      }
-    });
-  };
-
-  const applyingChanges = applyState.status === AsyncStatus.Loading;
-  const hasChanges = editedPowerTags || deleted.size > 0;
-
-  const powerTags = editedPowerTags ?? powerLevelTags;
-  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">Permissions</Text>
-            </Chip>
-          </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">
-                <Box alignItems="Baseline" gap="200" justifyContent="SpaceBetween">
-                  <Text size="L400">Power Levels</Text>
-                  <BetaNoticeBadge />
-                </Box>
-                <SequenceCard
-                  variant="SurfaceVariant"
-                  className={SequenceCardStyle}
-                  direction="Column"
-                  gap="400"
-                >
-                  <SettingTile
-                    title="New Power Level"
-                    description="Create a new power level."
-                    after={
-                      !createTag && (
-                        <Button
-                          onClick={() => setCreateTag(true)}
-                          variant="Secondary"
-                          fill="Soft"
-                          size="300"
-                          radii="300"
-                          outlined
-                          disabled={applyingChanges}
-                        >
-                          <Text size="B300">Create</Text>
-                        </Button>
-                      )
-                    }
-                  />
-                  {createTag && (
-                    <EditPower
-                      maxPower={maxPower}
-                      onSave={handleSaveTag}
-                      onClose={() => setCreateTag(false)}
-                    />
-                  )}
-                </SequenceCard>
-                {getPowers(powerTags).map((power) => {
-                  const tag = powerTags[power];
-                  const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
-
-                  return (
-                    <SequenceCard
-                      key={power}
-                      variant={deleted.has(power) ? 'Critical' : 'SurfaceVariant'}
-                      className={SequenceCardStyle}
-                      direction="Column"
-                      gap="400"
-                    >
-                      <UseStateProvider initial={false}>
-                        {(edit, setEdit) =>
-                          edit ? (
-                            <EditPower
-                              maxPower={maxPower}
-                              power={power}
-                              tag={tag}
-                              onSave={handleSaveTag}
-                              onClose={() => setEdit(false)}
-                            />
-                          ) : (
-                            <SettingTile
-                              before={<PowerColorBadge color={tag.color} />}
-                              title={
-                                <Box as="span" alignItems="Center" gap="200">
-                                  <b>{deleted.has(power) ? <s>{tag.name}</s> : tag.name}</b>
-                                  <Box as="span" shrink="No" alignItems="Inherit" gap="Inherit">
-                                    {tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
-                                    <Text as="span" size="T200" priority="300">
-                                      ({power})
-                                    </Text>
-                                  </Box>
-                                </Box>
-                              }
-                              after={
-                                deleted.has(power) ? (
-                                  <Chip
-                                    variant="Critical"
-                                    radii="Pill"
-                                    disabled={applyingChanges}
-                                    onClick={() => handleToggleDelete(power)}
-                                  >
-                                    <Text size="B300">Undo</Text>
-                                  </Chip>
-                                ) : (
-                                  <Box shrink="No" alignItems="Center" gap="200">
-                                    <TooltipProvider
-                                      tooltip={
-                                        <Tooltip style={{ maxWidth: toRem(200) }}>
-                                          {usedPowers.has(power) ? (
-                                            <Box direction="Column">
-                                              <Text size="L400">Used Power Level</Text>
-                                              <Text size="T200">
-                                                You have to remove its use before you can delete it.
-                                              </Text>
-                                            </Box>
-                                          ) : (
-                                            <Text>Delete</Text>
-                                          )}
-                                        </Tooltip>
-                                      }
-                                    >
-                                      {(triggerRef) => (
-                                        <Chip
-                                          ref={triggerRef}
-                                          variant="Secondary"
-                                          fill="None"
-                                          radii="Pill"
-                                          disabled={applyingChanges}
-                                          aria-disabled={usedPowers.has(power)}
-                                          onClick={
-                                            usedPowers.has(power)
-                                              ? undefined
-                                              : () => handleToggleDelete(power)
-                                          }
-                                        >
-                                          <Icon size="50" src={Icons.Delete} />
-                                        </Chip>
-                                      )}
-                                    </TooltipProvider>
-                                    <Chip
-                                      variant="Secondary"
-                                      radii="Pill"
-                                      disabled={applyingChanges}
-                                      onClick={() => setEdit(true)}
-                                    >
-                                      <Text size="B300">Edit</Text>
-                                    </Chip>
-                                  </Box>
-                                )
-                              }
-                            />
-                          )
-                        }
-                      </UseStateProvider>
-                    </SequenceCard>
-                  );
-                })}
-              </Box>
-              {hasChanges && (
-                <Menu
-                  style={{
-                    position: 'sticky',
-                    padding: config.space.S200,
-                    paddingLeft: config.space.S400,
-                    bottom: config.space.S400,
-                    left: config.space.S400,
-                    right: 0,
-                    zIndex: 1,
-                  }}
-                  variant="Success"
-                >
-                  <Box alignItems="Center" gap="400">
-                    <Box grow="Yes" direction="Column">
-                      {applyState.status === AsyncStatus.Error ? (
-                        <Text size="T200">
-                          <b>Failed to apply changes! Please try again.</b>
-                        </Text>
-                      ) : (
-                        <Text size="T200">
-                          <b>Changes saved! Apply when ready.</b>
-                        </Text>
-                      )}
-                    </Box>
-                    <Box shrink="No" gap="200">
-                      <Button
-                        size="300"
-                        variant="Success"
-                        fill="None"
-                        radii="300"
-                        disabled={applyingChanges}
-                        onClick={resetChanges}
-                      >
-                        <Text size="B300">Reset</Text>
-                      </Button>
-                      <Button
-                        size="300"
-                        variant="Success"
-                        radii="300"
-                        disabled={applyingChanges}
-                        before={
-                          applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />
-                        }
-                        onClick={handleApplyChanges}
-                      >
-                        <Text size="B300">Apply Changes</Text>
-                      </Button>
-                    </Box>
-                  </Box>
-                </Menu>
-              )}
-            </Box>
-          </PageContent>
-        </Scroll>
-      </Box>
-    </Page>
-  );
-}
index f8df64530bae39d88c6530d60a7739f0d3d2c4c7..513f82b455aecebb06367f95e5ce645a553f89a3 100644 (file)
@@ -1,17 +1,6 @@
 import { useMemo } from 'react';
-import { PermissionLocation } from '../../../hooks/usePowerLevels';
 import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
-
-export type PermissionItem = {
-  location: PermissionLocation;
-  name: string;
-  description?: string;
-};
-
-export type PermissionGroup = {
-  name: string;
-  items: PermissionItem[];
-};
+import { PermissionGroup } from '../../common-settings/permissions';
 
 export const usePermissionGroups = (): PermissionGroup[] => {
   const groups: PermissionGroup[] = useMemo(() => {
diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx
new file mode 100644 (file)
index 0000000..e565fb9
--- /dev/null
@@ -0,0 +1,173 @@
+import React, { useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
+import { JoinRule } from 'matrix-js-sdk';
+import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { mxcUrlToHttp } from '../../utils/matrix';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { SpaceSettingsPage } from '../../state/spaceSettings';
+import { useRoom } from '../../hooks/useRoom';
+import { EmojisStickers } from '../common-settings/emojis-stickers';
+import { Members } from '../common-settings/members';
+import { DeveloperTools } from '../common-settings/developer-tools';
+import { General } from './general';
+import { Permissions } from './permissions';
+
+type SpaceSettingsMenuItem = {
+  page: SpaceSettingsPage;
+  name: string;
+  icon: IconSrc;
+};
+
+const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] =>
+  useMemo(
+    () => [
+      {
+        page: SpaceSettingsPage.GeneralPage,
+        name: 'General',
+        icon: Icons.Setting,
+      },
+      {
+        page: SpaceSettingsPage.MembersPage,
+        name: 'Members',
+        icon: Icons.User,
+      },
+      {
+        page: SpaceSettingsPage.PermissionsPage,
+        name: 'Permissions',
+        icon: Icons.Lock,
+      },
+      {
+        page: SpaceSettingsPage.EmojisStickersPage,
+        name: 'Emojis & Stickers',
+        icon: Icons.Smile,
+      },
+      {
+        page: SpaceSettingsPage.DeveloperToolsPage,
+        name: 'Developer Tools',
+        icon: Icons.Terminal,
+      },
+    ],
+    []
+  );
+
+type SpaceSettingsProps = {
+  initialPage?: SpaceSettingsPage;
+  requestClose: () => void;
+};
+export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {
+  const room = useRoom();
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const mDirects = useAtomValue(mDirectAtom);
+
+  const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
+  const roomName = useRoomName(room);
+  const joinRuleContent = useRoomJoinRule(room);
+
+  const avatarUrl = roomAvatar
+    ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
+    : undefined;
+
+  const screenSize = useScreenSizeContext();
+  const [activePage, setActivePage] = useState<SpaceSettingsPage | undefined>(() => {
+    if (initialPage) return initialPage;
+    return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
+  });
+  const menuItems = useSpaceSettingsMenuItems();
+
+  const handlePageRequestClose = () => {
+    if (screenSize === ScreenSize.Mobile) {
+      setActivePage(undefined);
+      return;
+    }
+    requestClose();
+  };
+
+  return (
+    <PageRoot
+      nav={
+        screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
+          <PageNav size="300">
+            <PageNavHeader outlined={false}>
+              <Box grow="Yes" gap="200">
+                <Avatar size="200" radii="300">
+                  <RoomAvatar
+                    roomId={room.roomId}
+                    src={avatarUrl}
+                    alt={roomName}
+                    renderFallback={() => (
+                      <RoomIcon
+                        space
+                        size="50"
+                        joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
+                        filled
+                      />
+                    )}
+                  />
+                </Avatar>
+                <Text size="H4" truncate>
+                  {roomName}
+                </Text>
+              </Box>
+              <Box shrink="No">
+                {screenSize === ScreenSize.Mobile && (
+                  <IconButton onClick={requestClose} variant="Background">
+                    <Icon src={Icons.Cross} />
+                  </IconButton>
+                )}
+              </Box>
+            </PageNavHeader>
+            <Box grow="Yes" direction="Column">
+              <PageNavContent>
+                <div style={{ flexGrow: 1 }}>
+                  {menuItems.map((item) => (
+                    <MenuItem
+                      key={item.name}
+                      variant="Background"
+                      radii="400"
+                      aria-pressed={activePage === item.page}
+                      before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
+                      onClick={() => setActivePage(item.page)}
+                    >
+                      <Text
+                        style={{
+                          fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
+                        }}
+                        size="T300"
+                        truncate
+                      >
+                        {item.name}
+                      </Text>
+                    </MenuItem>
+                  ))}
+                </div>
+              </PageNavContent>
+            </Box>
+          </PageNav>
+        )
+      }
+    >
+      {activePage === SpaceSettingsPage.GeneralPage && (
+        <General requestClose={handlePageRequestClose} />
+      )}
+      {activePage === SpaceSettingsPage.MembersPage && (
+        <Members requestClose={handlePageRequestClose} />
+      )}
+      {activePage === SpaceSettingsPage.PermissionsPage && (
+        <Permissions requestClose={handlePageRequestClose} />
+      )}
+      {activePage === SpaceSettingsPage.EmojisStickersPage && (
+        <EmojisStickers requestClose={handlePageRequestClose} />
+      )}
+      {activePage === SpaceSettingsPage.DeveloperToolsPage && (
+        <DeveloperTools requestClose={handlePageRequestClose} />
+      )}
+    </PageRoot>
+  );
+}
diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx
new file mode 100644 (file)
index 0000000..085c5e2
--- /dev/null
@@ -0,0 +1,39 @@
+import React from 'react';
+import { SpaceSettings } from './SpaceSettings';
+import { Modal500 } from '../../components/Modal500';
+import { useCloseSpaceSettings, useSpaceSettingsState } from '../../state/hooks/spaceSettings';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { SpaceSettingsState } from '../../state/spaceSettings';
+import { RoomProvider } from '../../hooks/useRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+
+type RenderSettingsProps = {
+  state: SpaceSettingsState;
+};
+function RenderSettings({ state }: RenderSettingsProps) {
+  const { roomId, spaceId, page } = state;
+  const closeSettings = useCloseSpaceSettings();
+  const allJoinedRooms = useAllJoinedRoomsSet();
+  const getRoom = useGetRoom(allJoinedRooms);
+  const room = getRoom(roomId);
+  const space = spaceId ? getRoom(spaceId) : undefined;
+
+  if (!room) return null;
+
+  return (
+    <Modal500 requestClose={closeSettings}>
+      <SpaceProvider value={space ?? null}>
+        <RoomProvider value={room}>
+          <SpaceSettings initialPage={page} requestClose={closeSettings} />
+        </RoomProvider>
+      </SpaceProvider>
+    </Modal500>
+  );
+}
+
+export function SpaceSettingsRenderer() {
+  const state = useSpaceSettingsState();
+
+  if (!state) return null;
+  return <RenderSettings state={state} />;
+}
diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx
new file mode 100644 (file)
index 0000000..6f4d8d3
--- /dev/null
@@ -0,0 +1,63 @@
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { usePowerLevels } from '../../../hooks/usePowerLevels';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+  RoomProfile,
+  RoomJoinRules,
+  RoomLocalAddresses,
+  RoomPublishedAddresses,
+  RoomPublish,
+  RoomUpgrade,
+} from '../../common-settings/general';
+
+type GeneralProps = {
+  requestClose: () => void;
+};
+export function General({ requestClose }: GeneralProps) {
+  const room = useRoom();
+  const powerLevels = usePowerLevels(room);
+
+  return (
+    <Page>
+      <PageHeader outlined={false}>
+        <Box grow="Yes" gap="200">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <Text size="H3" truncate>
+              General
+            </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">
+              <RoomProfile powerLevels={powerLevels} />
+              <Box direction="Column" gap="100">
+                <Text size="L400">Options</Text>
+                <RoomJoinRules powerLevels={powerLevels} />
+                <RoomPublish powerLevels={powerLevels} />
+              </Box>
+              <Box direction="Column" gap="100">
+                <Text size="L400">Addresses</Text>
+                <RoomPublishedAddresses powerLevels={powerLevels} />
+                <RoomLocalAddresses powerLevels={powerLevels} />
+              </Box>
+              <Box direction="Column" gap="100">
+                <Text size="L400">Advance Options</Text>
+                <RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
+              </Box>
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/space-settings/general/index.ts b/src/app/features/space-settings/general/index.ts
new file mode 100644 (file)
index 0000000..0ab02c5
--- /dev/null
@@ -0,0 +1 @@
+export * from './General';
diff --git a/src/app/features/space-settings/index.ts b/src/app/features/space-settings/index.ts
new file mode 100644 (file)
index 0000000..e01a1b2
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './SpaceSettings';
+export * from './SpaceSettingsRenderer';
diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx
new file mode 100644 (file)
index 0000000..ae3769b
--- /dev/null
@@ -0,0 +1,67 @@
+import React, { useState } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { useRoom } from '../../../hooks/useRoom';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { usePermissionGroups } from './usePermissionItems';
+import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
+
+type PermissionsProps = {
+  requestClose: () => void;
+};
+export function Permissions({ requestClose }: PermissionsProps) {
+  const mx = useMatrixClient();
+  const room = useRoom();
+  const powerLevels = usePowerLevels(room);
+  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+  const canEditPowers = canSendStateEvent(
+    StateEvent.PowerLevelTags,
+    getPowerLevel(mx.getSafeUserId())
+  );
+  const permissionGroups = usePermissionGroups();
+
+  const [powerEditor, setPowerEditor] = useState(false);
+
+  const handleEditPowers = () => {
+    setPowerEditor(true);
+  };
+
+  if (canEditPowers && powerEditor) {
+    return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
+  }
+
+  return (
+    <Page>
+      <PageHeader outlined={false}>
+        <Box grow="Yes" gap="200">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <Text size="H3" truncate>
+              Permissions
+            </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">
+              <Powers
+                powerLevels={powerLevels}
+                onEdit={canEditPowers ? handleEditPowers : undefined}
+                permissionGroups={permissionGroups}
+              />
+              <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/space-settings/permissions/index.ts b/src/app/features/space-settings/permissions/index.ts
new file mode 100644 (file)
index 0000000..753f2b4
--- /dev/null
@@ -0,0 +1 @@
+export * from './Permissions';
diff --git a/src/app/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts
new file mode 100644 (file)
index 0000000..8192e7b
--- /dev/null
@@ -0,0 +1,148 @@
+import { useMemo } from 'react';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PermissionGroup } from '../../common-settings/permissions';
+
+export const usePermissionGroups = (): PermissionGroup[] => {
+  const groups: PermissionGroup[] = useMemo(() => {
+    const messagesGroup: PermissionGroup = {
+      name: 'Manage',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.SpaceChild,
+          },
+          name: 'Manage space rooms',
+        },
+        {
+          location: {},
+          name: 'Message Events',
+        },
+      ],
+    };
+
+    const moderationGroup: PermissionGroup = {
+      name: 'Moderation',
+      items: [
+        {
+          location: {
+            action: true,
+            key: 'invite',
+          },
+          name: 'Invite',
+        },
+        {
+          location: {
+            action: true,
+            key: 'kick',
+          },
+          name: 'Kick',
+        },
+        {
+          location: {
+            action: true,
+            key: 'ban',
+          },
+          name: 'Ban',
+        },
+      ],
+    };
+
+    const roomOverviewGroup: PermissionGroup = {
+      name: 'Space Overview',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomAvatar,
+          },
+          name: 'Space Avatar',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomName,
+          },
+          name: 'Space Name',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomTopic,
+          },
+          name: 'Space Topic',
+        },
+      ],
+    };
+
+    const roomSettingsGroup: PermissionGroup = {
+      name: 'Settings',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomJoinRules,
+          },
+          name: 'Change Space Access',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomCanonicalAlias,
+          },
+          name: 'Publish Address',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomPowerLevels,
+          },
+          name: 'Change All Permission',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.PowerLevelTags,
+          },
+          name: 'Edit Power Levels',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomTombstone,
+          },
+          name: 'Upgrade Space',
+        },
+        {
+          location: {
+            state: true,
+          },
+          name: 'Other Settings',
+        },
+      ],
+    };
+
+    const otherSettingsGroup: PermissionGroup = {
+      name: 'Other',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomServerAcl,
+          },
+          name: 'Change Server ACLs',
+        },
+      ],
+    };
+
+    return [
+      messagesGroup,
+      moderationGroup,
+      roomOverviewGroup,
+      roomSettingsGroup,
+      otherSettingsGroup,
+    ];
+  }, []);
+
+  return groups;
+};
diff --git a/src/app/features/space-settings/styles.css.ts b/src/app/features/space-settings/styles.css.ts
new file mode 100644 (file)
index 0000000..ce89c16
--- /dev/null
@@ -0,0 +1,6 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const SequenceCardStyle = style({
+  padding: config.space.S300,
+});
index 3c5f40c39a42774212ef6b3c21773f79a630941b..89743693c579bc33b7b0c10d31189ad929bd7489 100644 (file)
@@ -60,6 +60,7 @@ import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification'
 import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
 import { RoomSettingsRenderer } from '../features/room-settings';
 import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
+import { SpaceSettingsRenderer } from '../features/space-settings';
 
 export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
   const { hashRouter } = clientConfig;
@@ -125,6 +126,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
                         <Outlet />
                       </ClientLayout>
                       <RoomSettingsRenderer />
+                      <SpaceSettingsRenderer />
                       <ReceiveSelfDeviceVerification />
                       <AutoRestoreBackupOnVerification />
                     </ClientNonUIFeatures>
index 96e3b9adbcafad67d118c091992de33066b7baa1..5b47cb52ceca4b2448e6ff3b125fbf13f0e829d3 100644 (file)
@@ -82,7 +82,7 @@ import { useRoomsUnread } from '../../../state/hooks/unread';
 import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
 import { markAsRead } from '../../../../client/action/notifications';
 import { copyToClipboard } from '../../../utils/dom';
-import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { openInviteUser } from '../../../../client/action/navigation';
 import { stopPropagation } from '../../../utils/keyboard';
 import { getMatrixToRoom } from '../../../plugins/matrix-to';
 import { getViaServers } from '../../../plugins/via-servers';
@@ -90,6 +90,7 @@ import { getRoomAvatarUrl } from '../../../utils/room';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 import { useSetting } from '../../../state/hooks/settings';
 import { settingsAtom } from '../../../state/settings';
+import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
 
 type SpaceMenuProps = {
   room: Room;
@@ -104,6 +105,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
     const powerLevels = usePowerLevels(room);
     const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
     const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+    const openSpaceSettings = useOpenSpaceSettings();
 
     const allChild = useSpaceChildren(
       allRoomsAtom,
index 737663ad0679f893bf279a96a453cbffad4d6bb0..fa8c0ea8a0ced54ec66282153c2568d0c6ca1deb 100644 (file)
@@ -54,7 +54,7 @@ import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
 import { allRoomsAtom } from '../../../state/room-list/roomList';
 import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
 import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { openInviteUser } from '../../../../client/action/navigation';
 import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
 import { roomToParentsAtom } from '../../../state/room/roomToParents';
 import { markAsRead } from '../../../../client/action/notifications';
@@ -74,6 +74,7 @@ import {
   getRoomNotificationMode,
   useRoomsNotificationPreferencesContext,
 } from '../../../hooks/useRoomsNotificationPreferences';
+import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
 
 type SpaceMenuProps = {
   room: Room;
@@ -86,6 +87,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
   const powerLevels = usePowerLevels(room);
   const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
   const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+  const openSpaceSettings = useOpenSpaceSettings();
 
   const allChild = useSpaceChildren(
     allRoomsAtom,
diff --git a/src/app/state/hooks/spaceSettings.ts b/src/app/state/hooks/spaceSettings.ts
new file mode 100644 (file)
index 0000000..e589d65
--- /dev/null
@@ -0,0 +1,34 @@
+import { useCallback } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { spaceSettingsAtom, SpaceSettingsPage, SpaceSettingsState } from '../spaceSettings';
+
+export const useSpaceSettingsState = (): SpaceSettingsState | undefined => {
+  const data = useAtomValue(spaceSettingsAtom);
+
+  return data;
+};
+
+type CloseCallback = () => void;
+export const useCloseSpaceSettings = (): CloseCallback => {
+  const setSettings = useSetAtom(spaceSettingsAtom);
+
+  const close: CloseCallback = useCallback(() => {
+    setSettings(undefined);
+  }, [setSettings]);
+
+  return close;
+};
+
+type OpenCallback = (roomId: string, space?: string, page?: SpaceSettingsPage) => void;
+export const useOpenSpaceSettings = (): OpenCallback => {
+  const setSettings = useSetAtom(spaceSettingsAtom);
+
+  const open: OpenCallback = useCallback(
+    (roomId, spaceId, page) => {
+      setSettings({ roomId, spaceId, page });
+    },
+    [setSettings]
+  );
+
+  return open;
+};
diff --git a/src/app/state/spaceSettings.ts b/src/app/state/spaceSettings.ts
new file mode 100644 (file)
index 0000000..e79506b
--- /dev/null
@@ -0,0 +1,17 @@
+import { atom } from 'jotai';
+
+export enum SpaceSettingsPage {
+  GeneralPage,
+  MembersPage,
+  PermissionsPage,
+  EmojisStickersPage,
+  DeveloperToolsPage,
+}
+
+export type SpaceSettingsState = {
+  page?: SpaceSettingsPage;
+  roomId: string;
+  spaceId?: string;
+};
+
+export const spaceSettingsAtom = atom<SpaceSettingsState | undefined>(undefined);