mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	New room settings, add customizable power levels and dev tools (#2222)
* 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
This commit is contained in:
		
							parent
							
								
									00f3df8719
								
							
						
					
					
						commit
						286983c833
					
				
					 73 changed files with 6196 additions and 420 deletions
				
			
		
							
								
								
									
										11
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			@ -9654,6 +9655,16 @@
 | 
			
		|||
        "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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,4 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  KeyboardEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
| 
						 | 
				
			
			@ -22,22 +14,20 @@ import {
 | 
			
		|||
  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';
 | 
			
		||||
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;
 | 
			
		||||
| 
						 | 
				
			
			@ -46,45 +36,28 @@ type AccountDataInfo = {
 | 
			
		|||
type AccountDataEditProps = {
 | 
			
		||||
  type: string;
 | 
			
		||||
  defaultContent: string;
 | 
			
		||||
  submitChange: AccountDataSubmitCallback;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onSave: (info: AccountDataInfo) => void;
 | 
			
		||||
};
 | 
			
		||||
function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
function AccountDataEdit({
 | 
			
		||||
  type,
 | 
			
		||||
  defaultContent,
 | 
			
		||||
  submitChange,
 | 
			
		||||
  onCancel,
 | 
			
		||||
  onSave,
 | 
			
		||||
}: AccountDataEditProps) {
 | 
			
		||||
  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 { 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) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -140,7 +113,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
			
		|||
      as="form"
 | 
			
		||||
      onSubmit={handleSubmit}
 | 
			
		||||
      grow="Yes"
 | 
			
		||||
      className={css.EditorContent}
 | 
			
		||||
      style={{
 | 
			
		||||
        padding: config.space.S400,
 | 
			
		||||
      }}
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="400"
 | 
			
		||||
      aria-disabled={submitting}
 | 
			
		||||
| 
						 | 
				
			
			@ -174,6 +149,7 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
			
		|||
            fill="Soft"
 | 
			
		||||
            size="400"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={onCancel}
 | 
			
		||||
            disabled={submitting}
 | 
			
		||||
          >
 | 
			
		||||
| 
						 | 
				
			
			@ -194,7 +170,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
			
		|||
        <TextAreaComponent
 | 
			
		||||
          ref={textAreaRef}
 | 
			
		||||
          name="contentTextArea"
 | 
			
		||||
          className={css.EditorTextArea}
 | 
			
		||||
          style={{
 | 
			
		||||
            fontFamily: 'monospace',
 | 
			
		||||
          }}
 | 
			
		||||
          onKeyDown={handleKeyDown}
 | 
			
		||||
          defaultValue={defaultContent}
 | 
			
		||||
          resize="None"
 | 
			
		||||
| 
						 | 
				
			
			@ -221,7 +199,13 @@ type AccountDataViewProps = {
 | 
			
		|||
};
 | 
			
		||||
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" className={css.EditorContent} gap="400">
 | 
			
		||||
    <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>
 | 
			
		||||
| 
						 | 
				
			
			@ -259,15 +243,20 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
 | 
			
		|||
 | 
			
		||||
export type AccountDataEditorProps = {
 | 
			
		||||
  type?: string;
 | 
			
		||||
  content?: object;
 | 
			
		||||
  submitChange: AccountDataSubmitCallback;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
export function AccountDataEditor({
 | 
			
		||||
  type,
 | 
			
		||||
  content,
 | 
			
		||||
  submitChange,
 | 
			
		||||
  requestClose,
 | 
			
		||||
}: AccountDataEditorProps) {
 | 
			
		||||
  const [data, setData] = useState<AccountDataInfo>({
 | 
			
		||||
    type: type ?? '',
 | 
			
		||||
    content: mx.getAccountData(type ?? '')?.getContent() ?? {},
 | 
			
		||||
    content: content ?? {},
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const [edit, setEdit] = useState(!type);
 | 
			
		||||
| 
						 | 
				
			
			@ -316,6 +305,7 @@ export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps
 | 
			
		|||
          <AccountDataEdit
 | 
			
		||||
            type={data.type}
 | 
			
		||||
            defaultContent={contentJSONStr}
 | 
			
		||||
            submitChange={submitChange}
 | 
			
		||||
            onCancel={closeEdit}
 | 
			
		||||
            onSave={handleSave}
 | 
			
		||||
          />
 | 
			
		||||
							
								
								
									
										25
									
								
								src/app/components/BetaNoticeBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/app/components/BetaNoticeBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										59
									
								
								src/app/components/HexColorPickerPopOut.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/app/components/HexColorPickerPopOut.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										138
									
								
								src/app/components/JoinRulesSwitcher.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/app/components/JoinRulesSwitcher.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								src/app/components/MemberSortMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/components/MemberSortMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										49
									
								
								src/app/components/MembershipFilterMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/app/components/MembershipFilterMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								src/app/components/cutout-card/CutoutCard.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/app/components/cutout-card/CutoutCard.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										15
									
								
								src/app/components/cutout-card/CutoutCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/components/cutout-card/CutoutCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/cutout-card/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/cutout-card/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './CutoutCard';
 | 
			
		||||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
        if (addToRecentEmoji) {
 | 
			
		||||
          addRecentEmoji(mx, emojiInfo.data);
 | 
			
		||||
        }
 | 
			
		||||
        requestClose();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										53
									
								
								src/app/components/member-tile/MemberTile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/app/components/member-tile/MemberTile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/member-tile/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/member-tile/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './MemberTile';
 | 
			
		||||
							
								
								
									
										32
									
								
								src/app/components/member-tile/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/app/components/member-tile/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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,
 | 
			
		||||
]);
 | 
			
		||||
							
								
								
									
										21
									
								
								src/app/components/power/PowerColorBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/app/components/power/PowerColorBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										15
									
								
								src/app/components/power/PowerIcon.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/components/power/PowerIcon.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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} />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								src/app/components/power/PowerSelector.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/app/components/power/PowerSelector.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								src/app/components/power/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/app/components/power/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
export * from './PowerColorBadge';
 | 
			
		||||
export * from './PowerIcon';
 | 
			
		||||
export * from './PowerSelector';
 | 
			
		||||
							
								
								
									
										73
									
								
								src/app/components/power/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/app/components/power/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>;
 | 
			
		||||
							
								
								
									
										16
									
								
								src/app/components/server-badge/ServerBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/app/components/server-badge/ServerBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/server-badge/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/server-badge/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './ServerBadge';
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										172
									
								
								src/app/features/room-settings/RoomSettings.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/app/features/room-settings/RoomSettings.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								src/app/features/room-settings/RoomSettingsRenderer.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/app/features/room-settings/RoomSettingsRenderer.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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} />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										396
									
								
								src/app/features/room-settings/developer-tools/DevelopTools.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										396
									
								
								src/app/features/room-settings/developer-tools/DevelopTools.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										208
									
								
								src/app/features/room-settings/developer-tools/SendRoomEvent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								src/app/features/room-settings/developer-tools/SendRoomEvent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room-settings/developer-tools/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room-settings/developer-tools/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './DevelopTools';
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										349
									
								
								src/app/features/room-settings/emojis-stickers/RoomPacks.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										349
									
								
								src/app/features/room-settings/emojis-stickers/RoomPacks.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room-settings/emojis-stickers/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room-settings/emojis-stickers/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './EmojisStickers';
 | 
			
		||||
							
								
								
									
										57
									
								
								src/app/features/room-settings/general/General.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/app/features/room-settings/general/General.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										438
									
								
								src/app/features/room-settings/general/RoomAddress.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										438
									
								
								src/app/features/room-settings/general/RoomAddress.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										150
									
								
								src/app/features/room-settings/general/RoomEncryption.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/app/features/room-settings/general/RoomEncryption.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										169
									
								
								src/app/features/room-settings/general/RoomHistoryVisibility.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/app/features/room-settings/general/RoomHistoryVisibility.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										124
									
								
								src/app/features/room-settings/general/RoomJoinRules.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/app/features/room-settings/general/RoomJoinRules.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										351
									
								
								src/app/features/room-settings/general/RoomProfile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								src/app/features/room-settings/general/RoomProfile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room-settings/general/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room-settings/general/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './General';
 | 
			
		||||
							
								
								
									
										2
									
								
								src/app/features/room-settings/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/features/room-settings/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export * from './RoomSettings';
 | 
			
		||||
export * from './RoomSettingsRenderer';
 | 
			
		||||
							
								
								
									
										353
									
								
								src/app/features/room-settings/members/Members.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								src/app/features/room-settings/members/Members.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room-settings/members/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room-settings/members/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Members';
 | 
			
		||||
							
								
								
									
										287
									
								
								src/app/features/room-settings/permissions/PermissionGroups.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								src/app/features/room-settings/permissions/PermissionGroups.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										66
									
								
								src/app/features/room-settings/permissions/Permissions.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/app/features/room-settings/permissions/Permissions.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										170
									
								
								src/app/features/room-settings/permissions/Powers.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/app/features/room-settings/permissions/Powers.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										579
									
								
								src/app/features/room-settings/permissions/PowersEditor.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										579
									
								
								src/app/features/room-settings/permissions/PowersEditor.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room-settings/permissions/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room-settings/permissions/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Permissions';
 | 
			
		||||
							
								
								
									
										218
									
								
								src/app/features/room-settings/permissions/usePermissionItems.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								src/app/features/room-settings/permissions/usePermissionItems.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										6
									
								
								src/app/features/room-settings/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/app/features/room-settings/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const SequenceCardStyle = style({
 | 
			
		||||
  padding: config.space.S300,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
                  )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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] =
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
          <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>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
              ))}
 | 
			
		||||
            </CutoutCard>
 | 
			
		||||
          </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </SettingTile>
 | 
			
		||||
        )}
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								src/app/hooks/useGetRoom.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/app/hooks/useGetRoom.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/hooks/useImagePackRooms.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/hooks/useImagePackRooms.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										57
									
								
								src/app/hooks/useMemberFilter.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/app/hooks/useMemberFilter.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										48
									
								
								src/app/hooks/useMemberSort.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/app/hooks/useMemberSort.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,38 +1,154 @@
 | 
			
		|||
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 const usePowerLevelTags = () => {
 | 
			
		||||
  const powerLevelTags = useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      9000: {
 | 
			
		||||
 | 
			
		||||
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: 'Default',
 | 
			
		||||
    name: 'Member',
 | 
			
		||||
  },
 | 
			
		||||
    }),
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
  [-1]: {
 | 
			
		||||
    name: 'Muted',
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
  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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  },
 | 
			
		||||
  action: (powerLevels, action) => {
 | 
			
		||||
    const powerLevel = powerLevels[action];
 | 
			
		||||
    if (typeof powerLevel === 'number') {
 | 
			
		||||
      return powerLevel;
 | 
			
		||||
    }
 | 
			
		||||
    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 = powerLevels[action];
 | 
			
		||||
    if (typeof requiredPL === 'number') {
 | 
			
		||||
    const requiredPL = readPowerLevel.action(powerLevels, action);
 | 
			
		||||
    return powerLevel >= requiredPL;
 | 
			
		||||
  },
 | 
			
		||||
  canDoNotificationAction: (powerLevels, action, powerLevel) => {
 | 
			
		||||
    const requiredPL = readPowerLevel.notification(powerLevels, action);
 | 
			
		||||
    return powerLevel >= requiredPL;
 | 
			
		||||
    }
 | 
			
		||||
    return powerLevel >= DefaultPowerLevels[action];
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										29
									
								
								src/app/hooks/useRoomAccountData.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/app/hooks/useRoomAccountData.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										170
									
								
								src/app/hooks/useRoomAliases.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/app/hooks/useRoomAliases.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										50
									
								
								src/app/hooks/useRoomState.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/app/hooks/useRoomState.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										44
									
								
								src/app/hooks/useTextAreaCodeEditor.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/app/hooks/useTextAreaCodeEditor.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										34
									
								
								src/app/state/hooks/roomSettings.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/app/state/hooks/roomSettings.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										17
									
								
								src/app/state/roomSettings.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/state/roomSettings.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue