mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(posting): add scoped options and qst formatting (#1135)
* feat(posting): add scoped options and qst formatting * fix(posting): address post options review feedback * fix(posting): sync option content before publish * fix(posting): handle final review nits
This commit is contained in:
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ReplyModal from '../reply-modal';
|
||||
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
@@ -15,8 +16,9 @@ const testState = vi.hoisted(() => ({
|
||||
'music-posting.eth': {
|
||||
address: 'music-posting.eth',
|
||||
features: {},
|
||||
title: '/mu/ - Music',
|
||||
},
|
||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||
} as Record<string, { address: string; features?: Record<string, unknown>; title?: string }>,
|
||||
handleUploadMock: vi.fn(),
|
||||
isMobile: false,
|
||||
isResolvingExternalQuotes: false,
|
||||
@@ -128,6 +130,8 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
findDirectoryByAddress: (directories: Array<{ address: string; features?: Record<string, unknown>; title?: string }>, address: string | undefined) =>
|
||||
directories.find((entry) => entry.address === address),
|
||||
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
||||
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||
}));
|
||||
@@ -148,7 +152,12 @@ vi.mock('../../../hooks/use-stable-community', () => ({
|
||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||
default: () => ({
|
||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||
publishReply: testState.publishReplyMock,
|
||||
publishReply: (options?: Record<string, unknown>) => {
|
||||
if (options) {
|
||||
testState.setPublishReplyOptionsMock(options);
|
||||
}
|
||||
return testState.publishReplyMock(options);
|
||||
},
|
||||
publishReplyError: testState.publishReplyError,
|
||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||
replyIndex: testState.replyIndex,
|
||||
@@ -177,9 +186,20 @@ vi.mock('../../loading-ellipsis', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('lodash/debounce', () => ({
|
||||
default: <T extends (...args: any[]) => void>(fn: T) => {
|
||||
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
|
||||
wrapped.cancel = () => undefined;
|
||||
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const wrapped = ((...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => fn(...args), wait);
|
||||
}) as T & { cancel: () => void };
|
||||
wrapped.cancel = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = undefined;
|
||||
};
|
||||
return wrapped;
|
||||
},
|
||||
}));
|
||||
@@ -269,6 +289,12 @@ const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, va
|
||||
});
|
||||
};
|
||||
|
||||
const waitForOptionsValidation = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
|
||||
});
|
||||
};
|
||||
|
||||
const clickButtonByText = async (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||
await act(async () => {
|
||||
@@ -285,6 +311,22 @@ describe('ReplyModal', () => {
|
||||
'music-posting.eth': {
|
||||
address: 'music-posting.eth',
|
||||
features: {},
|
||||
title: '/mu/ - Music',
|
||||
},
|
||||
'random-nsfw.bso': {
|
||||
address: 'random-nsfw.bso',
|
||||
features: {},
|
||||
title: '/b/ - Random',
|
||||
},
|
||||
'silly-stuff.bso': {
|
||||
address: 'silly-stuff.bso',
|
||||
features: {},
|
||||
title: '/s5s/ - Silly Stuff',
|
||||
},
|
||||
'traditional-games.bso': {
|
||||
address: 'traditional-games.bso',
|
||||
features: {},
|
||||
title: '/tg/ - Traditional Games',
|
||||
},
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
@@ -325,6 +367,9 @@ describe('ReplyModal', () => {
|
||||
'music-posting.eth': {
|
||||
address: 'music-posting.eth',
|
||||
},
|
||||
'traditional-games.bso': {
|
||||
address: 'traditional-games.bso',
|
||||
},
|
||||
};
|
||||
testState.showUploadControls = true;
|
||||
testState.uploadComplete = undefined;
|
||||
@@ -349,12 +394,19 @@ describe('ReplyModal', () => {
|
||||
await renderReplyModal('/mu/thread/post-1');
|
||||
|
||||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
expect(optionsInput).toBeTruthy();
|
||||
expect(linkInput).toBeTruthy();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(nameInput?.value).toBe('Alice');
|
||||
expect(optionsInput?.getAttribute('placeholder')).toBe('Options');
|
||||
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
|
||||
expect(textarea?.value).toBe('>>42\nselected text');
|
||||
expect(Boolean(optionsInput!.compareDocumentPosition(textarea!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||||
expect(Boolean(textarea!.compareDocumentPosition(linkInput!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||||
expect(container.textContent).toContain('choose_file');
|
||||
expect(container.textContent).toContain('Spoiler?');
|
||||
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
|
||||
@@ -410,7 +462,7 @@ describe('ReplyModal', () => {
|
||||
expect(container.textContent).toContain('error: empty_comment_alert');
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
const spoilerCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
await dispatchInput(linkInput, 'not-a-url');
|
||||
await act(async () => {
|
||||
@@ -435,6 +487,137 @@ describe('ReplyModal', () => {
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates unsupported options and stores fortune output in reply content', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = '';
|
||||
|
||||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||||
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(optionsInput, 'x y z');
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
|
||||
await waitForOptionsValidation();
|
||||
expect(container.textContent).toContain('unsupported options: x, y, z');
|
||||
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'unsupported options: x, y, z');
|
||||
expect(delayedOptionsError?.className).toContain('error');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
await dispatchInput(optionsInput, 'fortune');
|
||||
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||||
content: 'reply body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
|
||||
});
|
||||
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
randomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = '';
|
||||
delete testState.directoryByAddress['silly-stuff.bso'];
|
||||
|
||||
await renderReplyModal('/s5s/thread/post-1', 'silly-stuff.bso');
|
||||
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(optionsInput, 'fortune');
|
||||
await waitForOptionsValidation();
|
||||
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'silly reply');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||||
content: 'silly reply<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
|
||||
});
|
||||
randomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores dice rolls in reply content on dice-enabled boards', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = '';
|
||||
|
||||
await renderReplyModal('/tg/thread/post-1', 'traditional-games.bso');
|
||||
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(optionsInput, 'dice+2d6');
|
||||
await waitForOptionsValidation();
|
||||
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'dice reply');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||||
content: '<b>Rolled 2, 2 = 4 (2d6)<br><br></b>dice reply',
|
||||
});
|
||||
randomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('treats dice rolls as unsupported outside /tg/ and /qst/ in reply modal', async () => {
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = '';
|
||||
|
||||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||||
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(optionsInput, 'dice+1d6');
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
|
||||
await waitForOptionsValidation();
|
||||
expect(container.textContent).toContain('unsupported options: dice+1d6');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice reply');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain dice reply' });
|
||||
});
|
||||
|
||||
it('treats fortune as unsupported outside /b/ and /s5s/ in reply modal', async () => {
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = '';
|
||||
|
||||
await renderReplyModal('/mu/thread/post-1', 'music-posting.eth');
|
||||
|
||||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(optionsInput, 'fortune');
|
||||
expect(container.textContent).not.toContain('unsupported options');
|
||||
|
||||
await waitForOptionsValidation();
|
||||
expect(container.textContent).toContain('unsupported options: fortune');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain reply' });
|
||||
});
|
||||
|
||||
it('shows BBCode controls only for board mods and inserts tags into the reply textarea', async () => {
|
||||
testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
|
||||
testState.rolesByCommunity = {
|
||||
@@ -476,7 +659,7 @@ describe('ReplyModal', () => {
|
||||
await renderReplyModal('/mu/thread/post-1');
|
||||
|
||||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
|
||||
await dispatchInput(nameInput, 'Alicia');
|
||||
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
||||
@@ -533,7 +716,7 @@ describe('ReplyModal', () => {
|
||||
|
||||
await renderReplyModal('/all/thread/post-1');
|
||||
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
||||
expect(container.textContent).not.toContain('warning');
|
||||
expect(container.textContent).not.toContain('Spoiler?');
|
||||
|
||||
@@ -4,6 +4,15 @@ 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,
|
||||
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';
|
||||
@@ -47,6 +56,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
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 } =
|
||||
@@ -73,6 +83,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}, 0);
|
||||
});
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<HTMLInputElement>(null);
|
||||
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
|
||||
const diceRollRef = useRef<DiceRoll | null>(null);
|
||||
const lastSelectionStartRef = useRef(0);
|
||||
const lastSelectionEndRef = useRef(0);
|
||||
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
||||
@@ -100,11 +113,33 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}, 1000),
|
||||
);
|
||||
|
||||
const onPublishReply = () => {
|
||||
const currentContent = textRef.current?.value.trim() || '';
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
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),
|
||||
);
|
||||
|
||||
if (!currentContent && !currentUrl) {
|
||||
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);
|
||||
|
||||
if (currentOptionsError) {
|
||||
setError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!publishContent.trim() && !currentUrl) {
|
||||
setError(t('error') + ': ' + t('empty_comment_alert'));
|
||||
return;
|
||||
}
|
||||
@@ -119,16 +154,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
return;
|
||||
}
|
||||
|
||||
checkContentLengthRef.current.cancel();
|
||||
setLengthError(null);
|
||||
|
||||
if (currentContent.length > 2000) {
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setError(t('error') + ': ' + t('field_too_long'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
publishReply();
|
||||
publishReply({ content: publishContent });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,6 +226,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
checkContentLengthRef.current.cancel();
|
||||
checkPostOptionsRef.current.cancel();
|
||||
restoreBodyTextSelection();
|
||||
};
|
||||
}, []);
|
||||
@@ -248,8 +282,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
lastSelectionStartRef.current = len;
|
||||
lastSelectionEndRef.current = len;
|
||||
const content = textRef.current.value;
|
||||
setPublishReplyOptions({ content });
|
||||
checkContentLengthRef.current(content, t);
|
||||
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
setPublishReplyOptions({ content: publishContent });
|
||||
checkContentLengthRef.current(publishContent, t);
|
||||
|
||||
const spellcheckTimeout = window.setTimeout(() => {
|
||||
if (textRef.current) {
|
||||
@@ -265,17 +300,26 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
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<HTMLTextAreaElement>) => {
|
||||
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
|
||||
lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current;
|
||||
};
|
||||
|
||||
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number) => {
|
||||
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => {
|
||||
if (isBbcodePreviewing) {
|
||||
setBbcodePreviewContent(content);
|
||||
}
|
||||
@@ -283,8 +327,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
lastSelectionStartRef.current = selectionStart;
|
||||
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
|
||||
}
|
||||
setPublishReplyOptions({ content });
|
||||
checkContentLengthRef.current(content, t);
|
||||
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);
|
||||
setError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError));
|
||||
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -341,8 +393,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
lastSelectionStartRef.current = nextCursor;
|
||||
lastSelectionEndRef.current = nextCursor;
|
||||
|
||||
setPublishReplyOptions({ content: nextValue });
|
||||
checkContentLengthRef.current(nextValue, t);
|
||||
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({
|
||||
@@ -407,17 +460,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.options}>
|
||||
<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 });
|
||||
}}
|
||||
ref={optionsRef}
|
||||
aria-label={t('options')}
|
||||
placeholder={capitalize(t('options'))}
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
onChange={handleOptionsChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
@@ -450,6 +502,19 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className={styles.footer}>
|
||||
{showUploadControls && (
|
||||
<span className={styles.uploadContainer}>
|
||||
|
||||
Reference in New Issue
Block a user