feat: Integrate forum layout support across various components and enhance room handling logic

This commit is contained in:
2026-07-06 01:06:20 +10:00
parent d1626e3585
commit c57797ffe4
62 changed files with 6876 additions and 385 deletions

View File

@@ -0,0 +1,268 @@
import React, { FormEventHandler, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Line,
Scroll,
Text,
color,
config,
toRem,
} from 'folds';
import {
CustomEditor,
Toolbar,
isEmptyEditor,
resetEditor,
resetEditorHistory,
toMatrixCustomHTML,
toPlainText,
trimCustomHtml,
useEditor,
} from '../../components/editor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { sendForumPost } from './forumFeed';
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
import type { ForumPublishedPost, ForumSection } from './types';
import { ForumTopicTargetFields } from './ForumTopicTargetFields';
import * as t from './forumTheme.css';
type ForumNewPostModalProps = {
sections: ForumSection[];
initialCategory?: string;
initialTopicRoomId?: string;
onClose: () => void;
onPublished?: (published: ForumPublishedPost) => void;
};
export function ForumNewPostModal({
sections,
initialCategory = '',
initialTopicRoomId = '',
onClose,
onPublished,
}: ForumNewPostModalProps) {
const mx = useMatrixClient();
const editor = useEditor();
const formRef = useRef<HTMLFormElement>(null);
const [title, setTitle] = useState('');
const [category, setCategory] = useState(initialCategory);
const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const categoryTopics = useMemo(() => {
if (category) {
return sections.find((s) => s.title === category)?.topics ?? [];
}
return sections.flatMap((s) => s.topics);
}, [category, sections]);
useEffect(() => {
if (!topicRoomId) return;
if (!categoryTopics.some((topic) => topic.roomId === topicRoomId)) {
setTopicRoomId('');
}
}, [categoryTopics, topicRoomId]);
const handleClose = useCallback(() => {
if (sending) return;
resetEditor(editor);
resetEditorHistory(editor);
onClose();
}, [editor, onClose, sending]);
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
async (evt) => {
evt.preventDefault();
if (sending) return;
const trimmedTitle = title.trim();
const plainText = toPlainText(editor.children, false).trim();
const formattedHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
);
if (!topicRoomId) {
setError('Select a topic for this post.');
return;
}
if (!trimmedTitle) {
setError('Post title is required.');
return;
}
if (isEmptyEditor(editor)) {
setError('Post body is required.');
return;
}
setSending(true);
setError(null);
try {
const eventId = await sendForumPost(mx, topicRoomId, {
title: trimmedTitle,
plainText,
formattedHtml,
});
resetEditor(editor);
resetEditorHistory(editor);
onPublished?.({
eventId,
topicRoomId,
title: trimmedTitle,
plainText,
formattedHtml,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to publish post');
} finally {
setSending(false);
}
},
[editor, mx, onClose, onPublished, sending, title, topicRoomId]
);
const handleEditorKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if (isKeyHotkey('mod+enter', evt) && !sending) {
evt.preventDefault();
evt.currentTarget.closest('form')?.requestSubmit();
}
},
[sending]
);
const handleCategoryChange = useCallback((next: string) => {
setCategory(next);
setTopicRoomId('');
}, []);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: !sending,
escapeDeactivates: stopPropagation,
}}
>
<Modal
size="500"
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 'min(90vh, 52rem)',
maxWidth: 'min(720px, calc(100vw - 2rem))',
width: '100%',
}}
>
<Header
size="500"
style={{
padding: config.space.S300,
paddingLeft: config.space.S400,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text size="H4">New post</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleClose} disabled={sending} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack style={{ flex: '1 1 auto', minHeight: 0 }}>
<Box
as="form"
ref={formRef}
className={t.ForumPostModalForm}
onSubmit={handleSubmit}
direction="Column"
style={{ padding: config.space.S400 }}
>
<ForumTopicTargetFields
sections={sections}
category={category}
topicRoomId={topicRoomId}
onCategoryChange={handleCategoryChange}
onTopicChange={setTopicRoomId}
disabled={sending}
/>
<input
id="forum-post-title"
className={t.ForumFilterSelect}
name="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={140}
required
autoFocus
placeholder="Title"
aria-label="Title"
autoComplete="off"
disabled={sending}
/>
<Box className={t.ForumPostModalBodyField}>
<Box className={t.ForumPostModalEditorWrap}>
<CustomEditor
editor={editor}
fillHeight
placeholder="Write your post…"
maxHeight={toRem(280)}
onKeyDown={handleEditorKeyDown}
bottom={
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
}
/>
</Box>
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<Box className={t.ForumPostModalActions}>
<Button
type="button"
variant="SurfaceVariant"
size="400"
radii="300"
onClick={handleClose}
disabled={sending}
>
<Text size="B400">Cancel</Text>
</Button>
<Button type="submit" variant="Primary" size="400" radii="300" disabled={sending}>
<Text size="B400">{sending ? 'Publishing…' : 'Publish post'}</Text>
</Button>
</Box>
</Box>
</Scroll>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}