78090d1c2d
Glassmorphism root cause (from audit): the body-background useEffect had `lotusTerminal && chatBackground === 'none' ? 'tactical'` as the fallback guard — meaning the tactical grid only appeared when Lotus Terminal mode was active. With default settings (TDS off, no chat background chosen), the body got no background at all, so backdrop-filter had a flat solid colour to blur — identical to unblurred. Fix: drop the `lotusTerminal &&` guard so the tactical dot-grid is always the fallback when chatBackground is 'none', regardless of theme. Glassmorphism is now visible in all themes without any additional setup. ESLint: RoomProfile.tsx had `../../../components/emoji-board` imported before `matrix-js-sdk` violating import/order. Moved it after. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
color,
|
|
config,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
PopOut,
|
|
RectCords,
|
|
Spinner,
|
|
Text,
|
|
TextArea,
|
|
} from 'folds';
|
|
import React, { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
|
import { useAtomValue } from 'jotai';
|
|
import Linkify from 'linkify-react';
|
|
import classNames from 'classnames';
|
|
import { JoinRule, MatrixError } from 'matrix-js-sdk';
|
|
import { EmojiBoard } from '../../../components/emoji-board';
|
|
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 { 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';
|
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
|
|
|
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
|
|
|
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selected = textarea.value.substring(start, end);
|
|
const inner = selected || placeholder;
|
|
const replacement = `${syntax}${inner}${syntax}`;
|
|
const newValue = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
nativeSetter?.call(textarea, newValue);
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
const cursorStart = start + syntax.length;
|
|
const cursorEnd = cursorStart + inner.length;
|
|
textarea.focus();
|
|
textarea.setSelectionRange(cursorStart, cursorEnd);
|
|
}
|
|
|
|
function topicMarkdownToHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
function buildTopicContent(topic: string): Record<string, string> {
|
|
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
|
|
const formattedBody = topicMarkdownToHtml(topic);
|
|
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
|
|
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
|
|
}
|
|
|
|
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 [nameValue, setNameValue] = useState(name);
|
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
|
|
|
const handleEmojiSelect = useCallback((unicode: string) => {
|
|
setNameValue((prev) => unicode + prev);
|
|
setEmojiAnchor(undefined);
|
|
}, []);
|
|
|
|
const topicRef = useRef<HTMLTextAreaElement>(null);
|
|
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) {
|
|
const topicContent = buildTopicContent(roomTopic);
|
|
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, topicContent);
|
|
}
|
|
},
|
|
[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 topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
|
|
if (!topicTextArea) return;
|
|
|
|
const roomName = nameValue.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
|
|
roomType={room.getType()}
|
|
size="400"
|
|
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
|
filled
|
|
/>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
</Box>
|
|
</Box>
|
|
<Box direction="Inherit" gap="100">
|
|
<Text size="L400">Name</Text>
|
|
<Box direction="Row" gap="100" alignItems="Center">
|
|
{canEditName && !submitting && (
|
|
<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
|
|
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
|
|
name="nameInput"
|
|
value={nameValue}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
|
|
variant="Secondary"
|
|
radii="300"
|
|
readOnly={!canEditName || submitting}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
<Box direction="Inherit" gap="100">
|
|
<Box alignItems="Center" justifyContent="SpaceBetween">
|
|
<Text size="L400">Topic</Text>
|
|
{canEditTopic && !submitting && (
|
|
<Box gap="100">
|
|
{(
|
|
[
|
|
{ label: 'B', syntax: '**', placeholder: 'bold', title: 'Bold' },
|
|
{ label: 'I', syntax: '*', placeholder: 'italic', title: 'Italic' },
|
|
{
|
|
label: 'S',
|
|
syntax: '~~',
|
|
placeholder: 'strikethrough',
|
|
title: 'Strikethrough',
|
|
},
|
|
{ label: '`', syntax: '`', placeholder: 'code', title: 'Inline Code' },
|
|
] as const
|
|
).map(({ label, syntax, placeholder, title }) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
title={title}
|
|
aria-label={title}
|
|
onClick={() =>
|
|
topicRef.current && wrapSelection(topicRef.current, syntax, placeholder)
|
|
}
|
|
style={{
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
|
borderRadius: config.radii.R300,
|
|
background: color.Surface.Container,
|
|
color: color.Surface.OnContainer,
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
fontWeight: label === 'B' ? 700 : label === 'I' ? undefined : undefined,
|
|
fontStyle: label === 'I' ? 'italic' : undefined,
|
|
fontFamily: label === '`' ? 'monospace' : 'inherit',
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<TextArea
|
|
ref={topicRef}
|
|
name="topicTextArea"
|
|
defaultValue={topic}
|
|
placeholder="Describe this room… supports **bold**, *italic*, ~~strikethrough~~, `code`"
|
|
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 = {
|
|
permissions: RoomPermissionsAPI;
|
|
};
|
|
export function RoomProfile({ permissions }: RoomProfileProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const room = useRoom();
|
|
const directs = useAtomValue(mDirectAtom);
|
|
|
|
const avatar = useRoomAvatar(room, directs.has(room.roomId));
|
|
const name = useRoomName(room);
|
|
const topic = useRoomTopic(room);
|
|
const joinRule = useRoomJoinRule(room);
|
|
|
|
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
|
|
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
|
|
const canEditTopic = permissions.stateEvent(StateEvent.RoomTopic, mx.getSafeUserId());
|
|
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?.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.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
|
|
roomType={room.getType()}
|
|
size="400"
|
|
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
|
filled
|
|
/>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</SequenceCard>
|
|
</Box>
|
|
);
|
|
}
|