Add new space settings (#2293)

This commit is contained in:
Ajay Bura 2025-03-27 19:54:13 +11:00 committed by GitHub
parent 4aed4d7472
commit 5c39a36c12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 691 additions and 63 deletions

View file

@ -0,0 +1,396 @@
import React, { useCallback, useState } from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Switch,
Button,
MenuItem,
config,
color,
} from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { copyToClipboard } from '../../../utils/dom';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomState } from '../../../hooks/useRoomState';
import { StateEventEditor, StateEventInfo } from './StateEventEditor';
import { SendRoomEvent } from './SendRoomEvent';
import { useRoomAccountData } from '../../../hooks/useRoomAccountData';
import { CutoutCard } from '../../../components/cutout-card';
import {
AccountDataEditor,
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
type DeveloperToolsProps = {
requestClose: () => void;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const mx = useMatrixClient();
const room = useRoom();
const roomState = useRoomState(room);
const accountData = useRoomAccountData(room);
const [expandState, setExpandState] = useState(false);
const [expandStateType, setExpandStateType] = useState<string>();
const [openStateEvent, setOpenStateEvent] = useState<StateEventInfo>();
const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>();
const [expandAccountData, setExpandAccountData] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
const handleClose = useCallback(() => {
setOpenStateEvent(undefined);
setComposeEvent(undefined);
setAccountDataType(undefined);
}, []);
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
await mx.setRoomAccountData(room.roomId, type, content);
},
[mx, room.roomId]
);
if (accountDataType !== undefined) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
content={accountDataType ? accountData.get(accountDataType) : undefined}
submitChange={submitAccountData}
requestClose={handleClose}
/>
);
}
if (composeEvent) {
return <SendRoomEvent {...composeEvent} requestClose={handleClose} />;
}
if (openStateEvent) {
return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Room ID"
description={`Copy room ID to clipboard. ("${room.roomId}")`}
after={
<Button
onClick={() => copyToClipboard(room.roomId ?? '<NO_ROOM_ID_FOUND>')}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Copy</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="New Message Event"
description="Create and send a new message event within the room."
after={
<Button
onClick={() => setComposeEvent({})}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Compose</Text>
</Button>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Room State"
description="State events of the room."
after={
<Button
onClick={() => setExpandState(!expandState)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon
src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
>
<Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expandState && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {roomState.size}</Text>
</Box>
<CutoutCard>
<MenuItem
onClick={() => setComposeEvent({ stateKey: '' })}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(roomState.keys())
.sort()
.map((eventType) => {
const expanded = eventType === expandStateType;
const stateKeyToEvents = roomState.get(eventType);
if (!stateKeyToEvents) return null;
return (
<Box id={eventType} key={eventType} direction="Column" gap="100">
<MenuItem
onClick={() =>
setExpandStateType(expanded ? undefined : eventType)
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={
<Icon
size="50"
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
/>
}
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
>
<Box grow="Yes">
<Text size="T200" truncate>
{eventType}
</Text>
</Box>
</MenuItem>
{expanded && (
<div
style={{
marginLeft: config.space.S400,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<MenuItem
onClick={() =>
setComposeEvent({ type: eventType, stateKey: '' })
}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(stateKeyToEvents.keys())
.sort()
.map((stateKey) => (
<MenuItem
onClick={() => {
setOpenStateEvent({
type: eventType,
stateKey,
});
}}
key={stateKey}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
{stateKey ? `"${stateKey}"` : 'Default'}
</Text>
</Box>
</MenuItem>
))}
</div>
)}
</Box>
);
})}
</CutoutCard>
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Account Data"
description="Private personalization data stored within room."
after={
<Button
onClick={() => setExpandAccountData(!expandAccountData)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon
src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
>
<Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expandAccountData && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountData.size}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => setAccountDataType(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(accountData.keys())
.sort()
.map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => setAccountDataType(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
)}
</SequenceCard>
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,208 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import {
Box,
Chip,
Icon,
Icons,
IconButton,
Text,
config,
Button,
Spinner,
color,
TextArea as TextAreaComponent,
Input,
} from 'folds';
import { Page, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { useAlive } from '../../../hooks/useAlive';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area';
const EDITOR_INTENT_SPACE_COUNT = 2;
export type SendRoomEventProps = {
type?: string;
stateKey?: string;
requestClose: () => void;
};
export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const composeStateEvent = typeof stateKey === 'string';
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [jsonError, setJSONError] = useState<SyntaxError>();
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
textAreaRef,
EDITOR_INTENT_SPACE_COUNT
);
const [submitState, submit] = useAsyncCallback<
object,
MatrixError,
[string, string | undefined, object]
>(
useCallback(
(evtType, evtStateKey, evtContent) => {
if (typeof evtStateKey === 'string') {
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
}
return mx.sendEvent(room.roomId, evtType as any, evtContent);
},
[mx, room]
)
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (submitting) return;
const target = evt.target as HTMLFormElement | undefined;
const typeInput = target?.typeInput as HTMLInputElement | undefined;
const stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
if (!typeInput || !contentTextArea) return;
const evtType = typeInput.value;
const evtStateKey = stateKeyInput?.value;
const contentStr = contentTextArea.value.trim();
let parsedContent: object;
try {
parsedContent = JSON.parse(contentStr);
} catch (e) {
setJSONError(e as SyntaxError);
return;
}
setJSONError(undefined);
if (parsedContent === null) {
return;
}
submit(evtType, evtStateKey, parsedContent).then(() => {
if (alive()) {
requestClose();
}
});
};
useEffect(() => {
if (jsonError) {
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
const cursor = new Cursor(errorPosition, errorPosition, 'none');
operations.select(cursor);
getTarget()?.focus();
}
}, [jsonError, operations, getTarget]);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Developer Tools</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<Box
as="form"
onSubmit={handleSubmit}
grow="Yes"
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
aria-disabled={submitting}
>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
<Box gap="300">
<Box grow="Yes" direction="Column">
<Input
variant="Background"
name="typeInput"
size="400"
radii="300"
readOnly={submitting}
defaultValue={type}
required
/>
</Box>
<Button
variant="Success"
size="400"
radii="300"
type="submit"
disabled={submitting}
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
>
<Text size="B400">Send</Text>
</Button>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{submitState.error.message}</b>
</Text>
)}
</Box>
{composeStateEvent && (
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">State Key (Optional)</Text>
<Input
variant="Background"
name="stateKeyInput"
size="400"
radii="300"
readOnly={submitting}
defaultValue={stateKey}
/>
</Box>
)}
<Box grow="Yes" direction="Column" gap="100">
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}
onKeyDown={handleKeyDown}
resize="None"
spellCheck="false"
required
readOnly={submitting}
/>
{jsonError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>
{jsonError.name}: {jsonError.message}
</b>
</Text>
)}
</Box>
</Box>
</Box>
</Page>
);
}

View file

@ -0,0 +1,298 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Text,
Icon,
Icons,
IconButton,
Chip,
Scroll,
config,
TextArea as TextAreaComponent,
color,
Spinner,
Button,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { Page, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { TextViewerContent } from '../../../components/text-viewer';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAlive } from '../../../hooks/useAlive';
import { Cursor } from '../../../plugins/text-area';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
const EDITOR_INTENT_SPACE_COUNT = 2;
type StateEventEditProps = {
type: string;
stateKey: string;
content: object;
requestClose: () => void;
};
function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const defaultContentStr = useMemo(
() => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
[content]
);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [jsonError, setJSONError] = useState<SyntaxError>();
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
textAreaRef,
EDITOR_INTENT_SPACE_COUNT
);
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
useCallback(
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
[mx, room, type, stateKey]
)
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (submitting) return;
const target = evt.target as HTMLFormElement | undefined;
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
if (!contentTextArea) return;
const contentStr = contentTextArea.value.trim();
let parsedContent: object;
try {
parsedContent = JSON.parse(contentStr);
} catch (e) {
setJSONError(e as SyntaxError);
return;
}
setJSONError(undefined);
if (
parsedContent === null ||
defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
) {
return;
}
submit(parsedContent).then(() => {
if (alive()) {
requestClose();
}
});
};
useEffect(() => {
if (jsonError) {
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
const cursor = new Cursor(errorPosition, errorPosition, 'none');
operations.select(cursor);
getTarget()?.focus();
}
}, [jsonError, operations, getTarget]);
return (
<Box
as="form"
onSubmit={handleSubmit}
grow="Yes"
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
aria-disabled={submitting}
>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">State Event</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={type}
description={stateKey}
after={
<Box gap="200">
<Button
variant="Success"
size="300"
radii="300"
type="submit"
disabled={submitting}
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
>
<Text size="B300">Save</Text>
</Button>
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={requestClose}
disabled={submitting}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
}
/>
</SequenceCard>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{submitState.error.message}</b>
</Text>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}
onKeyDown={handleKeyDown}
defaultValue={defaultContentStr}
resize="None"
spellCheck="false"
required
readOnly={submitting}
/>
{jsonError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>
{jsonError.name}: {jsonError.message}
</b>
</Text>
)}
</Box>
</Box>
);
}
type StateEventViewProps = {
content: object;
eventJSONStr: string;
onEditContent?: (content: object) => void;
};
function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
return (
<Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
<Box grow="Yes" direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Box grow="Yes">
<Text size="L400">State Event</Text>
</Box>
{onEditContent && (
<Box shrink="No" gap="200">
<Chip
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => onEditContent(content)}
>
<Text size="B300">Edit</Text>
</Chip>
</Box>
)}
</Box>
<SequenceCard variant="SurfaceVariant">
<Scroll visibility="Always" size="300" hideTrack>
<TextViewerContent
size="T300"
style={{
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
}}
text={eventJSONStr}
langName="JSON"
/>
</Scroll>
</SequenceCard>
</Box>
</Box>
);
}
export type StateEventInfo = {
type: string;
stateKey: string;
};
export type StateEventEditorProps = StateEventInfo & {
requestClose: () => void;
};
export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
const mx = useMatrixClient();
const room = useRoom();
const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
const [editContent, setEditContent] = useState<object>();
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
const eventJSONStr = useMemo(() => {
if (!stateEvent) return '';
return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
}, [stateEvent]);
const handleCloseEdit = useCallback(() => {
setEditContent(undefined);
}, []);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Developer Tools</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
{editContent ? (
<StateEventEdit
type={type}
stateKey={stateKey}
content={editContent}
requestClose={handleCloseEdit}
/>
) : (
<StateEventView
content={stateEvent?.getContent() ?? {}}
onEditContent={canEdit ? setEditContent : undefined}
eventJSONStr={eventJSONStr}
/>
)}
</Box>
</Page>
);
}

