import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; import { type DiceRoll, type FortuneEntry, POST_OPTIONS_VALIDATION_DELAY_MS, getContentWithPostOptionState as getContentWithOptions, getPostOptionsDirectoryCode, getUnsupportedPostOptionsMessage, hasNonokoOption, isUnsupportedPostOptionsMessage, } from '../../lib/utils/post-options-utils'; import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useSelectedTextStore from '../../stores/use-selected-text-store'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import { useDirectoryByAddress } from '../../hooks/use-directories'; import usePublishReply from '../../hooks/use-publish-reply'; import useIsMobile from '../../hooks/use-is-mobile'; import { useFileUpload } from '../../hooks/use-file-upload'; import { useCommunityField } from '../../hooks/use-stable-community'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; import LoadingEllipsis from '../loading-ellipsis'; import styles from './reply-modal.module.css'; import capitalize from 'lodash/capitalize'; import debounce from 'lodash/debounce'; import { useSpring, animated } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg'; interface ReplyModalProps { closeModal: () => void; showReplyModal: boolean; parentCid: string; parentNumber: number | null; threadNumber: number | null; postCid: string; scrollY: number; communityAddress: string; } const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => { const { t } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const params = useParams(); const isInAllView = isAllView(location.pathname); const isInModView = isModView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directoryEntry = useDirectoryByAddress(communityAddress); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = usePublishReply({ cid: parentCid, communityAddress, postCid, }); const account = useAccount(); const { displayName } = account?.author || {}; const accountAddress = account?.author?.address; const roles = useCommunityField(communityAddress, (community) => community?.roles); const accountRole = accountAddress ? roles?.[accountAddress]?.role : undefined; const showBbcodeToolbar = hasModQueueAccessRole(accountRole); const textRef = useRef(null); const setTextRef = useRef((element: HTMLTextAreaElement | null) => { textRef.current = element; if (!element) return; window.setTimeout(() => { if (textRef.current === element) { element.focus(); } }, 0); }); const urlRef = useRef(null); const optionsRef = useRef(null); const fortuneEntryRef = useRef(null); const diceRollRef = useRef(null); const nonokoRedirectPathRef = useRef(null); const lastSelectionStartRef = useRef(0); const lastSelectionEndRef = useRef(0); const lastProcessedQuoteInsertRequestIdRef = useRef(0); const { selectedText } = useSelectedTextStore(); const openEmpty = useReplyModalStore((state) => state.openEmpty); const quoteInsertRequestId = useReplyModalStore((state) => state.quoteInsertRequestId); const quoteInsertNumber = useReplyModalStore((state) => state.quoteInsertNumber); const quoteInsertSelectedText = useReplyModalStore((state) => state.quoteInsertSelectedText); const [error, setError] = useState(null); const [lengthError, setLengthError] = useState(null); const [url, setUrl] = useState(''); const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false); const [bbcodePreviewContent, setBbcodePreviewContent] = useState(''); const checkContentLengthRef = useRef( debounce((content: string, t: TFunction) => { const length = content.trim().length; if (length > 2000) { setError(null); setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`); } else { setLengthError(null); } }, 1000), ); const checkPostOptionsRef = useRef( debounce((options: string, directoryCode: string | undefined) => { const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode); if (nextOptionsError) { setLengthError(null); setError(nextOptionsError); } }, POST_OPTIONS_VALIDATION_DELAY_MS), ); const onPublishReply = () => { const currentContent = textRef.current?.value || ''; const currentUrl = urlRef.current?.value.trim() || ''; const currentOptions = optionsRef.current?.value || ''; const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode); const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); checkContentLengthRef.current.cancel(); checkPostOptionsRef.current.cancel(); setLengthError(null); nonokoRedirectPathRef.current = null; if (currentOptionsError) { setError(currentOptionsError); return; } if (!publishContent.trim() && !currentUrl) { setError(t('error') + ': ' + t('empty_comment_alert')); return; } if (currentUrl && !isValidPublishURL(currentUrl)) { setError(t('error') + ': ' + t('invalid_url_alert')); return; } const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null; if (expiringMediaLinkAlert) { setError(expiringMediaLinkAlert); return; } if (publishContent.trim().length > 2000) { setError(t('error') + ': ' + t('field_too_long')); return; } setError(null); nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null; publishReply({ content: publishContent }); }; useEffect(() => { if (typeof replyIndex === 'number') { const nonokoRedirectPath = nonokoRedirectPathRef.current; nonokoRedirectPathRef.current = null; resetPublishReplyOptions(); closeModal(); if (nonokoRedirectPath) { navigate(nonokoRedirectPath); } } }, [replyIndex, resetPublishReplyOptions, closeModal, navigate]); const nodeRef = useRef(null); const isMobile = useIsMobile(); const [{ left, top }, api] = useSpring( () => ({ from: { left: Math.round(window.innerWidth / 2 - 150), top: Math.round(window.innerHeight / 2 - 200), }, }), [], ); const bodySelectionStyleBeforeDragRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); const disableBodyTextSelection = () => { if (!bodySelectionStyleBeforeDragRef.current) { bodySelectionStyleBeforeDragRef.current = { userSelect: document.body.style.userSelect, webkitUserSelect: document.body.style.webkitUserSelect, }; } Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' }); }; const restoreBodyTextSelection = () => { const previousStyle = bodySelectionStyleBeforeDragRef.current; Object.assign(document.body.style, { userSelect: previousStyle?.userSelect ?? '', webkitUserSelect: previousStyle?.webkitUserSelect ?? '', }); bodySelectionStyleBeforeDragRef.current = null; }; const bind = useDrag( ({ active, event, offset: [ox, oy] }) => { const nextLeft = Math.round(ox); const nextTop = Math.round(oy); if (active) { event.preventDefault(); disableBodyTextSelection(); } else { restoreBodyTextSelection(); } api.start({ left: nextLeft, top: nextTop, immediate: true }); }, { from: () => [left.get(), top.get()], filterTaps: true, bounds: undefined, }, ); useEffect(() => { return () => { checkContentLengthRef.current.cancel(); checkPostOptionsRef.current.cancel(); restoreBodyTextSelection(); }; }, []); useEffect(() => { if (nodeRef.current && isMobile) { const viewportHeight = window.innerHeight; const centeredPosition = Math.round(scrollY + viewportHeight / 2 - 300); api.start({ top: centeredPosition, immediate: true }); } }, [api, isMobile, scrollY]); const parentCidRef = useRef(null); useEffect(() => { if (!showReplyModal || isMobile) { return; } const closeReplyModalOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeModal(); } }; document.addEventListener('keydown', closeReplyModalOnEscape); return () => document.removeEventListener('keydown', closeReplyModalOnEscape); }, [showReplyModal, isMobile, closeModal]); useEffect(() => { if (parentCidRef.current) { const cidWidth = parentCidRef.current.offsetWidth; parentCidRef.current.style.width = `${cidWidth}px`; } }, [parentCid]); useEffect(() => { if (textRef.current) { const len = textRef.current.value.length; textRef.current.setSelectionRange(len, len); } }, []); const defaultParentQuote = `>>${parentNumber ?? '?'}\n`; // Enable spellcheck after initial content is injected into the textarea. useEffect(() => { if (showReplyModal && textRef.current) { textRef.current.spellcheck = false; textRef.current.value = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`; const len = textRef.current.value.length; lastSelectionStartRef.current = len; lastSelectionEndRef.current = len; const content = textRef.current.value; const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); setPublishReplyOptions({ content: publishContent }); checkContentLengthRef.current(publishContent, t); const spellcheckTimeout = window.setTimeout(() => { if (textRef.current) { textRef.current.spellcheck = true; } }, 100); return () => { window.clearTimeout(spellcheckTimeout); }; } }, [showReplyModal, openEmpty, defaultParentQuote, selectedText]); useEffect(() => { if (!showReplyModal) { checkContentLengthRef.current.cancel(); checkPostOptionsRef.current.cancel(); setIsBbcodePreviewing(false); setBbcodePreviewContent(''); } }, [showReplyModal]); useEffect(() => { if (!showReplyModal) { fortuneEntryRef.current = null; diceRollRef.current = null; } }, [showReplyModal]); const handleContentInput = (e: React.ChangeEvent) => { lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length; lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current; }; const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => { if (isBbcodePreviewing) { setBbcodePreviewContent(content); } if (typeof selectionStart === 'number') { lastSelectionStartRef.current = selectionStart; lastSelectionEndRef.current = selectionEnd ?? selectionStart; } const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); setPublishReplyOptions({ content: publishContent }); checkContentLengthRef.current(publishContent, t); }; const handleOptionsChange = (e: React.ChangeEvent) => { const options = e.target.value; handleContentValueChange(textRef.current?.value || '', undefined, undefined, options); setError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError)); checkPostOptionsRef.current(options, postOptionsDirectoryCode); }; const handleContentChange = (e: React.ChangeEvent) => { handleContentValueChange(e.target.value); }; const handleBbcodePreviewToggle = () => { if (isBbcodePreviewing) { setIsBbcodePreviewing(false); window.requestAnimationFrame(() => textRef.current?.focus()); return; } setBbcodePreviewContent(textRef.current?.value ?? ''); setIsBbcodePreviewing(true); }; useEffect(() => { const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current; const textarea = textRef.current; if (!canInsertQuote || !textarea) { return; } // Guard: skip if we already processed this exact request id. // setPublishReplyOptions identity changes after each call (store update -> new content -> new useCallback), // which re-triggers this effect. Without this guard, that creates an infinite update loop. if (quoteInsertRequestId === lastProcessedQuoteInsertRequestIdRef.current) { return; } lastProcessedQuoteInsertRequestIdRef.current = quoteInsertRequestId; const quote = `>>${quoteInsertNumber ?? '?'}`; const selectedQuote = quoteInsertSelectedText?.trimEnd() || ''; const isFocused = document.activeElement === textarea; const rawStart = isFocused ? (textarea.selectionStart ?? textarea.value.length) : lastSelectionStartRef.current; const selectionEnd = isFocused ? (textarea.selectionEnd ?? rawStart) : lastSelectionEndRef.current; const start = Math.max(rawStart, 0); const end = Math.max(selectionEnd, 0); const before = textarea.value.slice(0, start); const after = textarea.value.slice(end); const needsLeadingNewline = before.length > 0 && !before.endsWith('\n'); let insertion = `${needsLeadingNewline ? '\n' : ''}${quote}\n`; if (selectedQuote) { insertion += `${selectedQuote}\n`; } const nextValue = `${before}${insertion}${after}`; textarea.value = nextValue; const nextCursor = before.length + insertion.length; textarea.focus(); textarea.setSelectionRange(nextCursor, nextCursor); lastSelectionStartRef.current = nextCursor; lastSelectionEndRef.current = nextCursor; const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); setPublishReplyOptions({ content: publishContent }); checkContentLengthRef.current(publishContent, t); }, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]); const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ onUploadComplete: (uploadedUrl: string) => { if (uploadedUrl) { setUrl(uploadedUrl); if (urlRef.current) { urlRef.current.value = uploadedUrl; } setPublishReplyOptions({ link: uploadedUrl }); } }, }); const uploadMode = useMediaHostingStore((state) => state.uploadMode); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const displayedFileName = getPublishURLFilename(url) || uploadedFileName; const hasInitializedDisplayName = useRef(false); useEffect(() => { if (displayName && !hasInitializedDisplayName.current) { hasInitializedDisplayName.current = true; setPublishReplyOptions({ displayName }); } }, [displayName, setPublishReplyOptions]); const modalContent = (
{t('reply_to_no', { no: threadNumber ?? '?' })}
{ setAccount({ ...account, author: { ...account?.author, displayName: e.target.value } }); setPublishReplyOptions({ displayName: e.target.value }); }} />
{showBbcodeToolbar && ( )} {showBbcodeToolbar && isBbcodePreviewing && }