From ff6b4cd201adc0fd90c04ee891d5272458b227ed Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 21 May 2026 22:12:41 +0700 Subject: [PATCH] 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 --- .../markdown/__tests__/markdown.test.tsx | 57 +++++ src/components/markdown/markdown.tsx | 163 +++++++++++++- .../post-form/__tests__/post-form.test.tsx | 207 ++++++++++++++++-- src/components/post-form/post-form.tsx | 97 ++++++-- .../__tests__/reply-modal.test.tsx | 201 ++++++++++++++++- src/components/reply-modal/reply-modal.tsx | 115 +++++++--- src/hooks/__tests__/use-publish-post.test.tsx | 18 ++ .../__tests__/use-publish-reply.test.tsx | 19 ++ src/hooks/use-publish-post.ts | 26 ++- src/hooks/use-publish-reply.ts | 130 ++++++----- .../__tests__/post-options-utils.test.ts | 8 + src/lib/utils/post-options-utils.ts | 202 +++++++++++++++++ 12 files changed, 1119 insertions(+), 124 deletions(-) create mode 100644 src/lib/utils/__tests__/post-options-utils.test.ts create mode 100644 src/lib/utils/post-options-utils.ts 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: 'body

Your 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 body

Your 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 fortune

Your 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'))} [