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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user