diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index c9dc88ac..33bf6675 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -279,6 +279,63 @@ describe('Markdown', () => { expect(container.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),'); }); + it('renders whitelisted 4chan fortune markup with its specific color', async () => { + await renderMarkdown({ + content: 'bodyYour fortune: Excellent Luck', + }); + + const fortune = container.querySelector('.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: 'Rolled 2, 2 = 4 (2d6)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('span')).find((node) => node.textContent === 'red'); + const green = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'green'); + const blue = Array.from(container.querySelectorAll('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('span')).some((node) => node.style.color)).toBe(false); + expect(container.textContent).toBe('[b]bold[/b] [red]red[/red]'); + }); + it('renders number quote links with op and unavailable state derived from cached comments', async () => { testState.comments = { 'comment-42': { cid: 'comment-42', number: 42 }, diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index e1876012..c04deb18 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -17,6 +17,7 @@ import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stor import { useComment } from '@bitsocial/bitsocial-react-hooks'; import ReplyQuotePreview from '../reply-quote-preview'; import ExternalNumberQuoteLink from './external-number-quote-link'; +import { createDiceRollMarkupRegex, createFortuneMarkupRegex, getMatchingFortuneEntry } from '../../lib/utils/post-options-utils'; const safeParseUrl = (href: string): URL | null => { try { @@ -152,6 +153,18 @@ const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/; const QUOTE_LINK_REGEX = /(?/\w])>>(\d+)(?![\d/])/; const URL_REGEX = /https?:\/\/[^\s<>[\]]+/; +type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue'; +const QST_BBCODE_OPEN_REGEX = /\[(b|i|red|green|blue)\]/g; +const QST_BBCODE_COLORS = { + red: '#C41E3A', + green: '#00A550', + blue: '#1d8dc4', +} satisfies Record, 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, React.CSSProperties>; const COMBINED_REGEX = new RegExp( `(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, @@ -308,6 +321,7 @@ interface RenderContext { isInCatalogView: boolean; postCid?: string; communityAddress?: string; + enableQstBbcode: boolean; } interface MarkdownProps { @@ -398,7 +412,7 @@ const TokenNode = ({ token, context }: { token: Token; context: RenderContext }) switch (token.type) { case 'text': - return <>{token.value}>; + return context.enableQstBbcode ? : <>{token.value}>; case 'url': { const href = token.href; const linkMediaInfo = getLinkMediaInfo(href); @@ -445,17 +459,157 @@ const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContex ); }; +const findMatchingQstBbcodeClose = (text: string, tag: QstBbcodeTag, searchStart: number): number => { + const tagRegex = new RegExp(`\\[(/?)${tag}\\]`, 'g'); + tagRegex.lastIndex = searchStart; + let depth = 1; + let match: RegExpExecArray | null; + + while ((match = tagRegex.exec(text)) !== null) { + depth += match[1] ? -1 : 1; + if (depth === 0) { + return match.index; + } + } + + return -1; +}; + +const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.ReactNode[]) => { + if (tag === 'b') { + return {children}; + } + if (tag === 'i') { + return {children}; + } + return ( + + {children} + + ); +}; + +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({text.slice(lastIndex, matchStart)}); + } + + 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({text.slice(lastIndex)}); + } + + return elements; +}; + +const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}>; + +const Fortune = ({ color, text }: { color: string; text: string }) => ( + + + + Your fortune: {text} + +); + +const DiceRoll = ({ text }: { text: string }) => ( + + {text} + + + +); + +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(); + } + + if (nextMatch.type === 'dice') { + elements.push(); + } else { + const [, color, text] = nextMatch.match; + const fortune = getMatchingFortuneEntry(color, text); + if (fortune) { + elements.push(); + } else { + elements.push(); + } + } + + lastIndex = matchEnd; + } + + if (lastIndex < line.length) { + elements.push(); + } + + return elements; +}; + const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => { const location = useLocation(); const params = useParams(); const isInCatalogView = isCatalogView(location.pathname, params); + const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst'; const rendered = useMemo(() => { const normalized = normalizeContent(content || ''); const lines = normalized.split('\n'); const elements: React.ReactNode[] = []; - const context = { isInCatalogView, postCid, communityAddress }; + const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode }; lines.forEach((line, lineIndex) => { if (lineIndex > 0) { @@ -466,8 +620,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) const isGreentext = isGreentextLine(line); - const tokens = tokenize(line); - const lineElements = ; + const lineElements = renderLineContent(line, context); if (isGreentext) { elements.push( @@ -481,7 +634,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) }); return elements; - }, [content, isInCatalogView, postCid, communityAddress]); + }, [content, isInCatalogView, postCid, communityAddress, enableQstBbcode]); return ( diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index c0edd0fa..31ed21c8 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import PostForm, { LinkTypePreviewer } from '../post-form'; +import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -110,6 +111,7 @@ vi.mock('../../../hooks/use-account-community-addresses', () => ({ })); vi.mock('../../../hooks/use-directories', () => ({ + findDirectoryByAddress: (directories: typeof testState.directories, address: string | undefined) => directories.find((entry) => entry.address === address), useDirectories: () => testState.directories, useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address), normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), @@ -159,10 +161,24 @@ vi.mock('../../../hooks/use-publish-post', async () => { }), [communityAddress], ); - const publishPost = React.useCallback(() => { - testState.publishedPostOptions = getPublishPostOptions(); - return testState.publishPostMock(); - }, [getPublishPostOptions]); + const publishPost = React.useCallback( + (options?: Record) => { + const sanitizedOptions = Object.entries(options || {}).reduce( + (acc, [key, value]) => { + acc[key] = value === '' ? undefined : value; + return acc; + }, + {} as Record, + ); + testState.publishPostOptions = { + ...getPublishPostOptions(), + ...sanitizedOptions, + }; + testState.publishedPostOptions = testState.publishPostOptions; + return testState.publishPostMock(options); + }, + [getPublishPostOptions], + ); const resetPublishPostOptions = React.useCallback(() => { testState.publishPostOptions = {}; testState.resetPublishPostOptionsMock(); @@ -209,7 +225,13 @@ vi.mock('../../../hooks/use-publish-reply', async () => { postCid: postCid ?? cid, communityAddress, }); - const publishReply = React.useCallback(() => testState.publishReplyMock(), []); + const publishReply = React.useCallback((options?: Record) => { + if (options) { + testState.setPublishReplyOptionsMock(options); + setPublishReplyOptionsState((previous) => ({ ...previous, ...options })); + } + return testState.publishReplyMock(options); + }, []); const resetPublishReplyOptions = React.useCallback(() => testState.resetPublishReplyOptionsMock(), []); const setPublishReplyOptions = React.useCallback((options: Record) => { testState.setPublishReplyOptionsMock(options); @@ -274,9 +296,20 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({ })); vi.mock('lodash/debounce', () => ({ - default: void>(fn: T) => { - const wrapped = ((...args: Parameters) => fn(...args)) as T & { cancel: () => void }; - wrapped.cancel = () => undefined; + default: void>(fn: T, wait = 0) => { + let timeout: ReturnType | undefined; + const wrapped = ((...args: Parameters) => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => fn(...args), wait); + }) as T & { cancel: () => void }; + wrapped.cancel = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = undefined; + }; return wrapped; }, })); @@ -362,6 +395,12 @@ const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, va }); }; +const waitForOptionsValidation = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20)); + }); +}; + const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => { await act(async () => { if (typeof value === 'boolean' && 'checked' in element) { @@ -385,6 +424,9 @@ describe('PostForm', () => { testState.comments = {}; testState.directories = [ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, + { address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' }, + { address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' }, + { address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' }, { address: 'mod.eth', features: {}, title: '/mod/ - Moderation' }, ]; testState.editedComment = undefined; @@ -408,6 +450,7 @@ describe('PostForm', () => { testState.uploadedFileName = 'picked.png'; testState.communities = { 'music-posting.eth': { address: 'music-posting.eth' }, + 'traditional-games.bso': { address: 'traditional-games.bso' }, }; testState.handleUploadMock.mockReset(); testState.navigateMock.mockReset(); @@ -480,12 +523,14 @@ describe('PostForm', () => { const textInputs = table?.querySelectorAll('input[type="text"]') || []; const nameInput = textInputs[0]; - const subjectInput = textInputs[1]; - const linkInput = textInputs[2]; + const optionsInput = textInputs[1]; + const subjectInput = textInputs[2]; + const linkInput = textInputs[3]; const textarea = table?.querySelector('textarea'); const select = table?.querySelector('select'); expect(nameInput).toBeTruthy(); + expect(optionsInput?.getAttribute('aria-label')).toBe('options'); expect(subjectInput).toBeTruthy(); expect(linkInput).toBeTruthy(); expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg'); @@ -546,7 +591,7 @@ describe('PostForm', () => { table = container.querySelector('table'); textarea = table?.querySelector('textarea'); const textInputs = table?.querySelectorAll('input[type="text"]') || []; - const linkInput = textInputs[2]; + const linkInput = textInputs[3]; expect(textarea?.value).toBe(''); expect(linkInput).toBeTruthy(); @@ -559,6 +604,140 @@ describe('PostForm', () => { expect(testState.publishedPostOptions?.content).toBeUndefined(); }); + it('validates unsupported options and stores fortune output in post content', async () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25); + testState.resolvedCommunityAddress = 'random-nsfw.bso'; + + await renderPostForm('/b'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table'); + const optionsInput = table?.querySelector('input[aria-label="options"]'); + const textarea = table?.querySelector('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 bodyYour fortune: Excellent Luck'); + + 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('input[aria-label="options"]'); + const textarea = table?.querySelector('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 fortuneYour fortune: Excellent Luck'); + 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('input[aria-label="options"]'); + const textarea = table?.querySelector('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('Rolled 4 + 3 = 7 (1d6 + 3)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('input[aria-label="options"]'); + const textarea = table?.querySelector('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('input[aria-label="options"]'); + const textarea = table?.querySelector('textarea'); + + await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6'); + expect(container.textContent).not.toContain('unsupported options'); + + await waitForOptionsValidation(); + expect(container.textContent).toContain('unsupported options: dice+1d6'); + + await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice'); + await clickByText(table as HTMLTableElement, 'post'); + + expect(testState.publishPostMock).not.toHaveBeenCalled(); + expect(testState.publishPostOptions.content).toBe('plain dice'); + }); + it('shows BBCode controls only for board mods and inserts tags into the post textarea', async () => { testState.account = { author: { address: 'mod.eth', displayName: 'Alice' }, @@ -653,7 +832,7 @@ describe('PostForm', () => { const table = container.querySelector('table'); const textInputs = table?.querySelectorAll('input[type="text"]') || []; - const linkInput = textInputs[2]; + const linkInput = textInputs[3]; expect(table?.textContent).toContain('no_file_chosen'); @@ -691,7 +870,7 @@ describe('PostForm', () => { const table = container.querySelector('table'); const textInputs = table?.querySelectorAll('input[type="text"]') || []; - const linkInput = textInputs[2]; + const linkInput = textInputs[3]; const longFilename = 'TELEMMGLPICT000378070158_17159651831200_trans_NvBQzQNjv4BqpVlberWd9EgFPZtcLiMQf0Rf_Wk3V23H2268P_XkPxc.jpeg'; await dispatchInput(linkInput as HTMLInputElement, `https://www.telegraph.co.uk/multimedia/${longFilename}`); @@ -761,7 +940,7 @@ describe('PostForm', () => { expect(container.textContent).toContain('error: empty_comment_alert'); const textInputs = table?.querySelectorAll('input[type="text"]') || []; - const linkInput = textInputs[1]; + const linkInput = textInputs[2]; expect(linkInput).toBeTruthy(); await dispatchInput(linkInput as HTMLInputElement, 'not-a-url'); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 7a1d7050..c2b32673 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -7,6 +7,15 @@ import getShortAddress from '../../lib/get-short-address'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; +import { + type DiceRoll, + type FortuneEntry, + POST_OPTIONS_VALIDATION_DELAY_MS, + getContentWithPostOptionState as getContentWithOptions, + getPostOptionsDirectoryCode, + getUnsupportedPostOptionsMessage, + isUnsupportedPostOptionsMessage, +} from '../../lib/utils/post-options-utils'; import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils'; import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; @@ -112,12 +121,14 @@ interface PostFormFieldsProps { isBbcodePreviewing: boolean; postCid: string; subjectRef: React.Ref; + optionsRef: React.RefObject; textRef: React.RefObject; urlRef: React.Ref; url: string; lengthError: string | null; handleContentChange: (e: React.ChangeEvent) => void; - handleContentValueChange: (content: string) => void; + handleContentValueChange: (content: string, options?: string) => void; + handleOptionsChange: (e: React.ChangeEvent) => void; setPublishPostOptions: (opts: Record) => void; setPublishReplyOptions: (opts: Record) => void; setUrl: (url: string) => void; @@ -152,12 +163,14 @@ const PostFormFields = ({ isBbcodePreviewing, postCid, subjectRef, + optionsRef, textRef, urlRef, url, lengthError, handleContentChange, handleContentValueChange, + handleOptionsChange, setPublishPostOptions, setPublishReplyOptions, setUrl, @@ -214,6 +227,12 @@ const PostFormFields = ({ /> + + {t('options')} + + + + {!isInPostView && ( {t('subject')} @@ -333,7 +352,7 @@ const PostFormFields = ({ {((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && ( - {t('options')} + {capitalize(t('spoiler'))} [ @@ -398,6 +417,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const textRef = useRef(null); const urlRef = useRef(null); const subjectRef = useRef(null); + const optionsRef = useRef(null); + const fortuneEntryRef = useRef(null); + const diceRollRef = useRef(null); const location = useLocation(); const isInAllView = isAllView(location.pathname); @@ -409,6 +431,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules'; const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true; const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; + const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); @@ -434,6 +457,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }, 1000), ).current; + const checkPostOptions = useRef( + debounce((options: string, directoryCode: string | undefined) => { + const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode); + if (nextOptionsError) { + setFormError(nextOptionsError); + } + }, POST_OPTIONS_VALIDATION_DELAY_MS), + ).current; + const resetFields = () => { if (textRef.current) { textRef.current.value = ''; @@ -444,20 +476,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: if (subjectRef.current) { subjectRef.current.value = ''; } + if (optionsRef.current) { + optionsRef.current.value = ''; + } + checkContentLength.cancel(); + checkPostOptions.cancel(); + fortuneEntryRef.current = null; + diceRollRef.current = null; setIsBbcodePreviewing(false); setBbcodePreviewContent(''); }; const onPublishPost = () => { const currentTitle = subjectRef.current?.value.trim() || ''; - const currentContent = textRef.current?.value.trim() || ''; + const currentContent = textRef.current?.value || ''; const currentUrl = urlRef.current?.value.trim() || ''; + const currentOptions = optionsRef.current?.value || ''; + const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode); + const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); checkContentLength.cancel(); + checkPostOptions.cancel(); setLengthError(null); setFormError(null); - if (!currentTitle && !currentContent && !currentUrl) { + if (currentOptionsError) { + setFormError(currentOptionsError); + return; + } + + if (!currentTitle && !publishContent.trim() && !currentUrl) { setFormError(`${t('error')}: ${t('empty_comment_alert')}`); return; } @@ -471,7 +519,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } - if (currentContent.length > 2000) { + if (publishContent.trim().length > 2000) { setFormError(`${t('error')}: ${t('field_too_long')}`); return; } @@ -481,7 +529,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } - publishPost(); + publishPost({ content: publishContent }); }; // redirect to pending page when pending comment is created @@ -503,30 +551,39 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: useEffect(() => { return () => { checkContentLength.cancel(); + checkPostOptions.cancel(); if (isInPostView) { resetPublishReplyOptions(); } else { resetPublishPostOptions(); } }; - }, [checkContentLength, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]); + }, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]); - const handleContentValueChange = (content: string) => { + const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => { + const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); if (isBbcodePreviewing) { setBbcodePreviewContent(content); } if (isInPostView) { - setPublishReplyOptions({ content }); + setPublishReplyOptions({ content: publishContent }); } else { - setPublishPostOptions({ content }); + setPublishPostOptions({ content: publishContent }); } - checkContentLength(content, t); + checkContentLength(publishContent, t); }; const handleContentChange = (e: React.ChangeEvent) => { handleContentValueChange(e.target.value); }; + const handleOptionsChange = (e: React.ChangeEvent) => { + const options = e.target.value; + handleContentValueChange(textRef.current?.value || '', options); + setFormError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError)); + checkPostOptions(options, postOptionsDirectoryCode); + }; + const handleBbcodePreviewToggle = () => { if (isBbcodePreviewing) { setIsBbcodePreviewing(false); @@ -539,14 +596,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }; const onPublishReply = () => { - const currentContent = textRef.current?.value.trim() || ''; const currentUrl = urlRef.current?.value.trim() || ''; + const currentOptions = optionsRef.current?.value || ''; + const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode); + const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); checkContentLength.cancel(); + checkPostOptions.cancel(); setLengthError(null); setFormError(null); - if (!currentContent && !currentUrl) { + if (currentOptionsError) { + setFormError(currentOptionsError); + return; + } + + if (!publishContent.trim() && !currentUrl) { setFormError(`${t('error')}: ${t('empty_comment_alert')}`); return; } @@ -561,12 +626,12 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } - if (currentContent.length > 2000) { + if (publishContent.trim().length > 2000) { setFormError(`${t('error')}: ${t('field_too_long')}`); return; } - publishReply(); + publishReply({ content: publishContent }); }; useEffect(() => { @@ -619,12 +684,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: isBbcodePreviewing={isBbcodePreviewing} postCid={postCid} subjectRef={subjectRef} + optionsRef={optionsRef} textRef={textRef} urlRef={urlRef} url={url} lengthError={lengthError} handleContentChange={handleContentChange} handleContentValueChange={handleContentValueChange} + handleOptionsChange={handleOptionsChange} setPublishPostOptions={setPublishPostOptions} setPublishReplyOptions={setPublishReplyOptions} setUrl={setUrl} diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 48961b3f..02807466 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ReplyModal from '../reply-modal'; +import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -15,8 +16,9 @@ const testState = vi.hoisted(() => ({ 'music-posting.eth': { address: 'music-posting.eth', features: {}, + title: '/mu/ - Music', }, - } as Record }>, + } as Record; title?: string }>, handleUploadMock: vi.fn(), isMobile: false, isResolvingExternalQuotes: false, @@ -128,6 +130,8 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({ })); vi.mock('../../../hooks/use-directories', () => ({ + findDirectoryByAddress: (directories: Array<{ address: string; features?: Record; title?: string }>, address: string | undefined) => + directories.find((entry) => entry.address === address), useDirectoryByAddress: (address: string) => testState.directoryByAddress[address], normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); @@ -148,7 +152,12 @@ vi.mock('../../../hooks/use-stable-community', () => ({ vi.mock('../../../hooks/use-publish-reply', () => ({ default: () => ({ isResolvingExternalQuotes: testState.isResolvingExternalQuotes, - publishReply: testState.publishReplyMock, + publishReply: (options?: Record) => { + if (options) { + testState.setPublishReplyOptionsMock(options); + } + return testState.publishReplyMock(options); + }, publishReplyError: testState.publishReplyError, publishReplyStateMessage: testState.publishReplyStateMessage, replyIndex: testState.replyIndex, @@ -177,9 +186,20 @@ vi.mock('../../loading-ellipsis', () => ({ })); vi.mock('lodash/debounce', () => ({ - default: void>(fn: T) => { - const wrapped = ((...args: Parameters) => fn(...args)) as T & { cancel: () => void }; - wrapped.cancel = () => undefined; + default: void>(fn: T, wait = 0) => { + let timeout: ReturnType | undefined; + const wrapped = ((...args: Parameters) => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => fn(...args), wait); + }) as T & { cancel: () => void }; + wrapped.cancel = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = undefined; + }; return wrapped; }, })); @@ -269,6 +289,12 @@ const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, va }); }; +const waitForOptionsValidation = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20)); + }); +}; + const clickButtonByText = async (text: string) => { const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); await act(async () => { @@ -285,6 +311,22 @@ describe('ReplyModal', () => { 'music-posting.eth': { address: 'music-posting.eth', features: {}, + title: '/mu/ - Music', + }, + 'random-nsfw.bso': { + address: 'random-nsfw.bso', + features: {}, + title: '/b/ - Random', + }, + 'silly-stuff.bso': { + address: 'silly-stuff.bso', + features: {}, + title: '/s5s/ - Silly Stuff', + }, + 'traditional-games.bso': { + address: 'traditional-games.bso', + features: {}, + title: '/tg/ - Traditional Games', }, }; testState.handleUploadMock.mockReset(); @@ -325,6 +367,9 @@ describe('ReplyModal', () => { 'music-posting.eth': { address: 'music-posting.eth', }, + 'traditional-games.bso': { + address: 'traditional-games.bso', + }, }; testState.showUploadControls = true; testState.uploadComplete = undefined; @@ -349,12 +394,19 @@ describe('ReplyModal', () => { await renderReplyModal('/mu/thread/post-1'); const nameInput = container.querySelectorAll('input[type="text"]')[0]; - const linkInput = container.querySelectorAll('input[type="text"]')[1]; + const optionsInput = container.querySelectorAll('input[type="text"]')[1]; + const linkInput = container.querySelectorAll('input[type="text"]')[2]; const textarea = container.querySelector('textarea'); + expect(optionsInput).toBeTruthy(); + expect(linkInput).toBeTruthy(); + expect(textarea).toBeTruthy(); expect(nameInput?.value).toBe('Alice'); + expect(optionsInput?.getAttribute('placeholder')).toBe('Options'); expect(linkInput?.getAttribute('placeholder')).toContain('Link'); expect(textarea?.value).toBe('>>42\nselected text'); + expect(Boolean(optionsInput!.compareDocumentPosition(textarea!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + expect(Boolean(textarea!.compareDocumentPosition(linkInput!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); expect(container.textContent).toContain('choose_file'); expect(container.textContent).toContain('Spoiler?'); expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}'); @@ -410,7 +462,7 @@ describe('ReplyModal', () => { expect(container.textContent).toContain('error: empty_comment_alert'); expect(testState.publishReplyMock).not.toHaveBeenCalled(); - const linkInput = container.querySelectorAll('input[type="text"]')[1]; + const linkInput = container.querySelectorAll('input[type="text"]')[2]; const spoilerCheckbox = container.querySelector('input[type="checkbox"]'); await dispatchInput(linkInput, 'not-a-url'); await act(async () => { @@ -435,6 +487,137 @@ describe('ReplyModal', () => { expect(testState.publishReplyMock).toHaveBeenCalledTimes(1); }); + it('validates unsupported options and stores fortune output in reply content', async () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25); + testState.openEmpty = true; + testState.selectedText = ''; + + await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso'); + + const optionsInput = container.querySelectorAll('input[type="text"]')[1]; + const textarea = container.querySelector('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 bodyYour fortune: Excellent Luck', + }); + + 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('input[type="text"]')[1]; + const textarea = container.querySelector('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 replyYour fortune: Excellent Luck', + }); + 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('input[type="text"]')[1]; + const textarea = container.querySelector('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: 'Rolled 2, 2 = 4 (2d6)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('input[type="text"]')[1]; + const textarea = container.querySelector('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('input[type="text"]')[1]; + const textarea = container.querySelector('textarea'); + + await dispatchInput(optionsInput, 'fortune'); + expect(container.textContent).not.toContain('unsupported options'); + + await waitForOptionsValidation(); + expect(container.textContent).toContain('unsupported options: fortune'); + + await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply'); + await clickButtonByText('post'); + + expect(testState.publishReplyMock).not.toHaveBeenCalled(); + expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain reply' }); + }); + it('shows BBCode controls only for board mods and inserts tags into the reply textarea', async () => { testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } }; testState.rolesByCommunity = { @@ -476,7 +659,7 @@ describe('ReplyModal', () => { await renderReplyModal('/mu/thread/post-1'); const nameInput = container.querySelectorAll('input[type="text"]')[0]; - const linkInput = container.querySelectorAll('input[type="text"]')[1]; + const linkInput = container.querySelectorAll('input[type="text"]')[2]; await dispatchInput(nameInput, 'Alicia'); expect(testState.setAccountMock).toHaveBeenCalledWith({ @@ -533,7 +716,7 @@ describe('ReplyModal', () => { await renderReplyModal('/all/thread/post-1'); - const linkInput = container.querySelectorAll('input[type="text"]')[1]; + const linkInput = container.querySelectorAll('input[type="text"]')[2]; expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg'); expect(container.textContent).not.toContain('warning'); expect(container.textContent).not.toContain('Spoiler?'); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 22125b7b..03ee468b 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -4,6 +4,15 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; +import { + type DiceRoll, + type FortuneEntry, + POST_OPTIONS_VALIDATION_DELAY_MS, + getContentWithPostOptionState as getContentWithOptions, + getPostOptionsDirectoryCode, + getUnsupportedPostOptionsMessage, + isUnsupportedPostOptionsMessage, +} from '../../lib/utils/post-options-utils'; import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; @@ -47,6 +56,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directoryEntry = useDirectoryByAddress(communityAddress); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; + const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = @@ -73,6 +83,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }, 0); }); const urlRef = useRef(null); + const optionsRef = useRef(null); + const fortuneEntryRef = useRef(null); + const diceRollRef = useRef(null); const lastSelectionStartRef = useRef(0); const lastSelectionEndRef = useRef(0); const lastProcessedQuoteInsertRequestIdRef = useRef(0); @@ -100,11 +113,33 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }, 1000), ); - const onPublishReply = () => { - const currentContent = textRef.current?.value.trim() || ''; - const currentUrl = urlRef.current?.value.trim() || ''; + const checkPostOptionsRef = useRef( + debounce((options: string, directoryCode: string | undefined) => { + const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode); + if (nextOptionsError) { + setLengthError(null); + setError(nextOptionsError); + } + }, POST_OPTIONS_VALIDATION_DELAY_MS), + ); - if (!currentContent && !currentUrl) { + const onPublishReply = () => { + const currentContent = textRef.current?.value || ''; + const currentUrl = urlRef.current?.value.trim() || ''; + const currentOptions = optionsRef.current?.value || ''; + const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode); + const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); + + checkContentLengthRef.current.cancel(); + checkPostOptionsRef.current.cancel(); + setLengthError(null); + + if (currentOptionsError) { + setError(currentOptionsError); + return; + } + + if (!publishContent.trim() && !currentUrl) { setError(t('error') + ': ' + t('empty_comment_alert')); return; } @@ -119,16 +154,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa return; } - checkContentLengthRef.current.cancel(); - setLengthError(null); - - if (currentContent.length > 2000) { + if (publishContent.trim().length > 2000) { setError(t('error') + ': ' + t('field_too_long')); return; } setError(null); - publishReply(); + publishReply({ content: publishContent }); }; useEffect(() => { @@ -194,6 +226,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa useEffect(() => { return () => { + checkContentLengthRef.current.cancel(); + checkPostOptionsRef.current.cancel(); restoreBodyTextSelection(); }; }, []); @@ -248,8 +282,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa lastSelectionStartRef.current = len; lastSelectionEndRef.current = len; const content = textRef.current.value; - setPublishReplyOptions({ content }); - checkContentLengthRef.current(content, t); + const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); + setPublishReplyOptions({ content: publishContent }); + checkContentLengthRef.current(publishContent, t); const spellcheckTimeout = window.setTimeout(() => { if (textRef.current) { @@ -265,17 +300,26 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa useEffect(() => { if (!showReplyModal) { + checkContentLengthRef.current.cancel(); + checkPostOptionsRef.current.cancel(); setIsBbcodePreviewing(false); setBbcodePreviewContent(''); } }, [showReplyModal]); + useEffect(() => { + if (!showReplyModal) { + fortuneEntryRef.current = null; + diceRollRef.current = null; + } + }, [showReplyModal]); + const handleContentInput = (e: React.ChangeEvent) => { lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length; lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current; }; - const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number) => { + const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => { if (isBbcodePreviewing) { setBbcodePreviewContent(content); } @@ -283,8 +327,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa lastSelectionStartRef.current = selectionStart; lastSelectionEndRef.current = selectionEnd ?? selectionStart; } - setPublishReplyOptions({ content }); - checkContentLengthRef.current(content, t); + const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); + setPublishReplyOptions({ content: publishContent }); + checkContentLengthRef.current(publishContent, t); + }; + + const handleOptionsChange = (e: React.ChangeEvent) => { + 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) => { @@ -341,8 +393,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa lastSelectionStartRef.current = nextCursor; lastSelectionEndRef.current = nextCursor; - setPublishReplyOptions({ content: nextValue }); - checkContentLengthRef.current(nextValue, t); + const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); + setPublishReplyOptions({ content: publishContent }); + checkContentLengthRef.current(publishContent, t); }, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]); const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ @@ -407,17 +460,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }} /> - + { - setUrl(e.target.value); - setPublishReplyOptions({ link: e.target.value }); - }} + ref={optionsRef} + aria-label={t('options')} + placeholder={capitalize(t('options'))} + autoCorrect='off' + autoComplete='off' + spellCheck='false' + onChange={handleOptionsChange} /> @@ -450,6 +502,19 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }} /> + + { + setUrl(e.target.value); + setPublishReplyOptions({ link: e.target.value }); + }} + /> + {showUploadControls && ( diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx index 243888f8..24f086f2 100644 --- a/src/hooks/__tests__/use-publish-post.test.tsx +++ b/src/hooks/__tests__/use-publish-post.test.tsx @@ -137,4 +137,22 @@ describe('usePublishPost', () => { expect(latestValue.publishPostError).toBe('blocked:unresolved'); expect(testState.publishCommentMock).not.toHaveBeenCalled(); }); + + it('publishes after synchronizing one-shot publish options', async () => { + await act(async () => { + latestValue.setPublishPostOptions({ + content: 'Old body', + } as never); + }); + + await act(async () => { + latestValue.publishPost({ + content: 'Fresh body', + } as never); + await Promise.resolve(); + }); + + expect(testState.lastPublishOptions?.content).toBe('Fresh body'); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx index 9db6f423..92b5d08c 100644 --- a/src/hooks/__tests__/use-publish-reply.test.tsx +++ b/src/hooks/__tests__/use-publish-reply.test.tsx @@ -167,6 +167,25 @@ describe('usePublishReply', () => { expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); }); + it('publishes after synchronizing one-shot reply options', async () => { + await act(async () => { + latestValue.setPublishReplyOptions({ + content: 'Old reply', + } as never); + }); + + await act(async () => { + await latestValue.publishReply({ + content: 'Fresh reply', + } as never); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(testState.lastPublishOptions?.content).toBe('Fresh reply'); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + }); + it('blocks publish when a same-board external quote cannot be resolved', async () => { testState.resolveExternalQuoteTargetMock.mockResolvedValue(null); diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts index c33ac8df..31506a14 100644 --- a/src/hooks/use-publish-post.ts +++ b/src/hooks/use-publish-post.ts @@ -26,6 +26,8 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { const addChallenge = useChallengesStore((state) => state.addChallenge); const abandonPublishRef = useRef<(() => Promise) | undefined>(); const [publishPostError, setPublishPostError] = useState(null); + const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0); + const startedPublishRequestIdRef = useRef(0); const { blockedReason } = usePublishAuthorDomainGuard(); const abandonCurrentPublish = useCallback(async () => { await abandonPublishRef.current?.(); @@ -90,7 +92,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { setPublishPostError(null); }, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]); - const publishPost = useCallback(() => { + const startPublishPost = useCallback(() => { if (blockedReason) { setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason)); return; @@ -100,6 +102,28 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { return publishComment(); }, [blockedReason, publishComment]); + useEffect(() => { + if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) { + return; + } + + startedPublishRequestIdRef.current = pendingPublishRequestId; + startPublishPost(); + }, [pendingPublishRequestId, startPublishPost]); + + const publishPost = useCallback( + (options?: Partial) => { + if (options) { + setPublishPostOptions(options); + setPendingPublishRequestId((requestId) => requestId + 1); + return; + } + + return startPublishPost(); + }, + [setPublishPostOptions, startPublishPost], + ); + return { setPublishPostOptions, resetPublishPostOptions, diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index 194cc1ac..92c3c33d 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -41,6 +41,8 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti const startedPublishRequestIdRef = useRef(0); const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState(); const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0); + const [pendingSyncedPublishRequestId, setPendingSyncedPublishRequestId] = useState(0); + const startedSyncedPublishRequestIdRef = useRef(0); const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false); const [publishReplyError, setPublishReplyError] = useState(null); const [publishReplyStateMessage, setPublishReplyStateMessage] = useState(null); @@ -151,64 +153,82 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti publishComment(); }, [pendingPublishRequestId, publishComment]); - const publishReply = useCallback(async () => { - setPublishReplyError(null); - - if (blockedReason) { - setPublishReplyStateMessage(null); - setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason)); - return; - } - - if (publishResolvableQuoteReferences.length === 0) { - setResolvedExternalQuotedCids(undefined); - setPublishReplyStateMessage(null); - setPendingPublishRequestId((requestId) => requestId + 1); - return; - } - - if (!account?.id) { - setPublishReplyError(t('external_quote_resolution_unavailable')); - return; - } - - setIsResolvingExternalQuotes(true); - - try { - const resolvedCids = new Set(); - - for (const reference of publishResolvableQuoteReferences) { - const resolvedTarget = await resolveExternalQuoteTarget({ - account, - directories, - onStatus: (status) => { - setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status)); - }, - reference, - }); - - if (!resolvedTarget?.cid) { - setPublishReplyError( - t('external_quote_publish_missing', { - interpolation: { escapeValue: false }, - quote: reference.raw, - }), - ); - return; - } - - resolvedCids.add(resolvedTarget.cid); + const publishReply = useCallback( + async (options?: Partial) => { + if (options) { + setPublishReplyOptions(options); + setPendingSyncedPublishRequestId((requestId) => requestId + 1); + return; } - setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined); - setPublishReplyStateMessage(null); - setPendingPublishRequestId((requestId) => requestId + 1); - } catch { - setPublishReplyError(t('external_quote_resolution_unavailable')); - } finally { - setIsResolvingExternalQuotes(false); + setPublishReplyError(null); + + if (blockedReason) { + setPublishReplyStateMessage(null); + setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason)); + return; + } + + if (publishResolvableQuoteReferences.length === 0) { + setResolvedExternalQuotedCids(undefined); + setPublishReplyStateMessage(null); + setPendingPublishRequestId((requestId) => requestId + 1); + return; + } + + if (!account?.id) { + setPublishReplyError(t('external_quote_resolution_unavailable')); + return; + } + + setIsResolvingExternalQuotes(true); + + try { + const resolvedCids = new Set(); + + for (const reference of publishResolvableQuoteReferences) { + const resolvedTarget = await resolveExternalQuoteTarget({ + account, + directories, + onStatus: (status) => { + setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status)); + }, + reference, + }); + + if (!resolvedTarget?.cid) { + setPublishReplyError( + t('external_quote_publish_missing', { + interpolation: { escapeValue: false }, + quote: reference.raw, + }), + ); + return; + } + + resolvedCids.add(resolvedTarget.cid); + } + + setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined); + setPublishReplyStateMessage(null); + setPendingPublishRequestId((requestId) => requestId + 1); + } catch { + setPublishReplyError(t('external_quote_resolution_unavailable')); + } finally { + setIsResolvingExternalQuotes(false); + } + }, + [account, blockedReason, directories, publishResolvableQuoteReferences, setPublishReplyOptions, t], + ); + + useEffect(() => { + if (pendingSyncedPublishRequestId === 0 || pendingSyncedPublishRequestId === startedSyncedPublishRequestIdRef.current) { + return; } - }, [account, blockedReason, directories, publishResolvableQuoteReferences, t]); + + startedSyncedPublishRequestIdRef.current = pendingSyncedPublishRequestId; + publishReply(); + }, [pendingSyncedPublishRequestId, publishReply]); return { isResolvingExternalQuotes, diff --git a/src/lib/utils/__tests__/post-options-utils.test.ts b/src/lib/utils/__tests__/post-options-utils.test.ts new file mode 100644 index 00000000..447eaa6d --- /dev/null +++ b/src/lib/utils/__tests__/post-options-utils.test.ts @@ -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'); + }); +}); diff --git a/src/lib/utils/post-options-utils.ts b/src/lib/utils/post-options-utils.ts new file mode 100644 index 00000000..7da9919a --- /dev/null +++ b/src/lib/utils/post-options-utils.ts @@ -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 { + 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 => { + 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 => `Your fortune: ${text}`; + +const appendFortuneToContent = (content: string, fortune: FortuneEntry): string => `${content}${getFortuneMarkup(fortune)}`; + +const rollDice = (diceOption: NonNullable>, 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 => `${getDiceRollText(diceRoll)}`; + +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, + diceRollRef: PostOptionsStateRef, + 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 = 'Your fortune: ([^<]+)<\\/b><\\/span>'; +const DICE_ROLL_MARKUP_PATTERN = '(Rolled \\d+(?:, \\d+)*(?: [+-] \\d+)?(?: = -?\\d+)? \\(\\d+d\\d+(?: [+-] \\d+)?\\))<\\/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);