From 59665da50d736757446b0d431f4bc3e23e3a2d29 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:27:57 +0300 Subject: [PATCH 01/10] feat: add /highlights page and remove highlight modal Add a dedicated /highlights page with channel tab navigation, replacing the inline highlight modal. Each highlight is collapsible (title + TLDR) and links back to the post. Highlight cards in the feed now link directly to the highlights page with deep-linking support via query params. - New /highlights page with ISR and SEO - Dynamic /highlights/[channel] subpages per channel - Tab navigation with swipe support (generic TabContainer feature) - Shallow routing for smooth tab transitions - Remove HighlightPostModal and all related infrastructure --- .../src/components/FeedItemComponent.tsx | 103 +-- .../cards/highlight/HighlightCards.spec.tsx | 30 +- .../cards/highlight/HighlightGrid.tsx | 24 +- .../cards/highlight/HighlightList.tsx | 24 +- .../src/components/cards/highlight/common.tsx | 112 +-- .../components/highlights/HighlightItem.tsx | 81 ++ .../components/highlights/HighlightsPage.tsx | 190 +++++ .../components/modals/HighlightPostModal.tsx | 764 ------------------ .../modals/highlight/HighlightMobileTrack.tsx | 57 -- .../modals/highlight/HighlightRails.tsx | 225 ------ .../highlight/highlightPostModalUtils.tsx | 77 -- .../highlight/useHighlightModalNavigation.ts | 55 -- .../highlight/useHighlightModalPosts.ts | 69 -- .../useHighlightModalRenderConfigs.tsx | 156 ---- .../src/components/tabs/TabContainer.tsx | 64 +- .../shared/src/components/tabs/TabList.tsx | 14 +- packages/shared/src/graphql/highlights.ts | 72 ++ .../webapp/pages/highlights/[channel].tsx | 97 +++ packages/webapp/pages/highlights/index.tsx | 72 ++ 19 files changed, 635 insertions(+), 1651 deletions(-) create mode 100644 packages/shared/src/components/highlights/HighlightItem.tsx create mode 100644 packages/shared/src/components/highlights/HighlightsPage.tsx delete mode 100644 packages/shared/src/components/modals/HighlightPostModal.tsx delete mode 100644 packages/shared/src/components/modals/highlight/HighlightMobileTrack.tsx delete mode 100644 packages/shared/src/components/modals/highlight/HighlightRails.tsx delete mode 100644 packages/shared/src/components/modals/highlight/highlightPostModalUtils.tsx delete mode 100644 packages/shared/src/components/modals/highlight/useHighlightModalNavigation.ts delete mode 100644 packages/shared/src/components/modals/highlight/useHighlightModalPosts.ts delete mode 100644 packages/shared/src/components/modals/highlight/useHighlightModalRenderConfigs.tsx create mode 100644 packages/webapp/pages/highlights/[channel].tsx create mode 100644 packages/webapp/pages/highlights/index.tsx diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 243de76c3df..2122891ac83 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import type { AdSquadItem, FeedItem } from '../hooks/useFeed'; import { isBoostedPostAd, isBoostedSquadAd } from '../hooks/useFeed'; import { PlaceholderGrid } from './cards/placeholder/PlaceholderGrid'; @@ -11,7 +10,7 @@ import { isSocialTwitterPost, PostType } from '../graphql/posts'; import type { LoggedUser } from '../lib/user'; import useLogImpression from '../hooks/feed/useLogImpression'; import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; -import { LogEvent, Origin, TargetType } from '../lib/log'; +import { Origin, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; @@ -42,7 +41,7 @@ import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; -import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed'; +import { adLogEvent, feedLogExtra } from '../lib/feed'; import { useLogContext } from '../contexts/LogContext'; import { MarketingCtaVariant } from './marketingCta/common'; import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; @@ -56,13 +55,7 @@ import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; import { HighlightGrid } from './cards/highlight/HighlightGrid'; import { HighlightList } from './cards/highlight/HighlightList'; -import { - getHighlightIdsKey, - getHighlightIds, - MAJOR_HEADLINES_MAX_FIRST, - majorHeadlinesQueryOptions, -} from '../graphql/highlights'; -import { HighlightPostModal } from './modals/HighlightPostModal'; +import { getHighlightIdsKey } from '../graphql/highlights'; export type FeedItemComponentProps = { item: FeedItem; @@ -271,10 +264,6 @@ function FeedItemComponent({ disableAdRefresh, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); - const queryClient = useQueryClient(); - const [selectedHighlightId, setSelectedHighlightId] = React.useState< - string | null - >(null); const inViewRef = useLogImpression( item, index, @@ -293,92 +282,8 @@ function FeedItemComponent({ shouldUseListFeedLayout || shouldUseListMode ? HighlightList : HighlightGrid; - const highlightIds = getHighlightIds(item.highlights); - const openHighlightModal = (highlightId: string): void => { - queryClient - .fetchQuery( - majorHeadlinesQueryOptions({ first: MAJOR_HEADLINES_MAX_FIRST }), - ) - .catch(() => undefined) - .finally(() => setSelectedHighlightId(highlightId)); - }; - return ( - <> - { - const [firstHighlight] = item.highlights; - - if (!firstHighlight) { - return; - } - - logEvent( - feedHighlightsLogEvent(LogEvent.Click, { - columns: virtualizedNumCards, - column, - row, - feedName, - ranking, - action: 'read_all_click', - count: item.highlights.length, - highlightIds, - feedMeta: item.feedMeta, - }), - ); - - openHighlightModal(firstHighlight.id); - }} - onHighlightClick={(highlight, position) => { - logEvent( - feedHighlightsLogEvent(LogEvent.Click, { - columns: virtualizedNumCards, - column, - row, - feedName, - ranking, - action: 'highlight_click', - position, - count: item.highlights.length, - clickedHighlight: highlight, - highlightIds, - feedMeta: item.feedMeta, - }), - ); - - openHighlightModal(highlight.id); - }} - /> - setSelectedHighlightId(null)} - onHighlightClick={(highlight, position, modalHighlights) => { - logEvent( - feedHighlightsLogEvent(LogEvent.Click, { - columns: virtualizedNumCards, - column, - row, - feedName, - ranking, - action: 'modal_highlight_click', - position, - count: modalHighlights.length, - clickedHighlight: highlight, - highlightIds: getHighlightIds(modalHighlights), - feedMeta: item.feedMeta, - }), - ); - }} - onSelectHighlight={(highlight) => { - setSelectedHighlightId(highlight.id); - }} - /> - - ); + return ; } const { diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 1172789023f..5713eb32079 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; @@ -28,33 +27,20 @@ const highlights = [ ]; describe('Highlight cards', () => { - it('should render the grid card and trigger highlight actions', async () => { - const onHighlightClick = jest.fn(); - const onReadAllClick = jest.fn(); - - render( - , - ); + it('should render the grid card with highlight links', () => { + render(); expect(screen.getByText('Happening Now')).toBeInTheDocument(); - - await userEvent.click( - screen.getByRole('button', { name: /the first highlight/i }), - ); - await userEvent.click(screen.getByLabelText('Read all highlights')); - - expect(onHighlightClick).toHaveBeenCalledWith(highlights[0], 1); - expect(onReadAllClick).toHaveBeenCalledTimes(1); + expect(screen.getByText('The first highlight')).toBeInTheDocument(); + expect(screen.getByText('The second highlight')).toBeInTheDocument(); + expect(screen.getByText('Read all')).toBeInTheDocument(); }); - it('should render the list card', () => { + it('should render the list card with highlight links', () => { render(); + expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); - expect(screen.getByLabelText('Read all highlights')).toBeInTheDocument(); + expect(screen.getByText('Read all')).toBeInTheDocument(); }); }); diff --git a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx index e1d02e1d7e1..64f624bc987 100644 --- a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx @@ -1,36 +1,20 @@ import type { ReactElement, Ref } from 'react'; import React, { forwardRef } from 'react'; -import classNames from 'classnames'; import { Card } from '../common/Card'; import type { HighlightCardProps } from './common'; -import { - getHighlightCardContainerHandlers, - HighlightCardContent, -} from './common'; +import { HighlightCardContent } from './common'; export const HighlightGrid = forwardRef(function HighlightGrid( - { highlights, onHighlightClick, onReadAllClick }: HighlightCardProps, + { highlights }: HighlightCardProps, ref: Ref, ): ReactElement { return ( - + ); }); diff --git a/packages/shared/src/components/cards/highlight/HighlightList.tsx b/packages/shared/src/components/cards/highlight/HighlightList.tsx index ba0554119b7..5ffd9c07cd4 100644 --- a/packages/shared/src/components/cards/highlight/HighlightList.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightList.tsx @@ -1,36 +1,20 @@ import type { ReactElement, Ref } from 'react'; import React, { forwardRef } from 'react'; -import classNames from 'classnames'; import { ListCard } from '../common/list/ListCard'; import type { HighlightCardProps } from './common'; -import { - getHighlightCardContainerHandlers, - HighlightCardContent, -} from './common'; +import { HighlightCardContent } from './common'; export const HighlightList = forwardRef(function HighlightList( - { highlights, onHighlightClick, onReadAllClick }: HighlightCardProps, + { highlights }: HighlightCardProps, ref: Ref, ): ReactElement { return ( - + ); }); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index ccd822c000c..665c0933981 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -1,74 +1,45 @@ -import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import type { PostHighlight } from '../../../graphql/highlights'; +import { webappUrl } from '../../../lib/constants'; import { RelativeTime } from '../../utilities/RelativeTime'; +import Link from '../../utilities/Link'; export interface HighlightCardProps { highlights: PostHighlight[]; - onHighlightClick?: (highlight: PostHighlight, position: number) => void; - onReadAllClick?: () => void; } const titleGradientClassName = 'feed-highlights-title-gradient'; -export const getHighlightCardContainerHandlers = ( - onReadAllClick?: () => void, -): { - onClick?: (event: MouseEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; -} => { - if (!onReadAllClick) { - return {}; - } +const HIGHLIGHTS_URL = `${webappUrl}highlights`; - return { - onClick: () => onReadAllClick(), - onKeyDown: (event) => { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - onReadAllClick(); - }, - }; -}; +const getHighlightUrl = (highlight: PostHighlight): string => + `${HIGHLIGHTS_URL}?highlight=${highlight.id}`; const HighlightRow = ({ highlight, - index, - onHighlightClick, }: { highlight: PostHighlight; - index: number; - onHighlightClick?: (highlight: PostHighlight, position: number) => void; }): ReactElement => { return ( - + + + + {highlight.headline} + + + + ); }; export const HighlightCardContent = ({ highlights, - onHighlightClick, - onReadAllClick, variant, }: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { const headerClassName = @@ -94,38 +65,27 @@ export const HighlightCardContent = ({
- {highlights.map((highlight, index) => ( - + {highlights.map((highlight) => ( + ))}
- + + + + Read all + + + → + + +
); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx new file mode 100644 index 00000000000..77081efa320 --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { PostHighlightFeed } from '../../graphql/highlights'; +import { stripHtmlTags } from '../../lib/strings'; +import { ArrowIcon } from '../icons/Arrow'; +import { IconSize } from '../Icon'; +import Link from '../utilities/Link'; +import { RelativeTime } from '../utilities/RelativeTime'; + +interface HighlightItemProps { + highlight: PostHighlightFeed; + defaultExpanded?: boolean; +} + +export const HighlightItem = ({ + highlight, + defaultExpanded = false, +}: HighlightItemProps): ReactElement => { + const [expanded, setExpanded] = useState(defaultExpanded); + const ref = useRef(null); + + useEffect(() => { + if (defaultExpanded && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [defaultExpanded]); + + const tldr = useMemo(() => { + const summary = highlight.post.summary?.trim(); + if (summary) { + return summary; + } + + const html = highlight.post.contentHtml?.trim(); + if (html) { + return stripHtmlTags(html).slice(0, 300); + } + + return ''; + }, [highlight.post.summary, highlight.post.contentHtml]); + + return ( +
+ + {expanded && tldr && ( +
+

{tldr}

+ + + Read more + + +
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx new file mode 100644 index 00000000000..b319c9aa273 --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -0,0 +1,190 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { gqlClient } from '../../graphql/common'; +import type { + ChannelConfiguration, + HighlightsPageData, + PostHighlightFeed, + PostHighlightsFeedData, +} from '../../graphql/highlights'; +import { + HIGHLIGHTS_PAGE_QUERY, + MAJOR_HEADLINES_MAX_FIRST, + POST_HIGHLIGHTS_FEED_QUERY, +} from '../../graphql/highlights'; +import { Tab, TabContainer } from '../tabs/TabContainer'; +import { HighlightItem } from './HighlightItem'; + +const MAJOR_HEADLINES_LABEL = 'Headlines'; +const SKELETON_COUNT = 5; +const HIGHLIGHTS_BASE_URL = '/highlights'; + +export const HIGHLIGHTS_PAGE_QUERY_KEY = ['highlights-page']; + +const HighlightSkeleton = (): ReactElement => ( +
+
+
+
+); + +const useChannelHighlights = (channel: string | null) => + useQuery({ + queryKey: ['channel-highlights-feed', channel], + queryFn: () => + gqlClient.request(POST_HIGHLIGHTS_FEED_QUERY, { + channel, + }), + enabled: !!channel, + staleTime: 60_000, + }); + +interface HighlightFeedListProps { + highlights: PostHighlightFeed[]; + loading: boolean; + expandedId?: string; +} + +const HighlightFeedList = ({ + highlights, + loading, + expandedId, +}: HighlightFeedListProps): ReactElement => { + if (loading) { + return ( +
+ {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} +
+ ); + } + + if (highlights.length === 0) { + return ( +

+ No highlights yet +

+ ); + } + + return ( +
+ {highlights.map((highlight) => ( + + ))} +
+ ); +}; + +const MajorHeadlinesTab = ({ + highlights, + loading, + expandedId, +}: { + highlights: PostHighlightFeed[]; + loading: boolean; + expandedId?: string; +}): ReactElement => ( + +); + +const ChannelTab = ({ + channel, + expandedId, +}: { + channel: ChannelConfiguration; + expandedId?: string; +}): ReactElement => { + const { data, isFetching } = useChannelHighlights(channel.channel); + const highlights = data?.postHighlights ?? []; + const loading = isFetching && !data; + + return ( + + ); +}; + +export const HighlightsPage = (): ReactElement => { + const router = useRouter(); + const channel = router.query.channel as string | undefined; + const expandedId = router.query.highlight as string | undefined; + const { data, isFetching } = useQuery({ + queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, + queryFn: () => + gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { + first: MAJOR_HEADLINES_MAX_FIRST, + }), + staleTime: 60_000, + }); + + const channels = data?.channelConfigurations ?? []; + const majorHeadlines = useMemo( + () => data?.majorHeadlines?.edges?.map((edge) => edge.node) ?? [], + [data], + ); + const majorLoading = isFetching && !data; + + const activeTab = + channels.find((c) => c.channel === channel)?.displayName ?? + MAJOR_HEADLINES_LABEL; + + return ( +
+
+

+ Happening Now +

+
+ + {[ + + + , + ...channels.map((ch) => ( + + + + )), + ]} + +
+ ); +}; diff --git a/packages/shared/src/components/modals/HighlightPostModal.tsx b/packages/shared/src/components/modals/HighlightPostModal.tsx deleted file mode 100644 index 5922b3a40d7..00000000000 --- a/packages/shared/src/components/modals/HighlightPostModal.tsx +++ /dev/null @@ -1,764 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import classNames from 'classnames'; -import { useSwipeable } from 'react-swipeable'; -import type { SwipeEventData } from 'react-swipeable'; -import type { ModalProps } from './common/Modal'; -import BasePostModal from './BasePostModal'; -import PostLoadingSkeleton from '../post/PostLoadingSkeleton'; -import { useViewSize, ViewSize } from '../../hooks'; -import type { PostHighlight } from '../../graphql/highlights'; -import { useKeyboardNavigation } from '../../hooks/useKeyboardNavigation'; -import usePostNavigationPosition from '../../hooks/usePostNavigationPosition'; -import { LogEvent, Origin } from '../../lib/log'; -import { postLogEvent } from '../../lib/feed'; -import { useLogContext } from '../../contexts/LogContext'; -import { PostPosition } from '../../hooks/usePostModalNavigation'; -import { - HighlightDesktopRail, - HighlightMobileRail, - type HighlightSelectionHandler, -} from './highlight/HighlightRails'; -import { HighlightMobileTrack } from './highlight/HighlightMobileTrack'; -import { - getHighlightMobileTrackStyle, - highlightMobilePaneStyle, -} from './highlight/highlightPostModalUtils'; -import { useHighlightModalNavigation } from './highlight/useHighlightModalNavigation'; -import { useHighlightModalPosts } from './highlight/useHighlightModalPosts'; -import { useHighlightModalRenderConfigs } from './highlight/useHighlightModalRenderConfigs'; - -interface HighlightPostModalProps extends Pick { - selectedHighlightId: string | null; - highlights: PostHighlight[]; - onRequestClose: ModalProps['onRequestClose']; - onHighlightClick?: HighlightSelectionHandler; - onSelectHighlight: HighlightSelectionHandler; -} -const useIsomorphicLayoutEffect = - typeof window === 'undefined' ? useEffect : useLayoutEffect; -const SWIPE_COMMIT_DURATION_MS = 220; -const SWIPE_LOCK_DISTANCE_PX = 12; -const SWIPE_AXIS_RATIO = 1.35; -const DESKTOP_HIGHLIGHTS_RESTORE_IDLE_MS = 900; - -export function HighlightPostModal({ - selectedHighlightId, - highlights: initialHighlights, - isOpen, - onRequestClose, - onHighlightClick, - onSelectHighlight, -}: HighlightPostModalProps): ReactElement | null { - const { logEvent } = useLogContext(); - const [isNavigationReady, setIsNavigationReady] = useState(false); - const [hasHydrated, setHasHydrated] = useState(false); - const [isDrawerMinimized, setIsDrawerMinimized] = useState(false); - const [swipeOffsetX, setSwipeOffsetX] = useState(0); - const [isSwipeDragging, setIsSwipeDragging] = useState(false); - const [isSwipeTransitioning, setIsSwipeTransitioning] = useState(false); - const [showRightScrollGlow, setShowRightScrollGlow] = useState(false); - const [isDesktopHighlightsHovered, setIsDesktopHighlightsHovered] = - useState(false); - const [isDesktopHighlightsScrolling, setIsDesktopHighlightsScrolling] = - useState(false); - const tabsScrollRef = useRef(null); - const drawerContainerRef = useRef(null); - const articleViewportRef = useRef(null); - const lastArticleScrollTopRef = useRef(0); - const swipeTransitionTimeoutRef = useRef(null); - const desktopHighlightsScrollTimeoutRef = useRef(null); - const desktopHighlightsRestoreTimeoutRef = useRef(null); - const isDesktopHighlightsHoveredRef = useRef(false); - const isLaptop = useViewSize(ViewSize.Laptop); - const shouldUseMobileLayout = hasHydrated && !isLaptop; - const { - activeHighlight, - activeIndex, - canNavigateNext, - canNavigatePrevious, - highlights, - nextHighlight, - postPosition, - previousHighlight, - } = useHighlightModalNavigation({ - initialHighlights, - isOpen, - selectedHighlightId, - }); - const { - isInitialLoading, - isLoadingNextPost, - nextPost, - previousPost, - resolvedPost, - } = useHighlightModalPosts({ - activeHighlight, - isOpen, - nextHighlight, - previousHighlight, - selectedHighlightId, - shouldUseMobileLayout, - }); - const { position, onLoad } = usePostNavigationPosition({ - isDisplayed: isOpen, - offset: 0, - }); - const modalPostPosition = isInitialLoading ? PostPosition.Only : postPosition; - - useEffect(() => { - setHasHydrated(true); - }, []); - - useEffect(() => { - if (!isOpen) { - setIsNavigationReady(false); - setIsDrawerMinimized(false); - setSwipeOffsetX(0); - setIsSwipeDragging(false); - setIsSwipeTransitioning(false); - return; - } - - if (!shouldUseMobileLayout) { - return; - } - - setIsDrawerMinimized(false); - }, [isOpen, shouldUseMobileLayout]); - - useEffect(() => { - if (!isOpen || !shouldUseMobileLayout) { - return undefined; - } - - const viewport = articleViewportRef.current; - if (!viewport) { - return undefined; - } - - lastArticleScrollTopRef.current = viewport.scrollTop; - - const onScroll = (): void => { - const currentScrollTop = viewport.scrollTop; - const scrollDelta = currentScrollTop - lastArticleScrollTopRef.current; - - if (Math.abs(scrollDelta) < 2) { - return; - } - - lastArticleScrollTopRef.current = currentScrollTop; - if (scrollDelta > 0) { - setIsDrawerMinimized(true); - return; - } - - setIsDrawerMinimized(false); - }; - - const onWheel = (event: WheelEvent): void => { - if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) { - return; - } - - if (Math.abs(event.deltaX) < 4) { - return; - } - - setIsDrawerMinimized(false); - }; - - viewport.addEventListener('scroll', onScroll, { passive: true }); - viewport.addEventListener('wheel', onWheel, { passive: true }); - - return () => { - viewport.removeEventListener('scroll', onScroll); - viewport.removeEventListener('wheel', onWheel); - }; - }, [isOpen, shouldUseMobileLayout]); - - const updateHighlightsRightGlow = useCallback((): void => { - const scrollContainer = tabsScrollRef.current; - - if (!scrollContainer) { - setShowRightScrollGlow(false); - return; - } - - const hasScrollableRight = - scrollContainer.scrollLeft + scrollContainer.clientWidth < - scrollContainer.scrollWidth - 1; - setShowRightScrollGlow(hasScrollableRight); - }, []); - - const scrollActiveHighlightIntoView = useCallback( - (behavior: ScrollBehavior = 'auto'): boolean => { - const container = tabsScrollRef.current; - if (!container) { - return false; - } - - const activeButton = container.querySelector( - `[data-highlight-id="${activeHighlight?.id}"]`, - ); - if (!activeButton) { - return false; - } - - activeButton.scrollIntoView({ - behavior, - block: 'nearest', - inline: 'center', - }); - updateHighlightsRightGlow(); - - return true; - }, - [activeHighlight?.id, updateHighlightsRightGlow], - ); - - const clearDesktopHighlightsScrollTimeout = useCallback((): void => { - if (desktopHighlightsScrollTimeoutRef.current === null) { - return; - } - - window.clearTimeout(desktopHighlightsScrollTimeoutRef.current); - desktopHighlightsScrollTimeoutRef.current = null; - }, []); - - const clearDesktopHighlightsRestoreTimeout = useCallback((): void => { - if (desktopHighlightsRestoreTimeoutRef.current === null) { - return; - } - - window.clearTimeout(desktopHighlightsRestoreTimeoutRef.current); - desktopHighlightsRestoreTimeoutRef.current = null; - }, []); - - const scheduleDesktopHighlightsRestore = useCallback((): void => { - if (shouldUseMobileLayout) { - return; - } - - clearDesktopHighlightsRestoreTimeout(); - desktopHighlightsRestoreTimeoutRef.current = window.setTimeout(() => { - desktopHighlightsRestoreTimeoutRef.current = null; - - if (isDesktopHighlightsHoveredRef.current) { - return; - } - - scrollActiveHighlightIntoView('smooth'); - }, DESKTOP_HIGHLIGHTS_RESTORE_IDLE_MS); - }, [ - clearDesktopHighlightsRestoreTimeout, - scrollActiveHighlightIntoView, - shouldUseMobileLayout, - ]); - - const markDesktopHighlightsScrolling = useCallback((): void => { - if (shouldUseMobileLayout) { - return; - } - - setIsDesktopHighlightsScrolling(true); - clearDesktopHighlightsScrollTimeout(); - clearDesktopHighlightsRestoreTimeout(); - desktopHighlightsScrollTimeoutRef.current = window.setTimeout(() => { - setIsDesktopHighlightsScrolling(false); - desktopHighlightsScrollTimeoutRef.current = null; - - if (!isDesktopHighlightsHoveredRef.current) { - scheduleDesktopHighlightsRestore(); - } - }, 180); - }, [ - clearDesktopHighlightsRestoreTimeout, - clearDesktopHighlightsScrollTimeout, - scheduleDesktopHighlightsRestore, - shouldUseMobileLayout, - ]); - - useEffect(() => { - isDesktopHighlightsHoveredRef.current = isDesktopHighlightsHovered; - }, [isDesktopHighlightsHovered]); - - useEffect(() => { - return () => { - clearDesktopHighlightsScrollTimeout(); - clearDesktopHighlightsRestoreTimeout(); - }; - }, [ - clearDesktopHighlightsRestoreTimeout, - clearDesktopHighlightsScrollTimeout, - ]); - - useIsomorphicLayoutEffect(() => { - if (!isOpen || !highlights.length) { - return undefined; - } - - if (shouldUseMobileLayout && isDrawerMinimized) { - setIsNavigationReady(true); - return undefined; - } - - let rafId: number | null = null; - let delayedRafId: number | null = null; - let delayedTimeoutId: number | null = null; - - const attemptScroll = (attempt = 0): void => { - const hasScrolled = scrollActiveHighlightIntoView('auto'); - - if (hasScrolled || attempt >= 6) { - setIsNavigationReady(true); - return; - } - - rafId = window.requestAnimationFrame(() => { - attemptScroll(attempt + 1); - }); - }; - - delayedRafId = window.requestAnimationFrame(() => { - attemptScroll(); - }); - - if (shouldUseMobileLayout) { - delayedTimeoutId = window.setTimeout(() => { - delayedRafId = window.requestAnimationFrame(() => { - attemptScroll(); - }); - }, 320); - } - - return () => { - if (rafId !== null) { - window.cancelAnimationFrame(rafId); - } - - if (delayedRafId !== null) { - window.cancelAnimationFrame(delayedRafId); - } - - if (delayedTimeoutId !== null) { - window.clearTimeout(delayedTimeoutId); - } - }; - }, [ - highlights.length, - isDrawerMinimized, - isOpen, - scrollActiveHighlightIntoView, - shouldUseMobileLayout, - ]); - - useEffect(() => { - if (!isOpen || !shouldUseMobileLayout || isDrawerMinimized) { - setShowRightScrollGlow(false); - return undefined; - } - - const rafId = window.requestAnimationFrame(updateHighlightsRightGlow); - - return () => { - window.cancelAnimationFrame(rafId); - }; - }, [ - activeIndex, - isDrawerMinimized, - isOpen, - shouldUseMobileLayout, - updateHighlightsRightGlow, - ]); - - const onPreviousPost = useCallback((): void => { - if (!canNavigatePrevious) { - return; - } - - if (resolvedPost) { - logEvent( - postLogEvent(LogEvent.NavigatePrevious, resolvedPost, { - extra: { origin: Origin.ArticleModal, from_highlights: true }, - }), - ); - } - - onSelectHighlight(highlights[activeIndex - 1], activeIndex, highlights); - }, [ - activeIndex, - canNavigatePrevious, - highlights, - logEvent, - onSelectHighlight, - resolvedPost, - ]); - - const onNextPost = useCallback((): void => { - if (!canNavigateNext) { - return; - } - - if (resolvedPost) { - logEvent( - postLogEvent(LogEvent.NavigateNext, resolvedPost, { - extra: { origin: Origin.ArticleModal, from_highlights: true }, - }), - ); - } - - onSelectHighlight(highlights[activeIndex + 1], activeIndex + 2, highlights); - }, [ - activeIndex, - canNavigateNext, - highlights, - logEvent, - onSelectHighlight, - resolvedPost, - ]); - - const onPreviousPostAndOpenDrawer = useCallback((): void => { - onPreviousPost(); - if (shouldUseMobileLayout) { - setIsDrawerMinimized(false); - } - }, [onPreviousPost, shouldUseMobileLayout]); - - const onNextPostAndOpenDrawer = useCallback((): void => { - onNextPost(); - if (shouldUseMobileLayout) { - setIsDrawerMinimized(false); - } - }, [onNextPost, shouldUseMobileLayout]); - - const clearSwipeTransitionTimeout = useCallback((): void => { - if (swipeTransitionTimeoutRef.current === null) { - return; - } - - window.clearTimeout(swipeTransitionTimeoutRef.current); - swipeTransitionTimeoutRef.current = null; - }, []); - - const getSwipeViewportWidth = useCallback((): number => { - return articleViewportRef.current?.clientWidth ?? window.innerWidth; - }, []); - - const resetSwipePosition = useCallback((): void => { - setIsSwipeTransitioning(false); - setSwipeOffsetX(0); - }, []); - - const commitSwipeNavigation = useCallback( - (direction: 'left' | 'right', navigate: () => void): void => { - if (isSwipeTransitioning) { - return; - } - - clearSwipeTransitionTimeout(); - setIsSwipeDragging(false); - setIsSwipeTransitioning(true); - const viewportWidth = getSwipeViewportWidth(); - const outOffset = direction === 'left' ? -viewportWidth : viewportWidth; - - setSwipeOffsetX(outOffset); - swipeTransitionTimeoutRef.current = window.setTimeout(() => { - navigate(); - resetSwipePosition(); - swipeTransitionTimeoutRef.current = null; - }, SWIPE_COMMIT_DURATION_MS); - }, - [ - clearSwipeTransitionTimeout, - getSwipeViewportWidth, - isSwipeTransitioning, - resetSwipePosition, - ], - ); - - const animateSwipeReset = useCallback((): void => { - if (!swipeOffsetX) { - setIsSwipeDragging(false); - return; - } - - clearSwipeTransitionTimeout(); - setIsSwipeDragging(false); - setIsSwipeTransitioning(true); - setSwipeOffsetX(0); - swipeTransitionTimeoutRef.current = window.setTimeout(() => { - setIsSwipeTransitioning(false); - swipeTransitionTimeoutRef.current = null; - }, SWIPE_COMMIT_DURATION_MS); - }, [clearSwipeTransitionTimeout, swipeOffsetX]); - - const shouldCommitSwipeNavigation = useCallback( - (eventData: SwipeEventData): boolean => { - const swipeThreshold = getSwipeViewportWidth() * 0.18; - - return eventData.absX >= swipeThreshold; - }, - [getSwipeViewportWidth], - ); - - const shouldHandleArticleSwipe = useCallback( - (eventData: SwipeEventData): boolean => { - if (!shouldUseMobileLayout) { - return false; - } - - if (isLoadingNextPost || isSwipeTransitioning) { - return false; - } - - const target = eventData.event.target as Node | null; - if (target && drawerContainerRef.current?.contains(target)) { - return false; - } - - if (eventData.absX < SWIPE_LOCK_DISTANCE_PX) { - return false; - } - - if (eventData.absX <= eventData.absY * SWIPE_AXIS_RATIO) { - return false; - } - - return true; - }, - [isLoadingNextPost, isSwipeTransitioning, shouldUseMobileLayout], - ); - - const keyboardNavigationEvents = useMemo( - (): [string, (event: KeyboardEvent) => void][] => [ - ['ArrowLeft', () => onPreviousPost()], - ['ArrowRight', () => onNextPost()], - ['j', () => onPreviousPost()], - ['k', () => onNextPost()], - ], - [onNextPost, onPreviousPost], - ); - const keyboardNavigationParent = - isOpen && typeof window !== 'undefined' ? window : null; - - const { - currentContent, - nextPaneContent, - postModalConfig, - previousPaneContent, - } = useHighlightModalRenderConfigs({ - activeIndex, - highlightsLength: highlights.length, - nextPost, - onNextPost, - onPreviousPost, - onRequestClose, - position, - postPosition, - previousPost, - resolvedPost, - }); - - const { ref: swipeableRef, ...swipeHandlers } = useSwipeable({ - onSwiping: (eventData) => { - if (!shouldHandleArticleSwipe(eventData)) { - return; - } - - const { deltaX } = eventData; - const maxOffset = getSwipeViewportWidth() * 0.6; - const nextOffset = Math.max(-maxOffset, Math.min(maxOffset, deltaX)); - - setSwipeOffsetX(nextOffset); - setIsSwipeDragging(true); - }, - onSwipedLeft: (eventData) => { - if (!shouldHandleArticleSwipe(eventData)) { - animateSwipeReset(); - return; - } - - if (!canNavigateNext || !shouldCommitSwipeNavigation(eventData)) { - animateSwipeReset(); - return; - } - - commitSwipeNavigation('left', onNextPostAndOpenDrawer); - }, - onSwipedRight: (eventData) => { - if (!shouldHandleArticleSwipe(eventData)) { - animateSwipeReset(); - return; - } - - if (!canNavigatePrevious || !shouldCommitSwipeNavigation(eventData)) { - animateSwipeReset(); - return; - } - - commitSwipeNavigation('right', onPreviousPostAndOpenDrawer); - }, - trackTouch: true, - delta: 12, - }); - - const setArticleViewportRef = useCallback( - (node: HTMLDivElement | null): void => { - articleViewportRef.current = node; - swipeableRef(node); - }, - [swipeableRef], - ); - - useKeyboardNavigation(keyboardNavigationParent, keyboardNavigationEvents, { - disableOnTags: ['textarea', 'select', 'input'], - }); - - useEffect(() => { - return () => { - clearSwipeTransitionTimeout(); - }; - }, [clearSwipeTransitionTimeout]); - - const selectHighlightFromRail = useCallback( - ( - highlight: PostHighlight, - highlightPosition: number, - modalHighlights: PostHighlight[], - ): void => { - onHighlightClick?.(highlight, highlightPosition, modalHighlights); - onSelectHighlight(highlight, highlightPosition, modalHighlights); - }, - [onHighlightClick, onSelectHighlight], - ); - const onDesktopRailMouseEnter = useCallback((): void => { - setIsDesktopHighlightsHovered(true); - clearDesktopHighlightsRestoreTimeout(); - }, [clearDesktopHighlightsRestoreTimeout]); - - const onDesktopRailMouseLeave = useCallback((): void => { - setIsDesktopHighlightsHovered(false); - - if (!isDesktopHighlightsScrolling) { - scheduleDesktopHighlightsRestore(); - } - }, [isDesktopHighlightsScrolling, scheduleDesktopHighlightsRestore]); - - const onRestoreMobileDrawer = useCallback((): void => { - setIsDrawerMinimized(false); - }, []); - - if (!isOpen || !selectedHighlightId || !activeHighlight) { - return null; - } - - const mobileTrackStyle = getHighlightMobileTrackStyle({ - isSwipeDragging, - isSwipeTransitioning, - swipeCommitDurationMs: SWIPE_COMMIT_DURATION_MS, - swipeOffsetX, - }); - - return ( - - Happening Now - - } - > -
- {!shouldUseMobileLayout && ( - - )} - {shouldUseMobileLayout && ( - - )} -
-
- {shouldUseMobileLayout ? ( - - ) : ( - <> - {resolvedPost ? ( -
-
{postModalConfig.content}
-
- ) : ( -
- -
- )} - - )} -
-
-
-
- ); -} diff --git a/packages/shared/src/components/modals/highlight/HighlightMobileTrack.tsx b/packages/shared/src/components/modals/highlight/HighlightMobileTrack.tsx deleted file mode 100644 index fcb8a8d20eb..00000000000 --- a/packages/shared/src/components/modals/highlight/HighlightMobileTrack.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { CSSProperties, ReactElement, ReactNode } from 'react'; -import React from 'react'; -import type { PostHighlight } from '../../../graphql/highlights'; - -interface HighlightMobileTrackProps { - activeHighlight: PostHighlight; - currentContent: ReactNode; - isLoadingNextPost: boolean; - mobilePaneStyle: CSSProperties; - mobileTrackStyle: CSSProperties; - nextHighlight?: PostHighlight; - nextPaneContent: ReactNode; - previousHighlight?: PostHighlight; - previousPaneContent: ReactNode; -} - -export const HighlightMobileTrack = ({ - activeHighlight, - currentContent, - isLoadingNextPost, - mobilePaneStyle, - mobileTrackStyle, - nextHighlight, - nextPaneContent, - previousHighlight, - previousPaneContent, -}: HighlightMobileTrackProps): ReactElement => { - return ( -
-
- {previousHighlight ? previousPaneContent :
} -
-
- {currentContent} -
-
- {nextHighlight ? nextPaneContent :
} -
-
- ); -}; diff --git a/packages/shared/src/components/modals/highlight/HighlightRails.tsx b/packages/shared/src/components/modals/highlight/HighlightRails.tsx deleted file mode 100644 index 3940e5d5413..00000000000 --- a/packages/shared/src/components/modals/highlight/HighlightRails.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import type { ReactElement, RefObject } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { ArrowIcon } from '../../icons'; -import type { PostHighlight } from '../../../graphql/highlights'; -import { RelativeTime } from '../../utilities/RelativeTime'; - -export type HighlightSelectionHandler = ( - highlight: PostHighlight, - position: number, - highlights: PostHighlight[], -) => void; - -interface HighlightTabsProps { - highlights: PostHighlight[]; - activeIndex: number; - onSelectHighlight: HighlightSelectionHandler; -} - -interface HighlightDesktopRailProps extends HighlightTabsProps { - isNavigationReady: boolean; - onMouseEnter: () => void; - onMouseLeave: () => void; - onScroll: () => void; - scrollRef: RefObject; -} - -interface HighlightMobileRailProps extends HighlightTabsProps { - drawerContainerRef: RefObject; - isDrawerMinimized: boolean; - isNavigationReady: boolean; - onRestoreDrawer: () => void; - scrollRef: RefObject; - showRightScrollGlow: boolean; - updateHighlightsRightGlow: () => void; -} - -const highlightTabsSkeletonItems = Array.from( - { length: 4 }, - (_, index) => index, -); - -export const HighlightTabs = ({ - highlights, - activeIndex, - onSelectHighlight, -}: HighlightTabsProps): ReactElement => { - return ( - <> - {highlights.map((highlight, index) => { - const isActive = index === activeIndex; - - return ( - - ); - })} - - ); -}; - -const HighlightTabsSkeleton = (): ReactElement => { - return ( -
- {highlightTabsSkeletonItems.map((item) => ( -
-
-
-
-
- ))} -
- ); -}; - -export const HighlightDesktopRail = ({ - activeIndex, - highlights, - isNavigationReady, - onMouseEnter, - onMouseLeave, - onScroll, - onSelectHighlight, - scrollRef, -}: HighlightDesktopRailProps): ReactElement => { - return ( -
-
-
-
- {highlights.length ? ( - - ) : null} -
-
- {!isNavigationReady && ( -
- -
- )} -
-
- ); -}; - -export const HighlightMobileRail = ({ - activeIndex, - drawerContainerRef, - highlights, - isDrawerMinimized, - isNavigationReady, - onRestoreDrawer, - onSelectHighlight, - scrollRef, - showRightScrollGlow, - updateHighlightsRightGlow, -}: HighlightMobileRailProps): ReactElement => { - if (isDrawerMinimized) { - return ( -
-
- -
-
- ); - } - - return ( -
-
-
-
-
- -
-
- {!isNavigationReady && ( -
-
- -
-
- )} -
-
-
-
- ); -}; diff --git a/packages/shared/src/components/modals/highlight/highlightPostModalUtils.tsx b/packages/shared/src/components/modals/highlight/highlightPostModalUtils.tsx deleted file mode 100644 index 69819990ffe..00000000000 --- a/packages/shared/src/components/modals/highlight/highlightPostModalUtils.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { CSSProperties, ReactElement } from 'react'; -import React from 'react'; -import PostLoadingSkeleton from '../../post/PostLoadingSkeleton'; -import type { PostModalRenderConfig } from '../getPostModalRenderConfig'; -import type { PostType } from '../../../graphql/posts'; -import { PostPosition } from '../../../hooks/usePostModalNavigation'; - -export const getHighlightPostPosition = ( - activeIndex: number, - highlightsLength: number, -): PostPosition => { - if (highlightsLength <= 1) { - return PostPosition.Only; - } - - if (activeIndex <= 0) { - return PostPosition.First; - } - - if (activeIndex >= highlightsLength - 1) { - return PostPosition.Last; - } - - return PostPosition.Middle; -}; - -export const buildHighlightPostPane = ( - paneConfig: PostModalRenderConfig | null, - panePostType: PostType, -): ReactElement => { - if (!paneConfig) { - return ( -
- -
- ); - } - - return
{paneConfig.content}
; -}; - -export const getHighlightMobileTrackStyle = ({ - isSwipeDragging, - isSwipeTransitioning, - swipeCommitDurationMs, - swipeOffsetX, -}: { - isSwipeDragging: boolean; - isSwipeTransitioning: boolean; - swipeCommitDurationMs: number; - swipeOffsetX: number; -}): CSSProperties => { - let transition = 'none'; - - if (isSwipeTransitioning) { - transition = `transform ${swipeCommitDurationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`; - } - - if (isSwipeDragging) { - transition = 'none'; - } - - return { - transform: `translateX(calc(-33.333333% + ${swipeOffsetX}px))`, - transition, - willChange: 'transform', - }; -}; - -export const highlightMobilePaneStyle: CSSProperties = { - backfaceVisibility: 'hidden', - WebkitBackfaceVisibility: 'hidden', -}; diff --git a/packages/shared/src/components/modals/highlight/useHighlightModalNavigation.ts b/packages/shared/src/components/modals/highlight/useHighlightModalNavigation.ts deleted file mode 100644 index 1d5e9a643f8..00000000000 --- a/packages/shared/src/components/modals/highlight/useHighlightModalNavigation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import type { PostHighlight } from '../../../graphql/highlights'; -import { - MAJOR_HEADLINES_MAX_FIRST, - majorHeadlinesQueryOptions, -} from '../../../graphql/highlights'; -import { getHighlightPostPosition } from './highlightPostModalUtils'; - -interface UseHighlightModalNavigationProps { - initialHighlights: PostHighlight[]; - isOpen: boolean; - selectedHighlightId: string | null; -} - -export const useHighlightModalNavigation = ({ - initialHighlights, - isOpen, - selectedHighlightId, -}: UseHighlightModalNavigationProps) => { - const { data } = useQuery({ - ...majorHeadlinesQueryOptions({ first: MAJOR_HEADLINES_MAX_FIRST }), - enabled: isOpen, - }); - const fetchedHighlights = - data?.majorHeadlines.edges.map(({ node }) => node) ?? []; - const highlights = fetchedHighlights.length - ? fetchedHighlights - : initialHighlights; - const activeIndex = useMemo(() => { - const foundIndex = highlights.findIndex( - ({ id }) => id === selectedHighlightId, - ); - - return foundIndex >= 0 ? foundIndex : 0; - }, [highlights, selectedHighlightId]); - const previousHighlight = - activeIndex > 0 ? highlights[activeIndex - 1] : undefined; - const nextHighlight = - activeIndex < highlights.length - 1 - ? highlights[activeIndex + 1] - : undefined; - const activeHighlight = highlights[activeIndex]; - - return { - activeHighlight, - activeIndex, - canNavigateNext: activeIndex >= 0 && activeIndex < highlights.length - 1, - canNavigatePrevious: activeIndex > 0, - highlights, - nextHighlight, - postPosition: getHighlightPostPosition(activeIndex, highlights.length), - previousHighlight, - }; -}; diff --git a/packages/shared/src/components/modals/highlight/useHighlightModalPosts.ts b/packages/shared/src/components/modals/highlight/useHighlightModalPosts.ts deleted file mode 100644 index 3c6b99ff2e1..00000000000 --- a/packages/shared/src/components/modals/highlight/useHighlightModalPosts.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { PostHighlight } from '../../../graphql/highlights'; -import { usePostById } from '../../../hooks/usePostById'; - -interface UseHighlightModalPostsProps { - activeHighlight?: PostHighlight; - isOpen: boolean; - nextHighlight?: PostHighlight; - previousHighlight?: PostHighlight; - selectedHighlightId: string | null; - shouldUseMobileLayout: boolean; -} - -export const useHighlightModalPosts = ({ - activeHighlight, - isOpen, - nextHighlight, - previousHighlight, - selectedHighlightId, - shouldUseMobileLayout, -}: UseHighlightModalPostsProps) => { - const [lastLoadedPost, setLastLoadedPost] = useState< - ReturnType['post'] | null - >(null); - const activePostId = isOpen ? activeHighlight?.post.id ?? '' : ''; - const { post } = usePostById({ - id: activePostId, - options: { enabled: !!activePostId }, - }); - const { post: previousPost } = usePostById({ - id: isOpen && shouldUseMobileLayout ? previousHighlight?.post.id ?? '' : '', - options: { - enabled: !!previousHighlight?.post.id && isOpen && shouldUseMobileLayout, - }, - }); - const { post: nextPost } = usePostById({ - id: isOpen && shouldUseMobileLayout ? nextHighlight?.post.id ?? '' : '', - options: { - enabled: !!nextHighlight?.post.id && isOpen && shouldUseMobileLayout, - }, - }); - - useEffect(() => { - if (!post?.id) { - return; - } - - setLastLoadedPost(post); - }, [activeHighlight?.id, activePostId, post, selectedHighlightId]); - - useEffect(() => { - if (isOpen) { - return; - } - - setLastLoadedPost(null); - }, [isOpen]); - - const resolvedPost = post ?? lastLoadedPost; - - return { - isInitialLoading: !resolvedPost, - isLoadingNextPost: !!resolvedPost && post?.id !== activeHighlight?.post.id, - nextPost, - post, - previousPost, - resolvedPost, - }; -}; diff --git a/packages/shared/src/components/modals/highlight/useHighlightModalRenderConfigs.tsx b/packages/shared/src/components/modals/highlight/useHighlightModalRenderConfigs.tsx deleted file mode 100644 index b961f510514..00000000000 --- a/packages/shared/src/components/modals/highlight/useHighlightModalRenderConfigs.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, { useMemo } from 'react'; -import type { CSSProperties, ReactElement } from 'react'; -import PostLoadingSkeleton from '../../post/PostLoadingSkeleton'; -import type { ModalProps } from '../common/Modal'; -import type { PostModalRenderConfig } from '../getPostModalRenderConfig'; -import { getPostModalRenderConfig } from '../getPostModalRenderConfig'; -import type { Post } from '../../../graphql/posts'; -import { PostType } from '../../../graphql/posts'; -import { - buildHighlightPostPane, - getHighlightPostPosition, -} from './highlightPostModalUtils'; -import type { PostPosition } from '../../../hooks/usePostModalNavigation'; - -interface UseHighlightModalRenderConfigsProps { - activeIndex: number; - highlightsLength: number; - nextPost?: Post; - onNextPost: () => void; - onPreviousPost: () => void; - onRequestClose?: ModalProps['onRequestClose']; - position: CSSProperties['position']; - postPosition: PostPosition; - previousPost?: Post; - resolvedPost?: Post | null; -} - -const emptyPostModalConfig: Pick< - PostModalRenderConfig, - 'loadingClassName' | 'postType' | 'size' -> = { - postType: PostType.Article, - loadingClassName: '!pb-2 tablet:pb-0', - size: undefined, -}; - -export const useHighlightModalRenderConfigs = ({ - activeIndex, - highlightsLength, - nextPost, - onNextPost, - onPreviousPost, - onRequestClose, - position, - postPosition, - previousPost, - resolvedPost, -}: UseHighlightModalRenderConfigsProps) => { - const postModalConfig = useMemo(() => { - if (!resolvedPost) { - return { - ...emptyPostModalConfig, - content: null, - }; - } - - return getPostModalRenderConfig({ - position, - post: resolvedPost, - postPosition, - onPreviousPost, - onNextPost, - onRequestClose, - hideSubscribeAction: true, - }); - }, [ - onNextPost, - onPreviousPost, - onRequestClose, - position, - postPosition, - resolvedPost, - ]); - - const previousPostModalConfig = useMemo(() => { - if (!previousPost) { - return null; - } - - return getPostModalRenderConfig({ - position, - post: previousPost, - postPosition: getHighlightPostPosition(activeIndex - 1, highlightsLength), - onPreviousPost, - onNextPost, - onRequestClose, - hideSubscribeAction: true, - }); - }, [ - activeIndex, - highlightsLength, - onNextPost, - onPreviousPost, - onRequestClose, - position, - previousPost, - ]); - - const nextPostModalConfig = useMemo(() => { - if (!nextPost) { - return null; - } - - return getPostModalRenderConfig({ - position, - post: nextPost, - postPosition: getHighlightPostPosition(activeIndex + 1, highlightsLength), - onPreviousPost, - onNextPost, - onRequestClose, - hideSubscribeAction: true, - }); - }, [ - activeIndex, - highlightsLength, - nextPost, - onNextPost, - onPreviousPost, - onRequestClose, - position, - ]); - - const currentContent = useMemo((): ReactElement => { - if (resolvedPost) { - return
{postModalConfig.content}
; - } - - return ( -
- -
- ); - }, [ - postModalConfig.content, - postModalConfig.loadingClassName, - postModalConfig.postType, - resolvedPost, - ]); - - return { - currentContent, - nextPaneContent: buildHighlightPostPane( - nextPostModalConfig, - nextPostModalConfig?.postType ?? PostType.Article, - ), - postModalConfig, - previousPaneContent: buildHighlightPostPane( - previousPostModalConfig, - previousPostModalConfig?.postType ?? PostType.Article, - ), - }; -}; diff --git a/packages/shared/src/components/tabs/TabContainer.tsx b/packages/shared/src/components/tabs/TabContainer.tsx index 3fad5ef2aaa..fea639d7466 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -1,7 +1,14 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react'; -import React, { createElement, useRef, useState } from 'react'; +import React, { + createElement, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; +import { useSwipeable } from 'react-swipeable'; import type { AllowedTabTags, TabListProps } from './TabList'; import TabList from './TabList'; import type { RenderTab } from './common'; @@ -40,7 +47,7 @@ export interface TabContainerProps { controlledActive?: T; onActiveChange?: ( active: T, - event: React.MouseEvent, + event?: React.MouseEvent, ) => boolean | void; renderTab?: RenderTab; shouldFocusTabOnChange?: boolean; @@ -48,6 +55,8 @@ export interface TabContainerProps { showBorder?: boolean; showHeader?: boolean; style?: CSSProperties; + shallow?: boolean; + swipeable?: boolean; tabListProps?: Pick; tabTag?: AllowedTabTags; extraHeaderContent?: ReactNode; @@ -64,6 +73,8 @@ export function TabContainer({ showBorder = true, showHeader = true, style, + shallow = false, + swipeable = false, tabListProps = {}, tabTag, extraHeaderContent, @@ -102,16 +113,59 @@ export function TabContainer({ }, 0); if (child?.props?.url) { - router.push(child.props.url); + if (shallow) { + router.replace(child.props.url, undefined, { shallow: true }); + } else { + router.push(child.props.url); + } } } }; + const labels = useMemo(() => children.map((c) => c.props.label), [children]); + + const navigateTab = useCallback( + (direction: 'next' | 'previous') => { + const currentIndex = labels.indexOf(currentActive); + const nextIndex = + direction === 'next' ? currentIndex + 1 : currentIndex - 1; + + if (nextIndex < 0 || nextIndex >= labels.length) { + return; + } + + const nextChild = children[nextIndex]; + const nextLabel = nextChild.props.label; + setActive(nextLabel); + onActiveChange?.(nextLabel, undefined); + + if (nextChild.props.url) { + if (shallow) { + router.replace(nextChild.props.url, undefined, { shallow: true }); + } else { + router.push(nextChild.props.url); + } + } + }, + [children, currentActive, labels, onActiveChange, router, shallow], + ); + + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => navigateTab('next'), + onSwipedRight: () => navigateTab('previous'), + trackTouch: true, + delta: 10, + }); + const isTabActive = ({ props: { url, label }, }: ReactElement>) => { + if (controlledActive) { + return label === currentActive; + } + if (url) { - return router.pathname === url; + return router.asPath === url; } return label === currentActive; @@ -172,7 +226,7 @@ export function TabContainer({ /> {extraHeaderContent} - {render} +
{render}
); } diff --git a/packages/shared/src/components/tabs/TabList.tsx b/packages/shared/src/components/tabs/TabList.tsx index b0b04a6a0f1..6ce3eaa5bda 100644 --- a/packages/shared/src/components/tabs/TabList.tsx +++ b/packages/shared/src/components/tabs/TabList.tsx @@ -62,13 +62,15 @@ function TabList({ } const scrollableParentRect = scrollableParent.getBoundingClientRect(); - - if ( + const isOutOfView = activeTabRect.left < scrollableParentRect.left || - activeTabRect.right > scrollableParentRect.right - ) { - currentActiveTab.current.parentElement.parentElement.scrollTo({ - left: offset, + activeTabRect.right > scrollableParentRect.right; + + if (isOutOfView) { + const centeredOffset = + offset - scrollableParentRect.width / 2 + activeTabRect.width / 2; + scrollableParent.scrollTo({ + left: Math.max(0, centeredOffset), behavior: 'smooth', }); } diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index e5022988361..5bd76e785ee 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -13,6 +13,19 @@ export interface PostHighlight { }; } +export interface PostHighlightFeed { + id: string; + channel: string; + headline: string; + highlightedAt: string; + post: { + id: string; + commentsPermalink: string; + summary?: string; + contentHtml?: string; + }; +} + export interface MajorHeadlinesData { majorHeadlines: Connection; } @@ -83,3 +96,62 @@ export const majorHeadlinesQueryOptions = ({ }), staleTime: ONE_MINUTE, }); + +export const POST_HIGHLIGHT_FEED_FRAGMENT = gql` + fragment PostHighlightFeedCard on PostHighlight { + id + channel + headline + highlightedAt + post { + id + commentsPermalink + summary + contentHtml + } + } +`; + +export interface PostHighlightsFeedData { + postHighlights: PostHighlightFeed[]; +} + +export const POST_HIGHLIGHTS_FEED_QUERY = gql` + query PostHighlightsFeed($channel: String!) { + postHighlights(channel: $channel) { + ...PostHighlightFeedCard + } + } + ${POST_HIGHLIGHT_FEED_FRAGMENT} +`; + +export interface ChannelConfiguration { + channel: string; + displayName: string; +} + +export interface HighlightsPageData { + majorHeadlines: Connection; + channelConfigurations: ChannelConfiguration[]; +} + +export const HIGHLIGHTS_PAGE_QUERY = gql` + query HighlightsPage($first: Int, $after: String) { + majorHeadlines(first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + } + edges { + node { + ...PostHighlightFeedCard + } + } + } + channelConfigurations { + channel + displayName + } + } + ${POST_HIGHLIGHT_FEED_FRAGMENT} +`; diff --git a/packages/webapp/pages/highlights/[channel].tsx b/packages/webapp/pages/highlights/[channel].tsx new file mode 100644 index 00000000000..7c73cc27f87 --- /dev/null +++ b/packages/webapp/pages/highlights/[channel].tsx @@ -0,0 +1,97 @@ +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import type { ReactElement } from 'react'; +import React from 'react'; +import type { DehydratedState } from '@tanstack/react-query'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import type { + HighlightsPageData, + PostHighlightsFeedData, +} from '@dailydotdev/shared/src/graphql/highlights'; +import { + HIGHLIGHTS_PAGE_QUERY, + MAJOR_HEADLINES_MAX_FIRST, + POST_HIGHLIGHTS_FEED_QUERY, +} from '@dailydotdev/shared/src/graphql/highlights'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + HighlightsPage, + HIGHLIGHTS_PAGE_QUERY_KEY, +} from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { defaultOpenGraph, defaultSeo } from '../../next-seo'; + +const HIGHLIGHTS_TITLE = 'Highlights | daily.dev'; +const HIGHLIGHTS_DESCRIPTION = + 'Curated highlights from across the developer ecosystem. Stay on top of the most important stories, releases, and discussions.'; + +const HighlightsChannelPage = (): ReactElement => ; + +const getHighlightsLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +HighlightsChannelPage.getLayout = getHighlightsLayout; +HighlightsChannelPage.layoutProps = { + screenCentered: false, + seo: { + title: HIGHLIGHTS_TITLE, + description: HIGHLIGHTS_DESCRIPTION, + openGraph: { + ...defaultOpenGraph, + title: HIGHLIGHTS_TITLE, + description: HIGHLIGHTS_DESCRIPTION, + type: 'website', + }, + ...defaultSeo, + }, +}; + +export default HighlightsChannelPage; + +interface HighlightsChannelPageProps { + dehydratedState: DehydratedState; +} + +export async function getStaticPaths(): Promise { + return { + paths: [], + fallback: 'blocking', + }; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise< + GetStaticPropsResult +> { + const channel = params?.channel as string; + const queryClient = new QueryClient(); + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, + queryFn: () => + gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { + first: MAJOR_HEADLINES_MAX_FIRST, + }), + }), + queryClient.prefetchQuery({ + queryKey: ['channel-highlights-feed', channel], + queryFn: () => + gqlClient.request(POST_HIGHLIGHTS_FEED_QUERY, { + channel, + }), + }), + ]); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + revalidate: 60, + }; +} diff --git a/packages/webapp/pages/highlights/index.tsx b/packages/webapp/pages/highlights/index.tsx new file mode 100644 index 00000000000..3bf3ff5cfe0 --- /dev/null +++ b/packages/webapp/pages/highlights/index.tsx @@ -0,0 +1,72 @@ +import type { GetStaticPropsResult } from 'next'; +import type { ReactElement } from 'react'; +import React from 'react'; +import type { DehydratedState } from '@tanstack/react-query'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import type { HighlightsPageData } from '@dailydotdev/shared/src/graphql/highlights'; +import { + HIGHLIGHTS_PAGE_QUERY, + MAJOR_HEADLINES_MAX_FIRST, +} from '@dailydotdev/shared/src/graphql/highlights'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + HighlightsPage, + HIGHLIGHTS_PAGE_QUERY_KEY, +} from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { defaultOpenGraph, defaultSeo } from '../../next-seo'; + +const HIGHLIGHTS_TITLE = 'Highlights | daily.dev'; +const HIGHLIGHTS_DESCRIPTION = + 'Curated highlights from across the developer ecosystem. Stay on top of the most important stories, releases, and discussions.'; + +const HighlightsPageWrapper = (): ReactElement => ; + +const getHighlightsLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +HighlightsPageWrapper.getLayout = getHighlightsLayout; +HighlightsPageWrapper.layoutProps = { + screenCentered: false, + seo: { + title: HIGHLIGHTS_TITLE, + description: HIGHLIGHTS_DESCRIPTION, + canonical: 'https://app.daily.dev/highlights', + openGraph: { + ...defaultOpenGraph, + title: HIGHLIGHTS_TITLE, + description: HIGHLIGHTS_DESCRIPTION, + url: 'https://app.daily.dev/highlights', + type: 'website', + }, + ...defaultSeo, + }, +}; + +export default HighlightsPageWrapper; + +interface HighlightsPageProps { + dehydratedState: DehydratedState; +} + +export async function getStaticProps(): Promise< + GetStaticPropsResult +> { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, + queryFn: () => + gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { + first: MAJOR_HEADLINES_MAX_FIRST, + }), + }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + revalidate: 60, + }; +} From 7710be0370ce5b00a27fbb50b59adb2289659f24 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:40:00 +0300 Subject: [PATCH 02/10] fix: resolve strict typecheck and lint errors in TabContainer and TabList --- .../src/components/tabs/TabContainer.tsx | 65 ++++++++++++------- .../shared/src/components/tabs/TabList.tsx | 6 +- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/components/tabs/TabContainer.tsx b/packages/shared/src/components/tabs/TabContainer.tsx index fea639d7466..a19682d1f4b 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -81,14 +81,17 @@ export function TabContainer({ }: TabContainerProps): ReactElement { const router = useRouter(); const containerRef = useRef(null); + const tabs = useMemo(() => children ?? [], [children]); const [active, setActive] = useState(() => { - const defaultLabel = children[0].props.label; + if (!tabs.length) { + return '' as T; + } - if (children[0].props.url) { - const matchingChild = children.find( - (c) => c.props.url === router.pathname, - ); + const defaultLabel = tabs[0].props.label; + + if (tabs[0].props.url) { + const matchingChild = tabs.find((c) => c.props.url === router.pathname); return matchingChild ? matchingChild.props.label : defaultLabel; } @@ -96,10 +99,22 @@ export function TabContainer({ }); const currentActive = controlledActive ?? active; - const onClick: TabListProps['onClick'] = (label: T, event) => { - const child = children.find((c) => c.props.label === label); - setActive(label); - const shouldChange = onActiveChange?.(label, event); + + const navigateToUrl = useCallback( + (url: string) => { + if (shallow) { + router.replace(url, undefined, { shallow: true }); + } else { + router.push(url); + } + }, + [router, shallow], + ); + + const onClick: TabListProps['onClick'] = (label, event) => { + const child = tabs.find((c) => c.props.label === label); + setActive(label as T); + const shouldChange = onActiveChange?.(label as T, event); // evaluate !== false due to backwards compatibility with implementations that return undefined if (shouldChange !== false) { @@ -113,16 +128,12 @@ export function TabContainer({ }, 0); if (child?.props?.url) { - if (shallow) { - router.replace(child.props.url, undefined, { shallow: true }); - } else { - router.push(child.props.url); - } + navigateToUrl(child.props.url); } } }; - const labels = useMemo(() => children.map((c) => c.props.label), [children]); + const labels = useMemo(() => tabs.map((c) => c.props.label), [tabs]); const navigateTab = useCallback( (direction: 'next' | 'previous') => { @@ -134,20 +145,20 @@ export function TabContainer({ return; } - const nextChild = children[nextIndex]; + const nextChild = tabs[nextIndex]; + if (!nextChild) { + return; + } + const nextLabel = nextChild.props.label; setActive(nextLabel); onActiveChange?.(nextLabel, undefined); if (nextChild.props.url) { - if (shallow) { - router.replace(nextChild.props.url, undefined, { shallow: true }); - } else { - router.push(nextChild.props.url); - } + navigateToUrl(nextChild.props.url); } }, - [children, currentActive, labels, onActiveChange, router, shallow], + [tabs, currentActive, labels, navigateToUrl, onActiveChange], ); const swipeHandlers = useSwipeable({ @@ -173,7 +184,11 @@ export function TabContainer({ const renderSingleComponent = () => { if (!shouldMountInactive) { - const child = children.find(isTabActive); + const child = tabs.find(isTabActive); + + if (!child) { + return null; + } return createElement(child.type, child.props); } @@ -183,7 +198,7 @@ export function TabContainer({ const render = !shouldMountInactive ? renderSingleComponent() - : children.map((child, i) => + : tabs.map((child, i) => createElement>(child.type, { ...child.props, key: child.key || child.props.label || i, @@ -212,7 +227,7 @@ export function TabContainer({ )} > - items={children.map(({ props }) => ({ + items={tabs.map(({ props }) => ({ label: props.label, url: props.url, hint: props.hint, diff --git a/packages/shared/src/components/tabs/TabList.tsx b/packages/shared/src/components/tabs/TabList.tsx index 6ce3eaa5bda..c509c5ca81f 100644 --- a/packages/shared/src/components/tabs/TabList.tsx +++ b/packages/shared/src/components/tabs/TabList.tsx @@ -80,7 +80,9 @@ function TabList({ useLayoutEffect(() => { // get the active tab's rect and offset so that we can position the indicator const activeTabRect = currentActiveTab.current?.getBoundingClientRect(); - const offset = activeTabRect ? currentActiveTab?.current.offsetLeft : 0; + const offset = activeTabRect + ? currentActiveTab.current?.offsetLeft ?? 0 + : 0; const indicatorOffset = activeTabRect ? activeTabRect.width / 2 + offset : 0; @@ -145,7 +147,7 @@ function TabList({ if (isAnchor) { event.preventDefault(); } - onClick(label, event); + onClick?.(label, event); }} {...(isAnchor ? { From a0feec901db9a928a7d914b41a0d20958ee985c8 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:50:36 +0300 Subject: [PATCH 03/10] fix: mock constants in highlight card tests to fix CI --- .../src/components/cards/highlight/HighlightCards.spec.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 5713eb32079..ccdce67b432 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -3,6 +3,10 @@ import { render, screen } from '@testing-library/react'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; +jest.mock('../../../lib/constants', () => ({ + webappUrl: '/', +})); + const highlights = [ { id: 'highlight-1', From 0121f42bc222a4a85c2902f3e516c0aaacc5421e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:55:18 +0300 Subject: [PATCH 04/10] chore: retrigger CI From bbfcba82bc9e0447ca60873031ece23419ec5e04 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:02:05 +0300 Subject: [PATCH 05/10] fix: update Feed test to match new highlight card markup --- packages/shared/src/components/Feed.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 3784b1e06d3..fc0700b5ba4 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -428,7 +428,7 @@ describe('Feed logged in', () => { await waitForNock(); expect(await screen.findByText('Happening Now')).toBeInTheDocument(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); - expect(screen.getByLabelText('Read all highlights')).toBeInTheDocument(); + expect(screen.getByText('Read all')).toBeInTheDocument(); }); it('should keep feedV2 highlights in the response order', async () => { From 487b1655a2bb7d4a7774fe376f21f4c43beee61c Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:01:20 +0300 Subject: [PATCH 06/10] feat: add digest subscribe CTA to channel highlight pages Show a bell icon above channel highlights to subscribe to the channel's digest. Single toggle: click follows + enables notifications if not subscribed, or disables notifications if already subscribed. Includes a skeleton placeholder to prevent layout shift while user state loads. --- .../src/components/highlights/DigestCTA.tsx | 108 ++++++++++++++++++ .../components/highlights/HighlightsPage.tsx | 16 ++- packages/shared/src/graphql/highlights.ts | 22 ++++ 3 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/components/highlights/DigestCTA.tsx diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx new file mode 100644 index 00000000000..af6ba4e7e2c --- /dev/null +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import type { ChannelDigestConfiguration } from '../../graphql/highlights'; +import type { Source } from '../../graphql/sources'; +import { useAuthContext } from '../../contexts/AuthContext'; +import useFeedSettings from '../../hooks/useFeedSettings'; +import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollow'; +import { useSourceActionsNotify } from '../../hooks/source/useSourceActionsNotify'; +import { AuthTriggers } from '../../lib/auth'; +import SourceActionsNotify from '../sources/SourceActions/SourceActionsNotify'; +import Link from '../utilities/Link'; + +const CTA_HEIGHT = 'h-10'; + +const DigestCTASkeleton = (): ReactElement => ( +
+
+
+
+); + +interface DigestCTAProps { + digest: ChannelDigestConfiguration; + displayName: string; +} + +export const DigestCTA = ({ + digest, + displayName, +}: DigestCTAProps): ReactElement | null => { + const { source } = digest; + const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings({ + enabled: isLoggedIn && !!source?.id, + }); + const { isFollowing, toggleFollow } = useSourceActionsFollow({ + source: source as Source, + }); + const { haveNotificationsOn, onNotify } = useSourceActionsNotify({ + source, + }); + + const isUserStateLoading = + !isAuthReady || (isLoggedIn && (isFeedSettingsLoading || !feedSettings)); + + const onToggleSubscription = useCallback(async () => { + if (!isLoggedIn) { + showLogin({ trigger: AuthTriggers.SourceSubscribe }); + return; + } + + if (haveNotificationsOn) { + await onNotify(); + return; + } + + if (!isFollowing) { + try { + await toggleFollow(); + } catch { + // already following, ignore + } + } + + await onNotify(); + }, [ + isLoggedIn, + showLogin, + haveNotificationsOn, + isFollowing, + toggleFollow, + onNotify, + ]); + + if (!source) { + return null; + } + + if (isUserStateLoading) { + return ; + } + + return ( +
+ + Get a {digest.frequency} digest of{' '} + + + {displayName} + + {' '} + news + + + { + event.preventDefault(); + event.stopPropagation(); + onToggleSubscription(); + }} + /> + +
+ ); +}; diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index b319c9aa273..2c66ff4b809 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -15,6 +15,7 @@ import { POST_HIGHLIGHTS_FEED_QUERY, } from '../../graphql/highlights'; import { Tab, TabContainer } from '../tabs/TabContainer'; +import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; const MAJOR_HEADLINES_LABEL = 'Headlines'; @@ -111,11 +112,16 @@ const ChannelTab = ({ const loading = isFetching && !data; return ( - + <> + {channel.digest && ( + + )} + + ); }; diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 5bd76e785ee..e52944b5d24 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -125,9 +125,21 @@ export const POST_HIGHLIGHTS_FEED_QUERY = gql` ${POST_HIGHLIGHT_FEED_FRAGMENT} `; +export interface ChannelDigestConfiguration { + frequency: string; + source?: { + id: string; + name: string; + image: string; + handle: string; + permalink: string; + }; +} + export interface ChannelConfiguration { channel: string; displayName: string; + digest?: ChannelDigestConfiguration | null; } export interface HighlightsPageData { @@ -151,6 +163,16 @@ export const HIGHLIGHTS_PAGE_QUERY = gql` channelConfigurations { channel displayName + digest { + frequency + source { + id + name + image + handle + permalink + } + } } } ${POST_HIGHLIGHT_FEED_FRAGMENT} From efd2d36439d91269e83efb9fc55c7a753a654a8f Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:26:38 +0300 Subject: [PATCH 07/10] fix: harden highlights page navigation --- .../src/components/FeedItemComponent.tsx | 46 ++++++++++++-- .../cards/highlight/HighlightCards.spec.tsx | 29 +++++++++ .../cards/highlight/HighlightGrid.tsx | 9 ++- .../cards/highlight/HighlightList.tsx | 9 ++- .../src/components/cards/highlight/common.tsx | 38 ++++++++++-- .../src/components/highlights/DigestCTA.tsx | 50 ++++++++++----- .../highlights/HighlightItem.spec.tsx | 47 ++++++++++++++ .../components/highlights/HighlightItem.tsx | 6 ++ .../components/highlights/HighlightsPage.tsx | 42 +++++-------- .../src/components/tabs/TabContainer.spec.tsx | 4 +- .../src/components/tabs/TabContainer.tsx | 13 ++-- packages/shared/src/graphql/highlights.ts | 31 ++++++++++ .../webapp/pages/highlights/[channel].tsx | 62 ++++++++++--------- packages/webapp/pages/highlights/index.tsx | 20 +----- 14 files changed, 298 insertions(+), 108 deletions(-) create mode 100644 packages/shared/src/components/highlights/HighlightItem.spec.tsx diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 2122891ac83..bc27a832a5d 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -10,7 +10,7 @@ import { isSocialTwitterPost, PostType } from '../graphql/posts'; import type { LoggedUser } from '../lib/user'; import useLogImpression from '../hooks/feed/useLogImpression'; import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; -import { Origin, TargetType } from '../lib/log'; +import { LogEvent, Origin, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; @@ -41,7 +41,7 @@ import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; -import { adLogEvent, feedLogExtra } from '../lib/feed'; +import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed'; import { useLogContext } from '../contexts/LogContext'; import { MarketingCtaVariant } from './marketingCta/common'; import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; @@ -55,7 +55,7 @@ import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; import { HighlightGrid } from './cards/highlight/HighlightGrid'; import { HighlightList } from './cards/highlight/HighlightList'; -import { getHighlightIdsKey } from '../graphql/highlights'; +import { getHighlightIds, getHighlightIdsKey } from '../graphql/highlights'; export type FeedItemComponentProps = { item: FeedItem; @@ -282,8 +282,46 @@ function FeedItemComponent({ shouldUseListFeedLayout || shouldUseListMode ? HighlightList : HighlightGrid; + const highlightIds = getHighlightIds(item.highlights); - return ; + return ( + { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + columns: virtualizedNumCards, + column, + row, + feedName, + ranking, + action: 'read_all_click', + count: item.highlights.length, + highlightIds, + feedMeta: item.feedMeta, + }), + ); + }} + onHighlightClick={(highlight, position) => { + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + columns: virtualizedNumCards, + column, + row, + feedName, + ranking, + action: 'highlight_click', + position, + count: item.highlights.length, + clickedHighlight: highlight, + highlightIds, + feedMeta: item.feedMeta, + }), + ); + }} + /> + ); } const { diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index ccdce67b432..3a8229622c2 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; @@ -38,6 +39,13 @@ describe('Highlight cards', () => { expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); expect(screen.getByText('Read all')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /the first highlight/i }), + ).toHaveAttribute('href', '/highlights?highlight=highlight-1'); + expect(screen.getByLabelText('Read all highlights')).toHaveAttribute( + 'href', + '/highlights?highlight=highlight-1', + ); }); it('should render the list card with highlight links', () => { @@ -47,4 +55,25 @@ describe('Highlight cards', () => { expect(screen.getByText('The second highlight')).toBeInTheDocument(); expect(screen.getByText('Read all')).toBeInTheDocument(); }); + + it('should trigger the highlight callbacks without blocking navigation', async () => { + const onHighlightClick = jest.fn(); + const onReadAllClick = jest.fn(); + + render( + , + ); + + await userEvent.click( + screen.getByRole('link', { name: /the first highlight/i }), + ); + await userEvent.click(screen.getByLabelText('Read all highlights')); + + expect(onHighlightClick).toHaveBeenCalledWith(highlights[0], 1); + expect(onReadAllClick).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx index 64f624bc987..86dfaa5305a 100644 --- a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx @@ -5,7 +5,7 @@ import type { HighlightCardProps } from './common'; import { HighlightCardContent } from './common'; export const HighlightGrid = forwardRef(function HighlightGrid( - { highlights }: HighlightCardProps, + { highlights, onHighlightClick, onReadAllClick }: HighlightCardProps, ref: Ref, ): ReactElement { return ( @@ -14,7 +14,12 @@ export const HighlightGrid = forwardRef(function HighlightGrid( data-testid="highlightItem" className="group flex h-full flex-col overflow-hidden !bg-surface-float hover:!bg-surface-float" > - + ); }); diff --git a/packages/shared/src/components/cards/highlight/HighlightList.tsx b/packages/shared/src/components/cards/highlight/HighlightList.tsx index 5ffd9c07cd4..2e244b19d2f 100644 --- a/packages/shared/src/components/cards/highlight/HighlightList.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightList.tsx @@ -5,7 +5,7 @@ import type { HighlightCardProps } from './common'; import { HighlightCardContent } from './common'; export const HighlightList = forwardRef(function HighlightList( - { highlights }: HighlightCardProps, + { highlights, onHighlightClick, onReadAllClick }: HighlightCardProps, ref: Ref, ): ReactElement { return ( @@ -14,7 +14,12 @@ export const HighlightList = forwardRef(function HighlightList( data-testid="highlightItem" className="group overflow-hidden !border-0 !border-t !border-border-subtlest-tertiary !bg-gradient-to-b !from-surface-float !to-background-default !px-4 !py-6" > - + ); }); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 665c0933981..5fc3d01359e 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -8,23 +8,36 @@ import Link from '../../utilities/Link'; export interface HighlightCardProps { highlights: PostHighlight[]; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; } const titleGradientClassName = 'feed-highlights-title-gradient'; const HIGHLIGHTS_URL = `${webappUrl}highlights`; +const getHighlightsUrl = (highlightId?: string): string => + highlightId ? `${HIGHLIGHTS_URL}?highlight=${highlightId}` : HIGHLIGHTS_URL; + const getHighlightUrl = (highlight: PostHighlight): string => - `${HIGHLIGHTS_URL}?highlight=${highlight.id}`; + getHighlightsUrl(highlight.id); const HighlightRow = ({ highlight, + index, + onHighlightClick, }: { highlight: PostHighlight; + index: number; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; }): ReactElement => { return ( - + onHighlightClick?.(highlight, index + 1)} + > {highlight.headline} @@ -40,6 +53,8 @@ const HighlightRow = ({ export const HighlightCardContent = ({ highlights, + onHighlightClick, + onReadAllClick, variant, }: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { const headerClassName = @@ -51,6 +66,7 @@ export const HighlightCardContent = ({ ? 'flex flex-col gap-2' : 'flex flex-1 flex-col gap-0 px-2.5 pb-1 pt-0'; const footerClassName = variant === 'list' ? 'pt-1.5' : 'px-1 pb-1'; + const firstHighlight = highlights[0]; return ( <> @@ -65,13 +81,23 @@ export const HighlightCardContent = ({
- {highlights.map((highlight) => ( - + {highlights.map((highlight, index) => ( + ))}
- - + + onReadAllClick?.()} + > Read all diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx index af6ba4e7e2c..267f1629140 100644 --- a/packages/shared/src/components/highlights/DigestCTA.tsx +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; import type { ChannelDigestConfiguration } from '../../graphql/highlights'; import type { Source } from '../../graphql/sources'; +import { SourceType } from '../../graphql/sources'; import { useAuthContext } from '../../contexts/AuthContext'; import useFeedSettings from '../../hooks/useFeedSettings'; import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollow'; @@ -24,17 +25,21 @@ interface DigestCTAProps { displayName: string; } -export const DigestCTA = ({ +interface DigestCTAContentProps extends DigestCTAProps { + source: Source; +} + +const DigestCTAContent = ({ digest, displayName, -}: DigestCTAProps): ReactElement | null => { - const { source } = digest; + source, +}: DigestCTAContentProps): ReactElement => { const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings({ - enabled: isLoggedIn && !!source?.id, + enabled: isLoggedIn, }); const { isFollowing, toggleFollow } = useSourceActionsFollow({ - source: source as Source, + source, }); const { haveNotificationsOn, onNotify } = useSourceActionsNotify({ source, @@ -55,11 +60,7 @@ export const DigestCTA = ({ } if (!isFollowing) { - try { - await toggleFollow(); - } catch { - // already following, ignore - } + await toggleFollow(); } await onNotify(); @@ -72,10 +73,6 @@ export const DigestCTA = ({ onNotify, ]); - if (!source) { - return null; - } - if (isUserStateLoading) { return ; } @@ -106,3 +103,28 @@ export const DigestCTA = ({
); }; + +export const DigestCTA = ({ + digest, + displayName, +}: DigestCTAProps): ReactElement | null => { + const source = digest.source + ? { + ...digest.source, + type: SourceType.Machine, + public: true, + } + : null; + + if (!source) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx new file mode 100644 index 00000000000..c641a65b9ec --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { PostHighlightFeed } from '../../graphql/highlights'; +import { HighlightItem } from './HighlightItem'; + +const scrollIntoView = jest.fn(); +const summary = 'A concise summary for the expanded highlight item.'; + +const highlight: PostHighlightFeed = { + id: 'highlight-1', + channel: 'agents', + headline: 'The first highlight', + highlightedAt: '2026-04-05T09:00:00.000Z', + post: { + id: 'post-1', + commentsPermalink: '/posts/post-1', + summary, + }, +}; + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); +}); + +beforeEach(() => { + scrollIntoView.mockClear(); +}); + +describe('HighlightItem', () => { + it('should expand when the route-driven default changes after mount', () => { + const { rerender } = render(); + + expect(screen.queryByText(summary)).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(summary)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /read more/i })).toHaveAttribute( + 'href', + '/posts/post-1', + ); + expect(scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 77081efa320..ff6a3766e71 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -20,6 +20,12 @@ export const HighlightItem = ({ const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); + useEffect(() => { + if (defaultExpanded) { + setExpanded(true); + } + }, [defaultExpanded]); + useEffect(() => { if (defaultExpanded && ref.current) { ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 2c66ff4b809..194be925d8e 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -2,17 +2,13 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { gqlClient } from '../../graphql/common'; import type { ChannelConfiguration, - HighlightsPageData, PostHighlightFeed, - PostHighlightsFeedData, } from '../../graphql/highlights'; import { - HIGHLIGHTS_PAGE_QUERY, - MAJOR_HEADLINES_MAX_FIRST, - POST_HIGHLIGHTS_FEED_QUERY, + channelHighlightsFeedQueryOptions, + highlightsPageQueryOptions, } from '../../graphql/highlights'; import { Tab, TabContainer } from '../tabs/TabContainer'; import { DigestCTA } from './DigestCTA'; @@ -22,8 +18,6 @@ const MAJOR_HEADLINES_LABEL = 'Headlines'; const SKELETON_COUNT = 5; const HIGHLIGHTS_BASE_URL = '/highlights'; -export const HIGHLIGHTS_PAGE_QUERY_KEY = ['highlights-page']; - const HighlightSkeleton = (): ReactElement => (
@@ -31,15 +25,20 @@ const HighlightSkeleton = (): ReactElement => (
); -const useChannelHighlights = (channel: string | null) => +const getSingleQueryParam = ( + param: string | string[] | undefined, +): string | undefined => { + if (!param) { + return undefined; + } + + return Array.isArray(param) ? param[0] : param; +}; + +const useChannelHighlights = (channel: string | undefined) => useQuery({ - queryKey: ['channel-highlights-feed', channel], - queryFn: () => - gqlClient.request(POST_HIGHLIGHTS_FEED_QUERY, { - channel, - }), + ...channelHighlightsFeedQueryOptions(channel ?? ''), enabled: !!channel, - staleTime: 60_000, }); interface HighlightFeedListProps { @@ -127,16 +126,9 @@ const ChannelTab = ({ export const HighlightsPage = (): ReactElement => { const router = useRouter(); - const channel = router.query.channel as string | undefined; - const expandedId = router.query.highlight as string | undefined; - const { data, isFetching } = useQuery({ - queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, - queryFn: () => - gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { - first: MAJOR_HEADLINES_MAX_FIRST, - }), - staleTime: 60_000, - }); + const channel = getSingleQueryParam(router.query.channel); + const expandedId = getSingleQueryParam(router.query.highlight); + const { data, isFetching } = useQuery(highlightsPageQueryOptions()); const channels = data?.channelConfigurations ?? []; const majorHeadlines = useMemo( diff --git a/packages/shared/src/components/tabs/TabContainer.spec.tsx b/packages/shared/src/components/tabs/TabContainer.spec.tsx index 4851a110959..bebd665d5ca 100644 --- a/packages/shared/src/components/tabs/TabContainer.spec.tsx +++ b/packages/shared/src/components/tabs/TabContainer.spec.tsx @@ -109,7 +109,9 @@ describe('tab container component', () => { const first = await screen.findByText('First'); fireEvent.click(first); - expect(routerPush).toHaveBeenCalledWith('/first'); + expect(routerPush).toHaveBeenCalledWith('/first', undefined, { + shallow: false, + }); }); }); }); diff --git a/packages/shared/src/components/tabs/TabContainer.tsx b/packages/shared/src/components/tabs/TabContainer.tsx index a19682d1f4b..29f310f438d 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -13,6 +13,8 @@ import type { AllowedTabTags, TabListProps } from './TabList'; import TabList from './TabList'; import type { RenderTab } from './common'; +const getRouterPathname = (path?: string): string => path?.split('?')[0] ?? ''; + export interface TabProps { children?: ReactNode; label: T; @@ -82,6 +84,7 @@ export function TabContainer({ const router = useRouter(); const containerRef = useRef(null); const tabs = useMemo(() => children ?? [], [children]); + const currentPath = getRouterPathname(router.asPath || router.pathname); const [active, setActive] = useState(() => { if (!tabs.length) { @@ -91,7 +94,7 @@ export function TabContainer({ const defaultLabel = tabs[0].props.label; if (tabs[0].props.url) { - const matchingChild = tabs.find((c) => c.props.url === router.pathname); + const matchingChild = tabs.find((c) => c.props.url === currentPath); return matchingChild ? matchingChild.props.label : defaultLabel; } @@ -102,11 +105,7 @@ export function TabContainer({ const navigateToUrl = useCallback( (url: string) => { - if (shallow) { - router.replace(url, undefined, { shallow: true }); - } else { - router.push(url); - } + router.push(url, undefined, { shallow }); }, [router, shallow], ); @@ -176,7 +175,7 @@ export function TabContainer({ } if (url) { - return router.asPath === url; + return currentPath === url; } return label === currentActive; diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index e52944b5d24..ef9af389947 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -31,6 +31,7 @@ export interface MajorHeadlinesData { } const ONE_MINUTE = 60 * 1000; +export const HIGHLIGHTS_PAGE_QUERY_KEY = ['highlights-page']; type HighlightIdentity = Pick; @@ -116,6 +117,11 @@ export interface PostHighlightsFeedData { postHighlights: PostHighlightFeed[]; } +export const getChannelHighlightsFeedQueryKey = (channel: string) => [ + 'channel-highlights-feed', + channel, +]; + export const POST_HIGHLIGHTS_FEED_QUERY = gql` query PostHighlightsFeed($channel: String!) { postHighlights(channel: $channel) { @@ -177,3 +183,28 @@ export const HIGHLIGHTS_PAGE_QUERY = gql` } ${POST_HIGHLIGHT_FEED_FRAGMENT} `; + +export const highlightsPageQueryOptions = ({ + first = MAJOR_HEADLINES_MAX_FIRST, + after, +}: { + first?: number; + after?: string; +} = {}) => ({ + queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, + queryFn: () => + gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { + first, + after, + }), + staleTime: ONE_MINUTE, +}); + +export const channelHighlightsFeedQueryOptions = (channel: string) => ({ + queryKey: getChannelHighlightsFeedQueryKey(channel), + queryFn: () => + gqlClient.request(POST_HIGHLIGHTS_FEED_QUERY, { + channel, + }), + staleTime: ONE_MINUTE, +}); diff --git a/packages/webapp/pages/highlights/[channel].tsx b/packages/webapp/pages/highlights/[channel].tsx index 7c73cc27f87..a825e72b694 100644 --- a/packages/webapp/pages/highlights/[channel].tsx +++ b/packages/webapp/pages/highlights/[channel].tsx @@ -3,24 +3,16 @@ import type { GetStaticPropsContext, GetStaticPropsResult, } from 'next'; +import type { ParsedUrlQuery } from 'querystring'; import type { ReactElement } from 'react'; import React from 'react'; import type { DehydratedState } from '@tanstack/react-query'; import { dehydrate, QueryClient } from '@tanstack/react-query'; -import type { - HighlightsPageData, - PostHighlightsFeedData, -} from '@dailydotdev/shared/src/graphql/highlights'; import { - HIGHLIGHTS_PAGE_QUERY, - MAJOR_HEADLINES_MAX_FIRST, - POST_HIGHLIGHTS_FEED_QUERY, + channelHighlightsFeedQueryOptions, + highlightsPageQueryOptions, } from '@dailydotdev/shared/src/graphql/highlights'; -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { - HighlightsPage, - HIGHLIGHTS_PAGE_QUERY_KEY, -} from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; +import { HighlightsPage } from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; @@ -56,6 +48,10 @@ interface HighlightsChannelPageProps { dehydratedState: DehydratedState; } +interface HighlightsChannelPageParams extends ParsedUrlQuery { + channel: string; +} + export async function getStaticPaths(): Promise { return { paths: [], @@ -65,28 +61,34 @@ export async function getStaticPaths(): Promise { export async function getStaticProps({ params, -}: GetStaticPropsContext): Promise< +}: GetStaticPropsContext): Promise< GetStaticPropsResult > { - const channel = params?.channel as string; + const channel = params?.channel; + + if (!channel) { + return { + notFound: true, + revalidate: 60, + }; + } + const queryClient = new QueryClient(); + const highlightsPage = await queryClient.fetchQuery( + highlightsPageQueryOptions(), + ); + const isKnownChannel = highlightsPage.channelConfigurations.some( + ({ channel: configuredChannel }) => configuredChannel === channel, + ); + + if (!isKnownChannel) { + return { + notFound: true, + revalidate: 60, + }; + } - await Promise.all([ - queryClient.prefetchQuery({ - queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, - queryFn: () => - gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { - first: MAJOR_HEADLINES_MAX_FIRST, - }), - }), - queryClient.prefetchQuery({ - queryKey: ['channel-highlights-feed', channel], - queryFn: () => - gqlClient.request(POST_HIGHLIGHTS_FEED_QUERY, { - channel, - }), - }), - ]); + await queryClient.prefetchQuery(channelHighlightsFeedQueryOptions(channel)); return { props: { diff --git a/packages/webapp/pages/highlights/index.tsx b/packages/webapp/pages/highlights/index.tsx index 3bf3ff5cfe0..6087101d17d 100644 --- a/packages/webapp/pages/highlights/index.tsx +++ b/packages/webapp/pages/highlights/index.tsx @@ -3,16 +3,8 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { DehydratedState } from '@tanstack/react-query'; import { dehydrate, QueryClient } from '@tanstack/react-query'; -import type { HighlightsPageData } from '@dailydotdev/shared/src/graphql/highlights'; -import { - HIGHLIGHTS_PAGE_QUERY, - MAJOR_HEADLINES_MAX_FIRST, -} from '@dailydotdev/shared/src/graphql/highlights'; -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { - HighlightsPage, - HIGHLIGHTS_PAGE_QUERY_KEY, -} from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; +import { highlightsPageQueryOptions } from '@dailydotdev/shared/src/graphql/highlights'; +import { HighlightsPage } from '@dailydotdev/shared/src/components/highlights/HighlightsPage'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; @@ -55,13 +47,7 @@ export async function getStaticProps(): Promise< > { const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ - queryKey: HIGHLIGHTS_PAGE_QUERY_KEY, - queryFn: () => - gqlClient.request(HIGHLIGHTS_PAGE_QUERY, { - first: MAJOR_HEADLINES_MAX_FIRST, - }), - }); + await queryClient.prefetchQuery(highlightsPageQueryOptions()); return { props: { From 3ecce9f4795922546600c6ead9156e240986dc71 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:33:28 +0300 Subject: [PATCH 08/10] Apply suggestion from @idoshamun --- packages/shared/src/components/tabs/TabContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/tabs/TabContainer.tsx b/packages/shared/src/components/tabs/TabContainer.tsx index 29f310f438d..5629467d4ff 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -164,7 +164,7 @@ export function TabContainer({ onSwipedLeft: () => navigateTab('next'), onSwipedRight: () => navigateTab('previous'), trackTouch: true, - delta: 10, + delta: 40, }); const isTabActive = ({ From a9711d46a64e68a8fc469639258ed9d409f54cf2 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:36:33 +0300 Subject: [PATCH 09/10] fix: address highlights review feedback --- packages/shared/src/components/highlights/DigestCTA.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx index 267f1629140..b38554f701d 100644 --- a/packages/shared/src/components/highlights/DigestCTA.tsx +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -35,9 +35,7 @@ const DigestCTAContent = ({ source, }: DigestCTAContentProps): ReactElement => { const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); - const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings({ - enabled: isLoggedIn, - }); + const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings(); const { isFollowing, toggleFollow } = useSourceActionsFollow({ source, }); From 0172cc9cae424340252ee692417f0aa202e18def Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:46:06 +0300 Subject: [PATCH 10/10] refactor: remove some mess --- .../src/components/highlights/DigestCTA.tsx | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx index b38554f701d..86cf7d23d96 100644 --- a/packages/shared/src/components/highlights/DigestCTA.tsx +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -7,7 +7,6 @@ import { useAuthContext } from '../../contexts/AuthContext'; import useFeedSettings from '../../hooks/useFeedSettings'; import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollow'; import { useSourceActionsNotify } from '../../hooks/source/useSourceActionsNotify'; -import { AuthTriggers } from '../../lib/auth'; import SourceActionsNotify from '../sources/SourceActions/SourceActionsNotify'; import Link from '../utilities/Link'; @@ -34,7 +33,7 @@ const DigestCTAContent = ({ displayName, source, }: DigestCTAContentProps): ReactElement => { - const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + const { isAuthReady, isLoggedIn } = useAuthContext(); const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings(); const { isFollowing, toggleFollow } = useSourceActionsFollow({ source, @@ -47,29 +46,12 @@ const DigestCTAContent = ({ !isAuthReady || (isLoggedIn && (isFeedSettingsLoading || !feedSettings)); const onToggleSubscription = useCallback(async () => { - if (!isLoggedIn) { - showLogin({ trigger: AuthTriggers.SourceSubscribe }); - return; - } - - if (haveNotificationsOn) { - await onNotify(); - return; - } - if (!isFollowing) { - await toggleFollow(); + toggleFollow(); } await onNotify(); - }, [ - isLoggedIn, - showLogin, - haveNotificationsOn, - isFollowing, - toggleFollow, - onNotify, - ]); + }, [isFollowing, toggleFollow, onNotify]); if (isUserStateLoading) { return ;