Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-05-23 17:20:18 +07:00
9 changed files with 294 additions and 16 deletions
+49 -7
View File
@@ -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' },
+33 -3
View File
@@ -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)),
+34
View File
@@ -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?',