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