diff --git a/src/components/__tests__/post-community-address-compat.test.tsx b/src/components/__tests__/post-community-address-compat.test.tsx index 377238e1..6f3cefcf 100644 --- a/src/components/__tests__/post-community-address-compat.test.tsx +++ b/src/components/__tests__/post-community-address-compat.test.tsx @@ -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'); + }); }); diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index ea6b6e03..c7de1164 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -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; diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 48b013cd..0e9a2890 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -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; diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx index 2c608e42..b4254c1c 100644 --- a/src/views/mod-queue/__tests__/mod-queue.test.tsx +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -10,8 +10,11 @@ const act = (React as { act?: (callback: () => void | Promise) => 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('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
, 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('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); + }); }); diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 559fdecf..de7cf149 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -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; +} + +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
, +// 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 . 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 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( +
+ +
, + document.body, + ) + : null; + + return ( + <> + {postUrl ? ( + + {excerpt} + + ) : ( + + {excerpt} + + )} + {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
{modQueueUrl ? /{boardDisplayPath ?? '—'}/ : /{boardDisplayPath ?? '—'}/}
)}
- {postUrl ? ( - - {excerpt} - - ) : ( - {excerpt} - )} +
{isMobile ? ( @@ -539,15 +644,8 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
- {t('excerpt')}:{' '} - {postUrl ? ( - - {excerpt} - - ) : ( - {excerpt} - )}{' '} - / {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))} + {t('excerpt')}: / {t('type')}:{' '} + {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}