Add new space settings (#2293)

This commit is contained in:
Ajay Bura
2025-03-27 19:54:13 +11:00
committed by GitHub
parent 4aed4d7472
commit 5c39a36c12
44 changed files with 691 additions and 63 deletions

View File

@@ -0,0 +1,438 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Badge,
Box,
Button,
Checkbox,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Text,
toRem,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import {
useLocalAliases,
usePublishedAliases,
usePublishUnpublishAliases,
useSetMainAlias,
} from '../../../hooks/useRoomAliases';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { CutoutCard } from '../../../components/cutout-card';
import { getIdServer } from '../../../../util/matrixUtil';
import { replaceSpaceWithDash } from '../../../utils/common';
import { useAlive } from '../../../hooks/useAlive';
import { StateEvent } from '../../../../types/matrix/room';
type RoomPublishedAddressesProps = {
powerLevels: IPowerLevels;
};
export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomCanonicalAlias,
userPowerLevel
);
const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
const setMainAlias = useSetMainAlias(room);
const [mainState, setMain] = useAsyncCallback(setMainAlias);
const loading = mainState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Published Addresses"
description={
<span>
If access is <b>Public</b>, Published addresses will be used to join by anyone.
</span>
}
/>
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
{publishedAliases.length === 0 ? (
<Box direction="Column" gap="100">
<Text size="L400">No Addresses</Text>
<Text size="T200">
To publish an address, it needs to be set as a local address first
</Text>
</Box>
) : (
<Box direction="Column" gap="300">
{publishedAliases.map((alias) => (
<Box key={alias} as="span" gap="200" alignItems="Center">
<Box grow="Yes" gap="Inherit" alignItems="Center">
<Text size="T300" truncate>
{alias === canonicalAlias ? <b>{alias}</b> : alias}
</Text>
{alias === canonicalAlias && (
<Badge variant="Success" fill="Solid" size="500">
<Text size="L400">Main</Text>
</Badge>
)}
</Box>
{canEditCanonical && (
<Box shrink="No" gap="100">
{alias === canonicalAlias ? (
<Chip
variant="Warning"
radii="Pill"
fill="None"
disabled={loading}
onClick={() => setMain(undefined)}
>
<Text size="B300">Unset Main</Text>
</Chip>
) : (
<Chip
variant="Success"
radii="Pill"
fill={canonicalAlias ? 'None' : 'Soft'}
disabled={loading}
onClick={() => setMain(alias)}
>
<Text size="B300">Set Main</Text>
</Chip>
)}
</Box>
)}
</Box>
))}
{mainState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{(mainState.error as MatrixError).message}
</Text>
)}
</Box>
)}
</CutoutCard>
</SequenceCard>
);
}
function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const server = getIdServer(userId);
const alive = useAlive();
const [addState, addAlias] = useAsyncCallback(addLocalAlias);
const adding = addState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
if (adding) return;
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const aliasInput = target?.aliasInput as HTMLInputElement | undefined;
if (!aliasInput) return;
const alias = replaceSpaceWithDash(aliasInput.value.trim());
if (!alias) return;
addAlias(`#${alias}:${server}`).then(() => {
if (alive()) {
aliasInput.value = '';
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Box gap="200">
<Box grow="Yes" direction="Column">
<Input
name="aliasInput"
variant="Secondary"
size="400"
radii="300"
before={<Text size="T200">#</Text>}
readOnly={adding}
after={
<Text style={{ maxWidth: toRem(300) }} size="T200" truncate>
:{server}
</Text>
}
/>
</Box>
<Box shrink="No">
<Button
variant="Success"
size="400"
radii="300"
type="submit"
disabled={adding}
before={adding && <Spinner size="100" variant="Success" fill="Solid" />}
>
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
{addState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(addState.error as MatrixError).httpStatus === 409
? 'Address is already in use!'
: (addState.error as MatrixError).message}
</Text>
)}
</Box>
);
}
function LocalAddressesList({
localAliases,
removeLocalAlias,
canEditCanonical,
}: {
localAliases: string[];
removeLocalAlias: (alias: string) => Promise<void>;
canEditCanonical?: boolean;
}) {
const room = useRoom();
const alive = useAlive();
const [, publishedAliases] = usePublishedAliases(room);
const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room);
const [selectedAliases, setSelectedAliases] = useState<string[]>([]);
const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias));
const toggleSelect = (alias: string) => {
setSelectedAliases((aliases) => {
if (aliases.includes(alias)) {
return aliases.filter((a) => a !== alias);
}
const newAliases = [...aliases];
newAliases.push(alias);
return newAliases;
});
};
const clearSelected = () => {
if (alive()) {
setSelectedAliases([]);
}
};
const [deleteState, deleteAliases] = useAsyncCallback(
useCallback(
async (aliases: string[]) => {
for (let i = 0; i < aliases.length; i += 1) {
const alias = aliases[i];
// eslint-disable-next-line no-await-in-loop
await removeLocalAlias(alias);
}
},
[removeLocalAlias]
)
);
const [publishState, publish] = useAsyncCallback(publishAliases);
const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases);
const handleDelete = () => {
deleteAliases(selectedAliases).then(clearSelected);
};
const handlePublish = () => {
publish(selectedAliases).then(clearSelected);
};
const handleUnpublish = () => {
unpublish(selectedAliases).then(clearSelected);
};
const loading =
deleteState.status === AsyncStatus.Loading ||
publishState.status === AsyncStatus.Loading ||
unpublishState.status === AsyncStatus.Loading;
let error: MatrixError | undefined;
if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError;
if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError;
if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError;
return (
<Box direction="Column" gap="300">
{selectedAliases.length > 0 && (
<Box gap="200">
<Box grow="Yes">
<Text size="L400">{selectedAliases.length} Selected</Text>
</Box>
<Box shrink="No" gap="Inherit">
{canEditCanonical &&
(selectHasPublished ? (
<Chip
variant="Warning"
radii="Pill"
disabled={loading}
onClick={handleUnpublish}
before={
unpublishState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Warning" />
)
}
>
<Text size="B300">Unpublish</Text>
</Chip>
) : (
<Chip
variant="Success"
radii="Pill"
disabled={loading}
onClick={handlePublish}
before={
publishState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Success" />
)
}
>
<Text size="B300">Publish</Text>
</Chip>
))}
<Chip
variant="Critical"
radii="Pill"
disabled={loading}
onClick={handleDelete}
before={
deleteState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Critical" />
)
}
>
<Text size="B300">Delete</Text>
</Chip>
</Box>
</Box>
)}
{localAliases.map((alias) => {
const published = publishedAliases.includes(alias);
const selected = selectedAliases.includes(alias);
return (
<Box key={alias} as="span" alignItems="Center" gap="200">
<Box shrink="No">
<Checkbox
checked={selected}
onChange={() => toggleSelect(alias)}
size="50"
variant="Primary"
disabled={loading}
/>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{alias}
</Text>
</Box>
<Box shrink="No" gap="100">
{published && (
<Badge variant="Success" fill="Soft" size="500">
<Text size="L400">Published</Text>
</Badge>
)}
</Box>
</Box>
);
})}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error.message}
</Text>
)}
</Box>
);
}
export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomCanonicalAlias,
userPowerLevel
);
const [expand, setExpand] = useState(false);
const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Local Addresses"
description="Set local address so users can join through your homeserver."
after={
<Button
type="button"
onClick={() => setExpand(!expand)}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
before={
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
}
>
<Text as="span" size="B300" truncate>
{expand ? 'Collapse' : 'Expand'}
</Text>
</Button>
}
/>
{expand && (
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
{localAliasesState.status === AsyncStatus.Loading && (
<Box gap="100">
<Spinner variant="Secondary" size="100" />
<Text size="T200">Loading...</Text>
</Box>
)}
{localAliasesState.status === AsyncStatus.Success &&
(localAliasesState.data.length === 0 ? (
<Box direction="Column" gap="100">
<Text size="L400">No Addresses</Text>
</Box>
) : (
<LocalAddressesList
localAliases={localAliasesState.data}
removeLocalAlias={removeLocalAlias}
canEditCanonical={canEditCanonical}
/>
))}
{localAliasesState.status === AsyncStatus.Error && (
<Box gap="100">
<Text size="T200" style={{ color: color.Critical.Main }}>
{localAliasesState.error.message}
</Text>
</Box>
)}
</CutoutCard>
)}
{expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
</SequenceCard>
);
}

View File

@@ -0,0 +1,150 @@
import {
Badge,
Box,
Button,
color,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import React, { useCallback, useState } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
type RoomEncryptionProps = {
powerLevels: IPowerLevels;
};
export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEnable = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomEncryption,
userPowerLevel
);
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
algorithm: string;
}>();
const enabled = content?.algorithm === ROOM_ENC_ALGO;
const [enableState, enable] = useAsyncCallback(
useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
algorithm: ROOM_ENC_ALGO,
});
}, [mx, room.roomId])
);
const enabling = enableState.status === AsyncStatus.Loading;
const [prompt, setPrompt] = useState(false);
const handleEnable = () => {
enable();
setPrompt(false);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Room Encryption"
description={
enabled
? 'Messages in this room are protected by end-to-end encryption.'
: 'Once enabled, encryption cannot be disabled!'
}
after={
enabled ? (
<Badge size="500" variant="Success" fill="Solid" radii="300">
<Text size="L400">Enabled</Text>
</Badge>
) : (
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={!canEnable}
onClick={() => setPrompt(true)}
before={enabling && <Spinner size="100" variant="Primary" fill="Solid" />}
>
<Text size="B300">Enable</Text>
</Button>
)
}
>
{enableState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(enableState.error as MatrixError).message}
</Text>
)}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Enable Encryption</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400">
Are you sure? Once enabled, encryption cannot be disabled!
</Text>
<Button type="submit" variant="Primary" onClick={handleEnable}>
<Text size="B400">Enable E2E Encryption</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
</SequenceCard>
);
}

