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),');
|
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 () => {
|
it('renders number quote links with op and unavailable state derived from cached comments', async () => {
|
||||||
testState.comments = {
|
testState.comments = {
|
||||||
'comment-42': { cid: 'comment-42', number: 42 },
|
'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 { useComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview';
|
||||||
import ExternalNumberQuoteLink from './external-number-quote-link';
|
import ExternalNumberQuoteLink from './external-number-quote-link';
|
||||||
|
import { createDiceRollMarkupRegex, createFortuneMarkupRegex, getMatchingFortuneEntry } from '../../lib/utils/post-options-utils';
|
||||||
|
|
||||||
const safeParseUrl = (href: string): URL | null => {
|
const safeParseUrl = (href: string): URL | null => {
|
||||||
try {
|
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 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 QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
|
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(
|
const COMBINED_REGEX = new RegExp(
|
||||||
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
|
`(${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;
|
isInCatalogView: boolean;
|
||||||
postCid?: string;
|
postCid?: string;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
|
enableQstBbcode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
@@ -398,7 +412,7 @@ const TokenNode = ({ token, context }: { token: Token; context: RenderContext })
|
|||||||
|
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return <>{token.value}</>;
|
return context.enableQstBbcode ? <QstBbcodeText text={token.value} tokenKey={token.key} /> : <>{token.value}</>;
|
||||||
case 'url': {
|
case 'url': {
|
||||||
const href = token.href;
|
const href = token.href;
|
||||||
const linkMediaInfo = getLinkMediaInfo(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 Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||||
|
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
|
||||||
|
|
||||||
const rendered = useMemo(() => {
|
const rendered = useMemo(() => {
|
||||||
const normalized = normalizeContent(content || '');
|
const normalized = normalizeContent(content || '');
|
||||||
const lines = normalized.split('\n');
|
const lines = normalized.split('\n');
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
|
|
||||||
const context = { isInCatalogView, postCid, communityAddress };
|
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
|
||||||
|
|
||||||
lines.forEach((line, lineIndex) => {
|
lines.forEach((line, lineIndex) => {
|
||||||
if (lineIndex > 0) {
|
if (lineIndex > 0) {
|
||||||
@@ -466,8 +620,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
|||||||
|
|
||||||
const isGreentext = isGreentextLine(line);
|
const isGreentext = isGreentextLine(line);
|
||||||
|
|
||||||
const tokens = tokenize(line);
|
const lineElements = renderLineContent(line, context);
|
||||||
const lineElements = <TokenList tokens={tokens} context={context} />;
|
|
||||||
|
|
||||||
if (isGreentext) {
|
if (isGreentext) {
|
||||||
elements.push(
|
elements.push(
|
||||||
@@ -481,7 +634,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}, [content, isInCatalogView, postCid, communityAddress]);
|
}, [content, isInCatalogView, postCid, communityAddress, enableQstBbcode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.markdown}>
|
<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 { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import PostForm, { LinkTypePreviewer } from '../post-form';
|
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;
|
(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>;
|
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', () => ({
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
|
findDirectoryByAddress: (directories: typeof testState.directories, address: string | undefined) => directories.find((entry) => entry.address === address),
|
||||||
useDirectories: () => testState.directories,
|
useDirectories: () => testState.directories,
|
||||||
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
||||||
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
@@ -159,10 +161,24 @@ vi.mock('../../../hooks/use-publish-post', async () => {
|
|||||||
}),
|
}),
|
||||||
[communityAddress],
|
[communityAddress],
|
||||||
);
|
);
|
||||||
const publishPost = React.useCallback(() => {
|
const publishPost = React.useCallback(
|
||||||
testState.publishedPostOptions = getPublishPostOptions();
|
(options?: Record<string, unknown>) => {
|
||||||
return testState.publishPostMock();
|
const sanitizedOptions = Object.entries(options || {}).reduce(
|
||||||
}, [getPublishPostOptions]);
|
(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(() => {
|
const resetPublishPostOptions = React.useCallback(() => {
|
||||||
testState.publishPostOptions = {};
|
testState.publishPostOptions = {};
|
||||||
testState.resetPublishPostOptionsMock();
|
testState.resetPublishPostOptionsMock();
|
||||||
@@ -209,7 +225,13 @@ vi.mock('../../../hooks/use-publish-reply', async () => {
|
|||||||
postCid: postCid ?? cid,
|
postCid: postCid ?? cid,
|
||||||
communityAddress,
|
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 resetPublishReplyOptions = React.useCallback(() => testState.resetPublishReplyOptionsMock(), []);
|
||||||
const setPublishReplyOptions = React.useCallback((options: Record<string, unknown>) => {
|
const setPublishReplyOptions = React.useCallback((options: Record<string, unknown>) => {
|
||||||
testState.setPublishReplyOptionsMock(options);
|
testState.setPublishReplyOptionsMock(options);
|
||||||
@@ -274,9 +296,20 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('lodash/debounce', () => ({
|
vi.mock('lodash/debounce', () => ({
|
||||||
default: <T extends (...args: any[]) => void>(fn: T) => {
|
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
|
||||||
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
wrapped.cancel = () => 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;
|
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) => {
|
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
if (typeof value === 'boolean' && 'checked' in element) {
|
if (typeof value === 'boolean' && 'checked' in element) {
|
||||||
@@ -385,6 +424,9 @@ describe('PostForm', () => {
|
|||||||
testState.comments = {};
|
testState.comments = {};
|
||||||
testState.directories = [
|
testState.directories = [
|
||||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
{ 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' },
|
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||||
];
|
];
|
||||||
testState.editedComment = undefined;
|
testState.editedComment = undefined;
|
||||||
@@ -408,6 +450,7 @@ describe('PostForm', () => {
|
|||||||
testState.uploadedFileName = 'picked.png';
|
testState.uploadedFileName = 'picked.png';
|
||||||
testState.communities = {
|
testState.communities = {
|
||||||
'music-posting.eth': { address: 'music-posting.eth' },
|
'music-posting.eth': { address: 'music-posting.eth' },
|
||||||
|
'traditional-games.bso': { address: 'traditional-games.bso' },
|
||||||
};
|
};
|
||||||
testState.handleUploadMock.mockReset();
|
testState.handleUploadMock.mockReset();
|
||||||
testState.navigateMock.mockReset();
|
testState.navigateMock.mockReset();
|
||||||
@@ -480,12 +523,14 @@ describe('PostForm', () => {
|
|||||||
|
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||||
const nameInput = textInputs[0];
|
const nameInput = textInputs[0];
|
||||||
const subjectInput = textInputs[1];
|
const optionsInput = textInputs[1];
|
||||||
const linkInput = textInputs[2];
|
const subjectInput = textInputs[2];
|
||||||
|
const linkInput = textInputs[3];
|
||||||
const textarea = table?.querySelector('textarea');
|
const textarea = table?.querySelector('textarea');
|
||||||
const select = table?.querySelector('select');
|
const select = table?.querySelector('select');
|
||||||
|
|
||||||
expect(nameInput).toBeTruthy();
|
expect(nameInput).toBeTruthy();
|
||||||
|
expect(optionsInput?.getAttribute('aria-label')).toBe('options');
|
||||||
expect(subjectInput).toBeTruthy();
|
expect(subjectInput).toBeTruthy();
|
||||||
expect(linkInput).toBeTruthy();
|
expect(linkInput).toBeTruthy();
|
||||||
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
||||||
@@ -546,7 +591,7 @@ describe('PostForm', () => {
|
|||||||
table = container.querySelector('table');
|
table = container.querySelector('table');
|
||||||
textarea = table?.querySelector('textarea');
|
textarea = table?.querySelector('textarea');
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||||
const linkInput = textInputs[2];
|
const linkInput = textInputs[3];
|
||||||
|
|
||||||
expect(textarea?.value).toBe('');
|
expect(textarea?.value).toBe('');
|
||||||
expect(linkInput).toBeTruthy();
|
expect(linkInput).toBeTruthy();
|
||||||
@@ -559,6 +604,140 @@ describe('PostForm', () => {
|
|||||||
expect(testState.publishedPostOptions?.content).toBeUndefined();
|
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 () => {
|
it('shows BBCode controls only for board mods and inserts tags into the post textarea', async () => {
|
||||||
testState.account = {
|
testState.account = {
|
||||||
author: { address: 'mod.eth', displayName: 'Alice' },
|
author: { address: 'mod.eth', displayName: 'Alice' },
|
||||||
@@ -653,7 +832,7 @@ describe('PostForm', () => {
|
|||||||
|
|
||||||
const table = container.querySelector('table');
|
const table = container.querySelector('table');
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||||
const linkInput = textInputs[2];
|
const linkInput = textInputs[3];
|
||||||
|
|
||||||
expect(table?.textContent).toContain('no_file_chosen');
|
expect(table?.textContent).toContain('no_file_chosen');
|
||||||
|
|
||||||
@@ -691,7 +870,7 @@ describe('PostForm', () => {
|
|||||||
|
|
||||||
const table = container.querySelector('table');
|
const table = container.querySelector('table');
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
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';
|
const longFilename = 'TELEMMGLPICT000378070158_17159651831200_trans_NvBQzQNjv4BqpVlberWd9EgFPZtcLiMQf0Rf_Wk3V23H2268P_XkPxc.jpeg';
|
||||||
|
|
||||||
await dispatchInput(linkInput as HTMLInputElement, `https://www.telegraph.co.uk/multimedia/${longFilename}`);
|
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');
|
expect(container.textContent).toContain('error: empty_comment_alert');
|
||||||
|
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||||
const linkInput = textInputs[1];
|
const linkInput = textInputs[2];
|
||||||
expect(linkInput).toBeTruthy();
|
expect(linkInput).toBeTruthy();
|
||||||
|
|
||||||
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
|
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 useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||||
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||||
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-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 { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
|
||||||
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
|
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
|
||||||
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
||||||
@@ -112,12 +121,14 @@ interface PostFormFieldsProps {
|
|||||||
isBbcodePreviewing: boolean;
|
isBbcodePreviewing: boolean;
|
||||||
postCid: string;
|
postCid: string;
|
||||||
subjectRef: React.Ref<HTMLInputElement>;
|
subjectRef: React.Ref<HTMLInputElement>;
|
||||||
|
optionsRef: React.RefObject<HTMLInputElement>;
|
||||||
textRef: React.RefObject<HTMLTextAreaElement>;
|
textRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
urlRef: React.Ref<HTMLInputElement>;
|
urlRef: React.Ref<HTMLInputElement>;
|
||||||
url: string;
|
url: string;
|
||||||
lengthError: string | null;
|
lengthError: string | null;
|
||||||
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
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;
|
setPublishPostOptions: (opts: Record<string, unknown>) => void;
|
||||||
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
|
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
|
||||||
setUrl: (url: string) => void;
|
setUrl: (url: string) => void;
|
||||||
@@ -152,12 +163,14 @@ const PostFormFields = ({
|
|||||||
isBbcodePreviewing,
|
isBbcodePreviewing,
|
||||||
postCid,
|
postCid,
|
||||||
subjectRef,
|
subjectRef,
|
||||||
|
optionsRef,
|
||||||
textRef,
|
textRef,
|
||||||
urlRef,
|
urlRef,
|
||||||
url,
|
url,
|
||||||
lengthError,
|
lengthError,
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
handleContentValueChange,
|
handleContentValueChange,
|
||||||
|
handleOptionsChange,
|
||||||
setPublishPostOptions,
|
setPublishPostOptions,
|
||||||
setPublishReplyOptions,
|
setPublishReplyOptions,
|
||||||
setUrl,
|
setUrl,
|
||||||
@@ -214,6 +227,12 @@ const PostFormFields = ({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 && (
|
{!isInPostView && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('subject')}</td>
|
<td>{t('subject')}</td>
|
||||||
@@ -333,7 +352,7 @@ const PostFormFields = ({
|
|||||||
</tr>
|
</tr>
|
||||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||||
<tr className={styles.spoilerButton}>
|
<tr className={styles.spoilerButton}>
|
||||||
<td>{t('options')}</td>
|
<td>{capitalize(t('spoiler'))}</td>
|
||||||
<td>
|
<td>
|
||||||
[
|
[
|
||||||
<label>
|
<label>
|
||||||
@@ -398,6 +417,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
const subjectRef = 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 location = useLocation();
|
||||||
const isInAllView = isAllView(location.pathname);
|
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 rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules';
|
||||||
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
|
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
|
|
||||||
@@ -434,6 +457,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}, 1000),
|
}, 1000),
|
||||||
).current;
|
).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 = () => {
|
const resetFields = () => {
|
||||||
if (textRef.current) {
|
if (textRef.current) {
|
||||||
textRef.current.value = '';
|
textRef.current.value = '';
|
||||||
@@ -444,20 +476,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
if (subjectRef.current) {
|
if (subjectRef.current) {
|
||||||
subjectRef.current.value = '';
|
subjectRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
if (optionsRef.current) {
|
||||||
|
optionsRef.current.value = '';
|
||||||
|
}
|
||||||
|
checkContentLength.cancel();
|
||||||
|
checkPostOptions.cancel();
|
||||||
|
fortuneEntryRef.current = null;
|
||||||
|
diceRollRef.current = null;
|
||||||
setIsBbcodePreviewing(false);
|
setIsBbcodePreviewing(false);
|
||||||
setBbcodePreviewContent('');
|
setBbcodePreviewContent('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPublishPost = () => {
|
const onPublishPost = () => {
|
||||||
const currentTitle = subjectRef.current?.value.trim() || '';
|
const currentTitle = subjectRef.current?.value.trim() || '';
|
||||||
const currentContent = textRef.current?.value.trim() || '';
|
const currentContent = textRef.current?.value || '';
|
||||||
const currentUrl = urlRef.current?.value.trim() || '';
|
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();
|
checkContentLength.cancel();
|
||||||
|
checkPostOptions.cancel();
|
||||||
setLengthError(null);
|
setLengthError(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
if (!currentTitle && !currentContent && !currentUrl) {
|
if (currentOptionsError) {
|
||||||
|
setFormError(currentOptionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTitle && !publishContent.trim() && !currentUrl) {
|
||||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -471,7 +519,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentContent.length > 2000) {
|
if (publishContent.trim().length > 2000) {
|
||||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -481,7 +529,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
publishPost();
|
publishPost({ content: publishContent });
|
||||||
};
|
};
|
||||||
|
|
||||||
// redirect to pending page when pending comment is created
|
// redirect to pending page when pending comment is created
|
||||||
@@ -503,30 +551,39 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
checkContentLength.cancel();
|
checkContentLength.cancel();
|
||||||
|
checkPostOptions.cancel();
|
||||||
if (isInPostView) {
|
if (isInPostView) {
|
||||||
resetPublishReplyOptions();
|
resetPublishReplyOptions();
|
||||||
} else {
|
} else {
|
||||||
resetPublishPostOptions();
|
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) {
|
if (isBbcodePreviewing) {
|
||||||
setBbcodePreviewContent(content);
|
setBbcodePreviewContent(content);
|
||||||
}
|
}
|
||||||
if (isInPostView) {
|
if (isInPostView) {
|
||||||
setPublishReplyOptions({ content });
|
setPublishReplyOptions({ content: publishContent });
|
||||||
} else {
|
} else {
|
||||||
setPublishPostOptions({ content });
|
setPublishPostOptions({ content: publishContent });
|
||||||
}
|
}
|
||||||
checkContentLength(content, t);
|
checkContentLength(publishContent, t);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
handleContentValueChange(e.target.value);
|
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 = () => {
|
const handleBbcodePreviewToggle = () => {
|
||||||
if (isBbcodePreviewing) {
|
if (isBbcodePreviewing) {
|
||||||
setIsBbcodePreviewing(false);
|
setIsBbcodePreviewing(false);
|
||||||
@@ -539,14 +596,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPublishReply = () => {
|
const onPublishReply = () => {
|
||||||
const currentContent = textRef.current?.value.trim() || '';
|
|
||||||
const currentUrl = urlRef.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();
|
checkContentLength.cancel();
|
||||||
|
checkPostOptions.cancel();
|
||||||
setLengthError(null);
|
setLengthError(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
if (!currentContent && !currentUrl) {
|
if (currentOptionsError) {
|
||||||
|
setFormError(currentOptionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publishContent.trim() && !currentUrl) {
|
||||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -561,12 +626,12 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentContent.length > 2000) {
|
if (publishContent.trim().length > 2000) {
|
||||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
publishReply();
|
publishReply({ content: publishContent });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -619,12 +684,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
isBbcodePreviewing={isBbcodePreviewing}
|
isBbcodePreviewing={isBbcodePreviewing}
|
||||||
postCid={postCid}
|
postCid={postCid}
|
||||||
subjectRef={subjectRef}
|
subjectRef={subjectRef}
|
||||||
|
optionsRef={optionsRef}
|
||||||
textRef={textRef}
|
textRef={textRef}
|
||||||
urlRef={urlRef}
|
urlRef={urlRef}
|
||||||
url={url}
|
url={url}
|
||||||
lengthError={lengthError}
|
lengthError={lengthError}
|
||||||
handleContentChange={handleContentChange}
|
handleContentChange={handleContentChange}
|
||||||
handleContentValueChange={handleContentValueChange}
|
handleContentValueChange={handleContentValueChange}
|
||||||
|
handleOptionsChange={handleOptionsChange}
|
||||||
setPublishPostOptions={setPublishPostOptions}
|
setPublishPostOptions={setPublishPostOptions}
|
||||||
setPublishReplyOptions={setPublishReplyOptions}
|
setPublishReplyOptions={setPublishReplyOptions}
|
||||||
setUrl={setUrl}
|
setUrl={setUrl}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
|||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import ReplyModal from '../reply-modal';
|
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;
|
(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>;
|
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': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
features: {},
|
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(),
|
handleUploadMock: vi.fn(),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isResolvingExternalQuotes: false,
|
isResolvingExternalQuotes: false,
|
||||||
@@ -128,6 +130,8 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-directories', () => ({
|
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],
|
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
||||||
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
}));
|
}));
|
||||||
@@ -148,7 +152,12 @@ vi.mock('../../../hooks/use-stable-community', () => ({
|
|||||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||||
publishReply: testState.publishReplyMock,
|
publishReply: (options?: Record<string, unknown>) => {
|
||||||
|
if (options) {
|
||||||
|
testState.setPublishReplyOptionsMock(options);
|
||||||
|
}
|
||||||
|
return testState.publishReplyMock(options);
|
||||||
|
},
|
||||||
publishReplyError: testState.publishReplyError,
|
publishReplyError: testState.publishReplyError,
|
||||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||||
replyIndex: testState.replyIndex,
|
replyIndex: testState.replyIndex,
|
||||||
@@ -177,9 +186,20 @@ vi.mock('../../loading-ellipsis', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('lodash/debounce', () => ({
|
vi.mock('lodash/debounce', () => ({
|
||||||
default: <T extends (...args: any[]) => void>(fn: T) => {
|
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
|
||||||
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
wrapped.cancel = () => 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;
|
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 clickButtonByText = async (text: string) => {
|
||||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -285,6 +311,22 @@ describe('ReplyModal', () => {
|
|||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
features: {},
|
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();
|
testState.handleUploadMock.mockReset();
|
||||||
@@ -325,6 +367,9 @@ describe('ReplyModal', () => {
|
|||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
},
|
},
|
||||||
|
'traditional-games.bso': {
|
||||||
|
address: 'traditional-games.bso',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
testState.showUploadControls = true;
|
testState.showUploadControls = true;
|
||||||
testState.uploadComplete = undefined;
|
testState.uploadComplete = undefined;
|
||||||
@@ -349,12 +394,19 @@ describe('ReplyModal', () => {
|
|||||||
await renderReplyModal('/mu/thread/post-1');
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
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');
|
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||||
|
|
||||||
|
expect(optionsInput).toBeTruthy();
|
||||||
|
expect(linkInput).toBeTruthy();
|
||||||
|
expect(textarea).toBeTruthy();
|
||||||
expect(nameInput?.value).toBe('Alice');
|
expect(nameInput?.value).toBe('Alice');
|
||||||
|
expect(optionsInput?.getAttribute('placeholder')).toBe('Options');
|
||||||
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
|
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
|
||||||
expect(textarea?.value).toBe('>>42\nselected text');
|
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('choose_file');
|
||||||
expect(container.textContent).toContain('Spoiler?');
|
expect(container.textContent).toContain('Spoiler?');
|
||||||
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
|
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(container.textContent).toContain('error: empty_comment_alert');
|
||||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
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"]');
|
const spoilerCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
await dispatchInput(linkInput, 'not-a-url');
|
await dispatchInput(linkInput, 'not-a-url');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -435,6 +487,137 @@ describe('ReplyModal', () => {
|
|||||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
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.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
|
||||||
testState.rolesByCommunity = {
|
testState.rolesByCommunity = {
|
||||||
@@ -476,7 +659,7 @@ describe('ReplyModal', () => {
|
|||||||
await renderReplyModal('/mu/thread/post-1');
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
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');
|
await dispatchInput(nameInput, 'Alicia');
|
||||||
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
||||||
@@ -533,7 +716,7 @@ describe('ReplyModal', () => {
|
|||||||
|
|
||||||
await renderReplyModal('/all/thread/post-1');
|
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(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
||||||
expect(container.textContent).not.toContain('warning');
|
expect(container.textContent).not.toContain('warning');
|
||||||
expect(container.textContent).not.toContain('Spoiler?');
|
expect(container.textContent).not.toContain('Spoiler?');
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-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 { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
|
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
|
||||||
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
||||||
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
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 isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||||
const directoryEntry = useDirectoryByAddress(communityAddress);
|
const directoryEntry = useDirectoryByAddress(communityAddress);
|
||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
|
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||||
@@ -73,6 +83,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
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 lastSelectionStartRef = useRef(0);
|
||||||
const lastSelectionEndRef = useRef(0);
|
const lastSelectionEndRef = useRef(0);
|
||||||
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
||||||
@@ -100,11 +113,33 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
}, 1000),
|
}, 1000),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPublishReply = () => {
|
const checkPostOptionsRef = useRef(
|
||||||
const currentContent = textRef.current?.value.trim() || '';
|
debounce((options: string, directoryCode: string | undefined) => {
|
||||||
const currentUrl = urlRef.current?.value.trim() || '';
|
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'));
|
setError(t('error') + ': ' + t('empty_comment_alert'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,16 +154,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkContentLengthRef.current.cancel();
|
if (publishContent.trim().length > 2000) {
|
||||||
setLengthError(null);
|
|
||||||
|
|
||||||
if (currentContent.length > 2000) {
|
|
||||||
setError(t('error') + ': ' + t('field_too_long'));
|
setError(t('error') + ': ' + t('field_too_long'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
publishReply();
|
publishReply({ content: publishContent });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,6 +226,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
checkContentLengthRef.current.cancel();
|
||||||
|
checkPostOptionsRef.current.cancel();
|
||||||
restoreBodyTextSelection();
|
restoreBodyTextSelection();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -248,8 +282,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
lastSelectionStartRef.current = len;
|
lastSelectionStartRef.current = len;
|
||||||
lastSelectionEndRef.current = len;
|
lastSelectionEndRef.current = len;
|
||||||
const content = textRef.current.value;
|
const content = textRef.current.value;
|
||||||
setPublishReplyOptions({ content });
|
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
checkContentLengthRef.current(content, t);
|
setPublishReplyOptions({ content: publishContent });
|
||||||
|
checkContentLengthRef.current(publishContent, t);
|
||||||
|
|
||||||
const spellcheckTimeout = window.setTimeout(() => {
|
const spellcheckTimeout = window.setTimeout(() => {
|
||||||
if (textRef.current) {
|
if (textRef.current) {
|
||||||
@@ -265,17 +300,26 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showReplyModal) {
|
if (!showReplyModal) {
|
||||||
|
checkContentLengthRef.current.cancel();
|
||||||
|
checkPostOptionsRef.current.cancel();
|
||||||
setIsBbcodePreviewing(false);
|
setIsBbcodePreviewing(false);
|
||||||
setBbcodePreviewContent('');
|
setBbcodePreviewContent('');
|
||||||
}
|
}
|
||||||
}, [showReplyModal]);
|
}, [showReplyModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showReplyModal) {
|
||||||
|
fortuneEntryRef.current = null;
|
||||||
|
diceRollRef.current = null;
|
||||||
|
}
|
||||||
|
}, [showReplyModal]);
|
||||||
|
|
||||||
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
|
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
|
||||||
lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current;
|
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) {
|
if (isBbcodePreviewing) {
|
||||||
setBbcodePreviewContent(content);
|
setBbcodePreviewContent(content);
|
||||||
}
|
}
|
||||||
@@ -283,8 +327,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
lastSelectionStartRef.current = selectionStart;
|
lastSelectionStartRef.current = selectionStart;
|
||||||
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
|
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
|
||||||
}
|
}
|
||||||
setPublishReplyOptions({ content });
|
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
checkContentLengthRef.current(content, t);
|
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>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -341,8 +393,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
lastSelectionStartRef.current = nextCursor;
|
lastSelectionStartRef.current = nextCursor;
|
||||||
lastSelectionEndRef.current = nextCursor;
|
lastSelectionEndRef.current = nextCursor;
|
||||||
|
|
||||||
setPublishReplyOptions({ content: nextValue });
|
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
checkContentLengthRef.current(nextValue, t);
|
setPublishReplyOptions({ content: publishContent });
|
||||||
|
checkContentLengthRef.current(publishContent, t);
|
||||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
|
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
|
||||||
|
|
||||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||||
@@ -407,17 +460,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.link}>
|
<div className={styles.options}>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
ref={urlRef}
|
ref={optionsRef}
|
||||||
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
|
aria-label={t('options')}
|
||||||
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
|
placeholder={capitalize(t('options'))}
|
||||||
disabled={isUploading}
|
autoCorrect='off'
|
||||||
onChange={(e) => {
|
autoComplete='off'
|
||||||
setUrl(e.target.value);
|
spellCheck='false'
|
||||||
setPublishReplyOptions({ link: e.target.value });
|
onChange={handleOptionsChange}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -450,6 +502,19 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.footer}>
|
||||||
{showUploadControls && (
|
{showUploadControls && (
|
||||||
<span className={styles.uploadContainer}>
|
<span className={styles.uploadContainer}>
|
||||||
|
|||||||
@@ -137,4 +137,22 @@ describe('usePublishPost', () => {
|
|||||||
expect(latestValue.publishPostError).toBe('blocked:unresolved');
|
expect(latestValue.publishPostError).toBe('blocked:unresolved');
|
||||||
expect(testState.publishCommentMock).not.toHaveBeenCalled();
|
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);
|
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 () => {
|
it('blocks publish when a same-board external quote cannot be resolved', async () => {
|
||||||
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
|
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
|
|||||||
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
||||||
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
||||||
const [publishPostError, setPublishPostError] = useState<string | null>(null);
|
const [publishPostError, setPublishPostError] = useState<string | null>(null);
|
||||||
|
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
|
||||||
|
const startedPublishRequestIdRef = useRef(0);
|
||||||
const { blockedReason } = usePublishAuthorDomainGuard();
|
const { blockedReason } = usePublishAuthorDomainGuard();
|
||||||
const abandonCurrentPublish = useCallback(async () => {
|
const abandonCurrentPublish = useCallback(async () => {
|
||||||
await abandonPublishRef.current?.();
|
await abandonPublishRef.current?.();
|
||||||
@@ -90,7 +92,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
|
|||||||
setPublishPostError(null);
|
setPublishPostError(null);
|
||||||
}, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]);
|
}, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]);
|
||||||
|
|
||||||
const publishPost = useCallback(() => {
|
const startPublishPost = useCallback(() => {
|
||||||
if (blockedReason) {
|
if (blockedReason) {
|
||||||
setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason));
|
setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason));
|
||||||
return;
|
return;
|
||||||
@@ -100,6 +102,28 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
|
|||||||
return publishComment();
|
return publishComment();
|
||||||
}, [blockedReason, 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 {
|
return {
|
||||||
setPublishPostOptions,
|
setPublishPostOptions,
|
||||||
resetPublishPostOptions,
|
resetPublishPostOptions,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
|
|||||||
const startedPublishRequestIdRef = useRef(0);
|
const startedPublishRequestIdRef = useRef(0);
|
||||||
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
|
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
|
||||||
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
|
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
|
||||||
|
const [pendingSyncedPublishRequestId, setPendingSyncedPublishRequestId] = useState(0);
|
||||||
|
const startedSyncedPublishRequestIdRef = useRef(0);
|
||||||
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
|
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
|
||||||
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
|
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
|
||||||
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
|
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
|
||||||
@@ -151,64 +153,82 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
|
|||||||
publishComment();
|
publishComment();
|
||||||
}, [pendingPublishRequestId, publishComment]);
|
}, [pendingPublishRequestId, publishComment]);
|
||||||
|
|
||||||
const publishReply = useCallback(async () => {
|
const publishReply = useCallback(
|
||||||
setPublishReplyError(null);
|
async (options?: Partial<Comment>) => {
|
||||||
|
if (options) {
|
||||||
if (blockedReason) {
|
setPublishReplyOptions(options);
|
||||||
setPublishReplyStateMessage(null);
|
setPendingSyncedPublishRequestId((requestId) => requestId + 1);
|
||||||
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
|
return;
|
||||||
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);
|
setPublishReplyError(null);
|
||||||
setPublishReplyStateMessage(null);
|
|
||||||
setPendingPublishRequestId((requestId) => requestId + 1);
|
if (blockedReason) {
|
||||||
} catch {
|
setPublishReplyStateMessage(null);
|
||||||
setPublishReplyError(t('external_quote_resolution_unavailable'));
|
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
|
||||||
} finally {
|
return;
|
||||||
setIsResolvingExternalQuotes(false);
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
isResolvingExternalQuotes,
|
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