View file

@ -0,0 +1 @@
export * from './DevelopTools';

View file

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
import { RoomPacks } from './RoomPacks';
type EmojisStickersProps = {
requestClose: () => void;
};
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
const [imagePack, setImagePack] = useState<ImagePack>();
const handleImagePackViewClose = () => {
setImagePack(undefined);
};
if (imagePack) {
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Emojis & Stickers
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<RoomPacks onViewPack={setImagePack} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,349 @@
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
Button,
Icon,
Icons,
Avatar,
AvatarImage,
AvatarFallback,
toRem,
config,
Input,
Spinner,
color,
IconButton,
Menu,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import {
ImagePack,
ImageUsage,
PackAddress,
packAddressEqual,
PackContent,
} from '../../../plugins/custom-emoji';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomImagePacks } from '../../../hooks/useImagePacks';
import { LineClamp2 } from '../../../styles/Text.css';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { suffixRename } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
type CreatePackTileProps = {
packs: ImagePack[];
roomId: string;
};
function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
useCallback(
async (stateKey, name) => {
const content: PackContent = {
pack: {
display_name: name,
},
};
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
},
[mx, roomId]
)
);
const creating = addState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (creating) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
if (!nameInput) return;
const name = nameInput?.value.trim();
if (!name) return;
let packKey = name.replace(/\s/g, '-');
const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
if (hasPack(packKey)) {
packKey = suffixRename(packKey, hasPack);
}
addPack(packKey, name).then(() => {
if (alive()) {
nameInput.value = '';
}
});
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="New Pack"
description="Add your own emoji and sticker pack to use in room."
>
<Box
style={{ marginTop: config.space.S200 }}
as="form"
onSubmit={handleSubmit}
gap="200"
alignItems="End"
>
<Box direction="Column" gap="100" grow="Yes">
<Text size="L400">Name</Text>
<Input
name="nameInput"
required
size="400"
variant="Secondary"
radii="300"
readOnly={creating}
/>
{addState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{addState.error.message}
</Text>
)}
</Box>
<Button
variant="Success"
radii="300"
type="submit"
disabled={creating}
before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
>
<Text size="B400">Create</Text>
</Button>
</Box>
</SettingTile>
</SequenceCard>
);
}
type RoomPacksProps = {
onViewPack: (imagePack: ImagePack) => void;
};
export function RoomPacks({ onViewPack }: RoomPacksProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const alive = useAlive();
const powerLevels = usePowerLevels(room);
const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
const unfilteredPacks = useRoomImagePacks(room);
const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
const hasChanges = removedPacks.length > 0;
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
for (let i = 0; i < removedPacks.length; i += 1) {
const addr = removedPacks[i];
// eslint-disable-next-line no-await-in-loop
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
}
}, [mx, room, removedPacks])
);
const applyingChanges = applyState.status === AsyncStatus.Loading;
const handleRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => [...addresses, address]);
};
const handleUndoRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
};
const handleCancelChanges = () => setRemovedPacks([]);
const handleApplyChanges = () => {
applyChanges().then(() => {
if (alive()) {
setRemovedPacks([]);
}
});
};
const renderPack = (pack: ImagePack) => {
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const { address } = pack;
if (!address) return null;
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
return (
<SequenceCard
key={pack.id}
className={SequenceCardStyle}
variant={removed ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<SettingTile
title={
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
{pack.meta.name ?? 'Unknown'}
</span>
}
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
before={
<Box alignItems="Center" gap="300">
{canEdit &&
(removed ? (
<IconButton
size="300"
radii="Pill"
variant="Critical"
onClick={() => handleUndoRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Plus} size="100" />
</IconButton>
) : (
<IconButton
size="300"
radii="Pill"
variant="Secondary"
onClick={() => handleRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
))}
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
</Box>
}
after={
!removed && (
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={() => onViewPack(pack)}
>
<Text size="B300">View</Text>
</Button>
)
}
/>
</SequenceCard>
);
};
return (
<>
<Box direction="Column" gap="100">
<Text size="L400">Packs</Text>
{canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
{packs.map(renderPack)}
{packs.length === 0 && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box
justifyContent="Center"
direction="Column"
gap="200"
style={{
padding: `${config.space.S700} ${config.space.S400}`,
maxWidth: toRem(300),
margin: 'auto',
}}
>
<Text size="H5" align="Center">
No Packs
</Text>
<Text size="T200" align="Center">
There are no emoji or sticker packs to display at the moment.
</Text>
</Box>
</SequenceCard>
)}
</Box>
{hasChanges && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Critical"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to remove packs! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Delete selected packs. ({removedPacks.length} selected)</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={handleCancelChanges}
>
<Text size="B300">Cancel</Text>
</Button>
<Button
size="300"
variant="Critical"
radii="300"
disabled={applyingChanges}
before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
onClick={handleApplyChanges}
>
<Text size="B300">Delete</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</>
);
}

View file

@ -0,0 +1 @@
export * from './EmojisStickers';

View file

@ -0,0 +1,438 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Badge,
Box,
Button,
Checkbox,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Text,
toRem,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../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>
);
}

