import {
Avatar,
Box,
Button,
Chip,
color,
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 parse from 'html-react-parser';
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 { sanitizeCustomHtml } from '../../../utils/sanitize';
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 { sendStateEvent } from '../../../utils/room';
import { describeRoomVisibility } from '../../../utils/roomVisibilityLine';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
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, '');
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) => {
// Clamp to the same cap as the input's maxLength — programmatic setState isn't
// constrained by the DOM maxLength, so the picker could otherwise exceed it.
setNameValue((prev) => (unicode + prev).slice(0, 255));
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]);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
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 sendStateEvent(mx, room.roomId, StateEvent.RoomAvatar, {
url: roomAvatarMxc,
});
}
if (roomName !== undefined) {
await sendStateEvent(mx, room.roomId, StateEvent.RoomName, { name: roomName });
}
if (roomTopic !== undefined) {
const topicContent = buildTopicContent(roomTopic);
await sendStateEvent(mx, room.roomId, StateEvent.RoomTopic, 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"
maxLength={255}
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);
// [Gitea #133] One line, no controls: encryption · join rule · history.
const historyVisibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
const visibilityLine = describeRoomVisibility({
encrypted: room.hasEncryptionStateEvent(),
joinRule: joinRule?.join_rule,
historyVisibility: historyVisibilityEvent?.getContent<{ history_visibility?: string }>()
.history_visibility,
});
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'}
{visibilityLine}
{topic && (
{topic.format === 'org.matrix.custom.html' &&
typeof topic.formatted_body === 'string' ? (
parse(sanitizeCustomHtml(topic.formatted_body))
) : (
{topic.topic}
)}
)}
{canEdit && (
}
onClick={() => setEdit(true)}
outlined
>
Edit
)}
(
)}
/>
)}
);
}