feat(posting): add scoped options and qst formatting (#1135)

* feat(posting): add scoped options and qst formatting

* fix(posting): address post options review feedback

* fix(posting): sync option content before publish

* fix(posting): handle final review nits
This commit is contained in:
Tommaso Casaburi
2026-05-21 22:12:41 +07:00
committed by GitHub
parent 73503eadd0
commit ff6b4cd201
12 changed files with 1119 additions and 124 deletions
@@ -279,6 +279,63 @@ 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>',
});
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');
});
it('renders whitelisted 4chan dice roll markup as bold post content', async () => {
await renderMarkdown({
content: '<b>Rolled 2, 2 = 4 (2d6)<br><br></b>dice body',
});
const diceRoll = container.querySelector('strong');
expect(diceRoll?.textContent).toBe('Rolled 2, 2 = 4 (2d6)');
expect(diceRoll?.querySelectorAll('br')).toHaveLength(2);
expect(container.textContent).toBe('Rolled 2, 2 = 4 (2d6)dice body');
});
it('renders only the /qst/ author formatting BBCode tags on qst routes', async () => {
await renderMarkdown(
{
content: '[b]bold[/b] [i]italic[/i] [red]red[/red] [green]green[/green] [blue]blue[/blue] [u]raw[/u] [spoiler][b]hidden[/b][/spoiler]',
},
'/qst/thread/post-1',
);
expect(container.querySelector('strong')?.textContent).toBe('bold');
expect(container.querySelector('em')?.textContent).toBe('italic');
const red = Array.from(container.querySelectorAll<HTMLElement>('span')).find((node) => node.textContent === 'red');
const green = Array.from(container.querySelectorAll<HTMLElement>('span')).find((node) => node.textContent === 'green');
const blue = Array.from(container.querySelectorAll<HTMLElement>('span')).find((node) => node.textContent === 'blue');
expect(red?.style.color).toBe('rgb(196, 30, 58)');
expect(green?.style.color).toBe('rgb(0, 165, 80)');
expect(blue?.style.color).toBe('rgb(29, 141, 196)');
expect(container.querySelector('.spoilertext strong')?.textContent).toBe('hidden');
expect(container.textContent).toContain('[u]raw[/u]');
});
it('leaves qst-only BBCode raw outside qst routes', async () => {
await renderMarkdown(
{
content: '[b]bold[/b] [red]red[/red]',
},
'/tg/thread/post-1',
);
expect(container.querySelector('strong')).toBeNull();
expect(Array.from(container.querySelectorAll<HTMLElement>('span')).some((node) => node.style.color)).toBe(false);
expect(container.textContent).toBe('[b]bold[/b] [red]red[/red]');
});
it('renders number quote links with op and unavailable state derived from cached comments', async () => {
testState.comments = {
'comment-42': { cid: 'comment-42', number: 42 },
+158 -5
View File
@@ -17,6 +17,7 @@ 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';
const safeParseUrl = (href: string): URL | null => {
try {
@@ -152,6 +153,18 @@ const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue';
const QST_BBCODE_OPEN_REGEX = /\[(b|i|red|green|blue)\]/g;
const QST_BBCODE_COLORS = {
red: '#C41E3A',
green: '#00A550',
blue: '#1d8dc4',
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, string>;
const QST_BBCODE_COLOR_STYLES = {
red: { color: QST_BBCODE_COLORS.red },
green: { color: QST_BBCODE_COLORS.green },
blue: { color: QST_BBCODE_COLORS.blue },
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, React.CSSProperties>;
const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
@@ -308,6 +321,7 @@ interface RenderContext {
isInCatalogView: boolean;
postCid?: string;
communityAddress?: string;
enableQstBbcode: boolean;
}
interface MarkdownProps {
@@ -398,7 +412,7 @@ const TokenNode = ({ token, context }: { token: Token; context: RenderContext })
switch (token.type) {
case 'text':
return <>{token.value}</>;
return context.enableQstBbcode ? <QstBbcodeText text={token.value} tokenKey={token.key} /> : <>{token.value}</>;
case 'url': {
const href = token.href;
const linkMediaInfo = getLinkMediaInfo(href);
@@ -445,17 +459,157 @@ const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContex
);
};
const findMatchingQstBbcodeClose = (text: string, tag: QstBbcodeTag, searchStart: number): number => {
const tagRegex = new RegExp(`\\[(/?)${tag}\\]`, 'g');
tagRegex.lastIndex = searchStart;
let depth = 1;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(text)) !== null) {
depth += match[1] ? -1 : 1;
if (depth === 0) {
return match.index;
}
}
return -1;
};
const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.ReactNode[]) => {
if (tag === 'b') {
return <strong key={key}>{children}</strong>;
}
if (tag === 'i') {
return <em key={key}>{children}</em>;
}
return (
<span key={key} style={QST_BBCODE_COLOR_STYLES[tag]}>
{children}
</span>
);
};
const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[] => {
const elements: React.ReactNode[] = [];
const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g');
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const tag = match[1] as QstBbcodeTag;
const matchStart = match.index;
const contentStart = regex.lastIndex;
const closeStart = findMatchingQstBbcodeClose(text, tag, contentStart);
if (closeStart === -1) {
continue;
}
if (matchStart > lastIndex) {
elements.push(<React.Fragment key={`${keyPrefix}text-${lastIndex}-${matchStart}`}>{text.slice(lastIndex, matchStart)}</React.Fragment>);
}
const closeEnd = closeStart + tag.length + 3;
const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`;
elements.push(renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, renderQstBbcodeText(text.slice(contentStart, closeStart), childKeyPrefix)));
lastIndex = closeEnd;
regex.lastIndex = closeEnd;
}
if (lastIndex < text.length) {
elements.push(<React.Fragment key={`${keyPrefix}text-${lastIndex}-${text.length}`}>{text.slice(lastIndex)}</React.Fragment>);
}
return elements;
};
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}</>;
const Fortune = ({ color, text }: { color: string; text: string }) => (
<span className='fortune' style={{ color }}>
<br />
<br />
<strong>Your fortune: {text}</strong>
</span>
);
const DiceRoll = ({ text }: { text: string }) => (
<strong>
{text}
<br />
<br />
</strong>
);
const renderLineContent = (line: string, context: RenderContext): React.ReactNode[] => {
const elements: React.ReactNode[] = [];
let lastIndex = 0;
const fortuneMarkupRegex = createFortuneMarkupRegex();
const diceRollMarkupRegex = createDiceRollMarkupRegex();
while (lastIndex < line.length) {
fortuneMarkupRegex.lastIndex = lastIndex;
diceRollMarkupRegex.lastIndex = lastIndex;
const fortuneMatch = fortuneMarkupRegex.exec(line);
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;
if (!nextMatch) {
break;
}
const [fullMatch] = nextMatch.match;
const matchStart = nextMatch.match.index;
const matchEnd = matchStart + fullMatch.length;
if (matchStart > lastIndex) {
elements.push(<TokenList key={`text-${lastIndex}-${matchStart}`} tokens={tokenize(line.slice(lastIndex, matchStart), `${lastIndex}:`)} context={context} />);
}
if (nextMatch.type === 'dice') {
elements.push(<DiceRoll key={`dice-${matchStart}`} text={nextMatch.match[1]} />);
} else {
const [, color, text] = nextMatch.match;
const fortune = getMatchingFortuneEntry(color, text);
if (fortune) {
elements.push(<Fortune key={`fortune-${matchStart}`} color={fortune.color} text={fortune.text} />);
} else {
elements.push(<TokenList key={`text-${matchStart}-${matchEnd}`} tokens={tokenize(fullMatch, `${matchStart}:`)} context={context} />);
}
}
lastIndex = matchEnd;
}
if (lastIndex < line.length) {
elements.push(<TokenList key={`text-${lastIndex}-${line.length}`} tokens={tokenize(line.slice(lastIndex), `${lastIndex}:`)} context={context} />);
}
return elements;
};
const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
const location = useLocation();
const params = useParams();
const isInCatalogView = isCatalogView(location.pathname, params);
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
const rendered = useMemo(() => {
const normalized = normalizeContent(content || '');
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
const context = { isInCatalogView, postCid, communityAddress };
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
lines.forEach((line, lineIndex) => {
if (lineIndex > 0) {
@@ -466,8 +620,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const isGreentext = isGreentextLine(line);
const tokens = tokenize(line);
const lineElements = <TokenList tokens={tokens} context={context} />;
const lineElements = renderLineContent(line, context);
if (isGreentext) {
elements.push(
@@ -481,7 +634,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
});
return elements;
}, [content, isInCatalogView, postCid, communityAddress]);
}, [content, isInCatalogView, postCid, communityAddress, enableQstBbcode]);
return (
<span className={styles.markdown}>
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostForm, { LinkTypePreviewer } from '../post-form';
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -110,6 +111,7 @@ vi.mock('../../../hooks/use-account-community-addresses', () => ({
}));
vi.mock('../../../hooks/use-directories', () => ({
findDirectoryByAddress: (directories: typeof testState.directories, address: string | undefined) => directories.find((entry) => entry.address === address),
useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
@@ -159,10 +161,24 @@ vi.mock('../../../hooks/use-publish-post', async () => {
}),
[communityAddress],
);
const publishPost = React.useCallback(() => {
testState.publishedPostOptions = getPublishPostOptions();
return testState.publishPostMock();
}, [getPublishPostOptions]);
const publishPost = React.useCallback(
(options?: Record<string, unknown>) => {
const sanitizedOptions = Object.entries(options || {}).reduce(
(acc, [key, value]) => {
acc[key] = value === '' ? undefined : value;
return acc;
},
{} as Record<string, unknown>,
);
testState.publishPostOptions = {
...getPublishPostOptions(),
...sanitizedOptions,
};
testState.publishedPostOptions = testState.publishPostOptions;
return testState.publishPostMock(options);
},
[getPublishPostOptions],
);
const resetPublishPostOptions = React.useCallback(() => {
testState.publishPostOptions = {};
testState.resetPublishPostOptionsMock();
@@ -209,7 +225,13 @@ vi.mock('../../../hooks/use-publish-reply', async () => {
postCid: postCid ?? cid,
communityAddress,
});
const publishReply = React.useCallback(() => testState.publishReplyMock(), []);
const publishReply = React.useCallback((options?: Record<string, unknown>) => {
if (options) {
testState.setPublishReplyOptionsMock(options);
setPublishReplyOptionsState((previous) => ({ ...previous, ...options }));
}
return testState.publishReplyMock(options);
}, []);
const resetPublishReplyOptions = React.useCallback(() => testState.resetPublishReplyOptionsMock(), []);
const setPublishReplyOptions = React.useCallback((options: Record<string, unknown>) => {
testState.setPublishReplyOptionsMock(options);
@@ -274,9 +296,20 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
}));
vi.mock('lodash/debounce', () => ({
default: <T extends (...args: any[]) => void>(fn: T) => {
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
wrapped.cancel = () => undefined;
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const wrapped = ((...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), wait);
}) as T & { cancel: () => void };
wrapped.cancel = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = undefined;
};
return wrapped;
},
}));
@@ -362,6 +395,12 @@ const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, va
});
};
const waitForOptionsValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
});
};
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
await act(async () => {
if (typeof value === 'boolean' && 'checked' in element) {
@@ -385,6 +424,9 @@ describe('PostForm', () => {
testState.comments = {};
testState.directories = [
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
];
testState.editedComment = undefined;
@@ -408,6 +450,7 @@ describe('PostForm', () => {
testState.uploadedFileName = 'picked.png';
testState.communities = {
'music-posting.eth': { address: 'music-posting.eth' },
'traditional-games.bso': { address: 'traditional-games.bso' },
};
testState.handleUploadMock.mockReset();
testState.navigateMock.mockReset();
@@ -480,12 +523,14 @@ describe('PostForm', () => {
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const nameInput = textInputs[0];
const subjectInput = textInputs[1];
const linkInput = textInputs[2];
const optionsInput = textInputs[1];
const subjectInput = textInputs[2];
const linkInput = textInputs[3];
const textarea = table?.querySelector('textarea');
const select = table?.querySelector('select');
expect(nameInput).toBeTruthy();
expect(optionsInput?.getAttribute('aria-label')).toBe('options');
expect(subjectInput).toBeTruthy();
expect(linkInput).toBeTruthy();
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
@@ -546,7 +591,7 @@ describe('PostForm', () => {
table = container.querySelector('table');
textarea = table?.querySelector('textarea');
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const linkInput = textInputs[2];
const linkInput = textInputs[3];
expect(textarea?.value).toBe('');
expect(linkInput).toBeTruthy();
@@ -559,6 +604,140 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBeUndefined();
});
it('validates unsupported options and stores fortune output in post content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'random-nsfw.bso';
await renderPostForm('/b');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
expect(optionsInput).toBeTruthy();
expect(textarea).toBeTruthy();
await dispatchInput(optionsInput as HTMLInputElement, 'x y z');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: x, y, z');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'unsupported options: x, y, z');
expect(delayedOptionsError?.className).toContain('error');
expect(delayedOptionsError?.className).toContain('formError');
await dispatchInput(textarea as HTMLTextAreaElement, 'fortune body');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).not.toHaveBeenCalled();
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>');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
randomSpy.mockRestore();
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'silly-stuff.bso';
testState.directories = testState.directories.filter((entry) => entry.address !== 'silly-stuff.bso');
await renderPostForm('/s5s');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'silly fortune');
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>');
randomSpy.mockRestore();
});
it('stores dice rolls in post content on dice-enabled boards', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5);
testState.resolvedCommunityAddress = 'quests.bso';
await renderPostForm('/qst');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6+3');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'dice body');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.content).toBe('<b>Rolled 4 + 3 = 7 (1d6 + 3)<br><br></b>dice body');
randomSpy.mockRestore();
});
it('treats fortune as unsupported outside /b/ and /s5s/', async () => {
testState.resolvedCommunityAddress = 'music-posting.eth';
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: fortune');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain body');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).not.toHaveBeenCalled();
expect(testState.publishPostOptions.content).toBe('plain body');
});
it('treats dice rolls as unsupported outside /tg/ and /qst/', async () => {
testState.resolvedCommunityAddress = 'random-nsfw.bso';
await renderPostForm('/b');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: dice+1d6');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).not.toHaveBeenCalled();
expect(testState.publishPostOptions.content).toBe('plain dice');
});
it('shows BBCode controls only for board mods and inserts tags into the post textarea', async () => {
testState.account = {
author: { address: 'mod.eth', displayName: 'Alice' },
@@ -653,7 +832,7 @@ describe('PostForm', () => {
const table = container.querySelector('table');
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const linkInput = textInputs[2];
const linkInput = textInputs[3];
expect(table?.textContent).toContain('no_file_chosen');
@@ -691,7 +870,7 @@ describe('PostForm', () => {
const table = container.querySelector('table');
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const linkInput = textInputs[2];
const linkInput = textInputs[3];
const longFilename = 'TELEMMGLPICT000378070158_17159651831200_trans_NvBQzQNjv4BqpVlberWd9EgFPZtcLiMQf0Rf_Wk3V23H2268P_XkPxc.jpeg';
await dispatchInput(linkInput as HTMLInputElement, `https://www.telegraph.co.uk/multimedia/${longFilename}`);
@@ -761,7 +940,7 @@ describe('PostForm', () => {
expect(container.textContent).toContain('error: empty_comment_alert');
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const linkInput = textInputs[1];
const linkInput = textInputs[2];
expect(linkInput).toBeTruthy();
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
+82 -15
View File
@@ -7,6 +7,15 @@ import getShortAddress from '../../lib/get-short-address';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
import {
type DiceRoll,
type FortuneEntry,
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
isUnsupportedPostOptionsMessage,
} from '../../lib/utils/post-options-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
@@ -112,12 +121,14 @@ interface PostFormFieldsProps {
isBbcodePreviewing: boolean;
postCid: string;
subjectRef: React.Ref<HTMLInputElement>;
optionsRef: React.RefObject<HTMLInputElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
lengthError: string | null;
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleContentValueChange: (content: string) => void;
handleContentValueChange: (content: string, options?: string) => void;
handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
setPublishPostOptions: (opts: Record<string, unknown>) => void;
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
setUrl: (url: string) => void;
@@ -152,12 +163,14 @@ const PostFormFields = ({
isBbcodePreviewing,
postCid,
subjectRef,
optionsRef,
textRef,
urlRef,
url,
lengthError,
handleContentChange,
handleContentValueChange,
handleOptionsChange,
setPublishPostOptions,
setPublishReplyOptions,
setUrl,
@@ -214,6 +227,12 @@ const PostFormFields = ({
/>
</td>
</tr>
<tr>
<td>{t('options')}</td>
<td>
<input type='text' aria-label={t('options')} ref={optionsRef} autoCorrect='off' autoComplete='off' spellCheck='false' onChange={handleOptionsChange} />
</td>
</tr>
{!isInPostView && (
<tr>
<td>{t('subject')}</td>
@@ -333,7 +352,7 @@ const PostFormFields = ({
</tr>
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
<tr className={styles.spoilerButton}>
<td>{t('options')}</td>
<td>{capitalize(t('spoiler'))}</td>
<td>
[
<label>
@@ -398,6 +417,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const textRef = useRef<HTMLTextAreaElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const subjectRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const location = useLocation();
const isInAllView = isAllView(location.pathname);
@@ -409,6 +431,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules';
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
@@ -434,6 +457,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, 1000),
).current;
const checkPostOptions = useRef(
debounce((options: string, directoryCode: string | undefined) => {
const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode);
if (nextOptionsError) {
setFormError(nextOptionsError);
}
}, POST_OPTIONS_VALIDATION_DELAY_MS),
).current;
const resetFields = () => {
if (textRef.current) {
textRef.current.value = '';
@@ -444,20 +476,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
if (subjectRef.current) {
subjectRef.current.value = '';
}
if (optionsRef.current) {
optionsRef.current.value = '';
}
checkContentLength.cancel();
checkPostOptions.cancel();
fortuneEntryRef.current = null;
diceRollRef.current = null;
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
};
const onPublishPost = () => {
const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
if (!currentTitle && !currentContent && !currentUrl) {
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (!currentTitle && !publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
@@ -471,7 +519,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
if (currentContent.length > 2000) {
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
@@ -481,7 +529,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
publishPost();
publishPost({ content: publishContent });
};
// redirect to pending page when pending comment is created
@@ -503,30 +551,39 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
useEffect(() => {
return () => {
checkContentLength.cancel();
checkPostOptions.cancel();
if (isInPostView) {
resetPublishReplyOptions();
} else {
resetPublishPostOptions();
}
};
}, [checkContentLength, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
}, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
const handleContentValueChange = (content: string) => {
const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => {
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
if (isInPostView) {
setPublishReplyOptions({ content });
setPublishReplyOptions({ content: publishContent });
} else {
setPublishPostOptions({ content });
setPublishPostOptions({ content: publishContent });
}
checkContentLength(content, t);
checkContentLength(publishContent, t);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleContentValueChange(e.target.value);
};
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const options = e.target.value;
handleContentValueChange(textRef.current?.value || '', options);
setFormError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError));
checkPostOptions(options, postOptionsDirectoryCode);
};
const handleBbcodePreviewToggle = () => {
if (isBbcodePreviewing) {
setIsBbcodePreviewing(false);
@@ -539,14 +596,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
const onPublishReply = () => {
const currentContent = textRef.current?.value.trim() || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
if (!currentContent && !currentUrl) {
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (!publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
@@ -561,12 +626,12 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
if (currentContent.length > 2000) {
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
publishReply();
publishReply({ content: publishContent });
};
useEffect(() => {
@@ -619,12 +684,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
isBbcodePreviewing={isBbcodePreviewing}
postCid={postCid}
subjectRef={subjectRef}
optionsRef={optionsRef}
textRef={textRef}
urlRef={urlRef}
url={url}
lengthError={lengthError}
handleContentChange={handleContentChange}
handleContentValueChange={handleContentValueChange}
handleOptionsChange={handleOptionsChange}
setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions}
setUrl={setUrl}
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReplyModal from '../reply-modal';
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -15,8 +16,9 @@ const testState = vi.hoisted(() => ({
'music-posting.eth': {
address: 'music-posting.eth',
features: {},
title: '/mu/ - Music',
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
} as Record<string, { address: string; features?: Record<string, unknown>; title?: string }>,
handleUploadMock: vi.fn(),
isMobile: false,
isResolvingExternalQuotes: false,
@@ -128,6 +130,8 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
}));
vi.mock('../../../hooks/use-directories', () => ({
findDirectoryByAddress: (directories: Array<{ address: string; features?: Record<string, unknown>; title?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address),
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
}));
@@ -148,7 +152,12 @@ vi.mock('../../../hooks/use-stable-community', () => ({
vi.mock('../../../hooks/use-publish-reply', () => ({
default: () => ({
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
publishReply: testState.publishReplyMock,
publishReply: (options?: Record<string, unknown>) => {
if (options) {
testState.setPublishReplyOptionsMock(options);
}
return testState.publishReplyMock(options);
},
publishReplyError: testState.publishReplyError,
publishReplyStateMessage: testState.publishReplyStateMessage,
replyIndex: testState.replyIndex,
@@ -177,9 +186,20 @@ vi.mock('../../loading-ellipsis', () => ({
}));
vi.mock('lodash/debounce', () => ({
default: <T extends (...args: any[]) => void>(fn: T) => {
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
wrapped.cancel = () => undefined;
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const wrapped = ((...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), wait);
}) as T & { cancel: () => void };
wrapped.cancel = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = undefined;
};
return wrapped;
},
}));
@@ -269,6 +289,12 @@ const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, va
});
};
const waitForOptionsValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
});
};
const clickButtonByText = async (text: string) => {
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
await act(async () => {
@@ -285,6 +311,22 @@ describe('ReplyModal', () => {
'music-posting.eth': {
address: 'music-posting.eth',
features: {},
title: '/mu/ - Music',
},
'random-nsfw.bso': {
address: 'random-nsfw.bso',
features: {},
title: '/b/ - Random',
},
'silly-stuff.bso': {
address: 'silly-stuff.bso',
features: {},
title: '/s5s/ - Silly Stuff',
},
'traditional-games.bso': {
address: 'traditional-games.bso',
features: {},
title: '/tg/ - Traditional Games',
},
};
testState.handleUploadMock.mockReset();
@@ -325,6 +367,9 @@ describe('ReplyModal', () => {
'music-posting.eth': {
address: 'music-posting.eth',
},
'traditional-games.bso': {
address: 'traditional-games.bso',
},
};
testState.showUploadControls = true;
testState.uploadComplete = undefined;
@@ -349,12 +394,19 @@ describe('ReplyModal', () => {
await renderReplyModal('/mu/thread/post-1');
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
expect(optionsInput).toBeTruthy();
expect(linkInput).toBeTruthy();
expect(textarea).toBeTruthy();
expect(nameInput?.value).toBe('Alice');
expect(optionsInput?.getAttribute('placeholder')).toBe('Options');
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
expect(textarea?.value).toBe('>>42\nselected text');
expect(Boolean(optionsInput!.compareDocumentPosition(textarea!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(Boolean(textarea!.compareDocumentPosition(linkInput!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(container.textContent).toContain('choose_file');
expect(container.textContent).toContain('Spoiler?');
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
@@ -410,7 +462,7 @@ describe('ReplyModal', () => {
expect(container.textContent).toContain('error: empty_comment_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const spoilerCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
await dispatchInput(linkInput, 'not-a-url');
await act(async () => {
@@ -435,6 +487,137 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('validates unsupported options and stores fortune output in reply content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'x y z');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: x, y, z');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'unsupported options: x, y, z');
expect(delayedOptionsError?.className).toContain('error');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: 'reply body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
});
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
randomSpy.mockRestore();
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
delete testState.directoryByAddress['silly-stuff.bso'];
await renderReplyModal('/s5s/thread/post-1', 'silly-stuff.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'fortune');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'silly reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: 'silly reply<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
});
randomSpy.mockRestore();
});
it('stores dice rolls in reply content on dice-enabled boards', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/tg/thread/post-1', 'traditional-games.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'dice+2d6');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'dice reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: '<b>Rolled 2, 2 = 4 (2d6)<br><br></b>dice reply',
});
randomSpy.mockRestore();
});
it('treats dice rolls as unsupported outside /tg/ and /qst/ in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'dice+1d6');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: dice+1d6');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain dice reply' });
});
it('treats fortune as unsupported outside /b/ and /s5s/ in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/mu/thread/post-1', 'music-posting.eth');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: fortune');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain reply' });
});
it('shows BBCode controls only for board mods and inserts tags into the reply textarea', async () => {
testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
testState.rolesByCommunity = {
@@ -476,7 +659,7 @@ describe('ReplyModal', () => {
await renderReplyModal('/mu/thread/post-1');
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(nameInput, 'Alicia');
expect(testState.setAccountMock).toHaveBeenCalledWith({
@@ -533,7 +716,7 @@ describe('ReplyModal', () => {
await renderReplyModal('/all/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
expect(container.textContent).not.toContain('warning');
expect(container.textContent).not.toContain('Spoiler?');
+90 -25
View File
@@ -4,6 +4,15 @@ import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
import {
type DiceRoll,
type FortuneEntry,
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
isUnsupportedPostOptionsMessage,
} from '../../lib/utils/post-options-utils';
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
@@ -47,6 +56,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const directoryEntry = useDirectoryByAddress(communityAddress);
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
@@ -73,6 +83,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}, 0);
});
const urlRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = useRef(0);
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
@@ -100,11 +113,33 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}, 1000),
);
const onPublishReply = () => {
const currentContent = textRef.current?.value.trim() || '';
const currentUrl = urlRef.current?.value.trim() || '';
const checkPostOptionsRef = useRef(
debounce((options: string, directoryCode: string | undefined) => {
const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode);
if (nextOptionsError) {
setLengthError(null);
setError(nextOptionsError);
}
}, POST_OPTIONS_VALIDATION_DELAY_MS),
);
if (!currentContent && !currentUrl) {
const onPublishReply = () => {
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
setLengthError(null);
if (currentOptionsError) {
setError(currentOptionsError);
return;
}
if (!publishContent.trim() && !currentUrl) {
setError(t('error') + ': ' + t('empty_comment_alert'));
return;
}
@@ -119,16 +154,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
return;
}
checkContentLengthRef.current.cancel();
setLengthError(null);
if (currentContent.length > 2000) {
if (publishContent.trim().length > 2000) {
setError(t('error') + ': ' + t('field_too_long'));
return;
}
setError(null);
publishReply();
publishReply({ content: publishContent });
};
useEffect(() => {
@@ -194,6 +226,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
useEffect(() => {
return () => {
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
restoreBodyTextSelection();
};
}, []);
@@ -248,8 +282,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
lastSelectionStartRef.current = len;
lastSelectionEndRef.current = len;
const content = textRef.current.value;
setPublishReplyOptions({ content });
checkContentLengthRef.current(content, t);
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
const spellcheckTimeout = window.setTimeout(() => {
if (textRef.current) {
@@ -265,17 +300,26 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
useEffect(() => {
if (!showReplyModal) {
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
}
}, [showReplyModal]);
useEffect(() => {
if (!showReplyModal) {
fortuneEntryRef.current = null;
diceRollRef.current = null;
}
}, [showReplyModal]);
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current;
};
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number) => {
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => {
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
@@ -283,8 +327,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
lastSelectionStartRef.current = selectionStart;
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
}
setPublishReplyOptions({ content });
checkContentLengthRef.current(content, t);
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
};
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const options = e.target.value;
handleContentValueChange(textRef.current?.value || '', undefined, undefined, options);
setError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError));
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -341,8 +393,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
lastSelectionStartRef.current = nextCursor;
lastSelectionEndRef.current = nextCursor;
setPublishReplyOptions({ content: nextValue });
checkContentLengthRef.current(nextValue, t);
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
@@ -407,17 +460,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}}
/>
</div>
<div className={styles.link}>
<div className={styles.options}>
<input
type='text'
ref={urlRef}
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading}
onChange={(e) => {
setUrl(e.target.value);
setPublishReplyOptions({ link: e.target.value });
}}
ref={optionsRef}
aria-label={t('options')}
placeholder={capitalize(t('options'))}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
onChange={handleOptionsChange}
/>
</div>
<div className={styles.content}>
@@ -450,6 +502,19 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}}
/>
</div>
<div className={styles.link}>
<input
type='text'
ref={urlRef}
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading}
onChange={(e) => {
setUrl(e.target.value);
setPublishReplyOptions({ link: e.target.value });
}}
/>
</div>
<div className={styles.footer}>
{showUploadControls && (
<span className={styles.uploadContainer}>
@@ -137,4 +137,22 @@ describe('usePublishPost', () => {
expect(latestValue.publishPostError).toBe('blocked:unresolved');
expect(testState.publishCommentMock).not.toHaveBeenCalled();
});
it('publishes after synchronizing one-shot publish options', async () => {
await act(async () => {
latestValue.setPublishPostOptions({
content: 'Old body',
} as never);
});
await act(async () => {
latestValue.publishPost({
content: 'Fresh body',
} as never);
await Promise.resolve();
});
expect(testState.lastPublishOptions?.content).toBe('Fresh body');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
});
@@ -167,6 +167,25 @@ describe('usePublishReply', () => {
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('publishes after synchronizing one-shot reply options', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
content: 'Old reply',
} as never);
});
await act(async () => {
await latestValue.publishReply({
content: 'Fresh reply',
} as never);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(testState.lastPublishOptions?.content).toBe('Fresh reply');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('blocks publish when a same-board external quote cannot be resolved', async () => {
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
+25 -1
View File
@@ -26,6 +26,8 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
const addChallenge = useChallengesStore((state) => state.addChallenge);
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
const [publishPostError, setPublishPostError] = useState<string | null>(null);
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
const startedPublishRequestIdRef = useRef(0);
const { blockedReason } = usePublishAuthorDomainGuard();
const abandonCurrentPublish = useCallback(async () => {
await abandonPublishRef.current?.();
@@ -90,7 +92,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
setPublishPostError(null);
}, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]);
const publishPost = useCallback(() => {
const startPublishPost = useCallback(() => {
if (blockedReason) {
setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason));
return;
@@ -100,6 +102,28 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
return publishComment();
}, [blockedReason, publishComment]);
useEffect(() => {
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
return;
}
startedPublishRequestIdRef.current = pendingPublishRequestId;
startPublishPost();
}, [pendingPublishRequestId, startPublishPost]);
const publishPost = useCallback(
(options?: Partial<Comment>) => {
if (options) {
setPublishPostOptions(options);
setPendingPublishRequestId((requestId) => requestId + 1);
return;
}
return startPublishPost();
},
[setPublishPostOptions, startPublishPost],
);
return {
setPublishPostOptions,
resetPublishPostOptions,
+75 -55
View File
@@ -41,6 +41,8 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
const startedPublishRequestIdRef = useRef(0);
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
const [pendingSyncedPublishRequestId, setPendingSyncedPublishRequestId] = useState(0);
const startedSyncedPublishRequestIdRef = useRef(0);
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
@@ -151,64 +153,82 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
publishComment();
}, [pendingPublishRequestId, publishComment]);
const publishReply = useCallback(async () => {
setPublishReplyError(null);
if (blockedReason) {
setPublishReplyStateMessage(null);
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
return;
}
if (publishResolvableQuoteReferences.length === 0) {
setResolvedExternalQuotedCids(undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
return;
}
if (!account?.id) {
setPublishReplyError(t('external_quote_resolution_unavailable'));
return;
}
setIsResolvingExternalQuotes(true);
try {
const resolvedCids = new Set<string>();
for (const reference of publishResolvableQuoteReferences) {
const resolvedTarget = await resolveExternalQuoteTarget({
account,
directories,
onStatus: (status) => {
setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status));
},
reference,
});
if (!resolvedTarget?.cid) {
setPublishReplyError(
t('external_quote_publish_missing', {
interpolation: { escapeValue: false },
quote: reference.raw,
}),
);
return;
}
resolvedCids.add(resolvedTarget.cid);
const publishReply = useCallback(
async (options?: Partial<Comment>) => {
if (options) {
setPublishReplyOptions(options);
setPendingSyncedPublishRequestId((requestId) => requestId + 1);
return;
}
setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
} catch {
setPublishReplyError(t('external_quote_resolution_unavailable'));
} finally {
setIsResolvingExternalQuotes(false);
setPublishReplyError(null);
if (blockedReason) {
setPublishReplyStateMessage(null);
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
return;
}
if (publishResolvableQuoteReferences.length === 0) {
setResolvedExternalQuotedCids(undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
return;
}
if (!account?.id) {
setPublishReplyError(t('external_quote_resolution_unavailable'));
return;
}
setIsResolvingExternalQuotes(true);
try {
const resolvedCids = new Set<string>();
for (const reference of publishResolvableQuoteReferences) {
const resolvedTarget = await resolveExternalQuoteTarget({
account,
directories,
onStatus: (status) => {
setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status));
},
reference,
});
if (!resolvedTarget?.cid) {
setPublishReplyError(
t('external_quote_publish_missing', {
interpolation: { escapeValue: false },
quote: reference.raw,
}),
);
return;
}
resolvedCids.add(resolvedTarget.cid);
}
setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
} catch {
setPublishReplyError(t('external_quote_resolution_unavailable'));
} finally {
setIsResolvingExternalQuotes(false);
}
},
[account, blockedReason, directories, publishResolvableQuoteReferences, setPublishReplyOptions, t],
);
useEffect(() => {
if (pendingSyncedPublishRequestId === 0 || pendingSyncedPublishRequestId === startedSyncedPublishRequestIdRef.current) {
return;
}
}, [account, blockedReason, directories, publishResolvableQuoteReferences, t]);
startedSyncedPublishRequestIdRef.current = pendingSyncedPublishRequestId;
publishReply();
}, [pendingSyncedPublishRequestId, publishReply]);
return {
isResolvingExternalQuotes,
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { getUnsupportedPostOptionsMessage } from '../post-options-utils';
describe('post-options-utils', () => {
it('rejects additional dice options instead of dropping them', () => {
expect(getUnsupportedPostOptionsMessage('dice+1d6 dice+1d20', 'qst')).toBe('unsupported options: dice+1d20');
});
});
+202
View File
@@ -0,0 +1,202 @@
export const POST_OPTIONS_VALIDATION_DELAY_MS = 700;
const FORTUNE_DIRECTORY_CODES = new Set(['b', 's5s']);
const DICE_DIRECTORY_CODES = new Set(['qst', 'tg']);
const POST_OPTION_ROUTE_DIRECTORY_CODES = new Set([...FORTUNE_DIRECTORY_CODES, ...DICE_DIRECTORY_CODES]);
const MAX_DICE_COUNT = 25;
export interface FortuneEntry {
color: string;
text: string;
}
export interface DiceRoll {
option: string;
count: number;
sides: number;
rolls: number[];
modifier: number;
modifierText: string;
total: number;
}
interface PostOptionsDirectory {
directoryCode?: string;
title?: string;
}
interface PostOptionsStateRef<T> {
current: T;
}
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;
};
const FORTUNE_ENTRIES: readonly FortuneEntry[] = [
{ text: 'Bad Luck', color: '#7fec11' },
{ text: 'Average Luck', color: '#bac200' },
{ text: 'Good Luck', color: '#e7890c' },
{ text: 'Excellent Luck', color: '#fd4d32' },
{ text: 'Reply hazy, try again', color: '#f51c6a' },
{ text: 'Godly Luck', color: '#d302a7' },
{ text: 'Very Bad Luck', color: '#9d05da' },
{ text: 'Outlook good', color: '#6023f8' },
{ text: 'Better not tell you now', color: '#2a56fb' },
{ text: 'You will meet a dark handsome stranger', color: '#0893e1' },
{ text: 'キタ━━━━━━(゚∀゚)━━━━━━ !!!!', color: '#00cbb0' },
{ text: ' ´_ゝ`)フーン ', color: '#16f174' },
{ text: 'Good news will come to you by mail', color: '#43fd3b' },
];
const parsePostOptions = (value: string): string[] => value.trim().split(/\s+/).filter(Boolean);
const parseDiceOption = (option: string): { count: number; sides: number; modifier: number; modifierText: string; option: string } | null => {
const match = option.match(/^dice\+(\d+)d(\d+)(?:([+-])(\d+))?$/);
if (!match) return null;
const rawCount = Number(match[1]);
const sides = Number(match[2]);
if (!Number.isInteger(rawCount) || !Number.isInteger(sides) || rawCount < 1 || sides < 1) {
return null;
}
const count = Math.min(MAX_DICE_COUNT, rawCount);
const modifierValue = match[4] ? Number(match[4]) : 0;
const modifier = match[3] === '-' ? -modifierValue : modifierValue;
const modifierText = match[3] ? ` ${match[3]} ${modifierValue}` : '';
return { count, sides, modifier, modifierText, option };
};
export const getPostOptionsDirectoryCode = (directory: PostOptionsDirectory | null | undefined, pathname?: string): string | undefined => {
if (directory?.directoryCode) {
return directory.directoryCode;
}
return directory?.title?.match(/^\/([^/]+)\//)?.[1] ?? getRouteDirectoryCode(pathname);
};
const isSupportedPostOption = (option: string, directoryCode: string | undefined): boolean => {
if (option === 'fortune') {
return !!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode);
}
return !!parseDiceOption(option) && !!directoryCode && DICE_DIRECTORY_CODES.has(directoryCode);
};
const getUnsupportedPostOptions = (value: string, directoryCode: string | undefined): string[] => {
let hasDiceOption = false;
return parsePostOptions(value).filter((option) => {
const diceOption = parseDiceOption(option);
if (diceOption && directoryCode && DICE_DIRECTORY_CODES.has(directoryCode)) {
const isExtraDiceOption = hasDiceOption;
hasDiceOption = true;
return isExtraDiceOption;
}
return !isSupportedPostOption(option, directoryCode);
});
};
export const getUnsupportedPostOptionsMessage = (value: string, directoryCode: string | undefined): string | null => {
const unsupportedOptions = getUnsupportedPostOptions(value, directoryCode);
return unsupportedOptions.length > 0 ? `unsupported options: ${unsupportedOptions.join(', ')}` : null;
};
export const isUnsupportedPostOptionsMessage = (message: string | null): boolean => message?.startsWith('unsupported options:') === true;
const hasFortuneOption = (value: string, directoryCode: string | undefined): boolean =>
!!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode) && parsePostOptions(value).includes('fortune');
const getDiceOption = (value: string, directoryCode: string | undefined): ReturnType<typeof parseDiceOption> => {
if (!directoryCode || !DICE_DIRECTORY_CODES.has(directoryCode)) {
return null;
}
for (const option of parsePostOptions(value)) {
const diceOption = parseDiceOption(option);
if (diceOption) {
return diceOption;
}
}
return null;
};
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 appendFortuneToContent = (content: string, fortune: FortuneEntry): string => `${content}${getFortuneMarkup(fortune)}`;
const rollDice = (diceOption: NonNullable<ReturnType<typeof parseDiceOption>>, currentDiceRoll: DiceRoll | null): DiceRoll => {
if (currentDiceRoll?.option === diceOption.option) {
return currentDiceRoll;
}
const rolls = Array.from({ length: diceOption.count }, () => Math.floor(Math.random() * diceOption.sides) + 1);
const total = rolls.reduce((sum, roll) => sum + roll, 0) + diceOption.modifier;
return {
option: diceOption.option,
count: diceOption.count,
sides: diceOption.sides,
rolls,
modifier: diceOption.modifier,
modifierText: diceOption.modifierText,
total,
};
};
const getDiceRollText = (diceRoll: DiceRoll): string => {
const rolls = diceRoll.rolls.join(', ');
const total = diceRoll.count > 1 || diceRoll.modifier !== 0 ? ` = ${diceRoll.total}` : '';
return `Rolled ${rolls}${diceRoll.modifierText}${total} (${diceRoll.count}d${diceRoll.sides}${diceRoll.modifierText})`;
};
const getDiceRollMarkup = (diceRoll: DiceRoll): string => `<b>${getDiceRollText(diceRoll)}<br><br></b>`;
const prependDiceRollToContent = (content: string, diceRoll: DiceRoll): string => `${getDiceRollMarkup(diceRoll)}${content}`;
const getContentWithPostOptions = (
content: string,
options: string,
currentFortuneEntry: FortuneEntry | null,
currentDiceRoll: DiceRoll | null,
directoryCode: string | undefined,
): { 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;
if (!hasFortuneOption(options, directoryCode)) {
return { content: nextContent, fortuneEntry: null, diceRoll };
}
const fortuneEntry = currentFortuneEntry || getRandomFortuneEntry();
nextContent = appendFortuneToContent(nextContent, fortuneEntry);
return { content: nextContent, fortuneEntry, diceRoll };
};
export const getContentWithPostOptionState = (
content: string,
options: string,
fortuneEntryRef: PostOptionsStateRef<FortuneEntry | null>,
diceRollRef: PostOptionsStateRef<DiceRoll | null>,
directoryCode: string | undefined,
): string => {
const result = getContentWithPostOptions(content, options, fortuneEntryRef.current, diceRollRef.current, directoryCode);
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 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 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);