mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(quotes): resolve external quote links across boards (#1064)
This commit is contained in:
@@ -54,7 +54,9 @@ describe('url-utils', () => {
|
||||
|
||||
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/biz/123')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/board.eth/123')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false);
|
||||
expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
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 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;
|
||||
subplebbitAddress: 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, subplebbitAddress: string) =>
|
||||
`external-quote-board-${accountId}-${subplebbitAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`;
|
||||
|
||||
const getCachedComment = (cid?: string) => (cid ? subplebbitsPagesStore.getState().comments[cid] : undefined);
|
||||
|
||||
const findLoadedCommentByNumber = ({ number, subplebbitAddress }: { number: number; subplebbitAddress: string }) => {
|
||||
const comments = Object.values(subplebbitsPagesStore.getState().comments) as Array<Comment | undefined>;
|
||||
|
||||
return comments.find((comment) => comment?.subplebbitAddress === subplebbitAddress && comment?.number === number && comment?.cid);
|
||||
};
|
||||
|
||||
const buildResolvedTarget = ({
|
||||
cid,
|
||||
comment,
|
||||
directories,
|
||||
subplebbitAddress,
|
||||
}: {
|
||||
cid: string;
|
||||
comment?: Comment;
|
||||
directories: DirectoryCommunity[];
|
||||
subplebbitAddress: string;
|
||||
}): ResolvedExternalQuoteTarget => {
|
||||
const boardPath = getBoardPath(subplebbitAddress, directories);
|
||||
return {
|
||||
boardPath,
|
||||
cid,
|
||||
comment,
|
||||
isUnavailable: isUnavailableComment(comment),
|
||||
route: `/${boardPath}/thread/${cid}`,
|
||||
subplebbitAddress,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
subplebbitAddress,
|
||||
directories,
|
||||
}: {
|
||||
account: ResolverAccount;
|
||||
directories: DirectoryCommunity[];
|
||||
number: number;
|
||||
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
||||
quoteDisplay: string;
|
||||
subplebbitAddress: 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,
|
||||
subplebbitAddress,
|
||||
},
|
||||
directories,
|
||||
);
|
||||
|
||||
onStatus?.({
|
||||
phase: 'search-board',
|
||||
boardLabel,
|
||||
quoteDisplay,
|
||||
});
|
||||
|
||||
const feedName = getBoardFeedName(accountId, subplebbitAddress);
|
||||
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 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,
|
||||
subplebbitAddress,
|
||||
threads,
|
||||
}: {
|
||||
account: ResolverAccount;
|
||||
directories: DirectoryCommunity[];
|
||||
number: number;
|
||||
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
||||
quoteDisplay: string;
|
||||
subplebbitAddress: 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,
|
||||
subplebbitAddress,
|
||||
},
|
||||
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 targetSubplebbitAddress = getExternalQuoteBoardAddress(reference, directories);
|
||||
const quoteDisplay = reference.raw;
|
||||
const cachedCid = usePostNumberStore.getState().numberToCid[targetSubplebbitAddress]?.[reference.number];
|
||||
if (cachedCid) {
|
||||
return buildResolvedTarget({
|
||||
cid: cachedCid,
|
||||
comment: getCachedComment(cachedCid),
|
||||
directories,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
}
|
||||
|
||||
const loadedComment = findLoadedCommentByNumber({
|
||||
number: reference.number,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
if (loadedComment?.cid) {
|
||||
registerComments([loadedComment]);
|
||||
return buildResolvedTarget({
|
||||
cid: loadedComment.cid,
|
||||
comment: loadedComment,
|
||||
directories,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
}
|
||||
|
||||
const { match: matchingThread, threads } = await loadBoardThreads({
|
||||
account,
|
||||
directories,
|
||||
number: reference.number,
|
||||
onStatus,
|
||||
quoteDisplay,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
|
||||
if (matchingThread?.cid) {
|
||||
registerComments([matchingThread]);
|
||||
return buildResolvedTarget({
|
||||
cid: matchingThread.cid,
|
||||
comment: matchingThread,
|
||||
directories,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
}
|
||||
|
||||
const matchingReply = await searchThreadReplies({
|
||||
account,
|
||||
directories,
|
||||
number: reference.number,
|
||||
onStatus,
|
||||
quoteDisplay,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
threads,
|
||||
});
|
||||
|
||||
if (!matchingReply?.cid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerComments([matchingReply]);
|
||||
return buildResolvedTarget({
|
||||
cid: matchingReply.cid,
|
||||
comment: matchingReply,
|
||||
directories,
|
||||
subplebbitAddress: targetSubplebbitAddress,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { DirectoryCommunity } from '../../hooks/use-directories';
|
||||
import { getBoardPath, 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\\-.]+)';
|
||||
|
||||
const CROSSBOARD_NUMBER_QUOTE_REGEX = new RegExp(`>>>\\/(${CROSSBOARD_NUMBER_BOARD_PART})\\/(\\d+)(?=[^\\d]|$)`, 'g');
|
||||
export const CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX = new RegExp(`>>>\\/(${CROSSBOARD_NUMBER_BOARD_PART})\\/(\\d+)[.,:;!?]*`);
|
||||
|
||||
export type SameBoardExternalQuoteReference = {
|
||||
kind: 'same-board';
|
||||
number: number;
|
||||
raw: string;
|
||||
subplebbitAddress: string;
|
||||
};
|
||||
|
||||
export type CrossBoardExternalQuoteReference = {
|
||||
kind: 'cross-board';
|
||||
boardIdentifier: string;
|
||||
number: number;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type ExternalQuoteReference = SameBoardExternalQuoteReference | CrossBoardExternalQuoteReference;
|
||||
|
||||
const getExternalQuoteKey = (reference: ExternalQuoteReference) =>
|
||||
reference.kind === 'cross-board'
|
||||
? `${reference.kind}:${reference.boardIdentifier}:${reference.number}`
|
||||
: `${reference.kind}:${reference.subplebbitAddress}:${reference.number}`;
|
||||
|
||||
export const getExternalQuoteBoardAddress = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) =>
|
||||
reference.kind === 'cross-board' ? getSubplebbitAddress(reference.boardIdentifier, directories) : reference.subplebbitAddress;
|
||||
|
||||
export const getExternalQuoteBoardLabel = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => {
|
||||
const address = getExternalQuoteBoardAddress(reference, directories);
|
||||
return getBoardPath(address, directories);
|
||||
};
|
||||
|
||||
export const extractUnresolvedExternalQuoteReferences = ({
|
||||
content,
|
||||
scopedNumberToCid,
|
||||
subplebbitAddress,
|
||||
}: {
|
||||
content?: string;
|
||||
scopedNumberToCid?: Record<number, string>;
|
||||
subplebbitAddress?: string;
|
||||
}) => {
|
||||
if (!content) {
|
||||
return [] as ExternalQuoteReference[];
|
||||
}
|
||||
|
||||
const references = new Map<string, ExternalQuoteReference>();
|
||||
|
||||
if (subplebbitAddress) {
|
||||
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]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reference: SameBoardExternalQuoteReference = {
|
||||
kind: 'same-board',
|
||||
number,
|
||||
raw: `>>${number}`,
|
||||
subplebbitAddress,
|
||||
};
|
||||
references.set(getExternalQuoteKey(reference), reference);
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(new RegExp(CROSSBOARD_NUMBER_QUOTE_REGEX.source, 'g'))) {
|
||||
const boardIdentifier = match[1];
|
||||
const number = Number.parseInt(match[2], 10);
|
||||
|
||||
if (!boardIdentifier || Number.isNaN(number)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reference: CrossBoardExternalQuoteReference = {
|
||||
kind: 'cross-board',
|
||||
boardIdentifier,
|
||||
number,
|
||||
raw: `>>>/${boardIdentifier}/${number}`,
|
||||
};
|
||||
references.set(getExternalQuoteKey(reference), reference);
|
||||
}
|
||||
|
||||
return [...references.values()];
|
||||
};
|
||||
|
||||
export type ExternalQuoteSearchStatus =
|
||||
| {
|
||||
phase: 'search-board';
|
||||
boardLabel: string;
|
||||
quoteDisplay: string;
|
||||
}
|
||||
| {
|
||||
phase: 'search-thread';
|
||||
boardLabel: string;
|
||||
currentThread: number;
|
||||
totalThreads: number;
|
||||
quoteDisplay: string;
|
||||
}
|
||||
| {
|
||||
phase: 'redirecting';
|
||||
boardLabel: string;
|
||||
quoteDisplay: string;
|
||||
};
|
||||
|
||||
export const getExternalQuoteStatusMessage = (t: (key: string, options?: Record<string, unknown>) => string, status: ExternalQuoteSearchStatus) => {
|
||||
switch (status.phase) {
|
||||
case 'search-board':
|
||||
return t('external_quote_search_board_feed', { board: status.boardLabel });
|
||||
case 'search-thread':
|
||||
return t('external_quote_search_thread', {
|
||||
board: status.boardLabel,
|
||||
current: status.currentThread,
|
||||
total: status.totalThreads,
|
||||
});
|
||||
case 'redirecting':
|
||||
return t('external_quote_redirecting_to_post', { board: status.boardLabel });
|
||||
}
|
||||
};
|
||||
@@ -180,6 +180,12 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
||||
return true; // CID is exactly 46 alphanumeric chars
|
||||
}
|
||||
|
||||
// Check if it's a directory + post number pattern: >>>/biz/123
|
||||
const directoryPostNumberMatch = pathPart.match(/^([a-zA-Z0-9]{1,10})\/(\d+)$/);
|
||||
if (directoryPostNumberMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a full address + thread pattern: >>>/board.eth/fullCid
|
||||
const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/);
|
||||
if (addressThreadMatch) {
|
||||
@@ -188,6 +194,13 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
||||
return isValidDomain(address) || isValidIPNSKey(address);
|
||||
}
|
||||
|
||||
// Check if it's a full address + post number pattern: >>>/board.eth/123
|
||||
const addressPostNumberMatch = pathPart.match(/^([^/]+)\/(\d+)$/);
|
||||
if (addressPostNumberMatch) {
|
||||
const [, address] = addressPostNumberMatch;
|
||||
return isValidDomain(address) || isValidIPNSKey(address);
|
||||
}
|
||||
|
||||
// Check if it's just a full address pattern: >>>/board.eth
|
||||
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user