View File

@@ -0,0 +1,169 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
Button,
color,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
const useVisibilityStr = () =>
useMemo(
() => ({
[HistoryVisibility.Invited]: 'After Invite',
[HistoryVisibility.Joined]: 'After Join',
[HistoryVisibility.Shared]: 'All Messages',
[HistoryVisibility.WorldReadable]: 'All Messages (Guests)',
}),
[]
);
const useVisibilityMenu = () =>
useMemo(
() => [
HistoryVisibility.Shared,
HistoryVisibility.Invited,
HistoryVisibility.Joined,
HistoryVisibility.WorldReadable,
],
[]
);
type RoomHistoryVisibilityProps = {
powerLevels: IPowerLevels;
};
export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEdit = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomHistoryVisibility,
userPowerLevel
);
const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
const historyVisibility: HistoryVisibility =
visibilityEvent?.getContent<RoomHistoryVisibilityEventContent>().history_visibility ??
HistoryVisibility.Shared;
const visibilityMenu = useVisibilityMenu();
const visibilityStr = useVisibilityStr();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const [submitState, submit] = useAsyncCallback(
useCallback(
async (visibility: HistoryVisibility) => {
const content: RoomHistoryVisibilityEventContent = {
history_visibility: visibility,
};
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
},
[mx, room.roomId]
)
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleChange = (visibility: HistoryVisibility) => {
submit(visibility);
setMenuAnchor(undefined);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Message History Visibility"
description="Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect."
after={
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
{visibilityMenu.map((visibility) => (
<MenuItem
key={visibility}
size="300"
radii="300"
onClick={() => handleChange(visibility)}
aria-pressed={visibility === historyVisibility}
>
<Text as="span" size="T300" truncate>
{visibilityStr[visibility]}
</Text>
</MenuItem>
))}
</Menu>
</FocusTrap>
}
>
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={!canEdit || submitting}
onClick={handleOpenMenu}
after={
submitting ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.ChevronBottom} />
)
}
>
<Text size="B300">{visibilityStr[historyVisibility]}</Text>
</Button>
</PopOut>
}
>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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