diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx new file mode 100644 index 0000000000..84b0dd575b --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { HighlightPostSidebarWidget } from './HighlightPostSidebarWidget'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { useLogContext } from '../../../contexts/LogContext'; +import { gqlClient } from '../../../graphql/common'; + +jest.mock('../../../lib/constants', () => ({ + webappUrl: '/', + isDevelopment: false, +})); + +jest.mock('../../../contexts/AuthContext'); +jest.mock('../../../hooks/useConditionalFeature'); +jest.mock('../../../contexts/LogContext'); +jest.mock('../../../graphql/common', () => ({ + gqlClient: { + request: jest.fn(), + }, +})); + +const mockUseAuthContext = jest.mocked(useAuthContext); +const mockUseConditionalFeature = jest.mocked(useConditionalFeature); +const mockUseLogContext = jest.mocked(useLogContext); +const mockGqlRequest = jest.mocked(gqlClient.request); + +const buildHighlight = (id: string, headline: string) => ({ + id, + channel: 'agents', + headline, + highlightedAt: '2026-04-05T09:00:00.000Z', + post: { + id: `post-${id}`, + commentsPermalink: `/posts/post-${id}`, + }, +}); + +const buildResponse = (highlights: ReturnType[]) => ({ + majorHeadlines: { + edges: highlights.map((node) => ({ node, cursor: node.id })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, +}); + +const renderWidget = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + , + ); +}; + +describe('HighlightPostSidebarWidget', () => { + const logEvent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockGqlRequest.mockReset(); + mockUseAuthContext.mockReturnValue({ + user: { id: 'u1' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + mockUseLogContext.mockReturnValue({ + logEvent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders title and the first headline when query resolves', async () => { + mockGqlRequest.mockResolvedValue( + buildResponse([ + buildHighlight('h1', 'The first highlight'), + buildHighlight('h2', 'The second highlight'), + ]), + ); + + renderWidget(); + + expect(await screen.findByText('Happening Now')).toBeInTheDocument(); + expect(screen.getByText('The first highlight')).toBeInTheDocument(); + expect(screen.queryByText('The second highlight')).not.toBeInTheDocument(); + }); + + it('returns null when no highlights are returned', async () => { + mockGqlRequest.mockResolvedValue(buildResponse([])); + + renderWidget(); + + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('postPageHighlightWidget'), + ).not.toBeInTheDocument(); + }); + + it('returns null when feature flag is off', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + mockGqlRequest.mockResolvedValue( + buildResponse([buildHighlight('h1', 'Hidden headline')]), + ); + + renderWidget(); + + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('postPageHighlightWidget'), + ).not.toBeInTheDocument(); + }); + + it('points "Read all" to /highlights with the first highlight id', async () => { + mockGqlRequest.mockResolvedValue( + buildResponse([buildHighlight('first', 'First headline')]), + ); + + renderWidget(); + + const readAll = await screen.findByLabelText('Read all highlights'); + expect(readAll).toHaveAttribute('href', '/highlights?highlight=first'); + }); + + it('rotates headlines after the interval', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + mockGqlRequest.mockResolvedValue( + buildResponse([ + buildHighlight('h1', 'The first highlight'), + buildHighlight('h2', 'The second highlight'), + ]), + ); + + renderWidget(); + + await act(async () => { + await Promise.resolve(); + }); + + await waitFor(() => + expect(screen.getByText('The first highlight')).toBeInTheDocument(), + ); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('The second highlight')).toBeInTheDocument(); + expect(screen.queryByText('The first highlight')).not.toBeInTheDocument(); + }); + + it('pauses rotation while hovering and resumes after unhover', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + mockGqlRequest.mockResolvedValue( + buildResponse([ + buildHighlight('h1', 'The first highlight'), + buildHighlight('h2', 'The second highlight'), + ]), + ); + + renderWidget(); + + await act(async () => { + await Promise.resolve(); + }); + await waitFor(() => + expect(screen.getByText('The first highlight')).toBeInTheDocument(), + ); + + const widget = screen.getByTestId('postPageHighlightWidget'); + + await act(async () => { + fireEvent.mouseEnter(widget); + }); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('The first highlight')).toBeInTheDocument(); + expect(screen.queryByText('The second highlight')).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseLeave(widget); + }); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('The second highlight')).toBeInTheDocument(); + expect(screen.queryByText('The first highlight')).not.toBeInTheDocument(); + }); + + it('logs an impression when highlights load', async () => { + mockGqlRequest.mockResolvedValue( + buildResponse([buildHighlight('h1', 'A headline')]), + ); + + renderWidget(); + + await screen.findByText('Happening Now'); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ event_name: 'impression' }), + ), + ); + }); +}); diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx new file mode 100644 index 0000000000..dca3ee3cdc --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -0,0 +1,199 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { WidgetContainer } from '../../widgets/common'; +import { getHighlightsUrl, highlightsTitleGradientClassName } from './common'; +import { + majorHeadlinesQueryOptions, + type PostHighlight, +} from '../../../graphql/highlights'; +import { RelativeTime } from '../../utilities/RelativeTime'; +import Link from '../../utilities/Link'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featurePostPageHighlights } from '../../../lib/featureManagement'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, Origin } from '../../../lib/log'; +import { feedHighlightsLogEvent } from '../../../lib/feed'; +import useLogEventOnce from '../../../hooks/log/useLogEventOnce'; + +const HIGHLIGHTS_LIMIT = 10; +const ROTATION_INTERVAL_MS = 6000; +const FADE_DURATION_MS = 500; +const FEED_NAME = 'post-page-highlights'; + +const prefersReducedMotion = (): boolean => { + if (typeof window === 'undefined' || !window.matchMedia) { + return false; + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +}; + +export const HighlightPostSidebarWidget = (): ReactElement | null => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { value: isEnabled } = useConditionalFeature({ + feature: featurePostPageHighlights, + shouldEvaluate: !!user, + }); + + const { data } = useQuery({ + ...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }), + enabled: isEnabled && !!user, + }); + + const highlights: PostHighlight[] = + data?.majorHeadlines?.edges?.map((edge) => edge.node) ?? []; + + const [index, setIndex] = useState(0); + const [isVisible, setIsVisible] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const fadeOutTimeoutRef = useRef | null>(null); + + const hasHighlights = highlights.length > 0; + const shouldRender = isEnabled && hasHighlights; + const canRotate = shouldRender && highlights.length > 1 && !isPaused; + + useLogEventOnce( + () => + feedHighlightsLogEvent(LogEvent.Impression, { + feedName: FEED_NAME, + action: 'impression', + count: highlights.length, + highlightIds: highlights.map((h) => h.id), + origin: Origin.ArticlePage, + }), + { condition: shouldRender }, + ); + + useEffect(() => { + if (index >= highlights.length && highlights.length > 0) { + setIndex(0); + } + }, [highlights.length, index]); + + useEffect(() => { + if (!canRotate) { + return undefined; + } + + const reducedMotion = prefersReducedMotion(); + const interval = setInterval(() => { + if (typeof document !== 'undefined' && document.hidden) { + return; + } + + if (reducedMotion) { + setIndex((prev) => (prev + 1) % highlights.length); + return; + } + + setIsVisible(false); + fadeOutTimeoutRef.current = setTimeout(() => { + setIndex((prev) => (prev + 1) % highlights.length); + setIsVisible(true); + }, FADE_DURATION_MS); + }, ROTATION_INTERVAL_MS); + + return () => { + clearInterval(interval); + if (fadeOutTimeoutRef.current) { + clearTimeout(fadeOutTimeoutRef.current); + fadeOutTimeoutRef.current = null; + } + }; + }, [canRotate, highlights.length]); + + const onPauseStart = useCallback(() => setIsPaused(true), []); + const onPauseEnd = useCallback(() => setIsPaused(false), []); + + if (!shouldRender) { + return null; + } + + const current = highlights[Math.min(index, highlights.length - 1)]; + const firstHighlight = highlights[0]; + + const onHighlightClick = () => { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + feedName: FEED_NAME, + action: 'highlight_click', + position: index + 1, + count: highlights.length, + clickedHighlight: current, + highlightIds: highlights.map((h) => h.id), + origin: Origin.ArticlePage, + }), + ); + }; + + const onReadAllClick = () => { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + feedName: FEED_NAME, + action: 'read_all_click', + count: highlights.length, + highlightIds: highlights.map((h) => h.id), + origin: Origin.ArticlePage, + }), + ); + }; + + const headlineHref = getHighlightsUrl(current.id); + + return ( + +

+ Happening Now +

+
+ + + + {current.headline} + + + + +
+ + + Read all + + → + + + +
+ ); +}; diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 5560f76429..ca3ec83c1e 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -12,16 +12,54 @@ export interface HighlightCardProps { onReadAllClick?: () => void; } -const titleGradientClassName = 'feed-highlights-title-gradient'; +export const highlightsTitleGradientClassName = + 'feed-highlights-title-gradient'; const HIGHLIGHTS_URL = `${webappUrl}highlights`; -const getHighlightsUrl = (highlightId?: string): string => +export const getHighlightsUrl = (highlightId?: string): string => highlightId ? `${HIGHLIGHTS_URL}?highlight=${highlightId}` : HIGHLIGHTS_URL; const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); +export const ReadAllHighlightsFooter = ({ + highlightId, + onClick, + className, +}: { + highlightId?: string; + onClick?: () => void; + className?: string; +}): ReactElement => { + const href = getHighlightsUrl(highlightId); + return ( +
+ + onClick?.()} + > + + Read all + + + → + + + +
+ ); +}; + const HighlightRow = ({ highlight, index, @@ -73,7 +111,7 @@ export const HighlightCardContent = ({

@@ -90,29 +128,11 @@ export const HighlightCardContent = ({ /> ))} -
- - onReadAllClick?.()} - > - - Read all - - - → - - - -
+ ); }; diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 428fc28336..27bf7aa79c 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -17,6 +17,7 @@ import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; import { PostSignupWidget } from './PostSignupWidget'; +import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; const UserEntityCard = dynamic( /* webpackChunkName: "userEntityCard" */ () => @@ -104,6 +105,7 @@ export function PostWidgets({ link={post.commentsPermalink} onCopyPostLink={onCopyPostLink} /> + {tokenRefreshed && } diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx index 483ecbcae0..d209c32c9b 100644 --- a/packages/shared/src/components/post/SquadPostWidgets.tsx +++ b/packages/shared/src/components/post/SquadPostWidgets.tsx @@ -17,6 +17,7 @@ import type { UserShortProfile } from '../../lib/user'; import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { PostSignupWidget } from './PostSignupWidget'; +import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; export function SquadPostWidgets({ onCopyPostLink, @@ -76,6 +77,7 @@ export function SquadPostWidgets({ /> )} + {tokenRefreshed && } diff --git a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx index 9b42cca302..fe2605c75b 100644 --- a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx +++ b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx @@ -11,6 +11,7 @@ import { FooterLinks } from '../../footer'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; import { FeaturedArchives } from '../../widgets/FeaturedArchives'; import { PostSignupWidget } from '../PostSignupWidget'; +import { HighlightPostSidebarWidget } from '../../cards/highlight/HighlightPostSidebarWidget'; export const CollectionPostWidgets = ({ onCopyPostLink, @@ -37,6 +38,7 @@ export const CollectionPostWidgets = ({ onCopyPostLink={onCopyPostLink} link={post.commentsPermalink} /> + diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 65d91a589d..5ef53dfa2a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -33,6 +33,10 @@ export const discussedFeedVersion = new Feature('discussed_feed_version', 2); export const latestFeedVersion = new Feature('latest_feed_version', 2); export const customFeedVersion = new Feature('custom_feed_version', 2); export const featureFeedV2Highlights = new Feature('feed_v2_highlights', false); +export const featurePostPageHighlights = new Feature( + 'post_page_highlights', + false, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/shared/src/lib/feed.ts b/packages/shared/src/lib/feed.ts index 322bbaac94..d24419e391 100644 --- a/packages/shared/src/lib/feed.ts +++ b/packages/shared/src/lib/feed.ts @@ -181,6 +181,7 @@ interface FeedHighlightsLogEventOptions extends FeedItemPosition { highlightIds?: string[]; feedMeta?: string | null; position?: number; + origin?: Origin; } export function feedHighlightsLogEvent( @@ -197,6 +198,7 @@ export function feedHighlightsLogEvent( highlightIds, feedMeta, position, + origin, }: FeedHighlightsLogEventOptions, ): FeedItemLogEvent { return { @@ -209,7 +211,7 @@ export function feedHighlightsLogEvent( feed_item_title: clickedHighlight?.headline, target_type: TargetType.HighlightsCard, extra: JSON.stringify({ - ...feedLogExtra(feedName, ranking).extra, + ...feedLogExtra(feedName, ranking, undefined, origin).extra, ...(action ? { action } : {}), ...(typeof count === 'number' ? { count } : {}), ...(typeof position === 'number' ? { position } : {}),