feat(posts): add mod bbcode editor

This commit is contained in:
Tommaso Casaburi
2026-05-19 15:45:22 +07:00
parent b549789a23
commit d2d047e5de
14 changed files with 843 additions and 20 deletions
@@ -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