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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user