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, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/_(.+?)_/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/\[(.+?)\]\((.+?)\)/g, '$1')
.replace(/\n/g, '
');
}
function buildTopicContent(topic: string): Record {
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(/
/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();
const handleEmojiSelect = useCallback((unicode: string) => {
setNameValue((prev) => unicode + prev);
setEmojiAnchor(undefined);
}, []);
const topicRef = useRef(null);
const [imageFile, setImageFile] = useState();
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 = (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 (
Avatar
{uploadAtom ? (
) : (
{!roomAvatar && avatar && (
)}
{roomAvatar && (
)}
)}
(
)}
/>
Name
{canEditName && !submitting && (
setEmojiAnchor(undefined)}
/>
}
>
) => {
const rect = evt.currentTarget.getBoundingClientRect();
setEmojiAnchor((prev) => (prev ? undefined : rect));
}}
>
)}
) => setNameValue(e.target.value)}
variant="Secondary"
radii="300"
readOnly={!canEditName || submitting}
style={{ width: '100%' }}
/>
Topic
{canEditTopic && !submitting && (
{(
[
{ 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 }) => (
))}
)}
{submitState.status === AsyncStatus.Error && (
{(submitState.error as MatrixError).message}
)}
}
>
Save
);
}
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 (
Profile
{edit ? (
) : (
{name ?? 'Unknown'}
{topic && (
{topic.topic}
)}
{canEdit && (
}
onClick={() => setEdit(true)}
outlined
>
Edit
)}
(
)}
/>
)}
);
}