mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(post options): support nonoko board redirects (#1137)
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getUnsupportedPostOptionsMessage } from '../post-options-utils';
|
||||
import { getNonokoPendingAccountCommentIndex, getNonokoPendingRouteState, getUnsupportedPostOptionsMessage, hasNonokoOption } from '../post-options-utils';
|
||||
|
||||
describe('post-options-utils', () => {
|
||||
it('rejects additional dice options instead of dropping them', () => {
|
||||
expect(getUnsupportedPostOptionsMessage('dice+1d6 dice+1d20', 'qst')).toBe('unsupported options: dice+1d20');
|
||||
});
|
||||
|
||||
it('supports nonoko while keeping sage unsupported', () => {
|
||||
expect(getUnsupportedPostOptionsMessage('nonoko', undefined)).toBeNull();
|
||||
expect(getUnsupportedPostOptionsMessage('sage', 'b')).toBe('unsupported options: sage');
|
||||
expect(hasNonokoOption('fortune nonoko')).toBe(true);
|
||||
expect(hasNonokoOption('nonokosage')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the nonoko pending account comment index from direct and wrapped route state', () => {
|
||||
expect(getNonokoPendingRouteState(7)).toEqual({ nonokoPendingAccountCommentIndex: 7 });
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: 7 })).toBe(7);
|
||||
expect(getNonokoPendingAccountCommentIndex({ usr: { nonokoPendingAccountCommentIndex: 8 } })).toBe(8);
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: -1 })).toBeUndefined();
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: '7' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,10 @@ export const getPostOptionsDirectoryCode = (directory: PostOptionsDirectory | nu
|
||||
};
|
||||
|
||||
const isSupportedPostOption = (option: string, directoryCode: string | undefined): boolean => {
|
||||
if (option === 'nonoko') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (option === 'fortune') {
|
||||
return !!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode);
|
||||
}
|
||||
@@ -106,6 +110,35 @@ export const getUnsupportedPostOptionsMessage = (value: string, directoryCode: s
|
||||
|
||||
export const isUnsupportedPostOptionsMessage = (message: string | null): boolean => message?.startsWith('unsupported options:') === true;
|
||||
|
||||
export const hasNonokoOption = (value: string): boolean => parsePostOptions(value).includes('nonoko');
|
||||
|
||||
const NONOKO_PENDING_ACCOUNT_COMMENT_INDEX_STATE_KEY = 'nonokoPendingAccountCommentIndex';
|
||||
|
||||
type NonokoPendingRouteState = {
|
||||
[NONOKO_PENDING_ACCOUNT_COMMENT_INDEX_STATE_KEY]?: unknown;
|
||||
usr?: unknown;
|
||||
};
|
||||
|
||||
const getNonokoPendingAccountCommentIndexFromRecord = (state: NonokoPendingRouteState): number | undefined => {
|
||||
const value = state[NONOKO_PENDING_ACCOUNT_COMMENT_INDEX_STATE_KEY];
|
||||
return Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
|
||||
};
|
||||
|
||||
export const getNonokoPendingRouteState = (commentIndex: number) => ({
|
||||
[NONOKO_PENDING_ACCOUNT_COMMENT_INDEX_STATE_KEY]: commentIndex,
|
||||
});
|
||||
|
||||
export const getNonokoPendingAccountCommentIndex = (state: unknown): number | undefined => {
|
||||
if (!state || typeof state !== 'object') return undefined;
|
||||
|
||||
const directIndex = getNonokoPendingAccountCommentIndexFromRecord(state as NonokoPendingRouteState);
|
||||
if (directIndex !== undefined) return directIndex;
|
||||
|
||||
const wrappedState = (state as NonokoPendingRouteState).usr;
|
||||
if (!wrappedState || typeof wrappedState !== 'object') return undefined;
|
||||
return getNonokoPendingAccountCommentIndexFromRecord(wrappedState as NonokoPendingRouteState);
|
||||
};
|
||||
|
||||
const hasFortuneOption = (value: string, directoryCode: string | undefined): boolean =>
|
||||
!!directoryCode && FORTUNE_DIRECTORY_CODES.has(directoryCode) && parsePostOptions(value).includes('fortune');
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../.
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
type TestComment = {
|
||||
cid: string;
|
||||
cid?: string;
|
||||
content?: string;
|
||||
index?: number;
|
||||
parentCid?: string;
|
||||
pinned?: boolean;
|
||||
communityAddress?: string;
|
||||
deleted?: boolean;
|
||||
@@ -30,7 +33,7 @@ type TestCommunity = {
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: { subscriptions: [] as string[] },
|
||||
accountComments: [] as TestComment[],
|
||||
accountComments: [] as Array<TestComment | undefined>,
|
||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||
accountCommunityAddresses: [] as string[],
|
||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string; directoryCode?: string }>,
|
||||
@@ -83,7 +86,7 @@ vi.mock('react-i18next', () => ({
|
||||
}));
|
||||
|
||||
const getScopedAccountComments = (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
|
||||
let scopedComments = [...testState.accountComments];
|
||||
let scopedComments = testState.accountComments.filter(Boolean) as TestComment[];
|
||||
|
||||
if (options?.commentIndices?.length) {
|
||||
const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0);
|
||||
@@ -191,7 +194,7 @@ vi.mock('react-virtuoso', () => ({
|
||||
return createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'virtuoso' },
|
||||
data.map((item, index) => createElement('div', { key: item.cid }, itemContent(index, item))),
|
||||
data.map((item, index) => createElement('div', { key: item.cid ?? index }, itemContent(index, item))),
|
||||
endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null,
|
||||
components?.Footer ? createElement(components.Footer) : null,
|
||||
);
|
||||
@@ -272,7 +275,7 @@ vi.mock('../../../components/footer', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../post', () => ({
|
||||
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || 'missing-post'),
|
||||
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || post?.content || 'missing-post'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/snow', () => ({
|
||||
@@ -300,13 +303,24 @@ const flushEffects = async (count = 5) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderBoard = async ({ boardProps, initialEntry, routePath }: { boardProps?: BoardProps; initialEntry: string; routePath: string }) => {
|
||||
const renderBoard = async ({
|
||||
boardProps,
|
||||
initialEntry,
|
||||
initialState,
|
||||
routePath,
|
||||
}: {
|
||||
boardProps?: BoardProps;
|
||||
initialEntry: string;
|
||||
initialState?: unknown;
|
||||
routePath: string;
|
||||
}) => {
|
||||
latestLocation = initialEntry;
|
||||
const initialEntries = initialState === undefined ? [initialEntry] : [{ pathname: initialEntry, state: initialState }];
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
{ initialEntries },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
@@ -525,6 +539,34 @@ describe('Board', () => {
|
||||
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
testState.feed = [
|
||||
{ cid: 'pinned-post', pinned: true, communityAddress: 'music-posting.eth' },
|
||||
{ cid: 'older-post', communityAddress: 'music-posting.eth' },
|
||||
{ cid: 'oldest-post', communityAddress: 'music-posting.eth' },
|
||||
];
|
||||
testState.accountComments = [];
|
||||
testState.accountComments[7] = {
|
||||
content: 'pending thread body',
|
||||
communityAddress: 'music-posting.eth',
|
||||
index: 7,
|
||||
state: 'publishing-challenge',
|
||||
timestamp: currentTimestamp,
|
||||
};
|
||||
|
||||
await renderBoard({
|
||||
initialEntry: '/mu',
|
||||
initialState: { nonokoPendingAccountCommentIndex: 7 },
|
||||
routePath: '/:boardIdentifier/*',
|
||||
});
|
||||
|
||||
expect(testState.accountCommentsCalls).toContainEqual({
|
||||
commentIndices: [7],
|
||||
});
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'pending thread body']);
|
||||
});
|
||||
|
||||
it('redirects oversized board pages back to the last available page', async () => {
|
||||
testState.feed = [
|
||||
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
||||
import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
|
||||
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
||||
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
@@ -299,6 +300,17 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
[communityAddress],
|
||||
);
|
||||
const { accountComments: recentAccountComments } = useAccountComments(accountCommentLookupOptions);
|
||||
const nonokoPendingAccountCommentIndex = getNonokoPendingAccountCommentIndex(location.state);
|
||||
const nonokoPendingAccountCommentLookupOptions = useMemo(
|
||||
() =>
|
||||
typeof nonokoPendingAccountCommentIndex === 'number'
|
||||
? {
|
||||
commentIndices: [nonokoPendingAccountCommentIndex],
|
||||
}
|
||||
: EMPTY_ACCOUNT_COMMENT_LOOKUP,
|
||||
[nonokoPendingAccountCommentIndex],
|
||||
);
|
||||
const { accountComments: nonokoPendingAccountComments } = useAccountComments(nonokoPendingAccountCommentLookupOptions);
|
||||
|
||||
const pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
|
||||
const currentPage = getPageFromFeedPath(pathWithoutSettings);
|
||||
@@ -315,6 +327,18 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
|
||||
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
|
||||
const feedCids = useMemo(() => new Set(feed.map((f) => f.cid)), [feed]);
|
||||
const nonokoPendingAccountComment = useMemo(() => {
|
||||
const comment = nonokoPendingAccountComments.find(Boolean);
|
||||
if (!comment) return undefined;
|
||||
|
||||
const { cid, deleted, parentCid, postCid, removed } = comment;
|
||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||
if (deleted || removed || parentCid || commentCommunityAddress !== communityAddress) return undefined;
|
||||
if (cid && postCid && cid !== postCid) return undefined;
|
||||
if (cid && feedCids.has(cid)) return undefined;
|
||||
|
||||
return comment;
|
||||
}, [nonokoPendingAccountComments, communityAddress, feedCids]);
|
||||
const filteredComments = useMemo(
|
||||
() =>
|
||||
recentAccountComments.filter((comment) => {
|
||||
@@ -333,16 +357,22 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
}),
|
||||
[recentAccountComments, communityAddress, feedCids],
|
||||
);
|
||||
const localAccountComments = useMemo(() => {
|
||||
if (!nonokoPendingAccountComment) return filteredComments;
|
||||
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
|
||||
|
||||
return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)];
|
||||
}, [nonokoPendingAccountComment, filteredComments]);
|
||||
|
||||
// show newest account comment at the top of the feed but after pinned posts
|
||||
const combinedFeed = useMemo(() => {
|
||||
const newFeed = [...feed];
|
||||
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
|
||||
if (filteredComments.length > 0) {
|
||||
newFeed.splice(lastPinnedIndex + 1, 0, ...filteredComments);
|
||||
if (localAccountComments.length > 0) {
|
||||
newFeed.splice(lastPinnedIndex + 1, 0, ...localAccountComments);
|
||||
}
|
||||
return newFeed;
|
||||
}, [feed, filteredComments]);
|
||||
}, [feed, localAccountComments]);
|
||||
|
||||
const cappedFeed = useMemo(
|
||||
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
|
||||
|
||||
@@ -255,6 +255,40 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sage',
|
||||
question: 'What is "sage"?',
|
||||
answer: (
|
||||
<>
|
||||
On imageboards, entering <code>sage</code> in the <code>[Options]</code> field while replying means "do not bump this thread." 5chan cannot implement it as a
|
||||
real option yet because boards use the <code>active</code> sort type from pkc-js, and a reply cannot opt out of updating that active order until pkc-js
|
||||
supports configurable page sorts. That work is tracked in{' '}
|
||||
<a href='https://github.com/pkcprotocol/pkc-js/issues/73' {...externalLinkProps}>
|
||||
pkc-js issue #73
|
||||
</a>
|
||||
{'. '}pkc-js is a core Bitsocial library, so <code>sage</code> has to land there before 5chan can make it work. It is not a downvote.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nonoko',
|
||||
question: 'How can I be returned to the board index after I post?',
|
||||
answer: (
|
||||
<>
|
||||
Enter <code>nonoko</code> in the <code>[Options]</code> field before submitting. 5chan normally sends you toward the new or pending post after submission;{' '}
|
||||
<code>nonoko</code> returns you to the board index instead. It works for new threads and replies.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nonokosage',
|
||||
question: 'Can I use nonoko and sage at the same time?',
|
||||
answer: (
|
||||
<>
|
||||
Not yet. <code>nonoko</code> works now, but <code>sage</code> and <code>nonokosage</code> are still blocked on the pkc-js page-sort work above.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
question: 'Can I retrieve an old post or image?',
|
||||
|
||||
Reference in New Issue
Block a user