cinny/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
Ajay Bura f82cfead46
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run
Support room version 12 (#2399)
* WIP - support room version 12

* add room creators hook

* revert changes from powerlevels

* improve use room creators hook

* add hook to get dm users

* add options to add creators in create room/space

* add member item component in member drawer

* remove unused import

* extract member drawer header component

* get room creators as set only if room version support them

* add room permissions hook

* support room v12 creators power

* make predecessor event id optional

* add info about founders in permissions

* allow to create infinite powers to room creators

* allow everyone with permission to create infinite power

* handle additional creators in room upgrade

* add option to follow space tombstone
2025-08-13 00:12:30 +10:00

353 lines
11 KiB
TypeScript

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