fix(fortune): scope s5s fortune markup (#1150)

* fix(markdown): scope fortune markup to fortune boards

* fix(fortune): store fortune output as bbcode

* fix(fortune): keep legacy fortune rendering

* fix(tests): resolve catalog button mock merge

* fix(fortune): validate hidden fortune length
This commit is contained in:
Tommaso Casaburi
2026-06-03 13:49:35 +07:00
committed by GitHub
parent 84f357cba1
commit d09d2d05d1
16 changed files with 439 additions and 93 deletions
@@ -337,6 +337,12 @@ const waitForOptionsValidation = async () => {
});
};
const waitForContentLengthValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1020));
});
};
const clickButtonByText = async (text: string) => {
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
await act(async () => {
@@ -713,7 +719,7 @@ describe('ReplyModal', () => {
});
});
it('validates unsupported options and stores fortune output in reply content', async () => {
it('validates unsupported options and keeps fortune output out of preview state until reply publish', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
@@ -740,15 +746,38 @@ describe('ReplyModal', () => {
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>',
content: 'reply body',
});
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'reply body[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
randomSpy.mockRestore();
});
it('counts hidden fortune output in reply length validation without revealing it', 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') as HTMLTextAreaElement;
const longContent = 'x'.repeat(1930);
await dispatchInput(optionsInput, 'fortune');
await dispatchInput(textarea, longContent);
await waitForContentLengthValidation();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: longContent });
expect(container.textContent).toContain('comment_field_too_long:2001');
expect(container.textContent).not.toContain('[fortune color=');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('links the unsupported sage option to its FAQ entry in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
@@ -784,9 +813,10 @@ describe('ReplyModal', () => {
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>',
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'silly reply[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'silly reply' });
randomSpy.mockRestore();
});
+13 -8
View File
@@ -12,6 +12,7 @@ import {
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getPostOptionsDirectoryCode,
getPostOptionsPublishContentLength,
getPostOptionsValidationError,
hasNonokoOption,
isPostOptionsValidationError,
@@ -120,8 +121,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const checkContentLengthRef = useRef(
debounce((content: string, t: TFunction) => {
const length = content.trim().length;
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
const length = getPostOptionsPublishContentLength(content, options, directoryCode);
if (length > 2000) {
setError(null);
setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`);
@@ -324,9 +325,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const len = textRef.current.value.length;
lastSelectionStartRef.current = len;
lastSelectionEndRef.current = len;
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
includeFortune: false,
});
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
const spellcheckTimeout = window.setTimeout(() => {
if (textRef.current) {
@@ -368,9 +371,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
lastSelectionStartRef.current = selectionStart;
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
}
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, { includeFortune: false });
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
checkContentLengthRef.current(publishContent, t, options, postOptionsDirectoryCode);
};
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -467,9 +470,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
lastSelectionStartRef.current = nextCursor;
lastSelectionEndRef.current = nextCursor;
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
includeFortune: false,
});
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({