Add new space settings (#2293)
This commit is contained in:
parent
4aed4d7472
commit
5c39a36c12
44 changed files with 691 additions and 63 deletions
|
|
@ -29,6 +29,17 @@ export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[JoinRule.Invite]: Icons.SpaceLock,
|
||||||
|
[JoinRule.Knock]: Icons.SpaceLock,
|
||||||
|
[JoinRule.Restricted]: Icons.Space,
|
||||||
|
[JoinRule.Public]: Icons.SpaceGlobe,
|
||||||
|
[JoinRule.Private]: Icons.SpaceLock,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
type JoinRuleLabels = Record<JoinRule, string>;
|
type JoinRuleLabels = Record<JoinRule, string>;
|
||||||
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
color,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
Input,
|
||||||
|
} from 'folds';
|
||||||
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
export type SendRoomEventProps = {
|
||||||
|
type?: string;
|
||||||
|
stateKey?: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
const composeStateEvent = typeof stateKey === 'string';
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<
|
||||||
|
object,
|
||||||
|
MatrixError,
|
||||||
|
[string, string | undefined, object]
|
||||||
|
>(
|
||||||
|
useCallback(
|
||||||
|
(evtType, evtStateKey, evtContent) => {
|
||||||
|
if (typeof evtStateKey === 'string') {
|
||||||
|
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
||||||
|
}
|
||||||
|
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
||||||
|
},
|
||||||
|
[mx, room]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||||
|
const stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!typeInput || !contentTextArea) return;
|
||||||
|
|
||||||
|
const evtType = typeInput.value;
|
||||||
|
const evtStateKey = stateKeyInput?.value;
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (parsedContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(evtType, evtStateKey, parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
name="typeInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={submitting}
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Send</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{composeStateEvent && (
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">State Key (Optional)</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
name="stateKeyInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={submitting}
|
||||||
|
defaultValue={stateKey}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Scroll,
|
||||||
|
config,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
color,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { TextViewerContent } from '../../../components/text-viewer';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
|
||||||
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
type StateEventEditProps = {
|
||||||
|
type: string;
|
||||||
|
stateKey: string;
|
||||||
|
content: object;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const defaultContentStr = useMemo(
|
||||||
|
() => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
|
||||||
|
[content]
|
||||||
|
);
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||||
|
useCallback(
|
||||||
|
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
||||||
|
[mx, room, type, stateKey]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!contentTextArea) return;
|
||||||
|
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsedContent === null ||
|
||||||
|
defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">State Event</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={type}
|
||||||
|
description={stateKey}
|
||||||
|
after={
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={requestClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
defaultValue={defaultContentStr}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateEventViewProps = {
|
||||||
|
content: object;
|
||||||
|
eventJSONStr: string;
|
||||||
|
onEditContent?: (content: object) => void;
|
||||||
|
};
|
||||||
|
function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="End">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="L400">State Event</Text>
|
||||||
|
</Box>
|
||||||
|
{onEditContent && (
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => onEditContent(content)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Edit</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<SequenceCard variant="SurfaceVariant">
|
||||||
|
<Scroll visibility="Always" size="300" hideTrack>
|
||||||
|
<TextViewerContent
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
|
||||||
|
}}
|
||||||
|
text={eventJSONStr}
|
||||||
|
langName="JSON"
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateEventInfo = {
|
||||||
|
type: string;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
export type StateEventEditorProps = StateEventInfo & {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
|
||||||
|
const [editContent, setEditContent] = useState<object>();
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
|
||||||
|
const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
|
||||||
|
|
||||||
|
const eventJSONStr = useMemo(() => {
|
||||||
|
if (!stateEvent) return '';
|
||||||
|
return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
|
||||||
|
}, [stateEvent]);
|
||||||
|
|
||||||
|
const handleCloseEdit = useCallback(() => {
|
||||||
|
setEditContent(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{editContent ? (
|
||||||
|
<StateEventEdit
|
||||||
|
type={type}
|
||||||
|
stateKey={stateKey}
|
||||||
|
content={editContent}
|
||||||
|
requestClose={handleCloseEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StateEventView
|
||||||
|
content={stateEvent?.getContent() ?? {}}
|
||||||
|
onEditContent={canEdit ? setEditContent : undefined}
|
||||||
|
eventJSONStr={eventJSONStr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './DevelopTools';
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||||
|
import { ImagePackView } from '../../../components/image-pack-view';
|
||||||
|
import { RoomPacks } from './RoomPacks';
|
||||||
|
|
||||||
|
type EmojisStickersProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||||
|
const [imagePack, setImagePack] = useState<ImagePack>();
|
||||||
|
|
||||||
|
const handleImagePackViewClose = () => {
|
||||||
|
setImagePack(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imagePack) {
|
||||||
|
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text size="H3" truncate>
|
||||||
|
Emojis & Stickers
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<RoomPacks onViewPack={setImagePack} />
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
Normal file
349
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
toRem,
|
||||||
|
config,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
color,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import {
|
||||||
|
ImagePack,
|
||||||
|
ImageUsage,
|
||||||
|
PackAddress,
|
||||||
|
packAddressEqual,
|
||||||
|
PackContent,
|
||||||
|
} from '../../../plugins/custom-emoji';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useRoomImagePacks } from '../../../hooks/useImagePacks';
|
||||||
|
import { LineClamp2 } from '../../../styles/Text.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { suffixRename } from '../../../utils/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
|
||||||
|
type CreatePackTileProps = {
|
||||||
|
packs: ImagePack[];
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
|
||||||
|
useCallback(
|
||||||
|
async (stateKey, name) => {
|
||||||
|
const content: PackContent = {
|
||||||
|
pack: {
|
||||||
|
display_name: name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
|
||||||
|
},
|
||||||
|
[mx, roomId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const creating = addState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (creating) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const nameInput = target?.nameInput as HTMLInputElement | undefined;
|
||||||
|
if (!nameInput) return;
|
||||||
|
const name = nameInput?.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
let packKey = name.replace(/\s/g, '-');
|
||||||
|
|
||||||
|
const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
|
||||||
|
if (hasPack(packKey)) {
|
||||||
|
packKey = suffixRename(packKey, hasPack);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPack(packKey, name).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
nameInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="New Pack"
|
||||||
|
description="Add your own emoji and sticker pack to use in room."
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
gap="200"
|
||||||
|
alignItems="End"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100" grow="Yes">
|
||||||
|
<Text size="L400">Name</Text>
|
||||||
|
<Input
|
||||||
|
name="nameInput"
|
||||||
|
required
|
||||||
|
size="400"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
readOnly={creating}
|
||||||
|
/>
|
||||||
|
{addState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{addState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Create</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomPacksProps = {
|
||||||
|
onViewPack: (imagePack: ImagePack) => void;
|
||||||
|
};
|
||||||
|
export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
|
||||||
|
const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
|
||||||
|
|
||||||
|
const unfilteredPacks = useRoomImagePacks(room);
|
||||||
|
const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
|
||||||
|
|
||||||
|
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
|
||||||
|
const hasChanges = removedPacks.length > 0;
|
||||||
|
|
||||||
|
const [applyState, applyChanges] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
for (let i = 0; i < removedPacks.length; i += 1) {
|
||||||
|
const addr = removedPacks[i];
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
|
||||||
|
}
|
||||||
|
}, [mx, room, removedPacks])
|
||||||
|
);
|
||||||
|
const applyingChanges = applyState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleRemove = (address: PackAddress) => {
|
||||||
|
setRemovedPacks((addresses) => [...addresses, address]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndoRemove = (address: PackAddress) => {
|
||||||
|
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelChanges = () => setRemovedPacks([]);
|
||||||
|
|
||||||
|
const handleApplyChanges = () => {
|
||||||
|
applyChanges().then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
setRemovedPacks([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPack = (pack: ImagePack) => {
|
||||||
|
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||||
|
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||||
|
const { address } = pack;
|
||||||
|
if (!address) return null;
|
||||||
|
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
key={pack.id}
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant={removed ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={
|
||||||
|
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
|
||||||
|
{pack.meta.name ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||||
|
before={
|
||||||
|
<Box alignItems="Center" gap="300">
|
||||||
|
{canEdit &&
|
||||||
|
(removed ? (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => handleUndoRemove(address)}
|
||||||
|
disabled={applyingChanges}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="Secondary"
|
||||||
|
onClick={() => handleRemove(address)}
|
||||||
|
disabled={applyingChanges}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
<Avatar size="300" radii="300">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback>
|
||||||
|
<Icon size="400" src={Icons.Sticker} filled />
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
!removed && (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => onViewPack(pack)}
|
||||||
|
>
|
||||||
|
<Text size="B300">View</Text>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Packs</Text>
|
||||||
|
{canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
|
||||||
|
{packs.map(renderPack)}
|
||||||
|
{packs.length === 0 && (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S700} ${config.space.S400}`,
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
margin: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H5" align="Center">
|
||||||
|
No Packs
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" align="Center">
|
||||||
|
There are no emoji or sticker packs to display at the moment.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
bottom: config.space.S400,
|
||||||
|
left: config.space.S400,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
variant="Critical"
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="400">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{applyState.status === AsyncStatus.Error ? (
|
||||||
|
<Text size="T200">
|
||||||
|
<b>Failed to remove packs! Please try again.</b>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="T200">
|
||||||
|
<b>Delete selected packs. ({removedPacks.length} selected)</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
disabled={applyingChanges}
|
||||||
|
onClick={handleCancelChanges}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
disabled={applyingChanges}
|
||||||
|
before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
|
||||||
|
onClick={handleApplyChanges}
|
||||||
|
>
|
||||||
|
<Text size="B300">Delete</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EmojisStickers';
|
||||||
438
src/app/features/common-settings/general/RoomAddress.tsx
Normal file
438
src/app/features/common-settings/general/RoomAddress.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/features/common-settings/general/RoomEncryption.tsx
Normal file
150
src/app/features/common-settings/general/RoomEncryption.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/app/features/common-settings/general/RoomJoinRules.tsx
Normal file
130
src/app/features/common-settings/general/RoomJoinRules.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/app/features/common-settings/general/RoomProfile.tsx
Normal file
361
src/app/features/common-settings/general/RoomProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/features/common-settings/general/RoomPublish.tsx
Normal file
70
src/app/features/common-settings/general/RoomPublish.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/app/features/common-settings/general/RoomUpgrade.tsx
Normal file
228
src/app/features/common-settings/general/RoomUpgrade.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/features/common-settings/general/index.ts
Normal file
7
src/app/features/common-settings/general/index.ts
Normal 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';
|
||||||
353
src/app/features/common-settings/members/Members.tsx
Normal file
353
src/app/features/common-settings/members/Members.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { RoomMember } from 'matrix-js-sdk';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useRoomMembers } from '../../../hooks/useRoomMembers';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
|
||||||
|
import {
|
||||||
|
useFlattenPowerLevelTagMembers,
|
||||||
|
usePowerLevelTags,
|
||||||
|
} from '../../../hooks/usePowerLevelTags';
|
||||||
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
|
import { MemberTile } from '../../../components/member-tile';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
|
||||||
|
import { ServerBadge } from '../../../components/server-badge';
|
||||||
|
import { openProfileViewer } from '../../../../client/action/navigation';
|
||||||
|
import { useDebounce } from '../../../hooks/useDebounce';
|
||||||
|
import {
|
||||||
|
SearchItemStrGetter,
|
||||||
|
useAsyncSearch,
|
||||||
|
UseAsyncSearchOptions,
|
||||||
|
} from '../../../hooks/useAsyncSearch';
|
||||||
|
import { getMemberSearchStr } from '../../../utils/room';
|
||||||
|
import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
|
||||||
|
import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
|
import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
|
||||||
|
import { MemberSortMenu } from '../../../components/MemberSortMenu';
|
||||||
|
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
||||||
|
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 1000,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
normalizeOptions: {
|
||||||
|
ignoreWhitespace: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
|
||||||
|
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
|
||||||
|
getMemberSearchStr(m, query, mxIdToName);
|
||||||
|
|
||||||
|
type MembersProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function Members({ requestClose }: MembersProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
|
||||||
|
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
||||||
|
|
||||||
|
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
|
||||||
|
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
|
||||||
|
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
|
||||||
|
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const sortedMembers = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(members)
|
||||||
|
.filter(membershipFilter.filterFn)
|
||||||
|
.sort(memberSort.sortFn)
|
||||||
|
.sort((a, b) => b.powerLevel - a.powerLevel),
|
||||||
|
[members, membershipFilter, memberSort]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
|
sortedMembers,
|
||||||
|
getRoomMemberStr,
|
||||||
|
SEARCH_OPTIONS
|
||||||
|
);
|
||||||
|
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
|
||||||
|
|
||||||
|
const flattenTagMembers = useFlattenPowerLevelTagMembers(
|
||||||
|
result?.items ?? sortedMembers,
|
||||||
|
getPowerLevel,
|
||||||
|
getPowerLevelTag
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: flattenTagMembers.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 40,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (evt.target.value) search(evt.target.value);
|
||||||
|
else resetSearch();
|
||||||
|
},
|
||||||
|
[search, resetSearch]
|
||||||
|
),
|
||||||
|
{ wait: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchReset = () => {
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.value = '';
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
resetSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const btn = evt.currentTarget as HTMLButtonElement;
|
||||||
|
const userId = btn.getAttribute('data-user-id');
|
||||||
|
openProfileViewer(userId, room.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text size="H3" truncate>
|
||||||
|
{room.getJoinedMemberCount()} Members
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
|
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box
|
||||||
|
style={{ position: 'sticky', top: config.space.S100, zIndex: 1 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="500"
|
||||||
|
placeholder="Search"
|
||||||
|
outlined
|
||||||
|
after={
|
||||||
|
result && (
|
||||||
|
<Chip
|
||||||
|
variant={result.items.length > 0 ? 'Success' : 'Critical'}
|
||||||
|
outlined
|
||||||
|
size="400"
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed
|
||||||
|
onClick={handleSearchReset}
|
||||||
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{result.items.length === 0
|
||||||
|
? 'No Results'
|
||||||
|
: `${result.items.length} Results`}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box ref={scrollTopAnchorRef} alignItems="Center" justifyContent="End" gap="200">
|
||||||
|
<UseStateProvider initial={undefined}>
|
||||||
|
{(anchor: RectCords | undefined, setAnchor) => (
|
||||||
|
<PopOut
|
||||||
|
anchor={anchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<MembershipFilterMenu
|
||||||
|
selected={membershipFilterIndex}
|
||||||
|
onSelect={setMembershipFilterIndex}
|
||||||
|
requestClose={() => setAnchor(undefined)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
onClick={
|
||||||
|
((evt) =>
|
||||||
|
setAnchor(
|
||||||
|
evt.currentTarget.getBoundingClientRect()
|
||||||
|
)) as MouseEventHandler<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon src={Icons.Filter} size="50" />}
|
||||||
|
>
|
||||||
|
<Text size="T200">{membershipFilter.name}</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
<UseStateProvider initial={undefined}>
|
||||||
|
{(anchor: RectCords | undefined, setAnchor) => (
|
||||||
|
<PopOut
|
||||||
|
anchor={anchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<MemberSortMenu
|
||||||
|
selected={sortFilterIndex}
|
||||||
|
onSelect={setSortFilterIndex}
|
||||||
|
requestClose={() => setAnchor(undefined)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
onClick={
|
||||||
|
((evt) =>
|
||||||
|
setAnchor(
|
||||||
|
evt.currentTarget.getBoundingClientRect()
|
||||||
|
)) as MouseEventHandler<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon src={Icons.Sort} size="50" />}
|
||||||
|
>
|
||||||
|
<Text size="T200">{memberSort.name}</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
</Box>
|
||||||
|
<ScrollTopContainer
|
||||||
|
style={{ top: toRem(64) }}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
anchorRef={scrollTopAnchorRef}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => virtualizer.scrollToOffset(0)}
|
||||||
|
variant="Surface"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
size="300"
|
||||||
|
aria-label="Scroll to Top"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ChevronTop} size="300" />
|
||||||
|
</IconButton>
|
||||||
|
</ScrollTopContainer>
|
||||||
|
{fetchingMembers && (
|
||||||
|
<Box justifyContent="Center">
|
||||||
|
<Spinner />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: virtualizer.getTotalSize(),
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const tagOrMember = flattenTagMembers[vItem.index];
|
||||||
|
|
||||||
|
if ('userId' in tagOrMember) {
|
||||||
|
const server = getMxIdServer(tagOrMember.userId);
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={`${tagOrMember.userId}-${vItem.index}`}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<div style={{ paddingTop: config.space.S200 }}>
|
||||||
|
<MemberTile
|
||||||
|
data-user-id={tagOrMember.userId}
|
||||||
|
onClick={handleMemberClick}
|
||||||
|
mx={mx}
|
||||||
|
room={room}
|
||||||
|
member={tagOrMember}
|
||||||
|
useAuthentication={useAuthentication}
|
||||||
|
after={
|
||||||
|
server && (
|
||||||
|
<Box as="span" shrink="No" alignSelf="End">
|
||||||
|
<ServerBadge server={server} fill="None" />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={vItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="L400">{tagOrMember.name}</Text>
|
||||||
|
</div>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/features/common-settings/members/index.ts
Normal file
1
src/app/features/common-settings/members/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './Members';
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/app/features/common-settings/permissions/Powers.tsx
Normal file
176
src/app/features/common-settings/permissions/Powers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue