mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* Update README.md * fix(post): stop thread navigation from forcing OP alignment Route-driven thread opens now use the normal top-of-page behavior, while explicit OP permalink intents carry a scrollThreadContainerCid state and resolve against the visible thread container only. The old spacer injection path is removed. * fix(post): address PR review follow-ups
241 lines
9.5 KiB
TypeScript
241 lines
9.5 KiB
TypeScript
import { memo, useEffect, useMemo, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks';
|
|
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
|
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
import { isAllView } from '../../lib/utils/view-utils';
|
|
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
|
import { useDirectories } from '../../hooks/use-directories';
|
|
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
|
import ErrorDisplay from '../../components/error-display/error-display';
|
|
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
|
|
import PostDesktop from '../../components/post-desktop';
|
|
import PostMobile from '../../components/post-mobile';
|
|
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
|
import styles from './post.module.css';
|
|
|
|
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
|
// This hook falls back to the subplebbit pages store (populated by useFeed) so content
|
|
// from the catalog appears instantly instead of going through a loading phase.
|
|
const useCommentWithFeedCache = (options: { commentCid: string | undefined }) => {
|
|
const comment = useComment(options);
|
|
const cachedComment = useSubplebbitsPagesStore((state) => state.comments[options?.commentCid || '']);
|
|
|
|
return useMemo(() => {
|
|
if (!cachedComment || comment?.timestamp) return comment;
|
|
return { ...cachedComment, state: comment?.state, error: comment?.error, errors: comment?.errors } as Comment;
|
|
}, [comment, cachedComment]);
|
|
};
|
|
|
|
export interface PostProps {
|
|
index?: number;
|
|
isHidden?: boolean;
|
|
hasThumbnail?: boolean;
|
|
post?: any;
|
|
postReplyCount?: number;
|
|
reply?: any;
|
|
roles?: Role[];
|
|
showAllReplies?: boolean;
|
|
showReplies?: boolean;
|
|
targetReplyCid?: string;
|
|
threadNumber?: number;
|
|
isModQueue?: boolean;
|
|
modQueueStatus?: 'approved' | 'rejected' | 'failed' | null;
|
|
modQueueError?: string;
|
|
isPublishing?: boolean;
|
|
onApprove?: () => void;
|
|
onReject?: () => void;
|
|
quotedByMap?: Map<string, Comment[]>;
|
|
}
|
|
|
|
export const Post = memo(
|
|
({ post, showAllReplies = false, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject }: PostProps) => {
|
|
// Only subscribe to roles field to avoid rerenders from updatingState changes
|
|
const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles);
|
|
const isMobile = useIsMobile();
|
|
|
|
let comment = post;
|
|
|
|
// handle pending mod or author edit
|
|
const { editedComment } = useEditedComment({ comment });
|
|
if (editedComment) {
|
|
comment = editedComment;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.thread}>
|
|
<div className={styles.postContainer}>
|
|
{isMobile ? (
|
|
<PostMobile
|
|
post={comment}
|
|
roles={roles}
|
|
showAllReplies={showAllReplies}
|
|
showReplies={showReplies}
|
|
targetReplyCid={targetReplyCid}
|
|
isModQueue={isModQueue}
|
|
modQueueStatus={modQueueStatus}
|
|
modQueueError={modQueueError}
|
|
isPublishing={isPublishing}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
/>
|
|
) : (
|
|
<PostDesktop
|
|
post={comment}
|
|
roles={roles}
|
|
showAllReplies={showAllReplies}
|
|
showReplies={showReplies}
|
|
targetReplyCid={targetReplyCid}
|
|
isModQueue={isModQueue}
|
|
modQueueStatus={modQueueStatus}
|
|
modQueueError={modQueueError}
|
|
isPublishing={isPublishing}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
(prevProps, nextProps) => {
|
|
const prev = prevProps.post;
|
|
const next = nextProps.post;
|
|
return (
|
|
prev?.cid === next?.cid &&
|
|
prev?.replyCount === next?.replyCount &&
|
|
prev?.updatedAt === next?.updatedAt &&
|
|
prev?.locked === next?.locked &&
|
|
prev?.pinned === next?.pinned &&
|
|
prev?.removed === next?.removed &&
|
|
prev?.deleted === next?.deleted &&
|
|
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
|
|
prevProps.showAllReplies === nextProps.showAllReplies &&
|
|
prevProps.showReplies === nextProps.showReplies &&
|
|
prevProps.targetReplyCid === nextProps.targetReplyCid &&
|
|
prevProps.isModQueue === nextProps.isModQueue &&
|
|
prevProps.modQueueStatus === nextProps.modQueueStatus &&
|
|
prevProps.modQueueError === nextProps.modQueueError &&
|
|
prevProps.isPublishing === nextProps.isPublishing &&
|
|
prevProps.onApprove === nextProps.onApprove &&
|
|
prevProps.onReject === nextProps.onReject
|
|
);
|
|
},
|
|
);
|
|
|
|
const PostPage = () => {
|
|
const { t } = useTranslation();
|
|
const params = useParams();
|
|
const location = useLocation();
|
|
const { commentCid } = params;
|
|
const subplebbitAddress = useResolvedSubplebbitAddress();
|
|
const isInAllView = isAllView(location.pathname);
|
|
|
|
const comment = useCommentWithFeedCache({ commentCid });
|
|
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
|
|
|
const navigate = useNavigate();
|
|
useEffect(() => {
|
|
if (comment?.subplebbitAddress && subplebbitAddress && !areSameBoardAddress(comment.subplebbitAddress, subplebbitAddress)) {
|
|
navigate('/not-found', { replace: true });
|
|
}
|
|
}, [comment?.subplebbitAddress, subplebbitAddress, navigate]);
|
|
|
|
const subplebbit = useSubplebbit({ subplebbitAddress });
|
|
const { error: subplebbitError, shortAddress, title } = subplebbit || {};
|
|
const directories = useDirectories();
|
|
|
|
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
|
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid });
|
|
let post: Comment;
|
|
if (comment.parentCid) {
|
|
post = postComment;
|
|
} else {
|
|
post = comment;
|
|
}
|
|
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
|
|
|
|
const { error } = post || {};
|
|
|
|
// These two effects split normal opens from explicit OP-top intents:
|
|
// the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the
|
|
// second consumes `requestedThreadTopCid` once per `location.key` via
|
|
// `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only
|
|
// replays for deliberate OP-link clicks and never for route-driven thread opens.
|
|
useEffect(() => {
|
|
if (!comment?.cid || comment.parentCid) return;
|
|
if (requestedThreadTopCid === comment.cid) return;
|
|
window.scrollTo(0, 0);
|
|
}, [comment?.cid, comment?.parentCid, requestedThreadTopCid]);
|
|
|
|
useEffect(() => {
|
|
if (!commentCid || post?.cid !== commentCid) return;
|
|
if (requestedThreadTopCid !== commentCid) return;
|
|
|
|
const consumedKey = `${location.key}:${commentCid}`;
|
|
if (consumedThreadTopScrollRef.current === consumedKey) return;
|
|
|
|
if (scrollThreadContainerToTop(commentCid)) {
|
|
consumedThreadTopScrollRef.current = consumedKey;
|
|
}
|
|
}, [commentCid, location.key, post?.cid, requestedThreadTopCid]);
|
|
|
|
useEffect(() => {
|
|
const boardIdentifier = params.boardIdentifier;
|
|
const isDirectory = boardIdentifier ? isDirectoryBoard(boardIdentifier, directories) : false;
|
|
|
|
let boardTitle: string;
|
|
if (isInAllView) {
|
|
boardTitle = t('all');
|
|
} else if (isDirectory) {
|
|
boardTitle = `/${boardIdentifier}/`;
|
|
} else {
|
|
boardTitle = title ? title : shortAddress || subplebbitAddress || '';
|
|
}
|
|
|
|
const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30);
|
|
const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : '';
|
|
document.title = `${boardTitle}${postTitlePart} - 5chan`;
|
|
}, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]);
|
|
|
|
const shouldShowCommentError = comment?.error?.message && !comment?.cid;
|
|
const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;
|
|
const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid;
|
|
|
|
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
|
|
|
return (
|
|
<div className={styles.content}>
|
|
{shouldShowPostError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={error} />
|
|
</div>
|
|
)}
|
|
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} />
|
|
{shouldShowSubplebbitError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={subplebbitError} />
|
|
</div>
|
|
)}
|
|
{shouldShowCommentError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={comment?.error} />
|
|
</div>
|
|
)}
|
|
{post?.cid && subplebbitAddress ? (
|
|
<>
|
|
<PageFooterDesktop
|
|
firstRow={<ThreadFooterFirstRow postCid={post.cid} threadNumber={post?.number} subplebbitAddress={subplebbitAddress} isThreadClosed={!!post?.locked} />}
|
|
styleRow={<ThreadFooterStyleRow />}
|
|
/>
|
|
<ThreadFooterMobile postCid={post.cid} threadNumber={post?.number} subplebbitAddress={subplebbitAddress} isThreadClosed={!!post?.locked} />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PostPage;
|