New room settings, add customizable power levels and dev tools (#2222)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Wed, 19 Mar 2025 12:14:54 +0000 (23:14 +1100)
committerGitHub <noreply@github.com>
Wed, 19 Mar 2025 12:14:54 +0000 (23:14 +1100)
* WIP - add room settings dialog

* join rule setting - WIP

* show emojis & stickers in room settings - WIP

* restyle join rule switcher

* Merge branch 'dev' into new-room-settings

* add join rule hook

* open room settings from global state

* open new room settings from all places

* rearrange settings menu item

* add option for creating new image pack

* room devtools - WIP

* render room state events as list

* add option to open state event

* add option to edit state event

* refactor text area code editor into hook

* add option to send message and state event

* add cutout card component

* add hook for room account data

* display room account data - WIP

* refactor global account data editor component

* add account data editor in room

* fix font style in devtool

* show state events in compact form

* add option to delete room image pack

* add server badge component

* add member tile component

* render members in room settings

* add search in room settings member

* add option to reset member search

* add filter in room members

* fix member virtual item key

* remove color from serve badge in room members

* show room in settings

* fix loading indicator position

* power level tags in room setting - WIP

* generate fallback tag in backward compatible way

* add color picker

* add powers editor - WIP

* add props to stop adding emoji to recent usage

* add beta feature notice badge

* add types for power level tag icon

* refactor image pack rooms code to hook

* option for adding new power levels tags

* remove console log

* refactor power icon

* add option to edit power level tags

* remove power level from powers pill

* fix power level labels

* add option to delete power levels

* fix long power level name shrinks power integer

* room permissions - WIP

* add power level selector component

* add room permissions

* move user default permission setting to other group

* add power permission peek menu

* fix weigh of power switch text

* hide above for max power in permission switcher

* improve beta badge description

* render room profile in room settings

* add option to edit room profile

* make room topic input text area

* add option to enable room encryption in room settings

* add option to change message history visibility

* add option to change join rule

* add option for addresses in room settings

* close encryption dialog after enabling

74 files changed:
package-lock.json
package.json
src/app/components/AccountDataEditor.tsx [new file with mode: 0644]
src/app/components/BetaNoticeBadge.tsx [new file with mode: 0644]
src/app/components/HexColorPickerPopOut.tsx [new file with mode: 0644]
src/app/components/JoinRulesSwitcher.tsx [new file with mode: 0644]
src/app/components/MemberSortMenu.tsx [new file with mode: 0644]
src/app/components/MembershipFilterMenu.tsx [new file with mode: 0644]
src/app/components/cutout-card/CutoutCard.css.ts [new file with mode: 0644]
src/app/components/cutout-card/CutoutCard.tsx [new file with mode: 0644]
src/app/components/cutout-card/index.ts [new file with mode: 0644]
src/app/components/emoji-board/EmojiBoard.tsx
src/app/components/member-tile/MemberTile.tsx [new file with mode: 0644]
src/app/components/member-tile/index.ts [new file with mode: 0644]
src/app/components/member-tile/style.css.ts [new file with mode: 0644]
src/app/components/power/PowerColorBadge.tsx [new file with mode: 0644]
src/app/components/power/PowerIcon.tsx [new file with mode: 0644]
src/app/components/power/PowerSelector.tsx [new file with mode: 0644]
src/app/components/power/index.ts [new file with mode: 0644]
src/app/components/power/style.css.ts [new file with mode: 0644]
src/app/components/server-badge/ServerBadge.tsx [new file with mode: 0644]
src/app/components/server-badge/index.ts [new file with mode: 0644]
src/app/features/lobby/HierarchyItemMenu.tsx
src/app/features/room-nav/RoomNavItem.tsx
src/app/features/room-settings/RoomSettings.tsx [new file with mode: 0644]
src/app/features/room-settings/RoomSettingsRenderer.tsx [new file with mode: 0644]
src/app/features/room-settings/developer-tools/DevelopTools.tsx [new file with mode: 0644]
src/app/features/room-settings/developer-tools/SendRoomEvent.tsx [new file with mode: 0644]
src/app/features/room-settings/developer-tools/StateEventEditor.tsx [new file with mode: 0644]
src/app/features/room-settings/developer-tools/index.ts [new file with mode: 0644]
src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx [new file with mode: 0644]
src/app/features/room-settings/emojis-stickers/RoomPacks.tsx [new file with mode: 0644]
src/app/features/room-settings/emojis-stickers/index.ts [new file with mode: 0644]
src/app/features/room-settings/general/General.tsx [new file with mode: 0644]
src/app/features/room-settings/general/RoomAddress.tsx [new file with mode: 0644]
src/app/features/room-settings/general/RoomEncryption.tsx [new file with mode: 0644]
src/app/features/room-settings/general/RoomHistoryVisibility.tsx [new file with mode: 0644]
src/app/features/room-settings/general/RoomJoinRules.tsx [new file with mode: 0644]
src/app/features/room-settings/general/RoomProfile.tsx [new file with mode: 0644]
src/app/features/room-settings/general/index.ts [new file with mode: 0644]
src/app/features/room-settings/index.ts [new file with mode: 0644]
src/app/features/room-settings/members/Members.tsx [new file with mode: 0644]
src/app/features/room-settings/members/index.ts [new file with mode: 0644]
src/app/features/room-settings/permissions/PermissionGroups.tsx [new file with mode: 0644]
src/app/features/room-settings/permissions/Permissions.tsx [new file with mode: 0644]
src/app/features/room-settings/permissions/Powers.tsx [new file with mode: 0644]
src/app/features/room-settings/permissions/PowersEditor.tsx [new file with mode: 0644]
src/app/features/room-settings/permissions/index.ts [new file with mode: 0644]
src/app/features/room-settings/permissions/usePermissionItems.ts [new file with mode: 0644]
src/app/features/room-settings/styles.css.ts [new file with mode: 0644]
src/app/features/room/MembersDrawer.tsx
src/app/features/room/RoomInput.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomViewHeader.tsx
src/app/features/settings/developer-tools/AccountData.tsx
src/app/features/settings/developer-tools/AccountDataEditor.tsx [deleted file]
src/app/features/settings/developer-tools/DevelopTools.tsx
src/app/features/settings/developer-tools/styles.css.ts [deleted file]
src/app/hooks/useGetRoom.ts [new file with mode: 0644]
src/app/hooks/useImagePackRooms.ts [new file with mode: 0644]
src/app/hooks/useMemberFilter.ts [new file with mode: 0644]
src/app/hooks/useMemberSort.ts [new file with mode: 0644]
src/app/hooks/usePowerLevelTags.ts
src/app/hooks/usePowerLevels.ts
src/app/hooks/useRoomAccountData.ts [new file with mode: 0644]
src/app/hooks/useRoomAliases.ts [new file with mode: 0644]
src/app/hooks/useRoomMeta.ts
src/app/hooks/useRoomState.ts [new file with mode: 0644]
src/app/hooks/useTextAreaCodeEditor.ts [new file with mode: 0644]
src/app/pages/Router.tsx
src/app/state/hooks/roomSettings.ts [new file with mode: 0644]
src/app/state/roomSettings.ts [new file with mode: 0644]
src/app/utils/matrix.ts
src/types/matrix/room.ts

index b173bc70e4e445e91dba257fc0841b278a9f63c3..3971adb6f1f2193b4672aa1ff5249273034d341b 100644 (file)
@@ -54,6 +54,7 @@
         "react-aria": "3.29.1",
         "react-autosize-textarea": "7.1.0",
         "react-blurhash": "0.2.0",
+        "react-colorful": "5.6.1",
         "react-dom": "18.2.0",
         "react-error-boundary": "4.0.13",
         "react-google-recaptcha": "2.1.0",
         "react": ">=15"
       }
     },
+    "node_modules/react-colorful": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+      "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
     "node_modules/react-dom": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
index aea713edfacf4346e5aa43fc17f1f59c0c094f5f..074f4dd90db07d7a959910c75b75cbeb110e08cc 100644 (file)
@@ -65,6 +65,7 @@
     "react-aria": "3.29.1",
     "react-autosize-textarea": "7.1.0",
     "react-blurhash": "0.2.0",
+    "react-colorful": "5.6.1",
     "react-dom": "18.2.0",
     "react-error-boundary": "4.0.13",
     "react-google-recaptcha": "2.1.0",
diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx
new file mode 100644 (file)
index 0000000..2dbaf1f
--- /dev/null
@@ -0,0 +1,322 @@
+import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+  Box,
+  Text,
+  Icon,
+  Icons,
+  IconButton,
+  Input,
+  Button,
+  TextArea as TextAreaComponent,
+  color,
+  Spinner,
+  Chip,
+  Scroll,
+  config,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { Cursor } from '../plugins/text-area';
+import { syntaxErrorPosition } from '../utils/dom';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { Page, PageHeader } from './page';
+import { useAlive } from '../hooks/useAlive';
+import { SequenceCard } from './sequence-card';
+import { TextViewerContent } from './text-viewer';
+import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
+
+type AccountDataInfo = {
+  type: string;
+  content: object;
+};
+
+type AccountDataEditProps = {
+  type: string;
+  defaultContent: string;
+  submitChange: AccountDataSubmitCallback;
+  onCancel: () => void;
+  onSave: (info: AccountDataInfo) => void;
+};
+function AccountDataEdit({
+  type,
+  defaultContent,
+  submitChange,
+  onCancel,
+  onSave,
+}: AccountDataEditProps) {
+  const alive = useAlive();
+
+  const textAreaRef = useRef<HTMLTextAreaElement>(null);
+  const [jsonError, setJSONError] = useState<SyntaxError>();
+
+  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+    textAreaRef,
+    EDITOR_INTENT_SPACE_COUNT
+  );
+
+  const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
+  const submitting = submitState.status === AsyncStatus.Loading;
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    if (submitting) return;
+
+    const target = evt.target as HTMLFormElement | undefined;
+    const typeInput = target?.typeInput as HTMLInputElement | undefined;
+    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+    if (!typeInput || !contentTextArea) return;
+
+    const typeStr = typeInput.value.trim();
+    const contentStr = contentTextArea.value.trim();
+
+    let parsedContent: object;
+    try {
+      parsedContent = JSON.parse(contentStr);
+    } catch (e) {
+      setJSONError(e as SyntaxError);
+      return;
+    }
+    setJSONError(undefined);
+
+    if (
+      !typeStr ||
+      parsedContent === null ||
+      defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
+    ) {
+      return;
+    }
+
+    submit(typeStr, parsedContent).then(() => {
+      if (alive()) {
+        onSave({
+          type: typeStr,
+          content: parsedContent,
+        });
+      }
+    });
+  };
+
+  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">Account Data</Text>
+        <Box gap="300">
+          <Box grow="Yes" direction="Column">
+            <Input
+              variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
+              name="typeInput"
+              size="400"
+              radii="300"
+              readOnly={type.length > 0 || submitting}
+              defaultValue={type}
+              required
+            />
+          </Box>
+          <Button
+            variant="Success"
+            size="400"
+            radii="300"
+            type="submit"
+            disabled={submitting}
+            before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+          >
+            <Text size="B400">Save</Text>
+          </Button>
+          <Button
+            variant="Secondary"
+            fill="Soft"
+            size="400"
+            radii="300"
+            type="button"
+            onClick={onCancel}
+            disabled={submitting}
+          >
+            <Text size="B400">Cancel</Text>
+          </Button>
+        </Box>
+
+        {submitState.status === AsyncStatus.Error && (
+          <Text size="T200" style={{ color: color.Critical.Main }}>
+            <b>{submitState.error.message}</b>
+          </Text>
+        )}
+      </Box>
+      <Box grow="Yes" direction="Column" gap="100">
+        <Box shrink="No">
+          <Text size="L400">JSON Content</Text>
+        </Box>
+        <TextAreaComponent
+          ref={textAreaRef}
+          name="contentTextArea"
+          style={{
+            fontFamily: 'monospace',
+          }}
+          onKeyDown={handleKeyDown}
+          defaultValue={defaultContent}
+          resize="None"
+          spellCheck="false"
+          required
+          readOnly={submitting}
+        />
+        {jsonError && (
+          <Text size="T200" style={{ color: color.Critical.Main }}>
+            <b>
+              {jsonError.name}: {jsonError.message}
+            </b>
+          </Text>
+        )}
+      </Box>
+    </Box>
+  );
+}
+
+type AccountDataViewProps = {
+  type: string;
+  defaultContent: string;
+  onEdit: () => void;
+};
+function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+  return (
+    <Box
+      direction="Column"
+      style={{
+        padding: config.space.S400,
+      }}
+      gap="400"
+    >
+      <Box shrink="No" gap="300" alignItems="End">
+        <Box grow="Yes" direction="Column" gap="100">
+          <Text size="L400">Account Data</Text>
+          <Input
+            variant="SurfaceVariant"
+            size="400"
+            radii="300"
+            readOnly
+            defaultValue={type}
+            required
+          />
+        </Box>
+        <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
+          <Text size="B400">Edit</Text>
+        </Button>
+      </Box>
+      <Box grow="Yes" direction="Column" gap="100">
+        <Text size="L400">JSON Content</Text>
+        <SequenceCard variant="SurfaceVariant">
+          <Scroll visibility="Always" size="300" hideTrack>
+            <TextViewerContent
+              size="T300"
+              style={{
+                padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
+              }}
+              text={defaultContent}
+              langName="JSON"
+            />
+          </Scroll>
+        </SequenceCard>
+      </Box>
+    </Box>
+  );
+}
+
+export type AccountDataEditorProps = {
+  type?: string;
+  content?: object;
+  submitChange: AccountDataSubmitCallback;
+  requestClose: () => void;
+};
+
+export function AccountDataEditor({
+  type,
+  content,
+  submitChange,
+  requestClose,
+}: AccountDataEditorProps) {
+  const [data, setData] = useState<AccountDataInfo>({
+    type: type ?? '',
+    content: content ?? {},
+  });
+
+  const [edit, setEdit] = useState(!type);
+
+  const closeEdit = useCallback(() => {
+    if (!type) {
+      requestClose();
+      return;
+    }
+    setEdit(false);
+  }, [type, requestClose]);
+
+  const handleSave = useCallback((info: AccountDataInfo) => {
+    setData(info);
+    setEdit(false);
+  }, []);
+
+  const contentJSONStr = useMemo(
+    () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
+    [data.content]
+  );
+
+  return (
+    <Page>
+      <PageHeader outlined={false} balance>
+        <Box alignItems="Center" grow="Yes" gap="200">
+          <Box alignItems="Inherit" grow="Yes" gap="200">
+            <Chip
+              size="500"
+              radii="Pill"
+              onClick={requestClose}
+              before={<Icon size="100" src={Icons.ArrowLeft} />}
+            >
+              <Text size="T300">Developer Tools</Text>
+            </Chip>
+          </Box>
+          <Box shrink="No">
+            <IconButton onClick={requestClose} variant="Surface">
+              <Icon src={Icons.Cross} />
+            </IconButton>
+          </Box>
+        </Box>
+      </PageHeader>
+      <Box grow="Yes" direction="Column">
+        {edit ? (
+          <AccountDataEdit
+            type={data.type}
+            defaultContent={contentJSONStr}
+            submitChange={submitChange}
+            onCancel={closeEdit}
+            onSave={handleSave}
+          />
+        ) : (
+          <AccountDataView
+            type={data.type}
+            defaultContent={contentJSONStr}
+            onEdit={() => setEdit(true)}
+          />
+        )}
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/components/BetaNoticeBadge.tsx b/src/app/components/BetaNoticeBadge.tsx
new file mode 100644 (file)
index 0000000..d185987
--- /dev/null
@@ -0,0 +1,25 @@
+import React from 'react';
+import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds';
+
+export function BetaNoticeBadge() {
+  return (
+    <TooltipProvider
+      position="Right"
+      align="Center"
+      tooltip={
+        <Tooltip style={{ maxWidth: toRem(200) }}>
+          <Box direction="Column">
+            <Text size="L400">Notice</Text>
+            <Text size="T200">This feature is under testing and may change over time.</Text>
+          </Box>
+        </Tooltip>
+      }
+    >
+      {(triggerRef) => (
+        <Badge size="500" tabIndex={0} ref={triggerRef} variant="Primary" fill="Solid">
+          <Text size="L400">Beta</Text>
+        </Badge>
+      )}
+    </TooltipProvider>
+  );
+}
diff --git a/src/app/components/HexColorPickerPopOut.tsx b/src/app/components/HexColorPickerPopOut.tsx
new file mode 100644 (file)
index 0000000..d8fb4bc
--- /dev/null
@@ -0,0 +1,59 @@
+import FocusTrap from 'focus-trap-react';
+import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds';
+import React, { MouseEventHandler, ReactNode, useState } from 'react';
+import { stopPropagation } from '../utils/keyboard';
+
+type HexColorPickerPopOutProps = {
+  children: (onOpen: MouseEventHandler<HTMLElement>, opened: boolean) => ReactNode;
+  picker: ReactNode;
+  onRemove?: () => void;
+};
+export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) {
+  const [cords, setCords] = useState<RectCords>();
+
+  const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
+    setCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={cords}
+      position="Bottom"
+      align="Center"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            onDeactivate: () => setCords(undefined),
+            clickOutsideDeactivates: true,
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <Menu
+            style={{
+              padding: config.space.S100,
+              borderRadius: config.radii.R500,
+              overflow: 'initial',
+            }}
+          >
+            <Box direction="Column" gap="200">
+              {picker}
+              {onRemove && (
+                <Button
+                  size="300"
+                  variant="Secondary"
+                  fill="Soft"
+                  radii="400"
+                  onClick={() => onRemove()}
+                >
+                  <Text size="B300">Remove</Text>
+                </Button>
+              )}
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      {children(handleOpen, !!cords)}
+    </PopOut>
+  );
+}
diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx
new file mode 100644 (file)
index 0000000..ddd1903
--- /dev/null
@@ -0,0 +1,138 @@
+import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+  config,
+  Box,
+  MenuItem,
+  Text,
+  Icon,
+  Icons,
+  IconSrc,
+  RectCords,
+  PopOut,
+  Menu,
+  Button,
+  Spinner,
+} from 'folds';
+import { JoinRule } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { stopPropagation } from '../utils/keyboard';
+
+type JoinRuleIcons = Record<JoinRule, IconSrc>;
+export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
+  useMemo(
+    () => ({
+      [JoinRule.Invite]: Icons.HashLock,
+      [JoinRule.Knock]: Icons.HashLock,
+      [JoinRule.Restricted]: Icons.Hash,
+      [JoinRule.Public]: Icons.HashGlobe,
+      [JoinRule.Private]: Icons.HashLock,
+    }),
+    []
+  );
+
+type JoinRuleLabels = Record<JoinRule, string>;
+export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
+  useMemo(
+    () => ({
+      [JoinRule.Invite]: 'Invite Only',
+      [JoinRule.Knock]: 'Knock & Invite',
+      [JoinRule.Restricted]: 'Space Members',
+      [JoinRule.Public]: 'Public',
+      [JoinRule.Private]: 'Invite Only',
+    }),
+    []
+  );
+
+type JoinRulesSwitcherProps<T extends JoinRule[]> = {
+  icons: JoinRuleIcons;
+  labels: JoinRuleLabels;
+  rules: T;
+  value: T[number];
+  onChange: (value: T[number]) => void;
+  disabled?: boolean;
+  changing?: boolean;
+};
+export function JoinRulesSwitcher<T extends JoinRule[]>({
+  icons,
+  labels,
+  rules,
+  value,
+  onChange,
+  disabled,
+  changing,
+}: JoinRulesSwitcherProps<T>) {
+  const [cords, setCords] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const handleChange = useCallback(
+    (selectedRule: JoinRule) => {
+      setCords(undefined);
+      onChange(selectedRule);
+    },
+    [onChange]
+  );
+
+  return (
+    <PopOut
+      anchor={cords}
+      position="Bottom"
+      align="End"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setCords(undefined),
+            clickOutsideDeactivates: true,
+            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <Menu>
+            <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+              {rules.map((rule) => (
+                <MenuItem
+                  key={rule}
+                  size="300"
+                  variant="Surface"
+                  radii="300"
+                  aria-pressed={value === rule}
+                  onClick={() => handleChange(rule)}
+                  before={<Icon size="100" src={icons[rule]} />}
+                  disabled={disabled}
+                >
+                  <Box grow="Yes">
+                    <Text size="T300">{labels[rule]}</Text>
+                  </Box>
+                </MenuItem>
+              ))}
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Button
+        size="300"
+        variant="Secondary"
+        fill="Soft"
+        radii="300"
+        outlined
+        before={<Icon size="100" src={icons[value]} />}
+        after={
+          changing ? (
+            <Spinner size="100" variant="Secondary" fill="Soft" />
+          ) : (
+            <Icon size="100" src={Icons.ChevronBottom} />
+          )
+        }
+        onClick={handleOpenMenu}
+        disabled={disabled}
+      >
+        <Text size="B300">{labels[value]}</Text>
+      </Button>
+    </PopOut>
+  );
+}
diff --git a/src/app/components/MemberSortMenu.tsx b/src/app/components/MemberSortMenu.tsx
new file mode 100644 (file)
index 0000000..d77c80c
--- /dev/null
@@ -0,0 +1,45 @@
+import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { config, Menu, MenuItem, Text } from 'folds';
+import { stopPropagation } from '../utils/keyboard';
+import { useMemberSortMenu } from '../hooks/useMemberSort';
+
+type MemberSortMenuProps = {
+  requestClose: () => void;
+  selected: number;
+  onSelect: (index: number) => void;
+};
+export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
+  const memberSortMenu = useMemberSortMenu();
+
+  return (
+    <FocusTrap
+      focusTrapOptions={{
+        initialFocus: false,
+        onDeactivate: requestClose,
+        clickOutsideDeactivates: true,
+        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+        escapeDeactivates: stopPropagation,
+      }}
+    >
+      <Menu style={{ padding: config.space.S100 }}>
+        {memberSortMenu.map((menuItem, index) => (
+          <MenuItem
+            key={menuItem.name}
+            variant="Surface"
+            aria-pressed={selected === index}
+            size="300"
+            radii="300"
+            onClick={() => {
+              onSelect(index);
+              requestClose();
+            }}
+          >
+            <Text size="T300">{menuItem.name}</Text>
+          </MenuItem>
+        ))}
+      </Menu>
+    </FocusTrap>
+  );
+}
diff --git a/src/app/components/MembershipFilterMenu.tsx b/src/app/components/MembershipFilterMenu.tsx
new file mode 100644 (file)
index 0000000..bf17677
--- /dev/null
@@ -0,0 +1,49 @@
+import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { config, Menu, MenuItem, Text } from 'folds';
+import { stopPropagation } from '../utils/keyboard';
+import { useMembershipFilterMenu } from '../hooks/useMemberFilter';
+
+type MembershipFilterMenuProps = {
+  requestClose: () => void;
+  selected: number;
+  onSelect: (index: number) => void;
+};
+export function MembershipFilterMenu({
+  selected,
+  onSelect,
+  requestClose,
+}: MembershipFilterMenuProps) {
+  const membershipFilterMenu = useMembershipFilterMenu();
+
+  return (
+    <FocusTrap
+      focusTrapOptions={{
+        initialFocus: false,
+        onDeactivate: requestClose,
+        clickOutsideDeactivates: true,
+        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+        escapeDeactivates: stopPropagation,
+      }}
+    >
+      <Menu style={{ padding: config.space.S100 }}>
+        {membershipFilterMenu.map((menuItem, index) => (
+          <MenuItem
+            key={menuItem.name}
+            variant="Surface"
+            aria-pressed={selected === index}
+            size="300"
+            radii="300"
+            onClick={() => {
+              onSelect(index);
+              requestClose();
+            }}
+          >
+            <Text size="T300">{menuItem.name}</Text>
+          </MenuItem>
+        ))}
+      </Menu>
+    </FocusTrap>
+  );
+}
diff --git a/src/app/components/cutout-card/CutoutCard.css.ts b/src/app/components/cutout-card/CutoutCard.css.ts
new file mode 100644 (file)
index 0000000..8bdf020
--- /dev/null
@@ -0,0 +1,8 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const CutoutCard = style({
+  borderRadius: config.radii.R300,
+  borderWidth: config.borderWidth.B300,
+  overflow: 'hidden',
+});
diff --git a/src/app/components/cutout-card/CutoutCard.tsx b/src/app/components/cutout-card/CutoutCard.tsx
new file mode 100644 (file)
index 0000000..bf5ddf8
--- /dev/null
@@ -0,0 +1,15 @@
+import { as, ContainerColor as TContainerColor } from 'folds';
+import React from 'react';
+import classNames from 'classnames';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as css from './CutoutCard.css';
+
+export const CutoutCard = as<'div', { variant?: TContainerColor }>(
+  ({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => (
+    <AsCutoutCard
+      className={classNames(ContainerColor({ variant }), css.CutoutCard, className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
diff --git a/src/app/components/cutout-card/index.ts b/src/app/components/cutout-card/index.ts
new file mode 100644 (file)
index 0000000..4ce2f8b
--- /dev/null
@@ -0,0 +1 @@
+export * from './CutoutCard';
index 287350818699d766ff8bba50d2180050b305512f..72a60f2b79023467b6cc2773971e7577afd8f2b8 100644 (file)
@@ -654,6 +654,7 @@ export function EmojiBoard({
   onCustomEmojiSelect,
   onStickerSelect,
   allowTextCustomEmoji,
+  addToRecentEmoji = true,
 }: {
   tab?: EmojiBoardTab;
   onTabChange?: (tab: EmojiBoardTab) => void;
@@ -664,6 +665,7 @@ export function EmojiBoard({
   onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
   onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
   allowTextCustomEmoji?: boolean;
+  addToRecentEmoji?: boolean;
 }) {
   const emojiTab = tab === EmojiBoardTab.Emoji;
   const stickerTab = tab === EmojiBoardTab.Sticker;
@@ -735,7 +737,9 @@ export function EmojiBoard({
     if (emojiInfo.type === EmojiType.Emoji) {
       onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
       if (!evt.altKey && !evt.shiftKey) {
-        addRecentEmoji(mx, emojiInfo.data);
+        if (addToRecentEmoji) {
+          addRecentEmoji(mx, emojiInfo.data);
+        }
         requestClose();
       }
     }
diff --git a/src/app/components/member-tile/MemberTile.tsx b/src/app/components/member-tile/MemberTile.tsx
new file mode 100644 (file)
index 0000000..d36d46c
--- /dev/null
@@ -0,0 +1,53 @@
+import React, { ReactNode } from 'react';
+import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { UserAvatar } from '../user-avatar';
+import * as css from './style.css';
+
+const getName = (room: Room, member: RoomMember) =>
+  getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+type MemberTileProps = {
+  mx: MatrixClient;
+  room: Room;
+  member: RoomMember;
+  useAuthentication: boolean;
+  after?: ReactNode;
+};
+export const MemberTile = as<'button', MemberTileProps>(
+  ({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
+    const name = getName(room, member);
+    const username = getMxIdLocalPart(member.userId);
+
+    const avatarMxcUrl = member.getMxcAvatarUrl();
+    const avatarUrl = avatarMxcUrl
+      ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
+      : undefined;
+
+    return (
+      <AsMemberTile className={css.MemberTile} {...props} ref={ref}>
+        <Avatar size="300" radii="400">
+          <UserAvatar
+            userId={member.userId}
+            src={avatarUrl ?? undefined}
+            alt={name}
+            renderFallback={() => <Icon size="300" src={Icons.User} filled />}
+          />
+        </Avatar>
+        <Box grow="Yes" as="span" direction="Column">
+          <Text as="span" size="T300" truncate>
+            <b>{name}</b>
+          </Text>
+          <Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
+            <Text as="span" size="T200" priority="300" truncate>
+              {username}
+            </Text>
+          </Box>
+        </Box>
+        {after}
+      </AsMemberTile>
+    );
+  }
+);
diff --git a/src/app/components/member-tile/index.ts b/src/app/components/member-tile/index.ts
new file mode 100644 (file)
index 0000000..463f621
--- /dev/null
@@ -0,0 +1 @@
+export * from './MemberTile';
diff --git a/src/app/components/member-tile/style.css.ts b/src/app/components/member-tile/style.css.ts
new file mode 100644 (file)
index 0000000..7cfe894
--- /dev/null
@@ -0,0 +1,32 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds';
+
+export const MemberTile = style([
+  DefaultReset,
+  {
+    width: '100%',
+    display: 'flex',
+    alignItems: 'center',
+    gap: config.space.S200,
+
+    padding: config.space.S100,
+    borderRadius: config.radii.R500,
+
+    selectors: {
+      'button&': {
+        cursor: 'pointer',
+      },
+      '&[aria-pressed=true]': {
+        backgroundColor: color.Surface.ContainerActive,
+      },
+      'button&:hover, &:focus-visible': {
+        backgroundColor: color.Surface.ContainerHover,
+      },
+      'button&:active': {
+        backgroundColor: color.Surface.ContainerActive,
+      },
+    },
+  },
+  FocusOutline,
+  Disabled,
+]);
diff --git a/src/app/components/power/PowerColorBadge.tsx b/src/app/components/power/PowerColorBadge.tsx
new file mode 100644 (file)
index 0000000..a1df012
--- /dev/null
@@ -0,0 +1,21 @@
+import React from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './style.css';
+
+type PowerColorBadgeProps = {
+  color?: string;
+};
+export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
+  ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
+    <AsPowerColorBadge
+      className={classNames(css.PowerColorBadge, className)}
+      style={{
+        backgroundColor: color,
+        ...style,
+      }}
+      {...props}
+      ref={ref}
+    />
+  )
+);
diff --git a/src/app/components/power/PowerIcon.tsx b/src/app/components/power/PowerIcon.tsx
new file mode 100644 (file)
index 0000000..6f6df24
--- /dev/null
@@ -0,0 +1,15 @@
+import React from 'react';
+import * as css from './style.css';
+import { JUMBO_EMOJI_REG } from '../../utils/regex';
+
+type PowerIconProps = css.PowerIconVariants & {
+  iconSrc: string;
+  name?: string;
+};
+export function PowerIcon({ size, iconSrc, name }: PowerIconProps) {
+  return JUMBO_EMOJI_REG.test(iconSrc) ? (
+    <span className={css.PowerIcon({ size })}>{iconSrc}</span>
+  ) : (
+    <img className={css.PowerIcon({ size })} src={iconSrc} alt={name} />
+  );
+}
diff --git a/src/app/components/power/PowerSelector.tsx b/src/app/components/power/PowerSelector.tsx
new file mode 100644 (file)
index 0000000..2b3b48c
--- /dev/null
@@ -0,0 +1,94 @@
+import React, { forwardRef, MouseEventHandler, ReactNode, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import { Box, config, Menu, MenuItem, PopOut, Scroll, Text, toRem, RectCords } from 'folds';
+import { getPowers, PowerLevelTags } from '../../hooks/usePowerLevelTags';
+import { PowerColorBadge } from './PowerColorBadge';
+import { stopPropagation } from '../../utils/keyboard';
+
+type PowerSelectorProps = {
+  powerLevelTags: PowerLevelTags;
+  value: number;
+  onChange: (value: number) => void;
+};
+export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
+  ({ powerLevelTags, value, onChange }, ref) => (
+    <Menu
+      ref={ref}
+      style={{
+        maxHeight: '75vh',
+        maxWidth: toRem(300),
+        display: 'flex',
+      }}
+    >
+      <Box grow="Yes">
+        <Scroll size="0" hideTrack visibility="Hover">
+          <div style={{ padding: config.space.S100 }}>
+            {getPowers(powerLevelTags).map((power) => {
+              const selected = value === power;
+              const tag = powerLevelTags[power];
+
+              return (
+                <MenuItem
+                  key={power}
+                  aria-pressed={selected}
+                  radii="300"
+                  onClick={selected ? undefined : () => onChange(power)}
+                  before={<PowerColorBadge color={tag.color} />}
+                  after={<Text size="L400">{power}</Text>}
+                >
+                  <Text style={{ flexGrow: 1 }} size="B400" truncate>
+                    {tag.name}
+                  </Text>
+                </MenuItem>
+              );
+            })}
+          </div>
+        </Scroll>
+      </Box>
+    </Menu>
+  )
+);
+
+type PowerSwitcherProps = PowerSelectorProps & {
+  children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
+};
+export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) {
+  const [menuCords, setMenuCords] = useState<RectCords>();
+
+  const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={menuCords}
+      offset={5}
+      position="Bottom"
+      align="End"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuCords(undefined),
+            clickOutsideDeactivates: true,
+            isKeyForward: (evt: KeyboardEvent) =>
+              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <PowerSelector
+            powerLevelTags={powerLevelTags}
+            value={value}
+            onChange={(v) => {
+              onChange(v);
+              setMenuCords(undefined);
+            }}
+          />
+        </FocusTrap>
+      }
+    >
+      {children(handleOpen, !!menuCords)}
+    </PopOut>
+  );
+}
diff --git a/src/app/components/power/index.ts b/src/app/components/power/index.ts
new file mode 100644 (file)
index 0000000..e288780
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './PowerColorBadge';
+export * from './PowerIcon';
+export * from './PowerSelector';
diff --git a/src/app/components/power/style.css.ts b/src/app/components/power/style.css.ts
new file mode 100644 (file)
index 0000000..bf75298
--- /dev/null
@@ -0,0 +1,73 @@
+import { createVar, style } from '@vanilla-extract/css';
+import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
+import { color, config, DefaultReset, toRem } from 'folds';
+
+export const PowerColorBadge = style({
+  display: 'inline-block',
+  flexShrink: 0,
+  width: toRem(16),
+  height: toRem(16),
+  backgroundColor: color.Surface.OnContainer,
+  borderRadius: config.radii.Pill,
+  border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+});
+
+const PowerIconSize = createVar();
+export const PowerIcon = recipe({
+  base: [
+    DefaultReset,
+    {
+      display: 'inline-flex',
+      height: PowerIconSize,
+      minWidth: PowerIconSize,
+      fontSize: PowerIconSize,
+      lineHeight: PowerIconSize,
+      borderRadius: config.radii.R300,
+      cursor: 'default',
+    },
+  ],
+  variants: {
+    size: {
+      '50': {
+        vars: {
+          [PowerIconSize]: config.size.X50,
+        },
+      },
+      '100': {
+        vars: {
+          [PowerIconSize]: config.size.X100,
+        },
+      },
+      '200': {
+        vars: {
+          [PowerIconSize]: config.size.X200,
+        },
+      },
+      '300': {
+        vars: {
+          [PowerIconSize]: config.size.X300,
+        },
+      },
+      '400': {
+        vars: {
+          [PowerIconSize]: config.size.X400,
+        },
+      },
+      '500': {
+        vars: {
+          [PowerIconSize]: config.size.X500,
+        },
+      },
+      '600': {
+        vars: {
+          [PowerIconSize]: config.size.X600,
+        },
+      },
+    },
+  },
+  defaultVariants: {
+    size: '400',
+  },
+});
+
+export type PowerIconVariants = RecipeVariants<typeof PowerIcon>;
diff --git a/src/app/components/server-badge/ServerBadge.tsx b/src/app/components/server-badge/ServerBadge.tsx
new file mode 100644 (file)
index 0000000..f61a146
--- /dev/null
@@ -0,0 +1,16 @@
+import React from 'react';
+import { as, Badge, Text } from 'folds';
+
+export const ServerBadge = as<
+  'div',
+  {
+    server: string;
+    fill?: 'Solid' | 'None';
+  }
+>(({ as: AsServerBadge = 'div', fill, server, ...props }, ref) => (
+  <Badge as={AsServerBadge} variant="Secondary" fill={fill} radii="300" {...props} ref={ref}>
+    <Text as="span" size="L400" truncate>
+      {server}
+    </Text>
+  </Badge>
+));
diff --git a/src/app/components/server-badge/index.ts b/src/app/components/server-badge/index.ts
new file mode 100644 (file)
index 0000000..eed8918
--- /dev/null
@@ -0,0 +1 @@
+export * from './ServerBadge';
index d1a7ec6bac3d4ebed15d7627a3d33e67f9427714..195d44435aa6718a303bcb0d7c9d8e4a06a5368c 100644 (file)
@@ -18,16 +18,14 @@ import {
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
-import {
-  openInviteUser,
-  openSpaceSettings,
-  toggleRoomSettings,
-} from '../../../client/action/navigation';
+import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { stopPropagation } from '../../utils/keyboard';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
 
 type HierarchyItemWithParent = HierarchyItem & {
   parentId: string;
@@ -154,11 +152,14 @@ function SettingsMenuItem({
   requestClose: () => void;
   disabled?: boolean;
 }) {
+  const openRoomSettings = useOpenRoomSettings();
+  const space = useSpaceOptionally();
+
   const handleSettings = () => {
     if ('space' in item) {
       openSpaceSettings(item.roomId);
     } else {
-      toggleRoomSettings(item.roomId);
+      openRoomSettings(item.roomId, space?.roomId);
     }
     requestClose();
   };
index ffff0f45f788932092e4b6ed4a9124fd4ae1494e..27f728359ebd25f670a290a767f41b68a4f9b205 100644 (file)
@@ -29,7 +29,7 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 import { copyToClipboard } from '../../utils/dom';
 import { markAsRead } from '../../../client/action/notifications';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
@@ -41,6 +41,8 @@ import { getViaServers } from '../../plugins/via-servers';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useSetting } from '../../state/hooks/settings';
 import { settingsAtom } from '../../state/settings';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
 
 type RoomNavItemMenuProps = {
   room: Room;
@@ -54,6 +56,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
     const powerLevels = usePowerLevels(room);
     const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
     const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+    const openRoomSettings = useOpenRoomSettings();
+    const space = useSpaceOptionally();
 
     const handleMarkAsRead = () => {
       markAsRead(mx, room.roomId, hideActivity);
@@ -73,7 +77,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
     };
 
     const handleRoomSettings = () => {
-      toggleRoomSettings(room.roomId);
+      openRoomSettings(room.roomId, space?.roomId);
       requestClose();
     };
 
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
new file mode 100644 (file)
index 0000000..42192c0
--- /dev/null
@@ -0,0 +1,172 @@
+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 { General } from './general';
+import { Members } from './members';
+import { EmojisStickers } from './emojis-stickers';
+import { Permissions } from './permissions';
+import { RoomSettingsPage } from '../../state/roomSettings';
+import { useRoom } from '../../hooks/useRoom';
+import { DeveloperTools } from './developer-tools';
+
+type RoomSettingsMenuItem = {
+  page: RoomSettingsPage;
+  name: string;
+  icon: IconSrc;
+};
+
+const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
+  useMemo(
+    () => [
+      {
+        page: RoomSettingsPage.GeneralPage,
+        name: 'General',
+        icon: Icons.Setting,
+      },
+      {
+        page: RoomSettingsPage.MembersPage,
+        name: 'Members',
+        icon: Icons.User,
+      },
+      {
+        page: RoomSettingsPage.PermissionsPage,
+        name: 'Permissions',
+        icon: Icons.Lock,
+      },
+      {
+        page: RoomSettingsPage.EmojisStickersPage,
+        name: 'Emojis & Stickers',
+        icon: Icons.Smile,
+      },
+      {
+        page: RoomSettingsPage.DeveloperToolsPage,
+        name: 'Developer Tools',
+        icon: Icons.Terminal,
+      },
+    ],
+    []
+  );
+
+type RoomSettingsProps = {
+  initialPage?: RoomSettingsPage;
+  requestClose: () => void;
+};
+export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
+  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<RoomSettingsPage | undefined>(() => {
+    if (initialPage) return initialPage;
+    return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
+  });
+  const menuItems = useRoomSettingsMenuItems();
+
+  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
+                        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 === RoomSettingsPage.GeneralPage && (
+        <General requestClose={handlePageRequestClose} />
+      )}
+      {activePage === RoomSettingsPage.MembersPage && (
+        <Members requestClose={handlePageRequestClose} />
+      )}
+      {activePage === RoomSettingsPage.PermissionsPage && (
+        <Permissions requestClose={handlePageRequestClose} />
+      )}
+      {activePage === RoomSettingsPage.EmojisStickersPage && (
+        <EmojisStickers requestClose={handlePageRequestClose} />
+      )}
+      {activePage === RoomSettingsPage.DeveloperToolsPage && (
+        <DeveloperTools requestClose={handlePageRequestClose} />
+      )}
+    </PageRoot>
+  );
+}
diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx
new file mode 100644 (file)
index 0000000..bc96777
--- /dev/null
@@ -0,0 +1,39 @@
+import React from 'react';
+import { RoomSettings } from './RoomSettings';
+import { Modal500 } from '../../components/Modal500';
+import { useCloseRoomSettings, useRoomSettingsState } from '../../state/hooks/roomSettings';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { RoomSettingsState } from '../../state/roomSettings';
+import { RoomProvider } from '../../hooks/useRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+
+type RenderSettingsProps = {
+  state: RoomSettingsState;
+};
+function RenderSettings({ state }: RenderSettingsProps) {
+  const { roomId, spaceId, page } = state;
+  const closeSettings = useCloseRoomSettings();
+  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}>
+          <RoomSettings initialPage={page} requestClose={closeSettings} />
+        </RoomProvider>
+      </SpaceProvider>
+    </Modal500>
+  );
+}
+
+export function RoomSettingsRenderer() {
+  const state = useRoomSettingsState();
+
+  if (!state) return null;
+  return <RenderSettings state={state} />;
+}
diff --git a/src/app/features/room-settings/developer-tools/DevelopTools.tsx b/src/app/features/room-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/room-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/room-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/room-settings/developer-tools/StateEventEditor.tsx b/src/app/features/room-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/room-settings/developer-tools/index.ts b/src/app/features/room-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/room-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/room-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/room-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/room-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/room-settings/emojis-stickers/index.ts b/src/app/features/room-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/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
new file mode 100644 (file)
index 0000000..6d66406
--- /dev/null
@@ -0,0 +1,57 @@
+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';
+
+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} />
+                <RoomHistoryVisibility powerLevels={powerLevels} />
+                <RoomEncryption powerLevels={powerLevels} />
+              </Box>
+              <Box direction="Column" gap="100">
+                <Text size="L400">Addresses</Text>
+                <RoomPublishedAddresses powerLevels={powerLevels} />
+                <RoomLocalAddresses powerLevels={powerLevels} />
+              </Box>
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/room-settings/general/RoomAddress.tsx b/src/app/features/room-settings/general/RoomAddress.tsx
new file mode 100644 (file)
index 0000000..dfe6645
--- /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 '../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
new file mode 100644 (file)
index 0000000..7d95fe3
--- /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 '../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
new file mode 100644 (file)
index 0000000..d36e312
--- /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 '../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
new file mode 100644 (file)
index 0000000..a98fee6
--- /dev/null
@@ -0,0 +1,124 @@
+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
new file mode 100644 (file)
index 0000000..c0d89b4
--- /dev/null
@@ -0,0 +1,351 @@
+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 | null,
+        roomTopic?: string | null
+      ) => {
+        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();
+
+    submit(
+      roomAvatar === avatar ? undefined : roomAvatar || null,
+      roomName === name ? undefined : roomName || null,
+      roomTopic === topic ? undefined : roomTopic || null
+    ).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/index.ts b/src/app/features/room-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/room-settings/index.ts b/src/app/features/room-settings/index.ts
new file mode 100644 (file)
index 0000000..d949684
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './RoomSettings';
+export * from './RoomSettingsRenderer';
diff --git a/src/app/features/room-settings/members/Members.tsx b/src/app/features/room-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/room-settings/members/index.ts b/src/app/features/room-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/room-settings/permissions/PermissionGroups.tsx b/src/app/features/room-settings/permissions/PermissionGroups.tsx
new file mode 100644 (file)
index 0000000..add2f55
--- /dev/null
@@ -0,0 +1,287 @@
+/* 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>
+      )}
+    </>
+  );
+}
diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx
new file mode 100644 (file)
index 0000000..802dd65
--- /dev/null
@@ -0,0 +1,66 @@
+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';
+
+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 [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 powerLevels={powerLevels} />
+            </Box>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/room-settings/permissions/Powers.tsx b/src/app/features/room-settings/permissions/Powers.tsx
new file mode 100644 (file)
index 0000000..24a71e7
--- /dev/null
@@ -0,0 +1,170 @@
+/* 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
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/room-settings/permissions/index.ts b/src/app/features/room-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/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts
new file mode 100644 (file)
index 0000000..f8df645
--- /dev/null
@@ -0,0 +1,218 @@
+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[];
+};
+
+export const usePermissionGroups = (): PermissionGroup[] => {
+  const groups: PermissionGroup[] = useMemo(() => {
+    const messagesGroup: PermissionGroup = {
+      name: 'Messages',
+      items: [
+        {
+          location: {
+            key: MessageEvent.RoomMessage,
+          },
+          name: 'Send Messages',
+        },
+        {
+          location: {
+            key: MessageEvent.Sticker,
+          },
+          name: 'Send Stickers',
+        },
+        {
+          location: {
+            key: MessageEvent.Reaction,
+          },
+          name: 'Send Reactions',
+        },
+        {
+          location: {
+            notification: true,
+            key: 'room',
+          },
+          name: 'Ping @room',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomPinnedEvents,
+          },
+          name: 'Pin Messages',
+        },
+        {
+          location: {},
+          name: 'Other 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',
+        },
+        {
+          location: {
+            action: true,
+            key: 'redact',
+          },
+          name: 'Delete Others Messages',
+        },
+        {
+          location: {
+            key: MessageEvent.RoomRedaction,
+          },
+          name: 'Delete Self Messages',
+        },
+      ],
+    };
+
+    const roomOverviewGroup: PermissionGroup = {
+      name: 'Room Overview',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomAvatar,
+          },
+          name: 'Room Avatar',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomName,
+          },
+          name: 'Room Name',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomTopic,
+          },
+          name: 'Room Topic',
+        },
+      ],
+    };
+
+    const roomSettingsGroup: PermissionGroup = {
+      name: 'Settings',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomJoinRules,
+          },
+          name: 'Change Room 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.RoomEncryption,
+          },
+          name: 'Enable Encryption',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomHistoryVisibility,
+          },
+          name: 'History Visibility',
+        },
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomTombstone,
+          },
+          name: 'Upgrade Room',
+        },
+        {
+          location: {
+            state: true,
+          },
+          name: 'Other Settings',
+        },
+      ],
+    };
+
+    const otherSettingsGroup: PermissionGroup = {
+      name: 'Other',
+      items: [
+        {
+          location: {
+            state: true,
+            key: StateEvent.RoomServerAcl,
+          },
+          name: 'Change Server ACLs',
+        },
+        {
+          location: {
+            state: true,
+            key: 'im.vector.modular.widgets',
+          },
+          name: 'Modify Widgets',
+        },
+      ],
+    };
+
+    return [
+      messagesGroup,
+      moderationGroup,
+      roomOverviewGroup,
+      roomSettingsGroup,
+      otherSettingsGroup,
+    ];
+  }, []);
+
+  return groups;
+};
diff --git a/src/app/features/room-settings/styles.css.ts b/src/app/features/room-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 df8008ca215c8d36d9e7ed63a4d21f23ea372fc5..5edb4f2bf4b2c2d6069caad3772ebaeca58d65e5 100644 (file)
@@ -11,13 +11,11 @@ import {
   Badge,
   Box,
   Chip,
-  ContainerColor,
   Header,
   Icon,
   IconButton,
   Icons,
   Input,
-  Menu,
   MenuItem,
   PopOut,
   RectCords,
@@ -30,13 +28,11 @@ import {
 } from 'folds';
 import { Room, RoomMember } from 'matrix-js-sdk';
 import { useVirtualizer } from '@tanstack/react-virtual';
-import FocusTrap from 'focus-trap-react';
 import classNames from 'classnames';
 
 import { openProfileViewer } from '../../../client/action/navigation';
 import * as css from './MembersDrawer.css';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { Membership } from '../../../types/matrix/room';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import {
   SearchItemStrGetter,
@@ -44,7 +40,7 @@ import {
   useAsyncSearch,
 } from '../../hooks/useAsyncSearch';
 import { useDebounce } from '../../hooks/useDebounce';
-import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { usePowerLevelTags, useFlattenPowerLevelTagMembers } from '../../hooks/usePowerLevelTags';
 import { TypingIndicator } from '../../components/typing-indicator';
 import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
 import { getMxIdLocalPart } from '../../utils/matrix';
@@ -54,106 +50,12 @@ import { millify } from '../../plugins/millify';
 import { ScrollTopContainer } from '../../components/scroll-top-container';
 import { UserAvatar } from '../../components/user-avatar';
 import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
-import { stopPropagation } from '../../utils/keyboard';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-
-export const MembershipFilters = {
-  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
-  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
-  filterLeaved: (m: RoomMember) =>
-    m.membership === Membership.Leave &&
-    m.events.member?.getStateKey() === m.events.member?.getSender(),
-  filterKicked: (m: RoomMember) =>
-    m.membership === Membership.Leave &&
-    m.events.member?.getStateKey() !== m.events.member?.getSender(),
-  filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
-};
-
-export type MembershipFilterFn = (m: RoomMember) => boolean;
-
-export type MembershipFilter = {
-  name: string;
-  filterFn: MembershipFilterFn;
-  color: ContainerColor;
-};
-
-const useMembershipFilterMenu = (): MembershipFilter[] =>
-  useMemo(
-    () => [
-      {
-        name: 'Joined',
-        filterFn: MembershipFilters.filterJoined,
-        color: 'Background',
-      },
-      {
-        name: 'Invited',
-        filterFn: MembershipFilters.filterInvited,
-        color: 'Success',
-      },
-      {
-        name: 'Left',
-        filterFn: MembershipFilters.filterLeaved,
-        color: 'Secondary',
-      },
-      {
-        name: 'Kicked',
-        filterFn: MembershipFilters.filterKicked,
-        color: 'Warning',
-      },
-      {
-        name: 'Banned',
-        filterFn: MembershipFilters.filterBanned,
-        color: 'Critical',
-      },
-    ],
-    []
-  );
-
-export const SortFilters = {
-  filterAscending: (a: RoomMember, b: RoomMember) =>
-    a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
-  filterDescending: (a: RoomMember, b: RoomMember) =>
-    a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
-  filterNewestFirst: (a: RoomMember, b: RoomMember) =>
-    (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
-  filterOldest: (a: RoomMember, b: RoomMember) =>
-    (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
-};
-
-export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
-
-export type SortFilter = {
-  name: string;
-  filterFn: SortFilterFn;
-};
-
-const useSortFilterMenu = (): SortFilter[] =>
-  useMemo(
-    () => [
-      {
-        name: 'A to Z',
-        filterFn: SortFilters.filterAscending,
-      },
-      {
-        name: 'Z to A',
-        filterFn: SortFilters.filterDescending,
-      },
-      {
-        name: 'Newest',
-        filterFn: SortFilters.filterNewestFirst,
-      },
-      {
-        name: 'Oldest',
-        filterFn: SortFilters.filterOldest,
-      },
-    ],
-    []
-  );
-
-export type MembersFilterOptions = {
-  membershipFilter: MembershipFilter;
-  sortFilter: SortFilter;
-};
+import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
+import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
+import { MemberSortMenu } from '../../components/MemberSortMenu';
 
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
   limit: 1000,
@@ -176,17 +78,19 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
   const scrollRef = useRef<HTMLDivElement>(null);
   const searchInputRef = useRef<HTMLInputElement>(null);
   const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
-  const getPowerLevelTag = usePowerLevelTags();
+  const powerLevels = usePowerLevelsContext();
+  const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
   const fetchingMembers = members.length < room.getJoinedMemberCount();
   const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
 
   const membershipFilterMenu = useMembershipFilterMenu();
-  const sortFilterMenu = useSortFilterMenu();
+  const sortFilterMenu = useMemberSortMenu();
   const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
   const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+  const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
 
-  const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
-  const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
+  const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
+  const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
 
   const typingMembers = useRoomTypingMember(room.roomId);
 
@@ -194,9 +98,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
     () =>
       members
         .filter(membershipFilter.filterFn)
-        .sort(sortFilter.filterFn)
+        .sort(memberSort.sortFn)
         .sort((a, b) => b.powerLevel - a.powerLevel),
-    [members, membershipFilter, sortFilter]
+    [members, membershipFilter, memberSort]
   );
 
   const [result, search, resetSearch] = useAsyncSearch(
@@ -208,19 +112,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 
   const processMembers = result ? result.items : filteredMembers;
 
-  const PLTagOrRoomMember = useMemo(() => {
-    let prevTag: PowerLevelTag | undefined;
-    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
-    processMembers.forEach((m) => {
-      const plTag = getPowerLevelTag(m.powerLevel);
-      if (plTag !== prevTag) {
-        prevTag = plTag;
-        tagOrMember.push(plTag);
-      }
-      tagOrMember.push(m);
-    });
-    return tagOrMember;
-  }, [processMembers, getPowerLevelTag]);
+  const PLTagOrRoomMember = useFlattenPowerLevelTagMembers(
+    processMembers,
+    getPowerLevel,
+    getPowerLevelTag
+  );
 
   const virtualizer = useVirtualizer({
     count: PLTagOrRoomMember.length,
@@ -295,38 +191,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                       align="Start"
                       offset={4}
                       content={
-                        <FocusTrap
-                          focusTrapOptions={{
-                            initialFocus: false,
-                            onDeactivate: () => setAnchor(undefined),
-                            clickOutsideDeactivates: true,
-                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                            escapeDeactivates: stopPropagation,
-                          }}
-                        >
-                          <Menu style={{ padding: config.space.S100 }}>
-                            {membershipFilterMenu.map((menuItem, index) => (
-                              <MenuItem
-                                key={menuItem.name}
-                                variant={
-                                  menuItem.name === membershipFilter.name
-                                    ? menuItem.color
-                                    : 'Surface'
-                                }
-                                aria-pressed={menuItem.name === membershipFilter.name}
-                                size="300"
-                                radii="300"
-                                onClick={() => {
-                                  setMembershipFilterIndex(index);
-                                  setAnchor(undefined);
-                                }}
-                              >
-                                <Text size="T300">{menuItem.name}</Text>
-                              </MenuItem>
-                            ))}
-                          </Menu>
-                        </FocusTrap>
+                        <MembershipFilterMenu
+                          selected={membershipFilterIndex}
+                          onSelect={setMembershipFilterIndex}
+                          requestClose={() => setAnchor(undefined)}
+                        />
                       }
                     >
                       <Chip
@@ -336,7 +205,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                               evt.currentTarget.getBoundingClientRect()
                             )) as MouseEventHandler<HTMLButtonElement>
                         }
-                        variant={membershipFilter.color}
+                        variant="Background"
                         size="400"
                         radii="300"
                         before={<Icon src={Icons.Filter} size="50" />}
@@ -354,34 +223,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                       align="End"
                       offset={4}
                       content={
-                        <FocusTrap
-                          focusTrapOptions={{
-                            initialFocus: false,
-                            onDeactivate: () => setAnchor(undefined),
-                            clickOutsideDeactivates: true,
-                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                            escapeDeactivates: stopPropagation,
-                          }}
-                        >
-                          <Menu style={{ padding: config.space.S100 }}>
-                            {sortFilterMenu.map((menuItem, index) => (
-                              <MenuItem
-                                key={menuItem.name}
-                                variant="Surface"
-                                aria-pressed={menuItem.name === sortFilter.name}
-                                size="300"
-                                radii="300"
-                                onClick={() => {
-                                  setSortFilterIndex(index);
-                                  setAnchor(undefined);
-                                }}
-                              >
-                                <Text size="T300">{menuItem.name}</Text>
-                              </MenuItem>
-                            ))}
-                          </Menu>
-                        </FocusTrap>
+                        <MemberSortMenu
+                          selected={sortFilterIndex}
+                          onSelect={setSortFilterIndex}
+                          requestClose={() => setAnchor(undefined)}
+                        />
                       }
                     >
                       <Chip
@@ -396,7 +242,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                         radii="300"
                         after={<Icon src={Icons.Sort} size="50" />}
                       >
-                        <Text size="T200">{sortFilter.name}</Text>
+                        <Text size="T200">{memberSort.name}</Text>
                       </Chip>
                     </PopOut>
                   )}
index eb214f6204e37ebea9ea10b53af5b0b73900056c..7de12253d696de7c6d3f134e0b2bf82c74f133ed 100644 (file)
@@ -4,7 +4,6 @@ import React, {
   forwardRef,
   useCallback,
   useEffect,
-  useMemo,
   useRef,
   useState,
 } from 'react';
@@ -101,12 +100,7 @@ import {
   getVideoMsgContent,
 } from './msgContent';
 import colorMXID from '../../../util/colorMXID';
-import {
-  getAllParents,
-  getMemberDisplayName,
-  getMentionContent,
-  trimReplyFromBody,
-} from '../../utils/room';
+import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
 import { CommandAutocomplete } from './CommandAutocomplete';
 import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
 import { mobileOrTablet } from '../../utils/user-agent';
@@ -114,6 +108,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
 import { ReplyLayout, ThreadIndicator } from '../../components/message';
 import { roomToParentsAtom } from '../../state/room/roomToParents';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
 
 interface RoomInputProps {
   editor: Editor;
@@ -142,14 +137,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     );
     const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
 
-    const imagePackRooms: Room[] = useMemo(() => {
-      const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
-      return allParentSpaces.reduce<Room[]>((list, rId) => {
-        const r = mx.getRoom(rId);
-        if (r) list.push(r);
-        return list;
-      }, []);
-    }, [mx, roomId, roomToParents]);
+    const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
 
     const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
     const [autocompleteQuery, setAutocompleteQuery] =
index b0a765054f7b78437fa1becf67fc0d9774bc3215..2e50380e4dc3250eb06c9a7910b4c5dcb57c407e 100644 (file)
@@ -75,7 +75,6 @@ import {
 import {
   canEditEvent,
   decryptAllTimelineEvent,
-  getAllParents,
   getEditedEvent,
   getEventReactions,
   getLatestEditableEvt,
@@ -118,6 +117,7 @@ import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
 import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -454,16 +454,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   const mentionClickHandler = useMentionClickHandler(room.roomId);
   const spoilerClickHandler = useSpoilerClickHandler();
 
-  const imagePackRooms: Room[] = useMemo(() => {
-    const allParentSpaces = [room.roomId].concat(
-      Array.from(getAllParents(roomToParents, room.roomId))
-    );
-    return allParentSpaces.reduce<Room[]>((list, rId) => {
-      const r = mx.getRoom(rId);
-      if (r) list.push(r);
-      return list;
-    }, []);
-  }, [mx, room, roomToParents]);
+  const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
 
   const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
   const readUptoEventIdRef = useRef<string>();
index deac935ec692864d3f5d05a838cc557442546552..17a69fd3706b7214fd77434031dcbf00884f1d9d 100644 (file)
@@ -44,7 +44,7 @@ import { useRoomUnread } from '../../state/hooks/unread';
 import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { markAsRead } from '../../../client/action/notifications';
 import { roomToUnreadAtom } from '../../state/room/roomToUnread';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
 import { copyToClipboard } from '../../utils/dom';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
@@ -57,6 +57,7 @@ import { BackRouteHandler } from '../../components/BackRouteHandler';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
 import { RoomPinMenu } from './room-pin-menu';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 
 type RoomMenuProps = {
   room: Room;
@@ -87,8 +88,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
     requestClose();
   };
 
-  const handleRoomSettings = () => {
-    toggleRoomSettings(room.roomId);
+  const openSettings = useOpenRoomSettings();
+  const parentSpace = useSpaceOptionally();
+  const handleOpenSettings = () => {
+    openSettings(room.roomId, parentSpace?.roomId);
     requestClose();
   };
 
@@ -133,7 +136,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
           </Text>
         </MenuItem>
         <MenuItem
-          onClick={handleRoomSettings}
+          onClick={handleOpenSettings}
           size="300"
           after={<Icon size="100" src={Icons.Setting} />}
           radii="300"
index 743c28b7c67505b48060892d6593a9c392272882..8bccb62ec795431b65cc6f1f5179dc8fa595604c 100644 (file)
@@ -1,10 +1,11 @@
 import React, { useCallback, useState } from 'react';
-import { Box, Text, Icon, Icons, Chip, Button } from 'folds';
+import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
 import { SequenceCard } from '../../../components/sequence-card';
 import { SequenceCardStyle } from '../styles.css';
 import { SettingTile } from '../../../components/setting-tile';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
+import { CutoutCard } from '../../../components/cutout-card';
 
 type AccountDataProps = {
   expand: boolean;
@@ -13,14 +14,15 @@ type AccountDataProps = {
 };
 export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
   const mx = useMatrixClient();
-  const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
+  const [accountDataTypes, setAccountDataKeys] = useState(() =>
+    Array.from(mx.store.accountData.keys())
+  );
 
   useAccountDataCallback(
     mx,
-    useCallback(
-      () => setAccountData(Array.from(mx.store.accountData.values())),
-      [mx, setAccountData]
-    )
+    useCallback(() => {
+      setAccountDataKeys(Array.from(mx.store.accountData.keys()));
+    }, [mx])
   );
 
   return (
@@ -52,37 +54,45 @@ export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataPro
           }
         />
         {expand && (
-          <SettingTile>
-            <Box direction="Column" gap="200">
-              <Text size="L400">Types</Text>
-              <Box gap="200" wrap="Wrap">
-                <Chip
-                  variant="Secondary"
-                  fill="Soft"
-                  radii="Pill"
-                  before={<Icon size="50" src={Icons.Plus} />}
-                  onClick={() => onSelect(null)}
-                >
+          <Box direction="Column" gap="100">
+            <Box justifyContent="SpaceBetween">
+              <Text size="L400">Events</Text>
+              <Text size="L400">Total: {accountDataTypes.length}</Text>
+            </Box>
+            <CutoutCard>
+              <MenuItem
+                variant="Surface"
+                fill="None"
+                size="300"
+                radii="0"
+                before={<Icon size="50" src={Icons.Plus} />}
+                onClick={() => onSelect(null)}
+              >
+                <Box grow="Yes">
                   <Text size="T200" truncate>
                     Add New
                   </Text>
-                </Chip>
-                {accountData.map((mEvent) => (
-                  <Chip
-                    key={mEvent.getType()}
-                    variant="Secondary"
-                    fill="Soft"
-                    radii="Pill"
-                    onClick={() => onSelect(mEvent.getType())}
-                  >
+                </Box>
+              </MenuItem>
+              {accountDataTypes.sort().map((type) => (
+                <MenuItem
+                  key={type}
+                  variant="Surface"
+                  fill="None"
+                  size="300"
+                  radii="0"
+                  after={<Icon size="50" src={Icons.ChevronRight} />}
+                  onClick={() => onSelect(type)}
+                >
+                  <Box grow="Yes">
                     <Text size="T200" truncate>
-                      {mEvent.getType()}
+                      {type}
                     </Text>
-                  </Chip>
-                ))}
-              </Box>
-            </Box>
-          </SettingTile>
+                  </Box>
+                </MenuItem>
+              ))}
+            </CutoutCard>
+          </Box>
         )}
       </SequenceCard>
     </Box>
diff --git a/src/app/features/settings/developer-tools/AccountDataEditor.tsx b/src/app/features/settings/developer-tools/AccountDataEditor.tsx
deleted file mode 100644 (file)
index b5ac0f8..0000000
+++ /dev/null
@@ -1,332 +0,0 @@
-import React, {
-  FormEventHandler,
-  KeyboardEventHandler,
-  useCallback,
-  useEffect,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import {
-  Box,
-  Text,
-  Icon,
-  Icons,
-  IconButton,
-  Input,
-  Button,
-  TextArea as TextAreaComponent,
-  color,
-  Spinner,
-  Chip,
-  Scroll,
-  config,
-} from 'folds';
-import { isKeyHotkey } from 'is-hotkey';
-import { MatrixError } from 'matrix-js-sdk';
-import * as css from './styles.css';
-import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
-import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
-import { GetTarget } from '../../../plugins/text-area/type';
-import { syntaxErrorPosition } from '../../../utils/dom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { Page, PageHeader } from '../../../components/page';
-import { useAlive } from '../../../hooks/useAlive';
-import { SequenceCard } from '../../../components/sequence-card';
-import { TextViewerContent } from '../../../components/text-viewer';
-
-const EDITOR_INTENT_SPACE_COUNT = 2;
-
-type AccountDataInfo = {
-  type: string;
-  content: object;
-};
-
-type AccountDataEditProps = {
-  type: string;
-  defaultContent: string;
-  onCancel: () => void;
-  onSave: (info: AccountDataInfo) => void;
-};
-function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
-  const mx = useMatrixClient();
-  const alive = useAlive();
-
-  const textAreaRef = useRef<HTMLTextAreaElement>(null);
-  const [jsonError, setJSONError] = useState<SyntaxError>();
-
-  const getTarget: GetTarget = useCallback(() => {
-    const target = textAreaRef.current;
-    if (!target) throw new Error('TextArea element not found!');
-    return target;
-  }, []);
-
-  const { textArea, operations, intent } = useMemo(() => {
-    const ta = new TextArea(getTarget);
-    const op = new TextAreaOperations(getTarget);
-    return {
-      textArea: ta,
-      operations: op,
-      intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
-    };
-  }, [getTarget]);
-
-  const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
-
-  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
-    intentHandler(evt);
-    if (isKeyHotkey('escape', evt)) {
-      const cursor = Cursor.fromTextAreaElement(getTarget());
-      operations.deselect(cursor);
-    }
-  };
-
-  const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
-    useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
-  );
-  const submitting = submitState.status === AsyncStatus.Loading;
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    if (submitting) return;
-
-    const target = evt.target as HTMLFormElement | undefined;
-    const typeInput = target?.typeInput as HTMLInputElement | undefined;
-    const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
-    if (!typeInput || !contentTextArea) return;
-
-    const typeStr = typeInput.value.trim();
-    const contentStr = contentTextArea.value.trim();
-
-    let parsedContent: object;
-    try {
-      parsedContent = JSON.parse(contentStr);
-    } catch (e) {
-      setJSONError(e as SyntaxError);
-      return;
-    }
-    setJSONError(undefined);
-
-    if (
-      !typeStr ||
-      parsedContent === null ||
-      defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
-    ) {
-      return;
-    }
-
-    submit(typeStr, parsedContent).then(() => {
-      if (alive()) {
-        onSave({
-          type: typeStr,
-          content: parsedContent,
-        });
-      }
-    });
-  };
-
-  useEffect(() => {
-    if (jsonError) {
-      const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
-      const cursor = new Cursor(errorPosition, errorPosition, 'none');
-      operations.select(cursor);
-      getTarget()?.focus();
-    }
-  }, [jsonError, operations, getTarget]);
-
-  return (
-    <Box
-      as="form"
-      onSubmit={handleSubmit}
-      grow="Yes"
-      className={css.EditorContent}
-      direction="Column"
-      gap="400"
-      aria-disabled={submitting}
-    >
-      <Box shrink="No" direction="Column" gap="100">
-        <Text size="L400">Account Data</Text>
-        <Box gap="300">
-          <Box grow="Yes" direction="Column">
-            <Input
-              variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
-              name="typeInput"
-              size="400"
-              radii="300"
-              readOnly={type.length > 0 || submitting}
-              defaultValue={type}
-              required
-            />
-          </Box>
-          <Button
-            variant="Success"
-            size="400"
-            radii="300"
-            type="submit"
-            disabled={submitting}
-            before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
-          >
-            <Text size="B400">Save</Text>
-          </Button>
-          <Button
-            variant="Secondary"
-            fill="Soft"
-            size="400"
-            radii="300"
-            onClick={onCancel}
-            disabled={submitting}
-          >
-            <Text size="B400">Cancel</Text>
-          </Button>
-        </Box>
-
-        {submitState.status === AsyncStatus.Error && (
-          <Text size="T200" style={{ color: color.Critical.Main }}>
-            <b>{submitState.error.message}</b>
-          </Text>
-        )}
-      </Box>
-      <Box grow="Yes" direction="Column" gap="100">
-        <Box shrink="No">
-          <Text size="L400">JSON Content</Text>
-        </Box>
-        <TextAreaComponent
-          ref={textAreaRef}
-          name="contentTextArea"
-          className={css.EditorTextArea}
-          onKeyDown={handleKeyDown}
-          defaultValue={defaultContent}
-          resize="None"
-          spellCheck="false"
-          required
-          readOnly={submitting}
-        />
-        {jsonError && (
-          <Text size="T200" style={{ color: color.Critical.Main }}>
-            <b>
-              {jsonError.name}: {jsonError.message}
-            </b>
-          </Text>
-        )}
-      </Box>
-    </Box>
-  );
-}
-
-type AccountDataViewProps = {
-  type: string;
-  defaultContent: string;
-  onEdit: () => void;
-};
-function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
-  return (
-    <Box direction="Column" className={css.EditorContent} gap="400">
-      <Box shrink="No" gap="300" alignItems="End">
-        <Box grow="Yes" direction="Column" gap="100">
-          <Text size="L400">Account Data</Text>
-          <Input
-            variant="SurfaceVariant"
-            size="400"
-            radii="300"
-            readOnly
-            defaultValue={type}
-            required
-          />
-        </Box>
-        <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
-          <Text size="B400">Edit</Text>
-        </Button>
-      </Box>
-      <Box grow="Yes" direction="Column" gap="100">
-        <Text size="L400">JSON Content</Text>
-        <SequenceCard variant="SurfaceVariant">
-          <Scroll visibility="Always" size="300" hideTrack>
-            <TextViewerContent
-              size="T300"
-              style={{
-                padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
-              }}
-              text={defaultContent}
-              langName="JSON"
-            />
-          </Scroll>
-        </SequenceCard>
-      </Box>
-    </Box>
-  );
-}
-
-export type AccountDataEditorProps = {
-  type?: string;
-  requestClose: () => void;
-};
-
-export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
-  const mx = useMatrixClient();
-
-  const [data, setData] = useState<AccountDataInfo>({
-    type: type ?? '',
-    content: mx.getAccountData(type ?? '')?.getContent() ?? {},
-  });
-
-  const [edit, setEdit] = useState(!type);
-
-  const closeEdit = useCallback(() => {
-    if (!type) {
-      requestClose();
-      return;
-    }
-    setEdit(false);
-  }, [type, requestClose]);
-
-  const handleSave = useCallback((info: AccountDataInfo) => {
-    setData(info);
-    setEdit(false);
-  }, []);
-
-  const contentJSONStr = useMemo(
-    () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
-    [data.content]
-  );
-
-  return (
-    <Page>
-      <PageHeader outlined={false} balance>
-        <Box alignItems="Center" grow="Yes" gap="200">
-          <Box alignItems="Inherit" grow="Yes" gap="200">
-            <Chip
-              size="500"
-              radii="Pill"
-              onClick={requestClose}
-              before={<Icon size="100" src={Icons.ArrowLeft} />}
-            >
-              <Text size="T300">Developer Tools</Text>
-            </Chip>
-          </Box>
-          <Box shrink="No">
-            <IconButton onClick={requestClose} variant="Surface">
-              <Icon src={Icons.Cross} />
-            </IconButton>
-          </Box>
-        </Box>
-      </PageHeader>
-      <Box grow="Yes" direction="Column">
-        {edit ? (
-          <AccountDataEdit
-            type={data.type}
-            defaultContent={contentJSONStr}
-            onCancel={closeEdit}
-            onSave={handleSave}
-          />
-        ) : (
-          <AccountDataView
-            type={data.type}
-            defaultContent={contentJSONStr}
-            onEdit={() => setEdit(true)}
-          />
-        )}
-      </Box>
-    </Page>
-  );
-}
index b66452f50bd9cf03468a55ddd277559f6b2b0a0e..a3f04567b653367385d00c543f4e37bc98de8ae2 100644 (file)
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
 import { Page, PageContent, PageHeader } from '../../../components/page';
 import { SequenceCard } from '../../../components/sequence-card';
@@ -7,7 +7,10 @@ import { SettingTile } from '../../../components/setting-tile';
 import { useSetting } from '../../../state/hooks/settings';
 import { settingsAtom } from '../../../state/settings';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AccountDataEditor } from './AccountDataEditor';
+import {
+  AccountDataEditor,
+  AccountDataSubmitCallback,
+} from '../../../components/AccountDataEditor';
 import { copyToClipboard } from '../../../utils/dom';
 import { AccountData } from './AccountData';
 
@@ -20,10 +23,19 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
   const [expand, setExpend] = useState(false);
   const [accountDataType, setAccountDataType] = useState<string | null>();
 
+  const submitAccountData: AccountDataSubmitCallback = useCallback(
+    async (type, content) => {
+      await mx.setAccountData(type, content);
+    },
+    [mx]
+  );
+
   if (accountDataType !== undefined) {
     return (
       <AccountDataEditor
         type={accountDataType ?? undefined}
+        content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
+        submitChange={submitAccountData}
         requestClose={() => setAccountDataType(undefined)}
       />
     );
diff --git a/src/app/features/settings/developer-tools/styles.css.ts b/src/app/features/settings/developer-tools/styles.css.ts
deleted file mode 100644 (file)
index 9ff0294..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, config } from 'folds';
-
-export const EditorHeader = style([
-  DefaultReset,
-  {
-    paddingLeft: config.space.S400,
-    paddingRight: config.space.S200,
-    borderBottomWidth: config.borderWidth.B300,
-    flexShrink: 0,
-    gap: config.space.S200,
-  },
-]);
-
-export const EditorContent = style([
-  DefaultReset,
-  {
-    padding: config.space.S400,
-  },
-]);
-
-export const EditorTextArea = style({
-  fontFamily: 'monospace',
-});
diff --git a/src/app/hooks/useGetRoom.ts b/src/app/hooks/useGetRoom.ts
new file mode 100644 (file)
index 0000000..05fcf8d
--- /dev/null
@@ -0,0 +1,29 @@
+import { Room } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
+import { useCallback, useMemo } from 'react';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useMatrixClient } from './useMatrixClient';
+
+export const useAllJoinedRoomsSet = () => {
+  const allRooms = useAtomValue(allRoomsAtom);
+  const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+
+  return allJoinedRooms;
+};
+
+export type GetRoomCallback = (roomId: string) => Room | undefined;
+export const useGetRoom = (rooms: Set<string>): GetRoomCallback => {
+  const mx = useMatrixClient();
+
+  const getRoom: GetRoomCallback = useCallback(
+    (rId: string) => {
+      if (rooms.has(rId)) {
+        return mx.getRoom(rId) ?? undefined;
+      }
+      return undefined;
+    },
+    [mx, rooms]
+  );
+
+  return getRoom;
+};
diff --git a/src/app/hooks/useImagePackRooms.ts b/src/app/hooks/useImagePackRooms.ts
new file mode 100644 (file)
index 0000000..f04d2f2
--- /dev/null
@@ -0,0 +1,22 @@
+import { Room } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { getAllParents } from '../utils/room';
+import { useMatrixClient } from './useMatrixClient';
+
+export const useImagePackRooms = (
+  roomId: string,
+  roomToParents: Map<string, Set<string>>
+): Room[] => {
+  const mx = useMatrixClient();
+
+  const imagePackRooms: Room[] = useMemo(() => {
+    const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
+    return allParentSpaces.reduce<Room[]>((list, rId) => {
+      const r = mx.getRoom(rId);
+      if (r) list.push(r);
+      return list;
+    }, []);
+  }, [mx, roomId, roomToParents]);
+
+  return imagePackRooms;
+};
diff --git a/src/app/hooks/useMemberFilter.ts b/src/app/hooks/useMemberFilter.ts
new file mode 100644 (file)
index 0000000..2c33c7c
--- /dev/null
@@ -0,0 +1,57 @@
+import { useMemo } from 'react';
+import { RoomMember } from 'matrix-js-sdk';
+import { Membership } from '../../types/matrix/room';
+
+export const MembershipFilter = {
+  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
+  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
+  filterLeaved: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() === m.events.member?.getSender(),
+  filterKicked: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() !== m.events.member?.getSender(),
+  filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
+};
+
+export type MembershipFilterFn = (m: RoomMember) => boolean;
+
+export type MembershipFilterItem = {
+  name: string;
+  filterFn: MembershipFilterFn;
+};
+
+export const useMembershipFilterMenu = (): MembershipFilterItem[] =>
+  useMemo(
+    () => [
+      {
+        name: 'Joined',
+        filterFn: MembershipFilter.filterJoined,
+      },
+      {
+        name: 'Invited',
+        filterFn: MembershipFilter.filterInvited,
+      },
+      {
+        name: 'Left',
+        filterFn: MembershipFilter.filterLeaved,
+      },
+      {
+        name: 'Kicked',
+        filterFn: MembershipFilter.filterKicked,
+      },
+      {
+        name: 'Banned',
+        filterFn: MembershipFilter.filterBanned,
+      },
+    ],
+    []
+  );
+
+export const useMembershipFilter = (
+  index: number,
+  membershipFilter: MembershipFilterItem[]
+): MembershipFilterItem => {
+  const filter = membershipFilter[index] ?? membershipFilter[0];
+  return filter;
+};
diff --git a/src/app/hooks/useMemberSort.ts b/src/app/hooks/useMemberSort.ts
new file mode 100644 (file)
index 0000000..da95570
--- /dev/null
@@ -0,0 +1,48 @@
+import { RoomMember } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+
+export const MemberSort = {
+  Ascending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
+  Descending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
+  NewestFirst: (a: RoomMember, b: RoomMember) =>
+    (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
+  Oldest: (a: RoomMember, b: RoomMember) =>
+    (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
+};
+
+export type MemberSortFn = (a: RoomMember, b: RoomMember) => number;
+
+export type MemberSortItem = {
+  name: string;
+  sortFn: MemberSortFn;
+};
+
+export const useMemberSortMenu = (): MemberSortItem[] =>
+  useMemo(
+    () => [
+      {
+        name: 'A to Z',
+        sortFn: MemberSort.Ascending,
+      },
+      {
+        name: 'Z to A',
+        sortFn: MemberSort.Descending,
+      },
+      {
+        name: 'Newest',
+        sortFn: MemberSort.NewestFirst,
+      },
+      {
+        name: 'Oldest',
+        sortFn: MemberSort.Oldest,
+      },
+    ],
+    []
+  );
+
+export const useMemberSort = (index: number, memberSort: MemberSortItem[]): MemberSortItem => {
+  const item = memberSort[index] ?? memberSort[0];
+  return item;
+};
index dd0a3df8aeecee2891ad93c1341bd5fc7fbe4041..e91bacbcc7c5d153cde8a589b4df29555161859b 100644 (file)
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
 import { useCallback, useMemo } from 'react';
+import { IPowerLevels } from './usePowerLevels';
+import { useStateEvent } from './useStateEvent';
+import { StateEvent } from '../../types/matrix/room';
+import { IImageInfo } from '../../types/matrix/common';
 
+export type PowerLevelTagIcon = {
+  key?: string;
+  info?: IImageInfo;
+};
 export type PowerLevelTag = {
   name: string;
+  color?: string;
+  icon?: PowerLevelTagIcon;
+};
+
+export type PowerLevelTags = Record<number, PowerLevelTag>;
+
+export const powerSortFn = (a: number, b: number) => b - a;
+export const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
+
+export const getPowers = (tags: PowerLevelTags): number[] => {
+  const powers: number[] = Object.keys(tags).map((p) => parseInt(p, 10));
+
+  return sortPowers(powers);
+};
+
+export const getUsedPowers = (powerLevels: IPowerLevels): Set<number> => {
+  const powers: Set<number> = new Set();
+
+  const findAndAddPower = (data: Record<string, unknown>) => {
+    Object.keys(data).forEach((key) => {
+      const powerOrAny: unknown = data[key];
+
+      if (typeof powerOrAny === 'number') {
+        powers.add(powerOrAny);
+        return;
+      }
+      if (powerOrAny && typeof powerOrAny === 'object') {
+        findAndAddPower(powerOrAny as Record<string, unknown>);
+      }
+    });
+  };
+
+  findAndAddPower(powerLevels);
+
+  return powers;
+};
+
+const DEFAULT_TAGS: PowerLevelTags = {
+  9001: {
+    name: 'Goku',
+    color: '#ff6a00',
+  },
+  102: {
+    name: 'Goku Reborn',
+    color: '#ff6a7f',
+  },
+  101: {
+    name: 'Founder',
+    color: '#0000ff',
+  },
+  100: {
+    name: 'Admin',
+    color: '#a000e4',
+  },
+  50: {
+    name: 'Moderator',
+    color: '#1fd81f',
+  },
+  0: {
+    name: 'Member',
+  },
+  [-1]: {
+    name: 'Muted',
+  },
 };
-export const usePowerLevelTags = () => {
-  const powerLevelTags = useMemo(
-    () => ({
-      9000: {
-        name: 'Goku',
-      },
-      101: {
-        name: 'Founder',
-      },
-      100: {
-        name: 'Admin',
-      },
-      50: {
-        name: 'Moderator',
-      },
-      0: {
-        name: 'Default',
-      },
-    }),
-    []
-  );
 
-  return useCallback(
-    (powerLevel: number): PowerLevelTag => {
-      if (powerLevel >= 9000) return powerLevelTags[9000];
-      if (powerLevel >= 101) return powerLevelTags[101];
-      if (powerLevel === 100) return powerLevelTags[100];
-      if (powerLevel >= 50) return powerLevelTags[50];
-      return powerLevelTags[0];
+const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): PowerLevelTag => {
+  const highToLow = sortPowers(getPowers(powerLevelTags));
+
+  const tagPower = highToLow.find((p) => p < power);
+  const tag = typeof tagPower === 'number' ? powerLevelTags[tagPower] : undefined;
+
+  return {
+    name: tag ? `${tag.name} ${power}` : `Team ${power}`,
+  };
+};
+
+export type GetPowerLevelTag = (powerLevel: number) => PowerLevelTag;
+
+export const usePowerLevelTags = (
+  room: Room,
+  powerLevels: IPowerLevels
+): [PowerLevelTags, GetPowerLevelTag] => {
+  const tagsEvent = useStateEvent(room, StateEvent.PowerLevelTags);
+
+  const powerLevelTags: PowerLevelTags = useMemo(() => {
+    const content = tagsEvent?.getContent<PowerLevelTags>();
+    const powerToTags: PowerLevelTags = { ...content };
+
+    const powers = getUsedPowers(powerLevels);
+    Array.from(powers).forEach((power) => {
+      if (powerToTags[power]?.name === undefined) {
+        powerToTags[power] = DEFAULT_TAGS[power] ?? generateFallbackTag(DEFAULT_TAGS, power);
+      }
+    });
+
+    return powerToTags;
+  }, [powerLevels, tagsEvent]);
+
+  const getTag: GetPowerLevelTag = useCallback(
+    (power) => {
+      const tag: PowerLevelTag | undefined = powerLevelTags[power];
+      return tag ?? generateFallbackTag(DEFAULT_TAGS, power);
     },
     [powerLevelTags]
   );
+
+  return [powerLevelTags, getTag];
 };
+
+export const useFlattenPowerLevelTagMembers = (
+  members: RoomMember[],
+  getPowerLevel: (userId: string) => number,
+  getTag: GetPowerLevelTag
+): Array<PowerLevelTag | RoomMember> => {
+  const PLTagOrRoomMember = useMemo(() => {
+    let prevTag: PowerLevelTag | undefined;
+    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
+    members.forEach((member) => {
+      const memberPL = getPowerLevel(member.userId);
+      const tag = getTag(memberPL);
+      if (tag !== prevTag) {
+        prevTag = tag;
+        tagOrMember.push(tag);
+      }
+      tagOrMember.push(member);
+    });
+    return tagOrMember;
+  }, [members, getTag, getPowerLevel]);
+
+  return PLTagOrRoomMember;
+};
+
+export const getTagIconSrc = (
+  mx: MatrixClient,
+  useAuthentication: boolean,
+  icon: PowerLevelTagIcon
+): string | undefined =>
+  icon?.key?.startsWith('mxc://')
+    ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
+    : icon?.key;
index 0fcbbd33052c89596e38df76a79b4317cfa38277..8bf8b74743dafdf491e3956b26d75bc1f7beee3c 100644 (file)
@@ -1,26 +1,16 @@
-import { Room } from 'matrix-js-sdk';
-import { createContext, useCallback, useContext, useMemo } from 'react';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { createContext, useCallback, useContext, useMemo, useState } from 'react';
+import produce from 'immer';
 import { useStateEvent } from './useStateEvent';
 import { StateEvent } from '../../types/matrix/room';
-import { useForceUpdate } from './useForceUpdate';
 import { useStateEventCallback } from './useStateEventCallback';
 import { useMatrixClient } from './useMatrixClient';
 import { getStateEvent } from '../utils/room';
 
 export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
+export type PowerLevelNotificationsAction = 'room';
 
-enum DefaultPowerLevels {
-  usersDefault = 0,
-  stateDefault = 50,
-  eventsDefault = 0,
-  invite = 0,
-  redact = 50,
-  kick = 50,
-  ban = 50,
-  historical = 0,
-}
-
-export interface IPowerLevels {
+export type IPowerLevels = {
   users_default?: number;
   state_default?: number;
   events_default?: number;
@@ -33,12 +23,53 @@ export interface IPowerLevels {
   events?: Record<string, number>;
   users?: Record<string, number>;
   notifications?: Record<string, number>;
-}
+};
+
+const DEFAULT_POWER_LEVELS: Required<IPowerLevels> = {
+  users_default: 0,
+  state_default: 50,
+  events_default: 0,
+  invite: 0,
+  redact: 50,
+  kick: 50,
+  ban: 50,
+  historical: 0,
+  events: {},
+  users: {},
+  notifications: {
+    room: 50,
+  },
+};
+
+const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
+  produce(powerLevels, (draftPl: IPowerLevels) => {
+    const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
+    keys.forEach((key) => {
+      if (draftPl[key] === undefined) {
+        // eslint-disable-next-line no-param-reassign
+        draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
+      }
+    });
+    if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
+      // eslint-disable-next-line no-param-reassign
+      draftPl.notifications.room = DEFAULT_POWER_LEVELS.notifications.room;
+    }
+    return draftPl;
+  });
+
+const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
+  const pl = mEvent?.getContent<IPowerLevels>();
+  if (!pl) return DEFAULT_POWER_LEVELS;
+
+  return fillMissingPowers(pl);
+};
 
 export function usePowerLevels(room: Room): IPowerLevels {
   const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
-  const powerLevels: IPowerLevels =
-    powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
+  const powerLevels: IPowerLevels = useMemo(
+    () => getPowersLevelFromMatrixEvent(powerLevelsEvent),
+    [powerLevelsEvent]
+  );
 
   return powerLevels;
 }
@@ -55,7 +86,18 @@ export const usePowerLevelsContext = (): IPowerLevels => {
 
 export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
   const mx = useMatrixClient();
-  const [updateCount, forceUpdate] = useForceUpdate();
+  const getRoomsPowerLevels = useCallback(() => {
+    const rToPl = new Map<string, IPowerLevels>();
+
+    rooms.forEach((room) => {
+      const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, '');
+      rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent));
+    });
+
+    return rToPl;
+  }, [rooms]);
+
+  const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels());
 
   useStateEventCallback(
     mx,
@@ -68,28 +110,13 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
           event.getStateKey() === '' &&
           rooms.find((r) => r.roomId === roomId)
         ) {
-          forceUpdate();
+          setRoomToPowerLevels(getRoomsPowerLevels());
         }
       },
-      [rooms, forceUpdate]
+      [rooms, getRoomsPowerLevels]
     )
   );
 
-  const roomToPowerLevels = useMemo(
-    () => {
-      const rToPl = new Map<string, IPowerLevels>();
-
-      rooms.forEach((room) => {
-        const pl = getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>();
-        if (pl) rToPl.set(room.roomId, pl);
-      });
-
-      return rToPl;
-    },
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [rooms, updateCount]
-  );
-
   return roomToPowerLevels;
 };
 
@@ -104,42 +131,83 @@ export type CanDoAction = (
   action: PowerLevelActions,
   powerLevel: number
 ) => boolean;
+export type CanDoNotificationAction = (
+  powerLevels: IPowerLevels,
+  action: PowerLevelNotificationsAction,
+  powerLevel: number
+) => boolean;
 
 export type PowerLevelsAPI = {
   getPowerLevel: GetPowerLevel;
   canSendEvent: CanSend;
   canSendStateEvent: CanSend;
   canDoAction: CanDoAction;
+  canDoNotificationAction: CanDoNotificationAction;
 };
 
-export const powerLevelAPI: PowerLevelsAPI = {
-  getPowerLevel: (powerLevels, userId) => {
+export type ReadPowerLevelAPI = {
+  user: GetPowerLevel;
+  event: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
+  state: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
+  action: (powerLevels: IPowerLevels, action: PowerLevelActions) => number;
+  notification: (powerLevels: IPowerLevels, action: PowerLevelNotificationsAction) => number;
+};
+
+export const readPowerLevel: ReadPowerLevelAPI = {
+  user: (powerLevels, userId) => {
     const { users_default: usersDefault, users } = powerLevels;
     if (userId && users && typeof users[userId] === 'number') {
       return users[userId];
     }
-    return usersDefault ?? DefaultPowerLevels.usersDefault;
+    return usersDefault ?? DEFAULT_POWER_LEVELS.users_default;
   },
-  canSendEvent: (powerLevels, eventType, powerLevel) => {
+  event: (powerLevels, eventType) => {
     const { events, events_default: eventsDefault } = powerLevels;
     if (events && eventType && typeof events[eventType] === 'number') {
-      return powerLevel >= events[eventType];
+      return events[eventType];
     }
-    return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
+    return eventsDefault ?? DEFAULT_POWER_LEVELS.events_default;
   },
-  canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+  state: (powerLevels, eventType) => {
     const { events, state_default: stateDefault } = powerLevels;
     if (events && eventType && typeof events[eventType] === 'number') {
-      return powerLevel >= events[eventType];
+      return events[eventType];
     }
-    return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
+    return stateDefault ?? DEFAULT_POWER_LEVELS.state_default;
   },
-  canDoAction: (powerLevels, action, powerLevel) => {
-    const requiredPL = powerLevels[action];
-    if (typeof requiredPL === 'number') {
-      return powerLevel >= requiredPL;
+  action: (powerLevels, action) => {
+    const powerLevel = powerLevels[action];
+    if (typeof powerLevel === 'number') {
+      return powerLevel;
     }
-    return powerLevel >= DefaultPowerLevels[action];
+    return DEFAULT_POWER_LEVELS[action];
+  },
+  notification: (powerLevels, action) => {
+    const powerLevel = powerLevels.notifications?.[action];
+    if (typeof powerLevel === 'number') {
+      return powerLevel;
+    }
+    return DEFAULT_POWER_LEVELS.notifications[action];
+  },
+};
+
+export const powerLevelAPI: PowerLevelsAPI = {
+  getPowerLevel: (powerLevels, userId) => readPowerLevel.user(powerLevels, userId),
+  canSendEvent: (powerLevels, eventType, powerLevel) => {
+    const requiredPL = readPowerLevel.event(powerLevels, eventType);
+    return powerLevel >= requiredPL;
+  },
+  canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+    const requiredPL = readPowerLevel.state(powerLevels, eventType);
+    return powerLevel >= requiredPL;
+  },
+  canDoAction: (powerLevels, action, powerLevel) => {
+    const requiredPL = readPowerLevel.action(powerLevels, action);
+    return powerLevel >= requiredPL;
+  },
+  canDoNotificationAction: (powerLevels, action, powerLevel) => {
+    const requiredPL = readPowerLevel.notification(powerLevels, action);
+    return powerLevel >= requiredPL;
   },
 };
 
@@ -167,10 +235,121 @@ export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
     [powerLevels]
   );
 
+  const canDoNotificationAction = useCallback(
+    (action: PowerLevelNotificationsAction, powerLevel: number) =>
+      powerLevelAPI.canDoNotificationAction(powerLevels, action, powerLevel),
+    [powerLevels]
+  );
+
   return {
     getPowerLevel,
     canSendEvent,
     canSendStateEvent,
     canDoAction,
+    canDoNotificationAction,
   };
 };
+
+/**
+ * Permissions
+ */
+
+type DefaultPermissionLocation = {
+  user: true;
+  key?: string;
+};
+
+type ActionPermissionLocation = {
+  action: true;
+  key: PowerLevelActions;
+};
+
+type EventPermissionLocation = {
+  state?: true;
+  key?: string;
+};
+
+type NotificationPermissionLocation = {
+  notification: true;
+  key: PowerLevelNotificationsAction;
+};
+
+export type PermissionLocation =
+  | DefaultPermissionLocation
+  | ActionPermissionLocation
+  | EventPermissionLocation
+  | NotificationPermissionLocation;
+
+export const getPermissionPower = (
+  powerLevels: IPowerLevels,
+  location: PermissionLocation
+): number => {
+  if ('user' in location) {
+    return readPowerLevel.user(powerLevels, location.key);
+  }
+  if ('action' in location) {
+    return readPowerLevel.action(powerLevels, location.key);
+  }
+  if ('notification' in location) {
+    return readPowerLevel.notification(powerLevels, location.key);
+  }
+  if ('state' in location) {
+    return readPowerLevel.state(powerLevels, location.key);
+  }
+
+  return readPowerLevel.event(powerLevels, location.key);
+};
+
+export const applyPermissionPower = (
+  powerLevels: IPowerLevels,
+  location: PermissionLocation,
+  power: number
+): IPowerLevels => {
+  if ('user' in location) {
+    if (typeof location.key === 'string') {
+      const users = powerLevels.users ?? {};
+      users[location.key] = power;
+      // eslint-disable-next-line no-param-reassign
+      powerLevels.users = users;
+      return powerLevels;
+    }
+    // eslint-disable-next-line no-param-reassign
+    powerLevels.users_default = power;
+    return powerLevels;
+  }
+  if ('action' in location) {
+    // eslint-disable-next-line no-param-reassign
+    powerLevels[location.key] = power;
+    return powerLevels;
+  }
+  if ('notification' in location) {
+    const notifications = powerLevels.notifications ?? {};
+    notifications[location.key] = power;
+    // eslint-disable-next-line no-param-reassign
+    powerLevels.notifications = notifications;
+    return powerLevels;
+  }
+  if ('state' in location) {
+    if (typeof location.key === 'string') {
+      const events = powerLevels.events ?? {};
+      events[location.key] = power;
+      // eslint-disable-next-line no-param-reassign
+      powerLevels.events = events;
+      return powerLevels;
+    }
+    // eslint-disable-next-line no-param-reassign
+    powerLevels.state_default = power;
+    return powerLevels;
+  }
+
+  if (typeof location.key === 'string') {
+    const events = powerLevels.events ?? {};
+    events[location.key] = power;
+    // eslint-disable-next-line no-param-reassign
+    powerLevels.events = events;
+    return powerLevels;
+  }
+  // eslint-disable-next-line no-param-reassign
+  powerLevels.events_default = power;
+  return powerLevels;
+};
diff --git a/src/app/hooks/useRoomAccountData.ts b/src/app/hooks/useRoomAccountData.ts
new file mode 100644 (file)
index 0000000..e344ff8
--- /dev/null
@@ -0,0 +1,29 @@
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useCallback, useEffect, useState } from 'react';
+
+export const useRoomAccountData = (room: Room): Map<string, object> => {
+  const getAccountData = useCallback((): Map<string, object> => {
+    const accountData = new Map<string, object>();
+
+    Array.from(room.accountData.entries()).forEach(([type, mEvent]) => {
+      const content = mEvent.getContent();
+      accountData.set(type, content);
+    });
+
+    return accountData;
+  }, [room]);
+
+  const [accountData, setAccountData] = useState<Map<string, object>>(getAccountData);
+
+  useEffect(() => {
+    const handleEvent: RoomEventHandlerMap[RoomEvent.AccountData] = () => {
+      setAccountData(getAccountData());
+    };
+    room.on(RoomEvent.AccountData, handleEvent);
+    return () => {
+      room.removeListener(RoomEvent.AccountData, handleEvent);
+    };
+  }, [room, getAccountData]);
+
+  return accountData;
+};
diff --git a/src/app/hooks/useRoomAliases.ts b/src/app/hooks/useRoomAliases.ts
new file mode 100644 (file)
index 0000000..005ff0b
--- /dev/null
@@ -0,0 +1,170 @@
+import { useCallback, useEffect, useMemo } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { RoomCanonicalAliasEventContent } from 'matrix-js-sdk/lib/types';
+import { AsyncState, useAsyncCallback } from './useAsyncCallback';
+import { useMatrixClient } from './useMatrixClient';
+import { useAlive } from './useAlive';
+import { useStateEvent } from './useStateEvent';
+import { StateEvent } from '../../types/matrix/room';
+import { getStateEvent } from '../utils/room';
+
+export const usePublishedAliases = (room: Room): [string | undefined, string[]] => {
+  const aliasContent = useStateEvent(
+    room,
+    StateEvent.RoomCanonicalAlias
+  )?.getContent<RoomCanonicalAliasEventContent>();
+
+  const canonicalAlias = aliasContent?.alias;
+
+  const publishedAliases = useMemo(() => {
+    const aliases: string[] = [];
+    if (typeof aliasContent?.alias === 'string') {
+      aliases.push(aliasContent.alias);
+    }
+    aliasContent?.alt_aliases?.forEach((alias) => {
+      if (typeof alias === 'string') {
+        aliases.push(alias);
+      }
+    });
+    return aliases;
+  }, [aliasContent]);
+
+  return [canonicalAlias, publishedAliases];
+};
+
+export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Promise<void>) => {
+  const mx = useMatrixClient();
+  const mainAlias = useCallback(
+    async (alias: string | undefined) => {
+      const content = getStateEvent(
+        room,
+        StateEvent.RoomCanonicalAlias
+      )?.getContent<RoomCanonicalAliasEventContent>();
+
+      const altAliases: string[] = [];
+      if (content?.alias && content.alias !== alias) {
+        altAliases.push(content.alias);
+      }
+      content?.alt_aliases?.forEach((a) => {
+        if (a !== alias) {
+          altAliases.push(a);
+        }
+      });
+
+      const newContent: RoomCanonicalAliasEventContent = {
+        alias,
+        alt_aliases: altAliases,
+      };
+
+      await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+    },
+    [mx, room]
+  );
+
+  return mainAlias;
+};
+
+export const usePublishUnpublishAliases = (
+  room: Room
+): {
+  publishAliases: (aliases: string[]) => Promise<void>;
+  unpublishAliases: (aliases: string[]) => Promise<void>;
+} => {
+  const mx = useMatrixClient();
+  const publishAliases = useCallback(
+    async (aliases: string[]) => {
+      const content = getStateEvent(
+        room,
+        StateEvent.RoomCanonicalAlias
+      )?.getContent<RoomCanonicalAliasEventContent>();
+      const altAliases = content?.alt_aliases ?? [];
+
+      aliases.forEach((alias) => {
+        if (!altAliases.includes(alias)) {
+          altAliases.push(alias);
+        }
+      });
+
+      const newContent: RoomCanonicalAliasEventContent = {
+        alias: content?.alias,
+        alt_aliases: altAliases,
+      };
+
+      await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+    },
+    [mx, room]
+  );
+
+  const unpublishAliases = useCallback(
+    async (aliases: string[]) => {
+      const content = getStateEvent(
+        room,
+        StateEvent.RoomCanonicalAlias
+      )?.getContent<RoomCanonicalAliasEventContent>();
+      const altAliases: string[] = [];
+
+      content?.alt_aliases?.forEach((alias) => {
+        if (!aliases.includes(alias)) {
+          altAliases.push(alias);
+        }
+      });
+
+      const newContent: RoomCanonicalAliasEventContent = {
+        alias: content?.alias,
+        alt_aliases: altAliases,
+      };
+
+      await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+    },
+    [mx, room]
+  );
+
+  return {
+    publishAliases,
+    unpublishAliases,
+  };
+};
+
+export const useLocalAliases = (
+  roomId: string
+): {
+  localAliasesState: AsyncState<string[], MatrixError>;
+  addLocalAlias: (alias: string) => Promise<void>;
+  removeLocalAlias: (alias: string) => Promise<void>;
+} => {
+  const mx = useMatrixClient();
+  const alive = useAlive();
+
+  const [aliasesState, loadAliases] = useAsyncCallback<string[], MatrixError, []>(
+    useCallback(async () => {
+      const content = await mx.getLocalAliases(roomId);
+      return content.aliases;
+    }, [mx, roomId])
+  );
+
+  useEffect(() => {
+    loadAliases();
+  }, [loadAliases]);
+
+  const addLocalAlias = useCallback(
+    async (alias: string) => {
+      await mx.createAlias(alias, roomId);
+      if (alive()) await loadAliases();
+    },
+    [mx, roomId, loadAliases, alive]
+  );
+
+  const removeLocalAlias = useCallback(
+    async (alias: string) => {
+      await mx.deleteAlias(alias);
+      if (alive()) await loadAliases();
+    },
+    [mx, loadAliases, alive]
+  );
+
+  return {
+    localAliasesState: aliasesState,
+    addLocalAlias,
+    removeLocalAlias,
+  };
+};
index 51248831e0a6ea2e76993ea348e8601b34c85b01..8b0ae8ccc30ba2b42f9ecad8770f0b486569e505 100644 (file)
@@ -1,4 +1,5 @@
 import { useEffect, useState } from 'react';
+import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
 import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
 import { StateEvent } from '../../types/matrix/room';
 import { useStateEvent } from './useStateEvent';
@@ -39,3 +40,9 @@ export const useRoomTopic = (room: Room): string | undefined => {
 
   return topic;
 };
+
+export const useRoomJoinRule = (room: Room): RoomJoinRulesEventContent | undefined => {
+  const mEvent = useStateEvent(room, StateEvent.RoomJoinRules);
+  const joinRuleContent = mEvent?.getContent<RoomJoinRulesEventContent>();
+  return joinRuleContent;
+};
diff --git a/src/app/hooks/useRoomState.ts b/src/app/hooks/useRoomState.ts
new file mode 100644 (file)
index 0000000..a62b476
--- /dev/null
@@ -0,0 +1,50 @@
+import {
+  Direction,
+  MatrixEvent,
+  Room,
+  RoomStateEvent,
+  RoomStateEventHandlerMap,
+} from 'matrix-js-sdk';
+import { useCallback, useEffect, useState } from 'react';
+import { StateEvent } from '../../types/matrix/room';
+
+export type StateKeyToEvents = Map<string, MatrixEvent>;
+export type StateTypeToState = Map<string, StateKeyToEvents>;
+
+export const useRoomState = (room: Room): StateTypeToState => {
+  const getState = useCallback((): StateTypeToState => {
+    const roomState = room.getLiveTimeline().getState(Direction.Forward);
+    const state: StateTypeToState = new Map();
+
+    if (!roomState) return state;
+
+    roomState.events.forEach((stateKeyToEvents, eventType) => {
+      if (eventType === StateEvent.RoomMember) {
+        // Ignore room members from state on purpose;
+        return;
+      }
+      const kToE: StateKeyToEvents = new Map();
+      stateKeyToEvents.forEach((mEvent, stateKey) => kToE.set(stateKey, mEvent));
+
+      state.set(eventType, kToE);
+    });
+
+    return state;
+  }, [room]);
+
+  const [state, setState] = useState(getState);
+
+  useEffect(() => {
+    const roomState = room.getLiveTimeline().getState(Direction.Forward);
+    const handler: RoomStateEventHandlerMap[RoomStateEvent.Events] = () => {
+      setState(getState());
+    };
+
+    roomState?.on(RoomStateEvent.Events, handler);
+    return () => {
+      roomState?.removeListener(RoomStateEvent.Events, handler);
+    };
+  }, [room, getState]);
+
+  return state;
+};
diff --git a/src/app/hooks/useTextAreaCodeEditor.ts b/src/app/hooks/useTextAreaCodeEditor.ts
new file mode 100644 (file)
index 0000000..34c12f2
--- /dev/null
@@ -0,0 +1,44 @@
+import { useMemo, useCallback, KeyboardEventHandler, MutableRefObject } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import { TextArea, Intent, TextAreaOperations, Cursor } from '../plugins/text-area';
+import { useTextAreaIntentHandler } from './useTextAreaIntent';
+import { GetTarget } from '../plugins/text-area/type';
+
+export const useTextAreaCodeEditor = (
+  textAreaRef: MutableRefObject<HTMLTextAreaElement | null>,
+  intentSpaceCount: number
+) => {
+  const getTarget: GetTarget = useCallback(() => {
+    const target = textAreaRef.current;
+    if (!target) throw new Error('TextArea element not found!');
+    return target;
+  }, [textAreaRef]);
+
+  const { textArea, operations, intent } = useMemo(() => {
+    const ta = new TextArea(getTarget);
+    const op = new TextAreaOperations(getTarget);
+    return {
+      textArea: ta,
+      operations: op,
+      intent: new Intent(intentSpaceCount, ta, op),
+    };
+  }, [getTarget, intentSpaceCount]);
+
+  const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
+
+  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
+    intentHandler(evt);
+    if (isKeyHotkey('escape', evt)) {
+      const cursor = Cursor.fromTextAreaElement(getTarget());
+      operations.deselect(cursor);
+    }
+  };
+
+  return {
+    handleKeyDown,
+    textArea,
+    intent,
+    getTarget,
+    operations,
+  };
+};
index 657422e402e6bb02b9f99c4e7db1f78a15d1ba68..ee934e57370e06cd080a5d59a2da10e361cf0e8c 100644 (file)
@@ -58,6 +58,7 @@ import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
 import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
 import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
 import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
+import { RoomSettingsRenderer } from '../features/room-settings';
 
 export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
   const { hashRouter } = clientConfig;
@@ -121,6 +122,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
                     >
                       <Outlet />
                     </ClientLayout>
+                    <RoomSettingsRenderer />
                     <ReceiveSelfDeviceVerification />
                     <AutoRestoreBackupOnVerification />
                   </ClientNonUIFeatures>
diff --git a/src/app/state/hooks/roomSettings.ts b/src/app/state/hooks/roomSettings.ts
new file mode 100644 (file)
index 0000000..4d76491
--- /dev/null
@@ -0,0 +1,34 @@
+import { useCallback } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { roomSettingsAtom, RoomSettingsPage, RoomSettingsState } from '../roomSettings';
+
+export const useRoomSettingsState = (): RoomSettingsState | undefined => {
+  const data = useAtomValue(roomSettingsAtom);
+
+  return data;
+};
+
+type CloseCallback = () => void;
+export const useCloseRoomSettings = (): CloseCallback => {
+  const setSettings = useSetAtom(roomSettingsAtom);
+
+  const close: CloseCallback = useCallback(() => {
+    setSettings(undefined);
+  }, [setSettings]);
+
+  return close;
+};
+
+type OpenCallback = (roomId: string, space?: string, page?: RoomSettingsPage) => void;
+export const useOpenRoomSettings = (): OpenCallback => {
+  const setSettings = useSetAtom(roomSettingsAtom);
+
+  const open: OpenCallback = useCallback(
+    (roomId, spaceId, page) => {
+      setSettings({ roomId, spaceId, page });
+    },
+    [setSettings]
+  );
+
+  return open;
+};
diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts
new file mode 100644 (file)
index 0000000..327db59
--- /dev/null
@@ -0,0 +1,17 @@
+import { atom } from 'jotai';
+
+export enum RoomSettingsPage {
+  GeneralPage,
+  MembersPage,
+  PermissionsPage,
+  EmojisStickersPage,
+  DeveloperToolsPage,
+}
+
+export type RoomSettingsState = {
+  page?: RoomSettingsPage;
+  roomId: string;
+  spaceId?: string;
+};
+
+export const roomSettingsAtom = atom<RoomSettingsState | undefined>(undefined);
index 09f7e8f1c8d23cf2bf392bae96140a7e7e23750b..cd3c0862d3f4a29552743bd0ff587c69b5fe3a2d 100644 (file)
@@ -18,8 +18,7 @@ import { AccountDataEvent } from '../../types/matrix/accountData';
 import { getStateEvent } from './room';
 import { StateEvent } from '../../types/matrix/room';
 
-export const matchMxId = (id: string): RegExpMatchArray | null =>
-  id.match(/^([@!$+#])(\S+):(\S+)$/);
+export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
 
 export const validMxId = (id: string): boolean => !!matchMxId(id);
 
index c468309d60d5390ec5379fc847526d19531abfcf..65dc35f4b17e7ae3846a0656588def0337b1d6df 100644 (file)
@@ -35,6 +35,7 @@ export enum StateEvent {
   SpaceParent = 'm.space.parent',
 
   PoniesRoomEmotes = 'im.ponies.room_emotes',
+  PowerLevelTags = 'in.cinny.room.power_level_tags',
 }
 
 export enum MessageEvent {