import { useEffect, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { setAccount, useAccount } from '@bitsocialhq/pkc-react-hooks'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, 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 { useCurrentTime } from '../../hooks/use-current-time'; import { useFileUpload } from '../../hooks/use-file-upload'; 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'; interface ReplyModalProps { closeModal: () => void; showReplyModal: boolean; parentCid: string; parentNumber: number | null; threadNumber: number | null; postCid: string; scrollY: number; subplebbitAddress: string; } const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => { const { t } = useTranslation(); const location = useLocation(); const params = useParams(); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directoryEntry = useDirectoryByAddress(subplebbitAddress); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({ cid: parentCid, subplebbitAddress, postCid, }); const account = useAccount(); const { displayName } = account?.author || {}; const textRef = useRef(null); const urlRef = 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 checkContentLengthRef = useRef( debounce((content: string, t: Function) => { const length = content.trim().length; if (length > 2000) { setError(null); setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`); } else { setLengthError(null); } }, 1000), ); const onPublishReply = () => { const currentContent = textRef.current?.value.trim() || ''; const currentUrl = urlRef.current?.value.trim() || ''; if (!currentContent && !currentUrl) { setError(t('error') + ': ' + t('empty_comment_alert')); return; } if (currentUrl && !isValidURL(currentUrl)) { setError(t('error') + ': ' + t('invalid_url_alert')); return; } checkContentLengthRef.current.cancel(); setLengthError(null); if (currentContent.length > 2000) { setError(t('error') + ': ' + t('field_too_long')); return; } setError(null); publishReply(); }; useEffect(() => { if (typeof replyIndex === 'number') { resetPublishReplyOptions(); closeModal(); } }, [replyIndex, resetPublishReplyOptions, closeModal]); const nodeRef = useRef(null); const isMobile = useIsMobile(); const [{ x, y }, api] = useSpring(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200, })); const bind = useDrag( ({ active, event, offset: [ox, oy] }) => { if (active) { event.preventDefault(); document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; } else { document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; } api.start({ x: ox, y: oy, immediate: true }); }, { from: () => [x.get(), y.get()], filterTaps: true, bounds: undefined, }, ); useEffect(() => { if (nodeRef.current && isMobile) { const viewportHeight = window.innerHeight; const centeredPosition = scrollY + viewportHeight / 2 - 300; api.start({ y: centeredPosition, immediate: true }); } }, [isMobile, scrollY, api]); const parentCidRef = useRef(null); useEffect(() => { if (parentCidRef.current && parentCidRef.current) { const cidWidth = parentCidRef.current.offsetWidth; parentCidRef.current.style.width = `${cidWidth}px`; } }, [parentCid]); const currentTime = useCurrentTime(); // Only subscribe to updatedAt to avoid rerenders from updatingState changes const updatedAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.updatedAt); const isBoardOffline = updatedAt && updatedAt < currentTime - 60 * 60; const offlineAlert = updatedAt ? isBoardOffline && (
{t('warning')}:
) : t('subplebbit_offline_info'); useEffect(() => { if (showReplyModal && !isMobile) { setTimeout(() => { if (textRef.current) { textRef.current.focus(); } }, 0); const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { closeModal(); } }; document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('keydown', handleEscape); }; } }, [showReplyModal, closeModal, isMobile]); 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 formattedContent = formatMarkdown(textRef.current.value); setPublishReplyOptions({ content: formattedContent }); checkContentLengthRef.current(formattedContent, t); setTimeout(() => { if (textRef.current) { textRef.current.spellcheck = true; } }, 100); } }, [showReplyModal, openEmpty, defaultParentQuote, selectedText]); const handleContentInput = (e: React.ChangeEvent) => { lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length; lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current; }; const handleContentChange = (e: React.ChangeEvent) => { const formattedContent = formatMarkdown(e.target.value); setPublishReplyOptions({ content: formattedContent }); checkContentLengthRef.current(formattedContent, t); }; 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 formattedContent = formatMarkdown(nextValue); setPublishReplyOptions({ content: formattedContent }); checkContentLengthRef.current(formattedContent, t); }, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]); const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ onUploadComplete: (uploadedUrl: string) => { if (uploadedUrl) { if (urlRef.current) { urlRef.current.value = uploadedUrl; } setPublishReplyOptions({ link: uploadedUrl }); } }, }); const uploadMode = useMediaHostingStore((state) => state.uploadMode); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); 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 }); }} />
setPublishReplyOptions({ link: e.target.value })} />