}),
[]
);
+export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
+ useMemo(
+ () => ({
+ [JoinRule.Invite]: Icons.SpaceLock,
+ [JoinRule.Knock]: Icons.SpaceLock,
+ [JoinRule.Restricted]: Icons.Space,
+ [JoinRule.Public]: Icons.SpaceGlobe,
+ [JoinRule.Private]: Icons.SpaceLock,
+ }),
+ []
+ );
type JoinRuleLabels = Record<JoinRule, string>;
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './DevelopTools';
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ )}
+ </>
+ );
+}
--- /dev/null
+export * from './EmojisStickers';
--- /dev/null
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import {
+ Badge,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ color,
+ config,
+ Icon,
+ Icons,
+ Input,
+ Spinner,
+ Text,
+ toRem,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+ useLocalAliases,
+ usePublishedAliases,
+ usePublishUnpublishAliases,
+ useSetMainAlias,
+} from '../../../hooks/useRoomAliases';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { CutoutCard } from '../../../components/cutout-card';
+import { getIdServer } from '../../../../util/matrixUtil';
+import { replaceSpaceWithDash } from '../../../utils/common';
+import { useAlive } from '../../../hooks/useAlive';
+import { StateEvent } from '../../../../types/matrix/room';
+
+type RoomPublishedAddressesProps = {
+ powerLevels: IPowerLevels;
+};
+
+export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEditCanonical = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomCanonicalAlias,
+ userPowerLevel
+ );
+
+ const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
+ const setMainAlias = useSetMainAlias(room);
+
+ const [mainState, setMain] = useAsyncCallback(setMainAlias);
+ const loading = mainState.status === AsyncStatus.Loading;
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Published Addresses"
+ description={
+ <span>
+ If access is <b>Public</b>, Published addresses will be used to join by anyone.
+ </span>
+ }
+ />
+ <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
+ {publishedAliases.length === 0 ? (
+ <Box direction="Column" gap="100">
+ <Text size="L400">No Addresses</Text>
+ <Text size="T200">
+ To publish an address, it needs to be set as a local address first
+ </Text>
+ </Box>
+ ) : (
+ <Box direction="Column" gap="300">
+ {publishedAliases.map((alias) => (
+ <Box key={alias} as="span" gap="200" alignItems="Center">
+ <Box grow="Yes" gap="Inherit" alignItems="Center">
+ <Text size="T300" truncate>
+ {alias === canonicalAlias ? <b>{alias}</b> : alias}
+ </Text>
+ {alias === canonicalAlias && (
+ <Badge variant="Success" fill="Solid" size="500">
+ <Text size="L400">Main</Text>
+ </Badge>
+ )}
+ </Box>
+ {canEditCanonical && (
+ <Box shrink="No" gap="100">
+ {alias === canonicalAlias ? (
+ <Chip
+ variant="Warning"
+ radii="Pill"
+ fill="None"
+ disabled={loading}
+ onClick={() => setMain(undefined)}
+ >
+ <Text size="B300">Unset Main</Text>
+ </Chip>
+ ) : (
+ <Chip
+ variant="Success"
+ radii="Pill"
+ fill={canonicalAlias ? 'None' : 'Soft'}
+ disabled={loading}
+ onClick={() => setMain(alias)}
+ >
+ <Text size="B300">Set Main</Text>
+ </Chip>
+ )}
+ </Box>
+ )}
+ </Box>
+ ))}
+
+ {mainState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {(mainState.error as MatrixError).message}
+ </Text>
+ )}
+ </Box>
+ )}
+ </CutoutCard>
+ </SequenceCard>
+ );
+}
+
+function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
+ const mx = useMatrixClient();
+ const userId = mx.getSafeUserId();
+ const server = getIdServer(userId);
+ const alive = useAlive();
+
+ const [addState, addAlias] = useAsyncCallback(addLocalAlias);
+ const adding = addState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ if (adding) return;
+ evt.preventDefault();
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const aliasInput = target?.aliasInput as HTMLInputElement | undefined;
+ if (!aliasInput) return;
+ const alias = replaceSpaceWithDash(aliasInput.value.trim());
+ if (!alias) return;
+
+ addAlias(`#${alias}:${server}`).then(() => {
+ if (alive()) {
+ aliasInput.value = '';
+ }
+ });
+ };
+
+ return (
+ <Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
+ <Box gap="200">
+ <Box grow="Yes" direction="Column">
+ <Input
+ name="aliasInput"
+ variant="Secondary"
+ size="400"
+ radii="300"
+ before={<Text size="T200">#</Text>}
+ readOnly={adding}
+ after={
+ <Text style={{ maxWidth: toRem(300) }} size="T200" truncate>
+ :{server}
+ </Text>
+ }
+ />
+ </Box>
+ <Box shrink="No">
+ <Button
+ variant="Success"
+ size="400"
+ radii="300"
+ type="submit"
+ disabled={adding}
+ before={adding && <Spinner size="100" variant="Success" fill="Solid" />}
+ >
+ <Text size="B400">Save</Text>
+ </Button>
+ </Box>
+ </Box>
+ {addState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(addState.error as MatrixError).httpStatus === 409
+ ? 'Address is already in use!'
+ : (addState.error as MatrixError).message}
+ </Text>
+ )}
+ </Box>
+ );
+}
+
+function LocalAddressesList({
+ localAliases,
+ removeLocalAlias,
+ canEditCanonical,
+}: {
+ localAliases: string[];
+ removeLocalAlias: (alias: string) => Promise<void>;
+ canEditCanonical?: boolean;
+}) {
+ const room = useRoom();
+ const alive = useAlive();
+
+ const [, publishedAliases] = usePublishedAliases(room);
+ const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room);
+
+ const [selectedAliases, setSelectedAliases] = useState<string[]>([]);
+ const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias));
+
+ const toggleSelect = (alias: string) => {
+ setSelectedAliases((aliases) => {
+ if (aliases.includes(alias)) {
+ return aliases.filter((a) => a !== alias);
+ }
+ const newAliases = [...aliases];
+ newAliases.push(alias);
+ return newAliases;
+ });
+ };
+ const clearSelected = () => {
+ if (alive()) {
+ setSelectedAliases([]);
+ }
+ };
+
+ const [deleteState, deleteAliases] = useAsyncCallback(
+ useCallback(
+ async (aliases: string[]) => {
+ for (let i = 0; i < aliases.length; i += 1) {
+ const alias = aliases[i];
+ // eslint-disable-next-line no-await-in-loop
+ await removeLocalAlias(alias);
+ }
+ },
+ [removeLocalAlias]
+ )
+ );
+ const [publishState, publish] = useAsyncCallback(publishAliases);
+ const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases);
+
+ const handleDelete = () => {
+ deleteAliases(selectedAliases).then(clearSelected);
+ };
+ const handlePublish = () => {
+ publish(selectedAliases).then(clearSelected);
+ };
+ const handleUnpublish = () => {
+ unpublish(selectedAliases).then(clearSelected);
+ };
+
+ const loading =
+ deleteState.status === AsyncStatus.Loading ||
+ publishState.status === AsyncStatus.Loading ||
+ unpublishState.status === AsyncStatus.Loading;
+ let error: MatrixError | undefined;
+ if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError;
+ if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError;
+ if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError;
+
+ return (
+ <Box direction="Column" gap="300">
+ {selectedAliases.length > 0 && (
+ <Box gap="200">
+ <Box grow="Yes">
+ <Text size="L400">{selectedAliases.length} Selected</Text>
+ </Box>
+ <Box shrink="No" gap="Inherit">
+ {canEditCanonical &&
+ (selectHasPublished ? (
+ <Chip
+ variant="Warning"
+ radii="Pill"
+ disabled={loading}
+ onClick={handleUnpublish}
+ before={
+ unpublishState.status === AsyncStatus.Loading && (
+ <Spinner size="100" variant="Warning" />
+ )
+ }
+ >
+ <Text size="B300">Unpublish</Text>
+ </Chip>
+ ) : (
+ <Chip
+ variant="Success"
+ radii="Pill"
+ disabled={loading}
+ onClick={handlePublish}
+ before={
+ publishState.status === AsyncStatus.Loading && (
+ <Spinner size="100" variant="Success" />
+ )
+ }
+ >
+ <Text size="B300">Publish</Text>
+ </Chip>
+ ))}
+ <Chip
+ variant="Critical"
+ radii="Pill"
+ disabled={loading}
+ onClick={handleDelete}
+ before={
+ deleteState.status === AsyncStatus.Loading && (
+ <Spinner size="100" variant="Critical" />
+ )
+ }
+ >
+ <Text size="B300">Delete</Text>
+ </Chip>
+ </Box>
+ </Box>
+ )}
+ {localAliases.map((alias) => {
+ const published = publishedAliases.includes(alias);
+ const selected = selectedAliases.includes(alias);
+
+ return (
+ <Box key={alias} as="span" alignItems="Center" gap="200">
+ <Box shrink="No">
+ <Checkbox
+ checked={selected}
+ onChange={() => toggleSelect(alias)}
+ size="50"
+ variant="Primary"
+ disabled={loading}
+ />
+ </Box>
+ <Box grow="Yes">
+ <Text size="T300" truncate>
+ {alias}
+ </Text>
+ </Box>
+ <Box shrink="No" gap="100">
+ {published && (
+ <Badge variant="Success" fill="Soft" size="500">
+ <Text size="L400">Published</Text>
+ </Badge>
+ )}
+ </Box>
+ </Box>
+ );
+ })}
+ {error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {error.message}
+ </Text>
+ )}
+ </Box>
+ );
+}
+
+export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEditCanonical = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomCanonicalAlias,
+ userPowerLevel
+ );
+
+ const [expand, setExpand] = useState(false);
+
+ const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Local Addresses"
+ description="Set local address so users can join through your homeserver."
+ after={
+ <Button
+ type="button"
+ onClick={() => setExpand(!expand)}
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ outlined
+ radii="300"
+ before={
+ <Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
+ }
+ >
+ <Text as="span" size="B300" truncate>
+ {expand ? 'Collapse' : 'Expand'}
+ </Text>
+ </Button>
+ }
+ />
+ {expand && (
+ <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
+ {localAliasesState.status === AsyncStatus.Loading && (
+ <Box gap="100">
+ <Spinner variant="Secondary" size="100" />
+ <Text size="T200">Loading...</Text>
+ </Box>
+ )}
+ {localAliasesState.status === AsyncStatus.Success &&
+ (localAliasesState.data.length === 0 ? (
+ <Box direction="Column" gap="100">
+ <Text size="L400">No Addresses</Text>
+ </Box>
+ ) : (
+ <LocalAddressesList
+ localAliases={localAliasesState.data}
+ removeLocalAlias={removeLocalAlias}
+ canEditCanonical={canEditCanonical}
+ />
+ ))}
+ {localAliasesState.status === AsyncStatus.Error && (
+ <Box gap="100">
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {localAliasesState.error.message}
+ </Text>
+ </Box>
+ )}
+ </CutoutCard>
+ )}
+ {expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
+ </SequenceCard>
+ );
+}
--- /dev/null
+import {
+ Badge,
+ Box,
+ Button,
+ color,
+ config,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+} from 'folds';
+import React, { useCallback, useState } from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useRoom } from '../../../hooks/useRoom';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { stopPropagation } from '../../../utils/keyboard';
+
+const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
+
+type RoomEncryptionProps = {
+ powerLevels: IPowerLevels;
+};
+export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEnable = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomEncryption,
+ userPowerLevel
+ );
+ const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
+ algorithm: string;
+ }>();
+ const enabled = content?.algorithm === ROOM_ENC_ALGO;
+
+ const [enableState, enable] = useAsyncCallback(
+ useCallback(async () => {
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
+ algorithm: ROOM_ENC_ALGO,
+ });
+ }, [mx, room.roomId])
+ );
+
+ const enabling = enableState.status === AsyncStatus.Loading;
+
+ const [prompt, setPrompt] = useState(false);
+
+ const handleEnable = () => {
+ enable();
+ setPrompt(false);
+ };
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Room Encryption"
+ description={
+ enabled
+ ? 'Messages in this room are protected by end-to-end encryption.'
+ : 'Once enabled, encryption cannot be disabled!'
+ }
+ after={
+ enabled ? (
+ <Badge size="500" variant="Success" fill="Solid" radii="300">
+ <Text size="L400">Enabled</Text>
+ </Badge>
+ ) : (
+ <Button
+ size="300"
+ variant="Primary"
+ fill="Solid"
+ radii="300"
+ disabled={!canEnable}
+ onClick={() => setPrompt(true)}
+ before={enabling && <Spinner size="100" variant="Primary" fill="Solid" />}
+ >
+ <Text size="B300">Enable</Text>
+ </Button>
+ )
+ }
+ >
+ {enableState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(enableState.error as MatrixError).message}
+ </Text>
+ )}
+ {prompt && (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setPrompt(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Enable Encryption</Text>
+ </Box>
+ <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Text priority="400">
+ Are you sure? Once enabled, encryption cannot be disabled!
+ </Text>
+ <Button type="submit" variant="Primary" onClick={handleEnable}>
+ <Text size="B400">Enable E2E Encryption</Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ </SettingTile>
+ </SequenceCard>
+ );
+}
--- /dev/null
+import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+ Button,
+ color,
+ config,
+ Icon,
+ Icons,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+} from 'folds';
+import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
+import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { stopPropagation } from '../../../utils/keyboard';
+
+const useVisibilityStr = () =>
+ useMemo(
+ () => ({
+ [HistoryVisibility.Invited]: 'After Invite',
+ [HistoryVisibility.Joined]: 'After Join',
+ [HistoryVisibility.Shared]: 'All Messages',
+ [HistoryVisibility.WorldReadable]: 'All Messages (Guests)',
+ }),
+ []
+ );
+
+const useVisibilityMenu = () =>
+ useMemo(
+ () => [
+ HistoryVisibility.Shared,
+ HistoryVisibility.Invited,
+ HistoryVisibility.Joined,
+ HistoryVisibility.WorldReadable,
+ ],
+ []
+ );
+
+type RoomHistoryVisibilityProps = {
+ powerLevels: IPowerLevels;
+};
+export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEdit = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomHistoryVisibility,
+ userPowerLevel
+ );
+
+ const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
+ const historyVisibility: HistoryVisibility =
+ visibilityEvent?.getContent<RoomHistoryVisibilityEventContent>().history_visibility ??
+ HistoryVisibility.Shared;
+ const visibilityMenu = useVisibilityMenu();
+ const visibilityStr = useVisibilityStr();
+
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const [submitState, submit] = useAsyncCallback(
+ useCallback(
+ async (visibility: HistoryVisibility) => {
+ const content: RoomHistoryVisibilityEventContent = {
+ history_visibility: visibility,
+ };
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
+ },
+ [mx, room.roomId]
+ )
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleChange = (visibility: HistoryVisibility) => {
+ submit(visibility);
+ setMenuAnchor(undefined);
+ };
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Message History Visibility"
+ description="Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect."
+ after={
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ {visibilityMenu.map((visibility) => (
+ <MenuItem
+ key={visibility}
+ size="300"
+ radii="300"
+ onClick={() => handleChange(visibility)}
+ aria-pressed={visibility === historyVisibility}
+ >
+ <Text as="span" size="T300" truncate>
+ {visibilityStr[visibility]}
+ </Text>
+ </MenuItem>
+ ))}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ disabled={!canEdit || submitting}
+ onClick={handleOpenMenu}
+ after={
+ submitting ? (
+ <Spinner size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.ChevronBottom} />
+ )
+ }
+ >
+ <Text size="B300">{visibilityStr[historyVisibility]}</Text>
+ </Button>
+ </PopOut>
+ }
+ >
+ {submitState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(submitState.error as MatrixError).message}
+ </Text>
+ )}
+ </SettingTile>
+ </SequenceCard>
+ );
+}
--- /dev/null
+import React, { useCallback, useMemo } from 'react';
+import { color, Text } from 'folds';
+import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
+import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import {
+ JoinRulesSwitcher,
+ useRoomJoinRuleIcon,
+ useRoomJoinRuleLabel,
+ useSpaceJoinRuleIcon,
+} from '../../../components/JoinRulesSwitcher';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getStateEvents } from '../../../utils/room';
+
+type RestrictedRoomAllowContent = {
+ room_id: string;
+ type: RestrictedAllowType;
+};
+
+type RoomJoinRulesProps = {
+ powerLevels: IPowerLevels;
+};
+export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const roomVersion = parseInt(room.getVersion(), 10);
+ const allowRestricted = roomVersion >= 8;
+ const allowKnock = roomVersion >= 7;
+ const space = useSpaceOptionally();
+
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEdit = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomHistoryVisibility,
+ userPowerLevel
+ );
+
+ const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
+ const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
+ const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
+
+ const joinRules: Array<JoinRule> = useMemo(() => {
+ const r: JoinRule[] = [JoinRule.Invite];
+ if (allowKnock) {
+ r.push(JoinRule.Knock);
+ }
+ if (allowRestricted && space) {
+ r.push(JoinRule.Restricted);
+ }
+ r.push(JoinRule.Public);
+
+ return r;
+ }, [allowRestricted, allowKnock, space]);
+
+ const icons = useRoomJoinRuleIcon();
+ const spaceIcons = useSpaceJoinRuleIcon();
+ const labels = useRoomJoinRuleLabel();
+
+ const [submitState, submit] = useAsyncCallback(
+ useCallback(
+ async (joinRule: JoinRule) => {
+ const allow: RestrictedRoomAllowContent[] = [];
+ if (joinRule === JoinRule.Restricted) {
+ const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
+ event.getStateKey()
+ );
+ parents.forEach((parentRoomId) => {
+ if (!parentRoomId) return;
+ allow.push({
+ type: RestrictedAllowType.RoomMembership,
+ room_id: parentRoomId,
+ });
+ });
+ }
+
+ const c: RoomJoinRulesEventContent = {
+ join_rule: joinRule,
+ };
+ if (allow.length > 0) c.allow = allow;
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
+ },
+ [mx, room]
+ )
+ );
+
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={room.isSpaceRoom() ? 'Space Access' : 'Room Access'}
+ description={
+ room.isSpaceRoom()
+ ? 'Change how people can join the space.'
+ : 'Change how people can join the room.'
+ }
+ after={
+ <JoinRulesSwitcher
+ icons={room.isSpaceRoom() ? spaceIcons : icons}
+ labels={labels}
+ rules={joinRules}
+ value={rule}
+ onChange={submit}
+ disabled={!canEdit || submitting}
+ changing={submitting}
+ />
+ }
+ >
+ {submitState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(submitState.error as MatrixError).message}
+ </Text>
+ )}
+ </SettingTile>
+ </SequenceCard>
+ );
+}
--- /dev/null
+import {
+ Avatar,
+ Box,
+ Button,
+ Chip,
+ color,
+ Icon,
+ Icons,
+ Input,
+ Spinner,
+ Text,
+ TextArea,
+} from 'folds';
+import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import Linkify from 'linkify-react';
+import classNames from 'classnames';
+import { JoinRule, MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+ useRoomAvatar,
+ useRoomJoinRule,
+ useRoomName,
+ useRoomTopic,
+} from '../../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
+import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
+import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { useObjectURL } from '../../../hooks/useObjectURL';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+type RoomProfileEditProps = {
+ canEditAvatar: boolean;
+ canEditName: boolean;
+ canEditTopic: boolean;
+ avatar?: string;
+ name: string;
+ topic: string;
+ onClose: () => void;
+};
+export function RoomProfileEdit({
+ canEditAvatar,
+ canEditName,
+ canEditTopic,
+ avatar,
+ name,
+ topic,
+ onClose,
+}: RoomProfileEditProps) {
+ const room = useRoom();
+ const mx = useMatrixClient();
+ const alive = useAlive();
+ const useAuthentication = useMediaAuthentication();
+ const joinRule = useRoomJoinRule(room);
+ const [roomAvatar, setRoomAvatar] = useState(avatar);
+
+ const avatarUrl = roomAvatar
+ ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
+ : undefined;
+
+ const [imageFile, setImageFile] = useState<File>();
+ const avatarFileUrl = useObjectURL(imageFile);
+ const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
+ const uploadAtom = useMemo(() => {
+ if (imageFile) return createUploadAtom(imageFile);
+ return undefined;
+ }, [imageFile]);
+
+ const pickFile = useFilePicker(setImageFile, false);
+
+ const handleRemoveUpload = useCallback(() => {
+ setImageFile(undefined);
+ setRoomAvatar(avatar);
+ }, [avatar]);
+
+ const handleUploaded = useCallback((upload: UploadSuccess) => {
+ setRoomAvatar(upload.mxc);
+ }, []);
+
+ const [submitState, submit] = useAsyncCallback(
+ useCallback(
+ async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
+ if (roomAvatarMxc !== undefined) {
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
+ url: roomAvatarMxc,
+ });
+ }
+ if (roomName !== undefined) {
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
+ }
+ if (roomTopic !== undefined) {
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
+ }
+ },
+ [mx, room.roomId]
+ )
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (uploadingAvatar) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const nameInput = target?.nameInput as HTMLInputElement | undefined;
+ const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
+ if (!nameInput || !topicTextArea) return;
+
+ const roomName = nameInput.value.trim();
+ const roomTopic = topicTextArea.value.trim();
+
+ if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
+ return;
+ }
+
+ submit(
+ roomAvatar === avatar ? undefined : roomAvatar || null,
+ roomName === name ? undefined : roomName,
+ roomTopic === topic ? undefined : roomTopic
+ ).then(() => {
+ if (alive()) {
+ onClose();
+ }
+ });
+ };
+
+ return (
+ <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
+ <Box gap="400">
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Avatar</Text>
+ {uploadAtom ? (
+ <Box gap="200" direction="Column">
+ <CompactUploadCardRenderer
+ uploadAtom={uploadAtom}
+ onRemove={handleRemoveUpload}
+ onComplete={handleUploaded}
+ />
+ </Box>
+ ) : (
+ <Box gap="200">
+ <Button
+ type="button"
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ disabled={!canEditAvatar || submitting}
+ onClick={() => pickFile('image/*')}
+ >
+ <Text size="B300">Upload</Text>
+ </Button>
+ {!roomAvatar && avatar && (
+ <Button
+ type="button"
+ size="300"
+ variant="Success"
+ fill="None"
+ radii="300"
+ disabled={!canEditAvatar || submitting}
+ onClick={() => setRoomAvatar(avatar)}
+ >
+ <Text size="B300">Reset</Text>
+ </Button>
+ )}
+ {roomAvatar && (
+ <Button
+ type="button"
+ size="300"
+ variant="Critical"
+ fill="None"
+ radii="300"
+ disabled={!canEditAvatar || submitting}
+ onClick={() => setRoomAvatar(undefined)}
+ >
+ <Text size="B300">Remove</Text>
+ </Button>
+ )}
+ </Box>
+ )}
+ </Box>
+ <Box shrink="No">
+ <Avatar size="500" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <RoomIcon
+ space={room.isSpaceRoom()}
+ size="400"
+ joinRule={joinRule?.join_rule ?? JoinRule.Invite}
+ filled
+ />
+ )}
+ />
+ </Avatar>
+ </Box>
+ </Box>
+ <Box direction="Inherit" gap="100">
+ <Text size="L400">Name</Text>
+ <Input
+ name="nameInput"
+ defaultValue={name}
+ variant="Secondary"
+ radii="300"
+ readOnly={!canEditName || submitting}
+ />
+ </Box>
+ <Box direction="Inherit" gap="100">
+ <Text size="L400">Topic</Text>
+ <TextArea
+ name="topicTextArea"
+ defaultValue={topic}
+ variant="Secondary"
+ radii="300"
+ readOnly={!canEditTopic || submitting}
+ />
+ </Box>
+ {submitState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {(submitState.error as MatrixError).message}
+ </Text>
+ )}
+ <Box gap="300">
+ <Button
+ type="submit"
+ variant="Success"
+ size="300"
+ radii="300"
+ disabled={uploadingAvatar || submitting}
+ before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
+ >
+ <Text size="B300">Save</Text>
+ </Button>
+ <Button
+ type="reset"
+ onClick={onClose}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ >
+ <Text size="B300">Cancel</Text>
+ </Button>
+ </Box>
+ </Box>
+ );
+}
+
+type RoomProfileProps = {
+ powerLevels: IPowerLevels;
+};
+export function RoomProfile({ powerLevels }: RoomProfileProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const directs = useAtomValue(mDirectAtom);
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const userPowerLevel = getPowerLevel(mx.getSafeUserId());
+
+ const avatar = useRoomAvatar(room, directs.has(room.roomId));
+ const name = useRoomName(room);
+ const topic = useRoomTopic(room);
+ const joinRule = useRoomJoinRule(room);
+
+ const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
+ const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
+ const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
+ const canEdit = canEditAvatar || canEditName || canEditTopic;
+
+ const avatarUrl = avatar
+ ? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const [edit, setEdit] = useState(false);
+
+ const handleCloseEdit = useCallback(() => setEdit(false), []);
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Profile</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ {edit ? (
+ <RoomProfileEdit
+ canEditAvatar={canEditAvatar}
+ canEditName={canEditName}
+ canEditTopic={canEditTopic}
+ avatar={avatar}
+ name={name ?? ''}
+ topic={topic ?? ''}
+ onClose={handleCloseEdit}
+ />
+ ) : (
+ <Box gap="400">
+ <Box grow="Yes" direction="Column" gap="300">
+ <Box direction="Column" gap="100">
+ <Text className={BreakWord} size="H5">
+ {name ?? 'Unknown'}
+ </Text>
+ {topic && (
+ <Text className={classNames(BreakWord, LineClamp3)} size="T200">
+ <Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
+ </Text>
+ )}
+ </Box>
+ {canEdit && (
+ <Box gap="200">
+ <Chip
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ before={<Icon size="50" src={Icons.Pencil} />}
+ onClick={() => setEdit(true)}
+ outlined
+ >
+ <Text size="B300">Edit</Text>
+ </Chip>
+ </Box>
+ )}
+ </Box>
+ <Box shrink="No">
+ <Avatar size="500" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <RoomIcon
+ space={room.isSpaceRoom()}
+ size="400"
+ joinRule={joinRule?.join_rule ?? JoinRule.Invite}
+ filled
+ />
+ )}
+ />
+ </Avatar>
+ </Box>
+ </Box>
+ )}
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Box, color, Spinner, Switch, Text } from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+type RoomPublishProps = {
+ powerLevels: IPowerLevels;
+};
+export function RoomPublish({ powerLevels }: RoomPublishProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canEditCanonical = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomCanonicalAlias,
+ userPowerLevel
+ );
+
+ const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
+
+ const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility);
+
+ const loading =
+ visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Publish To Directory"
+ after={
+ <Box gap="200" alignItems="Center">
+ {loading && <Spinner variant="Secondary" />}
+ {!loading && visibilityState.status === AsyncStatus.Success && (
+ <Switch
+ value={visibilityState.data}
+ onChange={toggleVisibility}
+ disabled={!canEditCanonical}
+ />
+ )}
+ </Box>
+ }
+ >
+ {visibilityState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(visibilityState.error as MatrixError).message}
+ </Text>
+ )}
+
+ {toggleState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(toggleState.error as MatrixError).message}
+ </Text>
+ )}
+ </SettingTile>
+ </SequenceCard>
+ );
+}
--- /dev/null
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import {
+ Button,
+ color,
+ Spinner,
+ Text,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Dialog,
+ Header,
+ config,
+ Box,
+ IconButton,
+ Icon,
+ Icons,
+ Input,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { MatrixError } from 'matrix-js-sdk';
+import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useRoom } from '../../../hooks/useRoom';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useCapabilities } from '../../../hooks/useCapabilities';
+import { stopPropagation } from '../../../utils/keyboard';
+
+type RoomUpgradeProps = {
+ powerLevels: IPowerLevels;
+ requestClose: () => void;
+};
+export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+ const createContent = useStateEvent(
+ room,
+ StateEvent.RoomCreate
+ )?.getContent<RoomCreateEventContent>();
+ const roomVersion = createContent?.room_version ?? 1;
+ const predecessorRoomId = createContent?.predecessor?.room_id;
+
+ const capabilities = useCapabilities();
+ const defaultRoomVersion = capabilities['m.room_versions']?.default;
+
+ const tombstoneContent = useStateEvent(
+ room,
+ StateEvent.RoomTombstone
+ )?.getContent<RoomTombstoneEventContent>();
+ const replacementRoom = tombstoneContent?.replacement_room;
+
+ const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
+ const canUpgrade = powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.RoomTombstone,
+ userPowerLevel
+ );
+
+ const handleOpenRoom = () => {
+ if (replacementRoom) {
+ requestClose();
+ if (room.isSpaceRoom()) {
+ navigateSpace(replacementRoom);
+ } else {
+ navigateRoom(replacementRoom);
+ }
+ }
+ };
+
+ const handleOpenOldRoom = () => {
+ if (predecessorRoomId) {
+ requestClose();
+ if (room.isSpaceRoom()) {
+ navigateSpace(predecessorRoomId);
+ } else {
+ navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
+ }
+ }
+ };
+
+ const [upgradeState, upgrade] = useAsyncCallback(
+ useCallback(
+ async (version: string) => {
+ await mx.upgradeRoom(room.roomId, version);
+ },
+ [mx, room]
+ )
+ );
+
+ const upgrading = upgradeState.status === AsyncStatus.Loading;
+
+ const [prompt, setPrompt] = useState(false);
+
+ const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const versionInput = target?.versionInput as HTMLInputElement | undefined;
+ const version = versionInput?.value.trim();
+ if (!version) return;
+
+ upgrade(version);
+ setPrompt(false);
+ };
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
+ description={
+ replacementRoom
+ ? tombstoneContent.body ||
+ `This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
+ : `Current room version: ${roomVersion}.`
+ }
+ after={
+ <Box alignItems="Center" gap="200">
+ {predecessorRoomId && (
+ <Button
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={handleOpenOldRoom}
+ >
+ <Text size="B300">{room.isSpaceRoom() ? 'Old Space' : 'Old Room'}</Text>
+ </Button>
+ )}
+ {replacementRoom ? (
+ <Button
+ size="300"
+ variant="Success"
+ fill="Solid"
+ radii="300"
+ onClick={handleOpenRoom}
+ >
+ <Text size="B300">{room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'}</Text>
+ </Button>
+ ) : (
+ <Button
+ size="300"
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ disabled={upgrading || !canUpgrade}
+ before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
+ onClick={() => setPrompt(true)}
+ >
+ <Text size="B300">Upgrade</Text>
+ </Button>
+ )}
+ </Box>
+ }
+ >
+ {upgradeState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(upgradeState.error as MatrixError).message}
+ </Text>
+ )}
+
+ {prompt && (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setPrompt(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
+ </Box>
+ <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Text priority="400" style={{ color: color.Critical.Main }}>
+ <b>This action is irreversible!</b>
+ </Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Version</Text>
+ <Input
+ defaultValue={defaultRoomVersion}
+ name="versionInput"
+ variant="Background"
+ required
+ />
+ </Box>
+ <Button type="submit" variant="Secondary">
+ <Text size="B400">
+ {room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ </SettingTile>
+ </SequenceCard>
+ );
+}
--- /dev/null
+export * from './RoomAddress';
+export * from './RoomEncryption';
+export * from './RoomHistoryVisibility';
+export * from './RoomJoinRules';
+export * from './RoomProfile';
+export * from './RoomPublish';
+export * from './RoomUpgrade';
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './Members';
--- /dev/null
+/* eslint-disable react/no-array-index-key */
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
+import produce from 'immer';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import {
+ applyPermissionPower,
+ getPermissionPower,
+ IPowerLevels,
+ PermissionLocation,
+ usePowerLevelsAPI,
+} from '../../../hooks/usePowerLevels';
+import { PermissionGroup } from './types';
+import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { useRoom } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PowerSwitcher } from '../../../components/power';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+const USER_DEFAULT_LOCATION: PermissionLocation = {
+ user: true,
+};
+
+type PermissionGroupsProps = {
+ powerLevels: IPowerLevels;
+ permissionGroups: PermissionGroup[];
+};
+export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGroupsProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const alive = useAlive();
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const canChangePermission = canSendStateEvent(
+ StateEvent.RoomPowerLevels,
+ getPowerLevel(mx.getSafeUserId())
+ );
+ const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+ const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
+
+ const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
+ new Map()
+ );
+
+ useEffect(() => {
+ // reset permission update if component rerender
+ // as permission location object reference has changed
+ setPermissionUpdate(new Map());
+ }, [permissionGroups]);
+
+ const handleChangePermission = (
+ location: PermissionLocation,
+ newPower: number,
+ currentPower: number
+ ) => {
+ setPermissionUpdate((p) => {
+ const up: typeof p = new Map();
+ p.forEach((value, key) => {
+ up.set(key, value);
+ });
+ if (newPower === currentPower) {
+ up.delete(location);
+ } else {
+ up.set(location, newPower);
+ }
+ return up;
+ });
+ };
+
+ const [applyState, applyChanges] = useAsyncCallback(
+ useCallback(async () => {
+ const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => {
+ permissionGroups.forEach((group) =>
+ group.items.forEach((item) => {
+ const power = getPermissionPower(powerLevels, item.location);
+ applyPermissionPower(draftPowerLevels, item.location, power);
+ })
+ );
+ permissionUpdate.forEach((power, location) =>
+ applyPermissionPower(draftPowerLevels, location, power)
+ );
+ return draftPowerLevels;
+ });
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
+ }, [mx, room, powerLevels, permissionUpdate, permissionGroups])
+ );
+
+ const resetChanges = useCallback(() => {
+ setPermissionUpdate(new Map());
+ }, []);
+
+ const handleApplyChanges = () => {
+ applyChanges().then(() => {
+ if (alive()) {
+ resetChanges();
+ }
+ });
+ };
+
+ const applyingChanges = applyState.status === AsyncStatus.Loading;
+ const hasChanges = permissionUpdate.size > 0;
+
+ const renderUserGroup = () => {
+ const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION);
+ const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
+ const value = powerUpdate ?? power;
+
+ const tag = getPowerLevelTag(value);
+ const powerChanges = value !== power;
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Users</Text>
+ <SequenceCard
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Default Power"
+ description="Default power level for all users."
+ after={
+ <PowerSwitcher
+ powerLevelTags={powerLevelTags}
+ value={value}
+ onChange={(v) => handleChangePermission(USER_DEFAULT_LOCATION, v, power)}
+ >
+ {(handleOpen, opened) => (
+ <Chip
+ variant={powerChanges ? 'Success' : 'Secondary'}
+ outlined={powerChanges}
+ fill="Soft"
+ radii="Pill"
+ aria-selected={opened}
+ disabled={!canChangePermission || applyingChanges}
+ after={
+ powerChanges && (
+ <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+ )
+ }
+ before={
+ canChangePermission && (
+ <Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
+ )
+ }
+ onClick={handleOpen}
+ >
+ <Text size="B300" truncate>
+ {tag.name}
+ </Text>
+ </Chip>
+ )}
+ </PowerSwitcher>
+ }
+ />
+ </SequenceCard>
+ </Box>
+ );
+ };
+
+ return (
+ <>
+ {renderUserGroup()}
+ {permissionGroups.map((group, groupIndex) => (
+ <Box key={groupIndex} direction="Column" gap="100">
+ <Text size="L400">{group.name}</Text>
+ {group.items.map((item, itemIndex) => {
+ const power = getPermissionPower(powerLevels, item.location);
+ const powerUpdate = permissionUpdate.get(item.location);
+ const value = powerUpdate ?? power;
+
+ const tag = getPowerLevelTag(value);
+ const powerChanges = value !== power;
+
+ return (
+ <SequenceCard
+ key={itemIndex}
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={item.name}
+ description={item.description}
+ after={
+ <PowerSwitcher
+ powerLevelTags={powerLevelTags}
+ value={value}
+ onChange={(v) => handleChangePermission(item.location, v, power)}
+ >
+ {(handleOpen, opened) => (
+ <Chip
+ variant={powerChanges ? 'Success' : 'Secondary'}
+ outlined={powerChanges}
+ fill="Soft"
+ radii="Pill"
+ aria-selected={opened}
+ disabled={!canChangePermission || applyingChanges}
+ after={
+ powerChanges && (
+ <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+ )
+ }
+ before={
+ canChangePermission && (
+ <Icon
+ size="50"
+ src={opened ? Icons.ChevronTop : Icons.ChevronBottom}
+ />
+ )
+ }
+ onClick={handleOpen}
+ >
+ <Text size="B300" truncate>
+ {tag.name}
+ </Text>
+ {value < maxPower && <Text size="T200">& Above</Text>}
+ </Chip>
+ )}
+ </PowerSwitcher>
+ }
+ />
+ </SequenceCard>
+ );
+ })}
+ </Box>
+ ))}
+
+ {hasChanges && (
+ <Menu
+ style={{
+ position: 'sticky',
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+ bottom: config.space.S400,
+ left: config.space.S400,
+ right: 0,
+ zIndex: 1,
+ }}
+ variant="Success"
+ >
+ <Box alignItems="Center" gap="400">
+ <Box grow="Yes" direction="Column">
+ {applyState.status === AsyncStatus.Error ? (
+ <Text size="T200">
+ <b>Failed to apply changes! Please try again.</b>
+ </Text>
+ ) : (
+ <Text size="T200">
+ <b>Changes saved! Apply when ready.</b>
+ </Text>
+ )}
+ </Box>
+ <Box shrink="No" gap="200">
+ <Button
+ size="300"
+ variant="Success"
+ fill="None"
+ radii="300"
+ disabled={applyingChanges}
+ onClick={resetChanges}
+ >
+ <Text size="B300">Reset</Text>
+ </Button>
+ <Button
+ size="300"
+ variant="Success"
+ radii="300"
+ disabled={applyingChanges}
+ before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
+ onClick={handleApplyChanges}
+ >
+ <Text size="B300">Apply Changes</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ )}
+ </>
+ );
+}
--- /dev/null
+/* eslint-disable react/no-array-index-key */
+import React, { useState, MouseEventHandler, ReactNode } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ Chip,
+ Text,
+ RectCords,
+ PopOut,
+ Menu,
+ Scroll,
+ toRem,
+ config,
+ color,
+} from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { SettingTile } from '../../../components/setting-tile';
+import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
+import { useRoom } from '../../../hooks/useRoom';
+import { PowerColorBadge, PowerIcon } from '../../../components/power';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { stopPropagation } from '../../../utils/keyboard';
+import { PermissionGroup } from './types';
+
+type PeekPermissionsProps = {
+ powerLevels: IPowerLevels;
+ power: number;
+ permissionGroups: PermissionGroup[];
+ children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
+};
+function PeekPermissions({ powerLevels, power, permissionGroups, children }: PeekPermissionsProps) {
+ const [menuCords, setMenuCords] = useState<RectCords>();
+
+ const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={menuCords}
+ offset={5}
+ position="Bottom"
+ align="Center"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu
+ style={{
+ maxHeight: '75vh',
+ maxWidth: toRem(300),
+ display: 'flex',
+ }}
+ >
+ <Box grow="Yes" tabIndex={0}>
+ <Scroll size="0" hideTrack visibility="Hover">
+ <Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
+ {permissionGroups.map((group, groupIndex) => (
+ <Box key={groupIndex} direction="Column" gap="100">
+ <Text size="L400">{group.name}</Text>
+ <div>
+ {group.items.map((item, itemIndex) => {
+ const requiredPower = getPermissionPower(powerLevels, item.location);
+ const hasPower = requiredPower <= power;
+
+ return (
+ <Text
+ key={itemIndex}
+ size="T200"
+ style={{
+ color: hasPower ? undefined : color.Critical.Main,
+ }}
+ >
+ {hasPower ? '✅' : '❌'} {item.name}
+ </Text>
+ );
+ })}
+ </div>
+ </Box>
+ ))}
+ </Box>
+ </Scroll>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {children(handleOpen, !!menuCords)}
+ </PopOut>
+ );
+}
+
+type PowersProps = {
+ powerLevels: IPowerLevels;
+ permissionGroups: PermissionGroup[];
+ onEdit?: () => void;
+};
+export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
+
+ return (
+ <Box direction="Column" gap="100">
+ <SequenceCard
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Power Levels"
+ description="Manage and customize incremental power levels for users."
+ after={
+ onEdit && (
+ <Box gap="200">
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ onClick={onEdit}
+ >
+ <Text size="B300">Edit</Text>
+ </Button>
+ </Box>
+ )
+ }
+ />
+ <SettingTile>
+ <Box gap="200" wrap="Wrap">
+ {getPowers(powerLevelTags).map((power) => {
+ const tag = powerLevelTags[power];
+ const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+
+ return (
+ <PeekPermissions
+ key={power}
+ powerLevels={powerLevels}
+ power={power}
+ permissionGroups={permissionGroups}
+ >
+ {(openMenu, opened) => (
+ <Chip
+ onClick={openMenu}
+ variant="Secondary"
+ aria-pressed={opened}
+ radii="300"
+ before={<PowerColorBadge color={tag.color} />}
+ after={tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
+ >
+ <Text size="T300" truncate>
+ <b>{tag.name}</b>
+ </Text>
+ </Chip>
+ )}
+ </PeekPermissions>
+ );
+ })}
+ </Box>
+ </SettingTile>
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './PermissionGroups';
+export * from './Powers';
+export * from './PowersEditor';
+export * from './types';
--- /dev/null
+import { PermissionLocation } from '../../../hooks/usePowerLevels';
+
+export type PermissionItem = {
+ location: PermissionLocation;
+ name: string;
+ description?: string;
+};
+
+export type PermissionGroup = {
+ name: string;
+ items: PermissionItem[];
+};
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const SequenceCardStyle = style({
+ padding: config.space.S300,
+});
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
-import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { stopPropagation } from '../../utils/keyboard';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace';
+import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
disabled?: boolean;
}) {
const openRoomSettings = useOpenRoomSettings();
+ const openSpaceSettings = useOpenSpaceSettings();
const space = useSpaceOptionally();
const handleSettings = () => {
if ('space' in item) {
- openSpaceSettings(item.roomId);
+ openSpaceSettings(item.roomId, item.parentId);
} else {
openRoomSettings(item.roomId, space?.roomId);
}
import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import * as css from './LobbyHeader.css';
-import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
type LobbyMenuProps = {
roomId: string;
const mx = useMatrixClient();
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+ const openSpaceSettings = useOpenSpaceSettings();
const handleInvite = () => {
openInviteUser(roomId);
const name = useRoomName(space);
const avatarMxc = useRoomAvatar(space);
- const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
+ const avatarUrl = avatarMxc
+ ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
import { mDirectAtom } from '../../state/mDirectList';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { General } from './general';
-import { Members } from './members';
-import { EmojisStickers } from './emojis-stickers';
+import { Members } from '../common-settings/members';
+import { EmojisStickers } from '../common-settings/emojis-stickers';
import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom';
-import { DeveloperTools } from './developer-tools';
+import { DeveloperTools } from '../common-settings/developer-tools';
type RoomSettingsMenuItem = {
page: RoomSettingsPage;
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-export * from './DevelopTools';
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- )}
- </>
- );
-}
+++ /dev/null
-export * from './EmojisStickers';
import React from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
-import { RoomProfile } from './RoomProfile';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
-import { RoomEncryption } from './RoomEncryption';
-import { RoomHistoryVisibility } from './RoomHistoryVisibility';
-import { RoomJoinRules } from './RoomJoinRules';
-import { RoomLocalAddresses, RoomPublishedAddresses } from './RoomAddress';
-import { RoomPublish } from './RoomPublish';
-import { RoomUpgrade } from './RoomUpgrade';
+import {
+ RoomProfile,
+ RoomEncryption,
+ RoomHistoryVisibility,
+ RoomJoinRules,
+ RoomLocalAddresses,
+ RoomPublishedAddresses,
+ RoomPublish,
+ RoomUpgrade,
+} from '../../common-settings/general';
type GeneralProps = {
requestClose: () => void;
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-import {
- Avatar,
- Box,
- Button,
- Chip,
- color,
- Icon,
- Icons,
- Input,
- Spinner,
- Text,
- TextArea,
-} from 'folds';
-import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
-import { useAtomValue } from 'jotai';
-import Linkify from 'linkify-react';
-import classNames from 'classnames';
-import { JoinRule, MatrixError } from 'matrix-js-sdk';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { useRoom } from '../../../hooks/useRoom';
-import {
- useRoomAvatar,
- useRoomJoinRule,
- useRoomName,
- useRoomTopic,
-} from '../../../hooks/useRoomMeta';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
-import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
-import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
-import { mxcUrlToHttp } from '../../../utils/matrix';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { CompactUploadCardRenderer } from '../../../components/upload-card';
-import { useObjectURL } from '../../../hooks/useObjectURL';
-import { createUploadAtom, UploadSuccess } from '../../../state/upload';
-import { useFilePicker } from '../../../hooks/useFilePicker';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useAlive } from '../../../hooks/useAlive';
-
-type RoomProfileEditProps = {
- canEditAvatar: boolean;
- canEditName: boolean;
- canEditTopic: boolean;
- avatar?: string;
- name: string;
- topic: string;
- onClose: () => void;
-};
-export function RoomProfileEdit({
- canEditAvatar,
- canEditName,
- canEditTopic,
- avatar,
- name,
- topic,
- onClose,
-}: RoomProfileEditProps) {
- const room = useRoom();
- const mx = useMatrixClient();
- const alive = useAlive();
- const useAuthentication = useMediaAuthentication();
- const joinRule = useRoomJoinRule(room);
- const [roomAvatar, setRoomAvatar] = useState(avatar);
-
- const avatarUrl = roomAvatar
- ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
- : undefined;
-
- const [imageFile, setImageFile] = useState<File>();
- const avatarFileUrl = useObjectURL(imageFile);
- const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
- const uploadAtom = useMemo(() => {
- if (imageFile) return createUploadAtom(imageFile);
- return undefined;
- }, [imageFile]);
-
- const pickFile = useFilePicker(setImageFile, false);
-
- const handleRemoveUpload = useCallback(() => {
- setImageFile(undefined);
- setRoomAvatar(avatar);
- }, [avatar]);
-
- const handleUploaded = useCallback((upload: UploadSuccess) => {
- setRoomAvatar(upload.mxc);
- }, []);
-
- const [submitState, submit] = useAsyncCallback(
- useCallback(
- async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
- if (roomAvatarMxc !== undefined) {
- await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
- url: roomAvatarMxc,
- });
- }
- if (roomName !== undefined) {
- await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
- }
- if (roomTopic !== undefined) {
- await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
- }
- },
- [mx, room.roomId]
- )
- );
- const submitting = submitState.status === AsyncStatus.Loading;
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- if (uploadingAvatar) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const nameInput = target?.nameInput as HTMLInputElement | undefined;
- const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
- if (!nameInput || !topicTextArea) return;
-
- const roomName = nameInput.value.trim();
- const roomTopic = topicTextArea.value.trim();
-
- if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
- return;
- }
-
- submit(
- roomAvatar === avatar ? undefined : roomAvatar || null,
- roomName === name ? undefined : roomName,
- roomTopic === topic ? undefined : roomTopic
- ).then(() => {
- if (alive()) {
- onClose();
- }
- });
- };
-
- return (
- <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
- <Box gap="400">
- <Box grow="Yes" direction="Column" gap="100">
- <Text size="L400">Avatar</Text>
- {uploadAtom ? (
- <Box gap="200" direction="Column">
- <CompactUploadCardRenderer
- uploadAtom={uploadAtom}
- onRemove={handleRemoveUpload}
- onComplete={handleUploaded}
- />
- </Box>
- ) : (
- <Box gap="200">
- <Button
- type="button"
- size="300"
- variant="Secondary"
- fill="Soft"
- radii="300"
- disabled={!canEditAvatar || submitting}
- onClick={() => pickFile('image/*')}
- >
- <Text size="B300">Upload</Text>
- </Button>
- {!roomAvatar && avatar && (
- <Button
- type="button"
- size="300"
- variant="Success"
- fill="None"
- radii="300"
- disabled={!canEditAvatar || submitting}
- onClick={() => setRoomAvatar(avatar)}
- >
- <Text size="B300">Reset</Text>
- </Button>
- )}
- {roomAvatar && (
- <Button
- type="button"
- size="300"
- variant="Critical"
- fill="None"
- radii="300"
- disabled={!canEditAvatar || submitting}
- onClick={() => setRoomAvatar(undefined)}
- >
- <Text size="B300">Remove</Text>
- </Button>
- )}
- </Box>
- )}
- </Box>
- <Box shrink="No">
- <Avatar size="500" radii="300">
- <RoomAvatar
- roomId={room.roomId}
- src={avatarUrl}
- alt={name}
- renderFallback={() => (
- <RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
- )}
- />
- </Avatar>
- </Box>
- </Box>
- <Box direction="Inherit" gap="100">
- <Text size="L400">Name</Text>
- <Input
- name="nameInput"
- defaultValue={name}
- variant="Secondary"
- radii="300"
- readOnly={!canEditName || submitting}
- />
- </Box>
- <Box direction="Inherit" gap="100">
- <Text size="L400">Topic</Text>
- <TextArea
- name="topicTextArea"
- defaultValue={topic}
- variant="Secondary"
- radii="300"
- readOnly={!canEditTopic || submitting}
- />
- </Box>
- {submitState.status === AsyncStatus.Error && (
- <Text size="T200" style={{ color: color.Critical.Main }}>
- {(submitState.error as MatrixError).message}
- </Text>
- )}
- <Box gap="300">
- <Button
- type="submit"
- variant="Success"
- size="300"
- radii="300"
- disabled={uploadingAvatar || submitting}
- before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
- >
- <Text size="B300">Save</Text>
- </Button>
- <Button
- type="reset"
- onClick={onClose}
- variant="Secondary"
- fill="Soft"
- size="300"
- radii="300"
- >
- <Text size="B300">Cancel</Text>
- </Button>
- </Box>
- </Box>
- );
-}
-
-type RoomProfileProps = {
- powerLevels: IPowerLevels;
-};
-export function RoomProfile({ powerLevels }: RoomProfileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const room = useRoom();
- const directs = useAtomValue(mDirectAtom);
- const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
- const userPowerLevel = getPowerLevel(mx.getSafeUserId());
-
- const avatar = useRoomAvatar(room, directs.has(room.roomId));
- const name = useRoomName(room);
- const topic = useRoomTopic(room);
- const joinRule = useRoomJoinRule(room);
-
- const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
- const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
- const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
- const canEdit = canEditAvatar || canEditName || canEditTopic;
-
- const avatarUrl = avatar
- ? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
- : undefined;
-
- const [edit, setEdit] = useState(false);
-
- const handleCloseEdit = useCallback(() => setEdit(false), []);
-
- return (
- <Box direction="Column" gap="100">
- <Text size="L400">Profile</Text>
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- {edit ? (
- <RoomProfileEdit
- canEditAvatar={canEditAvatar}
- canEditName={canEditName}
- canEditTopic={canEditTopic}
- avatar={avatar}
- name={name ?? ''}
- topic={topic ?? ''}
- onClose={handleCloseEdit}
- />
- ) : (
- <Box gap="400">
- <Box grow="Yes" direction="Column" gap="300">
- <Box direction="Column" gap="100">
- <Text className={BreakWord} size="H5">
- {name ?? 'Unknown'}
- </Text>
- {topic && (
- <Text className={classNames(BreakWord, LineClamp3)} size="T200">
- <Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
- </Text>
- )}
- </Box>
- {canEdit && (
- <Box gap="200">
- <Chip
- variant="Secondary"
- fill="Soft"
- radii="300"
- before={<Icon size="50" src={Icons.Pencil} />}
- onClick={() => setEdit(true)}
- outlined
- >
- <Text size="B300">Edit</Text>
- </Chip>
- </Box>
- )}
- </Box>
- <Box shrink="No">
- <Avatar size="500" radii="300">
- <RoomAvatar
- roomId={room.roomId}
- src={avatarUrl}
- alt={name}
- renderFallback={() => (
- <RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
- )}
- />
- </Avatar>
- </Box>
- </Box>
- )}
- </SequenceCard>
- </Box>
- );
-}
+++ /dev/null
-import React from 'react';
-import { Box, color, Spinner, Switch, Text } from 'folds';
-import { MatrixError } from 'matrix-js-sdk';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useRoom } from '../../../hooks/useRoom';
-import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-
-type RoomPublishProps = {
- powerLevels: IPowerLevels;
-};
-export function RoomPublish({ powerLevels }: RoomPublishProps) {
- const mx = useMatrixClient();
- const room = useRoom();
- const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
- const canEditCanonical = powerLevelAPI.canSendStateEvent(
- powerLevels,
- StateEvent.RoomCanonicalAlias,
- userPowerLevel
- );
-
- const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
-
- const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility);
-
- const loading =
- visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
-
- return (
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <SettingTile
- title="Publish To Directory"
- after={
- <Box gap="200" alignItems="Center">
- {loading && <Spinner variant="Secondary" />}
- {!loading && visibilityState.status === AsyncStatus.Success && (
- <Switch
- value={visibilityState.data}
- onChange={toggleVisibility}
- disabled={!canEditCanonical}
- />
- )}
- </Box>
- }
- >
- {visibilityState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T200">
- {(visibilityState.error as MatrixError).message}
- </Text>
- )}
-
- {toggleState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T200">
- {(toggleState.error as MatrixError).message}
- </Text>
- )}
- </SettingTile>
- </SequenceCard>
- );
-}
+++ /dev/null
-import React, { FormEventHandler, useCallback, useState } from 'react';
-import {
- Button,
- color,
- Spinner,
- Text,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Dialog,
- Header,
- config,
- Box,
- IconButton,
- Icon,
- Icons,
- Input,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
-import { MatrixError } from 'matrix-js-sdk';
-import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useRoom } from '../../../hooks/useRoom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
-import { StateEvent } from '../../../../types/matrix/room';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useStateEvent } from '../../../hooks/useStateEvent';
-import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
-import { useCapabilities } from '../../../hooks/useCapabilities';
-import { stopPropagation } from '../../../utils/keyboard';
-
-type RoomUpgradeProps = {
- powerLevels: IPowerLevels;
- requestClose: () => void;
-};
-export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
- const mx = useMatrixClient();
- const room = useRoom();
- const { navigateRoom } = useRoomNavigate();
- const createContent = useStateEvent(
- room,
- StateEvent.RoomCreate
- )?.getContent<RoomCreateEventContent>();
- const roomVersion = createContent?.room_version ?? 1;
- const predecessorRoomId = createContent?.predecessor?.room_id;
-
- const capabilities = useCapabilities();
- const defaultRoomVersion = capabilities['m.room_versions']?.default;
-
- const tombstoneContent = useStateEvent(
- room,
- StateEvent.RoomTombstone
- )?.getContent<RoomTombstoneEventContent>();
- const replacementRoom = tombstoneContent?.replacement_room;
-
- const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
- const canUpgrade = powerLevelAPI.canSendStateEvent(
- powerLevels,
- StateEvent.RoomTombstone,
- userPowerLevel
- );
-
- const handleOpenRoom = () => {
- if (replacementRoom) {
- requestClose();
- navigateRoom(replacementRoom);
- }
- };
-
- const handleOpenOldRoom = () => {
- if (predecessorRoomId) {
- requestClose();
- navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
- }
- };
-
- const [upgradeState, upgrade] = useAsyncCallback(
- useCallback(
- async (version: string) => {
- await mx.upgradeRoom(room.roomId, version);
- },
- [mx, room]
- )
- );
-
- const upgrading = upgradeState.status === AsyncStatus.Loading;
-
- const [prompt, setPrompt] = useState(false);
-
- const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
-
- const target = evt.target as HTMLFormElement | undefined;
- const versionInput = target?.versionInput as HTMLInputElement | undefined;
- const version = versionInput?.value.trim();
- if (!version) return;
-
- upgrade(version);
- setPrompt(false);
- };
-
- return (
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <SettingTile
- title="Upgrade Room"
- description={
- replacementRoom
- ? tombstoneContent.body || 'This room has been replaced!'
- : `Current room version: ${roomVersion}.`
- }
- after={
- <Box alignItems="Center" gap="200">
- {predecessorRoomId && (
- <Button
- size="300"
- variant="Secondary"
- fill="Soft"
- outlined
- radii="300"
- onClick={handleOpenOldRoom}
- >
- <Text size="B300">Old Room</Text>
- </Button>
- )}
- {replacementRoom ? (
- <Button
- size="300"
- variant="Success"
- fill="Solid"
- radii="300"
- onClick={handleOpenRoom}
- >
- <Text size="B300">Open New Room</Text>
- </Button>
- ) : (
- <Button
- size="300"
- variant="Secondary"
- fill="Solid"
- radii="300"
- disabled={upgrading || !canUpgrade}
- before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
- onClick={() => setPrompt(true)}
- >
- <Text size="B300">Upgrade</Text>
- </Button>
- )}
- </Box>
- }
- >
- {upgradeState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T200">
- {(upgradeState.error as MatrixError).message}
- </Text>
- )}
-
- {prompt && (
- <Overlay open backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setPrompt(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- <Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
- <Header
- style={{
- padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
- borderBottomWidth: config.borderWidth.B300,
- }}
- variant="Surface"
- size="500"
- >
- <Box grow="Yes">
- <Text size="H4">Room Upgrade</Text>
- </Box>
- <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Header>
- <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
- <Text priority="400" style={{ color: color.Critical.Main }}>
- <b>This action is irreversible!</b>
- </Text>
- <Box direction="Column" gap="100">
- <Text size="L400">Version</Text>
- <Input
- defaultValue={defaultRoomVersion}
- name="versionInput"
- variant="Background"
- required
- />
- </Box>
- <Button type="submit" variant="Secondary">
- <Text size="B400">Upgrade Room</Text>
- </Button>
- </Box>
- </Dialog>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- </SettingTile>
- </SequenceCard>
- );
-}
+++ /dev/null
-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>
- );
-}
+++ /dev/null
-export * from './Members';
+++ /dev/null
-/* 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>
- )}
- </>
- );
-}
import React, { useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
-import { Powers } from './Powers';
import { useRoom } from '../../../hooks/useRoom';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
-import { PowersEditor } from './PowersEditor';
-import { PermissionGroups } from './PermissionGroups';
+import { usePermissionGroups } from './usePermissionItems';
+import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
type PermissionsProps = {
requestClose: () => void;
StateEvent.PowerLevelTags,
getPowerLevel(mx.getSafeUserId())
);
+ const permissionGroups = usePermissionGroups();
const [powerEditor, setPowerEditor] = useState(false);
<Powers
powerLevels={powerLevels}
onEdit={canEditPowers ? handleEditPowers : undefined}
+ permissionGroups={permissionGroups}
/>
- <PermissionGroups powerLevels={powerLevels} />
+ <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
</Box>
</PageContent>
</Scroll>
+++ /dev/null
-/* 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>
- );
-}
+++ /dev/null
-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>
- );
-}
import { useMemo } from 'react';
-import { PermissionLocation } from '../../../hooks/usePowerLevels';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
-
-export type PermissionItem = {
- location: PermissionLocation;
- name: string;
- description?: string;
-};
-
-export type PermissionGroup = {
- name: string;
- items: PermissionItem[];
-};
+import { PermissionGroup } from '../../common-settings/permissions';
export const usePermissionGroups = (): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => {
--- /dev/null
+import React, { useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
+import { JoinRule } from 'matrix-js-sdk';
+import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { mxcUrlToHttp } from '../../utils/matrix';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { SpaceSettingsPage } from '../../state/spaceSettings';
+import { useRoom } from '../../hooks/useRoom';
+import { EmojisStickers } from '../common-settings/emojis-stickers';
+import { Members } from '../common-settings/members';
+import { DeveloperTools } from '../common-settings/developer-tools';
+import { General } from './general';
+import { Permissions } from './permissions';
+
+type SpaceSettingsMenuItem = {
+ page: SpaceSettingsPage;
+ name: string;
+ icon: IconSrc;
+};
+
+const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] =>
+ useMemo(
+ () => [
+ {
+ page: SpaceSettingsPage.GeneralPage,
+ name: 'General',
+ icon: Icons.Setting,
+ },
+ {
+ page: SpaceSettingsPage.MembersPage,
+ name: 'Members',
+ icon: Icons.User,
+ },
+ {
+ page: SpaceSettingsPage.PermissionsPage,
+ name: 'Permissions',
+ icon: Icons.Lock,
+ },
+ {
+ page: SpaceSettingsPage.EmojisStickersPage,
+ name: 'Emojis & Stickers',
+ icon: Icons.Smile,
+ },
+ {
+ page: SpaceSettingsPage.DeveloperToolsPage,
+ name: 'Developer Tools',
+ icon: Icons.Terminal,
+ },
+ ],
+ []
+ );
+
+type SpaceSettingsProps = {
+ initialPage?: SpaceSettingsPage;
+ requestClose: () => void;
+};
+export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {
+ const room = useRoom();
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const mDirects = useAtomValue(mDirectAtom);
+
+ const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
+ const roomName = useRoomName(room);
+ const joinRuleContent = useRoomJoinRule(room);
+
+ const avatarUrl = roomAvatar
+ ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const screenSize = useScreenSizeContext();
+ const [activePage, setActivePage] = useState<SpaceSettingsPage | undefined>(() => {
+ if (initialPage) return initialPage;
+ return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
+ });
+ const menuItems = useSpaceSettingsMenuItems();
+
+ const handlePageRequestClose = () => {
+ if (screenSize === ScreenSize.Mobile) {
+ setActivePage(undefined);
+ return;
+ }
+ requestClose();
+ };
+
+ return (
+ <PageRoot
+ nav={
+ screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
+ <PageNav size="300">
+ <PageNavHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarUrl}
+ alt={roomName}
+ renderFallback={() => (
+ <RoomIcon
+ space
+ size="50"
+ joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
+ filled
+ />
+ )}
+ />
+ </Avatar>
+ <Text size="H4" truncate>
+ {roomName}
+ </Text>
+ </Box>
+ <Box shrink="No">
+ {screenSize === ScreenSize.Mobile && (
+ <IconButton onClick={requestClose} variant="Background">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ )}
+ </Box>
+ </PageNavHeader>
+ <Box grow="Yes" direction="Column">
+ <PageNavContent>
+ <div style={{ flexGrow: 1 }}>
+ {menuItems.map((item) => (
+ <MenuItem
+ key={item.name}
+ variant="Background"
+ radii="400"
+ aria-pressed={activePage === item.page}
+ before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
+ onClick={() => setActivePage(item.page)}
+ >
+ <Text
+ style={{
+ fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
+ }}
+ size="T300"
+ truncate
+ >
+ {item.name}
+ </Text>
+ </MenuItem>
+ ))}
+ </div>
+ </PageNavContent>
+ </Box>
+ </PageNav>
+ )
+ }
+ >
+ {activePage === SpaceSettingsPage.GeneralPage && (
+ <General requestClose={handlePageRequestClose} />
+ )}
+ {activePage === SpaceSettingsPage.MembersPage && (
+ <Members requestClose={handlePageRequestClose} />
+ )}
+ {activePage === SpaceSettingsPage.PermissionsPage && (
+ <Permissions requestClose={handlePageRequestClose} />
+ )}
+ {activePage === SpaceSettingsPage.EmojisStickersPage && (
+ <EmojisStickers requestClose={handlePageRequestClose} />
+ )}
+ {activePage === SpaceSettingsPage.DeveloperToolsPage && (
+ <DeveloperTools requestClose={handlePageRequestClose} />
+ )}
+ </PageRoot>
+ );
+}
--- /dev/null
+import React from 'react';
+import { SpaceSettings } from './SpaceSettings';
+import { Modal500 } from '../../components/Modal500';
+import { useCloseSpaceSettings, useSpaceSettingsState } from '../../state/hooks/spaceSettings';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { SpaceSettingsState } from '../../state/spaceSettings';
+import { RoomProvider } from '../../hooks/useRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+
+type RenderSettingsProps = {
+ state: SpaceSettingsState;
+};
+function RenderSettings({ state }: RenderSettingsProps) {
+ const { roomId, spaceId, page } = state;
+ const closeSettings = useCloseSpaceSettings();
+ const allJoinedRooms = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allJoinedRooms);
+ const room = getRoom(roomId);
+ const space = spaceId ? getRoom(spaceId) : undefined;
+
+ if (!room) return null;
+
+ return (
+ <Modal500 requestClose={closeSettings}>
+ <SpaceProvider value={space ?? null}>
+ <RoomProvider value={room}>
+ <SpaceSettings initialPage={page} requestClose={closeSettings} />
+ </RoomProvider>
+ </SpaceProvider>
+ </Modal500>
+ );
+}
+
+export function SpaceSettingsRenderer() {
+ const state = useSpaceSettingsState();
+
+ if (!state) return null;
+ return <RenderSettings state={state} />;
+}
--- /dev/null
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { usePowerLevels } from '../../../hooks/usePowerLevels';
+import { useRoom } from '../../../hooks/useRoom';
+import {
+ RoomProfile,
+ RoomJoinRules,
+ RoomLocalAddresses,
+ RoomPublishedAddresses,
+ RoomPublish,
+ RoomUpgrade,
+} from '../../common-settings/general';
+
+type GeneralProps = {
+ requestClose: () => void;
+};
+export function General({ requestClose }: GeneralProps) {
+ const room = useRoom();
+ const powerLevels = usePowerLevels(room);
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ General
+ </Text>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes">
+ <Scroll hideTrack visibility="Hover">
+ <PageContent>
+ <Box direction="Column" gap="700">
+ <RoomProfile powerLevels={powerLevels} />
+ <Box direction="Column" gap="100">
+ <Text size="L400">Options</Text>
+ <RoomJoinRules powerLevels={powerLevels} />
+ <RoomPublish powerLevels={powerLevels} />
+ </Box>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Addresses</Text>
+ <RoomPublishedAddresses powerLevels={powerLevels} />
+ <RoomLocalAddresses powerLevels={powerLevels} />
+ </Box>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Advance Options</Text>
+ <RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
+ </Box>
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './General';
--- /dev/null
+export * from './SpaceSettings';
+export * from './SpaceSettingsRenderer';
--- /dev/null
+import React, { useState } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { useRoom } from '../../../hooks/useRoom';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { usePermissionGroups } from './usePermissionItems';
+import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
+
+type PermissionsProps = {
+ requestClose: () => void;
+};
+export function Permissions({ requestClose }: PermissionsProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const canEditPowers = canSendStateEvent(
+ StateEvent.PowerLevelTags,
+ getPowerLevel(mx.getSafeUserId())
+ );
+ const permissionGroups = usePermissionGroups();
+
+ const [powerEditor, setPowerEditor] = useState(false);
+
+ const handleEditPowers = () => {
+ setPowerEditor(true);
+ };
+
+ if (canEditPowers && powerEditor) {
+ return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
+ }
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ Permissions
+ </Text>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes">
+ <Scroll hideTrack visibility="Hover">
+ <PageContent>
+ <Box direction="Column" gap="700">
+ <Powers
+ powerLevels={powerLevels}
+ onEdit={canEditPowers ? handleEditPowers : undefined}
+ permissionGroups={permissionGroups}
+ />
+ <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Permissions';
--- /dev/null
+import { useMemo } from 'react';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PermissionGroup } from '../../common-settings/permissions';
+
+export const usePermissionGroups = (): PermissionGroup[] => {
+ const groups: PermissionGroup[] = useMemo(() => {
+ const messagesGroup: PermissionGroup = {
+ name: 'Manage',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.SpaceChild,
+ },
+ name: 'Manage space rooms',
+ },
+ {
+ location: {},
+ name: 'Message Events',
+ },
+ ],
+ };
+
+ const moderationGroup: PermissionGroup = {
+ name: 'Moderation',
+ items: [
+ {
+ location: {
+ action: true,
+ key: 'invite',
+ },
+ name: 'Invite',
+ },
+ {
+ location: {
+ action: true,
+ key: 'kick',
+ },
+ name: 'Kick',
+ },
+ {
+ location: {
+ action: true,
+ key: 'ban',
+ },
+ name: 'Ban',
+ },
+ ],
+ };
+
+ const roomOverviewGroup: PermissionGroup = {
+ name: 'Space Overview',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomAvatar,
+ },
+ name: 'Space Avatar',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomName,
+ },
+ name: 'Space Name',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomTopic,
+ },
+ name: 'Space Topic',
+ },
+ ],
+ };
+
+ const roomSettingsGroup: PermissionGroup = {
+ name: 'Settings',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomJoinRules,
+ },
+ name: 'Change Space Access',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomCanonicalAlias,
+ },
+ name: 'Publish Address',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomPowerLevels,
+ },
+ name: 'Change All Permission',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.PowerLevelTags,
+ },
+ name: 'Edit Power Levels',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomTombstone,
+ },
+ name: 'Upgrade Space',
+ },
+ {
+ location: {
+ state: true,
+ },
+ name: 'Other Settings',
+ },
+ ],
+ };
+
+ const otherSettingsGroup: PermissionGroup = {
+ name: 'Other',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomServerAcl,
+ },
+ name: 'Change Server ACLs',
+ },
+ ],
+ };
+
+ return [
+ messagesGroup,
+ moderationGroup,
+ roomOverviewGroup,
+ roomSettingsGroup,
+ otherSettingsGroup,
+ ];
+ }, []);
+
+ return groups;
+};
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const SequenceCardStyle = style({
+ padding: config.space.S300,
+});
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
+import { SpaceSettingsRenderer } from '../features/space-settings';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
<Outlet />
</ClientLayout>
<RoomSettingsRenderer />
+ <SpaceSettingsRenderer />
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../../client/action/notifications';
import { copyToClipboard } from '../../../utils/dom';
-import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { openInviteUser } from '../../../../client/action/navigation';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
+import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
type SpaceMenuProps = {
room: Room;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+ const openSpaceSettings = useOpenSpaceSettings();
const allChild = useSpaceChildren(
allRoomsAtom,
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
-import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { openInviteUser } from '../../../../client/action/navigation';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../../client/action/notifications';
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
+import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
type SpaceMenuProps = {
room: Room;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+ const openSpaceSettings = useOpenSpaceSettings();
const allChild = useSpaceChildren(
allRoomsAtom,
--- /dev/null
+import { useCallback } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { spaceSettingsAtom, SpaceSettingsPage, SpaceSettingsState } from '../spaceSettings';
+
+export const useSpaceSettingsState = (): SpaceSettingsState | undefined => {
+ const data = useAtomValue(spaceSettingsAtom);
+
+ return data;
+};
+
+type CloseCallback = () => void;
+export const useCloseSpaceSettings = (): CloseCallback => {
+ const setSettings = useSetAtom(spaceSettingsAtom);
+
+ const close: CloseCallback = useCallback(() => {
+ setSettings(undefined);
+ }, [setSettings]);
+
+ return close;
+};
+
+type OpenCallback = (roomId: string, space?: string, page?: SpaceSettingsPage) => void;
+export const useOpenSpaceSettings = (): OpenCallback => {
+ const setSettings = useSetAtom(spaceSettingsAtom);
+
+ const open: OpenCallback = useCallback(
+ (roomId, spaceId, page) => {
+ setSettings({ roomId, spaceId, page });
+ },
+ [setSettings]
+ );
+
+ return open;
+};
--- /dev/null
+import { atom } from 'jotai';
+
+export enum SpaceSettingsPage {
+ GeneralPage,
+ MembersPage,
+ PermissionsPage,
+ EmojisStickersPage,
+ DeveloperToolsPage,
+}
+
+export type SpaceSettingsState = {
+ page?: SpaceSettingsPage;
+ roomId: string;
+ spaceId?: string;
+};
+
+export const spaceSettingsAtom = atom<SpaceSettingsState | undefined>(undefined);