mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-16 20:20:29 +03:00
Add new space settings (#2293)
This commit is contained in:
parent
4aed4d7472
commit
5c39a36c12
44 changed files with 691 additions and 63 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
579
src/app/features/common-settings/permissions/PowersEditor.tsx
Normal file
579
src/app/features/common-settings/permissions/PowersEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/app/features/common-settings/permissions/index.ts
Normal file
4
src/app/features/common-settings/permissions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './PermissionGroups';
|
||||
export * from './Powers';
|
||||
export * from './PowersEditor';
|
||||
export * from './types';
|
||||
12
src/app/features/common-settings/permissions/types.ts
Normal file
12
src/app/features/common-settings/permissions/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { PermissionLocation } from '../../../hooks/usePowerLevels';
|
||||
|
||||
export type PermissionItem = {
|
||||
location: PermissionLocation;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PermissionGroup = {
|
||||
name: string;
|
||||
items: PermissionItem[];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue