fix(mod-queue): rework excerpt hover preview, scope pending-age alerts (#1152)

Render the compact mod queue excerpt hover preview like the quote-link previews (clean read-only Post via useFloating, anchored to the right of the excerpt on desktop / below on mobile), cap its content at 350 chars, and scope the red pending-age timestamp to posts actually awaiting approval. Adds keyboard handlers to the span fallback.
This commit is contained in:
Tommaso Casaburi
2026-06-05 17:29:04 +07:00
committed by GitHub
parent ae16ae3dd3
commit 12977fa24a
5 changed files with 242 additions and 28 deletions
@@ -25,8 +25,10 @@ type TestComment = {
linkWidth?: number;
number?: number;
parentCid?: string;
pendingApproval?: boolean;
pinned?: boolean;
postCid?: string;
approved?: boolean;
removed?: boolean;
replyCount?: number;
replies?: {
@@ -167,6 +169,8 @@ vi.mock('../../lib/utils/time-utils', () => ({
vi.mock('../../lib/utils/pending-approval-moderation', () => ({
approvePendingCommentModeration: {},
isPendingApprovalAwaiting: (comment?: TestComment) =>
comment?.pendingApproval === true && comment?.approved !== true && comment?.approved !== false && !comment?.removed,
isPendingApprovalRejected: () => false,
rejectPendingCommentModeration: {},
}));
@@ -178,7 +182,7 @@ vi.mock('../../lib/utils/url-utils', () => ({
vi.mock('../../lib/utils/view-utils', () => ({
isAllView: (pathname: string) => pathname === '/all',
isModQueueView: () => false,
isModQueueView: (pathname: string) => pathname === '/mod/queue' || pathname.endsWith('/mod/queue'),
isModView: () => false,
isPendingPostView: () => false,
isPostPageView: (pathname: string) => pathname.includes('/thread/'),
@@ -535,4 +539,34 @@ describe('post community address compatibility', () => {
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThreadWithoutReplies() }));
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
});
it('does not show a mod queue age alert for published preview posts on the mod queue route', async () => {
const publishedPost = {
...makeLegacyThread(),
pendingApproval: false,
timestamp: 1_700_000_000,
};
await renderWithRoute(createElement(PostDesktop, { post: publishedPost, showReplies: false }), '/mod/queue');
expect(container.textContent).toContain('2026-03-13');
expect(container.textContent).not.toContain('moments ago');
await renderWithRoute(createElement(PostMobile, { post: publishedPost, showReplies: false }), '/mod/queue');
expect(container.textContent).toContain('2026-03-13');
expect(container.textContent).not.toContain('moments ago');
});
it('keeps the mod queue age alert for pending preview posts on the mod queue route', async () => {
const pendingPost = {
...makeLegacyThread(),
pendingApproval: true,
timestamp: 1_700_000_000,
};
await renderWithRoute(createElement(PostDesktop, { post: pendingPost, showReplies: false }), '/mod/queue');
expect(container.textContent).toContain('moments ago');
await renderWithRoute(createElement(PostMobile, { post: pendingPost, showReplies: false }), '/mod/queue');
expect(container.textContent).toContain('moments ago');
});
});
+2 -5
View File
@@ -8,7 +8,7 @@ import styles from '../../views/post/post.module.css';
import { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils';
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { approvePendingCommentModeration, isPendingApprovalRejected, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { isValidURL, parseHttpUrl } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
@@ -263,10 +263,7 @@ const PostInfo = ({
const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress;
// Check if post is awaiting approval and over threshold (for mod queue view)
const approved = post?.approved;
const alreadyApproved = approved === true;
const alreadyRejected = isPendingApprovalRejected(post);
const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected;
const isAwaitingApproval = isInModQueueView && isPendingApprovalAwaiting(post);
const timeWaiting = timestamp ? currentTime - timestamp : 0;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
+2 -5
View File
@@ -9,7 +9,7 @@ import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { approvePendingCommentModeration, isPendingApprovalRejected, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -210,10 +210,7 @@ const PostInfoAndMedia = ({
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
// Check if post is awaiting approval and over threshold (for mod queue view)
const approved = resolvedPost?.approved;
const alreadyApproved = approved === true;
const alreadyRejected = isPendingApprovalRejected(post);
const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected;
const isAwaitingApproval = isInModQueueView && isPendingApprovalAwaiting(resolvedPost);
const timeWaiting = timestamp ? currentTime - timestamp : 0;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
@@ -10,8 +10,11 @@ const act = (React as { act?: (callback: () => void | Promise<void>) => void | P
type TestComment = {
cid: string;
content?: string;
communityAddress?: string;
number?: number;
pendingApproval?: boolean;
timestamp?: number;
};
const testState = vi.hoisted(() => ({
@@ -96,6 +99,22 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () =>
selector({ accountsEditsSummaries: { account: {} }, activeAccountId: 'account' }),
}));
vi.mock('@floating-ui/react', () => ({
autoUpdate: vi.fn(),
flip: vi.fn(),
offset: vi.fn(),
shift: vi.fn(),
size: vi.fn(),
useFloating: () => ({
floatingStyles: { position: 'fixed' },
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
update: vi.fn(),
}),
}));
vi.mock('react-virtuoso', () => ({
Virtuoso: ({
components,
@@ -172,7 +191,18 @@ vi.mock('../../../components/tooltip', () => ({
}));
vi.mock('../../post/post', () => ({
Post: () => createElement('div', { 'data-testid': 'mod-queue-feed-post' }),
Post: ({ isModQueue, post, showReplies }: { isModQueue?: boolean; post?: TestComment; showReplies?: boolean }) =>
createElement(
'div',
{
'data-cid': post?.cid,
'data-content': post?.content,
'data-is-mod-queue': String(Boolean(isModQueue)),
'data-show-replies': String(Boolean(showReplies)),
'data-testid': 'mod-queue-feed-post',
},
post?.cid ?? 'missing',
),
}));
let container: HTMLDivElement;
@@ -234,4 +264,62 @@ describe('ModQueueView', () => {
expect(text).toContain('queue_is_empty');
expect(text.indexOf('No.')).toBeLessThan(text.indexOf('queue_is_empty'));
});
it('opens a full floating post preview from a compact excerpt hover', async () => {
testState.feed = [
{
cid: 'pending-reply',
communityAddress: 'music-posting.eth',
content: 'pending reply body',
number: 7,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const excerptLink = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find((link) => link.textContent === 'pending reply body');
expect(excerptLink).toBeTruthy();
expect(document.body.querySelector('[data-mod-queue-excerpt-preview="true"]')).toBeNull();
await act(async () => {
excerptLink?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const previewPost = document.body.querySelector('[data-mod-queue-excerpt-preview="true"] [data-testid="mod-queue-feed-post"]');
expect(previewPost?.getAttribute('data-cid')).toBe('pending-reply');
// Rendered like a quote-link hover preview: a clean read-only Post, not the
// mod-queue feed layout (no leading <hr>, no inline approve/reject buttons).
expect(previewPost?.getAttribute('data-is-mod-queue')).toBe('false');
expect(previewPost?.getAttribute('data-show-replies')).toBe('false');
});
it('caps long content in the floating preview so the hover card stays compact', async () => {
testState.feed = [
{
cid: 'long-post',
communityAddress: 'music-posting.eth',
content: 'x'.repeat(500),
number: 9,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const excerptLink = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find((link) => link.textContent?.startsWith('xxx'));
expect(excerptLink).toBeTruthy();
await act(async () => {
excerptLink?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const previewPost = document.body.querySelector('[data-mod-queue-excerpt-preview="true"] [data-testid="mod-queue-feed-post"]');
const previewContent = previewPost?.getAttribute('data-content') ?? '';
// 350-char cap + a single ellipsis character (shorter than the feed's 1000).
expect(previewContent.length).toBe(351);
expect(previewContent.endsWith('…')).toBe(true);
});
});
+114 -16
View File
@@ -1,10 +1,13 @@
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
import { Virtuoso } from 'react-virtuoso';
import styles from './mod-queue.module.css';
import postStyles from '../post/post.module.css';
import useModQueueStore from '../../stores/use-mod-queue-store';
import LoadingEllipsis from '../../components/loading-ellipsis';
import ErrorDisplay from '../../components/error-display/error-display';
@@ -169,6 +172,114 @@ interface ModQueueActionsProps {
variant: 'row' | 'card';
}
interface ModQueueExcerptPreviewLinkProps {
comment: Comment;
excerpt: string;
postUrl: string | undefined;
postUrlState: ReturnType<typeof getQueuedCommentRouteState>;
}
const getExcerptPreviewClassName = (comment: Comment) =>
!comment.parentCid ? `${postStyles.replyQuotePreview} ${postStyles.replyQuotePreviewOp}` : postStyles.replyQuotePreview;
// The hover card is a compact preview, so cap its body shorter than the feed's
// 1000-char CommentContent limit and mark the cut with an ellipsis.
const PREVIEW_CONTENT_MAX_LENGTH = 350;
const truncatePreviewComment = (comment: Comment): Comment => {
const { content } = comment;
if (typeof content !== 'string' || content.length <= PREVIEW_CONTENT_MAX_LENGTH) {
return comment;
}
return { ...comment, content: `${content.slice(0, PREVIEW_CONTENT_MAX_LENGTH).trimEnd()}` };
};
// Floating preview of the full pending post, anchored to the excerpt text.
// Mirrors the quote-link hover previews (reply-quote-preview): a clean read-only
// Post positioned with useFloating, not the feed/mod-queue layout. Rendering it
// without isModQueue keeps it consistent with quote previews (no leading <hr>,
// no thread container, no inline approve/reject buttons that hover can't reach).
//
// Placement mirrors the quote previews: to the right of the excerpt on desktop,
// below it on mobile. See setReferenceNode for why desktop anchors to the cell.
const ModQueueExcerptPreviewLink = ({ comment, excerpt, postUrl, postUrlState }: ModQueueExcerptPreviewLinkProps) => {
const isMobile = useIsMobile();
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const previewComment = truncatePreviewComment(comment);
const { refs, floatingStyles } = useFloating({
placement: isMobile ? 'bottom-start' : 'right-start',
middleware: [
offset(isMobile ? 4 : 8),
flip({ fallbackPlacements: isMobile ? ['top-start'] : ['left-start'] }),
shift({ padding: 10 }),
size({
padding: 10,
apply({ availableWidth, elements }) {
elements.floating.style.maxWidth = `${Math.max(0, availableWidth - 12)}px`;
},
}),
],
whileElementsMounted: autoUpdate,
});
// Stable callback refs from useFloating (not React refs read during render).
const { setReference, setFloating } = refs;
// Desktop anchors the card to the excerpt cell, not the inline <a>. The excerpt
// is a wide single-line link whose box overflows the clipped flex cell, so its
// right edge sits far past the visible text; anchoring there shoved the card off
// to the right. The parent cell's right edge tracks where the ellipsized excerpt
// visibly ends. Mobile keeps the <a> itself and opens below it.
const setReferenceNode = (node: HTMLElement | null) => {
setReference(!node || isMobile ? node : (node.parentElement ?? node));
};
const openPreview = () => setIsPreviewOpen(true);
const closePreview = () => setIsPreviewOpen(false);
const preview =
isPreviewOpen && typeof document !== 'undefined'
? createPortal(
<div className={getExcerptPreviewClassName(comment)} data-mod-queue-excerpt-preview='true' ref={setFloating} style={floatingStyles}>
<Post post={previewComment} showReplies={false} />
</div>,
document.body,
)
: null;
return (
<>
{postUrl ? (
<Link
to={postUrl}
state={postUrlState}
title={excerpt}
ref={setReferenceNode}
onMouseEnter={openPreview}
onFocus={openPreview}
onMouseLeave={closePreview}
onBlur={closePreview}
>
{excerpt}
</Link>
) : (
<span
title={excerpt}
ref={setReferenceNode}
tabIndex={0}
onMouseEnter={openPreview}
onFocus={openPreview}
onMouseLeave={closePreview}
onBlur={closePreview}
>
{excerpt}
</span>
)}
{preview}
</>
);
};
const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove, variant }: ModQueueActionsProps) => {
const { t } = useTranslation();
const displayError = error || errorMessage;
@@ -419,13 +530,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</span>}</div>
)}
<div className={styles.excerpt}>
{postUrl ? (
<Link to={postUrl} state={postUrlState} title={excerpt}>
{excerpt}
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}
<ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} />
</div>
<div className={styles.time}>
{isMobile ? (
@@ -539,15 +644,8 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
</span>
</div>
<div className={styles.cardContent}>
{t('excerpt')}:{' '}
{postUrl ? (
<Link to={postUrl} state={postUrlState} title={excerpt}>
{excerpt}
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}{' '}
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
{t('excerpt')}: <ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} /> / {t('type')}:{' '}
{isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
</div>
<ModQueueActions
status={status}