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(null); const [title, setTitle] = useState(''); const [category, setCategory] = useState(initialCategory); const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId); const [sending, setSending] = useState(false); const [error, setError] = useState(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 = 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 ( }>
New post
setTitle(e.target.value)} maxLength={140} required autoFocus placeholder="Title" aria-label="Title" autoComplete="off" disabled={sending} /> } /> {error && ( {error} )}
); }