Files
fluxer/fluxer_app/src/features/forum/components/ForumPostComposer.tsx
T

320 lines
13 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ChannelTextareaControls,
LexicalChannelTextareaContent,
} from '@app/features/channel/components/LexicalChannelTextareaContent';
import type {Channel} from '@app/features/channel/models/Channel';
import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout';
import * as ForumCommands from '@app/features/forum/commands/ForumCommands';
import styles from '@app/features/forum/components/Forum.module.css';
import {ForumComposerTags} from '@app/features/forum/components/ForumTagPicker';
import {isMediaChannel, isTagRequired} from '@app/features/forum/utils/ForumChannelUtils';
import {reportForumError} from '@app/features/forum/utils/ForumErrors';
import * as D from '@app/features/forum/utils/ForumMessageDescriptors';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {useTextareaAttachments} from '@app/features/messaging/hooks/useCloudUpload';
import type {SendMessageFunction} from '@app/features/messaging/hooks/useMessageSubmission';
import Drafts from '@app/features/messaging/state/MessagingDrafts';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {hasVisibleMessageContent} from '@app/features/messaging/utils/VisibleMessageContent';
import Permission from '@app/features/permissions/state/Permission';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {openThread} from '@app/features/threads/commands/ThreadNavigation';
import ThreadPanel from '@app/features/threads/state/ThreadPanel';
import * as ThreadD from '@app/features/threads/utils/ThreadMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Popout} from '@app/features/ui/popover/PopoverPopout';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import Users from '@app/features/user/state/Users';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {THREAD_NAME_MAX_LENGTH} from '@fluxer/constants/src/ThreadConstants';
import type {MessageStickerItem} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import {useLingui} from '@lingui/react/macro';
import {EyeIcon, ImageSquareIcon, SmileyIcon, XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef, useState} from 'react';
function useCooldownSeconds(until: number): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (until <= Date.now()) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [until]);
return Math.max(0, Math.ceil((until - now) / 1000));
}
function resolvePostArgs(
stickersOrTts?: Array<MessageStickerItem> | boolean,
favoriteMemeIdOrStickers?: string | Array<MessageStickerItem>,
maybeFavoriteMemeId?: string,
): {stickers: Array<MessageStickerItem>; favoriteMemeId?: string} {
if (typeof stickersOrTts === 'boolean') {
return {
stickers: Array.isArray(favoriteMemeIdOrStickers) ? favoriteMemeIdOrStickers : [],
favoriteMemeId: maybeFavoriteMemeId,
};
}
return {
stickers: stickersOrTts ?? [],
favoriteMemeId: typeof favoriteMemeIdOrStickers === 'string' ? favoriteMemeIdOrStickers : undefined,
};
}
interface ForumPostComposerProps {
forum: Channel;
initialTitle: string;
onClose: () => void;
}
export const ForumPostComposer = observer(({forum, initialTitle, onClose}: ForumPostComposerProps) => {
const {i18n} = useLingui();
const [title, setTitle] = useState(initialTitle.slice(0, THREAD_NAME_MAX_LENGTH));
const [tags, setTags] = useState<Array<string>>([]);
const [error, setError] = useState<string | undefined>(undefined);
const [body, setBody] = useState('');
const [preview, setPreview] = useState(false);
const controlsRef = useRef<ChannelTextareaControls | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
}, []);
const stateRef = useRef({title, tags});
stateRef.current = {title, tags};
const draftKey = `forum-post:${forum.id}`;
const accountKey = Users.viewAccountKey;
const media = isMediaChannel(forum);
const attachments = useTextareaAttachments(draftKey);
const bypassesSlowmode = Permission.can(Permissions.BYPASS_SLOWMODE, forum);
const cooldownSeconds = useCooldownSeconds(bypassesSlowmode ? 0 : ThreadPanel.getCreateCooldownUntil(forum.id));
const handleSubmit: SendMessageFunction = useCallback(
(content, _hasAttachments, stickersOrTts, favoriteMemeIdOrStickers, maybeFavoriteMemeId) => {
const {stickers, favoriteMemeId} = resolvePostArgs(stickersOrTts, favoriteMemeIdOrStickers, maybeFavoriteMemeId);
if (favoriteMemeId) {
setError(i18n._(D.POST_FAVORITE_MEDIA_UNSUPPORTED_DESCRIPTOR));
return false;
}
const name = stateRef.current.title.trim();
if (!name) {
setError(i18n._(D.POST_TITLE_REQUIRED_DESCRIPTOR));
return false;
}
if (isTagRequired(forum) && stateRef.current.tags.length === 0) {
setError(i18n._(D.POST_TAG_REQUIRED_DESCRIPTOR));
return false;
}
const hasAttachments = CloudUpload.getTextareaAttachments(draftKey).length > 0;
if (media && !hasAttachments) {
setError(i18n._(D.POST_MEDIA_REQUIRED_DESCRIPTOR));
return false;
}
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
const claimed = hasAttachments ? MessageSubmitUtils.claimMessageAttachments(draftKey, nonce, content) : [];
void ForumCommands.createForumPost(forum, {
name,
appliedTags: stateRef.current.tags,
content,
nonce,
hasAttachments: claimed.length > 0,
stickerIds: stickers.map((sticker) => sticker.id),
})
.then((post) => {
if (!post) return;
ThreadPanel.startCreateCooldown(forum.id, forum.rateLimitPerUser);
onClose();
openThread(post);
})
.catch((failure) => {
if (content && !Drafts.getDraft(draftKey)) DraftCommands.createDraft(accountKey, draftKey, content);
reportForumError(i18n, failure, D.POST_CREATE_FAILED_DESCRIPTOR);
});
return true;
},
[forum, i18n, media, onClose, draftKey],
);
const ready =
title.trim().length > 0 &&
cooldownSeconds === 0 &&
(media ? attachments.length > 0 : hasVisibleMessageContent(body) || attachments.length > 0) &&
(!isTagRequired(forum) || tags.length > 0);
const mobile = MobileLayout.enabled;
return (
<div className={styles.composerCard} data-flx="forum.forum-post-composer.composer">
<div className={styles.composerHead} data-flx="forum.forum-post-composer.composer-head">
<input
className={styles.composerTitle}
value={title}
onChange={(event) => {
setTitle(event.target.value);
setError(undefined);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
controlsRef.current?.focus();
}}
placeholder={i18n._(D.POST_TITLE_PLACEHOLDER_DESCRIPTOR)}
aria-label={i18n._(D.POST_TITLE_DESCRIPTOR)}
maxLength={THREAD_NAME_MAX_LENGTH}
ref={titleRef}
autoComplete="off"
data-flx="forum.forum-post-composer.input.set-title"
/>
<FocusRing offset={-2} data-flx="forum.forum-post-composer.focus-ring">
<button
type="button"
className={styles.composerIconButton}
onClick={onClose}
aria-label={i18n._(D.CANCEL_POST_DESCRIPTOR)}
data-flx="forum.forum-post-composer.icon-button.close"
>
<XIcon size={remFromPx(18)} weight="bold" data-flx="forum.forum-post-composer.x-icon" />
</button>
</FocusRing>
</div>
<div
className={clsx(styles.composerBody, preview && styles.composerBodyHidden)}
data-flx="forum.forum-post-composer.composer-body"
>
<LexicalChannelTextareaContent
accountKey={accountKey}
channel={forum}
draft={Drafts.getDraft(draftKey)}
draftSegments={Drafts.getDraftSegments(draftKey)}
disabled={cooldownSeconds > 0}
typingEnabled={false}
onSubmit={handleSubmit}
enableReply={false}
enableEditLast={false}
enableSlashCommands={false}
draftChannelId={draftKey}
placeholder={i18n._(media ? D.POST_MEDIA_PLACEHOLDER_DESCRIPTOR : D.POST_MESSAGE_PLACEHOLDER_DESCRIPTOR)}
bare
controlsRef={controlsRef}
onValueChange={setBody}
data-flx="forum.forum-post-composer.lexical-channel-textarea-content.submit"
/>
</div>
{preview && (
<div className={styles.composerPreview} data-flx="forum.forum-post-composer.composer-preview">
<span className={styles.composerPreviewLabel} data-flx="forum.forum-post-composer.composer-preview-label">
{i18n._(D.PREVIEW_DESCRIPTOR)}
</span>
{hasVisibleMessageContent(body) ? (
<SafeMarkdown
content={body}
options={{context: MarkdownContext.STANDARD_WITHOUT_JUMBO, channelId: forum.id}}
data-flx="forum.forum-post-composer.safe-markdown"
/>
) : (
<span className={styles.hint} data-flx="forum.forum-post-composer.preview-empty">
{i18n._(D.PREVIEW_EMPTY_DESCRIPTOR)}
</span>
)}
</div>
)}
{cooldownSeconds > 0 && (
<div className={styles.hint} role="status" data-flx="forum.forum-post-composer.hint">
{i18n._(ThreadD.SLOWMODE_COOLDOWN_DESCRIPTOR, {seconds: cooldownSeconds})}
</div>
)}
{error && (
<div className={styles.composerError} role="alert" data-flx="forum.forum-post-composer.error">
{error}
</div>
)}
<div className={styles.composerBar} data-flx="forum.forum-post-composer.composer-bar">
<div className={styles.composerBarStart} data-flx="forum.forum-post-composer.composer-bar-start">
<Tooltip text={i18n._(D.ATTACH_MEDIA_DESCRIPTOR)} data-flx="forum.forum-post-composer.attach-tooltip">
<FocusRing offset={-2} data-flx="forum.forum-post-composer.attach.focus-ring">
<button
type="button"
className={styles.composerIconButton}
onClick={() => controlsRef.current?.uploadFiles()}
aria-label={i18n._(D.ATTACH_MEDIA_DESCRIPTOR)}
data-flx="forum.forum-post-composer.attach-button"
>
<ImageSquareIcon size={remFromPx(20)} weight="bold" data-flx="forum.forum-post-composer.image-icon" />
</button>
</FocusRing>
</Tooltip>
{!mobile && (
<Popout
position="top-start"
render={({onClose: closePicker}) => (
<EmojiPickerPopout
channelId={forum.id}
handleSelect={(emoji) => controlsRef.current?.insertEmoji(emoji)}
onClose={closePicker}
data-flx="forum.forum-post-composer.emoji-picker-popout"
/>
)}
data-flx="forum.forum-post-composer.emoji-popout"
>
<Tooltip text={i18n._(D.ADD_EMOJI_DESCRIPTOR)} data-flx="forum.forum-post-composer.emoji-tooltip">
<FocusRing offset={-2} data-flx="forum.forum-post-composer.emoji.focus-ring">
<button
type="button"
className={styles.composerIconButton}
aria-label={i18n._(D.ADD_EMOJI_DESCRIPTOR)}
data-flx="forum.forum-post-composer.emoji-button"
>
<SmileyIcon size={remFromPx(20)} weight="bold" data-flx="forum.forum-post-composer.smiley-icon" />
</button>
</FocusRing>
</Tooltip>
</Popout>
)}
{forum.availableTags.length > 0 && (
<ForumComposerTags
forum={forum}
selected={tags}
onChange={(next) => {
setTags(next);
setError(undefined);
}}
data-flx="forum.forum-post-composer.forum-tag-picker.set-tags"
/>
)}
</div>
<div className={styles.composerBarEnd} data-flx="forum.forum-post-composer.composer-bar-end">
<Tooltip
text={i18n._(preview ? D.EDIT_DESCRIPTOR : D.PREVIEW_DESCRIPTOR)}
data-flx="forum.forum-post-composer.preview-tooltip"
>
<FocusRing offset={-2} data-flx="forum.forum-post-composer.preview.focus-ring">
<button
type="button"
className={clsx(styles.composerIconButton, preview && styles.composerIconButtonActive)}
onClick={() => setPreview((value) => !value)}
aria-pressed={preview}
aria-label={i18n._(D.PREVIEW_DESCRIPTOR)}
data-flx="forum.forum-post-composer.preview-button"
>
<EyeIcon size={remFromPx(20)} weight="bold" data-flx="forum.forum-post-composer.eye-icon" />
</button>
</FocusRing>
</Tooltip>
<Button
small
fitContent
disabled={!ready}
onClick={() => controlsRef.current?.submit()}
data-flx="forum.forum-post-composer.button.post"
>
{i18n._(D.POST_DESCRIPTOR)}
</Button>
</div>
</div>
</div>
);
});