New room settings, add customizable power levels and dev tools (#2222)

* WIP - add room settings dialog

* join rule setting - WIP

* show emojis & stickers in room settings - WIP

* restyle join rule switcher

* Merge branch 'dev' into new-room-settings

* add join rule hook

* open room settings from global state

* open new room settings from all places

* rearrange settings menu item

* add option for creating new image pack

* room devtools - WIP

* render room state events as list

* add option to open state event

* add option to edit state event

* refactor text area code editor into hook

* add option to send message and state event

* add cutout card component

* add hook for room account data

* display room account data - WIP

* refactor global account data editor component

* add account data editor in room

* fix font style in devtool

* show state events in compact form

* add option to delete room image pack

* add server badge component

* add member tile component

* render members in room settings

* add search in room settings member

* add option to reset member search

* add filter in room members

* fix member virtual item key

* remove color from serve badge in room members

* show room in settings

* fix loading indicator position

* power level tags in room setting - WIP

* generate fallback tag in backward compatible way

* add color picker

* add powers editor - WIP

* add props to stop adding emoji to recent usage

* add beta feature notice badge

* add types for power level tag icon

* refactor image pack rooms code to hook

* option for adding new power levels tags

* remove console log

* refactor power icon

* add option to edit power level tags

* remove power level from powers pill

* fix power level labels

* add option to delete power levels

* fix long power level name shrinks power integer

* room permissions - WIP

* add power level selector component

* add room permissions

* move user default permission setting to other group

* add power permission peek menu

* fix weigh of power switch text

* hide above for max power in permission switcher

* improve beta badge description

* render room profile in room settings

* add option to edit room profile

* make room topic input text area

* add option to enable room encryption in room settings

* add option to change message history visibility

* add option to change join rule

* add option for addresses in room settings

* close encryption dialog after enabling
This commit is contained in:
Ajay Bura
2025-03-19 23:14:54 +11:00
committed by GitHub
parent 00f3df8719
commit 286983c833
73 changed files with 6196 additions and 420 deletions

View File

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

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { Powers } from './Powers';
import { useRoom } from '../../../hooks/useRoom';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { PowersEditor } from './PowersEditor';
import { PermissionGroups } from './PermissionGroups';
type PermissionsProps = {
requestClose: () => void;
};
export function Permissions({ requestClose }: PermissionsProps) {
const mx = useMatrixClient();
const room = useRoom();
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEditPowers = canSendStateEvent(
StateEvent.PowerLevelTags,
getPowerLevel(mx.getSafeUserId())
);
const [powerEditor, setPowerEditor] = useState(false);
const handleEditPowers = () => {
setPowerEditor(true);
};
if (canEditPowers && powerEditor) {
return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Permissions
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Powers
powerLevels={powerLevels}
onEdit={canEditPowers ? handleEditPowers : undefined}
/>
<PermissionGroups powerLevels={powerLevels} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,218 @@
import { useMemo } from 'react';
import { PermissionLocation } from '../../../hooks/usePowerLevels';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
export type PermissionItem = {
location: PermissionLocation;
name: string;
description?: string;
};
export type PermissionGroup = {
name: string;
items: PermissionItem[];
};
export const usePermissionGroups = (): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => {
const messagesGroup: PermissionGroup = {
name: 'Messages',
items: [
{
location: {
key: MessageEvent.RoomMessage,
},
name: 'Send Messages',
},
{
location: {
key: MessageEvent.Sticker,
},
name: 'Send Stickers',
},
{
location: {
key: MessageEvent.Reaction,
},
name: 'Send Reactions',
},
{
location: {
notification: true,
key: 'room',
},
name: 'Ping @room',
},
{
location: {
state: true,
key: StateEvent.RoomPinnedEvents,
},
name: 'Pin Messages',
},
{
location: {},
name: 'Other Message Events',
},
],
};
const moderationGroup: PermissionGroup = {
name: 'Moderation',
items: [
{
location: {
action: true,
key: 'invite',
},
name: 'Invite',
},
{
location: {
action: true,
key: 'kick',
},
name: 'Kick',
},
{
location: {
action: true,
key: 'ban',
},
name: 'Ban',
},
{
location: {
action: true,
key: 'redact',
},
name: 'Delete Others Messages',
},
{
location: {
key: MessageEvent.RoomRedaction,
},
name: 'Delete Self Messages',
},
],
};
const roomOverviewGroup: PermissionGroup = {
name: 'Room Overview',
items: [
{
location: {
state: true,
key: StateEvent.RoomAvatar,
},
name: 'Room Avatar',
},
{
location: {
state: true,
key: StateEvent.RoomName,
},
name: 'Room Name',
},
{
location: {
state: true,
key: StateEvent.RoomTopic,
},
name: 'Room Topic',
},
],
};
const roomSettingsGroup: PermissionGroup = {
name: 'Settings',
items: [
{
location: {
state: true,
key: StateEvent.RoomJoinRules,
},
name: 'Change Room Access',
},
{
location: {
state: true,
key: StateEvent.RoomCanonicalAlias,
},
name: 'Publish Address',
},
{
location: {
state: true,
key: StateEvent.RoomPowerLevels,
},
name: 'Change All Permission',
},
{
location: {
state: true,
key: StateEvent.PowerLevelTags,
},
name: 'Edit Power Levels',
},
{
location: {
state: true,
key: StateEvent.RoomEncryption,
},
name: 'Enable Encryption',
},
{
location: {
state: true,
key: StateEvent.RoomHistoryVisibility,
},
name: 'History Visibility',
},
{
location: {
state: true,
key: StateEvent.RoomTombstone,
},
name: 'Upgrade Room',
},
{
location: {
state: true,
},
name: 'Other Settings',
},
],
};
const otherSettingsGroup: PermissionGroup = {
name: 'Other',
items: [
{
location: {
state: true,
key: StateEvent.RoomServerAcl,
},
name: 'Change Server ACLs',
},
{
location: {
state: true,
key: 'im.vector.modular.widgets',
},
name: 'Modify Widgets',
},
],
};
return [
messagesGroup,
moderationGroup,
roomOverviewGroup,
roomSettingsGroup,
otherSettingsGroup,
];
}, []);
return groups;
};