312 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|