c5fbc20394
P5-18: PresenceRingAvatar wrapper component applies a 2px box-shadow ring to user avatars — green (online), yellow (idle/unavailable), red (DND via status_msg='dnd'), no ring (offline). Applied to: message timeline sender avatars, members drawer (members + knock requests), @mention autocomplete, and inbox notification senders. P5-6: Leading emoji in room names renders at 1.15× in the sidebar via Unicode emoji regex detection in RoomNavItem. Emoji picker (EmojiBoard in PopOut) added to all three room-name inputs: Create Room dialog (converted to controlled input), Room Settings name field (shown only when canEditName), and the "Rename for me" local rename dialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
|
import { MatrixError, Room, JoinRule } from 'matrix-js-sdk';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
color,
|
|
config,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
PopOut,
|
|
RectCords,
|
|
Spinner,
|
|
Switch,
|
|
Text,
|
|
TextArea,
|
|
} from 'folds';
|
|
import { EmojiBoard } from '../../components/emoji-board';
|
|
import { SettingTile } from '../../components/setting-tile';
|
|
import { SequenceCard } from '../../components/sequence-card';
|
|
import {
|
|
creatorsSupported,
|
|
knockRestrictedSupported,
|
|
knockSupported,
|
|
restrictedSupported,
|
|
} from '../../utils/matrix';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
|
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
import { useCapabilities } from '../../hooks/useCapabilities';
|
|
import { useAlive } from '../../hooks/useAlive';
|
|
import { ErrorCode } from '../../cs-errorcode';
|
|
import {
|
|
AdditionalCreatorInput,
|
|
createRoom,
|
|
CreateRoomAliasInput,
|
|
CreateRoomData,
|
|
CreateRoomAccess,
|
|
CreateRoomAccessSelector,
|
|
RoomVersionSelector,
|
|
useAdditionalCreators,
|
|
CreateRoomType,
|
|
} from '../../components/create-room';
|
|
import { RoomType } from '../../../types/matrix/room';
|
|
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
|
|
import { getRoomIconSrc } from '../../utils/room';
|
|
|
|
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
|
|
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
|
|
|
|
let joinRule: JoinRule = JoinRule.Public;
|
|
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
|
|
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
|
|
|
|
return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule);
|
|
};
|
|
|
|
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
|
|
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
|
|
return Icons.Hash;
|
|
};
|
|
|
|
type CreateRoomFormProps = {
|
|
defaultAccess?: CreateRoomAccess;
|
|
defaultType?: CreateRoomType;
|
|
space?: Room;
|
|
onCreate?: (roomId: string) => void;
|
|
};
|
|
export function CreateRoomForm({
|
|
defaultAccess,
|
|
defaultType,
|
|
space,
|
|
onCreate,
|
|
}: CreateRoomFormProps) {
|
|
const mx = useMatrixClient();
|
|
const alive = useAlive();
|
|
|
|
const capabilities = useCapabilities();
|
|
const roomVersions = capabilities['m.room_versions'];
|
|
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
|
|
useEffect(() => {
|
|
// capabilities load async
|
|
selectRoomVersion(roomVersions?.default ?? '1');
|
|
}, [roomVersions?.default]);
|
|
|
|
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
|
|
|
const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom);
|
|
const [access, setAccess] = useState(
|
|
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private),
|
|
);
|
|
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
|
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
|
|
useAdditionalCreators();
|
|
const [federation, setFederation] = useState(true);
|
|
const [encryption, setEncryption] = useState(false);
|
|
const [knock, setKnock] = useState(false);
|
|
const [advance, setAdvance] = useState(false);
|
|
const [nameValue, setNameValue] = useState('');
|
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
|
|
|
const handleEmojiSelect = useCallback((unicode: string) => {
|
|
setNameValue((prev) => unicode + prev);
|
|
setEmojiAnchor(undefined);
|
|
}, []);
|
|
|
|
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
|
|
const allowKnockRestricted =
|
|
access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
|
|
|
const handleRoomVersionChange = (version: string) => {
|
|
if (!restrictedSupported(version)) {
|
|
setAccess(CreateRoomAccess.Private);
|
|
}
|
|
selectRoomVersion(version);
|
|
};
|
|
|
|
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
|
|
useCallback((data) => createRoom(mx, data), [mx]),
|
|
);
|
|
const loading = createState.status === AsyncStatus.Loading;
|
|
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
|
const disabled = createState.status === AsyncStatus.Loading;
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
evt.preventDefault();
|
|
if (disabled) return;
|
|
const form = evt.currentTarget;
|
|
|
|
const nameInput = form.nameInput as HTMLInputElement | undefined;
|
|
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
|
|
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
|
|
const roomName = nameInput?.value.trim();
|
|
const roomTopic = topicTextArea?.value.trim();
|
|
const aliasLocalPart =
|
|
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
|
|
|
if (!roomName) return;
|
|
const publicRoom = access === CreateRoomAccess.Public;
|
|
let roomKnock = false;
|
|
if (allowKnock && access === CreateRoomAccess.Private) {
|
|
roomKnock = knock;
|
|
}
|
|
if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
|
|
roomKnock = knock;
|
|
}
|
|
|
|
let roomType: RoomType | undefined;
|
|
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
|
|
|
|
create({
|
|
version: selectedRoomVersion,
|
|
type: roomType,
|
|
parent: space,
|
|
access,
|
|
name: roomName,
|
|
topic: roomTopic || undefined,
|
|
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
|
encryption: publicRoom ? false : encryption,
|
|
knock: roomKnock,
|
|
allowFederation: federation,
|
|
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
|
}).then((roomId) => {
|
|
if (alive()) {
|
|
onCreate?.(roomId);
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
|
{!space && (
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Type</Text>
|
|
<CreateRoomTypeSelector
|
|
value={type}
|
|
onSelect={setType}
|
|
disabled={disabled}
|
|
getIcon={getCreateRoomTypeToIcon}
|
|
/>
|
|
</Box>
|
|
)}
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Access</Text>
|
|
<CreateRoomAccessSelector
|
|
value={access}
|
|
onSelect={setAccess}
|
|
canRestrict={allowRestricted}
|
|
disabled={disabled}
|
|
getIcon={(roomAccess) => getCreateRoomAccessToIcon(roomAccess, type)}
|
|
/>
|
|
</Box>
|
|
<Box shrink="No" direction="Column" gap="100">
|
|
<Text size="L400">Name</Text>
|
|
<Box direction="Row" gap="100" alignItems="Center">
|
|
<PopOut
|
|
anchor={emojiAnchor}
|
|
position="Top"
|
|
align="End"
|
|
content={
|
|
<EmojiBoard
|
|
imagePackRooms={[]}
|
|
returnFocusOnDeactivate={false}
|
|
onEmojiSelect={handleEmojiSelect}
|
|
requestClose={() => setEmojiAnchor(undefined)}
|
|
/>
|
|
}
|
|
>
|
|
<IconButton
|
|
type="button"
|
|
size="400"
|
|
radii="400"
|
|
variant="Surface"
|
|
fill="Soft"
|
|
outlined
|
|
disabled={disabled}
|
|
aria-label="Insert emoji"
|
|
aria-expanded={!!emojiAnchor}
|
|
aria-haspopup="dialog"
|
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
|
const rect = evt.currentTarget.getBoundingClientRect();
|
|
setEmojiAnchor((prev) => (prev ? undefined : rect));
|
|
}}
|
|
>
|
|
<Icon src={Icons.Smile} size="400" />
|
|
</IconButton>
|
|
</PopOut>
|
|
<Box grow="Yes">
|
|
<Input
|
|
required
|
|
before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
|
|
name="nameInput"
|
|
autoFocus
|
|
size="500"
|
|
variant="SurfaceVariant"
|
|
radii="400"
|
|
autoComplete="off"
|
|
disabled={disabled}
|
|
value={nameValue}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
<Box shrink="No" direction="Column" gap="100">
|
|
<Text size="L400">Topic (Optional)</Text>
|
|
<TextArea
|
|
name="topicTextAria"
|
|
size="500"
|
|
variant="SurfaceVariant"
|
|
radii="400"
|
|
disabled={disabled}
|
|
/>
|
|
</Box>
|
|
|
|
{access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
|
|
|
|
<Box shrink="No" direction="Column" gap="100">
|
|
<Box gap="200" alignItems="End">
|
|
<Text size="L400">Options</Text>
|
|
<Box grow="Yes" justifyContent="End">
|
|
<Chip
|
|
radii="Pill"
|
|
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
|
onClick={() => setAdvance(!advance)}
|
|
type="button"
|
|
>
|
|
<Text size="T200">Advanced Options</Text>
|
|
</Chip>
|
|
</Box>
|
|
</Box>
|
|
{allowAdditionalCreators && (
|
|
<SequenceCard
|
|
style={{ padding: config.space.S300 }}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="500"
|
|
>
|
|
<AdditionalCreatorInput
|
|
additionalCreators={additionalCreators}
|
|
onSelect={addAdditionalCreator}
|
|
onRemove={removeAdditionalCreator}
|
|
/>
|
|
</SequenceCard>
|
|
)}
|
|
{access !== CreateRoomAccess.Public && (
|
|
<>
|
|
<SequenceCard
|
|
style={{ padding: config.space.S300 }}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="500"
|
|
>
|
|
<SettingTile
|
|
title="End-to-End Encryption"
|
|
description="Once this feature is enabled, it can't be disabled after the room is created."
|
|
after={
|
|
<Switch
|
|
variant="Primary"
|
|
value={encryption}
|
|
onChange={setEncryption}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
/>
|
|
</SequenceCard>
|
|
{advance && (allowKnock || allowKnockRestricted) && (
|
|
<SequenceCard
|
|
style={{ padding: config.space.S300 }}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="500"
|
|
>
|
|
<SettingTile
|
|
title="Knock to Join"
|
|
description="Anyone can send request to join this room."
|
|
after={
|
|
<Switch
|
|
variant="Primary"
|
|
value={knock}
|
|
onChange={setKnock}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
/>
|
|
</SequenceCard>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<SequenceCard
|
|
style={{ padding: config.space.S300 }}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="500"
|
|
>
|
|
<SettingTile
|
|
title="Allow Federation"
|
|
description="Users from other servers can join."
|
|
after={
|
|
<Switch
|
|
variant="Primary"
|
|
value={federation}
|
|
onChange={setFederation}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
/>
|
|
</SequenceCard>
|
|
{advance && (
|
|
<RoomVersionSelector
|
|
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
|
|
value={selectedRoomVersion}
|
|
onChange={handleRoomVersionChange}
|
|
disabled={disabled}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{error && (
|
|
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
|
<Icon src={Icons.Warning} filled size="100" />
|
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
|
<b>
|
|
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
|
|
? `Server rate-limited your request for ${millisecondsToMinutes(
|
|
(error.data.retry_after_ms as number | undefined) ?? 0,
|
|
)} minutes!`
|
|
: error.message}
|
|
</b>
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box shrink="No" direction="Column" gap="200">
|
|
<Button
|
|
type="submit"
|
|
size="500"
|
|
variant="Primary"
|
|
radii="400"
|
|
disabled={disabled}
|
|
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
|
|
>
|
|
<Text size="B500">Create</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|