mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* 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
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
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 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';
|
|
import { getExternalQuoteBoardAddress, getExternalQuoteBoardLabel } from './external-quote-utils';
|
|
import { getBoardPath } from './route-utils';
|
|
|
|
const BOARD_FEED_SORT_TYPE = 'new';
|
|
const BOARD_SEARCH_POSTS_PER_PAGE = 25;
|
|
const THREAD_REPLIES_SORT_TYPE = 'best';
|
|
const THREAD_SEARCH_REPLIES_PER_PAGE = 25;
|
|
const WAIT_FOR_STORE_TIMEOUT_MS = 30000;
|
|
const WAIT_FOR_STORE_INTERVAL_MS = 100;
|
|
|
|
type ResolverAccount = {
|
|
id?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type ResolvedExternalQuoteTarget = {
|
|
boardPath: string;
|
|
cid: string;
|
|
comment?: Comment;
|
|
isUnavailable: boolean;
|
|
route: string;
|
|
communityAddress: string;
|
|
};
|
|
|
|
const waitFor = async <T>(callback: () => T | undefined | false, timeoutMs = WAIT_FOR_STORE_TIMEOUT_MS) => {
|
|
const startedAt = Date.now();
|
|
|
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
const result = callback();
|
|
if (result) {
|
|
return result;
|
|
}
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, WAIT_FOR_STORE_INTERVAL_MS));
|
|
}
|
|
|
|
throw new Error('Timed out while resolving external quote');
|
|
};
|
|
|
|
const isUnavailableComment = (
|
|
comment?: {
|
|
commentModeration?: {
|
|
purged?: boolean;
|
|
};
|
|
deleted?: boolean;
|
|
removed?: boolean;
|
|
} | null,
|
|
) => Boolean(comment?.deleted || comment?.removed || comment?.commentModeration?.purged);
|
|
|
|
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 ? communitiesPagesStore.getState().comments[cid] : undefined);
|
|
|
|
const findLoadedCommentByNumber = ({ number, communityAddress }: { number: number; communityAddress: string }) => {
|
|
const comments = Object.values(communitiesPagesStore.getState().comments) as Array<Comment | undefined>;
|
|
|
|
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,
|
|
communityAddress,
|
|
}: {
|
|
cid: string;
|
|
comment?: Comment;
|
|
directories: DirectoryCommunity[];
|
|
communityAddress: string;
|
|
}): ResolvedExternalQuoteTarget => {
|
|
const boardPath = getBoardPath(communityAddress, directories);
|
|
return {
|
|
boardPath,
|
|
cid,
|
|
comment,
|
|
isUnavailable: isUnavailableComment(comment),
|
|
route: `/${boardPath}/thread/${cid}`,
|
|
communityAddress,
|
|
};
|
|
};
|
|
|
|
const registerComments = (comments: Comment[]) => {
|
|
if (!comments.length) {
|
|
return;
|
|
}
|
|
|
|
usePostNumberStore.getState().registerComments(comments);
|
|
};
|
|
|
|
const waitForBoardFeedPage = async (feedName: string, previousLength: number, expectedPageNumber: number) =>
|
|
waitFor(() => {
|
|
const state = feedsStore.getState();
|
|
const feed = state.loadedFeeds[feedName] ?? [];
|
|
const hasMore = state.feedsHaveMore[feedName];
|
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 0;
|
|
|
|
if (pageNumber < expectedPageNumber) {
|
|
return false;
|
|
}
|
|
|
|
if (feed.length > previousLength || feed.length >= expectedPageNumber * BOARD_SEARCH_POSTS_PER_PAGE || hasMore === false) {
|
|
return { feed, hasMore };
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const loadBoardThreads = async ({
|
|
account,
|
|
number,
|
|
onStatus,
|
|
quoteDisplay,
|
|
communityAddress,
|
|
directories,
|
|
}: {
|
|
account: ResolverAccount;
|
|
directories: DirectoryCommunity[];
|
|
number: number;
|
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
|
quoteDisplay: string;
|
|
communityAddress: string;
|
|
}) => {
|
|
const accountId = account.id;
|
|
if (!accountId) {
|
|
throw new Error('Missing account id while resolving external quote');
|
|
}
|
|
|
|
const boardLabel = getExternalQuoteBoardLabel(
|
|
{
|
|
kind: 'same-board',
|
|
number,
|
|
raw: quoteDisplay,
|
|
communityAddress,
|
|
},
|
|
directories,
|
|
);
|
|
|
|
onStatus?.({
|
|
phase: 'search-board',
|
|
boardLabel,
|
|
quoteDisplay,
|
|
});
|
|
|
|
const feedName = getBoardFeedName(accountId, communityAddress);
|
|
const feedState = feedsStore.getState();
|
|
if (!feedState.feedsOptions[feedName]) {
|
|
await feedState.addFeedToStore(feedName, [communityAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE);
|
|
}
|
|
|
|
await waitForBoardFeedPage(feedName, 0, 1);
|
|
|
|
while (true) {
|
|
const state = feedsStore.getState();
|
|
const feed = (state.loadedFeeds[feedName] ?? []) as Comment[];
|
|
const hasMore = state.feedsHaveMore[feedName];
|
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 1;
|
|
|
|
registerComments(feed);
|
|
|
|
const matchingThread = feed.find((thread) => thread.number === number);
|
|
if (matchingThread?.cid) {
|
|
return {
|
|
match: matchingThread,
|
|
threads: feed,
|
|
};
|
|
}
|
|
|
|
if (!hasMore) {
|
|
return {
|
|
match: undefined,
|
|
threads: feed,
|
|
};
|
|
}
|
|
|
|
const previousLength = feed.length;
|
|
state.incrementFeedPageNumber(feedName);
|
|
await waitForBoardFeedPage(feedName, previousLength, pageNumber + 1);
|
|
}
|
|
};
|
|
|
|
const waitForRepliesPage = async (feedName: string, previousLength: number, expectedPageNumber: number) =>
|
|
waitFor(() => {
|
|
const state = repliesStore.getState();
|
|
const replies = state.loadedFeeds[feedName] ?? [];
|
|
const hasMore = state.feedsHaveMore[feedName];
|
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 0;
|
|
|
|
if (pageNumber < expectedPageNumber) {
|
|
return false;
|
|
}
|
|
|
|
if (replies.length > previousLength || replies.length >= expectedPageNumber * THREAD_SEARCH_REPLIES_PER_PAGE || hasMore === false) {
|
|
return { hasMore, replies };
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const searchThreadReplies = async ({
|
|
account,
|
|
directories,
|
|
number,
|
|
onStatus,
|
|
quoteDisplay,
|
|
communityAddress,
|
|
threads,
|
|
}: {
|
|
account: ResolverAccount;
|
|
directories: DirectoryCommunity[];
|
|
number: number;
|
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
|
quoteDisplay: string;
|
|
communityAddress: string;
|
|
threads: Comment[];
|
|
}) => {
|
|
const accountId = account.id;
|
|
if (!accountId) {
|
|
throw new Error('Missing account id while resolving external quote');
|
|
}
|
|
|
|
const boardLabel = getExternalQuoteBoardLabel(
|
|
{
|
|
kind: 'same-board',
|
|
number,
|
|
raw: quoteDisplay,
|
|
communityAddress,
|
|
},
|
|
directories,
|
|
);
|
|
|
|
const candidateThreads = threads.filter((thread) => thread?.cid && thread.replyCount !== 0);
|
|
|
|
for (const [index, thread] of candidateThreads.entries()) {
|
|
onStatus?.({
|
|
phase: 'search-thread',
|
|
boardLabel,
|
|
currentThread: index + 1,
|
|
quoteDisplay,
|
|
totalThreads: candidateThreads.length,
|
|
});
|
|
|
|
const feedOptions = {
|
|
accountId,
|
|
commentCid: thread.cid,
|
|
commentDepth: thread.depth,
|
|
flat: true,
|
|
postCid: thread.postCid ?? thread.cid,
|
|
repliesPerPage: THREAD_SEARCH_REPLIES_PER_PAGE,
|
|
sortType: THREAD_REPLIES_SORT_TYPE,
|
|
streamPage: true,
|
|
};
|
|
const feedName = feedOptionsToFeedName(feedOptions);
|
|
await repliesStore.getState().addFeedToStoreOrUpdateComment(thread, feedOptions);
|
|
await waitForRepliesPage(feedName, 0, 1);
|
|
|
|
while (true) {
|
|
const state = repliesStore.getState();
|
|
const replies = (state.loadedFeeds[feedName] ?? []) as Comment[];
|
|
const hasMore = state.feedsHaveMore[feedName];
|
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 1;
|
|
|
|
registerComments(replies);
|
|
|
|
const matchingReply = replies.find((reply) => reply.number === number);
|
|
if (matchingReply?.cid) {
|
|
return matchingReply;
|
|
}
|
|
|
|
if (!hasMore) {
|
|
break;
|
|
}
|
|
|
|
const previousLength = replies.length;
|
|
state.incrementFeedPageNumber(feedName);
|
|
await waitForRepliesPage(feedName, previousLength, pageNumber + 1);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const resolveExternalQuoteTarget = async ({
|
|
account,
|
|
directories,
|
|
onStatus,
|
|
reference,
|
|
}: {
|
|
account?: ResolverAccount | null;
|
|
directories: DirectoryCommunity[];
|
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
|
reference: ExternalQuoteReference;
|
|
}): Promise<ResolvedExternalQuoteTarget | null> => {
|
|
if (!account?.id) {
|
|
throw new Error('Missing active account while resolving external quote');
|
|
}
|
|
|
|
const targetCommunityAddress = getExternalQuoteBoardAddress(reference, directories);
|
|
const quoteDisplay = reference.raw;
|
|
const cachedCid = usePostNumberStore.getState().numberToCid[targetCommunityAddress]?.[reference.number];
|
|
if (cachedCid) {
|
|
return buildResolvedTarget({
|
|
cid: cachedCid,
|
|
comment: getCachedComment(cachedCid),
|
|
directories,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
}
|
|
|
|
const loadedComment = findLoadedCommentByNumber({
|
|
number: reference.number,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
if (loadedComment?.cid) {
|
|
registerComments([loadedComment]);
|
|
return buildResolvedTarget({
|
|
cid: loadedComment.cid,
|
|
comment: loadedComment,
|
|
directories,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
}
|
|
|
|
const { match: matchingThread, threads } = await loadBoardThreads({
|
|
account,
|
|
directories,
|
|
number: reference.number,
|
|
onStatus,
|
|
quoteDisplay,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
|
|
if (matchingThread?.cid) {
|
|
registerComments([matchingThread]);
|
|
return buildResolvedTarget({
|
|
cid: matchingThread.cid,
|
|
comment: matchingThread,
|
|
directories,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
}
|
|
|
|
const matchingReply = await searchThreadReplies({
|
|
account,
|
|
directories,
|
|
number: reference.number,
|
|
onStatus,
|
|
quoteDisplay,
|
|
communityAddress: targetCommunityAddress,
|
|
threads,
|
|
});
|
|
|
|
if (!matchingReply?.cid) {
|
|
return null;
|
|
}
|
|
|
|
registerComments([matchingReply]);
|
|
return buildResolvedTarget({
|
|
cid: matchingReply.cid,
|
|
comment: matchingReply,
|
|
directories,
|
|
communityAddress: targetCommunityAddress,
|
|
});
|
|
};
|