mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user