2025-11-25 13:13:38 +01:00
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2026-05-23 16:54:38 +07:00
|
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
2026-03-08 17:10:48 +08:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-04-24 15:48:07 +07:00
|
|
|
import type { TFunction } from 'i18next';
|
2026-04-20 22:11:36 +07:00
|
|
|
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
2026-05-16 16:11:22 +07:00
|
|
|
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
|
2026-05-24 23:38:15 +07:00
|
|
|
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
|
2026-05-21 22:12:41 +07:00
|
|
|
import {
|
|
|
|
|
type DiceRoll,
|
|
|
|
|
type FortuneEntry,
|
2026-05-24 19:17:50 +07:00
|
|
|
type PostOptionsValidationError,
|
2026-05-21 22:12:41 +07:00
|
|
|
POST_OPTIONS_VALIDATION_DELAY_MS,
|
|
|
|
|
getContentWithPostOptionState as getContentWithOptions,
|
|
|
|
|
getPostOptionsDirectoryCode,
|
2026-05-24 19:17:50 +07:00
|
|
|
getPostOptionsValidationError,
|
2026-05-23 16:54:38 +07:00
|
|
|
hasNonokoOption,
|
2026-05-24 19:17:50 +07:00
|
|
|
isPostOptionsValidationError,
|
2026-05-21 22:12:41 +07:00
|
|
|
} from '../../lib/utils/post-options-utils';
|
2026-05-01 18:03:04 +07:00
|
|
|
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
|
2026-05-19 15:45:22 +07:00
|
|
|
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
2026-03-08 19:02:49 +08:00
|
|
|
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
2024-06-26 18:20:25 +02:00
|
|
|
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
2026-02-10 14:57:47 +08:00
|
|
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
2026-02-18 18:51:02 +08:00
|
|
|
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
2026-02-17 18:57:54 +08:00
|
|
|
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
2026-05-24 19:17:50 +07:00
|
|
|
import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories';
|
2024-08-06 21:57:28 +02:00
|
|
|
import usePublishReply from '../../hooks/use-publish-reply';
|
2024-05-29 16:17:37 +02:00
|
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
2026-02-17 16:44:16 +08:00
|
|
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
2026-05-19 15:45:22 +07:00
|
|
|
import { useCommunityField } from '../../hooks/use-stable-community';
|
2026-05-30 15:06:47 +07:00
|
|
|
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
2026-05-19 15:45:22 +07:00
|
|
|
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
2026-03-08 19:02:49 +08:00
|
|
|
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
2026-05-01 21:12:39 +07:00
|
|
|
import LoadingEllipsis from '../loading-ellipsis';
|
2026-05-30 15:06:47 +07:00
|
|
|
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
2026-05-24 19:17:50 +07:00
|
|
|
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
2024-04-28 20:08:41 +02:00
|
|
|
import styles from './reply-modal.module.css';
|
2026-02-18 15:31:01 +08:00
|
|
|
import capitalize from 'lodash/capitalize';
|
|
|
|
|
import debounce from 'lodash/debounce';
|
2025-03-01 11:48:56 +01:00
|
|
|
import { useSpring, animated } from '@react-spring/web';
|
|
|
|
|
import { useDrag } from '@use-gesture/react';
|
2024-11-04 17:34:40 +01:00
|
|
|
|
2026-05-01 18:03:04 +07:00
|
|
|
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
|
|
|
|
|
|
2024-04-28 20:08:41 +02:00
|
|
|
interface ReplyModalProps {
|
|
|
|
|
closeModal: () => void;
|
2024-08-09 12:08:22 +02:00
|
|
|
showReplyModal: boolean;
|
2024-04-28 20:08:41 +02:00
|
|
|
parentCid: string;
|
2025-12-28 11:53:02 +01:00
|
|
|
parentNumber: number | null;
|
2025-12-28 22:16:38 +01:00
|
|
|
threadNumber: number | null;
|
2024-08-06 20:12:07 +02:00
|
|
|
postCid: string;
|
2024-04-30 11:08:01 +02:00
|
|
|
scrollY: number;
|
2026-03-13 13:25:10 +08:00
|
|
|
communityAddress: string;
|
2024-04-28 20:08:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => {
|
2024-04-28 20:08:41 +02:00
|
|
|
const { t } = useTranslation();
|
2026-03-01 15:27:27 +08:00
|
|
|
const location = useLocation();
|
2026-05-23 16:54:38 +07:00
|
|
|
const navigate = useNavigate();
|
2026-03-01 15:27:27 +08:00
|
|
|
const params = useParams();
|
|
|
|
|
const isInAllView = isAllView(location.pathname);
|
2026-03-08 19:02:49 +08:00
|
|
|
const isInModView = isModView(location.pathname);
|
2026-03-01 15:27:27 +08:00
|
|
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
2026-05-24 19:17:50 +07:00
|
|
|
const directories = useDirectories();
|
|
|
|
|
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
2026-02-17 17:35:53 +08:00
|
|
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
2026-05-21 22:12:41 +07:00
|
|
|
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
2026-05-30 15:06:47 +07:00
|
|
|
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
|
2026-03-01 15:27:27 +08:00
|
|
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
|
|
|
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
2026-05-24 23:14:36 +07:00
|
|
|
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
2026-03-12 17:41:11 +08:00
|
|
|
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
|
|
|
|
usePublishReply({
|
|
|
|
|
cid: parentCid,
|
2026-04-16 15:33:03 +07:00
|
|
|
communityAddress,
|
2026-03-12 17:41:11 +08:00
|
|
|
postCid,
|
|
|
|
|
});
|
2024-04-28 20:08:41 +02:00
|
|
|
const account = useAccount();
|
|
|
|
|
const { displayName } = account?.author || {};
|
2026-05-19 15:45:22 +07:00
|
|
|
const accountAddress = account?.author?.address;
|
|
|
|
|
const roles = useCommunityField(communityAddress, (community) => community?.roles);
|
|
|
|
|
const accountRole = accountAddress ? roles?.[accountAddress]?.role : undefined;
|
|
|
|
|
const showBbcodeToolbar = hasModQueueAccessRole(accountRole);
|
2024-06-26 18:20:25 +02:00
|
|
|
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
2026-05-10 18:25:13 +07:00
|
|
|
const setTextRef = useRef((element: HTMLTextAreaElement | null) => {
|
|
|
|
|
textRef.current = element;
|
|
|
|
|
if (!element) return;
|
|
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (textRef.current === element) {
|
|
|
|
|
element.focus();
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
|
|
|
|
});
|
2024-04-28 20:08:41 +02:00
|
|
|
const urlRef = useRef<HTMLInputElement>(null);
|
2026-05-21 22:12:41 +07:00
|
|
|
const optionsRef = useRef<HTMLInputElement>(null);
|
2026-05-24 23:14:36 +07:00
|
|
|
const flagRef = useRef<HTMLSelectElement>(null);
|
2026-05-21 22:12:41 +07:00
|
|
|
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
|
|
|
|
|
const diceRollRef = useRef<DiceRoll | null>(null);
|
2026-05-23 16:54:38 +07:00
|
|
|
const nonokoRedirectPathRef = useRef<string | null>(null);
|
2026-02-10 14:57:47 +08:00
|
|
|
const lastSelectionStartRef = useRef(0);
|
|
|
|
|
const lastSelectionEndRef = useRef(0);
|
2026-05-29 17:09:07 +07:00
|
|
|
const initializedReplyContentKeyRef = useRef('');
|
2026-02-10 14:57:47 +08:00
|
|
|
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
2024-06-26 18:20:25 +02:00
|
|
|
const { selectedText } = useSelectedTextStore();
|
2026-02-23 19:33:59 +08:00
|
|
|
const openEmpty = useReplyModalStore((state) => state.openEmpty);
|
2026-02-10 14:57:47 +08:00
|
|
|
const quoteInsertRequestId = useReplyModalStore((state) => state.quoteInsertRequestId);
|
|
|
|
|
const quoteInsertNumber = useReplyModalStore((state) => state.quoteInsertNumber);
|
2026-02-10 15:51:58 +08:00
|
|
|
const quoteInsertSelectedText = useReplyModalStore((state) => state.quoteInsertSelectedText);
|
2024-04-28 20:08:41 +02:00
|
|
|
|
2026-05-24 19:17:50 +07:00
|
|
|
const [error, setError] = useState<string | PostOptionsValidationError | null>(null);
|
2025-01-31 23:38:07 +01:00
|
|
|
const [lengthError, setLengthError] = useState<string | null>(null);
|
2026-05-01 18:03:04 +07:00
|
|
|
const [url, setUrl] = useState('');
|
2026-05-19 15:45:22 +07:00
|
|
|
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
|
|
|
|
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
2025-01-31 23:38:07 +01:00
|
|
|
|
2026-02-24 15:15:44 +08:00
|
|
|
const checkContentLengthRef = useRef(
|
2026-04-24 15:48:07 +07:00
|
|
|
debounce((content: string, t: TFunction) => {
|
2025-01-31 23:38:07 +01:00
|
|
|
const length = content.trim().length;
|
|
|
|
|
if (length > 2000) {
|
|
|
|
|
setError(null);
|
|
|
|
|
setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`);
|
|
|
|
|
} else {
|
|
|
|
|
setLengthError(null);
|
|
|
|
|
}
|
|
|
|
|
}, 1000),
|
2026-02-24 15:15:44 +08:00
|
|
|
);
|
2024-11-04 17:55:51 +01:00
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
const checkPostOptionsRef = useRef(
|
|
|
|
|
debounce((options: string, directoryCode: string | undefined) => {
|
2026-05-24 19:17:50 +07:00
|
|
|
const nextOptionsError = getPostOptionsValidationError(options, directoryCode);
|
2026-05-21 22:12:41 +07:00
|
|
|
if (nextOptionsError) {
|
|
|
|
|
setLengthError(null);
|
|
|
|
|
setError(nextOptionsError);
|
|
|
|
|
}
|
|
|
|
|
}, POST_OPTIONS_VALIDATION_DELAY_MS),
|
|
|
|
|
);
|
2024-04-28 20:08:41 +02:00
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
const onPublishReply = () => {
|
|
|
|
|
const currentContent = textRef.current?.value || '';
|
|
|
|
|
const currentUrl = urlRef.current?.value.trim() || '';
|
|
|
|
|
const currentOptions = optionsRef.current?.value || '';
|
2026-05-24 19:17:50 +07:00
|
|
|
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
2026-05-21 22:12:41 +07:00
|
|
|
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
|
|
|
|
|
|
|
|
|
checkContentLengthRef.current.cancel();
|
|
|
|
|
checkPostOptionsRef.current.cancel();
|
|
|
|
|
setLengthError(null);
|
2026-05-23 16:54:38 +07:00
|
|
|
nonokoRedirectPathRef.current = null;
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
if (currentOptionsError) {
|
|
|
|
|
setError(currentOptionsError);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!publishContent.trim() && !currentUrl) {
|
2025-01-31 23:38:07 +01:00
|
|
|
setError(t('error') + ': ' + t('empty_comment_alert'));
|
2024-04-28 20:08:41 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 14:23:52 +07:00
|
|
|
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
2025-01-31 23:38:07 +01:00
|
|
|
setError(t('error') + ': ' + t('invalid_url_alert'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-16 16:11:22 +07:00
|
|
|
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
|
|
|
|
if (expiringMediaLinkAlert) {
|
|
|
|
|
setError(expiringMediaLinkAlert);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-01-31 23:38:07 +01:00
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
if (publishContent.trim().length > 2000) {
|
2025-01-31 23:38:07 +01:00
|
|
|
setError(t('error') + ': ' + t('field_too_long'));
|
2024-04-28 20:08:41 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2024-08-06 20:12:07 +02:00
|
|
|
|
2026-05-24 23:38:15 +07:00
|
|
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
2026-05-24 23:14:36 +07:00
|
|
|
|
2024-11-04 17:55:51 +01:00
|
|
|
setError(null);
|
2026-05-23 16:54:38 +07:00
|
|
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
2026-05-24 23:14:36 +07:00
|
|
|
publishReply({ content: publishContent, ...flagPublishOptions });
|
2024-04-28 20:08:41 +02:00
|
|
|
};
|
|
|
|
|
|
2024-08-21 11:11:19 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof replyIndex === 'number') {
|
2026-05-23 16:54:38 +07:00
|
|
|
const nonokoRedirectPath = nonokoRedirectPathRef.current;
|
|
|
|
|
nonokoRedirectPathRef.current = null;
|
2024-08-21 11:11:19 +02:00
|
|
|
resetPublishReplyOptions();
|
|
|
|
|
closeModal();
|
2026-05-23 16:54:38 +07:00
|
|
|
if (nonokoRedirectPath) {
|
|
|
|
|
navigate(nonokoRedirectPath);
|
|
|
|
|
}
|
2024-08-21 11:11:19 +02:00
|
|
|
}
|
2026-05-23 16:54:38 +07:00
|
|
|
}, [replyIndex, resetPublishReplyOptions, closeModal, navigate]);
|
2024-08-21 11:11:19 +02:00
|
|
|
|
2024-04-29 13:28:04 +02:00
|
|
|
const nodeRef = useRef<HTMLDivElement>(null);
|
2024-05-29 16:17:37 +02:00
|
|
|
const isMobile = useIsMobile();
|
2024-04-28 20:08:41 +02:00
|
|
|
|
2026-03-29 16:40:09 +07:00
|
|
|
const [{ left, top }, api] = useSpring(
|
|
|
|
|
() => ({
|
|
|
|
|
from: {
|
|
|
|
|
left: Math.round(window.innerWidth / 2 - 150),
|
|
|
|
|
top: Math.round(window.innerHeight / 2 - 200),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
);
|
2025-03-01 11:48:56 +01:00
|
|
|
|
2026-05-10 18:25:13 +07:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-01 11:48:56 +01:00
|
|
|
const bind = useDrag(
|
2025-03-03 22:27:39 +01:00
|
|
|
({ active, event, offset: [ox, oy] }) => {
|
2026-03-16 14:42:30 +08:00
|
|
|
const nextLeft = Math.round(ox);
|
|
|
|
|
const nextTop = Math.round(oy);
|
|
|
|
|
|
2025-03-03 22:27:39 +01:00
|
|
|
if (active) {
|
|
|
|
|
event.preventDefault();
|
2026-05-10 18:25:13 +07:00
|
|
|
disableBodyTextSelection();
|
2025-03-03 22:27:39 +01:00
|
|
|
} else {
|
2026-05-10 18:25:13 +07:00
|
|
|
restoreBodyTextSelection();
|
2025-03-03 22:27:39 +01:00
|
|
|
}
|
2026-03-16 14:42:30 +08:00
|
|
|
api.start({ left: nextLeft, top: nextTop, immediate: true });
|
2025-03-01 11:48:56 +01:00
|
|
|
},
|
|
|
|
|
{
|
2026-03-16 14:42:30 +08:00
|
|
|
from: () => [left.get(), top.get()],
|
2025-03-01 11:48:56 +01:00
|
|
|
filterTaps: true,
|
2025-03-02 16:04:45 +01:00
|
|
|
bounds: undefined,
|
2025-03-01 11:48:56 +01:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-10 18:25:13 +07:00
|
|
|
useEffect(() => {
|
2026-05-29 17:09:07 +07:00
|
|
|
const checkContentLength = checkContentLengthRef.current;
|
|
|
|
|
const checkPostOptions = checkPostOptionsRef.current;
|
|
|
|
|
|
2026-05-10 18:25:13 +07:00
|
|
|
return () => {
|
2026-05-29 17:09:07 +07:00
|
|
|
checkContentLength.cancel();
|
|
|
|
|
checkPostOptions.cancel();
|
2026-05-10 18:25:13 +07:00
|
|
|
restoreBodyTextSelection();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2024-04-29 13:28:04 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (nodeRef.current && isMobile) {
|
|
|
|
|
const viewportHeight = window.innerHeight;
|
2026-03-16 14:42:30 +08:00
|
|
|
const centeredPosition = Math.round(scrollY + viewportHeight / 2 - 300);
|
|
|
|
|
api.start({ top: centeredPosition, immediate: true });
|
2024-04-29 13:28:04 +02:00
|
|
|
}
|
2026-03-16 14:42:30 +08:00
|
|
|
}, [api, isMobile, scrollY]);
|
2024-04-29 13:28:04 +02:00
|
|
|
|
2024-05-09 22:55:57 +02:00
|
|
|
const parentCidRef = useRef<HTMLSpanElement>(null);
|
|
|
|
|
|
2026-05-10 18:25:13 +07:00
|
|
|
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]);
|
|
|
|
|
|
2024-05-09 22:55:57 +02:00
|
|
|
useEffect(() => {
|
2026-04-21 14:23:52 +07:00
|
|
|
if (parentCidRef.current) {
|
2024-05-09 22:55:57 +02:00
|
|
|
const cidWidth = parentCidRef.current.offsetWidth;
|
|
|
|
|
parentCidRef.current.style.width = `${cidWidth}px`;
|
|
|
|
|
}
|
|
|
|
|
}, [parentCid]);
|
|
|
|
|
|
2024-07-22 12:29:05 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (textRef.current) {
|
|
|
|
|
const len = textRef.current.value.length;
|
|
|
|
|
textRef.current.setSelectionRange(len, len);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-11 16:38:40 +08:00
|
|
|
const defaultParentQuote = `>>${parentNumber ?? '?'}\n`;
|
2024-06-26 22:20:45 +02:00
|
|
|
|
2026-02-11 16:38:40 +08:00
|
|
|
// Enable spellcheck after initial content is injected into the textarea.
|
2024-12-21 00:38:15 +01:00
|
|
|
useEffect(() => {
|
2026-05-29 17:09:07 +07:00
|
|
|
if (!showReplyModal || !textRef.current) {
|
|
|
|
|
initializedReplyContentKeyRef.current = '';
|
|
|
|
|
return;
|
2024-12-21 00:38:15 +01:00
|
|
|
}
|
2026-05-29 17:09:07 +07:00
|
|
|
|
|
|
|
|
const initialContent = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
|
|
|
|
|
const initialContentKey = `${parentCid}:${openEmpty ? 'empty' : 'quoted'}:${initialContent}`;
|
|
|
|
|
if (initializedReplyContentKeyRef.current === initialContentKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
initializedReplyContentKeyRef.current = initialContentKey;
|
|
|
|
|
|
|
|
|
|
textRef.current.spellcheck = false;
|
|
|
|
|
textRef.current.value = initialContent;
|
|
|
|
|
const len = textRef.current.value.length;
|
|
|
|
|
lastSelectionStartRef.current = len;
|
|
|
|
|
lastSelectionEndRef.current = len;
|
|
|
|
|
const publishContent = getContentWithOptions(initialContent, 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, parentCid, openEmpty, defaultParentQuote, selectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
2024-12-21 00:38:15 +01:00
|
|
|
|
2026-05-19 15:45:22 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!showReplyModal) {
|
2026-05-21 22:12:41 +07:00
|
|
|
checkContentLengthRef.current.cancel();
|
|
|
|
|
checkPostOptionsRef.current.cancel();
|
2026-05-19 15:45:22 +07:00
|
|
|
setIsBbcodePreviewing(false);
|
|
|
|
|
setBbcodePreviewContent('');
|
|
|
|
|
}
|
|
|
|
|
}, [showReplyModal]);
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!showReplyModal) {
|
|
|
|
|
fortuneEntryRef.current = null;
|
|
|
|
|
diceRollRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, [showReplyModal]);
|
|
|
|
|
|
2024-06-26 22:20:45 +02:00
|
|
|
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
2026-02-10 14:57:47 +08:00
|
|
|
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
|
|
|
|
|
lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current;
|
2024-06-26 22:20:45 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => {
|
2026-05-19 15:45:22 +07:00
|
|
|
if (isBbcodePreviewing) {
|
|
|
|
|
setBbcodePreviewContent(content);
|
|
|
|
|
}
|
|
|
|
|
if (typeof selectionStart === 'number') {
|
|
|
|
|
lastSelectionStartRef.current = selectionStart;
|
|
|
|
|
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
|
|
|
|
|
}
|
2026-05-21 22:12:41 +07:00
|
|
|
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
|
|
|
|
setPublishReplyOptions({ content: publishContent });
|
|
|
|
|
checkContentLengthRef.current(publishContent, t);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const options = e.target.value;
|
|
|
|
|
handleContentValueChange(textRef.current?.value || '', undefined, undefined, options);
|
2026-05-24 19:17:50 +07:00
|
|
|
setError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
|
2026-05-21 22:12:41 +07:00
|
|
|
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
|
2024-06-26 22:20:45 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-19 15:45:22 +07:00
|
|
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
handleContentValueChange(e.target.value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBbcodePreviewToggle = () => {
|
|
|
|
|
if (isBbcodePreviewing) {
|
|
|
|
|
setIsBbcodePreviewing(false);
|
|
|
|
|
window.requestAnimationFrame(() => textRef.current?.focus());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBbcodePreviewContent(textRef.current?.value ?? '');
|
|
|
|
|
setIsBbcodePreviewing(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-10 14:57:47 +08:00
|
|
|
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 ?? '?'}`;
|
2026-02-10 15:51:58 +08:00
|
|
|
const selectedQuote = quoteInsertSelectedText?.trimEnd() || '';
|
2026-02-10 14:57:47 +08:00
|
|
|
const isFocused = document.activeElement === textarea;
|
|
|
|
|
const rawStart = isFocused ? (textarea.selectionStart ?? textarea.value.length) : lastSelectionStartRef.current;
|
|
|
|
|
const selectionEnd = isFocused ? (textarea.selectionEnd ?? rawStart) : lastSelectionEndRef.current;
|
2026-02-11 16:38:40 +08:00
|
|
|
const start = Math.max(rawStart, 0);
|
|
|
|
|
const end = Math.max(selectionEnd, 0);
|
2026-02-10 15:51:58 +08:00
|
|
|
const before = textarea.value.slice(0, start);
|
|
|
|
|
const after = textarea.value.slice(end);
|
2026-02-11 16:38:40 +08:00
|
|
|
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
2026-02-10 15:51:58 +08:00
|
|
|
let insertion = `${needsLeadingNewline ? '\n' : ''}${quote}\n`;
|
|
|
|
|
if (selectedQuote) {
|
|
|
|
|
insertion += `${selectedQuote}\n`;
|
|
|
|
|
}
|
|
|
|
|
const nextValue = `${before}${insertion}${after}`;
|
2026-02-10 14:57:47 +08:00
|
|
|
|
|
|
|
|
textarea.value = nextValue;
|
2026-02-10 15:51:58 +08:00
|
|
|
const nextCursor = before.length + insertion.length;
|
2026-02-10 14:57:47 +08:00
|
|
|
textarea.focus();
|
|
|
|
|
textarea.setSelectionRange(nextCursor, nextCursor);
|
|
|
|
|
lastSelectionStartRef.current = nextCursor;
|
|
|
|
|
lastSelectionEndRef.current = nextCursor;
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
|
|
|
|
setPublishReplyOptions({ content: publishContent });
|
|
|
|
|
checkContentLengthRef.current(publishContent, t);
|
2026-05-29 17:09:07 +07:00
|
|
|
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
2026-02-10 14:57:47 +08:00
|
|
|
|
2026-05-30 15:06:47 +07:00
|
|
|
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
2026-02-17 16:44:16 +08:00
|
|
|
onUploadComplete: (uploadedUrl: string) => {
|
|
|
|
|
if (uploadedUrl) {
|
2026-05-01 18:03:04 +07:00
|
|
|
setUrl(uploadedUrl);
|
2024-11-04 17:34:40 +01:00
|
|
|
if (urlRef.current) {
|
2026-02-17 16:44:16 +08:00
|
|
|
urlRef.current.value = uploadedUrl;
|
2024-11-04 17:34:40 +01:00
|
|
|
}
|
2026-02-17 16:44:16 +08:00
|
|
|
setPublishReplyOptions({ link: uploadedUrl });
|
2024-11-04 17:34:40 +01:00
|
|
|
}
|
2026-02-17 16:44:16 +08:00
|
|
|
},
|
|
|
|
|
});
|
2026-05-30 15:06:47 +07:00
|
|
|
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
|
|
|
|
|
if ((urlRef.current?.value || url) !== uploadedUrl) return;
|
|
|
|
|
setUrl('');
|
|
|
|
|
if (urlRef.current) {
|
|
|
|
|
urlRef.current.value = '';
|
|
|
|
|
}
|
|
|
|
|
setPublishReplyOptions({ link: '' });
|
|
|
|
|
};
|
2026-02-18 18:51:02 +08:00
|
|
|
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
|
|
|
|
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
2026-05-01 18:03:04 +07:00
|
|
|
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
|
2024-11-04 17:34:40 +01:00
|
|
|
|
2025-01-26 22:25:56 +01:00
|
|
|
const hasInitializedDisplayName = useRef(false);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (displayName && !hasInitializedDisplayName.current) {
|
|
|
|
|
hasInitializedDisplayName.current = true;
|
|
|
|
|
setPublishReplyOptions({ displayName });
|
|
|
|
|
}
|
2025-01-26 23:06:49 +01:00
|
|
|
}, [displayName, setPublishReplyOptions]);
|
2025-01-26 22:25:56 +01:00
|
|
|
|
2024-04-29 13:28:04 +02:00
|
|
|
const modalContent = (
|
2025-03-01 11:48:56 +01:00
|
|
|
<animated.div
|
|
|
|
|
className={styles.container}
|
|
|
|
|
ref={nodeRef}
|
2026-04-24 15:48:07 +07:00
|
|
|
role='dialog'
|
|
|
|
|
aria-modal='true'
|
|
|
|
|
aria-labelledby='reply-modal-title'
|
2025-03-01 11:48:56 +01:00
|
|
|
style={{
|
2026-03-16 14:42:30 +08:00
|
|
|
left,
|
|
|
|
|
top,
|
2025-03-01 11:48:56 +01:00
|
|
|
touchAction: 'none',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-24 15:48:07 +07:00
|
|
|
<div id='reply-modal-title' className={`replyModalHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
|
2025-12-28 22:16:38 +01:00
|
|
|
{t('reply_to_no', { no: threadNumber ?? '?' })}
|
2024-04-29 14:26:02 +02:00
|
|
|
<button
|
2026-04-24 15:48:07 +07:00
|
|
|
type='button'
|
2024-04-29 14:26:02 +02:00
|
|
|
className={styles.closeIcon}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
closeModal();
|
|
|
|
|
}}
|
|
|
|
|
title='close'
|
2026-04-24 15:48:07 +07:00
|
|
|
aria-label={t('close')}
|
2024-04-29 14:26:02 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.replyForm}>
|
|
|
|
|
<div className={styles.name}>
|
|
|
|
|
<input
|
|
|
|
|
type='text'
|
2026-04-24 15:48:07 +07:00
|
|
|
aria-label={t('name')}
|
2024-04-29 14:26:02 +02:00
|
|
|
defaultValue={displayName}
|
2026-02-11 19:01:09 +08:00
|
|
|
placeholder={displayName ? undefined : capitalize(t('name'))}
|
2024-08-08 17:48:45 +02:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setAccount({ ...account, author: { ...account?.author, displayName: e.target.value } });
|
2025-01-30 17:09:21 +01:00
|
|
|
setPublishReplyOptions({ displayName: e.target.value });
|
2024-08-08 17:48:45 +02:00
|
|
|
}}
|
2024-04-29 14:26:02 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-21 22:12:41 +07:00
|
|
|
<div className={styles.options}>
|
2026-02-26 18:17:54 +08:00
|
|
|
<input
|
|
|
|
|
type='text'
|
2026-05-21 22:12:41 +07:00
|
|
|
ref={optionsRef}
|
|
|
|
|
aria-label={t('options')}
|
|
|
|
|
placeholder={capitalize(t('options'))}
|
|
|
|
|
autoCorrect='off'
|
|
|
|
|
autoComplete='off'
|
|
|
|
|
spellCheck='false'
|
|
|
|
|
onChange={handleOptionsChange}
|
2026-02-26 18:17:54 +08:00
|
|
|
/>
|
2024-04-29 14:26:02 +02:00
|
|
|
</div>
|
|
|
|
|
<div className={styles.content}>
|
2026-05-19 15:45:22 +07:00
|
|
|
{showBbcodeToolbar && (
|
|
|
|
|
<BbcodeEditorToolbar
|
|
|
|
|
textareaRef={textRef}
|
|
|
|
|
onChange={handleContentValueChange}
|
|
|
|
|
isPreviewing={isBbcodePreviewing}
|
|
|
|
|
onPreviewToggle={handleBbcodePreviewToggle}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{showBbcodeToolbar && isBbcodePreviewing && <BbcodePreview content={bbcodePreviewContent} postCid={postCid} communityAddress={communityAddress} />}
|
2026-02-10 14:57:47 +08:00
|
|
|
<textarea
|
|
|
|
|
cols={48}
|
|
|
|
|
rows={4}
|
|
|
|
|
wrap='soft'
|
2026-05-10 18:25:13 +07:00
|
|
|
ref={setTextRef.current}
|
2026-04-24 15:48:07 +07:00
|
|
|
aria-label={t('comment')}
|
2026-02-10 14:57:47 +08:00
|
|
|
spellCheck={true}
|
2026-05-19 15:45:22 +07:00
|
|
|
hidden={showBbcodeToolbar && isBbcodePreviewing}
|
2026-02-10 14:57:47 +08:00
|
|
|
onInput={handleContentInput}
|
|
|
|
|
onChange={handleContentChange}
|
|
|
|
|
onSelect={(e) => {
|
|
|
|
|
lastSelectionStartRef.current = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
|
|
|
|
|
lastSelectionEndRef.current = e.currentTarget.selectionEnd ?? lastSelectionStartRef.current;
|
|
|
|
|
}}
|
|
|
|
|
onBlur={(e) => {
|
|
|
|
|
lastSelectionStartRef.current = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
|
|
|
|
|
lastSelectionEndRef.current = e.currentTarget.selectionEnd ?? lastSelectionStartRef.current;
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2024-04-29 14:26:02 +02:00
|
|
|
</div>
|
2026-05-24 23:38:15 +07:00
|
|
|
<div className={styles.link}>
|
|
|
|
|
<input
|
|
|
|
|
type='text'
|
|
|
|
|
ref={urlRef}
|
|
|
|
|
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
|
|
|
|
|
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
|
|
|
|
|
disabled={isUploading}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setUrl(e.target.value);
|
|
|
|
|
setPublishReplyOptions({ link: e.target.value });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-30 15:06:47 +07:00
|
|
|
{showOekakiControls && (
|
|
|
|
|
<div className={styles.oekakiRow}>
|
|
|
|
|
<span className={styles.oekakiLabel}>Draw</span>
|
|
|
|
|
<OekakiDrawingControls className={styles.oekakiControls} disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={handleOekakiClearUploadedUrl} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{showOekakiControls && isWebRuntime() ? <div className={styles.oekakiWarning}>{OEKAKI_WEB_WARNING_TEXT}</div> : null}
|
2026-05-24 23:14:36 +07:00
|
|
|
{flagOptions.length > 0 && (
|
|
|
|
|
<div>
|
2026-05-24 23:30:59 +07:00
|
|
|
<select
|
|
|
|
|
key={flagOptions.map((option) => option.value).join('|')}
|
|
|
|
|
name='flag'
|
|
|
|
|
aria-label={t('flag')}
|
|
|
|
|
className={styles.flagSelector}
|
|
|
|
|
ref={flagRef}
|
|
|
|
|
defaultValue={flagOptions[0]?.value}
|
|
|
|
|
>
|
2026-05-24 23:14:36 +07:00
|
|
|
{flagOptions.map((option) => (
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-04-29 14:26:02 +02:00
|
|
|
<div className={styles.footer}>
|
2026-02-17 18:57:54 +08:00
|
|
|
{showUploadControls && (
|
|
|
|
|
<span className={styles.uploadContainer}>
|
|
|
|
|
<span className={styles.uploadButton}>
|
2026-04-24 15:48:07 +07:00
|
|
|
<button type='button' onClick={handleUpload} disabled={isUploading}>
|
2026-02-17 18:57:54 +08:00
|
|
|
{t('choose_file')}
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
2026-05-01 18:03:04 +07:00
|
|
|
<span className={styles.uploadFileName} title={displayedFileName || t('no_file_chosen')}>
|
2026-05-01 21:12:39 +07:00
|
|
|
{isUploading ? <LoadingEllipsis string={t('uploading')} /> : displayedFileName || t('no_file_chosen')}
|
2026-02-17 18:57:54 +08:00
|
|
|
</span>
|
2024-11-04 17:34:40 +01:00
|
|
|
</span>
|
2026-02-17 18:57:54 +08:00
|
|
|
)}
|
2026-02-17 17:35:53 +08:00
|
|
|
{showSpoilerForReply && (
|
|
|
|
|
<span className={styles.spoilerButton}>
|
|
|
|
|
[
|
|
|
|
|
<label>
|
2026-05-29 17:09:07 +07:00
|
|
|
<input type='checkbox' aria-label={capitalize(t('spoiler'))} onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
2026-02-17 17:35:53 +08:00
|
|
|
{capitalize(t('spoiler'))}?
|
|
|
|
|
</label>
|
|
|
|
|
]
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-24 15:48:07 +07:00
|
|
|
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} type='button' onClick={onPublishReply}>
|
2024-06-26 22:20:45 +02:00
|
|
|
{t('post')}
|
|
|
|
|
</button>
|
2024-04-29 14:26:02 +02:00
|
|
|
</div>
|
2026-05-22 00:44:14 +07:00
|
|
|
{showBbcodeToolbar ? <div className={styles.error}>warning: posting as moderator</div> : null}
|
2026-03-12 17:41:11 +08:00
|
|
|
{lengthError ? (
|
|
|
|
|
<div className={styles.error}>{lengthError}</div>
|
|
|
|
|
) : error ? (
|
2026-05-24 19:17:50 +07:00
|
|
|
<div className={styles.error}>{isPostOptionsValidationError(error) ? <PostOptionsErrorMessage error={error} directories={directories} /> : error}</div>
|
2026-03-12 17:41:11 +08:00
|
|
|
) : (
|
|
|
|
|
publishReplyError && <div className={styles.error}>{publishReplyError}</div>
|
|
|
|
|
)}
|
|
|
|
|
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
2026-03-13 13:25:10 +08:00
|
|
|
<BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} communityAddress={communityAddress} />
|
2024-04-29 14:26:02 +02:00
|
|
|
</div>
|
2025-03-01 11:48:56 +01:00
|
|
|
</animated.div>
|
2024-04-29 13:28:04 +02:00
|
|
|
);
|
|
|
|
|
|
2025-03-01 11:48:56 +01:00
|
|
|
return showReplyModal && modalContent;
|
2024-04-28 20:08:41 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ReplyModal;
|