refactor: migrate 5chan to the community hooks API (#1073)

* refactor(community-api): migrate 5chan to community hooks

* fix(review): address PR feedback

* fix(review): preserve legacy board context fallbacks

* fix(review): address latest bot feedback

* fix(review): use communityAddress in edit menu privileges
This commit is contained in:
Tommaso Casaburi
2026-03-13 13:25:10 +08:00
committed by GitHub
parent 9dc4d96d27
commit d7703953fb
105 changed files with 2659 additions and 1491 deletions
@@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const testState = vi.hoisted(() => ({
consoleErrorMock: vi.fn(),
subplebbits: {} as Record<string, { roles?: Record<string, { role?: string }> }>,
communities: {} as Record<string, { roles?: Record<string, { role?: string }> }>,
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({
default: {
getState: () => ({
subplebbits: testState.subplebbits,
communities: testState.communities,
}),
},
}));
@@ -20,7 +20,7 @@ describe('pattern-utils', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.subplebbits = {
testState.communities = {
'music-posting.eth': {
roles: {
'author-1': { role: 'moderator' },
+103
View File
@@ -0,0 +1,103 @@
type CommentWithLegacyCommunityAddress = {
communityAddress?: string;
replies?: {
pages?: Record<
string,
| {
comments?: Array<CommentWithLegacyCommunityAddress | undefined>;
}
| undefined
>;
};
subplebbitAddress?: string;
};
export const getCommentCommunityAddress = (comment?: unknown) => {
if (!comment || typeof comment !== 'object') {
return undefined;
}
const record = comment as { communityAddress?: unknown; subplebbitAddress?: unknown };
if (typeof record.communityAddress === 'string' && record.communityAddress) {
return record.communityAddress;
}
if (typeof record.subplebbitAddress === 'string' && record.subplebbitAddress) {
return record.subplebbitAddress;
}
return undefined;
};
const withResolvedReplyPages = (replies?: CommentWithLegacyCommunityAddress['replies']) => {
if (!replies?.pages) {
return replies;
}
let nextPages = replies.pages;
let pagesChanged = false;
for (const [sortType, page] of Object.entries(replies.pages)) {
if (!page?.comments?.length) {
continue;
}
let nextComments = page.comments;
let commentsChanged = false;
page.comments.forEach((reply, index) => {
const normalizedReply = withResolvedCommentCommunityAddress(reply);
if (normalizedReply === reply) {
return;
}
if (!commentsChanged) {
nextComments = [...(page.comments ?? [])];
commentsChanged = true;
}
nextComments[index] = normalizedReply;
});
if (!commentsChanged) {
continue;
}
if (!pagesChanged) {
nextPages = { ...replies.pages };
pagesChanged = true;
}
nextPages[sortType] = {
...page,
comments: nextComments,
};
}
if (!pagesChanged) {
return replies;
}
return {
...replies,
pages: nextPages,
};
};
export const withResolvedCommentCommunityAddress = <T extends CommentWithLegacyCommunityAddress | undefined | null>(comment: T): T => {
if (!comment) {
return comment;
}
const communityAddress = getCommentCommunityAddress(comment);
const replies = withResolvedReplyPages(comment.replies);
const needsResolvedCommunityAddress = !!communityAddress && comment.communityAddress !== communityAddress;
if (!needsResolvedCommunityAddress && replies === comment.replies) {
return comment;
}
return {
...comment,
...(needsResolvedCommunityAddress ? { communityAddress } : {}),
...(replies !== comment.replies ? { replies } : {}),
} as T;
};
+32 -29
View File
@@ -1,7 +1,7 @@
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import feedsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds';
import repliesStore, { feedOptionsToFeedName } from '@bitsocialnet/bitsocial-react-hooks/dist/stores/replies';
import subplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
import communitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages';
import type { DirectoryCommunity } from '../../hooks/use-directories';
import usePostNumberStore from '../../stores/use-post-number-store';
import type { ExternalQuoteReference, ExternalQuoteSearchStatus } from './external-quote-utils';
@@ -26,7 +26,7 @@ type ResolvedExternalQuoteTarget = {
comment?: Comment;
isUnavailable: boolean;
route: string;
subplebbitAddress: string;
communityAddress: string;
};
const waitFor = async <T>(callback: () => T | undefined | false, timeoutMs = WAIT_FOR_STORE_TIMEOUT_MS) => {
@@ -54,36 +54,39 @@ const isUnavailableComment = (
} | null,
) => Boolean(comment?.deleted || comment?.removed || comment?.commentModeration?.purged);
const getBoardFeedName = (accountId: string, subplebbitAddress: string) =>
`external-quote-board-${accountId}-${subplebbitAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`;
const getBoardFeedName = (accountId: string, communityAddress: string) =>
`external-quote-board-${accountId}-${communityAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`;
const getCachedComment = (cid?: string) => (cid ? subplebbitsPagesStore.getState().comments[cid] : undefined);
const getCachedComment = (cid?: string) => (cid ? communitiesPagesStore.getState().comments[cid] : undefined);
const findLoadedCommentByNumber = ({ number, subplebbitAddress }: { number: number; subplebbitAddress: string }) => {
const comments = Object.values(subplebbitsPagesStore.getState().comments) as Array<Comment | undefined>;
const findLoadedCommentByNumber = ({ number, communityAddress }: { number: number; communityAddress: string }) => {
const comments = Object.values(communitiesPagesStore.getState().comments) as Array<Comment | undefined>;
return comments.find((comment) => comment?.subplebbitAddress === subplebbitAddress && comment?.number === number && comment?.cid);
return comments.find((comment) => {
const address = (comment as { communityAddress?: string }).communityAddress || comment?.subplebbitAddress;
return address === communityAddress && comment?.number === number && comment?.cid;
});
};
const buildResolvedTarget = ({
cid,
comment,
directories,
subplebbitAddress,
communityAddress,
}: {
cid: string;
comment?: Comment;
directories: DirectoryCommunity[];
subplebbitAddress: string;
communityAddress: string;
}): ResolvedExternalQuoteTarget => {
const boardPath = getBoardPath(subplebbitAddress, directories);
const boardPath = getBoardPath(communityAddress, directories);
return {
boardPath,
cid,
comment,
isUnavailable: isUnavailableComment(comment),
route: `/${boardPath}/thread/${cid}`,
subplebbitAddress,
communityAddress,
};
};
@@ -118,7 +121,7 @@ const loadBoardThreads = async ({
number,
onStatus,
quoteDisplay,
subplebbitAddress,
communityAddress,
directories,
}: {
account: ResolverAccount;
@@ -126,7 +129,7 @@ const loadBoardThreads = async ({
number: number;
onStatus?: (status: ExternalQuoteSearchStatus) => void;
quoteDisplay: string;
subplebbitAddress: string;
communityAddress: string;
}) => {
const accountId = account.id;
if (!accountId) {
@@ -138,7 +141,7 @@ const loadBoardThreads = async ({
kind: 'same-board',
number,
raw: quoteDisplay,
subplebbitAddress,
communityAddress,
},
directories,
);
@@ -149,10 +152,10 @@ const loadBoardThreads = async ({
quoteDisplay,
});
const feedName = getBoardFeedName(accountId, subplebbitAddress);
const feedName = getBoardFeedName(accountId, communityAddress);
const feedState = feedsStore.getState();
if (!feedState.feedsOptions[feedName]) {
await feedState.addFeedToStore(feedName, [subplebbitAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE);
await feedState.addFeedToStore(feedName, [communityAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE);
}
await waitForBoardFeedPage(feedName, 0, 1);
@@ -210,7 +213,7 @@ const searchThreadReplies = async ({
number,
onStatus,
quoteDisplay,
subplebbitAddress,
communityAddress,
threads,
}: {
account: ResolverAccount;
@@ -218,7 +221,7 @@ const searchThreadReplies = async ({
number: number;
onStatus?: (status: ExternalQuoteSearchStatus) => void;
quoteDisplay: string;
subplebbitAddress: string;
communityAddress: string;
threads: Comment[];
}) => {
const accountId = account.id;
@@ -231,7 +234,7 @@ const searchThreadReplies = async ({
kind: 'same-board',
number,
raw: quoteDisplay,
subplebbitAddress,
communityAddress,
},
directories,
);
@@ -302,21 +305,21 @@ export const resolveExternalQuoteTarget = async ({
throw new Error('Missing active account while resolving external quote');
}
const targetSubplebbitAddress = getExternalQuoteBoardAddress(reference, directories);
const targetCommunityAddress = getExternalQuoteBoardAddress(reference, directories);
const quoteDisplay = reference.raw;
const cachedCid = usePostNumberStore.getState().numberToCid[targetSubplebbitAddress]?.[reference.number];
const cachedCid = usePostNumberStore.getState().numberToCid[targetCommunityAddress]?.[reference.number];
if (cachedCid) {
return buildResolvedTarget({
cid: cachedCid,
comment: getCachedComment(cachedCid),
directories,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
}
const loadedComment = findLoadedCommentByNumber({
number: reference.number,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
if (loadedComment?.cid) {
registerComments([loadedComment]);
@@ -324,7 +327,7 @@ export const resolveExternalQuoteTarget = async ({
cid: loadedComment.cid,
comment: loadedComment,
directories,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
}
@@ -334,7 +337,7 @@ export const resolveExternalQuoteTarget = async ({
number: reference.number,
onStatus,
quoteDisplay,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
if (matchingThread?.cid) {
@@ -343,7 +346,7 @@ export const resolveExternalQuoteTarget = async ({
cid: matchingThread.cid,
comment: matchingThread,
directories,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
}
@@ -353,7 +356,7 @@ export const resolveExternalQuoteTarget = async ({
number: reference.number,
onStatus,
quoteDisplay,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
threads,
});
@@ -366,6 +369,6 @@ export const resolveExternalQuoteTarget = async ({
cid: matchingReply.cid,
comment: matchingReply,
directories,
subplebbitAddress: targetSubplebbitAddress,
communityAddress: targetCommunityAddress,
});
};
+34 -7
View File
@@ -1,5 +1,5 @@
import type { DirectoryCommunity } from '../../hooks/use-directories';
import { getBoardPath, getSubplebbitAddress } from './route-utils';
import { getBoardPath, getCommunityAddress, getSubplebbitAddress } from './route-utils';
import { QUOTE_NUMBER_REGEX } from './url-utils';
const CROSSBOARD_NUMBER_BOARD_PART = '(?:[a-zA-Z0-9]{1,10}|12D3KooW[a-zA-Z0-9]{44}|[a-zA-Z0-9\\-.]+)';
@@ -11,7 +11,9 @@ export type SameBoardExternalQuoteReference = {
kind: 'same-board';
number: number;
raw: string;
subplebbitAddress: string;
communityAddress?: string;
// legacy compatibility alias
subplebbitAddress?: string;
};
export type CrossBoardExternalQuoteReference = {
@@ -23,35 +25,59 @@ export type CrossBoardExternalQuoteReference = {
export type ExternalQuoteReference = SameBoardExternalQuoteReference | CrossBoardExternalQuoteReference;
const getAddressForCanonicalReference = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]): string => {
if (reference.kind === 'cross-board') {
return resolveLegacyCommunityAddress(reference.boardIdentifier, directories);
}
return resolveLegacyCommunityAddress(reference.communityAddress || reference.subplebbitAddress || '', directories);
};
const getExternalQuoteKey = (reference: ExternalQuoteReference) =>
reference.kind === 'cross-board'
? `${reference.kind}:${reference.boardIdentifier}:${reference.number}`
: `${reference.kind}:${reference.subplebbitAddress}:${reference.number}`;
: `${reference.kind}:${reference.communityAddress || reference.subplebbitAddress}:${reference.number}`;
const resolveLegacyCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]) => {
// Canonical resolver in route utils handles directory or address mapping.
const address = getCommunityAddress(boardIdentifier, communities);
if (address) {
return address;
}
// Backward-compat helper alias if needed by callers with older util behavior.
return getSubplebbitAddress(boardIdentifier, communities);
};
export const getExternalQuoteBoardAddress = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) =>
reference.kind === 'cross-board' ? getSubplebbitAddress(reference.boardIdentifier, directories) : reference.subplebbitAddress;
getAddressForCanonicalReference(reference, directories);
export const getExternalQuoteBoardLabel = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => {
const address = getExternalQuoteBoardAddress(reference, directories);
const address = getAddressForCanonicalReference(reference, directories);
return getBoardPath(address, directories);
};
export const extractUnresolvedExternalQuoteReferences = ({
content,
scopedNumberToCid,
communityAddress,
subplebbitAddress,
}: {
content?: string;
scopedNumberToCid?: Record<number, string>;
// canonical input
communityAddress?: string;
// backward-compatible input name
subplebbitAddress?: string;
}) => {
const effectiveCommunityAddress = communityAddress || subplebbitAddress;
if (!content) {
return [] as ExternalQuoteReference[];
}
const references = new Map<string, ExternalQuoteReference>();
if (subplebbitAddress) {
if (effectiveCommunityAddress) {
for (const match of content.matchAll(new RegExp(QUOTE_NUMBER_REGEX.source, 'g'))) {
const number = Number.parseInt(match[1], 10);
if (Number.isNaN(number) || scopedNumberToCid?.[number]) {
@@ -62,7 +88,8 @@ export const extractUnresolvedExternalQuoteReferences = ({
kind: 'same-board',
number,
raw: `>>${number}`,
subplebbitAddress,
communityAddress: effectiveCommunityAddress,
subplebbitAddress: effectiveCommunityAddress,
};
references.set(getExternalQuoteKey(reference), reference);
}
+10 -4
View File
@@ -1,5 +1,9 @@
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits';
import communitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities';
type CommunityLike = {
roles?: Record<string, { role?: string }>;
};
/**
* Checks if a text matches a pattern according to various pattern matching rules:
@@ -138,12 +142,14 @@ export const displayNameMatchesPattern = (comment: Comment, pattern: string): bo
* @returns True if the user has the specified role, false otherwise
*/
export const userHasRole = (comment: Comment, role: string): boolean => {
if (!role || !comment?.author?.address || !comment?.subplebbitAddress) {
const communityAddress = (comment as { communityAddress?: string }).communityAddress ?? comment?.subplebbitAddress;
if (!role || !comment?.author?.address || !communityAddress) {
return false;
}
const subplebbits = useSubplebbitsStore.getState().subplebbits;
const subplebbit = subplebbits[comment.subplebbitAddress];
const communities = communitiesStore.getState().communities;
const subplebbit = communities[communityAddress] as CommunityLike | undefined;
if (!subplebbit?.roles) {
return false;
+20 -13
View File
@@ -1,9 +1,11 @@
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import { getCommentCommunityAddress } from './comment-utils';
export type PostMenuProps = {
cid?: string;
postCid?: string;
parentCid?: string;
communityAddress?: string;
subplebbitAddress?: string;
authorAddress?: string;
link?: string;
@@ -14,16 +16,21 @@ export type PostMenuProps = {
removed?: boolean;
};
export const selectPostMenuProps = (post?: Comment): PostMenuProps => ({
cid: post?.cid,
postCid: post?.postCid,
parentCid: post?.parentCid,
subplebbitAddress: post?.subplebbitAddress,
authorAddress: post?.author?.address,
link: post?.link,
linkWidth: post?.linkWidth,
linkHeight: post?.linkHeight,
thumbnailUrl: post?.thumbnailUrl,
deleted: post?.deleted,
removed: post?.removed,
});
export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
const communityAddress = getCommentCommunityAddress(post);
return {
cid: post?.cid,
postCid: post?.postCid,
parentCid: post?.parentCid,
communityAddress,
subplebbitAddress: post?.subplebbitAddress,
authorAddress: post?.author?.address,
link: post?.link,
linkWidth: post?.linkWidth,
linkHeight: post?.linkHeight,
thumbnailUrl: post?.thumbnailUrl,
deleted: post?.deleted,
removed: post?.removed,
};
};
+35 -10
View File
@@ -10,8 +10,18 @@ export interface CommentWithCid {
}
/** Minimal FeedOptions shape for board-feed filtering */
type LegacyFeedOptionsLike = {
subplebbitAddresses?: string[];
sortType: string;
postsPerPage?: number;
filter?: unknown;
newerThan?: number;
modQueue?: unknown;
accountComments?: unknown;
};
export interface FeedOptionsLike {
subplebbitAddresses: string[];
communityAddresses?: string[];
sortType: string;
postsPerPage?: number;
filter?: unknown;
@@ -21,7 +31,7 @@ export interface FeedOptionsLike {
}
/** FeedsOptions-like map */
export type FeedsOptionsLike = Record<string, FeedOptionsLike>;
export type FeedsOptionsLike = Record<string, FeedOptionsLike | LegacyFeedOptionsLike>;
/** Loaded feeds map: feedName -> Comment[] */
export type LoadedFeedsLike = Record<string, CommentWithCid[]>;
@@ -46,14 +56,29 @@ export function findPostPageInFeed(feed: CommentWithCid[], postCid: string, guiP
* Strict board-feed filter criteria.
* A feed is a "board feed" iff:
* - sortType === 'active'
* - single-sub feed (subplebbitAddresses.length === 1)
* - single-board feed (one community)
* - no filter, no newerThan, no modQueue, no accountComments
*/
export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: string): boolean {
const getCommunityAddresses = (opts: FeedOptionsLike | LegacyFeedOptionsLike): string[] => {
if ('communityAddresses' in opts && Array.isArray(opts.communityAddresses)) {
return opts.communityAddresses;
}
if ('subplebbitAddresses' in opts && Array.isArray(opts.subplebbitAddresses)) {
return opts.subplebbitAddresses;
}
return [];
};
/**
* Supports both canonical `communityAddresses` and legacy `subplebbitAddresses`.
*/
export function isBoardFeedOptions(opts: FeedOptionsLike | LegacyFeedOptionsLike, communityAddress: string): boolean {
const communityAddresses = getCommunityAddresses(opts);
return (
opts.sortType === 'active' &&
opts.subplebbitAddresses?.length === 1 &&
opts.subplebbitAddresses[0] === subplebbitAddress &&
communityAddresses.length === 1 &&
communityAddresses[0] === communityAddress &&
!opts.filter &&
opts.newerThan == null &&
!opts.modQueue &&
@@ -67,7 +92,7 @@ export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: str
*
* @param feedsOptions - Feeds store feedsOptions
* @param loadedFeeds - Feeds store loadedFeeds
* @param subplebbitAddress - Board subplebbit address
* @param communityAddress - Board community address
* @param postCid - CID of the post (OP) to locate
* @param guiPostsPerPage - Posts per GUI page
* @returns 1-based page number, or undefined if not found in any matching feed
@@ -75,15 +100,15 @@ export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: str
export function findPostPageInLoadedBoardFeeds(
feedsOptions: FeedsOptionsLike,
loadedFeeds: LoadedFeedsLike,
subplebbitAddress: string,
communityAddress: string,
postCid: string,
guiPostsPerPage: number,
): number | undefined {
if (!subplebbitAddress || !postCid || guiPostsPerPage <= 0) return undefined;
if (!communityAddress || !postCid || guiPostsPerPage <= 0) return undefined;
for (const feedName of Object.keys(feedsOptions)) {
const opts = feedsOptions[feedName];
if (!opts || !isBoardFeedOptions(opts, subplebbitAddress)) continue;
if (!opts || !isBoardFeedOptions(opts as FeedOptionsLike, communityAddress)) continue;
const feed = loadedFeeds[feedName];
if (!feed || !Array.isArray(feed)) continue;
+6 -1
View File
@@ -85,7 +85,7 @@ export const getBoardPath = (communityAddress: string, communities: DirectoryCom
/**
* Convert URL path (directory code or address) to community address
*/
export const getSubplebbitAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => {
export const getCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => {
const directoryToAddress = getDirectoryToAddressMap(communities);
// Check if it's a directory code
@@ -98,6 +98,11 @@ export const getSubplebbitAddress = (boardIdentifier: string, communities: Direc
return boardIdentifier;
};
/**
* Back-compat alias kept for route params and comments.
*/
export const getSubplebbitAddress = getCommunityAddress;
/**
* Compare two addresses; returns true if they refer to the same board (handles .bso/.eth aliases).
*/