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-aria": "3.29.1",
 | 
				
			||||||
        "react-autosize-textarea": "7.1.0",
 | 
					        "react-autosize-textarea": "7.1.0",
 | 
				
			||||||
        "react-blurhash": "0.2.0",
 | 
					        "react-blurhash": "0.2.0",
 | 
				
			||||||
 | 
					        "react-colorful": "5.6.1",
 | 
				
			||||||
        "react-dom": "18.2.0",
 | 
					        "react-dom": "18.2.0",
 | 
				
			||||||
        "react-error-boundary": "4.0.13",
 | 
					        "react-error-boundary": "4.0.13",
 | 
				
			||||||
        "react-google-recaptcha": "2.1.0",
 | 
					        "react-google-recaptcha": "2.1.0",
 | 
				
			||||||
| 
						 | 
					@ -9654,6 +9655,16 @@
 | 
				
			||||||
        "react": ">=15"
 | 
					        "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": {
 | 
					    "node_modules/react-dom": {
 | 
				
			||||||
      "version": "18.2.0",
 | 
					      "version": "18.2.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -65,6 +65,7 @@
 | 
				
			||||||
    "react-aria": "3.29.1",
 | 
					    "react-aria": "3.29.1",
 | 
				
			||||||
    "react-autosize-textarea": "7.1.0",
 | 
					    "react-autosize-textarea": "7.1.0",
 | 
				
			||||||
    "react-blurhash": "0.2.0",
 | 
					    "react-blurhash": "0.2.0",
 | 
				
			||||||
 | 
					    "react-colorful": "5.6.1",
 | 
				
			||||||
    "react-dom": "18.2.0",
 | 
					    "react-dom": "18.2.0",
 | 
				
			||||||
    "react-error-boundary": "4.0.13",
 | 
					    "react-error-boundary": "4.0.13",
 | 
				
			||||||
    "react-google-recaptcha": "2.1.0",
 | 
					    "react-google-recaptcha": "2.1.0",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,4 @@
 | 
				
			||||||
import React, {
 | 
					import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 | 
				
			||||||
  FormEventHandler,
 | 
					 | 
				
			||||||
  KeyboardEventHandler,
 | 
					 | 
				
			||||||
  useCallback,
 | 
					 | 
				
			||||||
  useEffect,
 | 
					 | 
				
			||||||
  useMemo,
 | 
					 | 
				
			||||||
  useRef,
 | 
					 | 
				
			||||||
  useState,
 | 
					 | 
				
			||||||
} from 'react';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Text,
 | 
					  Text,
 | 
				
			||||||
| 
						 | 
					@ -22,22 +14,20 @@ import {
 | 
				
			||||||
  Scroll,
 | 
					  Scroll,
 | 
				
			||||||
  config,
 | 
					  config,
 | 
				
			||||||
} from 'folds';
 | 
					} from 'folds';
 | 
				
			||||||
import { isKeyHotkey } from 'is-hotkey';
 | 
					 | 
				
			||||||
import { MatrixError } from 'matrix-js-sdk';
 | 
					import { MatrixError } from 'matrix-js-sdk';
 | 
				
			||||||
import * as css from './styles.css';
 | 
					import { Cursor } from '../plugins/text-area';
 | 
				
			||||||
import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
 | 
					import { syntaxErrorPosition } from '../utils/dom';
 | 
				
			||||||
import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
 | 
					import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
				
			||||||
import { GetTarget } from '../../../plugins/text-area/type';
 | 
					import { Page, PageHeader } from './page';
 | 
				
			||||||
import { syntaxErrorPosition } from '../../../utils/dom';
 | 
					import { useAlive } from '../hooks/useAlive';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { SequenceCard } from './sequence-card';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { TextViewerContent } from './text-viewer';
 | 
				
			||||||
import { Page, PageHeader } from '../../../components/page';
 | 
					import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
 | 
				
			||||||
import { useAlive } from '../../../hooks/useAlive';
 | 
					 | 
				
			||||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
					 | 
				
			||||||
import { TextViewerContent } from '../../../components/text-viewer';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
 | 
					const EDITOR_INTENT_SPACE_COUNT = 2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type AccountDataInfo = {
 | 
					type AccountDataInfo = {
 | 
				
			||||||
  type: string;
 | 
					  type: string;
 | 
				
			||||||
  content: object;
 | 
					  content: object;
 | 
				
			||||||
| 
						 | 
					@ -46,45 +36,28 @@ type AccountDataInfo = {
 | 
				
			||||||
type AccountDataEditProps = {
 | 
					type AccountDataEditProps = {
 | 
				
			||||||
  type: string;
 | 
					  type: string;
 | 
				
			||||||
  defaultContent: string;
 | 
					  defaultContent: string;
 | 
				
			||||||
 | 
					  submitChange: AccountDataSubmitCallback;
 | 
				
			||||||
  onCancel: () => void;
 | 
					  onCancel: () => void;
 | 
				
			||||||
  onSave: (info: AccountDataInfo) => void;
 | 
					  onSave: (info: AccountDataInfo) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
 | 
					function AccountDataEdit({
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  type,
 | 
				
			||||||
 | 
					  defaultContent,
 | 
				
			||||||
 | 
					  submitChange,
 | 
				
			||||||
 | 
					  onCancel,
 | 
				
			||||||
 | 
					  onSave,
 | 
				
			||||||
 | 
					}: AccountDataEditProps) {
 | 
				
			||||||
  const alive = useAlive();
 | 
					  const alive = useAlive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const textAreaRef = useRef<HTMLTextAreaElement>(null);
 | 
					  const textAreaRef = useRef<HTMLTextAreaElement>(null);
 | 
				
			||||||
  const [jsonError, setJSONError] = useState<SyntaxError>();
 | 
					  const [jsonError, setJSONError] = useState<SyntaxError>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const getTarget: GetTarget = useCallback(() => {
 | 
					  const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
 | 
				
			||||||
    const target = textAreaRef.current;
 | 
					    textAreaRef,
 | 
				
			||||||
    if (!target) throw new Error('TextArea element not found!');
 | 
					    EDITOR_INTENT_SPACE_COUNT
 | 
				
			||||||
    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 [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
 | 
				
			||||||
  const submitting = submitState.status === AsyncStatus.Loading;
 | 
					  const submitting = submitState.status === AsyncStatus.Loading;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
					  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
				
			||||||
| 
						 | 
					@ -140,7 +113,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
				
			||||||
      as="form"
 | 
					      as="form"
 | 
				
			||||||
      onSubmit={handleSubmit}
 | 
					      onSubmit={handleSubmit}
 | 
				
			||||||
      grow="Yes"
 | 
					      grow="Yes"
 | 
				
			||||||
      className={css.EditorContent}
 | 
					      style={{
 | 
				
			||||||
 | 
					        padding: config.space.S400,
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
      direction="Column"
 | 
					      direction="Column"
 | 
				
			||||||
      gap="400"
 | 
					      gap="400"
 | 
				
			||||||
      aria-disabled={submitting}
 | 
					      aria-disabled={submitting}
 | 
				
			||||||
| 
						 | 
					@ -174,6 +149,7 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
				
			||||||
            fill="Soft"
 | 
					            fill="Soft"
 | 
				
			||||||
            size="400"
 | 
					            size="400"
 | 
				
			||||||
            radii="300"
 | 
					            radii="300"
 | 
				
			||||||
 | 
					            type="button"
 | 
				
			||||||
            onClick={onCancel}
 | 
					            onClick={onCancel}
 | 
				
			||||||
            disabled={submitting}
 | 
					            disabled={submitting}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
| 
						 | 
					@ -194,7 +170,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData
 | 
				
			||||||
        <TextAreaComponent
 | 
					        <TextAreaComponent
 | 
				
			||||||
          ref={textAreaRef}
 | 
					          ref={textAreaRef}
 | 
				
			||||||
          name="contentTextArea"
 | 
					          name="contentTextArea"
 | 
				
			||||||
          className={css.EditorTextArea}
 | 
					          style={{
 | 
				
			||||||
 | 
					            fontFamily: 'monospace',
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
          onKeyDown={handleKeyDown}
 | 
					          onKeyDown={handleKeyDown}
 | 
				
			||||||
          defaultValue={defaultContent}
 | 
					          defaultValue={defaultContent}
 | 
				
			||||||
          resize="None"
 | 
					          resize="None"
 | 
				
			||||||
| 
						 | 
					@ -221,7 +199,13 @@ type AccountDataViewProps = {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
 | 
					function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
 | 
				
			||||||
  return (
 | 
					  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 shrink="No" gap="300" alignItems="End">
 | 
				
			||||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
					        <Box grow="Yes" direction="Column" gap="100">
 | 
				
			||||||
          <Text size="L400">Account Data</Text>
 | 
					          <Text size="L400">Account Data</Text>
 | 
				
			||||||
| 
						 | 
					@ -259,15 +243,20 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type AccountDataEditorProps = {
 | 
					export type AccountDataEditorProps = {
 | 
				
			||||||
  type?: string;
 | 
					  type?: string;
 | 
				
			||||||
 | 
					  content?: object;
 | 
				
			||||||
 | 
					  submitChange: AccountDataSubmitCallback;
 | 
				
			||||||
  requestClose: () => void;
 | 
					  requestClose: () => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
 | 
					export function AccountDataEditor({
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  type,
 | 
				
			||||||
 | 
					  content,
 | 
				
			||||||
 | 
					  submitChange,
 | 
				
			||||||
 | 
					  requestClose,
 | 
				
			||||||
 | 
					}: AccountDataEditorProps) {
 | 
				
			||||||
  const [data, setData] = useState<AccountDataInfo>({
 | 
					  const [data, setData] = useState<AccountDataInfo>({
 | 
				
			||||||
    type: type ?? '',
 | 
					    type: type ?? '',
 | 
				
			||||||
    content: mx.getAccountData(type ?? '')?.getContent() ?? {},
 | 
					    content: content ?? {},
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [edit, setEdit] = useState(!type);
 | 
					  const [edit, setEdit] = useState(!type);
 | 
				
			||||||
| 
						 | 
					@ -316,6 +305,7 @@ export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps
 | 
				
			||||||
          <AccountDataEdit
 | 
					          <AccountDataEdit
 | 
				
			||||||
            type={data.type}
 | 
					            type={data.type}
 | 
				
			||||||
            defaultContent={contentJSONStr}
 | 
					            defaultContent={contentJSONStr}
 | 
				
			||||||
 | 
					            submitChange={submitChange}
 | 
				
			||||||
            onCancel={closeEdit}
 | 
					            onCancel={closeEdit}
 | 
				
			||||||
            onSave={handleSave}
 | 
					            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,
 | 
					  onCustomEmojiSelect,
 | 
				
			||||||
  onStickerSelect,
 | 
					  onStickerSelect,
 | 
				
			||||||
  allowTextCustomEmoji,
 | 
					  allowTextCustomEmoji,
 | 
				
			||||||
 | 
					  addToRecentEmoji = true,
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  tab?: EmojiBoardTab;
 | 
					  tab?: EmojiBoardTab;
 | 
				
			||||||
  onTabChange?: (tab: EmojiBoardTab) => void;
 | 
					  onTabChange?: (tab: EmojiBoardTab) => void;
 | 
				
			||||||
| 
						 | 
					@ -664,6 +665,7 @@ export function EmojiBoard({
 | 
				
			||||||
  onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
 | 
					  onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
 | 
				
			||||||
  onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
 | 
					  onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
 | 
				
			||||||
  allowTextCustomEmoji?: boolean;
 | 
					  allowTextCustomEmoji?: boolean;
 | 
				
			||||||
 | 
					  addToRecentEmoji?: boolean;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const emojiTab = tab === EmojiBoardTab.Emoji;
 | 
					  const emojiTab = tab === EmojiBoardTab.Emoji;
 | 
				
			||||||
  const stickerTab = tab === EmojiBoardTab.Sticker;
 | 
					  const stickerTab = tab === EmojiBoardTab.Sticker;
 | 
				
			||||||
| 
						 | 
					@ -735,7 +737,9 @@ export function EmojiBoard({
 | 
				
			||||||
    if (emojiInfo.type === EmojiType.Emoji) {
 | 
					    if (emojiInfo.type === EmojiType.Emoji) {
 | 
				
			||||||
      onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
 | 
					      onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
 | 
				
			||||||
      if (!evt.altKey && !evt.shiftKey) {
 | 
					      if (!evt.altKey && !evt.shiftKey) {
 | 
				
			||||||
        addRecentEmoji(mx, emojiInfo.data);
 | 
					        if (addToRecentEmoji) {
 | 
				
			||||||
 | 
					          addRecentEmoji(mx, emojiInfo.data);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        requestClose();
 | 
					        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 { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 | 
				
			||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
 | 
					import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
 | 
				
			||||||
import {
 | 
					import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
 | 
				
			||||||
  openInviteUser,
 | 
					 | 
				
			||||||
  openSpaceSettings,
 | 
					 | 
				
			||||||
  toggleRoomSettings,
 | 
					 | 
				
			||||||
} from '../../../client/action/navigation';
 | 
					 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
				
			||||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
					import { UseStateProvider } from '../../components/UseStateProvider';
 | 
				
			||||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 | 
					import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 | 
				
			||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
					import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
				
			||||||
import { stopPropagation } from '../../utils/keyboard';
 | 
					import { stopPropagation } from '../../utils/keyboard';
 | 
				
			||||||
 | 
					import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 | 
				
			||||||
 | 
					import { useSpaceOptionally } from '../../hooks/useSpace';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type HierarchyItemWithParent = HierarchyItem & {
 | 
					type HierarchyItemWithParent = HierarchyItem & {
 | 
				
			||||||
  parentId: string;
 | 
					  parentId: string;
 | 
				
			||||||
| 
						 | 
					@ -154,11 +152,14 @@ function SettingsMenuItem({
 | 
				
			||||||
  requestClose: () => void;
 | 
					  requestClose: () => void;
 | 
				
			||||||
  disabled?: boolean;
 | 
					  disabled?: boolean;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
 | 
					  const openRoomSettings = useOpenRoomSettings();
 | 
				
			||||||
 | 
					  const space = useSpaceOptionally();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleSettings = () => {
 | 
					  const handleSettings = () => {
 | 
				
			||||||
    if ('space' in item) {
 | 
					    if ('space' in item) {
 | 
				
			||||||
      openSpaceSettings(item.roomId);
 | 
					      openSpaceSettings(item.roomId);
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      toggleRoomSettings(item.roomId);
 | 
					      openRoomSettings(item.roomId, space?.roomId);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    requestClose();
 | 
					    requestClose();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -29,7 +29,7 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 | 
				
			||||||
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 | 
					import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 | 
				
			||||||
import { copyToClipboard } from '../../utils/dom';
 | 
					import { copyToClipboard } from '../../utils/dom';
 | 
				
			||||||
import { markAsRead } from '../../../client/action/notifications';
 | 
					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 { UseStateProvider } from '../../components/UseStateProvider';
 | 
				
			||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
					import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
				
			||||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
 | 
					import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
 | 
				
			||||||
| 
						 | 
					@ -41,6 +41,8 @@ import { getViaServers } from '../../plugins/via-servers';
 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
import { useSetting } from '../../state/hooks/settings';
 | 
					import { useSetting } from '../../state/hooks/settings';
 | 
				
			||||||
import { settingsAtom } from '../../state/settings';
 | 
					import { settingsAtom } from '../../state/settings';
 | 
				
			||||||
 | 
					import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 | 
				
			||||||
 | 
					import { useSpaceOptionally } from '../../hooks/useSpace';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type RoomNavItemMenuProps = {
 | 
					type RoomNavItemMenuProps = {
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
| 
						 | 
					@ -54,6 +56,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
				
			||||||
    const powerLevels = usePowerLevels(room);
 | 
					    const powerLevels = usePowerLevels(room);
 | 
				
			||||||
    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
 | 
					    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
 | 
				
			||||||
    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
					    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
				
			||||||
 | 
					    const openRoomSettings = useOpenRoomSettings();
 | 
				
			||||||
 | 
					    const space = useSpaceOptionally();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleMarkAsRead = () => {
 | 
					    const handleMarkAsRead = () => {
 | 
				
			||||||
      markAsRead(mx, room.roomId, hideActivity);
 | 
					      markAsRead(mx, room.roomId, hideActivity);
 | 
				
			||||||
| 
						 | 
					@ -73,7 +77,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleRoomSettings = () => {
 | 
					    const handleRoomSettings = () => {
 | 
				
			||||||
      toggleRoomSettings(room.roomId);
 | 
					      openRoomSettings(room.roomId, space?.roomId);
 | 
				
			||||||
      requestClose();
 | 
					      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,
 | 
					  Badge,
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Chip,
 | 
					  Chip,
 | 
				
			||||||
  ContainerColor,
 | 
					 | 
				
			||||||
  Header,
 | 
					  Header,
 | 
				
			||||||
  Icon,
 | 
					  Icon,
 | 
				
			||||||
  IconButton,
 | 
					  IconButton,
 | 
				
			||||||
  Icons,
 | 
					  Icons,
 | 
				
			||||||
  Input,
 | 
					  Input,
 | 
				
			||||||
  Menu,
 | 
					 | 
				
			||||||
  MenuItem,
 | 
					  MenuItem,
 | 
				
			||||||
  PopOut,
 | 
					  PopOut,
 | 
				
			||||||
  RectCords,
 | 
					  RectCords,
 | 
				
			||||||
| 
						 | 
					@ -30,13 +28,11 @@ import {
 | 
				
			||||||
} from 'folds';
 | 
					} from 'folds';
 | 
				
			||||||
import { Room, RoomMember } from 'matrix-js-sdk';
 | 
					import { Room, RoomMember } from 'matrix-js-sdk';
 | 
				
			||||||
import { useVirtualizer } from '@tanstack/react-virtual';
 | 
					import { useVirtualizer } from '@tanstack/react-virtual';
 | 
				
			||||||
import FocusTrap from 'focus-trap-react';
 | 
					 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { openProfileViewer } from '../../../client/action/navigation';
 | 
					import { openProfileViewer } from '../../../client/action/navigation';
 | 
				
			||||||
import * as css from './MembersDrawer.css';
 | 
					import * as css from './MembersDrawer.css';
 | 
				
			||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { Membership } from '../../../types/matrix/room';
 | 
					 | 
				
			||||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
					import { UseStateProvider } from '../../components/UseStateProvider';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  SearchItemStrGetter,
 | 
					  SearchItemStrGetter,
 | 
				
			||||||
| 
						 | 
					@ -44,7 +40,7 @@ import {
 | 
				
			||||||
  useAsyncSearch,
 | 
					  useAsyncSearch,
 | 
				
			||||||
} from '../../hooks/useAsyncSearch';
 | 
					} from '../../hooks/useAsyncSearch';
 | 
				
			||||||
import { useDebounce } from '../../hooks/useDebounce';
 | 
					import { useDebounce } from '../../hooks/useDebounce';
 | 
				
			||||||
import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
 | 
					import { usePowerLevelTags, useFlattenPowerLevelTagMembers } from '../../hooks/usePowerLevelTags';
 | 
				
			||||||
import { TypingIndicator } from '../../components/typing-indicator';
 | 
					import { TypingIndicator } from '../../components/typing-indicator';
 | 
				
			||||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
 | 
					import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
 | 
				
			||||||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
					import { getMxIdLocalPart } from '../../utils/matrix';
 | 
				
			||||||
| 
						 | 
					@ -54,106 +50,12 @@ import { millify } from '../../plugins/millify';
 | 
				
			||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
 | 
					import { ScrollTopContainer } from '../../components/scroll-top-container';
 | 
				
			||||||
import { UserAvatar } from '../../components/user-avatar';
 | 
					import { UserAvatar } from '../../components/user-avatar';
 | 
				
			||||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
 | 
					import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
 | 
				
			||||||
import { stopPropagation } from '../../utils/keyboard';
 | 
					 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
 | 
				
			||||||
export const MembershipFilters = {
 | 
					import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
 | 
				
			||||||
  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
 | 
					import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
 | 
				
			||||||
  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
 | 
					import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
 | 
				
			||||||
  filterLeaved: (m: RoomMember) =>
 | 
					import { MemberSortMenu } from '../../components/MemberSortMenu';
 | 
				
			||||||
    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;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
 | 
					const SEARCH_OPTIONS: UseAsyncSearchOptions = {
 | 
				
			||||||
  limit: 1000,
 | 
					  limit: 1000,
 | 
				
			||||||
| 
						 | 
					@ -176,17 +78,19 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
  const scrollRef = useRef<HTMLDivElement>(null);
 | 
					  const scrollRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
  const searchInputRef = useRef<HTMLInputElement>(null);
 | 
					  const searchInputRef = useRef<HTMLInputElement>(null);
 | 
				
			||||||
  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
 | 
					  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
  const getPowerLevelTag = usePowerLevelTags();
 | 
					  const powerLevels = usePowerLevelsContext();
 | 
				
			||||||
 | 
					  const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
 | 
				
			||||||
  const fetchingMembers = members.length < room.getJoinedMemberCount();
 | 
					  const fetchingMembers = members.length < room.getJoinedMemberCount();
 | 
				
			||||||
  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
 | 
					  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const membershipFilterMenu = useMembershipFilterMenu();
 | 
					  const membershipFilterMenu = useMembershipFilterMenu();
 | 
				
			||||||
  const sortFilterMenu = useSortFilterMenu();
 | 
					  const sortFilterMenu = useMemberSortMenu();
 | 
				
			||||||
  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
 | 
					  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
 | 
				
			||||||
  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
 | 
					  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
 | 
				
			||||||
 | 
					  const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
 | 
					  const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
 | 
				
			||||||
  const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
 | 
					  const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const typingMembers = useRoomTypingMember(room.roomId);
 | 
					  const typingMembers = useRoomTypingMember(room.roomId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -194,9 +98,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
    () =>
 | 
					    () =>
 | 
				
			||||||
      members
 | 
					      members
 | 
				
			||||||
        .filter(membershipFilter.filterFn)
 | 
					        .filter(membershipFilter.filterFn)
 | 
				
			||||||
        .sort(sortFilter.filterFn)
 | 
					        .sort(memberSort.sortFn)
 | 
				
			||||||
        .sort((a, b) => b.powerLevel - a.powerLevel),
 | 
					        .sort((a, b) => b.powerLevel - a.powerLevel),
 | 
				
			||||||
    [members, membershipFilter, sortFilter]
 | 
					    [members, membershipFilter, memberSort]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [result, search, resetSearch] = useAsyncSearch(
 | 
					  const [result, search, resetSearch] = useAsyncSearch(
 | 
				
			||||||
| 
						 | 
					@ -208,19 +112,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const processMembers = result ? result.items : filteredMembers;
 | 
					  const processMembers = result ? result.items : filteredMembers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const PLTagOrRoomMember = useMemo(() => {
 | 
					  const PLTagOrRoomMember = useFlattenPowerLevelTagMembers(
 | 
				
			||||||
    let prevTag: PowerLevelTag | undefined;
 | 
					    processMembers,
 | 
				
			||||||
    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
 | 
					    getPowerLevel,
 | 
				
			||||||
    processMembers.forEach((m) => {
 | 
					    getPowerLevelTag
 | 
				
			||||||
      const plTag = getPowerLevelTag(m.powerLevel);
 | 
					  );
 | 
				
			||||||
      if (plTag !== prevTag) {
 | 
					 | 
				
			||||||
        prevTag = plTag;
 | 
					 | 
				
			||||||
        tagOrMember.push(plTag);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      tagOrMember.push(m);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    return tagOrMember;
 | 
					 | 
				
			||||||
  }, [processMembers, getPowerLevelTag]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const virtualizer = useVirtualizer({
 | 
					  const virtualizer = useVirtualizer({
 | 
				
			||||||
    count: PLTagOrRoomMember.length,
 | 
					    count: PLTagOrRoomMember.length,
 | 
				
			||||||
| 
						 | 
					@ -295,38 +191,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
                      align="Start"
 | 
					                      align="Start"
 | 
				
			||||||
                      offset={4}
 | 
					                      offset={4}
 | 
				
			||||||
                      content={
 | 
					                      content={
 | 
				
			||||||
                        <FocusTrap
 | 
					                        <MembershipFilterMenu
 | 
				
			||||||
                          focusTrapOptions={{
 | 
					                          selected={membershipFilterIndex}
 | 
				
			||||||
                            initialFocus: false,
 | 
					                          onSelect={setMembershipFilterIndex}
 | 
				
			||||||
                            onDeactivate: () => setAnchor(undefined),
 | 
					                          requestClose={() => 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>
 | 
					 | 
				
			||||||
                      }
 | 
					                      }
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      <Chip
 | 
					                      <Chip
 | 
				
			||||||
| 
						 | 
					@ -336,7 +205,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
                              evt.currentTarget.getBoundingClientRect()
 | 
					                              evt.currentTarget.getBoundingClientRect()
 | 
				
			||||||
                            )) as MouseEventHandler<HTMLButtonElement>
 | 
					                            )) as MouseEventHandler<HTMLButtonElement>
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        variant={membershipFilter.color}
 | 
					                        variant="Background"
 | 
				
			||||||
                        size="400"
 | 
					                        size="400"
 | 
				
			||||||
                        radii="300"
 | 
					                        radii="300"
 | 
				
			||||||
                        before={<Icon src={Icons.Filter} size="50" />}
 | 
					                        before={<Icon src={Icons.Filter} size="50" />}
 | 
				
			||||||
| 
						 | 
					@ -354,34 +223,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
                      align="End"
 | 
					                      align="End"
 | 
				
			||||||
                      offset={4}
 | 
					                      offset={4}
 | 
				
			||||||
                      content={
 | 
					                      content={
 | 
				
			||||||
                        <FocusTrap
 | 
					                        <MemberSortMenu
 | 
				
			||||||
                          focusTrapOptions={{
 | 
					                          selected={sortFilterIndex}
 | 
				
			||||||
                            initialFocus: false,
 | 
					                          onSelect={setSortFilterIndex}
 | 
				
			||||||
                            onDeactivate: () => setAnchor(undefined),
 | 
					                          requestClose={() => 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>
 | 
					 | 
				
			||||||
                      }
 | 
					                      }
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      <Chip
 | 
					                      <Chip
 | 
				
			||||||
| 
						 | 
					@ -396,7 +242,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
 | 
				
			||||||
                        radii="300"
 | 
					                        radii="300"
 | 
				
			||||||
                        after={<Icon src={Icons.Sort} size="50" />}
 | 
					                        after={<Icon src={Icons.Sort} size="50" />}
 | 
				
			||||||
                      >
 | 
					                      >
 | 
				
			||||||
                        <Text size="T200">{sortFilter.name}</Text>
 | 
					                        <Text size="T200">{memberSort.name}</Text>
 | 
				
			||||||
                      </Chip>
 | 
					                      </Chip>
 | 
				
			||||||
                    </PopOut>
 | 
					                    </PopOut>
 | 
				
			||||||
                  )}
 | 
					                  )}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,7 +4,6 @@ import React, {
 | 
				
			||||||
  forwardRef,
 | 
					  forwardRef,
 | 
				
			||||||
  useCallback,
 | 
					  useCallback,
 | 
				
			||||||
  useEffect,
 | 
					  useEffect,
 | 
				
			||||||
  useMemo,
 | 
					 | 
				
			||||||
  useRef,
 | 
					  useRef,
 | 
				
			||||||
  useState,
 | 
					  useState,
 | 
				
			||||||
} from 'react';
 | 
					} from 'react';
 | 
				
			||||||
| 
						 | 
					@ -101,12 +100,7 @@ import {
 | 
				
			||||||
  getVideoMsgContent,
 | 
					  getVideoMsgContent,
 | 
				
			||||||
} from './msgContent';
 | 
					} from './msgContent';
 | 
				
			||||||
import colorMXID from '../../../util/colorMXID';
 | 
					import colorMXID from '../../../util/colorMXID';
 | 
				
			||||||
import {
 | 
					import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
 | 
				
			||||||
  getAllParents,
 | 
					 | 
				
			||||||
  getMemberDisplayName,
 | 
					 | 
				
			||||||
  getMentionContent,
 | 
					 | 
				
			||||||
  trimReplyFromBody,
 | 
					 | 
				
			||||||
} from '../../utils/room';
 | 
					 | 
				
			||||||
import { CommandAutocomplete } from './CommandAutocomplete';
 | 
					import { CommandAutocomplete } from './CommandAutocomplete';
 | 
				
			||||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
 | 
					import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
 | 
				
			||||||
import { mobileOrTablet } from '../../utils/user-agent';
 | 
					import { mobileOrTablet } from '../../utils/user-agent';
 | 
				
			||||||
| 
						 | 
					@ -114,6 +108,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
 | 
				
			||||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
 | 
					import { ReplyLayout, ThreadIndicator } from '../../components/message';
 | 
				
			||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
					import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { useImagePackRooms } from '../../hooks/useImagePackRooms';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface RoomInputProps {
 | 
					interface RoomInputProps {
 | 
				
			||||||
  editor: Editor;
 | 
					  editor: Editor;
 | 
				
			||||||
| 
						 | 
					@ -142,14 +137,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
 | 
					    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const imagePackRooms: Room[] = useMemo(() => {
 | 
					    const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
 | 
				
			||||||
      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 [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
 | 
					    const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
 | 
				
			||||||
    const [autocompleteQuery, setAutocompleteQuery] =
 | 
					    const [autocompleteQuery, setAutocompleteQuery] =
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -75,7 +75,6 @@ import {
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  canEditEvent,
 | 
					  canEditEvent,
 | 
				
			||||||
  decryptAllTimelineEvent,
 | 
					  decryptAllTimelineEvent,
 | 
				
			||||||
  getAllParents,
 | 
					 | 
				
			||||||
  getEditedEvent,
 | 
					  getEditedEvent,
 | 
				
			||||||
  getEventReactions,
 | 
					  getEventReactions,
 | 
				
			||||||
  getLatestEditableEvt,
 | 
					  getLatestEditableEvt,
 | 
				
			||||||
| 
						 | 
					@ -118,6 +117,7 @@ import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
 | 
				
			||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
					import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 | 
					import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 | 
				
			||||||
 | 
					import { useImagePackRooms } from '../../hooks/useImagePackRooms';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
 | 
					const TimelineFloat = as<'div', css.TimelineFloatVariants>(
 | 
				
			||||||
  ({ position, className, ...props }, ref) => (
 | 
					  ({ position, className, ...props }, ref) => (
 | 
				
			||||||
| 
						 | 
					@ -454,16 +454,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
  const mentionClickHandler = useMentionClickHandler(room.roomId);
 | 
					  const mentionClickHandler = useMentionClickHandler(room.roomId);
 | 
				
			||||||
  const spoilerClickHandler = useSpoilerClickHandler();
 | 
					  const spoilerClickHandler = useSpoilerClickHandler();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const imagePackRooms: Room[] = useMemo(() => {
 | 
					  const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
 | 
				
			||||||
    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 [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
 | 
					  const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
 | 
				
			||||||
  const readUptoEventIdRef = useRef<string>();
 | 
					  const readUptoEventIdRef = useRef<string>();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -44,7 +44,7 @@ import { useRoomUnread } from '../../state/hooks/unread';
 | 
				
			||||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
 | 
					import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
 | 
				
			||||||
import { markAsRead } from '../../../client/action/notifications';
 | 
					import { markAsRead } from '../../../client/action/notifications';
 | 
				
			||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 | 
					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 { copyToClipboard } from '../../utils/dom';
 | 
				
			||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
					import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
				
			||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 | 
					import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 | 
				
			||||||
| 
						 | 
					@ -57,6 +57,7 @@ import { BackRouteHandler } from '../../components/BackRouteHandler';
 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
 | 
					import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
 | 
				
			||||||
import { RoomPinMenu } from './room-pin-menu';
 | 
					import { RoomPinMenu } from './room-pin-menu';
 | 
				
			||||||
 | 
					import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type RoomMenuProps = {
 | 
					type RoomMenuProps = {
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
| 
						 | 
					@ -87,8 +88,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
				
			||||||
    requestClose();
 | 
					    requestClose();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleRoomSettings = () => {
 | 
					  const openSettings = useOpenRoomSettings();
 | 
				
			||||||
    toggleRoomSettings(room.roomId);
 | 
					  const parentSpace = useSpaceOptionally();
 | 
				
			||||||
 | 
					  const handleOpenSettings = () => {
 | 
				
			||||||
 | 
					    openSettings(room.roomId, parentSpace?.roomId);
 | 
				
			||||||
    requestClose();
 | 
					    requestClose();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -133,7 +136,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
				
			||||||
          </Text>
 | 
					          </Text>
 | 
				
			||||||
        </MenuItem>
 | 
					        </MenuItem>
 | 
				
			||||||
        <MenuItem
 | 
					        <MenuItem
 | 
				
			||||||
          onClick={handleRoomSettings}
 | 
					          onClick={handleOpenSettings}
 | 
				
			||||||
          size="300"
 | 
					          size="300"
 | 
				
			||||||
          after={<Icon size="100" src={Icons.Setting} />}
 | 
					          after={<Icon size="100" src={Icons.Setting} />}
 | 
				
			||||||
          radii="300"
 | 
					          radii="300"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,10 +1,11 @@
 | 
				
			||||||
import React, { useCallback, useState } from 'react';
 | 
					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 { SequenceCard } from '../../../components/sequence-card';
 | 
				
			||||||
import { SequenceCardStyle } from '../styles.css';
 | 
					import { SequenceCardStyle } from '../styles.css';
 | 
				
			||||||
import { SettingTile } from '../../../components/setting-tile';
 | 
					import { SettingTile } from '../../../components/setting-tile';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
 | 
					import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
 | 
				
			||||||
 | 
					import { CutoutCard } from '../../../components/cutout-card';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type AccountDataProps = {
 | 
					type AccountDataProps = {
 | 
				
			||||||
  expand: boolean;
 | 
					  expand: boolean;
 | 
				
			||||||
| 
						 | 
					@ -13,14 +14,15 @@ type AccountDataProps = {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
 | 
					export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
 | 
					  const [accountDataTypes, setAccountDataKeys] = useState(() =>
 | 
				
			||||||
 | 
					    Array.from(mx.store.accountData.keys())
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useAccountDataCallback(
 | 
					  useAccountDataCallback(
 | 
				
			||||||
    mx,
 | 
					    mx,
 | 
				
			||||||
    useCallback(
 | 
					    useCallback(() => {
 | 
				
			||||||
      () => setAccountData(Array.from(mx.store.accountData.values())),
 | 
					      setAccountDataKeys(Array.from(mx.store.accountData.keys()));
 | 
				
			||||||
      [mx, setAccountData]
 | 
					    }, [mx])
 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -52,37 +54,45 @@ export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataPro
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        {expand && (
 | 
					        {expand && (
 | 
				
			||||||
          <SettingTile>
 | 
					          <Box direction="Column" gap="100">
 | 
				
			||||||
            <Box direction="Column" gap="200">
 | 
					            <Box justifyContent="SpaceBetween">
 | 
				
			||||||
              <Text size="L400">Types</Text>
 | 
					              <Text size="L400">Events</Text>
 | 
				
			||||||
              <Box gap="200" wrap="Wrap">
 | 
					              <Text size="L400">Total: {accountDataTypes.length}</Text>
 | 
				
			||||||
                <Chip
 | 
					            </Box>
 | 
				
			||||||
                  variant="Secondary"
 | 
					            <CutoutCard>
 | 
				
			||||||
                  fill="Soft"
 | 
					              <MenuItem
 | 
				
			||||||
                  radii="Pill"
 | 
					                variant="Surface"
 | 
				
			||||||
                  before={<Icon size="50" src={Icons.Plus} />}
 | 
					                fill="None"
 | 
				
			||||||
                  onClick={() => onSelect(null)}
 | 
					                size="300"
 | 
				
			||||||
                >
 | 
					                radii="0"
 | 
				
			||||||
 | 
					                before={<Icon size="50" src={Icons.Plus} />}
 | 
				
			||||||
 | 
					                onClick={() => onSelect(null)}
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <Box grow="Yes">
 | 
				
			||||||
                  <Text size="T200" truncate>
 | 
					                  <Text size="T200" truncate>
 | 
				
			||||||
                    Add New
 | 
					                    Add New
 | 
				
			||||||
                  </Text>
 | 
					                  </Text>
 | 
				
			||||||
                </Chip>
 | 
					                </Box>
 | 
				
			||||||
                {accountData.map((mEvent) => (
 | 
					              </MenuItem>
 | 
				
			||||||
                  <Chip
 | 
					              {accountDataTypes.sort().map((type) => (
 | 
				
			||||||
                    key={mEvent.getType()}
 | 
					                <MenuItem
 | 
				
			||||||
                    variant="Secondary"
 | 
					                  key={type}
 | 
				
			||||||
                    fill="Soft"
 | 
					                  variant="Surface"
 | 
				
			||||||
                    radii="Pill"
 | 
					                  fill="None"
 | 
				
			||||||
                    onClick={() => onSelect(mEvent.getType())}
 | 
					                  size="300"
 | 
				
			||||||
                  >
 | 
					                  radii="0"
 | 
				
			||||||
 | 
					                  after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
				
			||||||
 | 
					                  onClick={() => onSelect(type)}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  <Box grow="Yes">
 | 
				
			||||||
                    <Text size="T200" truncate>
 | 
					                    <Text size="T200" truncate>
 | 
				
			||||||
                      {mEvent.getType()}
 | 
					                      {type}
 | 
				
			||||||
                    </Text>
 | 
					                    </Text>
 | 
				
			||||||
                  </Chip>
 | 
					                  </Box>
 | 
				
			||||||
                ))}
 | 
					                </MenuItem>
 | 
				
			||||||
              </Box>
 | 
					              ))}
 | 
				
			||||||
            </Box>
 | 
					            </CutoutCard>
 | 
				
			||||||
          </SettingTile>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
      </SequenceCard>
 | 
					      </SequenceCard>
 | 
				
			||||||
    </Box>
 | 
					    </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 { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
 | 
				
			||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
					import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
				
			||||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
					import { SequenceCard } from '../../../components/sequence-card';
 | 
				
			||||||
| 
						 | 
					@ -7,7 +7,10 @@ import { SettingTile } from '../../../components/setting-tile';
 | 
				
			||||||
import { useSetting } from '../../../state/hooks/settings';
 | 
					import { useSetting } from '../../../state/hooks/settings';
 | 
				
			||||||
import { settingsAtom } from '../../../state/settings';
 | 
					import { settingsAtom } from '../../../state/settings';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { AccountDataEditor } from './AccountDataEditor';
 | 
					import {
 | 
				
			||||||
 | 
					  AccountDataEditor,
 | 
				
			||||||
 | 
					  AccountDataSubmitCallback,
 | 
				
			||||||
 | 
					} from '../../../components/AccountDataEditor';
 | 
				
			||||||
import { copyToClipboard } from '../../../utils/dom';
 | 
					import { copyToClipboard } from '../../../utils/dom';
 | 
				
			||||||
import { AccountData } from './AccountData';
 | 
					import { AccountData } from './AccountData';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,10 +23,19 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
 | 
				
			||||||
  const [expand, setExpend] = useState(false);
 | 
					  const [expand, setExpend] = useState(false);
 | 
				
			||||||
  const [accountDataType, setAccountDataType] = useState<string | null>();
 | 
					  const [accountDataType, setAccountDataType] = useState<string | null>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const submitAccountData: AccountDataSubmitCallback = useCallback(
 | 
				
			||||||
 | 
					    async (type, content) => {
 | 
				
			||||||
 | 
					      await mx.setAccountData(type, content);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [mx]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (accountDataType !== undefined) {
 | 
					  if (accountDataType !== undefined) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <AccountDataEditor
 | 
					      <AccountDataEditor
 | 
				
			||||||
        type={accountDataType ?? undefined}
 | 
					        type={accountDataType ?? undefined}
 | 
				
			||||||
 | 
					        content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
 | 
				
			||||||
 | 
					        submitChange={submitAccountData}
 | 
				
			||||||
        requestClose={() => setAccountDataType(undefined)}
 | 
					        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 { 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 = {
 | 
					export type PowerLevelTag = {
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
 | 
					  color?: string;
 | 
				
			||||||
 | 
					  icon?: PowerLevelTagIcon;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export const usePowerLevelTags = () => {
 | 
					 | 
				
			||||||
  const powerLevelTags = useMemo(
 | 
					 | 
				
			||||||
    () => ({
 | 
					 | 
				
			||||||
      9000: {
 | 
					 | 
				
			||||||
        name: 'Goku',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      101: {
 | 
					 | 
				
			||||||
        name: 'Founder',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      100: {
 | 
					 | 
				
			||||||
        name: 'Admin',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      50: {
 | 
					 | 
				
			||||||
        name: 'Moderator',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      0: {
 | 
					 | 
				
			||||||
        name: 'Default',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    }),
 | 
					 | 
				
			||||||
    []
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return useCallback(
 | 
					export type PowerLevelTags = Record<number, PowerLevelTag>;
 | 
				
			||||||
    (powerLevel: number): PowerLevelTag => {
 | 
					
 | 
				
			||||||
      if (powerLevel >= 9000) return powerLevelTags[9000];
 | 
					export const powerSortFn = (a: number, b: number) => b - a;
 | 
				
			||||||
      if (powerLevel >= 101) return powerLevelTags[101];
 | 
					export const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
 | 
				
			||||||
      if (powerLevel === 100) return powerLevelTags[100];
 | 
					
 | 
				
			||||||
      if (powerLevel >= 50) return powerLevelTags[50];
 | 
					export const getPowers = (tags: PowerLevelTags): number[] => {
 | 
				
			||||||
      return powerLevelTags[0];
 | 
					  const powers: number[] = Object.keys(tags).map((p) => parseInt(p, 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return sortPowers(powers);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getUsedPowers = (powerLevels: IPowerLevels): Set<number> => {
 | 
				
			||||||
 | 
					  const powers: Set<number> = new Set();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const findAndAddPower = (data: Record<string, unknown>) => {
 | 
				
			||||||
 | 
					    Object.keys(data).forEach((key) => {
 | 
				
			||||||
 | 
					      const powerOrAny: unknown = data[key];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (typeof powerOrAny === 'number') {
 | 
				
			||||||
 | 
					        powers.add(powerOrAny);
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (powerOrAny && typeof powerOrAny === 'object') {
 | 
				
			||||||
 | 
					        findAndAddPower(powerOrAny as Record<string, unknown>);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  findAndAddPower(powerLevels);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return powers;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const DEFAULT_TAGS: PowerLevelTags = {
 | 
				
			||||||
 | 
					  9001: {
 | 
				
			||||||
 | 
					    name: 'Goku',
 | 
				
			||||||
 | 
					    color: '#ff6a00',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  102: {
 | 
				
			||||||
 | 
					    name: 'Goku Reborn',
 | 
				
			||||||
 | 
					    color: '#ff6a7f',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  101: {
 | 
				
			||||||
 | 
					    name: 'Founder',
 | 
				
			||||||
 | 
					    color: '#0000ff',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  100: {
 | 
				
			||||||
 | 
					    name: 'Admin',
 | 
				
			||||||
 | 
					    color: '#a000e4',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  50: {
 | 
				
			||||||
 | 
					    name: 'Moderator',
 | 
				
			||||||
 | 
					    color: '#1fd81f',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  0: {
 | 
				
			||||||
 | 
					    name: 'Member',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  [-1]: {
 | 
				
			||||||
 | 
					    name: 'Muted',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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]
 | 
					    [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 { MatrixEvent, Room } from 'matrix-js-sdk';
 | 
				
			||||||
import { createContext, useCallback, useContext, useMemo } from 'react';
 | 
					import { createContext, useCallback, useContext, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import produce from 'immer';
 | 
				
			||||||
import { useStateEvent } from './useStateEvent';
 | 
					import { useStateEvent } from './useStateEvent';
 | 
				
			||||||
import { StateEvent } from '../../types/matrix/room';
 | 
					import { StateEvent } from '../../types/matrix/room';
 | 
				
			||||||
import { useForceUpdate } from './useForceUpdate';
 | 
					 | 
				
			||||||
import { useStateEventCallback } from './useStateEventCallback';
 | 
					import { useStateEventCallback } from './useStateEventCallback';
 | 
				
			||||||
import { useMatrixClient } from './useMatrixClient';
 | 
					import { useMatrixClient } from './useMatrixClient';
 | 
				
			||||||
import { getStateEvent } from '../utils/room';
 | 
					import { getStateEvent } from '../utils/room';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
 | 
					export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
 | 
				
			||||||
 | 
					export type PowerLevelNotificationsAction = 'room';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
enum DefaultPowerLevels {
 | 
					export type IPowerLevels = {
 | 
				
			||||||
  usersDefault = 0,
 | 
					 | 
				
			||||||
  stateDefault = 50,
 | 
					 | 
				
			||||||
  eventsDefault = 0,
 | 
					 | 
				
			||||||
  invite = 0,
 | 
					 | 
				
			||||||
  redact = 50,
 | 
					 | 
				
			||||||
  kick = 50,
 | 
					 | 
				
			||||||
  ban = 50,
 | 
					 | 
				
			||||||
  historical = 0,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface IPowerLevels {
 | 
					 | 
				
			||||||
  users_default?: number;
 | 
					  users_default?: number;
 | 
				
			||||||
  state_default?: number;
 | 
					  state_default?: number;
 | 
				
			||||||
  events_default?: number;
 | 
					  events_default?: number;
 | 
				
			||||||
| 
						 | 
					@ -33,12 +23,53 @@ export interface IPowerLevels {
 | 
				
			||||||
  events?: Record<string, number>;
 | 
					  events?: Record<string, number>;
 | 
				
			||||||
  users?: Record<string, number>;
 | 
					  users?: Record<string, number>;
 | 
				
			||||||
  notifications?: 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 {
 | 
					export function usePowerLevels(room: Room): IPowerLevels {
 | 
				
			||||||
  const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
 | 
					  const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
 | 
				
			||||||
  const powerLevels: IPowerLevels =
 | 
					  const powerLevels: IPowerLevels = useMemo(
 | 
				
			||||||
    powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
 | 
					    () => getPowersLevelFromMatrixEvent(powerLevelsEvent),
 | 
				
			||||||
 | 
					    [powerLevelsEvent]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return powerLevels;
 | 
					  return powerLevels;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -55,7 +86,18 @@ export const usePowerLevelsContext = (): IPowerLevels => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
 | 
					export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  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(
 | 
					  useStateEventCallback(
 | 
				
			||||||
    mx,
 | 
					    mx,
 | 
				
			||||||
| 
						 | 
					@ -68,28 +110,13 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
 | 
				
			||||||
          event.getStateKey() === '' &&
 | 
					          event.getStateKey() === '' &&
 | 
				
			||||||
          rooms.find((r) => r.roomId === roomId)
 | 
					          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;
 | 
					  return roomToPowerLevels;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -104,42 +131,83 @@ export type CanDoAction = (
 | 
				
			||||||
  action: PowerLevelActions,
 | 
					  action: PowerLevelActions,
 | 
				
			||||||
  powerLevel: number
 | 
					  powerLevel: number
 | 
				
			||||||
) => boolean;
 | 
					) => boolean;
 | 
				
			||||||
 | 
					export type CanDoNotificationAction = (
 | 
				
			||||||
 | 
					  powerLevels: IPowerLevels,
 | 
				
			||||||
 | 
					  action: PowerLevelNotificationsAction,
 | 
				
			||||||
 | 
					  powerLevel: number
 | 
				
			||||||
 | 
					) => boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type PowerLevelsAPI = {
 | 
					export type PowerLevelsAPI = {
 | 
				
			||||||
  getPowerLevel: GetPowerLevel;
 | 
					  getPowerLevel: GetPowerLevel;
 | 
				
			||||||
  canSendEvent: CanSend;
 | 
					  canSendEvent: CanSend;
 | 
				
			||||||
  canSendStateEvent: CanSend;
 | 
					  canSendStateEvent: CanSend;
 | 
				
			||||||
  canDoAction: CanDoAction;
 | 
					  canDoAction: CanDoAction;
 | 
				
			||||||
 | 
					  canDoNotificationAction: CanDoNotificationAction;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const powerLevelAPI: PowerLevelsAPI = {
 | 
					export type ReadPowerLevelAPI = {
 | 
				
			||||||
  getPowerLevel: (powerLevels, userId) => {
 | 
					  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;
 | 
					    const { users_default: usersDefault, users } = powerLevels;
 | 
				
			||||||
    if (userId && users && typeof users[userId] === 'number') {
 | 
					    if (userId && users && typeof users[userId] === 'number') {
 | 
				
			||||||
      return users[userId];
 | 
					      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;
 | 
					    const { events, events_default: eventsDefault } = powerLevels;
 | 
				
			||||||
    if (events && eventType && typeof events[eventType] === 'number') {
 | 
					    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;
 | 
					    const { events, state_default: stateDefault } = powerLevels;
 | 
				
			||||||
    if (events && eventType && typeof events[eventType] === 'number') {
 | 
					    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) => {
 | 
					  canDoAction: (powerLevels, action, powerLevel) => {
 | 
				
			||||||
    const requiredPL = powerLevels[action];
 | 
					    const requiredPL = readPowerLevel.action(powerLevels, action);
 | 
				
			||||||
    if (typeof requiredPL === 'number') {
 | 
					    return powerLevel >= requiredPL;
 | 
				
			||||||
      return powerLevel >= requiredPL;
 | 
					  },
 | 
				
			||||||
    }
 | 
					  canDoNotificationAction: (powerLevels, action, powerLevel) => {
 | 
				
			||||||
    return powerLevel >= DefaultPowerLevels[action];
 | 
					    const requiredPL = readPowerLevel.notification(powerLevels, action);
 | 
				
			||||||
 | 
					    return powerLevel >= requiredPL;
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -167,10 +235,121 @@ export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
 | 
				
			||||||
    [powerLevels]
 | 
					    [powerLevels]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const canDoNotificationAction = useCallback(
 | 
				
			||||||
 | 
					    (action: PowerLevelNotificationsAction, powerLevel: number) =>
 | 
				
			||||||
 | 
					      powerLevelAPI.canDoNotificationAction(powerLevels, action, powerLevel),
 | 
				
			||||||
 | 
					    [powerLevels]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    getPowerLevel,
 | 
					    getPowerLevel,
 | 
				
			||||||
    canSendEvent,
 | 
					    canSendEvent,
 | 
				
			||||||
    canSendStateEvent,
 | 
					    canSendStateEvent,
 | 
				
			||||||
    canDoAction,
 | 
					    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 { useEffect, useState } from 'react';
 | 
				
			||||||
 | 
					import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
 | 
				
			||||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
 | 
					import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
 | 
				
			||||||
import { StateEvent } from '../../types/matrix/room';
 | 
					import { StateEvent } from '../../types/matrix/room';
 | 
				
			||||||
import { useStateEvent } from './useStateEvent';
 | 
					import { useStateEvent } from './useStateEvent';
 | 
				
			||||||
| 
						 | 
					@ -39,3 +40,9 @@ export const useRoomTopic = (room: Room): string | undefined => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return topic;
 | 
					  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 { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
 | 
				
			||||||
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
 | 
					import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
 | 
				
			||||||
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
 | 
					import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
 | 
				
			||||||
 | 
					import { RoomSettingsRenderer } from '../features/room-settings';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
 | 
					export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
 | 
				
			||||||
  const { hashRouter } = clientConfig;
 | 
					  const { hashRouter } = clientConfig;
 | 
				
			||||||
| 
						 | 
					@ -121,6 +122,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      <Outlet />
 | 
					                      <Outlet />
 | 
				
			||||||
                    </ClientLayout>
 | 
					                    </ClientLayout>
 | 
				
			||||||
 | 
					                    <RoomSettingsRenderer />
 | 
				
			||||||
                    <ReceiveSelfDeviceVerification />
 | 
					                    <ReceiveSelfDeviceVerification />
 | 
				
			||||||
                    <AutoRestoreBackupOnVerification />
 | 
					                    <AutoRestoreBackupOnVerification />
 | 
				
			||||||
                  </ClientNonUIFeatures>
 | 
					                  </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 { getStateEvent } from './room';
 | 
				
			||||||
import { StateEvent } from '../../types/matrix/room';
 | 
					import { StateEvent } from '../../types/matrix/room';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const matchMxId = (id: string): RegExpMatchArray | null =>
 | 
					export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
 | 
				
			||||||
  id.match(/^([@!$+#])(\S+):(\S+)$/);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const validMxId = (id: string): boolean => !!matchMxId(id);
 | 
					export const validMxId = (id: string): boolean => !!matchMxId(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,6 +35,7 @@ export enum StateEvent {
 | 
				
			||||||
  SpaceParent = 'm.space.parent',
 | 
					  SpaceParent = 'm.space.parent',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PoniesRoomEmotes = 'im.ponies.room_emotes',
 | 
					  PoniesRoomEmotes = 'im.ponies.room_emotes',
 | 
				
			||||||
 | 
					  PowerLevelTags = 'in.cinny.room.power_level_tags',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum MessageEvent {
 | 
					export enum MessageEvent {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue