mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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 },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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?');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user