mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Accessibility/semantics pass aligned with React Doctor: role-based spans/divs become real buttons, role=status becomes <output>, modals use native <dialog>, author/peer flags render as <img>, and embed/challenge iframes are sandboxed. Includes review follow-ups: correct button chrome/centering/themed dialog colors and focus rings, the missing aria-label i18n keys (all languages), the Yandex image-search URL, clearer .bso setup steps, a keyboard-accessible image-search dropdown, and removal of the post-edit ('comment edited') display (mod edits unchanged).
483 lines
20 KiB
TypeScript
483 lines
20 KiB
TypeScript
import { memo, useEffect, useMemo, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { type Comment, type CommunityIdentifier, type Role, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks';
|
|
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
|
import { useCommunityField } from '../../hooks/use-stable-community';
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
import { isAllView } from '../../lib/utils/view-utils';
|
|
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
|
import { useDirectories } from '../../hooks/use-directories';
|
|
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
|
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
|
|
import { getCommentCommunityAddress } from '../../lib/utils/comment-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 { REPLIES_PER_PAGE } from '../../lib/constants';
|
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
|
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
|
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
|
import styles from './post.module.css';
|
|
|
|
export type CommentWithRefresh = Comment & {
|
|
approved?: boolean;
|
|
communityAddress?: string;
|
|
refresh?: () => Promise<void>;
|
|
state?: string;
|
|
pendingApproval?: boolean;
|
|
error?: Error;
|
|
errors?: Error[];
|
|
index?: number;
|
|
removed?: boolean;
|
|
};
|
|
|
|
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
|
if (!state || typeof state !== 'object') return undefined;
|
|
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
|
|
return state as QueuedCommentRouteState;
|
|
}
|
|
|
|
const wrappedState = (state as { usr?: unknown }).usr;
|
|
if (!wrappedState || typeof wrappedState !== 'object') return undefined;
|
|
if (!('queuedComment' in wrappedState) && !('scrollThreadContainerCid' in wrappedState)) return undefined;
|
|
return wrappedState as QueuedCommentRouteState;
|
|
};
|
|
|
|
const getEffectiveRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
|
const routeState = getRouteUserState(state);
|
|
if (routeState) return routeState;
|
|
if (typeof window === 'undefined') return undefined;
|
|
return getRouteUserState(window.history.state);
|
|
};
|
|
|
|
interface ReplyPaginationOverride {
|
|
hasMore?: boolean;
|
|
loadMore?: () => void;
|
|
replies: Comment[];
|
|
reset?: () => Promise<void>;
|
|
}
|
|
|
|
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
|
// This hook falls back to the communities 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; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => {
|
|
const comment = useComment(options);
|
|
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
|
|
|
|
return useMemo(() => {
|
|
if (!cachedComment || comment?.timestamp) return comment;
|
|
return {
|
|
...cachedComment,
|
|
refresh: comment?.refresh,
|
|
state: comment?.state,
|
|
error: comment?.error,
|
|
errors: comment?.errors,
|
|
} as CommentWithRefresh;
|
|
}, [comment, cachedComment]);
|
|
};
|
|
|
|
const getQueuedCommentFromRouteState = (state: unknown, commentCid: string | undefined): CommentWithRefresh | undefined => {
|
|
if (!commentCid) return undefined;
|
|
|
|
const queuedComment = getRouteUserState(state)?.queuedComment;
|
|
if (!queuedComment || typeof queuedComment !== 'object') return undefined;
|
|
return queuedComment.cid === commentCid ? queuedComment : undefined;
|
|
};
|
|
|
|
const mergeCommentFallback = (comment: CommentWithRefresh | undefined, fallback: CommentWithRefresh | undefined): CommentWithRefresh | undefined => {
|
|
if (!fallback) return comment;
|
|
if (!comment) return fallback;
|
|
if (comment.cid && fallback.cid && comment.cid !== fallback.cid) return comment;
|
|
|
|
const hasRenderableData =
|
|
comment.timestamp !== undefined ||
|
|
comment.number !== undefined ||
|
|
comment.replyCount !== undefined ||
|
|
!!comment.content ||
|
|
!!comment.title ||
|
|
!!comment.link ||
|
|
!!comment.thumbnailUrl ||
|
|
!!comment.error ||
|
|
!!comment.deleted ||
|
|
!!comment.removed;
|
|
|
|
if (hasRenderableData) return comment;
|
|
|
|
return {
|
|
...fallback,
|
|
error: comment.error,
|
|
errors: comment.errors,
|
|
refresh: comment.refresh,
|
|
state: comment.state,
|
|
};
|
|
};
|
|
|
|
const mergeRepliesWithQueuedReply = (replies: Comment[], queuedReply: CommentWithRefresh | undefined): Comment[] => {
|
|
if (!queuedReply?.cid) {
|
|
return replies;
|
|
}
|
|
|
|
const queuedReplyIndex = replies.findIndex((reply) => reply?.cid === queuedReply.cid);
|
|
if (queuedReplyIndex === -1) {
|
|
return [...replies, queuedReply];
|
|
}
|
|
|
|
const nextReplies = [...replies];
|
|
nextReplies[queuedReplyIndex] = {
|
|
...nextReplies[queuedReplyIndex],
|
|
...queuedReply,
|
|
};
|
|
return nextReplies;
|
|
};
|
|
|
|
export interface PostProps {
|
|
feedVirtualizationModeOverride?: ReplyVirtualizationMode;
|
|
index?: number;
|
|
isHidden?: boolean;
|
|
hasThumbnail?: boolean;
|
|
post?: CommentWithRefresh;
|
|
postReplyCount?: number;
|
|
reply?: Comment;
|
|
replyPaginationOverride?: ReplyPaginationOverride;
|
|
replyVirtualizationModeOverride?: ReplyVirtualizationMode;
|
|
roles?: Role[];
|
|
showAllReplies?: boolean;
|
|
showReplies?: boolean;
|
|
targetReplyCid?: string;
|
|
threadNumber?: number;
|
|
isModQueue?: boolean;
|
|
modQueueStatus?: 'approved' | 'rejected' | 'failed' | null;
|
|
modQueueError?: unknown;
|
|
isPublishing?: boolean;
|
|
onApprove?: () => void;
|
|
onReject?: () => void;
|
|
onRemoveFromModQueue?: () => void;
|
|
quotedByMap?: Map<string, Comment[]>;
|
|
}
|
|
|
|
export const Post = memo(
|
|
({
|
|
post,
|
|
showAllReplies = false,
|
|
showReplies = true,
|
|
targetReplyCid,
|
|
isModQueue,
|
|
modQueueStatus,
|
|
modQueueError,
|
|
isPublishing,
|
|
onApprove,
|
|
onReject,
|
|
onRemoveFromModQueue,
|
|
feedVirtualizationModeOverride,
|
|
replyPaginationOverride,
|
|
replyVirtualizationModeOverride,
|
|
}: PostProps) => {
|
|
// Only subscribe to roles field to avoid rerenders from updatingState changes
|
|
const communityAddress = getCommentCommunityAddress(post);
|
|
const roles = useCommunityField(communityAddress, (community) => community?.roles);
|
|
const isMobile = useIsMobile();
|
|
|
|
let comment = post;
|
|
|
|
// handle pending mod or author edit
|
|
const { editedComment } = useEditedComment({ comment });
|
|
comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined);
|
|
|
|
return (
|
|
<div className={styles.thread}>
|
|
<div className={styles.postContainer}>
|
|
{isMobile ? (
|
|
<PostMobile
|
|
feedVirtualizationModeOverride={feedVirtualizationModeOverride}
|
|
post={comment}
|
|
replyPaginationOverride={replyPaginationOverride}
|
|
replyVirtualizationModeOverride={replyVirtualizationModeOverride}
|
|
roles={roles}
|
|
showAllReplies={showAllReplies}
|
|
showReplies={showReplies}
|
|
targetReplyCid={targetReplyCid}
|
|
isModQueue={isModQueue}
|
|
modQueueStatus={modQueueStatus}
|
|
modQueueError={modQueueError}
|
|
isPublishing={isPublishing}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
onRemoveFromModQueue={onRemoveFromModQueue}
|
|
/>
|
|
) : (
|
|
<PostDesktop
|
|
feedVirtualizationModeOverride={feedVirtualizationModeOverride}
|
|
post={comment}
|
|
replyPaginationOverride={replyPaginationOverride}
|
|
replyVirtualizationModeOverride={replyVirtualizationModeOverride}
|
|
roles={roles}
|
|
showAllReplies={showAllReplies}
|
|
showReplies={showReplies}
|
|
targetReplyCid={targetReplyCid}
|
|
isModQueue={isModQueue}
|
|
modQueueStatus={modQueueStatus}
|
|
modQueueError={modQueueError}
|
|
isPublishing={isPublishing}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
onRemoveFromModQueue={onRemoveFromModQueue}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
(prevProps, nextProps) => {
|
|
const prev = prevProps.post;
|
|
const next = nextProps.post;
|
|
return (
|
|
prev?.cid === next?.cid &&
|
|
prev?.number === next?.number &&
|
|
prev?.postNumber === next?.postNumber &&
|
|
prev?.replyCount === next?.replyCount &&
|
|
prev?.updatedAt === next?.updatedAt &&
|
|
prev?.state === next?.state &&
|
|
prev?.publishingState === next?.publishingState &&
|
|
prev?.error === next?.error &&
|
|
prev?.errors === next?.errors &&
|
|
prev?.approved === next?.approved &&
|
|
prev?.locked === next?.locked &&
|
|
prev?.pinned === next?.pinned &&
|
|
prev?.pendingApproval === next?.pendingApproval &&
|
|
isCommentArchived(prev) === isCommentArchived(next) &&
|
|
prev?.removed === next?.removed &&
|
|
prev?.deleted === next?.deleted &&
|
|
prev?.reason === next?.reason &&
|
|
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
|
|
prevProps.showAllReplies === nextProps.showAllReplies &&
|
|
prevProps.showReplies === nextProps.showReplies &&
|
|
prevProps.targetReplyCid === nextProps.targetReplyCid &&
|
|
prevProps.feedVirtualizationModeOverride === nextProps.feedVirtualizationModeOverride &&
|
|
prevProps.replyPaginationOverride === nextProps.replyPaginationOverride &&
|
|
prevProps.replyVirtualizationModeOverride === nextProps.replyVirtualizationModeOverride &&
|
|
prevProps.isModQueue === nextProps.isModQueue &&
|
|
prevProps.modQueueStatus === nextProps.modQueueStatus &&
|
|
prevProps.modQueueError === nextProps.modQueueError &&
|
|
prevProps.isPublishing === nextProps.isPublishing &&
|
|
prevProps.onApprove === nextProps.onApprove &&
|
|
prevProps.onReject === nextProps.onReject &&
|
|
prevProps.onRemoveFromModQueue === nextProps.onRemoveFromModQueue
|
|
);
|
|
},
|
|
);
|
|
|
|
const PostPage = () => {
|
|
const { t } = useTranslation();
|
|
const params = useParams();
|
|
const { key: locationKey, pathname, state: locationState } = useLocation();
|
|
const { commentCid } = params;
|
|
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
|
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
|
|
const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate);
|
|
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
|
|
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
|
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
|
const resolvedCommunityIdentifier = useCommunityIdentifier(resolvedCommunityAddress);
|
|
const isInAllView = isAllView(pathname);
|
|
const routeState = useMemo(() => {
|
|
// locationKey/pathname are intentional deps: getEffectiveRouteUserState falls back to the
|
|
// non-reactive window.history.state, so the memo must re-run on every navigation to re-read it.
|
|
void locationKey;
|
|
void pathname;
|
|
return getEffectiveRouteUserState(locationState);
|
|
}, [locationKey, pathname, locationState]);
|
|
|
|
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier });
|
|
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
|
|
const comment = useMemo(() => mergeCommentFallback(resolvedComment, queuedComment), [resolvedComment, queuedComment]);
|
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
|
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
|
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
|
const previousThreadCidRef = useRef<string>();
|
|
const lastProcessedUpdateRequestIdRef = useRef(0);
|
|
|
|
const navigate = useNavigate();
|
|
useEffect(() => {
|
|
if (commentCommunityAddress && resolvedCommunityAddress && !areSameBoardAddress(commentCommunityAddress, resolvedCommunityAddress)) {
|
|
navigate('/not-found', { replace: true });
|
|
}
|
|
}, [commentCommunityAddress, resolvedCommunityAddress, navigate]);
|
|
|
|
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
|
const { error: communityError, shortAddress, title } = community || {};
|
|
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, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
|
|
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
|
|
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
|
|
|
|
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 `locationKey` 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 = `${locationKey}:${commentCid}`;
|
|
if (consumedThreadTopScrollRef.current === consumedKey) return;
|
|
|
|
if (scrollThreadContainerToTop(commentCid)) {
|
|
consumedThreadTopScrollRef.current = consumedKey;
|
|
}
|
|
}, [commentCid, locationKey, 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 || communityAddress || '';
|
|
}
|
|
|
|
const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30);
|
|
const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : '';
|
|
document.title = `${boardTitle}${postTitlePart} - 5chan`;
|
|
}, [title, shortAddress, communityAddress, 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 shouldShowCommunityError = communityError?.message && !post?.cid;
|
|
|
|
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
|
const queuedReply = comment?.parentCid && post?.cid && comment.cid !== post.cid ? comment : undefined;
|
|
const queuedReplyRepliesResult = useReplies({
|
|
comment: queuedReply && post?.cid ? post : undefined,
|
|
sortType: 'old',
|
|
flat: true,
|
|
repliesPerPage: REPLIES_PER_PAGE,
|
|
accountComments: { newerThan: Infinity, append: true },
|
|
});
|
|
const queuedReplyHasMore = queuedReplyRepliesResult.hasMore;
|
|
const queuedReplyLoadMore = queuedReplyRepliesResult.loadMore;
|
|
const queuedReplyReset = (queuedReplyRepliesResult as { reset?: () => Promise<void> }).reset;
|
|
const replyPaginationOverride = useMemo(() => {
|
|
if (!queuedReply || !post?.cid) return undefined;
|
|
const queuedReplyReplies = (queuedReplyRepliesResult.updatedReplies?.length ? queuedReplyRepliesResult.updatedReplies : queuedReplyRepliesResult.replies) || [];
|
|
return {
|
|
hasMore: queuedReplyHasMore,
|
|
loadMore: queuedReplyLoadMore,
|
|
replies: mergeRepliesWithQueuedReply(queuedReplyReplies, queuedReply),
|
|
reset: queuedReplyReset,
|
|
};
|
|
}, [post?.cid, queuedReply, queuedReplyHasMore, queuedReplyLoadMore, queuedReplyRepliesResult.replies, queuedReplyRepliesResult.updatedReplies, queuedReplyReset]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
resetThreadLiveUpdates();
|
|
};
|
|
}, [resetThreadLiveUpdates]);
|
|
|
|
useEffect(() => {
|
|
if (!post?.cid) return;
|
|
if (previousThreadCidRef.current && previousThreadCidRef.current !== post.cid) {
|
|
lastProcessedUpdateRequestIdRef.current = 0;
|
|
consumedThreadTopScrollRef.current = null;
|
|
resetThreadLiveUpdates();
|
|
}
|
|
previousThreadCidRef.current = post.cid;
|
|
}, [post?.cid, resetThreadLiveUpdates]);
|
|
|
|
useEffect(() => {
|
|
if (!post?.cid || updateRequestId <= lastProcessedUpdateRequestIdRef.current) return;
|
|
|
|
const refreshByCid = new Map<string, () => Promise<void>>();
|
|
if (comment?.cid && typeof comment.refresh === 'function') {
|
|
refreshByCid.set(comment.cid, comment.refresh);
|
|
}
|
|
if (post?.cid && typeof post.refresh === 'function') {
|
|
refreshByCid.set(post.cid, post.refresh);
|
|
}
|
|
if (refreshByCid.size === 0) return;
|
|
|
|
lastProcessedUpdateRequestIdRef.current = updateRequestId;
|
|
let cancelled = false;
|
|
startUpdate();
|
|
|
|
void Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh())).then((results) => {
|
|
if (cancelled) return;
|
|
|
|
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
|
|
finishUpdate(updateRequestId, hasSuccessfulRefresh);
|
|
|
|
const rejectedResult = results.find((result) => result.status === 'rejected');
|
|
if (rejectedResult?.status === 'rejected') {
|
|
console.error('Failed to refresh thread comments:', rejectedResult.reason);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [comment?.cid, comment?.refresh, finishUpdate, post?.cid, post?.refresh, startUpdate, updateRequestId]);
|
|
|
|
return (
|
|
<div className={styles.content}>
|
|
{shouldShowPostError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={error} />
|
|
</div>
|
|
)}
|
|
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} replyPaginationOverride={replyPaginationOverride} />
|
|
{shouldShowCommunityError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={communityError} />
|
|
</div>
|
|
)}
|
|
{shouldShowCommentError && (
|
|
<div className={styles.error}>
|
|
<ErrorDisplay error={comment?.error} />
|
|
</div>
|
|
)}
|
|
{post?.cid && communityAddress ? (
|
|
<>
|
|
<PageFooterDesktop
|
|
firstRow={
|
|
<ThreadFooterFirstRow
|
|
postCid={post.cid}
|
|
threadNumber={post?.number}
|
|
communityAddress={communityAddress}
|
|
isThreadClosed={!!(post?.locked || isCommentArchived(post))}
|
|
/>
|
|
}
|
|
styleRow={<ThreadFooterStyleRow />}
|
|
/>
|
|
<ThreadFooterMobile
|
|
postCid={post.cid}
|
|
threadNumber={post?.number}
|
|
communityAddress={communityAddress}
|
|
isThreadClosed={!!(post?.locked || isCommentArchived(post))}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PostPage;
|