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
@@ -226,6 +226,30 @@ describe('ChallengeModal', () => {
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('redacts generated fortune BBCode from challenge publication details', async () => {
const publication = {
...createPublication(),
content: 'body[fortune color=#fd4d32]Excellent Luck[/fortune]',
};
testState.publicationPreview = 'body';
testState.publicationType = 'post';
testState.challenges = [
createStoredChallenge(
{
challenge: '2 + 2',
type: 'text/plain',
},
publication,
),
];
await renderModal();
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('body');
expect(container.textContent).not.toContain('Excellent Luck');
expect(container.textContent).not.toContain('[fortune');
});
it('supports multi-step image challenges with next and previous navigation', async () => {
const publication = createPublication();
testState.challenges = [
@@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Challenge as ChallengeType, useAccount, useComment } from '@bitsocial/bitsocial-react-hooks';
import { getPublicationPreview, getPublicationType, getVotePreview } from '../../lib/utils/challenge-utils';
import { stripGeneratedFortuneMarkup } from '../../lib/utils/post-options-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useChallengesStore from '../../stores/use-challenges-store';
import useTrustedBoardUrlPermissionsStore from '../../stores/use-trusted-board-url-permissions-store';
@@ -325,6 +326,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const votePreview = getVotePreview(publication);
const { author, content, link, title, parentCid, shortCommunityAddress, communityAddress } = publication || {};
const visibleContent = content ? stripGeneratedFortuneMarkup(content) : '';
const { displayName } = author || {};
const parentAddress = useParentAddress(parentCid);
const community = shortCommunityAddress || communityAddress;
@@ -493,9 +495,9 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
<input type='text' aria-label={capitalize(t('subject'))} value={title} disabled readOnly />
</div>
)}
{content && (
{visibleContent && (
<div className={styles.content}>
<textarea aria-label={capitalize(t('comment'))} value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
<textarea aria-label={capitalize(t('comment'))} value={visibleContent} disabled readOnly cols={48} rows={4} wrap='soft' />
</div>
)}
{link && (
@@ -465,6 +465,27 @@ describe('CommentContent', () => {
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Publishing');
});
it('hides generated fortune output from unpublished comment content', async () => {
const content = 'body[fortune color=#fd4d32]Excellent Luck[/fortune]';
await renderContent({
content,
postCid: 'post-1',
state: 'publishing',
});
expect(queryMarkdownText()).toEqual(['body']);
expect(container.textContent).not.toContain('Excellent Luck');
await renderContent({
cid: 'post-1',
content,
postCid: 'post-1',
});
expect(queryMarkdownText()).toEqual([content]);
});
it('renders failed unpublished comment errors through ErrorDisplay', async () => {
testState.stateString = 'Failed';
await renderContent({
@@ -21,6 +21,7 @@ import capitalize from 'lodash/capitalize';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { formatErrorMessageForDisplay } from '../../lib/utils/error-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { stripGeneratedFortuneMarkup } from '../../lib/utils/post-options-utils';
const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => {
const quotedNumber = usePostNumberStore((state) => state.cidToNumber[cid]);
@@ -99,6 +100,7 @@ const CommentContent = ({
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { cid, content, deleted, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {};
const visibleContent = !cid && content ? stripGeneratedFortuneMarkup(content) : content;
const communityAddress = getCommentCommunityAddress(resolvedPost);
const authorAddress = resolvedPost?.author?.address;
const authorRole = getRoleByAddress(roles, authorAddress);
@@ -110,12 +112,12 @@ const CommentContent = ({
const [showFullComment, setShowFullComment] = useState(false);
const displayContent =
content &&
(!isInPostView && content.length > 1000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 1000)
: isInPostView && content.length > 2000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 2000)
: content);
visibleContent &&
(!isInPostView && visibleContent.length > 1000 && !showFullComment && !isPrivilegedAuthor
? visibleContent.slice(0, 1000)
: isInPostView && visibleContent.length > 2000 && !showFullComment && !isPrivilegedAuthor
? visibleContent.slice(0, 2000)
: visibleContent);
const quotelinkReplyFromStore = useCommunitiesPagesStore((state) => state.comments[parentCid]);
const quotelinkReplyFromHook = useComment({ commentCid: parentCid, onlyIfCached: true });
@@ -126,9 +128,9 @@ const CommentContent = ({
const isReplyingToReply = isReply && parentCid !== postCid;
const contentNumbers = useMemo(() => {
if (!content) return new Set<number>();
return new Set([...content.matchAll(/(?<![>/\w])>>(\d+)(?![\d/])/g)].map((m) => parseInt(m[1], 10)));
}, [content]);
if (!visibleContent) return new Set<number>();
return new Set([...visibleContent.matchAll(/(?<![>/\w])>>(\d+)(?![\d/])/g)].map((m) => parseInt(m[1], 10)));
}, [visibleContent]);
const relevantQuotedCids = useMemo(() => {
const cids = quotedCids ? [...quotedCids] : [];
@@ -213,8 +215,7 @@ const CommentContent = ({
) : deleted ? (
reasonMessage ? (
<>
<span className={styles.grayEditMessage}>{t('user_deleted_this_post')}</span>{' '}
{renderContent(reasonMessage)}
<span className={styles.grayEditMessage}>{t('user_deleted_this_post')}</span> {renderContent(reasonMessage)}
</>
) : (
<span className={styles.grayEditMessage}>{t('user_deleted_this_post')}</span>
@@ -236,33 +237,34 @@ const CommentContent = ({
) : null}
</>
)}
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && !isPrivilegedAuthor && (
<span className={styles.abbr}>
<br />
<br />
<Trans
i18nKey={'comment_too_long'}
shouldUnescape={true}
components={{
1: (
<button
type='button'
key={cid}
aria-label={t('view')}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowFullComment(true);
}
}}
onClick={() => setShowFullComment(true)}
/>
),
}}
/>
</span>
)}
{((!isInPostView && visibleContent?.length > 1000 && !showFullComment) || (isInPostView && visibleContent?.length > 2000 && !showFullComment)) &&
!isPrivilegedAuthor && (
<span className={styles.abbr}>
<br />
<br />
<Trans
i18nKey={'comment_too_long'}
shouldUnescape={true}
components={{
1: (
<button
type='button'
key={cid}
aria-label={t('view')}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowFullComment(true);
}
}}
onClick={() => setShowFullComment(true)}
/>
),
}}
/>
</span>
)}
</>
)}
{banned && (
@@ -17,6 +17,7 @@ const testState = vi.hoisted(() => ({
comments: {} as Record<string, TestComment>,
directories: [{ address: 'music-posting.eth', name: 'music-posting.bso', title: '/mu/ - Music' }] as Array<{
address: string;
directoryCode?: string;
name?: string;
title?: string;
}>,
@@ -333,16 +334,59 @@ describe('Markdown', () => {
expect(container.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),');
});
it('renders whitelisted 4chan fortune markup with its specific color', async () => {
await renderMarkdown({
content: 'body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
});
it('renders generated fortune BBCode on fortune boards without parsing surrounding user BBCode', async () => {
await renderMarkdown(
{
content: '[b]not bold[/b][fortune color=#fd4d32]Excellent Luck[/fortune]',
},
'/s5s/thread/post-1',
);
const fortune = container.querySelector<HTMLElement>('.fortune');
expect(fortune?.textContent).toBe('Your fortune: Excellent Luck');
expect(fortune?.style.color).toBe('rgb(253, 77, 50)');
expect(fortune?.querySelectorAll('br')).toHaveLength(2);
expect(container.textContent).toBe('bodyYour fortune: Excellent Luck');
expect(container.querySelectorAll('strong')).toHaveLength(1);
expect(container.textContent).toBe('[b]not bold[/b]Your fortune: Excellent Luck');
});
it('renders legacy fortune HTML text on fortune boards for existing posts', async () => {
const legacyFortune = '<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>';
await renderMarkdown({ content: `old${legacyFortune}` }, '/s5s/thread/post-1');
const fortune = container.querySelector<HTMLElement>('.fortune');
expect(fortune?.textContent).toBe('Your fortune: Excellent Luck');
expect(fortune?.style.color).toBe('rgb(253, 77, 50)');
expect(fortune?.querySelectorAll('br')).toHaveLength(2);
expect(container.textContent).toBe('oldYour fortune: Excellent Luck');
});
it('leaves generated fortune markers raw outside fortune boards', async () => {
const legacyFortune = '<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>';
await renderMarkdown({
content: `body[fortune color=#fd4d32]Excellent Luck[/fortune]${legacyFortune}`,
});
expect(container.querySelector('.fortune')).toBeNull();
expect(container.querySelector('strong')).toBeNull();
expect(container.textContent).toBe(`body[fortune color=#fd4d32]Excellent Luck[/fortune]${legacyFortune}`);
});
it('renders generated fortune BBCode for s5s comments in multiboard views', async () => {
testState.directories = [...testState.directories, { address: 'silly-stuff.bso', directoryCode: 's5s', title: '/s5s/ - Shit 5chan Says' }];
await renderMarkdown(
{
content: 'silly[fortune color=#fd4d32]Excellent Luck[/fortune]',
communityAddress: 'silly-stuff.eth',
},
'/all',
);
expect(container.querySelector<HTMLElement>('.fortune')?.textContent).toBe('Your fortune: Excellent Luck');
expect(container.textContent).toBe('sillyYour fortune: Excellent Luck');
});
it('renders whitelisted 4chan dice roll markup as bold post content', async () => {
+64 -16
View File
@@ -17,7 +17,15 @@ import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stor
import { useComment } from '@bitsocial/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link';
import { createDiceRollMarkupRegex, createFortuneMarkupRegex, getMatchingFortuneEntry } from '../../lib/utils/post-options-utils';
import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils';
import {
createDiceRollMarkupRegex,
createFortuneBbcodeRegex,
createLegacyFortuneMarkupRegex,
getMatchingFortuneEntry,
isFortuneDirectoryCode,
} from '../../lib/utils/post-options-utils';
const safeParseUrl = (href: string): URL | null => {
try {
@@ -166,6 +174,34 @@ const QST_BBCODE_COLOR_STYLES = {
blue: { color: QST_BBCODE_COLORS.blue },
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, React.CSSProperties>;
const getDirectoryCodeFromDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined =>
directory?.directoryCode?.trim().toLowerCase() || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
const getRouteBoardIdentifier = (pathname: string): string | undefined => pathname.split('/').filter(Boolean)[0]?.toLowerCase();
const getDirectoryCodeForIdentifier = (identifier: string | undefined, directories: DirectoryCommunity[]): string | undefined => {
if (!identifier) return undefined;
const normalizedIdentifier = identifier.trim().toLowerCase();
if (!normalizedIdentifier) return undefined;
const matchingDirectory = directories.find((directory) => getDirectoryCodeFromDirectory(directory) === normalizedIdentifier);
if (matchingDirectory) {
return getDirectoryCodeFromDirectory(matchingDirectory);
}
return getDirectoryCodeFromDirectory(findDirectoryByAddress(directories, identifier)) ?? getDirectoryCodeForBoardAddress(identifier) ?? normalizedIdentifier;
};
const getActiveDirectoryCode = (pathname: string, communityAddress: string | undefined, directories: DirectoryCommunity[]): string | undefined => {
const routeDirectoryCode = getDirectoryCodeForIdentifier(getRouteBoardIdentifier(pathname), directories);
if (isFortuneDirectoryCode(routeDirectoryCode)) {
return routeDirectoryCode;
}
return getDirectoryCodeForIdentifier(communityAddress, directories);
};
const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g',
@@ -326,6 +362,7 @@ interface RenderContext {
isInCatalogView: boolean;
postCid?: string;
communityAddress?: string;
enableFortuneMarkup: boolean;
enableQstBbcode: boolean;
parseSpoilers: boolean;
}
@@ -554,25 +591,33 @@ const DiceRoll = ({ text }: { text: string }) => (
const renderLineContent = (line: string, context: RenderContext): React.ReactNode[] => {
const elements: React.ReactNode[] = [];
let lastIndex = 0;
const fortuneMarkupRegex = createFortuneMarkupRegex();
const fortuneBbcodeRegex = context.enableFortuneMarkup ? createFortuneBbcodeRegex() : null;
const legacyFortuneMarkupRegex = context.enableFortuneMarkup ? createLegacyFortuneMarkupRegex() : null;
const diceRollMarkupRegex = createDiceRollMarkupRegex();
while (lastIndex < line.length) {
fortuneMarkupRegex.lastIndex = lastIndex;
if (fortuneBbcodeRegex) {
fortuneBbcodeRegex.lastIndex = lastIndex;
}
if (legacyFortuneMarkupRegex) {
legacyFortuneMarkupRegex.lastIndex = lastIndex;
}
diceRollMarkupRegex.lastIndex = lastIndex;
const fortuneMatch = fortuneMarkupRegex.exec(line);
const fortuneBbcodeMatch = fortuneBbcodeRegex?.exec(line) ?? null;
const legacyFortuneMatch = legacyFortuneMarkupRegex?.exec(line) ?? null;
const diceMatch = diceRollMarkupRegex.exec(line);
const nextMatch =
fortuneMatch && diceMatch
? fortuneMatch.index <= diceMatch.index
? { type: 'fortune' as const, match: fortuneMatch }
: { type: 'dice' as const, match: diceMatch }
: fortuneMatch
? { type: 'fortune' as const, match: fortuneMatch }
: diceMatch
? { type: 'dice' as const, match: diceMatch }
: null;
let nextMatch: { type: 'dice'; match: RegExpExecArray } | { type: 'fortune'; match: RegExpExecArray } | null = null;
for (const candidate of [
fortuneBbcodeMatch ? { type: 'fortune' as const, match: fortuneBbcodeMatch } : null,
legacyFortuneMatch ? { type: 'fortune' as const, match: legacyFortuneMatch } : null,
diceMatch ? { type: 'dice' as const, match: diceMatch } : null,
]) {
if (candidate && (!nextMatch || candidate.match.index < nextMatch.match.index)) {
nextMatch = candidate;
}
}
if (!nextMatch) {
break;
@@ -619,8 +664,11 @@ const renderLineContent = (line: string, context: RenderContext): React.ReactNod
const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = true }: MarkdownProps) => {
const location = useLocation();
const params = useParams();
const directories = useDirectories();
const isInCatalogView = isCatalogView(location.pathname, params);
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
const activeDirectoryCode = getActiveDirectoryCode(location.pathname, communityAddress, directories);
const enableFortuneMarkup = isFortuneDirectoryCode(activeDirectoryCode);
const rendered = useMemo(() => {
const normalized = normalizeContent(content || '');
@@ -628,7 +676,7 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
const elements: React.ReactNode[] = [];
let lineOffset = 0;
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode, parseSpoilers };
const context = { isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers };
lines.forEach((line, lineIndex) => {
const lineKey = `line-${lineOffset}`;
@@ -656,7 +704,7 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
});
return elements;
}, [content, isInCatalogView, postCid, communityAddress, enableQstBbcode, parseSpoilers]);
}, [content, isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers]);
return (
<span className={styles.markdown}>
@@ -458,6 +458,12 @@ const waitForOptionsValidation = async () => {
});
};
const waitForContentLengthValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1020));
});
};
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
await act(async () => {
if (typeof value === 'boolean' && 'checked' in element) {
@@ -932,7 +938,7 @@ describe('PostForm', () => {
});
});
it('validates unsupported options and stores fortune output in post content', async () => {
it('validates unsupported options and keeps fortune output out of preview state until post publish', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'random-nsfw.bso';
@@ -963,14 +969,38 @@ describe('PostForm', () => {
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
expect(container.textContent).not.toContain('Unsupported options');
expect(testState.publishPostOptions.content).toBe('fortune body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
expect(testState.publishPostOptions.content).toBe('fortune body');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'fortune body[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
randomSpy.mockRestore();
});
it('counts hidden fortune output in post length validation without revealing it', async () => {
testState.resolvedCommunityAddress = 'random-nsfw.bso';
await renderPostForm('/b');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const optionsInput = table.querySelector<HTMLInputElement>('input[aria-label="options"]') as HTMLInputElement;
const textarea = table.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const longContent = 'x'.repeat(1930);
await dispatchInput(optionsInput, 'fortune');
await dispatchInput(textarea, longContent);
await waitForContentLengthValidation();
expect(testState.publishPostOptions.content).toBe(longContent);
expect(container.textContent).toContain('comment_field_too_long');
expect(container.textContent).not.toContain('[fortune color=');
expect(testState.publishPostMock).not.toHaveBeenCalled();
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'silly-stuff.bso';
@@ -992,7 +1022,8 @@ describe('PostForm', () => {
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.content).toBe('silly fortune<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
expect(testState.publishedPostOptions?.content).toBe('silly fortune[fortune color=#fd4d32]Excellent Luck[/fortune]');
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: 'silly fortune' });
randomSpy.mockRestore();
});
+5 -4
View File
@@ -15,6 +15,7 @@ import {
getContentWithPostOptionState as getContentWithOptions,
getNonokoPendingRouteState,
getPostOptionsDirectoryCode,
getPostOptionsPublishContentLength,
getPostOptionsValidationError,
hasNonokoOption,
isPostOptionsValidationError,
@@ -548,8 +549,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const checkContentLength = 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) {
setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`);
} else {
@@ -694,7 +695,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => {
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, { includeFortune: false });
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
@@ -703,7 +704,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
} else {
setPublishPostOptions({ content: publishContent });
}
checkContentLength(publishContent, t);
checkContentLength(publishContent, t, options, postOptionsDirectoryCode);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -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({