mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(posts): add mod bbcode editor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user