Files
cinny/src/app/features/forum/ForumNewPostModal.tsx

312 lines
10 KiB
TypeScript

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, IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter, Line, Scroll, Text, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../components/icons';
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, sendForumMediaRoot, sendForumThreadAttachment } from './forumFeed';
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
import type { ForumPublishedPost, ForumSection } from './types';
import { ForumTopicTargetFields } from './ForumTopicTargetFields';
import { ForumPostUploadField, useForumPostUploads } from './ForumPostUploadField';
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 uploadRoomKey = topicRoomId || '__forum_new_post_draft__';
const {
selectedFiles,
uploadBoardHandlers,
handleUploadsReady,
collectUploadContents,
clearUploads,
} = useForumPostUploads(uploadRoomKey);
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);
clearUploads();
onClose();
}, [clearUploads, 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)
);
const hasText = !isEmptyEditor(editor);
const hasUploads = selectedFiles.length > 0;
if (!topicRoomId) {
setError('Select a topic for this post.');
return;
}
if (!trimmedTitle) {
setError('Post title is required.');
return;
}
if (!hasText && !hasUploads) {
setError('Post body or an attachment is required.');
return;
}
setSending(true);
setError(null);
try {
const attachmentContents = hasUploads ? await collectUploadContents() : [];
let eventId: string;
let publishedPlainText = plainText;
let publishedHtml = formattedHtml;
if (hasText) {
eventId = await sendForumPost(mx, topicRoomId, {
title: trimmedTitle,
plainText,
formattedHtml,
});
} else {
const [first, ...rest] = attachmentContents;
if (!first) {
throw new Error('Attachments failed to upload.');
}
eventId = await sendForumMediaRoot(mx, topicRoomId, trimmedTitle, first);
publishedPlainText = typeof first.body === 'string' ? first.body : trimmedTitle;
publishedHtml = '';
for (const content of rest) {
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
}
}
if (hasText) {
for (const content of attachmentContents) {
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
}
}
resetEditor(editor);
resetEditorHistory(editor);
clearUploads();
onPublished?.({
eventId,
topicRoomId,
title: trimmedTitle,
plainText: publishedPlainText,
formattedHtml: publishedHtml,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to publish post');
} finally {
setSending(false);
}
},
[
clearUploads,
collectUploadContents,
editor,
mx,
onClose,
onPublished,
selectedFiles.length,
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" />
<Box alignItems="Center" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
<ForumPostUploadField
roomId={uploadRoomKey}
disabled={sending || !topicRoomId}
uploadBoardHandlers={uploadBoardHandlers}
onUploadsReady={handleUploadsReady}
/>
<Box grow="Yes" />
<Toolbar />
</Box>
</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>
);
}