feat(post options): support nonoko board redirects (#1137)

This commit is contained in:
Tommaso Casaburi
2026-05-23 16:54:38 +07:00
committed by GitHub
parent cf96d3b1ae
commit 22c1b4f123
9 changed files with 294 additions and 16 deletions
@@ -175,7 +175,9 @@ vi.mock('../../../hooks/use-publish-post', async () => {
...sanitizedOptions,
};
testState.publishedPostOptions = testState.publishPostOptions;
return testState.publishPostMock(options);
const result = testState.publishPostMock(options);
forceUpdate();
return result;
},
[getPublishPostOptions],
);
@@ -907,6 +909,30 @@ describe('PostForm', () => {
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7');
});
it('redirects new posts to the board index when nonoko is used', async () => {
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.publishPostMock.mockImplementation(() => {
testState.postIndex = 7;
});
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const subjectInput = table?.querySelector<HTMLInputElement>('input[aria-label="subject"]');
await dispatchInput(optionsInput as HTMLInputElement, 'nonoko');
await dispatchInput(subjectInput as HTMLInputElement, 'Thread title');
await clickByText(table as HTMLTableElement, 'post');
await flushEffects();
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
expect(testState.navigateMock).toHaveBeenCalledWith('/mu', { state: { nonokoPendingAccountCommentIndex: 7 } });
expect(testState.navigateMock).not.toHaveBeenCalledWith('/pending/7');
});
it('resets the reply form after a completed reply publish', async () => {
testState.comments = {
'thread-cid': {
@@ -924,6 +950,35 @@ describe('PostForm', () => {
expect(container.querySelector('table')).toBeNull();
});
it('redirects replies from the inline form to the board index when nonoko is used', async () => {
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
},
};
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.publishReplyMock.mockImplementation(() => {
testState.replyIndex = 4;
});
await renderPostForm('/mu/thread/thread-cid');
await clickByText(container, 'post_a_reply');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'nonoko');
await dispatchInput(textarea as HTMLTextAreaElement, 'Reply body');
await clickByText(table as HTMLTableElement, 'post');
await flushEffects();
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
expect(container.querySelector('table')).toBeNull();
});
it('publishes replies from the open reply form', async () => {
testState.comments = {
'thread-cid': {
+28 -2
View File
@@ -12,8 +12,10 @@ import {
type FortuneEntry,
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getNonokoPendingRouteState,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
hasNonokoOption,
isUnsupportedPostOptionsMessage,
} from '../../lib/utils/post-options-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
@@ -420,6 +422,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const optionsRef = useRef<HTMLInputElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
const location = useLocation();
const isInAllView = isAllView(location.pathname);
@@ -487,6 +490,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setBbcodePreviewContent('');
};
const getBoardIndexPath = () => {
if (effectiveBoardAddress) {
return `/${getBoardPath(effectiveBoardAddress, directories)}`;
}
return params?.boardIdentifier ? `/${params.boardIdentifier}` : null;
};
const onPublishPost = () => {
const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || '';
@@ -499,6 +510,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setFormError(currentOptionsError);
@@ -529,6 +541,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent });
};
@@ -536,9 +549,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const navigate = useNavigate();
useEffect(() => {
if (typeof postIndex === 'number') {
const nonokoRedirectPath = nonokoRedirectPathRef.current;
nonokoRedirectPathRef.current = null;
resetPublishPostOptions();
resetFields();
navigate(`/pending/${postIndex}`);
if (nonokoRedirectPath) {
navigate(nonokoRedirectPath, { state: getNonokoPendingRouteState(postIndex) });
} else {
navigate(`/pending/${postIndex}`);
}
}
}, [postIndex, resetPublishPostOptions, navigate]);
@@ -605,6 +624,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setFormError(currentOptionsError);
@@ -631,15 +651,21 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent });
};
useEffect(() => {
if (typeof replyIndex === 'number') {
const nonokoRedirectPath = nonokoRedirectPathRef.current;
nonokoRedirectPathRef.current = null;
resetFields();
closeForm();
if (nonokoRedirectPath) {
navigate(nonokoRedirectPath);
}
}
}, [replyIndex, closeForm]);
}, [replyIndex, closeForm, navigate]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
@@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({
isMobile: false,
isResolvingExternalQuotes: false,
isUploading: false,
navigateMock: vi.fn(),
offlineTitle: '' as string | false,
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
offlineStatusLoading: false,
@@ -74,6 +75,14 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => testState.navigateMock,
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
@@ -333,6 +342,7 @@ describe('ReplyModal', () => {
testState.isMobile = false;
testState.isResolvingExternalQuotes = false;
testState.isUploading = false;
testState.navigateMock.mockReset();
testState.offlineTitle = '';
testState.offlineStates = {};
testState.offlineStatusLoading = false;
@@ -686,6 +696,29 @@ describe('ReplyModal', () => {
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
});
it('redirects to the board index after a reply when nonoko is used', async () => {
testState.openEmpty = true;
testState.selectedText = '';
testState.publishReplyMock.mockImplementation(() => {
testState.replyIndex = 3;
});
await renderReplyModal('/mu/thread/post-1');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'nonoko');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
await clickButtonByText('post');
await rerenderReplyModal('/mu/thread/post-1');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
});
it('inserts quote requests only once and keeps the textarea content stable across rerenders', async () => {
testState.isMobile = true;
testState.openEmpty = true;
+12 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
@@ -11,6 +11,7 @@ import {
getContentWithPostOptionState as getContentWithOptions,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
hasNonokoOption,
isUnsupportedPostOptionsMessage,
} from '../../lib/utils/post-options-utils';
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
@@ -50,6 +51,7 @@ interface ReplyModalProps {
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const params = useParams();
const isInAllView = isAllView(location.pathname);
const isInModView = isModView(location.pathname);
@@ -86,6 +88,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const optionsRef = useRef<HTMLInputElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = useRef(0);
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
@@ -133,6 +136,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
setLengthError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setError(currentOptionsError);
@@ -160,15 +164,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent });
};
useEffect(() => {
if (typeof replyIndex === 'number') {
const nonokoRedirectPath = nonokoRedirectPathRef.current;
nonokoRedirectPathRef.current = null;
resetPublishReplyOptions();
closeModal();
if (nonokoRedirectPath) {
navigate(nonokoRedirectPath);
}
}
}, [replyIndex, resetPublishReplyOptions, closeModal]);
}, [replyIndex, resetPublishReplyOptions, closeModal, navigate]);
const nodeRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();