mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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,7 +237,8 @@ const CommentContent = ({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && !isPrivilegedAuthor && (
|
||||
{((!isInPostView && visibleContent?.length > 1000 && !showFullComment) || (isInPostView && visibleContent?.length > 2000 && !showFullComment)) &&
|
||||
!isPrivilegedAuthor && (
|
||||
<span className={styles.abbr}>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
|
||||
vi.mock('../../lib/utils/challenge-utils', () => ({
|
||||
alertChallengeVerificationFailed: (...args: any[]) => testState.alertChallengeVerificationFailedMock(...args),
|
||||
redactGeneratedFortuneFromChallenge: (challenge: unknown) => challenge,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getContentWithPostOptionState,
|
||||
getNonokoPendingAccountCommentIndex,
|
||||
getNonokoPendingRouteState,
|
||||
getPostOptionsPublishContentLength,
|
||||
getPostOptionsValidationError,
|
||||
getUnsupportedPostOptionsMessage,
|
||||
hasNonokoOption,
|
||||
stripGeneratedFortuneMarkup,
|
||||
} from '../post-options-utils';
|
||||
|
||||
describe('post-options-utils', () => {
|
||||
@@ -42,4 +45,48 @@ describe('post-options-utils', () => {
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: -1 })).toBeUndefined();
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: '7' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips generated fortune markers before appending a new fortune on fortune boards', () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
const fortuneEntryRef = { current: null };
|
||||
const diceRollRef = { current: null };
|
||||
|
||||
expect(
|
||||
getContentWithPostOptionState(
|
||||
'body[fortune color=#6023f8]Outlook good[/fortune]<span class="fortune" style="color:#7fec11"><br><br><b>Your fortune: Bad Luck</b></span>',
|
||||
'fortune',
|
||||
fortuneEntryRef,
|
||||
diceRollRef,
|
||||
's5s',
|
||||
),
|
||||
).toBe('body[fortune color=#fd4d32]Excellent Luck[/fortune]');
|
||||
|
||||
randomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('strips user-entered generated fortune markers even when fortune is not selected on fortune boards', () => {
|
||||
const fortuneEntryRef = { current: null };
|
||||
const diceRollRef = { current: null };
|
||||
|
||||
expect(getContentWithPostOptionState('body[fortune color=#6023f8]Outlook good[/fortune]', '', fortuneEntryRef, diceRollRef, 's5s')).toBe('body');
|
||||
});
|
||||
|
||||
it('keeps invalid or non-fortune-board fortune-looking text', () => {
|
||||
const fortuneEntryRef = { current: null };
|
||||
const diceRollRef = { current: null };
|
||||
const userText = '[fortune color=#000000]Excellent Luck[/fortune]';
|
||||
|
||||
expect(stripGeneratedFortuneMarkup(userText)).toBe(userText);
|
||||
expect(getContentWithPostOptionState('body[fortune color=#6023f8]Outlook good[/fortune]', '', fortuneEntryRef, diceRollRef, 'mu')).toBe(
|
||||
'body[fortune color=#6023f8]Outlook good[/fortune]',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts hidden fortune output against publish length without rolling a fortune', () => {
|
||||
const longestFortune = '[fortune color=#0893e1]You will meet a dark handsome stranger[/fortune]';
|
||||
|
||||
expect(getPostOptionsPublishContentLength('body', 'fortune', 's5s')).toBe('body'.length + longestFortune.length);
|
||||
expect(getPostOptionsPublishContentLength('body ', 'fortune', 's5s')).toBe('body '.length + longestFortune.length);
|
||||
expect(getPostOptionsPublishContentLength('body ', 'fortune', 'mu')).toBe('body'.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-
|
||||
import { getFallbackDirectoriesData } from '../../hooks/use-directories';
|
||||
import { getCommentCommunityAddress } from './comment-utils';
|
||||
import { getBoardPath } from './route-utils';
|
||||
import { stripGeneratedFortuneMarkup } from './post-options-utils';
|
||||
|
||||
const resolveBoardIdentifier = (communityAddress: unknown): string => {
|
||||
if (typeof communityAddress !== 'string' || !communityAddress) {
|
||||
@@ -25,9 +26,53 @@ export type ChallengePublication = Partial<Comment> & {
|
||||
vote?: number;
|
||||
};
|
||||
|
||||
export const redactGeneratedFortuneFromPublication = <T>(publication: T): T => {
|
||||
if (!publication || typeof publication !== 'object') {
|
||||
return publication;
|
||||
}
|
||||
|
||||
const content = (publication as { content?: unknown }).content;
|
||||
if (typeof content !== 'string') {
|
||||
return publication;
|
||||
}
|
||||
|
||||
const redactedContent = stripGeneratedFortuneMarkup(content);
|
||||
if (redactedContent === content) {
|
||||
return publication;
|
||||
}
|
||||
|
||||
const redactedPublication = Object.create(Object.getPrototypeOf(publication)) as T & { content?: string; publishChallengeAnswers?: unknown };
|
||||
Object.assign(redactedPublication, publication, { content: redactedContent || undefined });
|
||||
|
||||
const publishChallengeAnswers = (publication as { publishChallengeAnswers?: unknown }).publishChallengeAnswers;
|
||||
if (typeof publishChallengeAnswers === 'function') {
|
||||
Object.defineProperty(redactedPublication, 'publishChallengeAnswers', {
|
||||
configurable: true,
|
||||
value: publishChallengeAnswers.bind(publication),
|
||||
});
|
||||
}
|
||||
|
||||
return redactedPublication as T;
|
||||
};
|
||||
|
||||
export const redactGeneratedFortuneFromChallenge = <T>(challenge: T): T => {
|
||||
if (!Array.isArray(challenge)) {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
const redactedChallenge = [...challenge];
|
||||
if (redactedChallenge.length > 1) {
|
||||
redactedChallenge[1] = redactGeneratedFortuneFromPublication(redactedChallenge[1]);
|
||||
}
|
||||
if (redactedChallenge.length > 2) {
|
||||
redactedChallenge[2] = redactGeneratedFortuneFromPublication(redactedChallenge[2]);
|
||||
}
|
||||
return redactedChallenge as T;
|
||||
};
|
||||
|
||||
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
|
||||
if (challengeVerification?.challengeSuccess === false) {
|
||||
console.warn('Challenge Verification Failed:', challengeVerification, 'Publication:', publication);
|
||||
console.warn('Challenge Verification Failed:', challengeVerification, 'Publication:', redactGeneratedFortuneFromPublication(publication));
|
||||
|
||||
let errorMessages: string[] = [];
|
||||
if (challengeVerification?.challengeErrors) {
|
||||
@@ -94,11 +139,12 @@ export const getPublicationPreview = (publication: ChallengePublication | undefi
|
||||
if (publication.title) {
|
||||
publicationPreview += publication.title;
|
||||
}
|
||||
if (publication.content) {
|
||||
const content = publication.content ? stripGeneratedFortuneMarkup(publication.content) : '';
|
||||
if (content) {
|
||||
if (publicationPreview) {
|
||||
publicationPreview += ': ';
|
||||
}
|
||||
publicationPreview += publication.content;
|
||||
publicationPreview += content;
|
||||
}
|
||||
if (!publicationPreview && publication.link) {
|
||||
publicationPreview += publication.link;
|
||||
|
||||
@@ -45,6 +45,10 @@ interface PostOptionsStateRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
interface PostOptionsContentOptions {
|
||||
includeFortune?: boolean;
|
||||
}
|
||||
|
||||
const getRouteDirectoryCode = (pathname: string | undefined): string | undefined => {
|
||||
const firstSegment = pathname?.split('/').filter(Boolean)[0];
|
||||
return firstSegment && POST_OPTION_ROUTE_DIRECTORY_CODES.has(firstSegment) ? firstSegment : undefined;
|
||||
@@ -99,7 +103,7 @@ const isSupportedPostOption = (option: string, directoryCode: string | undefined
|
||||
}
|
||||
|
||||
if (option === 'fortune') {
|
||||
return !!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode);
|
||||
return isFortuneDirectoryCode(directoryCode);
|
||||
}
|
||||
|
||||
return !!parseDiceOption(option) && !!directoryCode && DICE_DIRECTORY_CODES.has(directoryCode);
|
||||
@@ -165,6 +169,8 @@ export const getUnsupportedPostOptionsMessage = (value: string, directoryCode: s
|
||||
|
||||
export const hasNonokoOption = (value: string): boolean => parsePostOptions(value).includes('nonoko');
|
||||
|
||||
export const isFortuneDirectoryCode = (directoryCode: string | undefined): boolean => !!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode);
|
||||
|
||||
const NONOKO_PENDING_ACCOUNT_COMMENT_INDEX_STATE_KEY = 'nonokoPendingAccountCommentIndex';
|
||||
|
||||
type NonokoPendingRouteState = {
|
||||
@@ -193,7 +199,7 @@ export const getNonokoPendingAccountCommentIndex = (state: unknown): number | un
|
||||
};
|
||||
|
||||
const hasFortuneOption = (value: string, directoryCode: string | undefined): boolean =>
|
||||
!!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode) && parsePostOptions(value).includes('fortune');
|
||||
isFortuneDirectoryCode(directoryCode) && parsePostOptions(value).includes('fortune');
|
||||
|
||||
const getDiceOption = (value: string, directoryCode: string | undefined): ReturnType<typeof parseDiceOption> => {
|
||||
if (!directoryCode || !DICE_DIRECTORY_CODES.has(directoryCode)) {
|
||||
@@ -212,9 +218,13 @@ const getDiceOption = (value: string, directoryCode: string | undefined): Return
|
||||
|
||||
const getRandomFortuneEntry = (): FortuneEntry => FORTUNE_ENTRIES[Math.floor(Math.random() * FORTUNE_ENTRIES.length)] || FORTUNE_ENTRIES[0];
|
||||
|
||||
const getFortuneMarkup = ({ color, text }: FortuneEntry): string => `<span class="fortune" style="color:${color}"><br><br><b>Your fortune: ${text}</b></span>`;
|
||||
const getFortuneBbcode = ({ color, text }: FortuneEntry): string => `[fortune color=${color}]${text}[/fortune]`;
|
||||
const MAX_FORTUNE_BBCODE_LENGTH = Math.max(...FORTUNE_ENTRIES.map((entry) => getFortuneBbcode(entry).length));
|
||||
|
||||
const appendFortuneToContent = (content: string, fortune: FortuneEntry): string => `${content}${getFortuneMarkup(fortune)}`;
|
||||
const appendFortuneToContent = (content: string, fortune: FortuneEntry): string => `${content}${getFortuneBbcode(fortune)}`;
|
||||
|
||||
export const getPostOptionsPublishContentLength = (content: string, options: string, directoryCode: string | undefined): number =>
|
||||
hasFortuneOption(options, directoryCode) ? content.trimStart().length + MAX_FORTUNE_BBCODE_LENGTH : content.trim().length;
|
||||
|
||||
const rollDice = (diceOption: NonNullable<ReturnType<typeof parseDiceOption>>, currentDiceRoll: DiceRoll | null): DiceRoll => {
|
||||
if (currentDiceRoll?.option === diceOption.option) {
|
||||
@@ -251,15 +261,21 @@ const getContentWithPostOptions = (
|
||||
currentFortuneEntry: FortuneEntry | null,
|
||||
currentDiceRoll: DiceRoll | null,
|
||||
directoryCode: string | undefined,
|
||||
contentOptions: PostOptionsContentOptions = {},
|
||||
): { content: string; fortuneEntry: FortuneEntry | null; diceRoll: DiceRoll | null } => {
|
||||
const diceOption = getDiceOption(options, directoryCode);
|
||||
const diceRoll = diceOption ? rollDice(diceOption, currentDiceRoll) : null;
|
||||
let nextContent = diceRoll ? prependDiceRollToContent(content, diceRoll) : content;
|
||||
const baseContent = isFortuneDirectoryCode(directoryCode) ? stripGeneratedFortuneMarkup(content) : content;
|
||||
let nextContent = diceRoll ? prependDiceRollToContent(baseContent, diceRoll) : baseContent;
|
||||
|
||||
if (!hasFortuneOption(options, directoryCode)) {
|
||||
return { content: nextContent, fortuneEntry: null, diceRoll };
|
||||
}
|
||||
|
||||
if (contentOptions.includeFortune === false) {
|
||||
return { content: nextContent, fortuneEntry: currentFortuneEntry, diceRoll };
|
||||
}
|
||||
|
||||
const fortuneEntry = currentFortuneEntry || getRandomFortuneEntry();
|
||||
nextContent = appendFortuneToContent(nextContent, fortuneEntry);
|
||||
return { content: nextContent, fortuneEntry, diceRoll };
|
||||
@@ -271,18 +287,27 @@ export const getContentWithPostOptionState = (
|
||||
fortuneEntryRef: PostOptionsStateRef<FortuneEntry | null>,
|
||||
diceRollRef: PostOptionsStateRef<DiceRoll | null>,
|
||||
directoryCode: string | undefined,
|
||||
contentOptions?: PostOptionsContentOptions,
|
||||
): string => {
|
||||
const result = getContentWithPostOptions(content, options, fortuneEntryRef.current, diceRollRef.current, directoryCode);
|
||||
const result = getContentWithPostOptions(content, options, fortuneEntryRef.current, diceRollRef.current, directoryCode, contentOptions);
|
||||
fortuneEntryRef.current = result.fortuneEntry;
|
||||
diceRollRef.current = result.diceRoll;
|
||||
return result.content;
|
||||
};
|
||||
|
||||
const FORTUNE_MARKUP_PATTERN = '<span class="fortune" style="color:(#[0-9a-fA-F]{6})"><br><br><b>Your fortune: ([^<]+)<\\/b><\\/span>';
|
||||
const FORTUNE_BBCODE_PATTERN = '\\[fortune color=(#[0-9a-fA-F]{6})\\]([^\\r\\n]*?)\\[\\/fortune\\]';
|
||||
const LEGACY_FORTUNE_MARKUP_PATTERN = '<span class="fortune" style="color:(#[0-9a-fA-F]{6})"><br><br><b>Your fortune: ([^<]+)<\\/b><\\/span>';
|
||||
const DICE_ROLL_MARKUP_PATTERN = '<b>(Rolled \\d+(?:, \\d+)*(?: [+-] \\d+)?(?: = -?\\d+)? \\(\\d+d\\d+(?: [+-] \\d+)?\\))<br><br><\\/b>';
|
||||
|
||||
export const createFortuneMarkupRegex = (): RegExp => new RegExp(FORTUNE_MARKUP_PATTERN, 'g');
|
||||
export const createFortuneBbcodeRegex = (): RegExp => new RegExp(FORTUNE_BBCODE_PATTERN, 'g');
|
||||
export const createLegacyFortuneMarkupRegex = (): RegExp => new RegExp(LEGACY_FORTUNE_MARKUP_PATTERN, 'g');
|
||||
export const createDiceRollMarkupRegex = (): RegExp => new RegExp(DICE_ROLL_MARKUP_PATTERN, 'g');
|
||||
|
||||
export const getMatchingFortuneEntry = (color: string, text: string): FortuneEntry | undefined =>
|
||||
FORTUNE_ENTRIES.find((entry) => entry.color.toLowerCase() === color.toLowerCase() && entry.text === text);
|
||||
|
||||
const stripGeneratedFortuneMatches = (content: string, createRegex: () => RegExp): string =>
|
||||
content.replace(createRegex(), (match, color: string, text: string) => (getMatchingFortuneEntry(color, text) ? '' : match));
|
||||
|
||||
export const stripGeneratedFortuneMarkup = (content: string): string =>
|
||||
stripGeneratedFortuneMatches(stripGeneratedFortuneMatches(content, createFortuneBbcodeRegex), createLegacyFortuneMarkupRegex);
|
||||
|
||||
@@ -177,6 +177,24 @@ describe('interaction stores', () => {
|
||||
expect(useChallengesStore.getState().challenges).toEqual([]);
|
||||
});
|
||||
|
||||
it('redacts generated fortune content before storing challenge publications', async () => {
|
||||
const publishChallengeAnswers = vi.fn();
|
||||
const publication = {
|
||||
content: 'body[fortune color=#fd4d32]Excellent Luck[/fortune]',
|
||||
publishChallengeAnswers,
|
||||
};
|
||||
|
||||
useChallengesStore.getState().addChallenge([{ challenges: [] }, publication] as never);
|
||||
|
||||
const storedChallenge = useChallengesStore.getState().challenges[0]?.challenge as unknown[] | undefined;
|
||||
const storedPublication = storedChallenge?.[1] as typeof publication | undefined;
|
||||
expect(storedPublication?.content).toBe('body');
|
||||
expect(storedPublication?.content).not.toContain('Excellent Luck');
|
||||
|
||||
await storedPublication?.publishChallengeAnswers(['4']);
|
||||
expect(publishChallengeAnswers).toHaveBeenCalledWith(['4']);
|
||||
});
|
||||
|
||||
it('shows the disclaimer modal until accepted, then navigates directly on later opens', () => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { Challenge } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { redactGeneratedFortuneFromChallenge } from '../lib/utils/challenge-utils';
|
||||
|
||||
let nextChallengeId = 0;
|
||||
|
||||
@@ -14,7 +15,7 @@ const useChallengesStore = create<State>((set, get) => ({
|
||||
challenges: [],
|
||||
addChallenge: (challenge: Challenge, onAbandon?: () => Promise<void> | void) => {
|
||||
set((state) => ({
|
||||
challenges: [...state.challenges, { challenge, id: nextChallengeId++, onAbandon }],
|
||||
challenges: [...state.challenges, { challenge: redactGeneratedFortuneFromChallenge(challenge), id: nextChallengeId++, onAbandon }],
|
||||
}));
|
||||
},
|
||||
removeChallenge: () => {
|
||||
|
||||
Reference in New Issue
Block a user