From 22c1b4f1233a3b3f0219e1154136a8a961782d50 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sat, 23 May 2026 16:54:38 +0700 Subject: [PATCH] feat(post options): support nonoko board redirects (#1137) --- .../post-form/__tests__/post-form.test.tsx | 57 ++++++++++++++++++- src/components/post-form/post-form.tsx | 30 +++++++++- .../__tests__/reply-modal.test.tsx | 33 +++++++++++ src/components/reply-modal/reply-modal.tsx | 14 ++++- .../__tests__/post-options-utils.test.ts | 17 +++++- src/lib/utils/post-options-utils.ts | 33 +++++++++++ src/views/board/__tests__/board.test.tsx | 56 +++++++++++++++--- src/views/board/board.tsx | 36 +++++++++++- src/views/faq/faq.tsx | 34 +++++++++++ 9 files changed, 294 insertions(+), 16 deletions(-) diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index e1399981..febfc465 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -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('input[aria-label="options"]'); + const subjectInput = table?.querySelector('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('input[aria-label="options"]'); + const textarea = table?.querySelector('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': { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index b444b796..50b1e504 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -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(null); const fortuneEntryRef = useRef(null); const diceRollRef = useRef(null); + const nonokoRedirectPathRef = useRef(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) => { diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 822957cc..c5d6c8c7 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({ isMobile: false, isResolvingExternalQuotes: false, isUploading: false, + navigateMock: vi.fn(), offlineTitle: '' as string | false, offlineStates: {} as Record, offlineStatusLoading: false, @@ -74,6 +75,14 @@ vi.mock('react-i18next', () => ({ }), })); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('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('input[type="text"]')[1]; + const textarea = container.querySelector('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; diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index a0ac9eaf..72ed41ae 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -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(null); const fortuneEntryRef = useRef(null); const diceRollRef = useRef(null); + const nonokoRedirectPathRef = useRef(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(null); const isMobile = useIsMobile(); diff --git a/src/lib/utils/__tests__/post-options-utils.test.ts b/src/lib/utils/__tests__/post-options-utils.test.ts index 447eaa6d..6961d73d 100644 --- a/src/lib/utils/__tests__/post-options-utils.test.ts +++ b/src/lib/utils/__tests__/post-options-utils.test.ts @@ -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(); + }); }); diff --git a/src/lib/utils/post-options-utils.ts b/src/lib/utils/post-options-utils.ts index 7da9919a..61ad3942 100644 --- a/src/lib/utils/post-options-utils.ts +++ b/src/lib/utils/post-options-utils.ts @@ -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'); diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index db746cef..8db2a966 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -10,7 +10,10 @@ import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../. const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; 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, 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' }, diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 9f386ddc..803978e5 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -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)), diff --git a/src/views/faq/faq.tsx b/src/views/faq/faq.tsx index 632b27a9..3b50a1f7 100644 --- a/src/views/faq/faq.tsx +++ b/src/views/faq/faq.tsx @@ -255,6 +255,40 @@ const FAQ_SECTIONS: FAQSection[] = [ ), }, + { + id: 'sage', + question: 'What is "sage"?', + answer: ( + <> + On imageboards, entering sage in the [Options] field while replying means "do not bump this thread." 5chan cannot implement it as a + real option yet because boards use the active 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{' '} + + pkc-js issue #73 + + {'. '}pkc-js is a core Bitsocial library, so sage 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 nonoko in the [Options] field before submitting. 5chan normally sends you toward the new or pending post after submission;{' '} + nonoko 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. nonoko works now, but sage and nonokosage are still blocked on the pkc-js page-sort work above. + + ), + }, { id: 'archive', question: 'Can I retrieve an old post or image?',