feat: Integrate forum layout support across various components and enhance room handling logic
This commit is contained in:
268
src/app/features/forum/ForumNewPostModal.tsx
Normal file
268
src/app/features/forum/ForumNewPostModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user