View file

@ -0,0 +1,150 @@
import {
Badge,
Box,
Button,
color,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import React, { useCallback, useState } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../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>
);
}

View file

@ -0,0 +1,169 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
Button,
color,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../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>
);
}

View file

@ -0,0 +1,130 @@
import React, { useCallback, useMemo } from 'react';
import { color, Text } from 'folds';
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import {
JoinRulesSwitcher,
useRoomJoinRuleIcon,
useRoomJoinRuleLabel,
useSpaceJoinRuleIcon,
} from '../../../components/JoinRulesSwitcher';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getStateEvents } from '../../../utils/room';
type RestrictedRoomAllowContent = {
room_id: string;
type: RestrictedAllowType;
};
type RoomJoinRulesProps = {
powerLevels: IPowerLevels;
};
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const mx = useMatrixClient();
const room = useRoom();
const roomVersion = parseInt(room.getVersion(), 10);
const allowRestricted = roomVersion >= 8;
const allowKnock = roomVersion >= 7;
const space = useSpaceOptionally();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEdit = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomHistoryVisibility,
userPowerLevel
);
const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
const joinRules: Array<JoinRule> = useMemo(() => {
const r: JoinRule[] = [JoinRule.Invite];
if (allowKnock) {
r.push(JoinRule.Knock);
}
if (allowRestricted && space) {
r.push(JoinRule.Restricted);
}
r.push(JoinRule.Public);
return r;
}, [allowRestricted, allowKnock, space]);
const icons = useRoomJoinRuleIcon();
const spaceIcons = useSpaceJoinRuleIcon();
const labels = useRoomJoinRuleLabel();
const [submitState, submit] = useAsyncCallback(
useCallback(
async (joinRule: JoinRule) => {
const allow: RestrictedRoomAllowContent[] = [];
if (joinRule === JoinRule.Restricted) {
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
event.getStateKey()
);
parents.forEach((parentRoomId) => {
if (!parentRoomId) return;
allow.push({
type: RestrictedAllowType.RoomMembership,
room_id: parentRoomId,
});
});
}
const c: RoomJoinRulesEventContent = {
join_rule: joinRule,
};
if (allow.length > 0) c.allow = allow;
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
},
[mx, room]
)
);
const submitting = submitState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={room.isSpaceRoom() ? 'Space Access' : 'Room Access'}
description={
room.isSpaceRoom()
? 'Change how people can join the space.'
: 'Change how people can join the room.'
}
after={
<JoinRulesSwitcher
icons={room.isSpaceRoom() ? spaceIcons : icons}
labels={labels}
rules={joinRules}
value={rule}
onChange={submit}
disabled={!canEdit || submitting}
changing={submitting}
/>
}
>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}

