From d2d047e5de779142c0a714a42768860fad8462e4 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 19 May 2026 15:45:22 +0700 Subject: [PATCH] feat(posts): add mod bbcode editor --- package.json | 1 + .../bbcode-content/bbcode-content.module.css | 49 +++++ .../bbcode-content/bbcode-content.tsx | 138 ++++++++++++ .../bbcode-editor-toolbar.module.css | 65 ++++++ .../bbcode-editor-toolbar.tsx | 197 ++++++++++++++++++ .../__tests__/comment-content.test.tsx | 68 +++++- .../comment-content/comment-content.tsx | 34 ++- src/components/post-desktop/post-desktop.tsx | 4 +- .../post-form/__tests__/post-form.test.tsx | 97 ++++++++- src/components/post-form/post-form.tsx | 78 ++++++- src/components/post-mobile/post-mobile.tsx | 4 +- .../__tests__/reply-modal.test.tsx | 50 ++++- src/components/reply-modal/reply-modal.tsx | 51 ++++- yarn.lock | 27 +++ 14 files changed, 843 insertions(+), 20 deletions(-) create mode 100644 src/components/bbcode-content/bbcode-content.module.css create mode 100644 src/components/bbcode-content/bbcode-content.tsx create mode 100644 src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.module.css create mode 100644 src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.tsx diff --git a/package.json b/package.json index 8881c8b4..990bb18a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "license": "GPL-3.0-or-later", "private": true, "dependencies": { + "@bbob/parser": "4.3.1", "@bitsocial/bitsocial-react-hooks": "0.1.10", "@capacitor/app": "7.0.1", "@capacitor/browser": "7.0.5", diff --git a/src/components/bbcode-content/bbcode-content.module.css b/src/components/bbcode-content/bbcode-content.module.css new file mode 100644 index 00000000..5dcdb065 --- /dev/null +++ b/src/components/bbcode-content/bbcode-content.module.css @@ -0,0 +1,49 @@ +.content { + overflow-wrap: anywhere; +} + +.underline { + text-decoration: underline; +} + +.colorRed { + color: red; +} + +.size12 { + font-size: 12px; + line-height: 1.3; +} + +.size16 { + font-size: 16px; + line-height: 1.3; +} + +.size20 { + font-size: 20px; + line-height: 1.25; +} + +.size24 { + font-size: 24px; + line-height: 1.2; +} + +.size32 { + font-size: 32px; + line-height: 1.15; +} + +.sizeLarge { + font-size: 1.35em; + line-height: 1.3; +} + +.quote { + display: block; + margin: 3px 0; + padding-left: 8px; + border-left: 2px solid currentColor; + opacity: 0.82; +} diff --git a/src/components/bbcode-content/bbcode-content.tsx b/src/components/bbcode-content/bbcode-content.tsx new file mode 100644 index 00000000..80852283 --- /dev/null +++ b/src/components/bbcode-content/bbcode-content.tsx @@ -0,0 +1,138 @@ +import { useMemo, type ReactNode } from 'react'; +import { parse } from '@bbob/parser'; +import { parseHttpUrl } from '../../lib/utils/url-utils'; +import Markdown from '../markdown'; +import styles from './bbcode-content.module.css'; + +const ALLOWED_BBCODE_TAGS = ['b', 'i', 'u', 's', 'color', 'size', 'quote', 'url']; +const COLOR_CLASS_BY_NAME: Record = { + red: styles.colorRed, +}; +const SIZE_CLASS_BY_NAME: Record = { + '12': styles.size12, + '16': styles.size16, + '20': styles.size20, + '24': styles.size24, + '32': styles.size32, + large: styles.sizeLarge, +}; + +type BbcodeNode = + | string + | number + | null + | { + attrs?: Record; + content?: BbcodeNode | BbcodeNode[]; + tag?: unknown; + }; + +interface BbcodeContentProps { + communityAddress?: string; + content: string; + postCid?: string; +} + +const getFirstAttributeValue = (attrs: Record | undefined): string | undefined => { + const firstEntry = Object.entries(attrs || {})[0]; + if (!firstEntry) return undefined; + + const [key, value] = firstEntry; + return typeof value === 'string' ? value : key; +}; + +const normalizeContent = (content: BbcodeNode | BbcodeNode[] | undefined): BbcodeNode[] => { + if (Array.isArray(content)) return content; + return typeof content === 'undefined' ? [] : [content]; +}; + +const getPlainTextContent = (content: BbcodeNode | BbcodeNode[] | undefined): string => + normalizeContent(content) + .map((node) => { + if (node === null) return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + return getPlainTextContent(node.content); + }) + .join(''); + +const renderNode = (node: BbcodeNode, key: string, props: BbcodeContentProps): ReactNode => { + if (node === null) return null; + if (typeof node === 'string' || typeof node === 'number') { + const content = String(node); + return content ? : null; + } + + const tag = typeof node.tag === 'string' ? node.tag.toLowerCase() : ''; + const children = normalizeContent(node.content).map((child, index) => renderNode(child, `${key}-${index}`, props)); + + switch (tag) { + case 'b': + return {children}; + case 'i': + return {children}; + case 'u': + return ( + + {children} + + ); + case 's': + return {children}; + case 'color': { + const colorName = getFirstAttributeValue(node.attrs)?.trim().toLowerCase(); + const colorClass = colorName ? COLOR_CLASS_BY_NAME[colorName] : undefined; + return colorClass ? ( + + {children} + + ) : ( + {children} + ); + } + case 'size': { + const sizeName = getFirstAttributeValue(node.attrs)?.trim().toLowerCase(); + const sizeClass = sizeName ? SIZE_CLASS_BY_NAME[sizeName] : undefined; + return sizeClass ? ( + + {children} + + ) : ( + {children} + ); + } + case 'quote': + return ( + + {children} + + ); + case 'url': { + const urlValue = getFirstAttributeValue(node.attrs) || getPlainTextContent(node.content); + const parsedUrl = parseHttpUrl(urlValue.trim()); + return parsedUrl ? ( + + {children} + + ) : ( + {children} + ); + } + default: + return {children}; + } +}; + +const BbcodeContent = (props: BbcodeContentProps) => { + const nodes = useMemo( + () => + parse(props.content || '', { + caseFreeTags: true, + onlyAllowTags: ALLOWED_BBCODE_TAGS, + }) as BbcodeNode[], + [props.content], + ); + + return {nodes.map((node, index) => renderNode(node, `bbcode-${index}`, props))}; +}; + +export default BbcodeContent; diff --git a/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.module.css b/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.module.css new file mode 100644 index 00000000..6aac83fb --- /dev/null +++ b/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.module.css @@ -0,0 +1,65 @@ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + margin-bottom: 2px; + max-width: 420px; +} + +.toolbar .toolbarButton { + position: static; + bottom: auto; + margin: 0; + min-width: 22px; + height: 20px; + padding: 1px 5px; + line-height: 16px; + cursor: pointer; + filter: var(--filter80); + font-size: 10pt; + text-transform: none; +} + +.toolbar .toolbarButton:disabled { + cursor: default; + opacity: 0.6; +} + +.toolbarSelect { + height: 20px; + max-width: 74px; + font-size: 10pt; +} + +.boldButton { + font-weight: 700; +} + +.italicButton { + font-style: italic; +} + +.underlineButton { + text-decoration: underline; +} + +.strikeButton { + text-decoration: line-through; +} + +.redButton { + color: red; + font-weight: 700; +} + +.preview { + box-sizing: border-box; + width: min(100%, 420px); + min-height: 72px; + margin-bottom: 2px; + padding: 3px 4px; + border: 1px inset; + background: var(--post-form-field-input-background-color, revert); + overflow-wrap: anywhere; +} diff --git a/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.tsx b/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.tsx new file mode 100644 index 00000000..92311c1c --- /dev/null +++ b/src/components/bbcode-editor-toolbar/bbcode-editor-toolbar.tsx @@ -0,0 +1,197 @@ +import BbcodeContent from '../bbcode-content/bbcode-content'; +import styles from './bbcode-editor-toolbar.module.css'; + +interface BbcodeButton { + className?: string; + close: string; + label: string; + open: string; + placeholder: string; + title: string; +} + +const BBCODE_BUTTONS: BbcodeButton[] = [ + { + className: styles.boldButton, + close: '[/b]', + label: 'B', + open: '[b]', + placeholder: 'bold text', + title: 'Bold', + }, + { + className: styles.italicButton, + close: '[/i]', + label: 'I', + open: '[i]', + placeholder: 'italic text', + title: 'Italic', + }, + { + className: styles.underlineButton, + close: '[/u]', + label: 'U', + open: '[u]', + placeholder: 'underlined text', + title: 'Underline', + }, + { + className: styles.strikeButton, + close: '[/s]', + label: 'S', + open: '[s]', + placeholder: 'struck text', + title: 'Strikethrough', + }, + { + className: styles.redButton, + close: '[/color]', + label: 'Red', + open: '[color=red]', + placeholder: 'red text', + title: 'Red text', + }, +]; + +const SIZE_OPTIONS = [ + { label: '12px', value: '12' }, + { label: '16px', value: '16' }, + { label: '20px', value: '20' }, + { label: '24px', value: '24' }, + { label: '32px', value: '32' }, +]; + +interface BbcodeEditorToolbarProps { + isPreviewing: boolean; + onChange: (value: string, selectionStart?: number, selectionEnd?: number) => void; + onPreviewToggle: () => void; + textareaRef: { current: HTMLTextAreaElement | null }; +} + +const applyBbcode = (textarea: HTMLTextAreaElement, open: string, close: string, placeholder: string) => { + const value = textarea.value; + const selectionStart = textarea.selectionStart ?? value.length; + const selectionEnd = textarea.selectionEnd ?? selectionStart; + const selectedText = value.slice(selectionStart, selectionEnd); + const innerText = selectedText || placeholder; + const insertion = `${open}${innerText}${close}`; + const nextValue = `${value.slice(0, selectionStart)}${insertion}${value.slice(selectionEnd)}`; + const nextSelectionStart = selectionStart + open.length; + const nextSelectionEnd = nextSelectionStart + innerText.length; + + textarea.value = nextValue; + textarea.focus(); + textarea.setSelectionRange(nextSelectionStart, nextSelectionEnd); + + return { + nextSelectionEnd, + nextSelectionStart, + nextValue, + }; +}; + +const applyLinkBbcode = (textarea: HTMLTextAreaElement) => { + const value = textarea.value; + const selectionStart = textarea.selectionStart ?? value.length; + const selectionEnd = textarea.selectionEnd ?? selectionStart; + const selectedText = value.slice(selectionStart, selectionEnd) || 'link text'; + const urlPlaceholder = 'https://example.com'; + const open = `[url=${urlPlaceholder}]`; + const close = '[/url]'; + const insertion = `${open}${selectedText}${close}`; + const nextValue = `${value.slice(0, selectionStart)}${insertion}${value.slice(selectionEnd)}`; + const nextSelectionStart = selectionStart + '[url='.length; + const nextSelectionEnd = nextSelectionStart + urlPlaceholder.length; + + textarea.value = nextValue; + textarea.focus(); + textarea.setSelectionRange(nextSelectionStart, nextSelectionEnd); + + return { + nextSelectionEnd, + nextSelectionStart, + nextValue, + }; +}; + +const BbcodeEditorToolbar = ({ isPreviewing, onChange, onPreviewToggle, textareaRef }: BbcodeEditorToolbarProps) => { + const updateContent = (value: string, selectionStart?: number, selectionEnd?: number) => onChange(value, selectionStart, selectionEnd); + + const handleApply = (open: string, close: string, placeholder: string) => { + const textarea = textareaRef.current; + if (!textarea || isPreviewing) return; + + const { nextSelectionEnd, nextSelectionStart, nextValue } = applyBbcode(textarea, open, close, placeholder); + updateContent(nextValue, nextSelectionStart, nextSelectionEnd); + }; + + const handleLink = () => { + const textarea = textareaRef.current; + if (!textarea || isPreviewing) return; + + const { nextSelectionEnd, nextSelectionStart, nextValue } = applyLinkBbcode(textarea); + updateContent(nextValue, nextSelectionStart, nextSelectionEnd); + }; + + return ( +
+ {BBCODE_BUTTONS.map((button) => ( + + ))} + + + +
+ ); +}; + +export const BbcodePreview = ({ communityAddress, content, postCid }: { communityAddress?: string; content: string; postCid?: string }) => ( +
+ +
+); + +export default BbcodeEditorToolbar; diff --git a/src/components/comment-content/__tests__/comment-content.test.tsx b/src/components/comment-content/__tests__/comment-content.test.tsx index 9b1d8af0..a9bf58cd 100644 --- a/src/components/comment-content/__tests__/comment-content.test.tsx +++ b/src/components/comment-content/__tests__/comment-content.test.tsx @@ -9,6 +9,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise type TestComment = { author?: { + address?: string; community?: { banExpiresAt?: number; }; @@ -179,9 +180,11 @@ vi.mock('../../tooltip', () => ({ let container: HTMLDivElement; let root: Root; -const renderContent = async (comment: TestComment) => { +type TestRoleMap = Record; + +const renderContent = async (comment: TestComment, roles?: TestRoleMap) => { await act(async () => { - root.render(createElement(CommentContent, { comment } as any)); + root.render(createElement(CommentContent, { comment, roles } as any)); }); }; @@ -288,6 +291,67 @@ describe('CommentContent', () => { expect(blockquote?.querySelectorAll('br')).toHaveLength(2); }); + it('renders whitelisted BBCode only for board moderator authors', async () => { + await renderContent( + { + author: { address: '0xmod' }, + cid: 'post-1', + communityAddress: 'music-posting.eth', + content: '[b]bold[/b] [color=red][size=24][url=https://example.com]large red[/url][/size][/color] [x]literal[/x]', + postCid: 'post-1', + }, + { + '0xmod': { role: 'moderator' }, + }, + ); + + expect(container.querySelector('strong')?.textContent).toBe('bold'); + expect(container.querySelector('[class*="colorRed"]')?.textContent).toBe('large red'); + expect(container.querySelector('[class*="size24"]')?.textContent).toBe('large red'); + expect(container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/'); + expect(container.textContent).toContain('[x]literal[/x]'); + + await renderContent( + { + author: { address: '0xuser' }, + cid: 'post-2', + communityAddress: 'music-posting.eth', + content: '[b]plain[/b]', + postCid: 'post-2', + }, + { + '0xuser': { role: 'user' }, + }, + ); + + expect(container.querySelector('strong')).toBeNull(); + expect(queryMarkdownText()).toEqual(['[b]plain[/b]']); + }); + + it('ignores unsupported BBCode styling values for moderator authors', async () => { + await renderContent( + { + author: { address: '0xmod' }, + cid: 'post-1', + content: '[color=#ff0000]hex[/color] [color=blue]blue[/color] [url=javascript:alert(1)]bad[/url] [size=huge]huge[/size]', + postCid: 'post-1', + }, + { + '0xmod': { role: 'admin' }, + }, + ); + + expect(container.textContent).toContain('hex'); + expect(container.textContent).toContain('blue'); + expect(container.textContent).toContain('bad'); + expect(container.textContent).toContain('huge'); + expect(container.querySelector('[class*="colorRed"]')).toBeNull(); + expect(container.querySelector('[class*="colorBlue"]')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('[class*="sizeLarge"]')).toBeNull(); + expect(container.querySelector('[class*="size24"]')).toBeNull(); + }); + it('truncates long comments outside the post view and expands them on demand', async () => { const longComment = 'x'.repeat(1105); diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index e4c44fd7..16b73fb3 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -11,6 +11,7 @@ import { isPostPageView } from '../../lib/utils/view-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import useStateString from '../../hooks/use-state-string'; import LoadingEllipsis from '../../components/loading-ellipsis'; +import BbcodeContent from '../../components/bbcode-content/bbcode-content'; import ErrorDisplay from '../../components/error-display/error-display'; import ReplyQuotePreview from '../../components/reply-quote-preview'; import Markdown from '../../components/markdown'; @@ -19,6 +20,7 @@ import styles from '../../views/post/post.module.css'; import capitalize from 'lodash/capitalize'; import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { formatErrorMessageForDisplay } from '../../lib/utils/error-utils'; +import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => { const quotedNumber = usePostNumberStore((state) => state.cidToNumber[cid]); @@ -71,7 +73,24 @@ const getFailedCommentError = (comment: Comment | undefined): unknown => { return Array.isArray(comment.errors) ? comment.errors.find(Boolean) : undefined; }; -const CommentContent = ({ appendContent, comment: post, prependContent }: { appendContent?: ReactNode; comment: Comment; prependContent?: ReactNode }) => { +const getRoleByAddress = (roles: unknown, address?: string): string | undefined => { + if (!roles || !address || typeof roles !== 'object') return undefined; + + const roleEntry = (roles as Record)[address]; + return typeof roleEntry?.role === 'string' ? roleEntry.role : undefined; +}; + +const CommentContent = ({ + appendContent, + comment: post, + prependContent, + roles, +}: { + appendContent?: ReactNode; + comment: Comment; + prependContent?: ReactNode; + roles?: unknown; +}) => { const { t } = useTranslation(); const params = useParams(); const location = useLocation(); @@ -82,6 +101,9 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe const { cid, content, deleted, edit, original, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {}; const communityAddress = getCommentCommunityAddress(resolvedPost); + const authorAddress = resolvedPost?.author?.address; + const authorRole = getRoleByAddress(roles, authorAddress); + const shouldRenderBbcode = hasModQueueAccessRole(authorRole); const purged = resolvedPost?.commentModeration?.purged; const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt; const banned = !!banExpiresAt; @@ -146,6 +168,12 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe )} ); + const renderContent = (value: string | undefined) => + shouldRenderBbcode ? ( + + ) : ( + + ); return (
@@ -185,7 +213,7 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe ) ) : ( <> - {!showOriginal && } + {!showOriginal && renderContent(displayContent)} {pendingApproval && ( <>
@@ -221,7 +249,7 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe )} {edit && original?.content !== content && ( - {showOriginal && } + {showOriginal && renderContent(original?.content)}

)} {post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && ( - + )} @@ -1198,7 +1198,7 @@ const PostDesktop = ({ directRepliesByParentCid={directRepliesByParentCid} /> {!isHidden && !content && !(deleted || removed || purged) &&
} - {resolvedPost && !isHidden && } + {resolvedPost && !isHidden && }
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && ( diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 1eb4e6c0..eb5582f7 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -10,7 +10,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ account: { - author: { displayName: 'Alice' }, + author: { address: 'alice.eth', displayName: 'Alice' }, subscriptions: ['music-posting.eth'], }, accountComment: undefined as { communityAddress?: string } | undefined, @@ -40,6 +40,7 @@ const testState = vi.hoisted(() => ({ resetPublishPostOptionsMock: vi.fn(), resetPublishReplyOptionsMock: vi.fn(), resolvedCommunityAddress: undefined as string | undefined, + rolesByCommunity: {} as Record>, setAccountMock: vi.fn(), setPublishPostOptionsMock: vi.fn(), setPublishReplyOptionsMock: vi.fn(), @@ -103,6 +104,11 @@ vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); +vi.mock('../../../hooks/use-stable-community', () => ({ + useCommunityField: (communityAddress: string | undefined, selector: (community?: { roles?: Record }) => T) => + selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined), +})); + vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ default: () => ({ status: testState.gifFrameStatus, @@ -352,7 +358,7 @@ describe('PostForm', () => { beforeEach(() => { vi.clearAllMocks(); testState.account = { - author: { displayName: 'Alice' }, + author: { address: 'alice.eth', displayName: 'Alice' }, subscriptions: ['music-posting.eth'], }; testState.accountComment = undefined; @@ -376,6 +382,7 @@ describe('PostForm', () => { testState.publishReplyStateMessage = null; testState.replyIndex = undefined; testState.resolvedCommunityAddress = undefined; + testState.rolesByCommunity = {}; testState.showUploadControls = true; testState.uploadComplete = undefined; testState.uploadMode = 'always'; @@ -533,6 +540,92 @@ describe('PostForm', () => { expect(testState.publishedPostOptions?.content).toBeUndefined(); }); + it('shows BBCode controls only for board mods and inserts tags into the post textarea', async () => { + testState.account = { + author: { address: 'mod.eth', displayName: 'Alice' }, + subscriptions: ['music-posting.eth'], + }; + testState.resolvedCommunityAddress = 'music-posting.eth'; + testState.rolesByCommunity = { + 'music-posting.eth': { + 'mod.eth': { role: 'moderator' }, + }, + }; + + await renderPostForm('/mu'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table'); + const textarea = table?.querySelector('textarea'); + const boldButton = table?.querySelector('button[aria-label="Bold"]'); + const redButton = table?.querySelector('button[aria-label="Red text"]'); + const linkButton = table?.querySelector('button[aria-label="Link"]'); + const sizeSelect = table?.querySelector('select[aria-label="Text size"]'); + const rows = Array.from(table?.querySelectorAll('tr') || []); + expect(textarea).toBeTruthy(); + expect(boldButton).toBeTruthy(); + expect(redButton).toBeTruthy(); + expect(linkButton).toBeTruthy(); + expect(sizeSelect).toBeTruthy(); + expect(table?.querySelector('select[aria-label="Text color"]')).toBeNull(); + expect(rows.some((row) => row.querySelector('td')?.textContent === 'mods only')).toBe(true); + expect(rows.some((row) => row.querySelector('td')?.textContent === 'comment')).toBe(true); + expect(table?.textContent).not.toContain('Mod editor'); + expect(table?.querySelector('button[aria-label="Quote"]')).toBeNull(); + + await dispatchInput(textarea as HTMLTextAreaElement, 'hello world'); + textarea?.setSelectionRange(0, 5); + await act(async () => { + boldButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(textarea?.value).toBe('[b]hello[/b] world'); + expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b]hello[/b] world' }); + + const worldStart = textarea?.value.indexOf('world') ?? 0; + textarea?.setSelectionRange(worldStart, worldStart + 'world'.length); + await act(async () => { + redButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(textarea?.value).toBe('[b]hello[/b] [color=red]world[/color]'); + expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b]hello[/b] [color=red]world[/color]' }); + + const helloStart = textarea?.value.indexOf('hello') ?? 0; + textarea?.setSelectionRange(helloStart, helloStart + 'hello'.length); + await dispatchChange(sizeSelect as HTMLSelectElement, '24'); + + expect(textarea?.value).toBe('[b][size=24]hello[/size][/b] [color=red]world[/color]'); + expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b][size=24]hello[/size][/b] [color=red]world[/color]' }); + + const linkedWorldStart = textarea?.value.indexOf('world') ?? 0; + textarea?.setSelectionRange(linkedWorldStart, linkedWorldStart + 'world'.length); + await act(async () => { + linkButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const linkedContent = '[b][size=24]hello[/size][/b] [color=red][url=https://example.com]world[/url][/color]'; + expect(textarea?.value).toBe(linkedContent); + expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: linkedContent }); + + await clickByText(table as HTMLTableElement, 'Preview'); + const preview = table?.querySelector('[aria-label="BBCode preview"]'); + expect(preview?.textContent).toContain('hello'); + expect(preview?.textContent).toContain('world'); + expect(preview?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/'); + expect(table?.querySelector('textarea')?.value).toBe(linkedContent); + + await clickByText(table as HTMLTableElement, 'Edit'); + expect(table?.querySelector('[aria-label="BBCode preview"]')).toBeNull(); + + testState.rolesByCommunity = {}; + await renderPostForm('/mu'); + await clickByText(container, 'start_new_thread'); + + expect(container.querySelector('button[aria-label="Bold"]')).toBeNull(); + expect(container.textContent).not.toContain('mods only'); + }); + it('shows the pasted file-link filename next to the upload button', async () => { testState.uploadedFileName = null; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 1a55ee4c..cd9ff065 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -8,9 +8,11 @@ import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stor import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; +import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; +import { useCommunityField } from '../../hooks/use-stable-community'; import useIsMobile from '../../hooks/use-is-mobile'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import useSafeAccountComment from '../../hooks/use-safe-account-comment'; @@ -22,6 +24,7 @@ import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/sho import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; +import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import LoadingEllipsis from '../loading-ellipsis'; import styles from './post-form.module.css'; import capitalize from 'lodash/capitalize'; @@ -95,13 +98,17 @@ interface PostFormFieldsProps { t: TFunction; account: ReturnType; displayName: string | undefined; + bbcodePreviewContent: string; isInPostView: boolean; + isBbcodePreviewing: boolean; + postCid: string; subjectRef: React.Ref; - textRef: React.Ref; + textRef: React.RefObject; urlRef: React.Ref; url: string; lengthError: string | null; handleContentChange: (e: React.ChangeEvent) => void; + handleContentValueChange: (content: string) => void; setPublishPostOptions: (opts: Record) => void; setPublishReplyOptions: (opts: Record) => void; setUrl: (url: string) => void; @@ -118,6 +125,8 @@ interface PostFormFieldsProps { subscriptions: string[]; communityAddress: string | undefined; requirePostLinkIsMedia: boolean; + showBbcodeToolbar: boolean; + onBbcodePreviewToggle: () => void; onPublishReply: () => void; onPublishPost: () => void; handleUpload: () => void; @@ -128,13 +137,17 @@ const PostFormFields = ({ t, account, displayName, + bbcodePreviewContent, isInPostView, + isBbcodePreviewing, + postCid, subjectRef, textRef, urlRef, url, lengthError, handleContentChange, + handleContentValueChange, setPublishPostOptions, setPublishReplyOptions, setUrl, @@ -151,6 +164,8 @@ const PostFormFields = ({ subscriptions, communityAddress, requirePostLinkIsMedia, + showBbcodeToolbar, + onBbcodePreviewToggle, onPublishReply, onPublishPost, handleUpload, @@ -214,10 +229,34 @@ const PostFormFields = ({ )} + {showBbcodeToolbar ? ( + + mods only + + handleContentValueChange(content)} + isPreviewing={isBbcodePreviewing} + onPreviewToggle={onBbcodePreviewToggle} + /> + + + ) : null} {t('comment')} -