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 }) => ( ))} )}