View file

@ -0,0 +1,361 @@
import {
Avatar,
Box,
Button,
Chip,
color,
Icon,
Icons,
Input,
Spinner,
Text,
TextArea,
} from 'folds';
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import Linkify from 'linkify-react';
import classNames from 'classnames';
import { JoinRule, MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { useRoom } from '../../../hooks/useRoom';
import {
useRoomAvatar,
useRoomJoinRule,
useRoomName,
useRoomTopic,
} from '../../../hooks/useRoomMeta';
import { mDirectAtom } from '../../../state/mDirectList';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
type RoomProfileEditProps = {
canEditAvatar: boolean;
canEditName: boolean;
canEditTopic: boolean;
avatar?: string;
name: string;
topic: string;
onClose: () => void;
};
export function RoomProfileEdit({
canEditAvatar,
canEditName,
canEditTopic,
avatar,
name,
topic,
onClose,
}: RoomProfileEditProps) {
const room = useRoom();
const mx = useMatrixClient();
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
const joinRule = useRoomJoinRule(room);
const [roomAvatar, setRoomAvatar] = useState(avatar);
const avatarUrl = roomAvatar
? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);
const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
setRoomAvatar(avatar);
}, [avatar]);
const handleUploaded = useCallback((upload: UploadSuccess) => {
setRoomAvatar(upload.mxc);
}, []);
const [submitState, submit] = useAsyncCallback(
useCallback(
async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
if (roomAvatarMxc !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
url: roomAvatarMxc,
});
}
if (roomName !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
}
if (roomTopic !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
}
},
[mx, room.roomId]
)
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (uploadingAvatar) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
if (!nameInput || !topicTextArea) return;
const roomName = nameInput.value.trim();
const roomTopic = topicTextArea.value.trim();
if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
return;
}
submit(
roomAvatar === avatar ? undefined : roomAvatar || null,
roomName === name ? undefined : roomName,
roomTopic === topic ? undefined : roomTopic
).then(() => {
if (alive()) {
onClose();
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Box gap="400">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Avatar</Text>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={!canEditAvatar || submitting}
onClick={() => pickFile('image/*')}
>
<Text size="B300">Upload</Text>
</Button>
{!roomAvatar && avatar && (
<Button
type="button"
size="300"
variant="Success"
fill="None"
radii="300"
disabled={!canEditAvatar || submitting}
onClick={() => setRoomAvatar(avatar)}
>
<Text size="B300">Reset</Text>
</Button>
)}
{roomAvatar && (
<Button
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={!canEditAvatar || submitting}
onClick={() => setRoomAvatar(undefined)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
</Box>
<Box shrink="No">
<Avatar size="500" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled
/>
)}
/>
</Avatar>
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input
name="nameInput"
defaultValue={name}
variant="Secondary"
radii="300"
readOnly={!canEditName || submitting}
/>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Topic</Text>
<TextArea
name="topicTextArea"
defaultValue={topic}
variant="Secondary"
radii="300"
readOnly={!canEditTopic || submitting}
/>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{(submitState.error as MatrixError).message}
</Text>
)}
<Box gap="300">
<Button
type="submit"
variant="Success"
size="300"
radii="300"
disabled={uploadingAvatar || submitting}
before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
>
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
onClick={onClose}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type RoomProfileProps = {
powerLevels: IPowerLevels;
};
export function RoomProfile({ powerLevels }: RoomProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const directs = useAtomValue(mDirectAtom);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const userPowerLevel = getPowerLevel(mx.getSafeUserId());
const avatar = useRoomAvatar(room, directs.has(room.roomId));
const name = useRoomName(room);
const topic = useRoomTopic(room);
const joinRule = useRoomJoinRule(room);
const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
const canEdit = canEditAvatar || canEditName || canEditTopic;
const avatarUrl = avatar
? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [edit, setEdit] = useState(false);
const handleCloseEdit = useCallback(() => setEdit(false), []);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{edit ? (
<RoomProfileEdit
canEditAvatar={canEditAvatar}
canEditName={canEditName}
canEditTopic={canEditTopic}
avatar={avatar}
name={name ?? ''}
topic={topic ?? ''}
onClose={handleCloseEdit}
/>
) : (
<Box gap="400">
<Box grow="Yes" direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text className={BreakWord} size="H5">
{name ?? 'Unknown'}
</Text>
{topic && (
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
<Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
</Text>
)}
</Box>
{canEdit && (
<Box gap="200">
<Chip
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="50" src={Icons.Pencil} />}
onClick={() => setEdit(true)}
outlined
>
<Text size="B300">Edit</Text>
</Chip>
</Box>
)}
</Box>
<Box shrink="No">
<Avatar size="500" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled
/>
)}
/>
</Avatar>
</Box>
</Box>
)}
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,70 @@
import React from 'react';
import { Box, color, Spinner, Switch, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
type RoomPublishProps = {
powerLevels: IPowerLevels;
};
export function RoomPublish({ powerLevels }: RoomPublishProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomCanonicalAlias,
userPowerLevel
);
const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility);
const loading =
visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Publish To Directory"
after={
<Box gap="200" alignItems="Center">
{loading && <Spinner variant="Secondary" />}
{!loading && visibilityState.status === AsyncStatus.Success && (
<Switch
value={visibilityState.data}
onChange={toggleVisibility}
disabled={!canEditCanonical}
/>
)}
</Box>
}
>
{visibilityState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(visibilityState.error as MatrixError).message}
</Text>
)}
{toggleState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(toggleState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}

View file

@ -0,0 +1,228 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Button,
color,
Spinner,
Text,
Overlay,
OverlayBackdrop,
OverlayCenter,
Dialog,
Header,
config,
Box,
IconButton,
Icon,
Icons,
Input,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { MatrixError } from 'matrix-js-sdk';
import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { stopPropagation } from '../../../utils/keyboard';
type RoomUpgradeProps = {
powerLevels: IPowerLevels;
requestClose: () => void;
};
export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
const mx = useMatrixClient();
const room = useRoom();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const createContent = useStateEvent(
room,
StateEvent.RoomCreate
)?.getContent<RoomCreateEventContent>();
const roomVersion = createContent?.room_version ?? 1;
const predecessorRoomId = createContent?.predecessor?.room_id;
const capabilities = useCapabilities();
const defaultRoomVersion = capabilities['m.room_versions']?.default;
const tombstoneContent = useStateEvent(
room,
StateEvent.RoomTombstone
)?.getContent<RoomTombstoneEventContent>();
const replacementRoom = tombstoneContent?.replacement_room;
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canUpgrade = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomTombstone,
userPowerLevel
);
const handleOpenRoom = () => {
if (replacementRoom) {
requestClose();
if (room.isSpaceRoom()) {
navigateSpace(replacementRoom);
} else {
navigateRoom(replacementRoom);
}
}
};
const handleOpenOldRoom = () => {
if (predecessorRoomId) {
requestClose();
if (room.isSpaceRoom()) {
navigateSpace(predecessorRoomId);
} else {
navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
}
}
};
const [upgradeState, upgrade] = useAsyncCallback(
useCallback(
async (version: string) => {
await mx.upgradeRoom(room.roomId, version);
},
[mx, room]
)
);
const upgrading = upgradeState.status === AsyncStatus.Loading;
const [prompt, setPrompt] = useState(false);
const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const versionInput = target?.versionInput as HTMLInputElement | undefined;
const version = versionInput?.value.trim();
if (!version) return;
upgrade(version);
setPrompt(false);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
description={
replacementRoom
? tombstoneContent.body ||
`This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
: `Current room version: ${roomVersion}.`
}
after={
<Box alignItems="Center" gap="200">
{predecessorRoomId && (
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
onClick={handleOpenOldRoom}
>
<Text size="B300">{room.isSpaceRoom() ? 'Old Space' : 'Old Room'}</Text>
</Button>
)}
{replacementRoom ? (
<Button
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={handleOpenRoom}
>
<Text size="B300">{room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'}</Text>
</Button>
) : (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={upgrading || !canUpgrade}
before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
onClick={() => setPrompt(true)}
>
<Text size="B300">Upgrade</Text>
</Button>
)}
</Box>
}
>
{upgradeState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Version</Text>
<Input
defaultValue={defaultRoomVersion}
name="versionInput"
variant="Background"
required
/>
</Box>
<Button type="submit" variant="Secondary">
<Text size="B400">
{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
</SequenceCard>
);
}

View file

@ -0,0 +1,7 @@
export * from './RoomAddress';
export * from './RoomEncryption';
export * from './RoomHistoryVisibility';
export * from './RoomJoinRules';
export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomUpgrade';

View file

@ -0,0 +1,353 @@
import React, {
ChangeEventHandler,
MouseEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {
Box,
Chip,
config,
Icon,
IconButton,
Icons,
Input,
PopOut,
RectCords,
Scroll,
Spinner,
Text,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMember } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import {
useFlattenPowerLevelTagMembers,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { VirtualTile } from '../../../components/virtualizer';
import { MemberTile } from '../../../components/member-tile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { ServerBadge } from '../../../components/server-badge';
import { openProfileViewer } from '../../../../client/action/navigation';
import { useDebounce } from '../../../hooks/useDebounce';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../../hooks/useAsyncSearch';
import { getMemberSearchStr } from '../../../utils/room';
import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../../components/MemberSortMenu';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
matchOptions: {
contain: true,
},
normalizeOptions: {
ignoreWhitespace: false,
},
};
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
type MembersProps = {
requestClose: () => void;
};
export function Members({ requestClose }: MembersProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const members = useRoomMembers(mx, room.roomId);
const fetchingMembers = members.length < room.getJoinedMemberCount();
const powerLevels = usePowerLevels(room);
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const sortedMembers = useMemo(
() =>
Array.from(members)
.filter(membershipFilter.filterFn)
.sort(memberSort.sortFn)
.sort((a, b) => b.powerLevel - a.powerLevel),
[members, membershipFilter, memberSort]
);
const [result, search, resetSearch] = useAsyncSearch(
sortedMembers,
getRoomMemberStr,
SEARCH_OPTIONS
);
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
const flattenTagMembers = useFlattenPowerLevelTagMembers(
result?.items ?? sortedMembers,
getPowerLevel,
getPowerLevelTag
);
const virtualizer = useVirtualizer({
count: flattenTagMembers.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 40,
overscan: 10,
});
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
if (evt.target.value) search(evt.target.value);
else resetSearch();
},
[search, resetSearch]
),
{ wait: 200 }
);
const handleSearchReset = () => {
if (searchInputRef.current) {
searchInputRef.current.value = '';
searchInputRef.current.focus();
}
resetSearch();
};
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
openProfileViewer(userId, room.roomId);
requestClose();
};
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{room.getJoinedMemberCount()} Members
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" style={{ position: 'relative' }}>
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="200">
<Box
style={{ position: 'sticky', top: config.space.S100, zIndex: 1 }}
direction="Column"
gap="100"
>
<Input
ref={searchInputRef}
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
variant="SurfaceVariant"
size="500"
placeholder="Search"
outlined
after={
result && (
<Chip
variant={result.items.length > 0 ? 'Success' : 'Critical'}
outlined
size="400"
radii="Pill"
aria-pressed
onClick={handleSearchReset}
after={<Icon size="50" src={Icons.Cross} />}
>
<Text size="B300">
{result.items.length === 0
? 'No Results'
: `${result.items.length} Results`}
</Text>
</Chip>
)
}
/>
</Box>
<Box ref={scrollTopAnchorRef} alignItems="Center" justifyContent="End" gap="200">
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
<PopOut
anchor={anchor}
position="Bottom"
align="Start"
offset={4}
content={
<MembershipFilterMenu
selected={membershipFilterIndex}
onSelect={setMembershipFilterIndex}
requestClose={() => setAnchor(undefined)}
/>
}
>
<Chip
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="SurfaceVariant"
size="400"
radii="300"
before={<Icon src={Icons.Filter} size="50" />}
>
<Text size="T200">{membershipFilter.name}</Text>
</Chip>
</PopOut>
)}
</UseStateProvider>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
<PopOut
anchor={anchor}
position="Bottom"
align="End"
offset={4}
content={
<MemberSortMenu
selected={sortFilterIndex}
onSelect={setSortFilterIndex}
requestClose={() => setAnchor(undefined)}
/>
}
>
<Chip
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="SurfaceVariant"
size="400"
radii="300"
after={<Icon src={Icons.Sort} size="50" />}
>
<Text size="T200">{memberSort.name}</Text>
</Chip>
</PopOut>
)}
</UseStateProvider>
</Box>
<ScrollTopContainer
style={{ top: toRem(64) }}
scrollRef={scrollRef}
anchorRef={scrollTopAnchorRef}
>
<IconButton
onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"
outlined
size="300"
aria-label="Scroll to Top"
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>
</ScrollTopContainer>
{fetchingMembers && (
<Box justifyContent="Center">
<Spinner />
</Box>
)}
<Box
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
direction="Column"
gap="100"
>
{virtualizer.getVirtualItems().map((vItem) => {
const tagOrMember = flattenTagMembers[vItem.index];
if ('userId' in tagOrMember) {
const server = getMxIdServer(tagOrMember.userId);
return (
<VirtualTile
virtualItem={vItem}
key={`${tagOrMember.userId}-${vItem.index}`}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: config.space.S200 }}>
<MemberTile
data-user-id={tagOrMember.userId}
onClick={handleMemberClick}
mx={mx}
room={room}
member={tagOrMember}
useAuthentication={useAuthentication}
after={
server && (
<Box as="span" shrink="No" alignSelf="End">
<ServerBadge server={server} fill="None" />
</Box>
)
}
/>
</div>
</VirtualTile>
);
}
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div
style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
>
<Text size="L400">{tagOrMember.name}</Text>
</div>
</VirtualTile>
);
})}
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1 @@
export * from './Members';

View file

@ -0,0 +1,286 @@
/* eslint-disable react/no-array-index-key */
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
import produce from 'immer';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import {
applyPermissionPower,
getPermissionPower,
IPowerLevels,
PermissionLocation,
usePowerLevelsAPI,
} from '../../../hooks/usePowerLevels';
import { PermissionGroup } from './types';
import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useRoom } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { PowerSwitcher } from '../../../components/power';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
const USER_DEFAULT_LOCATION: PermissionLocation = {
user: true,
};
type PermissionGroupsProps = {
powerLevels: IPowerLevels;
permissionGroups: PermissionGroup[];
};
export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGroupsProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canChangePermission = canSendStateEvent(
StateEvent.RoomPowerLevels,
getPowerLevel(mx.getSafeUserId())
);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
new Map()
);
useEffect(() => {
// reset permission update if component rerender
// as permission location object reference has changed
setPermissionUpdate(new Map());
}, [permissionGroups]);
const handleChangePermission = (
location: PermissionLocation,
newPower: number,
currentPower: number
) => {
setPermissionUpdate((p) => {
const up: typeof p = new Map();
p.forEach((value, key) => {
up.set(key, value);
});
if (newPower === currentPower) {
up.delete(location);
} else {
up.set(location, newPower);
}
return up;
});
};
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => {
permissionGroups.forEach((group) =>
group.items.forEach((item) => {
const power = getPermissionPower(powerLevels, item.location);
applyPermissionPower(draftPowerLevels, item.location, power);
})
);
permissionUpdate.forEach((power, location) =>
applyPermissionPower(draftPowerLevels, location, power)
);
return draftPowerLevels;
});
await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
}, [mx, room, powerLevels, permissionUpdate, permissionGroups])
);
const resetChanges = useCallback(() => {
setPermissionUpdate(new Map());
}, []);
const handleApplyChanges = () => {
applyChanges().then(() => {
if (alive()) {
resetChanges();
}
});
};
const applyingChanges = applyState.status === AsyncStatus.Loading;
const hasChanges = permissionUpdate.size > 0;
const renderUserGroup = () => {
const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION);
const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
const value = powerUpdate ?? power;
const tag = getPowerLevelTag(value);
const powerChanges = value !== power;
return (
<Box direction="Column" gap="100">
<Text size="L400">Users</Text>
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title="Default Power"
description="Default power level for all users."
after={
<PowerSwitcher
powerLevelTags={powerLevelTags}
value={value}
onChange={(v) => handleChangePermission(USER_DEFAULT_LOCATION, v, power)}
>
{(handleOpen, opened) => (
<Chip
variant={powerChanges ? 'Success' : 'Secondary'}
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
disabled={!canChangePermission || applyingChanges}
after={
powerChanges && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
<Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
)
}
onClick={handleOpen}
>
<Text size="B300" truncate>
{tag.name}
</Text>
</Chip>
)}
</PowerSwitcher>
}
/>
</SequenceCard>
</Box>
);
};
return (
<>
{renderUserGroup()}
{permissionGroups.map((group, groupIndex) => (
<Box key={groupIndex} direction="Column" gap="100">
<Text size="L400">{group.name}</Text>
{group.items.map((item, itemIndex) => {
const power = getPermissionPower(powerLevels, item.location);
const powerUpdate = permissionUpdate.get(item.location);
const value = powerUpdate ?? power;
const tag = getPowerLevelTag(value);
const powerChanges = value !== power;
return (
<SequenceCard
key={itemIndex}
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title={item.name}
description={item.description}
after={
<PowerSwitcher
powerLevelTags={powerLevelTags}
value={value}
onChange={(v) => handleChangePermission(item.location, v, power)}
>
{(handleOpen, opened) => (
<Chip
variant={powerChanges ? 'Success' : 'Secondary'}
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
disabled={!canChangePermission || applyingChanges}
after={
powerChanges && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
<Icon
size="50"
src={opened ? Icons.ChevronTop : Icons.ChevronBottom}
/>
)
}
onClick={handleOpen}
>
<Text size="B300" truncate>
{tag.name}
</Text>
{value < maxPower && <Text size="T200">& Above</Text>}
</Chip>
)}
</PowerSwitcher>
}
/>
</SequenceCard>
);
})}
</Box>
))}
{hasChanges && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Success"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={resetChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={applyingChanges}
before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
onClick={handleApplyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</>
);
}

View file

@ -0,0 +1,176 @@
/* eslint-disable react/no-array-index-key */
import React, { useState, MouseEventHandler, ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Chip,
Text,
RectCords,
PopOut,
Menu,
Scroll,
toRem,
config,
color,
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { SettingTile } from '../../../components/setting-tile';
import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
import { PowerColorBadge, PowerIcon } from '../../../components/power';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { stopPropagation } from '../../../utils/keyboard';
import { PermissionGroup } from './types';
type PeekPermissionsProps = {
powerLevels: IPowerLevels;
power: number;
permissionGroups: PermissionGroup[];
children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
};
function PeekPermissions({ powerLevels, power, permissionGroups, children }: PeekPermissionsProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}}
>
<Box grow="Yes" tabIndex={0}>
<Scroll size="0" hideTrack visibility="Hover">
<Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
{permissionGroups.map((group, groupIndex) => (
<Box key={groupIndex} direction="Column" gap="100">
<Text size="L400">{group.name}</Text>
<div>
{group.items.map((item, itemIndex) => {
const requiredPower = getPermissionPower(powerLevels, item.location);
const hasPower = requiredPower <= power;
return (
<Text
key={itemIndex}
size="T200"
style={{
color: hasPower ? undefined : color.Critical.Main,
}}
>
{hasPower ? '✅' : '❌'} {item.name}
</Text>
);
})}
</div>
</Box>
))}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpen, !!menuCords)}
</PopOut>
);
}
type PowersProps = {
powerLevels: IPowerLevels;
permissionGroups: PermissionGroup[];
onEdit?: () => void;
};
export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
return (
<Box direction="Column" gap="100">
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title="Power Levels"
description="Manage and customize incremental power levels for users."
after={
onEdit && (
<Box gap="200">
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={onEdit}
>
<Text size="B300">Edit</Text>
</Button>
</Box>
)
}
/>
<SettingTile>
<Box gap="200" wrap="Wrap">
{getPowers(powerLevelTags).map((power) => {
const tag = powerLevelTags[power];
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
return (
<PeekPermissions
key={power}
powerLevels={powerLevels}
power={power}
permissionGroups={permissionGroups}
>
{(openMenu, opened) => (
<Chip
onClick={openMenu}
variant="Secondary"
aria-pressed={opened}
radii="300"
before={<PowerColorBadge color={tag.color} />}
after={tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
>
<Text size="T300" truncate>
<b>{tag.name}</b>
</Text>
</Chip>
)}
</PeekPermissions>
);
})}
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,579 @@
import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
Chip,
Icon,
Icons,
IconButton,
Scroll,
Button,
Input,
RectCords,
PopOut,
Menu,
config,
Spinner,
toRem,
TooltipProvider,
Tooltip,
} from 'folds';
import { HexColorPicker } from 'react-colorful';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { IPowerLevels } from '../../../hooks/usePowerLevels';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import {
getPowers,
getTagIconSrc,
getUsedPowers,
PowerLevelTag,
PowerLevelTagIcon,
PowerLevelTags,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { useRoom } from '../../../hooks/useRoom';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { PowerColorBadge, PowerIcon } from '../../../components/power';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { StateEvent } from '../../../../types/matrix/room';
import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
type EditPowerProps = {
maxPower: number;
power?: number;
tag?: PowerLevelTag;
onSave: (power: number, tag: PowerLevelTag) => void;
onClose: () => void;
};
function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const mx = useMatrixClient();
const room = useRoom();
const roomToParents = useAtomValue(roomToParentsAtom);
const useAuthentication = useMediaAuthentication();
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
const [iconFile, setIconFile] = useState<File>();
const pickFile = useFilePicker(setIconFile, false);
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
const uploadingIcon = iconFile && !tagIcon;
const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon);
const iconUploadAtom = useMemo(() => {
if (iconFile) return createUploadAtom(iconFile);
return undefined;
}, [iconFile]);
const handleRemoveIconUpload = useCallback(() => {
setIconFile(undefined);
}, []);
const handleIconUploaded = useCallback((upload: UploadSuccess) => {
setTagIcon({
key: upload.mxc,
});
setIconFile(undefined);
}, []);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (uploadingIcon) return;
const target = evt.target as HTMLFormElement | undefined;
const powerInput = target?.powerInput as HTMLInputElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
if (!powerInput || !nameInput) return;
const tagPower = parseInt(powerInput.value, 10);
if (Number.isNaN(tagPower)) return;
if (tagPower > maxPower) return;
const tagName = nameInput.value.trim();
if (!tagName) return;
const editedTag: PowerLevelTag = {
name: tagName,
color: tagColor,
icon: tagIcon,
};
onSave(power ?? tagPower, editedTag);
onClose();
};
return (
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
<Box direction="Column" gap="300">
<Box gap="200">
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Color</Text>
<Box gap="200">
<HexColorPickerPopOut
picker={<HexColorPicker color={tagColor} onChange={setTagColor} />}
onRemove={() => setTagColor(undefined)}
>
{(openPicker, opened) => (
<Button
aria-pressed={opened}
onClick={openPicker}
size="300"
type="button"
variant="Secondary"
fill="Soft"
radii="300"
before={<PowerColorBadge color={tagColor} />}
>
<Text size="B300">Pick</Text>
</Button>
)}
</HexColorPickerPopOut>
</Box>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
name="nameInput"
defaultValue={tag?.name}
placeholder="Bot"
size="300"
variant="Secondary"
radii="300"
required
/>
</Box>
<Box style={{ maxWidth: toRem(74) }} grow="Yes" direction="Column" gap="100">
<Text size="L400">Power</Text>
<Input
defaultValue={power}
name="powerInput"
size="300"
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
radii="300"
type="number"
placeholder="75"
max={maxPower}
outlined={typeof power === 'number'}
readOnly={typeof power === 'number'}
required
/>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Icon</Text>
{iconUploadAtom && !tagIconSrc ? (
<CompactUploadCardRenderer
uploadAtom={iconUploadAtom}
onRemove={handleRemoveIconUpload}
onComplete={handleIconUploaded}
/>
) : (
<Box gap="200" alignItems="Center">
{tagIconSrc ? (
<>
<PowerIcon size="500" iconSrc={tagIconSrc} />
<Button
onClick={() => setTagIcon(undefined)}
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
>
<Text size="B300">Remove</Text>
</Button>
</>
) : (
<>
<UseStateProvider initial={undefined}>
{(cords: RectCords | undefined, setCords) => (
<PopOut
position="Bottom"
anchor={cords}
content={
<EmojiBoard
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
allowTextCustomEmoji={false}
addToRecentEmoji={false}
onEmojiSelect={(key) => {
setTagIcon({ key });
setCords(undefined);
}}
onCustomEmojiSelect={(mxc) => {
setTagIcon({ key: mxc });
setCords(undefined);
}}
requestClose={() => {
setCords(undefined);
}}
/>
}
>
<Button
onClick={
((evt) =>
setCords(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="50" src={Icons.SmilePlus} />}
>
<Text size="B300">Pick</Text>
</Button>
</PopOut>
)}
</UseStateProvider>
<Button
onClick={() => pickFile('image/*')}
type="button"
size="300"
variant="Secondary"
fill="None"
radii="300"
>
<Text size="B300">Import</Text>
</Button>
</>
)}
</Box>
)}
</Box>
<Box direction="Row" gap="200" justifyContent="Start">
<Button
style={{ minWidth: toRem(64) }}
type="submit"
size="300"
variant="Success"
radii="300"
disabled={uploadingIcon}
>
<Text size="B300">Save</Text>
</Button>
<Button
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={onClose}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type PowersEditorProps = {
powerLevels: IPowerLevels;
requestClose: () => void;
};
export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const alive = useAlive();
const [usedPowers, maxPower] = useMemo(() => {
const up = getUsedPowers(powerLevels);
return [up, Math.max(...Array.from(up))];
}, [powerLevels]);
const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
const [editedPowerTags, setEditedPowerTags] = useState<PowerLevelTags>();
const [deleted, setDeleted] = useState<Set<number>>(new Set());
const [createTag, setCreateTag] = useState(false);
const handleToggleDelete = useCallback((power: number) => {
setDeleted((powers) => {
const newIds = new Set(powers);
if (newIds.has(power)) {
newIds.delete(power);
} else {
newIds.add(power);
}
return newIds;
});
}, []);
const handleSaveTag = useCallback(
(power: number, tag: PowerLevelTag) => {
setEditedPowerTags((tags) => {
const editedTags = { ...(tags ?? powerLevelTags) };
editedTags[power] = tag;
return editedTags;
});
},
[powerLevelTags]
);
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const content: PowerLevelTags = { ...(editedPowerTags ?? powerLevelTags) };
deleted.forEach((power) => {
delete content[power];
});
await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
}, [mx, room, powerLevelTags, editedPowerTags, deleted])
);
const resetChanges = useCallback(() => {
setEditedPowerTags(undefined);
setDeleted(new Set());
}, []);
const handleApplyChanges = () => {
applyChanges().then(() => {
if (alive()) {
resetChanges();
}
});
};
const applyingChanges = applyState.status === AsyncStatus.Loading;
const hasChanges = editedPowerTags || deleted.size > 0;
const powerTags = editedPowerTags ?? powerLevelTags;
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Permissions</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Box alignItems="Baseline" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Power Levels</Text>
<BetaNoticeBadge />
</Box>
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title="New Power Level"
description="Create a new power level."
after={
!createTag && (
<Button
onClick={() => setCreateTag(true)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={applyingChanges}
>
<Text size="B300">Create</Text>
</Button>
)
}
/>
{createTag && (
<EditPower
maxPower={maxPower}
onSave={handleSaveTag}
onClose={() => setCreateTag(false)}
/>
)}
</SequenceCard>
{getPowers(powerTags).map((power) => {
const tag = powerTags[power];
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
return (
<SequenceCard
key={power}
variant={deleted.has(power) ? 'Critical' : 'SurfaceVariant'}
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<UseStateProvider initial={false}>
{(edit, setEdit) =>
edit ? (
<EditPower
maxPower={maxPower}
power={power}
tag={tag}
onSave={handleSaveTag}
onClose={() => setEdit(false)}
/>
) : (
<SettingTile
before={<PowerColorBadge color={tag.color} />}
title={
<Box as="span" alignItems="Center" gap="200">
<b>{deleted.has(power) ? <s>{tag.name}</s> : tag.name}</b>
<Box as="span" shrink="No" alignItems="Inherit" gap="Inherit">
{tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
<Text as="span" size="T200" priority="300">
({power})
</Text>
</Box>
</Box>
}
after={
deleted.has(power) ? (
<Chip
variant="Critical"
radii="Pill"
disabled={applyingChanges}
onClick={() => handleToggleDelete(power)}
>
<Text size="B300">Undo</Text>
</Chip>
) : (
<Box shrink="No" alignItems="Center" gap="200">
<TooltipProvider
tooltip={
<Tooltip style={{ maxWidth: toRem(200) }}>
{usedPowers.has(power) ? (
<Box direction="Column">
<Text size="L400">Used Power Level</Text>
<Text size="T200">
You have to remove its use before you can delete it.
</Text>
</Box>
) : (
<Text>Delete</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
fill="None"
radii="Pill"
disabled={applyingChanges}
aria-disabled={usedPowers.has(power)}
onClick={
usedPowers.has(power)
? undefined
: () => handleToggleDelete(power)
}
>
<Icon size="50" src={Icons.Delete} />
</Chip>
)}
</TooltipProvider>
<Chip
variant="Secondary"
radii="Pill"
disabled={applyingChanges}
onClick={() => setEdit(true)}
>
<Text size="B300">Edit</Text>
</Chip>
</Box>
)
}
/>
)
}
</UseStateProvider>
</SequenceCard>
);
})}
</Box>
{hasChanges && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Success"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={resetChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={applyingChanges}
before={
applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />
}
onClick={handleApplyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,4 @@
export * from './PermissionGroups';
export * from './Powers';
export * from './PowersEditor';
export * from './types';

View file

@ -0,0 +1,12 @@
import { PermissionLocation } from '../../../hooks/usePowerLevels';
export type PermissionItem = {
location: PermissionLocation;
name: string;
description?: string;
};
export type PermissionGroup = {
name: string;
items: PermissionItem[];
};

View file

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});