merge: mod bbcode editor

This commit is contained in:
Tommaso Casaburi
2026-05-19 15:45:36 +07:00
14 changed files with 843 additions and 20 deletions
+1
View File
@@ -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",
@@ -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;
}
@@ -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<string, string> = {
red: styles.colorRed,
};
const SIZE_CLASS_BY_NAME: Record<string, string> = {
'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<string, unknown>;
content?: BbcodeNode | BbcodeNode[];
tag?: unknown;
};
interface BbcodeContentProps {
communityAddress?: string;
content: string;
postCid?: string;
}
const getFirstAttributeValue = (attrs: Record<string, unknown> | 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 ? <Markdown key={key} content={content} postCid={props.postCid} communityAddress={props.communityAddress} /> : 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 <strong key={key}>{children}</strong>;
case 'i':
return <em key={key}>{children}</em>;
case 'u':
return (
<span key={key} className={styles.underline}>
{children}
</span>
);
case 's':
return <s key={key}>{children}</s>;
case 'color': {
const colorName = getFirstAttributeValue(node.attrs)?.trim().toLowerCase();
const colorClass = colorName ? COLOR_CLASS_BY_NAME[colorName] : undefined;
return colorClass ? (
<span key={key} className={colorClass}>
{children}
</span>
) : (
<span key={key}>{children}</span>
);
}
case 'size': {
const sizeName = getFirstAttributeValue(node.attrs)?.trim().toLowerCase();
const sizeClass = sizeName ? SIZE_CLASS_BY_NAME[sizeName] : undefined;
return sizeClass ? (
<span key={key} className={sizeClass}>
{children}
</span>
) : (
<span key={key}>{children}</span>
);
}
case 'quote':
return (
<span key={key} className={styles.quote}>
{children}
</span>
);
case 'url': {
const urlValue = getFirstAttributeValue(node.attrs) || getPlainTextContent(node.content);
const parsedUrl = parseHttpUrl(urlValue.trim());
return parsedUrl ? (
<a key={key} href={parsedUrl.href} target='_blank' rel='noopener noreferrer'>
{children}
</a>
) : (
<span key={key}>{children}</span>
);
}
default:
return <span key={key}>{children}</span>;
}
};
const BbcodeContent = (props: BbcodeContentProps) => {
const nodes = useMemo(
() =>
parse(props.content || '', {
caseFreeTags: true,
onlyAllowTags: ALLOWED_BBCODE_TAGS,
}) as BbcodeNode[],
[props.content],
);
return <span className={styles.content}>{nodes.map((node, index) => renderNode(node, `bbcode-${index}`, props))}</span>;
};
export default BbcodeContent;
@@ -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;
}
@@ -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 (
<div className={styles.toolbar} aria-label='BBCode formatting'>
{BBCODE_BUTTONS.map((button) => (
<button
key={button.open}
type='button'
className={`${styles.toolbarButton} ${button.className || ''}`}
aria-label={button.title}
title={button.title}
disabled={isPreviewing}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleApply(button.open, button.close, button.placeholder)}
>
{button.label}
</button>
))}
<button
type='button'
className={styles.toolbarButton}
aria-label='Link'
title='Link'
disabled={isPreviewing}
onMouseDown={(event) => event.preventDefault()}
onClick={handleLink}
>
🔗
</button>
<select
className={styles.toolbarSelect}
aria-label='Text size'
title='Text size'
defaultValue=''
disabled={isPreviewing}
onChange={(event) => {
const size = event.target.value;
if (size) {
handleApply(`[size=${size}]`, '[/size]', `${size}px text`);
event.target.value = '';
}
}}
>
<option value=''>Size</option>
{SIZE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button type='button' className={styles.toolbarButton} aria-label={isPreviewing ? 'Edit BBCode' : 'Preview BBCode'} onClick={onPreviewToggle}>
{isPreviewing ? 'Edit' : 'Preview'}
</button>
</div>
);
};
export const BbcodePreview = ({ communityAddress, content, postCid }: { communityAddress?: string; content: string; postCid?: string }) => (
<div className={styles.preview} aria-label='BBCode preview'>
<BbcodeContent content={content} postCid={postCid} communityAddress={communityAddress} />
</div>
);
export default BbcodeEditorToolbar;
@@ -9,6 +9,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => 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<string, { role?: string }>;
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);
@@ -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<string, { role?: unknown } | undefined>)[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
)}
</div>
);
const renderContent = (value: string | undefined) =>
shouldRenderBbcode ? (
<BbcodeContent content={value || ''} postCid={postCid} communityAddress={communityAddress} />
) : (
<Markdown content={value || ''} postCid={postCid} communityAddress={communityAddress} />
);
return (
<blockquote className={`${styles.postMessage} ${!isReply && isMobile && styles.clampLines}`}>
@@ -185,7 +213,7 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe
)
) : (
<>
{!showOriginal && <Markdown content={displayContent} postCid={postCid} communityAddress={communityAddress} />}
{!showOriginal && renderContent(displayContent)}
{pendingApproval && (
<>
<br />
@@ -221,7 +249,7 @@ const CommentContent = ({ appendContent, comment: post, prependContent }: { appe
)}
{edit && original?.content !== content && (
<span className={styles.editedInfo}>
{showOriginal && <Markdown content={original?.content} postCid={postCid} communityAddress={communityAddress} />}
{showOriginal && renderContent(original?.content)}
<br />
<br />
<Trans
+2 -2
View File
@@ -819,7 +819,7 @@ const Reply = ({
/>
)}
{post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
<CommentContent comment={post} prependContent={failedPublishNotice} />
<CommentContent comment={post} prependContent={failedPublishNotice} roles={roles} />
)}
</div>
</div>
@@ -1198,7 +1198,7 @@ const PostDesktop = ({
directRepliesByParentCid={directRepliesByParentCid}
/>
{!isHidden && !content && !(deleted || removed || purged) && <div className={styles.spacer} />}
{resolvedPost && !isHidden && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && !isHidden && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} roles={roles} />}
</div>
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && (
<span className={styles.summary}>
@@ -10,7 +10,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => 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<string, Record<string, { role?: string }>>,
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: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => 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<HTMLTextAreaElement>('textarea');
const boldButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Bold"]');
const redButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Red text"]');
const linkButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Link"]');
const sizeSelect = table?.querySelector<HTMLSelectElement>('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<HTMLTextAreaElement>('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;
+74 -4
View File
@@ -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<typeof useAccount>;
displayName: string | undefined;
bbcodePreviewContent: string;
isInPostView: boolean;
isBbcodePreviewing: boolean;
postCid: string;
subjectRef: React.Ref<HTMLInputElement>;
textRef: React.Ref<HTMLTextAreaElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
lengthError: string | null;
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleContentValueChange: (content: string) => void;
setPublishPostOptions: (opts: Record<string, unknown>) => void;
setPublishReplyOptions: (opts: Record<string, unknown>) => 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 = ({
</td>
</tr>
)}
{showBbcodeToolbar ? (
<tr>
<td>mods only</td>
<td>
<BbcodeEditorToolbar
textareaRef={textRef}
onChange={(content) => handleContentValueChange(content)}
isPreviewing={isBbcodePreviewing}
onPreviewToggle={onBbcodePreviewToggle}
/>
</td>
</tr>
) : null}
<tr>
<td>{t('comment')}</td>
<td>
<textarea cols={48} rows={4} wrap='soft' ref={textRef} aria-label={t('comment')} onChange={handleContentChange} />
{showBbcodeToolbar && isBbcodePreviewing && (
<BbcodePreview content={bbcodePreviewContent} postCid={isInPostView ? postCid : undefined} communityAddress={communityAddress} />
)}
<textarea
cols={48}
rows={4}
wrap='soft'
ref={textRef}
aria-label={t('comment')}
hidden={showBbcodeToolbar && isBbcodePreviewing}
onChange={handleContentChange}
/>
{lengthError && <div className={styles.error}>{lengthError}</div>}
</td>
</tr>
@@ -345,9 +384,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
const roles = useCommunityField(effectiveBoardAddress, (community) => community?.roles);
const accountRole = accountAddress ? roles?.[accountAddress]?.role : undefined;
const showBbcodeToolbar = hasModQueueAccessRole(accountRole) || (!effectiveBoardAddress && isInModView && accountCommunityAddresses.length > 0);
const [lengthError, setLengthError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const checkContentLength = useRef(
debounce((content: string, t: TFunction) => {
@@ -370,6 +415,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
if (subjectRef.current) {
subjectRef.current.value = '';
}
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
};
const onPublishPost = () => {
@@ -435,8 +482,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
}, [checkContentLength, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
const handleContentValueChange = (content: string) => {
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
if (isInPostView) {
setPublishReplyOptions({ content });
} else {
@@ -445,6 +494,21 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
checkContentLength(content, t);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleContentValueChange(e.target.value);
};
const handleBbcodePreviewToggle = () => {
if (isBbcodePreviewing) {
setIsBbcodePreviewing(false);
window.requestAnimationFrame(() => textRef.current?.focus());
return;
}
setBbcodePreviewContent(textRef.current?.value ?? '');
setIsBbcodePreviewing(true);
};
const onPublishReply = () => {
const currentContent = textRef.current?.value.trim() || '';
const currentUrl = urlRef.current?.value.trim() || '';
@@ -521,13 +585,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
t={t}
account={account}
displayName={displayName}
bbcodePreviewContent={bbcodePreviewContent}
isInPostView={isInPostView}
isBbcodePreviewing={isBbcodePreviewing}
postCid={postCid}
subjectRef={subjectRef}
textRef={textRef}
urlRef={urlRef}
url={url}
lengthError={lengthError}
handleContentChange={handleContentChange}
handleContentValueChange={handleContentValueChange}
setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions}
setUrl={setUrl}
@@ -544,6 +612,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
subscriptions={subscriptions}
communityAddress={communityAddress}
requirePostLinkIsMedia={requirePostLinkIsMedia}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
onPublishPost={onPublishPost}
handleUpload={handleUpload}
+2 -2
View File
@@ -578,7 +578,7 @@ const Reply = ({
threadNumber={threadNumber}
/>
{post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
<CommentContent appendContent={mediaLoadFailureInfo} comment={post} prependContent={failedPublishNotice} />
<CommentContent appendContent={mediaLoadFailureInfo} comment={post} prependContent={failedPublishNotice} roles={roles} />
)}
{post && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
@@ -876,7 +876,7 @@ const PostMobile = ({
roles={roles}
threadNumber={resolvedPost?.number}
/>
{resolvedPost && <CommentContent appendContent={mediaLoadFailureInfo} comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && <CommentContent appendContent={mediaLoadFailureInfo} comment={resolvedPost} prependContent={failedPublishNotice} roles={roles} />}
{resolvedPost && <ReplyBacklinks post={resolvedPost} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
@@ -9,7 +9,7 @@ import ReplyModal from '../reply-modal';
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: { author: { displayName: 'Alice' } } as { author?: { displayName?: string } },
account: { author: { address: 'alice.eth', displayName: 'Alice' } } as { author?: { address?: string; displayName?: string } },
closeModalMock: vi.fn(),
directoryByAddress: {
'music-posting.eth': {
@@ -36,6 +36,7 @@ const testState = vi.hoisted(() => ({
replyIndex: undefined as number | undefined,
resetPublishReplyOptionsMock: vi.fn(),
resolvedCommunityAddress: undefined as string | undefined,
rolesByCommunity: {} as Record<string, Record<string, { role?: string }>>,
selectedText: 'selected text',
setAccountMock: vi.fn(),
setPublishReplyOptionsMock: vi.fn(),
@@ -139,6 +140,11 @@ vi.mock('../../../hooks/use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
}));
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => T) =>
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
}));
vi.mock('../../../hooks/use-publish-reply', () => ({
default: () => ({
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
@@ -273,7 +279,7 @@ const clickButtonByText = async (text: string) => {
describe('ReplyModal', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { author: { displayName: 'Alice' } };
testState.account = { author: { address: 'alice.eth', displayName: 'Alice' } };
testState.closeModalMock.mockReset();
testState.directoryByAddress = {
'music-posting.eth': {
@@ -300,6 +306,7 @@ describe('ReplyModal', () => {
testState.replyIndex = undefined;
testState.resetPublishReplyOptionsMock.mockReset();
testState.resolvedCommunityAddress = undefined;
testState.rolesByCommunity = {};
testState.selectedText = 'selected text';
testState.setAccountMock.mockReset();
testState.setPublishReplyOptionsMock.mockReset();
@@ -428,6 +435,43 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
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 = {
'music-posting.eth': {
'mod.eth': { role: 'admin' },
},
};
await renderReplyModal('/mu/thread/post-1');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
const redButton = container.querySelector<HTMLButtonElement>('button[aria-label="Red text"]');
const linkButton = container.querySelector<HTMLButtonElement>('button[aria-label="Link"]');
expect(textarea).toBeTruthy();
expect(redButton).toBeTruthy();
expect(linkButton).toBeTruthy();
expect(container.querySelector('select[aria-label="Text color"]')).toBeNull();
expect(container.textContent).not.toContain('mods only');
expect(container.textContent).not.toContain('Mod editor');
expect(container.querySelector('button[aria-label="Quote"]')).toBeNull();
const selectionStart = textarea?.value.indexOf('selected text') ?? 0;
textarea?.setSelectionRange(selectionStart, selectionStart + 'selected text'.length);
await act(async () => {
redButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(textarea?.value).toBe('>>42\n[color=red]selected text[/color]');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\n[color=red]selected text[/color]' });
testState.rolesByCommunity = {};
await rerenderReplyModal('/mu/thread/post-1');
expect(container.querySelector('button[aria-label="Red text"]')).toBeNull();
expect(container.textContent).not.toContain('mods only');
});
it('updates account state, applies upload completions, and closes once publishing succeeds', async () => {
await renderReplyModal('/mu/thread/post-1');
@@ -436,7 +480,7 @@ describe('ReplyModal', () => {
await dispatchInput(nameInput, 'Alicia');
expect(testState.setAccountMock).toHaveBeenCalledWith({
author: { displayName: 'Alicia' },
author: { address: 'alice.eth', displayName: 'Alicia' },
});
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alicia' });
+49 -2
View File
@@ -5,6 +5,7 @@ import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-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';
import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store';
@@ -14,6 +15,8 @@ import { useDirectoryByAddress } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload';
import { useCommunityField } from '../../hooks/use-stable-community';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import LoadingEllipsis from '../loading-ellipsis';
import styles from './reply-modal.module.css';
@@ -54,6 +57,10 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
});
const account = useAccount();
const { displayName } = account?.author || {};
const accountAddress = account?.author?.address;
const roles = useCommunityField(communityAddress, (community) => community?.roles);
const accountRole = accountAddress ? roles?.[accountAddress]?.role : undefined;
const showBbcodeToolbar = hasModQueueAccessRole(accountRole);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const setTextRef = useRef((element: HTMLTextAreaElement | null) => {
textRef.current = element;
@@ -78,6 +85,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [error, setError] = useState<string | null>(null);
const [lengthError, setLengthError] = useState<string | null>(null);
const [url, setUrl] = useState('');
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const checkContentLengthRef = useRef(
debounce((content: string, t: TFunction) => {
@@ -254,17 +263,45 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
useEffect(() => {
if (!showReplyModal) {
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
}
}, [showReplyModal]);
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
lastSelectionStartRef.current = e.target.selectionStart ?? e.target.value.length;
lastSelectionEndRef.current = e.target.selectionEnd ?? lastSelectionStartRef.current;
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number) => {
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
if (typeof selectionStart === 'number') {
lastSelectionStartRef.current = selectionStart;
lastSelectionEndRef.current = selectionEnd ?? selectionStart;
}
setPublishReplyOptions({ content });
checkContentLengthRef.current(content, t);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleContentValueChange(e.target.value);
};
const handleBbcodePreviewToggle = () => {
if (isBbcodePreviewing) {
setIsBbcodePreviewing(false);
window.requestAnimationFrame(() => textRef.current?.focus());
return;
}
setBbcodePreviewContent(textRef.current?.value ?? '');
setIsBbcodePreviewing(true);
};
useEffect(() => {
const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current;
@@ -384,6 +421,15 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
/>
</div>
<div className={styles.content}>
{showBbcodeToolbar && (
<BbcodeEditorToolbar
textareaRef={textRef}
onChange={handleContentValueChange}
isPreviewing={isBbcodePreviewing}
onPreviewToggle={handleBbcodePreviewToggle}
/>
)}
{showBbcodeToolbar && isBbcodePreviewing && <BbcodePreview content={bbcodePreviewContent} postCid={postCid} communityAddress={communityAddress} />}
<textarea
cols={48}
rows={4}
@@ -391,6 +437,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
ref={setTextRef.current}
aria-label={t('comment')}
spellCheck={true}
hidden={showBbcodeToolbar && isBbcodePreviewing}
onInput={handleContentInput}
onChange={handleContentChange}
onSelect={(e) => {
+27
View File
@@ -9,6 +9,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "5chan@workspace:."
dependencies:
"@bbob/parser": "npm:4.3.1"
"@bitsocial/bitsocial-react-hooks": "npm:0.1.10"
"@capacitor/android": "npm:7.4.5"
"@capacitor/app": "npm:7.0.1"
@@ -1530,6 +1531,32 @@ __metadata:
languageName: node
linkType: hard
"@bbob/parser@npm:4.3.1":
version: 4.3.1
resolution: "@bbob/parser@npm:4.3.1"
dependencies:
"@bbob/plugin-helper": "npm:*"
"@bbob/types": "npm:*"
checksum: 10c0/f21400db6843dc9ef6ba9e1ec16bae6f1b11b656af3701a122cea4802b27daab213f6244e340b40569538bb716c7129ed614a060b9f66096605b8e6540743fff
languageName: node
linkType: hard
"@bbob/plugin-helper@npm:*":
version: 4.3.1
resolution: "@bbob/plugin-helper@npm:4.3.1"
dependencies:
"@bbob/types": "npm:*"
checksum: 10c0/7f2fbc7ecb2925621c65be67aa35ab37aaddf9baa0c9783015039e9394293604b3993124ad471eb9e458f6aa3f1ae7e7725151fd571f46dfe4fc8e2b9c0032e6
languageName: node
linkType: hard
"@bbob/types@npm:*":
version: 4.3.1
resolution: "@bbob/types@npm:4.3.1"
checksum: 10c0/79bd4b299c79cff3634142b9de1f58383329147fdeee554fcb29a52300b6c7dcbd452ef906093fc08867d3089103ee4b209aa2b8a611824e0259bd5f52faaecf
languageName: node
linkType: hard
"@bitsocial/bitsocial-react-hooks@npm:0.1.10":
version: 0.1.10
resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.10"