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 () => { diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 243de76c3df..bc27a832a5d 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'; @@ -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 { getHighlightIds, 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, @@ -294,90 +283,44 @@ function FeedItemComponent({ ? 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); - }} - /> - + { + 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, + }), + ); + }} + /> ); } diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 1172789023f..3a8229622c2 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -4,6 +4,10 @@ import userEvent from '@testing-library/user-event'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; +jest.mock('../../../lib/constants', () => ({ + webappUrl: '/', +})); + const highlights = [ { id: 'highlight-1', @@ -28,7 +32,31 @@ const highlights = [ ]; describe('Highlight cards', () => { - it('should render the grid card and trigger highlight actions', async () => { + it('should render the grid card with highlight links', () => { + render(); + + expect(screen.getByText('Happening Now')).toBeInTheDocument(); + 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', () => { + render(); + + expect(screen.getByText('The first highlight')).toBeInTheDocument(); + 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(); @@ -40,21 +68,12 @@ describe('Highlight cards', () => { />, ); - expect(screen.getByText('Happening Now')).toBeInTheDocument(); - await userEvent.click( - screen.getByRole('button', { name: /the first highlight/i }), + 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); }); - - it('should render the list card', () => { - render(); - - expect(screen.getByText('The second highlight')).toBeInTheDocument(); - expect(screen.getByLabelText('Read all highlights')).toBeInTheDocument(); - }); }); diff --git a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx index e1d02e1d7e1..86dfaa5305a 100644 --- a/packages/shared/src/components/cards/highlight/HighlightGrid.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightGrid.tsx @@ -1,12 +1,8 @@ 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, @@ -16,14 +12,7 @@ export const HighlightGrid = forwardRef(function HighlightGrid( 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; - } +const getHighlightsUrl = (highlightId?: string): string => + highlightId ? `${HIGHLIGHTS_URL}?highlight=${highlightId}` : HIGHLIGHTS_URL; - event.preventDefault(); - onReadAllClick(); - }, - }; -}; +const getHighlightUrl = (highlight: PostHighlight): string => + getHighlightsUrl(highlight.id); const HighlightRow = ({ highlight, @@ -45,23 +32,22 @@ const HighlightRow = ({ onHighlightClick?: (highlight: PostHighlight, position: number) => void; }): ReactElement => { return ( - + + onHighlightClick?.(highlight, index + 1)} + > + + {highlight.headline} + + + + ); }; @@ -80,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 ( <> @@ -104,28 +91,27 @@ export const HighlightCardContent = ({ ))}
- + + Read all + + + → + + +
); diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx new file mode 100644 index 00000000000..86cf7d23d96 --- /dev/null +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -0,0 +1,110 @@ +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'; +import { useSourceActionsNotify } from '../../hooks/source/useSourceActionsNotify'; +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; +} + +interface DigestCTAContentProps extends DigestCTAProps { + source: Source; +} + +const DigestCTAContent = ({ + digest, + displayName, + source, +}: DigestCTAContentProps): ReactElement => { + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings(); + const { isFollowing, toggleFollow } = useSourceActionsFollow({ + source, + }); + const { haveNotificationsOn, onNotify } = useSourceActionsNotify({ + source, + }); + + const isUserStateLoading = + !isAuthReady || (isLoggedIn && (isFeedSettingsLoading || !feedSettings)); + + const onToggleSubscription = useCallback(async () => { + if (!isFollowing) { + toggleFollow(); + } + + await onNotify(); + }, [isFollowing, toggleFollow, onNotify]); + + if (isUserStateLoading) { + return ; + } + + return ( +
+ + Get a {digest.frequency} digest of{' '} + + + {displayName} + + {' '} + news + + + { + event.preventDefault(); + event.stopPropagation(); + onToggleSubscription(); + }} + /> + +
+ ); +}; + +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 new file mode 100644 index 00000000000..ff6a3766e71 --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -0,0 +1,87 @@ +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) { + setExpanded(true); + } + }, [defaultExpanded]); + + 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..194be925d8e --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -0,0 +1,188 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import type { + ChannelConfiguration, + PostHighlightFeed, +} from '../../graphql/highlights'; +import { + channelHighlightsFeedQueryOptions, + highlightsPageQueryOptions, +} from '../../graphql/highlights'; +import { Tab, TabContainer } from '../tabs/TabContainer'; +import { DigestCTA } from './DigestCTA'; +import { HighlightItem } from './HighlightItem'; + +const MAJOR_HEADLINES_LABEL = 'Headlines'; +const SKELETON_COUNT = 5; +const HIGHLIGHTS_BASE_URL = '/highlights'; + +const HighlightSkeleton = (): ReactElement => ( +
+
+
+
+); + +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({ + ...channelHighlightsFeedQueryOptions(channel ?? ''), + enabled: !!channel, + }); + +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 ( + <> + {channel.digest && ( + + )} + + + ); +}; + +export const HighlightsPage = (): ReactElement => { + const router = useRouter(); + const channel = getSingleQueryParam(router.query.channel); + const expandedId = getSingleQueryParam(router.query.highlight); + const { data, isFetching } = useQuery(highlightsPageQueryOptions()); + + 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.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 3fad5ef2aaa..5629467d4ff 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -1,11 +1,20 @@ 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'; +const getRouterPathname = (path?: string): string => path?.split('?')[0] ?? ''; + export interface TabProps { children?: ReactNode; label: T; @@ -40,7 +49,7 @@ export interface TabContainerProps { controlledActive?: T; onActiveChange?: ( active: T, - event: React.MouseEvent, + event?: React.MouseEvent, ) => boolean | void; renderTab?: RenderTab; shouldFocusTabOnChange?: boolean; @@ -48,6 +57,8 @@ export interface TabContainerProps { showBorder?: boolean; showHeader?: boolean; style?: CSSProperties; + shallow?: boolean; + swipeable?: boolean; tabListProps?: Pick; tabTag?: AllowedTabTags; extraHeaderContent?: ReactNode; @@ -64,20 +75,26 @@ export function TabContainer({ showBorder = true, showHeader = true, style, + shallow = false, + swipeable = false, tabListProps = {}, tabTag, extraHeaderContent, }: TabContainerProps): ReactElement { const router = useRouter(); const containerRef = useRef(null); + const tabs = useMemo(() => children ?? [], [children]); + const currentPath = getRouterPathname(router.asPath || router.pathname); 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 === currentPath); return matchingChild ? matchingChild.props.label : defaultLabel; } @@ -85,10 +102,18 @@ 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) => { + router.push(url, undefined, { shallow }); + }, + [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) { @@ -102,16 +127,55 @@ export function TabContainer({ }, 0); if (child?.props?.url) { - router.push(child.props.url); + navigateToUrl(child.props.url); } } }; + const labels = useMemo(() => tabs.map((c) => c.props.label), [tabs]); + + 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 = tabs[nextIndex]; + if (!nextChild) { + return; + } + + const nextLabel = nextChild.props.label; + setActive(nextLabel); + onActiveChange?.(nextLabel, undefined); + + if (nextChild.props.url) { + navigateToUrl(nextChild.props.url); + } + }, + [tabs, currentActive, labels, navigateToUrl, onActiveChange], + ); + + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => navigateTab('next'), + onSwipedRight: () => navigateTab('previous'), + trackTouch: true, + delta: 40, + }); + const isTabActive = ({ props: { url, label }, }: ReactElement>) => { + if (controlledActive) { + return label === currentActive; + } + if (url) { - return router.pathname === url; + return currentPath === url; } return label === currentActive; @@ -119,7 +183,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); } @@ -129,7 +197,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, @@ -158,7 +226,7 @@ export function TabContainer({ )} > - items={children.map(({ props }) => ({ + items={tabs.map(({ props }) => ({ label: props.label, url: props.url, hint: props.hint, @@ -172,7 +240,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..c509c5ca81f 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', }); } @@ -78,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; @@ -143,7 +147,7 @@ function TabList({ if (isAnchor) { event.preventDefault(); } - onClick(label, event); + onClick?.(label, event); }} {...(isAnchor ? { diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index e5022988361..ef9af389947 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -13,11 +13,25 @@ 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; } const ONE_MINUTE = 60 * 1000; +export const HIGHLIGHTS_PAGE_QUERY_KEY = ['highlights-page']; type HighlightIdentity = Pick; @@ -83,3 +97,114 @@ 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 getChannelHighlightsFeedQueryKey = (channel: string) => [ + 'channel-highlights-feed', + channel, +]; + +export const POST_HIGHLIGHTS_FEED_QUERY = gql` + query PostHighlightsFeed($channel: String!) { + postHighlights(channel: $channel) { + ...PostHighlightFeedCard + } + } + ${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 { + 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 + digest { + frequency + source { + id + name + image + handle + permalink + } + } + } + } + ${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 new file mode 100644 index 00000000000..a825e72b694 --- /dev/null +++ b/packages/webapp/pages/highlights/[channel].tsx @@ -0,0 +1,99 @@ +import type { + GetStaticPathsResult, + 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 { + channelHighlightsFeedQueryOptions, + 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'; + +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; +} + +interface HighlightsChannelPageParams extends ParsedUrlQuery { + channel: string; +} + +export async function getStaticPaths(): Promise { + return { + paths: [], + fallback: 'blocking', + }; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise< + GetStaticPropsResult +> { + 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 queryClient.prefetchQuery(channelHighlightsFeedQueryOptions(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..6087101d17d --- /dev/null +++ b/packages/webapp/pages/highlights/index.tsx @@ -0,0 +1,58 @@ +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 { 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'; + +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(highlightsPageQueryOptions()); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + revalidate: 60, + }; +}