mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf: remove reply backlink subscription churn
Replaced per-reply useReplies subscriptions in backlink rendering with a precomputed directRepliesByParentCid map from thread-level filteredReplies. Also optimized quote link rendering in comment-content with scoped store subscription, reduced edit menu state churn, and improved DOM lookups with memoization to keep all backlink rendering data-local and eliminate repeated backend-state-driven reference churn during board scroll.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useMemo, useState } from 'react';
|
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Comment, useComment } from '@plebbit/plebbit-react-hooks';
|
import { Comment, useComment } from '@plebbit/plebbit-react-hooks';
|
||||||
@@ -25,6 +25,41 @@ const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => {
|
|||||||
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotedComment} isOP={isOP} />;
|
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotedComment} isOP={isOP} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useScopedCidToNumber = (cids: string[]) => {
|
||||||
|
const sortedUniqueCids = useMemo(() => {
|
||||||
|
const uniqueCids = new Set<string>();
|
||||||
|
for (const cid of cids) {
|
||||||
|
if (cid) {
|
||||||
|
uniqueCids.add(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...uniqueCids].sort();
|
||||||
|
}, [cids]);
|
||||||
|
|
||||||
|
// Subscribe only to CIDs this comment needs so unrelated thread updates do not rerender content.
|
||||||
|
const cidNumbersSignature = usePostNumberStore(
|
||||||
|
useCallback((state) => sortedUniqueCids.map((cid) => `${cid}:${state.cidToNumber[cid] ?? ''}`).join('|'), [sortedUniqueCids]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (sortedUniqueCids.length === 0) {
|
||||||
|
return {} as Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cidToNumber } = usePostNumberStore.getState();
|
||||||
|
const nextCidToNumber: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const cid of sortedUniqueCids) {
|
||||||
|
const number = cidToNumber[cid];
|
||||||
|
if (typeof number === 'number') {
|
||||||
|
nextCidToNumber[cid] = number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCidToNumber;
|
||||||
|
}, [sortedUniqueCids, cidNumbersSignature]);
|
||||||
|
};
|
||||||
|
|
||||||
const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -59,7 +94,15 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
|||||||
return new Set([...matches].map((m) => parseInt(m[1], 10)));
|
return new Set([...matches].map((m) => parseInt(m[1], 10)));
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
const cidToNumber = usePostNumberStore((state) => state.cidToNumber);
|
const relevantQuotedCids = useMemo(() => {
|
||||||
|
const cids = quotedCids ? [...quotedCids] : [];
|
||||||
|
if (parentCid) {
|
||||||
|
cids.push(parentCid);
|
||||||
|
}
|
||||||
|
return cids;
|
||||||
|
}, [quotedCids, parentCid]);
|
||||||
|
|
||||||
|
const cidToNumber = useScopedCidToNumber(relevantQuotedCids);
|
||||||
const filteredQuotedCids = useMemo(() => {
|
const filteredQuotedCids = useMemo(() => {
|
||||||
if (!quotedCids?.length) return [];
|
if (!quotedCids?.length) return [];
|
||||||
return quotedCids.filter((cid: string) => {
|
return quotedCids.filter((cid: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
||||||
import {
|
import {
|
||||||
@@ -33,30 +33,22 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
|
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
|
||||||
|
const authorDisplayName = post?.author?.displayName;
|
||||||
|
const modBanExpiresAt = post?.commentModeration?.author?.banExpiresAt;
|
||||||
const purged = post?.commentModeration?.purged ?? false;
|
const purged = post?.commentModeration?.purged ?? false;
|
||||||
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
||||||
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
|
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
|
||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const [signer, setSigner] = useState<any>(account?.signer);
|
|
||||||
|
|
||||||
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
|
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
|
||||||
commentAuthorAddress: author?.address,
|
commentAuthorAddress: author?.address,
|
||||||
subplebbitAddress,
|
subplebbitAddress,
|
||||||
postCid,
|
postCid,
|
||||||
});
|
});
|
||||||
|
const signer = isAccountCommentAuthor ? account?.signer : null;
|
||||||
const checkSigner = useCallback(() => {
|
const latestPostRef = useRef(post);
|
||||||
if (isAccountCommentAuthor) {
|
latestPostRef.current = post;
|
||||||
setSigner(account?.signer);
|
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
|
||||||
} else {
|
|
||||||
setSigner(null);
|
|
||||||
}
|
|
||||||
}, [isAccountCommentAuthor, account?.signer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkSigner();
|
|
||||||
}, [checkSigner]);
|
|
||||||
|
|
||||||
const defaultPublishEditOptions = useMemo(() => {
|
const defaultPublishEditOptions = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -75,17 +67,17 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
purged: purged ?? false,
|
purged: purged ?? false,
|
||||||
spoiler: spoiler ?? false,
|
spoiler: spoiler ?? false,
|
||||||
reason,
|
reason,
|
||||||
author: post?.commentModeration?.author?.banExpiresAt ? { banExpiresAt: post.commentModeration.author.banExpiresAt } : undefined,
|
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onChallenge: (...args: any) => addChallenge([...args, post]),
|
onChallenge,
|
||||||
onChallengeVerification: alertChallengeVerificationFailed,
|
onChallengeVerification: alertChallengeVerificationFailed,
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
alert('Comment edit failed. ' + error.message);
|
alert('Comment edit failed. ' + error.message);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, post]);
|
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, modBanExpiresAt, onChallenge]);
|
||||||
|
|
||||||
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
|
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
|
||||||
|
|
||||||
@@ -94,19 +86,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
subplebbitAddress,
|
subplebbitAddress,
|
||||||
signer,
|
signer,
|
||||||
author: signer?.address === author?.address ? { address: signer?.address, displayName: post?.author?.displayName } : account?.author,
|
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
|
||||||
content: publishCommentEditOptions.content,
|
content: publishCommentEditOptions.content,
|
||||||
deleted: publishCommentEditOptions.deleted,
|
deleted: publishCommentEditOptions.deleted,
|
||||||
reason: publishCommentEditOptions.reason,
|
reason: publishCommentEditOptions.reason,
|
||||||
spoiler: publishCommentEditOptions.spoiler,
|
spoiler: publishCommentEditOptions.spoiler,
|
||||||
onChallenge: (...args: any) => addChallenge([...args, post]),
|
onChallenge,
|
||||||
onChallengeVerification: alertChallengeVerificationFailed,
|
onChallengeVerification: alertChallengeVerificationFailed,
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
alert('Comment edit failed. ' + error.message);
|
alert('Comment edit failed. ' + error.message);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[publishCommentEditOptions, cid, subplebbitAddress, signer, post, account?.author, author?.address],
|
[publishCommentEditOptions, cid, subplebbitAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge],
|
||||||
);
|
);
|
||||||
|
|
||||||
const modEditOptions = useMemo<PublishCommentModerationOptions>(
|
const modEditOptions = useMemo<PublishCommentModerationOptions>(
|
||||||
@@ -123,27 +115,31 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
author: publishCommentEditOptions.commentModeration?.author,
|
author: publishCommentEditOptions.commentModeration?.author,
|
||||||
},
|
},
|
||||||
author: account?.author,
|
author: account?.author,
|
||||||
onChallenge: (...args: any) => addChallenge([...args, post]),
|
onChallenge,
|
||||||
onChallengeVerification: alertChallengeVerificationFailed,
|
onChallengeVerification: alertChallengeVerificationFailed,
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
alert('Comment moderation failed. ' + error.message);
|
alert('Comment moderation failed. ' + error.message);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[publishCommentEditOptions, cid, subplebbitAddress, post, account?.author, parentCid],
|
[publishCommentEditOptions, cid, subplebbitAddress, account?.author, parentCid, onChallenge],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
|
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
|
||||||
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
|
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPublishCommentEditOptions(defaultPublishEditOptions);
|
|
||||||
}, [defaultPublishEditOptions]);
|
|
||||||
|
|
||||||
const [banDuration, setBanDuration] = useState(() =>
|
const [banDuration, setBanDuration] = useState(() =>
|
||||||
publishCommentEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(publishCommentEditOptions.commentModeration.author.banExpiresAt) : 1,
|
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetMenuState = () => {
|
||||||
|
setPublishCommentEditOptions(defaultPublishEditOptions);
|
||||||
|
setBanDuration(
|
||||||
|
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
||||||
|
);
|
||||||
|
setIsContentEditorOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { id, checked } = e.target;
|
const { id, checked } = e.target;
|
||||||
|
|
||||||
@@ -258,7 +254,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
type='checkbox'
|
type='checkbox'
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (cid && (isAccountCommentAuthor || isAccountMod)) {
|
if (cid && (isAccountCommentAuthor || isAccountMod)) {
|
||||||
setIsEditMenuOpen(!isEditMenuOpen);
|
if (!isEditMenuOpen) {
|
||||||
|
resetMenuState();
|
||||||
|
setIsEditMenuOpen(true);
|
||||||
|
} else {
|
||||||
|
setIsEditMenuOpen(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsEditMenuOpen(false);
|
setIsEditMenuOpen(false);
|
||||||
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
|
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
|
||||||
|
|||||||
@@ -247,14 +247,18 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
||||||
const remarkPlugins: any[] = [[supersub]];
|
const remarkPlugins = useMemo(() => {
|
||||||
|
const plugins: any[] = [[supersub]];
|
||||||
|
|
||||||
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
|
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
|
||||||
remarkPlugins.push([remarkGfm, { singleTilde: false }]);
|
plugins.push([remarkGfm, { singleTilde: false }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
remarkPlugins.push([blockquoteToGreentext]);
|
plugins.push([blockquoteToGreentext]);
|
||||||
remarkPlugins.push([spoilerTransform]);
|
plugins.push([spoilerTransform]);
|
||||||
|
|
||||||
|
return plugins;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
const customSchema = useMemo(
|
const customSchema = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -270,25 +274,18 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
const location = useLocation();
|
||||||
|
const params = useParams();
|
||||||
|
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||||
|
|
||||||
|
const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]);
|
||||||
|
|
||||||
// Preprocess content to convert plain text 5chan patterns to markdown links
|
// Preprocess content to convert plain text 5chan patterns to markdown links
|
||||||
const processedContent = preprocess5chanPatterns(content || '');
|
const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]);
|
||||||
|
|
||||||
return (
|
const components = useMemo(
|
||||||
<span className={styles.markdown}>
|
() =>
|
||||||
{isInCatalogView && title && (
|
({
|
||||||
<span>
|
|
||||||
<b>{title}</b>
|
|
||||||
{content ? ': ' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ReactMarkdown
|
|
||||||
children={processedContent}
|
|
||||||
remarkPlugins={remarkPlugins}
|
|
||||||
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize, customSchema]]}
|
|
||||||
components={
|
|
||||||
{
|
|
||||||
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
|
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
|
||||||
h1: ({ children }) => <p className={styles.header}>{children}</p>,
|
h1: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||||
h2: ({ children }) => <p className={styles.header}>{children}</p>,
|
h2: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||||
@@ -321,9 +318,19 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
|||||||
|
|
||||||
return renderAnchorLink(children, href || '', postCid);
|
return renderAnchorLink(children, href || '', postCid);
|
||||||
},
|
},
|
||||||
} as ExtendedComponents
|
}) as ExtendedComponents,
|
||||||
}
|
[isInCatalogView, postCid],
|
||||||
/>
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.markdown}>
|
||||||
|
{isInCatalogView && title && (
|
||||||
|
<span>
|
||||||
|
<b>{title}</b>
|
||||||
|
{content ? ': ' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ReactMarkdown children={processedContent} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ const PostInfo = ({
|
|||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
quotedByMap,
|
quotedByMap,
|
||||||
}: PostProps) => {
|
directRepliesByParentCid,
|
||||||
|
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
|
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
|
||||||
const title = post?.title?.trim();
|
const title = post?.title?.trim();
|
||||||
const { replies } = useReplies({ comment: post, accountComments: { newerThan: Infinity } });
|
|
||||||
const { address, shortAddress } = author || {};
|
const { address, shortAddress } = author || {};
|
||||||
const displayName = author?.displayName?.trim();
|
const displayName = author?.displayName?.trim();
|
||||||
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
|
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
|
||||||
@@ -205,12 +205,18 @@ const PostInfo = ({
|
|||||||
const userIDBackgroundColor = hashStringToColor(userID);
|
const userIDBackgroundColor = hashStringToColor(userID);
|
||||||
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
||||||
|
|
||||||
const handleUserAddressClick = useAuthorAddressClick();
|
|
||||||
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
|
||||||
|
|
||||||
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
|
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
|
||||||
const showUserID = pseudonymityMode !== 'per-reply';
|
const showUserID = pseudonymityMode !== 'per-reply';
|
||||||
|
|
||||||
|
const handleUserAddressClick = useAuthorAddressClick();
|
||||||
|
const numberOfPostsByAuthor = useMemo(() => {
|
||||||
|
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
||||||
|
}, [showUserID, deleted, removed, shortAddress, postCid]);
|
||||||
|
|
||||||
const { hidden } = useHide(post);
|
const { hidden } = useHide(post);
|
||||||
|
|
||||||
const { openReplyModal } = useReplyModalStore();
|
const { openReplyModal } = useReplyModalStore();
|
||||||
@@ -421,18 +427,35 @@ const PostInfo = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
|
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
|
||||||
{cid &&
|
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
|
||||||
parentCid &&
|
{cid && !parentCid && <OpBacklinks cid={cid} quotedByMap={quotedByMap} />}
|
||||||
replies &&
|
</span>
|
||||||
replies.map(
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReplyBacklinks = ({
|
||||||
|
post,
|
||||||
|
quotedByMap,
|
||||||
|
directRepliesByParentCid,
|
||||||
|
}: {
|
||||||
|
post: Comment;
|
||||||
|
quotedByMap?: Map<string, Comment[]>;
|
||||||
|
directRepliesByParentCid?: Map<string, Comment[]>;
|
||||||
|
}) => {
|
||||||
|
const { cid, parentCid } = post || {};
|
||||||
|
if (!cid || !parentCid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const directReplies = directRepliesByParentCid?.get(cid) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{directReplies.map(
|
||||||
(reply: Comment, index: number) =>
|
(reply: Comment, index: number) =>
|
||||||
reply?.parentCid === cid &&
|
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
||||||
reply?.cid &&
|
|
||||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
|
||||||
)}
|
)}
|
||||||
{cid &&
|
{quotedByMap
|
||||||
parentCid &&
|
|
||||||
quotedByMap
|
|
||||||
?.get(cid)
|
?.get(cid)
|
||||||
?.map(
|
?.map(
|
||||||
(reply: Comment, index: number) =>
|
(reply: Comment, index: number) =>
|
||||||
@@ -440,18 +463,20 @@ const PostInfo = ({
|
|||||||
reply?.cid &&
|
reply?.cid &&
|
||||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
|
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
|
||||||
)}
|
)}
|
||||||
{cid &&
|
</>
|
||||||
!parentCid &&
|
);
|
||||||
quotedByMap
|
};
|
||||||
|
|
||||||
|
const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map<string, Comment[]> }) => (
|
||||||
|
<>
|
||||||
|
{quotedByMap
|
||||||
?.get(cid)
|
?.get(cid)
|
||||||
?.map(
|
?.map(
|
||||||
(reply: Comment) =>
|
(reply: Comment) =>
|
||||||
reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`op-bl-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
|
reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`op-bl-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
|
||||||
)}
|
)}
|
||||||
</span>
|
</>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PostMediaProps {
|
interface PostMediaProps {
|
||||||
commentMediaInfo: CommentMediaInfo | undefined;
|
commentMediaInfo: CommentMediaInfo | undefined;
|
||||||
@@ -550,7 +575,14 @@ const PostMedia = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: PostProps) => {
|
const Reply = ({
|
||||||
|
postReplyCount,
|
||||||
|
reply,
|
||||||
|
roles,
|
||||||
|
threadNumber,
|
||||||
|
quotedByMap,
|
||||||
|
directRepliesByParentCid,
|
||||||
|
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
|
||||||
let post = reply;
|
let post = reply;
|
||||||
// handle pending mod or author edit
|
// handle pending mod or author edit
|
||||||
const { editedComment } = useEditedComment({ comment: reply });
|
const { editedComment } = useEditedComment({ comment: reply });
|
||||||
@@ -578,7 +610,15 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: Post
|
|||||||
<div className={styles.replyDesktop}>
|
<div className={styles.replyDesktop}>
|
||||||
<div className={styles.sideArrows}>{'>>'}</div>
|
<div className={styles.sideArrows}>{'>>'}</div>
|
||||||
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
|
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
|
||||||
<PostInfo post={post} postReplyCount={postReplyCount} roles={roles} isHidden={hidden} threadNumber={threadNumber} quotedByMap={quotedByMap} />
|
<PostInfo
|
||||||
|
post={post}
|
||||||
|
postReplyCount={postReplyCount}
|
||||||
|
roles={roles}
|
||||||
|
isHidden={hidden}
|
||||||
|
threadNumber={threadNumber}
|
||||||
|
quotedByMap={quotedByMap}
|
||||||
|
directRepliesByParentCid={directRepliesByParentCid}
|
||||||
|
/>
|
||||||
{link && !hidden && !(deleted || removed) && isValidURL(link) && (
|
{link && !hidden && !(deleted || removed) && isValidURL(link) && (
|
||||||
<PostMedia
|
<PostMedia
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
@@ -668,6 +708,22 @@ const PostDesktop = ({
|
|||||||
|
|
||||||
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
|
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
|
||||||
const filteredReplies = useMemo(() => (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]);
|
const filteredReplies = useMemo(() => (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]);
|
||||||
|
const directRepliesByParentCid = useMemo(() => {
|
||||||
|
const map = new Map<string, Comment[]>();
|
||||||
|
for (const reply of filteredReplies) {
|
||||||
|
const directParentCid = reply?.parentCid;
|
||||||
|
if (!directParentCid || !reply?.cid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingReplies = map.get(directParentCid);
|
||||||
|
if (existingReplies) {
|
||||||
|
existingReplies.push(reply);
|
||||||
|
} else {
|
||||||
|
map.set(directParentCid, [reply]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filteredReplies]);
|
||||||
|
|
||||||
const quotedByMap = useQuotedByMap(filteredReplies);
|
const quotedByMap = useQuotedByMap(filteredReplies);
|
||||||
|
|
||||||
@@ -760,6 +816,7 @@ const PostDesktop = ({
|
|||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onReject={onReject}
|
onReject={onReject}
|
||||||
quotedByMap={quotedByMap}
|
quotedByMap={quotedByMap}
|
||||||
|
directRepliesByParentCid={directRepliesByParentCid}
|
||||||
/>
|
/>
|
||||||
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
|
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
|
||||||
{!isHidden && <CommentContent comment={post} />}
|
{!isHidden && <CommentContent comment={post} />}
|
||||||
@@ -797,7 +854,14 @@ const PostDesktop = ({
|
|||||||
data={filteredReplies}
|
data={filteredReplies}
|
||||||
itemContent={(index, reply) => (
|
itemContent={(index, reply) => (
|
||||||
<div className={styles.replyContainer}>
|
<div className={styles.replyContainer}>
|
||||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
|
<Reply
|
||||||
|
reply={reply}
|
||||||
|
roles={roles}
|
||||||
|
postReplyCount={replyCount}
|
||||||
|
threadNumber={post?.number}
|
||||||
|
quotedByMap={quotedByMap}
|
||||||
|
directRepliesByParentCid={directRepliesByParentCid}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
useWindowScroll={true}
|
useWindowScroll={true}
|
||||||
@@ -816,7 +880,14 @@ const PostDesktop = ({
|
|||||||
!hasMore &&
|
!hasMore &&
|
||||||
filteredReplies.map((reply, index) => (
|
filteredReplies.map((reply, index) => (
|
||||||
<div key={index} className={styles.replyContainer}>
|
<div key={index} className={styles.replyContainer}>
|
||||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
|
<Reply
|
||||||
|
reply={reply}
|
||||||
|
roles={roles}
|
||||||
|
postReplyCount={replyCount}
|
||||||
|
threadNumber={post?.number}
|
||||||
|
quotedByMap={quotedByMap}
|
||||||
|
directRepliesByParentCid={directRepliesByParentCid}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Non-virtualized rendering for board view (last 5 replies or show omitted) */}
|
{/* Non-virtualized rendering for board view (last 5 replies or show omitted) */}
|
||||||
@@ -828,7 +899,14 @@ const PostDesktop = ({
|
|||||||
showReplies &&
|
showReplies &&
|
||||||
(showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
|
(showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
|
||||||
<div key={index} className={styles.replyContainer}>
|
<div key={index} className={styles.replyContainer}>
|
||||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
|
<Reply
|
||||||
|
reply={reply}
|
||||||
|
roles={roles}
|
||||||
|
postReplyCount={replyCount}
|
||||||
|
threadNumber={post?.number}
|
||||||
|
quotedByMap={quotedByMap}
|
||||||
|
directRepliesByParentCid={directRepliesByParentCid}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,12 +171,18 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
|||||||
const stateString = useStateString(post);
|
const stateString = useStateString(post);
|
||||||
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
|
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
|
||||||
|
|
||||||
const handleUserAddressClick = useAuthorAddressClick();
|
|
||||||
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
|
||||||
|
|
||||||
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
|
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
|
||||||
const showUserID = pseudonymityMode !== 'per-reply';
|
const showUserID = pseudonymityMode !== 'per-reply';
|
||||||
|
|
||||||
|
const handleUserAddressClick = useAuthorAddressClick();
|
||||||
|
const numberOfPostsByAuthor = useMemo(() => {
|
||||||
|
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
||||||
|
}, [showUserID, deleted, removed, shortAddress, postCid]);
|
||||||
|
|
||||||
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
|
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
|
||||||
const userIDBackgroundColor = hashStringToColor(userID);
|
const userIDBackgroundColor = hashStringToColor(userID);
|
||||||
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||||
import { QUOTE_NUMBER_REGEX } from '../lib/utils/url-utils';
|
import { QUOTE_NUMBER_REGEX } from '../lib/utils/url-utils';
|
||||||
import usePostNumberStore from '../stores/use-post-number-store';
|
import usePostNumberStore from '../stores/use-post-number-store';
|
||||||
@@ -8,6 +8,30 @@ interface ReplyQuoteTargets {
|
|||||||
quotedPostNumbers: number[];
|
quotedPostNumbers: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReplyFingerprint = (reply: Comment) =>
|
||||||
|
`${reply?.cid ?? ''}|${reply?.deleted ? '1' : '0'}|${reply?.removed ? '1' : '0'}|${reply?.edit?.timestamp ?? ''}|${reply?.state ?? ''}`;
|
||||||
|
|
||||||
|
const areQuotedByMapsEquivalent = (previousMap: Map<string, Comment[]>, nextMap: Map<string, Comment[]>) => {
|
||||||
|
if (previousMap.size !== nextMap.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [quotedCid, nextReplies] of nextMap) {
|
||||||
|
const previousReplies = previousMap.get(quotedCid);
|
||||||
|
if (!previousReplies || previousReplies.length !== nextReplies.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < nextReplies.length; i++) {
|
||||||
|
if (getReplyFingerprint(previousReplies[i]) !== getReplyFingerprint(nextReplies[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const extractReplyQuoteTargets = (replies: Comment[]) => {
|
const extractReplyQuoteTargets = (replies: Comment[]) => {
|
||||||
const quotedPostNumbers = new Set<number>();
|
const quotedPostNumbers = new Set<number>();
|
||||||
const replyQuoteTargets: ReplyQuoteTargets[] = [];
|
const replyQuoteTargets: ReplyQuoteTargets[] = [];
|
||||||
@@ -38,6 +62,7 @@ const extractReplyQuoteTargets = (replies: Comment[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useQuotedByMap = (replies: Comment[] = []) => {
|
const useQuotedByMap = (replies: Comment[] = []) => {
|
||||||
|
const stableQuotedByMapRef = useRef<Map<string, Comment[]>>(new Map());
|
||||||
const { replyQuoteTargets, quotedPostNumbers } = useMemo(() => extractReplyQuoteTargets(replies), [replies]);
|
const { replyQuoteTargets, quotedPostNumbers } = useMemo(() => extractReplyQuoteTargets(replies), [replies]);
|
||||||
|
|
||||||
// Subscribe only to post numbers referenced in this thread to avoid unrelated global store churn.
|
// Subscribe only to post numbers referenced in this thread to avoid unrelated global store churn.
|
||||||
@@ -92,6 +117,11 @@ const useQuotedByMap = (replies: Comment[] = []) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (areQuotedByMapsEquivalent(stableQuotedByMapRef.current, map)) {
|
||||||
|
return stableQuotedByMapRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
stableQuotedByMapRef.current = map;
|
||||||
return map;
|
return map;
|
||||||
}, [replyQuoteTargets, quotedNumberToCid]);
|
}, [replyQuoteTargets, quotedNumberToCid]);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user