diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000000..27336c64173 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# Enables CodeRabbit early-access / preview capabilities for PR reviews on this branch. +language: "en-US" +early_access: true diff --git a/packages/playwright/.gitignore b/packages/playwright/.gitignore index d60ce5cc826..e356bfc739a 100644 --- a/packages/playwright/.gitignore +++ b/packages/playwright/.gitignore @@ -4,3 +4,4 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +/exports/ diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx index ba228d7fe10..13fa3a15687 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx @@ -29,6 +29,8 @@ export type BriefCardProps = { container: string; card: string; }>; + showCloseButton?: boolean; + showBorder?: boolean; animationSrc?: string; progressPercentage?: number; headnote?: ReactNode; @@ -248,6 +250,7 @@ export const BriefCardInternal = ( We sent an AI agent to read the entire internet. Every release, every hot take, and every unreadable blog post from the past week. It's diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx index d87c57cf27a..c3a02548f86 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx @@ -9,12 +9,8 @@ import { } from '../../../typography/Typography'; import { briefButtonBg, - briefCardBg, - briefCardBorder, } from '../../../../styles/custom'; import type { BriefCardProps } from './BriefCard'; -import { BriefGradientIcon } from '../../../icons'; -import { IconSize } from '../../../Icon'; import { Button, ButtonSize, ButtonVariant } from '../../../buttons/Button'; import { BriefingType, @@ -32,15 +28,12 @@ import { LogEvent, TargetType } from '../../../../lib/log'; export type BriefCardDefaultProps = BriefCardProps; -const rootStyle = { - border: briefCardBorder, - background: briefCardBg, -}; - export const BriefCardDefault = ({ className, title, children, + showCloseButton = true, + showBorder = true, }: BriefCardDefaultProps): ReactElement => { const briefContext = useBriefContext(); const { displayToast } = useToastNotification(); @@ -98,23 +91,32 @@ export const BriefCardDefault = ({ return (
- + )} + - {title} @@ -123,7 +125,7 @@ export const BriefCardDefault = ({ style={{ background: briefButtonBg, }} - className="mt-auto w-full text-black" + className="brief-card-cta-gradient mt-auto w-full text-black" tag="a" type="button" variant={ButtonVariant.Primary} diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx index 9724ce93273..441f55c6984 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx @@ -4,7 +4,10 @@ import type { BriefCardProps } from './BriefCard'; import { BriefCard } from './BriefCard'; export const BriefCardFeed = ( - props: Pick, + props: Pick< + BriefCardProps, + 'targetId' | 'className' | 'showCloseButton' | 'showBorder' + >, ): ReactElement => { return ; }; diff --git a/packages/shared/src/components/cards/brief/BriefContext.tsx b/packages/shared/src/components/cards/brief/BriefContext.tsx index f45ce21dfa3..22dcef0d9a4 100644 --- a/packages/shared/src/components/cards/brief/BriefContext.tsx +++ b/packages/shared/src/components/cards/brief/BriefContext.tsx @@ -15,9 +15,10 @@ type BriefContext = { const [BriefContextProvider, useBriefContext] = createContextProvider( (): BriefContext => { const { user } = useAuthContext(); + const persistentBriefKey = `brief_card_${user?.id ?? 'anonymous'}_v3`; const [brief, setBrief] = usePersistentState( - `brief_card_${user.id}_v3`, + persistentBriefKey, undefined, ); diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx index 5c65c3285c7..a7985a1935b 100644 --- a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; import Link from '../../../utilities/Link'; import { FeedSettingsEditContext } from '../FeedSettingsEditContext'; @@ -21,6 +21,12 @@ import useProfileForm from '../../../../hooks/useProfileForm'; import { FeedType } from '../../../../graphql/feed'; import { usePlusSubscription } from '../../../../hooks'; import { Tooltip } from '../../../tooltip/Tooltip'; +import { Radio } from '../../../fields/Radio'; +import { + ExploreLayoutPreference, + getExploreLayoutPreference, + setExploreLayoutPreference, +} from '../../../../lib/exploreLayoutPreference'; export const FeedSettingsGeneralSection = (): ReactElement => { const { setData, data, feed, onDelete, editFeedSettings } = useContext( @@ -31,6 +37,13 @@ export const FeedSettingsGeneralSection = (): ReactElement => { const isMainFeed = feed?.type === FeedType.Main; const isCustomFeed = feed?.type === FeedType.Custom; const { isPlus } = usePlusSubscription(); + const [layoutPreference, setLayoutPreference] = useState( + ExploreLayoutPreference.New, + ); + + useEffect(() => { + setLayoutPreference(getExploreLayoutPreference()); + }, []); const isDefaultFeed = isMainFeed ? user.defaultFeedId === null @@ -167,6 +180,39 @@ export const FeedSettingsGeneralSection = (): ReactElement => { )}
+ {isMainFeed && ( +
+
+ + Feed layout + + + Choose your feed layout + +
+ { + setLayoutPreference(value); + setExploreLayoutPreference(value); + }} + /> +
+ )} {isCustomFeed && ( <> diff --git a/packages/shared/src/components/filters/AchievementTrackerButton.tsx b/packages/shared/src/components/filters/AchievementTrackerButton.tsx index c1cea894ac5..3f4b5ee6e6b 100644 --- a/packages/shared/src/components/filters/AchievementTrackerButton.tsx +++ b/packages/shared/src/components/filters/AchievementTrackerButton.tsx @@ -41,7 +41,13 @@ function AchievementIcon({ ); } -export function AchievementTrackerButton(): ReactElement | null { +export function AchievementTrackerButton({ + size = ButtonSize.Medium, + variant, +}: { + size?: ButtonSize; + variant?: ButtonVariant; +} = {}): ReactElement | null { const { openModal, closeModal } = useLazyModal(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); @@ -99,11 +105,7 @@ export function AchievementTrackerButton(): ReactElement | null { return trackedAchievement.achievement?.unit; } - const { unit } = trackedAchievement.achievement; - - return unit - ? `${progressValue} of ${targetCount} ${unit}` - : `${progressValue} of ${targetCount}`; + return `${progressValue}/${targetCount}`; })(); const hasButtonLabel = !!buttonLabel; @@ -162,8 +164,8 @@ export function AchievementTrackerButton(): ReactElement | null { const buttonContent = (
); diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index dc1fe8f4006..04847ab8341 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -1,11 +1,10 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; -import { addDays, subDays } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import classNames from 'classnames'; import { useRouter } from 'next/router'; import { StreakSection } from './StreakSection'; -import { DayStreak, Streak } from './DayStreak'; +import { DayStreak } from './DayStreak'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import type { ReadingDay, UserStreak } from '../../../graphql/users'; import { getReadingStreak30Days } from '../../../graphql/users'; @@ -14,12 +13,13 @@ import { useActions, useViewSize, ViewSize } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { SettingsIcon, VIcon, WarningIcon } from '../../icons'; -import { isWeekend, DayOfWeek } from '../../../lib/date'; +import { DayOfWeek } from '../../../lib/date'; import { DEFAULT_TIMEZONE, getTimezoneOffsetLabel, isSameDayInTimezone, } from '../../../lib/timezones'; +import { getStreak, getStreakDays } from '../readingStreakWeekHelpers'; import { timezoneSettingsUrl, webappUrl } from '../../../lib/constants'; import { useStreakTimezoneOk } from '../../../hooks/streaks/useStreakTimezoneOk'; import { usePrompt } from '../../../hooks/usePrompt'; @@ -48,60 +48,6 @@ import { usePushNotificationContext } from '../../../contexts/PushNotificationCo import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; -const getStreak = ({ - value, - today, - history, - startOfWeek = DayOfWeek.Monday, - timezone, -}: { - value: Date; - today: Date; - history?: ReadingDay[]; - startOfWeek?: number; - timezone?: string; -}): Streak => { - const isFreezeDay = isWeekend(value, startOfWeek, timezone); - const isToday = isSameDayInTimezone(value, today, timezone); - const isFuture = value > today; - const isCompleted = - !isFuture && - history?.some(({ date: historyDate, reads }) => { - const dateToCompare = new Date(historyDate); - const sameDate = isSameDayInTimezone(dateToCompare, value, timezone); - - return sameDate && reads > 0; - }); - - if (isCompleted) { - return Streak.Completed; - } - - if (isFreezeDay) { - return Streak.Freeze; - } - - if (isToday) { - return Streak.Pending; - } - - return Streak.Upcoming; -}; - -const getStreakDays = (today: Date) => { - return [ - subDays(today, 4), - subDays(today, 3), - subDays(today, 2), - subDays(today, 1), - today, - addDays(today, 1), - addDays(today, 2), - addDays(today, 3), - addDays(today, 4), - ]; // these dates will then be compared to the user's post views -}; - interface ReadingStreakPopupProps { streak: UserStreak; fullWidth?: boolean; diff --git a/packages/shared/src/components/streak/readingStreakWeekHelpers.ts b/packages/shared/src/components/streak/readingStreakWeekHelpers.ts new file mode 100644 index 00000000000..52f231fe8d1 --- /dev/null +++ b/packages/shared/src/components/streak/readingStreakWeekHelpers.ts @@ -0,0 +1,64 @@ +import { addDays, subDays } from 'date-fns'; +import type { ReadingDay } from '../../graphql/users'; +import { isWeekend, DayOfWeek } from '../../lib/date'; +import { isSameDayInTimezone } from '../../lib/timezones'; +import { Streak } from './popup/DayStreak'; + +export const getStreak = ({ + value, + today, + history, + startOfWeek = DayOfWeek.Monday, + timezone, +}: { + value: Date; + today: Date; + history?: ReadingDay[]; + startOfWeek?: number; + timezone?: string; +}): Streak => { + const isFreezeDay = isWeekend(value, startOfWeek, timezone); + const isToday = isSameDayInTimezone(value, today, timezone); + const isFuture = value > today; + const isCompleted = + !isFuture && + history?.some(({ date: historyDate, reads }) => { + const dateToCompare = new Date(historyDate); + const sameDate = isSameDayInTimezone(dateToCompare, value, timezone); + + return sameDate && reads > 0; + }); + + if (isCompleted) { + return Streak.Completed; + } + + if (isFreezeDay) { + return Streak.Freeze; + } + + if (isToday) { + return Streak.Pending; + } + + return Streak.Upcoming; +}; + +export const getStreakDays = (today: Date): Date[] => { + return [ + subDays(today, 4), + subDays(today, 3), + subDays(today, 2), + subDays(today, 1), + today, + addDays(today, 1), + addDays(today, 2), + addDays(today, 3), + addDays(today, 4), + ]; +}; + +/** Last seven calendar days ending on `today` (for compact strips). */ +export const getLastSevenDays = (today: Date): Date[] => { + return Array.from({ length: 7 }, (_, index) => subDays(today, 6 - index)); +}; diff --git a/packages/shared/src/components/widgets/BestDiscussions.tsx b/packages/shared/src/components/widgets/BestDiscussions.tsx index 125e6c689f2..99ffbbd130b 100644 --- a/packages/shared/src/components/widgets/BestDiscussions.tsx +++ b/packages/shared/src/components/widgets/BestDiscussions.tsx @@ -54,7 +54,10 @@ const ListItem = ({ post, onLinkClick }: PostProps): ReactElement => ( {post.title}
-
{post.numComments} Comments
+
+ {post.numComments}{' '} + Comments +
); diff --git a/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx index 995fa6c13e8..79838588445 100644 --- a/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx +++ b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx @@ -128,7 +128,7 @@ const HighlightCard = ({ return (
+
Live Highlights @@ -327,7 +327,7 @@ export const ArenaHighlightsFeed = ({ diff --git a/packages/shared/src/features/agents/arena/ArenaRankings.tsx b/packages/shared/src/features/agents/arena/ArenaRankings.tsx index 585ccab5467..0d57d07e55c 100644 --- a/packages/shared/src/features/agents/arena/ArenaRankings.tsx +++ b/packages/shared/src/features/agents/arena/ArenaRankings.tsx @@ -470,7 +470,7 @@ export const ArenaRankings = ({ 'overflow-hidden', compact ? 'bg-background-default' - : 'rounded-16 border border-border-subtlest-tertiary bg-background-subtle', + : 'min-w-[42rem] rounded-16 border border-border-subtlest-tertiary', )} > {/* Header */} diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index ecae3b87b12..52c81f9ba4a 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -16,6 +16,7 @@ import { FolderIcon, HammerIcon, LanguageIcon, + LinkIcon, MenuIcon as RawMenuIcon, MiniCloseIcon, MinusIcon, @@ -102,6 +103,8 @@ import type { FeedItem } from '../../hooks/useFeed'; import { isBoostedPostAd } from '../../hooks/useFeed'; import type { MenuItemProps } from '../../components/dropdown/common'; import { useFeedContentTypeAction } from '../../components/filters/useFeedContentTypeAction'; +import { useLoggedCopyPostLink } from '../../hooks/post/useLoggedCopyPostLink'; +import { ShareProvider } from '../../lib/share'; const getBlockLabel = ( name: string, @@ -189,6 +192,10 @@ const PostOptionButtonContent = ({ const { boostedBy } = useFeedCardContext(); const { hidePost, unhidePost } = useReportPost(); const { openSharePost } = useSharePost(origin); + const { onCopyLink, isLoading: isCopyingLink } = useLoggedCopyPostLink( + post, + Origin.PostContextMenu, + ); const { follow, unfollow, unblock, block } = useContentPreference(); const { openModal } = useLazyModal(); const { showPrompt } = usePrompt(); @@ -467,6 +474,12 @@ const PostOptionButtonContent = ({ ...logOpts, }), }, + { + icon: , + label: 'Copy link', + action: () => onCopyLink(ShareProvider.CopyLink), + disabled: isCopyingLink, + }, ]; if (canViewPostAnalytics({ user, post })) { diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index d4b9285b48a..9261c86ca2d 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -256,6 +256,9 @@ export const FEED_POST_INFO_FRAGMENT = gql` } coresRole } + scout { + ...UserAuthor + } type subType tags @@ -308,6 +311,7 @@ export const FEED_POST_INFO_FRAGMENT = gql` } endsAt } + ${USER_AUTHOR_FRAGMENT} ${POST_TRANSLATEABLE_FIELDS_FRAGMENT} `; @@ -592,7 +596,11 @@ export const FEED_POST_FRAGMENT = gql` } author { id + name + image username + permalink + reputation } slug clickbaitTitleDetected @@ -606,6 +614,7 @@ export const FEED_POST_FRAGMENT = gql` collectionSources { handle image + name } numCollectionSources updatedAt diff --git a/packages/shared/src/hooks/translation/useTranslation.ts b/packages/shared/src/hooks/translation/useTranslation.ts index 588e5f37d57..6a1a0af6882 100644 --- a/packages/shared/src/hooks/translation/useTranslation.ts +++ b/packages/shared/src/hooks/translation/useTranslation.ts @@ -30,6 +30,12 @@ type UseTranslation = (props: { queryKey?: QueryKey; queryType?: 'post' | 'feed'; clickbaitShieldEnabled?: boolean; + /** + * Skip the same-language filter so that smart-title fetching is attempted + * even when the post is already in the user's language. Used when callers + * need shielded titles regardless of translation status. + */ + skipLanguageFilter?: boolean; }) => { fetchTranslations: (id: Post[]) => Promise; }; @@ -141,6 +147,7 @@ export const useTranslation: UseTranslation = ({ queryKey, queryType = 'post', clickbaitShieldEnabled: clickbaitShieldEnabledProp, + skipLanguageFilter = false, }) => { const abort = useRef(); const { user, accessToken, isLoggedIn } = useAuthContext(); @@ -218,6 +225,7 @@ export const useTranslation: UseTranslation = ({ const postsToTranslate = posts .filter( (post) => + skipLanguageFilter || !( [PostType.Article, PostType.VideoYouTube].includes(post.type) && post.language === language @@ -316,6 +324,7 @@ export const useTranslation: UseTranslation = ({ updateFeed, updatePost, clickbaitShieldEnabled, + skipLanguageFilter, ], ); diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 8c5493aedff..301fbeb1947 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -33,6 +33,10 @@ export const slackIntegration = 'https://r.daily.dev/slack'; export const statusPage = 'https://r.daily.dev/status'; export const businessWebsiteUrl = 'https://r.daily.dev/business'; export const appsUrl = 'https://daily.dev/apps'; +export const androidAppStoreUrl = + 'https://play.google.com/store/apps/details?id=dev.daily'; +export const iosAppStoreUrl = + 'https://apps.apple.com/app/daily-dev/id6740634400'; export const timezoneSettingsUrl = 'https://r.daily.dev/timezone'; export const isDevelopment = process.env.NODE_ENV === 'development'; export const isProductionAPI = @@ -43,6 +47,9 @@ export const isTesting = export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true'; export const isBrave = (): boolean => { + if (typeof window === 'undefined') { + return false; + } if (!window.Promise) { return false; } diff --git a/packages/shared/src/lib/exploreLayoutPreference.ts b/packages/shared/src/lib/exploreLayoutPreference.ts new file mode 100644 index 00000000000..d38a039379e --- /dev/null +++ b/packages/shared/src/lib/exploreLayoutPreference.ts @@ -0,0 +1,30 @@ +import { storageWrapper } from './storageWrapper'; + +export enum ExploreLayoutPreference { + New = 'new', + Cards = 'cards', +} + +const EXPLORE_LAYOUT_PREFERENCE_KEY = 'explore_layout_preference'; +export const exploreLayoutPreferenceChangedEvent = + 'explore-layout-preference-changed'; + +export const getExploreLayoutPreference = (): ExploreLayoutPreference => { + const storedPreference = storageWrapper.getItem(EXPLORE_LAYOUT_PREFERENCE_KEY); + + if (storedPreference === ExploreLayoutPreference.Cards) { + return ExploreLayoutPreference.Cards; + } + + return ExploreLayoutPreference.New; +}; + +export const setExploreLayoutPreference = ( + preference: ExploreLayoutPreference, +): void => { + storageWrapper.setItem(EXPLORE_LAYOUT_PREFERENCE_KEY, preference); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(exploreLayoutPreferenceChangedEvent)); + } +}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index cf25dbb2af2..12f835b90ff 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -82,6 +82,7 @@ export enum Origin { HotTakeList = 'hot take list', HotAndCold = 'hot and cold', Leaderboard = 'leaderboard', + ExplorePage = 'explore page', } export enum LogEvent { @@ -551,6 +552,7 @@ export enum NotificationCtaPlacement { SquadCard = 'squad-card', PostActions = 'post-actions', SquadShareToast = 'squad-share-toast', + ExploreQuickActions = 'explore-quick-actions', } export enum NotificationCtaKind { @@ -572,6 +574,7 @@ export enum NotificationPromptSource { SquadChecklist = 'squad checklist', SourceSubscribe = 'source subscribe', ReadingReminder = 'reading reminder', + ExplorePage = 'explore page', } export enum ShortcutsSourceType { diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index e78aa463608..1c434805344 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1064,6 +1064,16 @@ meter::-webkit-meter-bar { background: rgb(255 0 168 / 35%); } + .explore-post-actions { + position: relative; + z-index: 0; + } + + .explore-post-actions > * { + position: relative; + z-index: 0; + } + @keyframes enable-notification-bell-ring { 0%, 100% { diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 67b1fce7a25..19d3f65609d 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -625,3 +625,186 @@ .agent-live-radar-sweep { animation: agent-live-radar-sweep 20s linear infinite; } + +@keyframes feed-highlights-title-gradient-shift { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} + +.feed-highlights-title-gradient { + color: transparent; + background-image: linear-gradient( + 120deg, + var(--theme-accent-blueCheese-default), + var(--theme-accent-cheese-default), + var(--theme-accent-avocado-default) + ); + background-size: 200% 200%; + background-clip: text; + -webkit-background-clip: text; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +.feed-highlights-sponsor-gradient-bg { + background-image: linear-gradient( + 120deg, + var(--theme-accent-blueCheese-default), + var(--theme-accent-cheese-default), + var(--theme-accent-avocado-default) + ); + background-size: 200% 200%; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +@keyframes brief-card-border-tail-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes brief-card-border-tail-opacity { + 0%, + 100% { + opacity: 0.72; + } + + 50% { + opacity: 1; + } +} + +.brief-card-animated-border { + position: relative; + overflow: hidden; + isolation: isolate; + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +.brief-card-animated-border::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from 0deg, + transparent 0deg 292deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 12%, transparent) + 312deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 52%, transparent) + 328deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 100%, transparent) + 340deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 45%, transparent) + 350deg, + transparent 360deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + transform-origin: center; + will-change: transform; + filter: drop-shadow( + 0 0 0.45rem + color-mix(in srgb, var(--theme-accent-blueCheese-default) 42%, transparent) + ); + animation: + brief-card-border-tail-spin 2.4s linear infinite, + brief-card-border-tail-opacity 2.4s ease-in-out infinite; +} + +.brief-card-animated-border::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from 180deg, + transparent 0deg 318deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 60%, transparent) + 336deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 18%, transparent) + 352deg, + transparent 360deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + transform-origin: center; + filter: blur(0.2rem); + opacity: 0.8; + animation: brief-card-border-tail-spin 3.6s linear infinite reverse; +} + +.brief-card-cta-gradient { + background-size: 200% 200%; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +@keyframes brief-card-magic-float { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-0.375rem); + } +} + +.brief-card-magic-float { + animation: brief-card-magic-float 4.8s cubic-bezier(0.42, 0, 0.58, 1) + infinite alternate; +} + +/* Explore page quick actions — static card outline */ +.explore-quick-action-border { + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +@media (prefers-reduced-motion: reduce) { + .feed-highlights-title-gradient { + animation: none; + background-position: 0% 50%; + } + + .feed-highlights-sponsor-gradient-bg { + animation: none; + background-position: 0% 50%; + } + + .brief-card-animated-border { + &::before { + animation: none; + } + + &::after { + animation: none; + } + } + + .brief-card-cta-gradient { + animation: none; + background-position: 0% 50%; + } + + .brief-card-magic-float { + animation: none; + } +} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 89a82ff1fcb..313b22d2e3b 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -252,12 +252,31 @@ export default { backgroundColor: 'transparent', }, }, + 'magic-float': { + '0%, 100%': { + transform: 'translateY(0) scale(1) rotate(0deg)', + filter: 'drop-shadow(0 0 8px rgba(168, 85, 247, 0.4))', + }, + '25%': { + transform: 'translateY(-4px) scale(1.02) rotate(-2deg)', + filter: 'drop-shadow(0 0 12px rgba(168, 85, 247, 0.6))', + }, + '50%': { + transform: 'translateY(-8px) scale(1.05) rotate(3deg)', + filter: 'drop-shadow(0 0 20px rgba(168, 85, 247, 0.9))', + }, + '75%': { + transform: 'translateY(-4px) scale(1.02) rotate(-1deg)', + filter: 'drop-shadow(0 0 12px rgba(168, 85, 247, 0.6))', + }, + }, }, animation: { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', + 'magic-float': 'magic-float 4s ease-in-out infinite', }, }, lineClamp: { diff --git a/packages/webapp/.eslintignore b/packages/webapp/.eslintignore index 4af6e38a1b1..09794729221 100644 --- a/packages/webapp/.eslintignore +++ b/packages/webapp/.eslintignore @@ -1,4 +1,5 @@ build/* +next-env.d.ts next.config.js public/push/onesignal/OneSignalSDKWorker.js public/scripts/*.js diff --git a/packages/webapp/components/agents/AgentsHighlightsSection.tsx b/packages/webapp/components/agents/AgentsHighlightsSection.tsx index 4d75dcf9d44..7654330bd0c 100644 --- a/packages/webapp/components/agents/AgentsHighlightsSection.tsx +++ b/packages/webapp/components/agents/AgentsHighlightsSection.tsx @@ -36,6 +36,8 @@ const DigestSubscribeButton = ({ return ( { event.preventDefault(); event.stopPropagation(); @@ -69,9 +71,9 @@ interface AgentsHighlightsSectionProps { } const HighlightSkeleton = (): ReactElement => ( -
-
-
+
+
+
); @@ -80,21 +82,19 @@ export const AgentsHighlightsSection = ({ loading, digestSource, }: AgentsHighlightsSectionProps): ReactElement | null => { + const visibleHighlights = highlights.slice(0, 5); + if (!loading && highlights.length === 0) { return null; } return ( -
-
-

+
+
+

Happening Now

- {!!digestSource?.id && ( -
- -
- )} + {!!digestSource?.id && }
{loading ? ( <> @@ -103,21 +103,34 @@ export const AgentsHighlightsSection = ({ ) : ( - highlights.map((highlight) => ( - ( + )) )}
diff --git a/packages/webapp/components/agents/AgentsLeaderboardSection.tsx b/packages/webapp/components/agents/AgentsLeaderboardSection.tsx index 6ad8bc7aa77..c96531ceb85 100644 --- a/packages/webapp/components/agents/AgentsLeaderboardSection.tsx +++ b/packages/webapp/components/agents/AgentsLeaderboardSection.tsx @@ -1,11 +1,19 @@ import type { ReactElement } from 'react'; import React from 'react'; import { ArenaRankings } from '@dailydotdev/shared/src/features/agents/arena/ArenaRankings'; +import { ArenaHighlightsFeed } from '@dailydotdev/shared/src/features/agents/arena/ArenaHighlightsFeed'; import type { ArenaTab, RankedTool, + SentimentHighlightItem, } from '@dailydotdev/shared/src/features/agents/arena/types'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Button, + ButtonGroup, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; const LiveIndicator = (): ReactElement => ( @@ -18,33 +26,110 @@ interface AgentsLeaderboardSectionProps { tools: RankedTool[]; loading: boolean; tab: ArenaTab; + onTabChange?: (tab: ArenaTab) => void; + compact?: boolean; + highlightsItems?: SentimentHighlightItem[]; } export const AgentsLeaderboardSection = ({ tools, loading, tab, + onTabChange, + compact = true, + highlightsItems = [], }: AgentsLeaderboardSectionProps): ReactElement => ( -
-
-

Arena

-
- - - View all - +
+ {compact ? ( +
+

Arena

+
+ + + View all + +
+
+ ) : ( +
+
+
+

+ The Arena +

+

+ Where AI tools fight for developer love +

+
+ {!!onTabChange && ( +
+ + + + +
+ )} +
+
+ )} + {compact ? ( + + ) : ( +
+
+ +
+
-
- + )}
); diff --git a/packages/webapp/components/explore/AgenticTopicClusterSection.tsx b/packages/webapp/components/explore/AgenticTopicClusterSection.tsx new file mode 100644 index 00000000000..3bdb0e24065 --- /dev/null +++ b/packages/webapp/components/explore/AgenticTopicClusterSection.tsx @@ -0,0 +1,443 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React from 'react'; +import { ChevronRight } from 'lucide-react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { QuaternaryButton } from '@dailydotdev/shared/src/components/buttons/QuaternaryButton'; +import InteractionCounter from '@dailydotdev/shared/src/components/InteractionCounter'; +import { BookmarkButton } from '@dailydotdev/shared/src/components/buttons/BookmarkButton'; +import { PostOptionButton } from '@dailydotdev/shared/src/features/posts/PostOptionButton'; +import { UpvoteButtonIcon } from '@dailydotdev/shared/src/components/cards/common/UpvoteButtonIcon'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { + DownvoteIcon, + DiscussIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { RelativeTime } from '@dailydotdev/shared/src/components/utilities/RelativeTime'; +import { PostContentReminder } from '@dailydotdev/shared/src/components/post/common/PostContentReminder'; +import { ClickbaitShield } from '@dailydotdev/shared/src/components/cards/common/ClickbaitShield'; +import { useSmartTitle } from '@dailydotdev/shared/src/hooks/post/useSmartTitle'; +import { + EXPLORE_TOPIC_CLUSTER_CATEGORIES, + type ExploreCategoryId, +} from './exploreCategories'; +import type { ExploreStory } from './exploreTypes'; +import { + getExploreCommunityPickPublisher, + getExploreStoryImage, + getExploreStoryTitle, +} from './exploreStoryHelpers'; +import { useExplorePostActionCallbacks } from './useExplorePostActionCallbacks'; + +interface ClusterStory { + id: string; + post?: ExploreStory; + publisher: string; + publisherImage?: string; + title: string; + href: string; + publishedAt?: string; + readTimeMinutes?: number | null; + upvotes: number; + comments: number; + image?: string; +} + +type TopicCluster = { + id: string; + topic: string; + topicHref: string; + featured: ClusterStory; + related: ClusterStory[]; +}; + +const mapToClusterStory = (story: ExploreStory): ClusterStory => { + const isCommunityPick = story.source?.name === 'Community Picks'; + const communityPublisher = isCommunityPick + ? getExploreCommunityPickPublisher(story) + : null; + + return { + id: story.id, + post: story, + publisher: + communityPublisher?.name || + story.source?.name || + story.author?.name || + 'Community', + publisherImage: + communityPublisher?.image || + story.source?.image || + story.author?.image || + undefined, + title: getExploreStoryTitle(story), + href: story.commentsPermalink, + publishedAt: story.createdAt || undefined, + readTimeMinutes: story.readTime ?? null, + upvotes: story.numUpvotes ?? 0, + comments: story.numComments ?? 0, + image: getExploreStoryImage(story), + }; +}; + +const StoryMeta = ({ + publisher, + publisherImage, + publishedAt, + post, +}: Pick< + ClusterStory, + 'publisher' | 'publisherImage' | 'publishedAt' | 'post' +>): ReactElement => ( +

+ {publisherImage ? ( + {publisher} + ) : ( + + {publisher.charAt(0)} + + )} + + {publisher} + + {publishedAt && ( + <> + + + + )} + {!!post?.clickbaitTitleDetected && !post?.flags?.ad && ( + + )} +

+); + +const StoryHeadline = ({ + story, + className, +}: { + story: ClusterStory; + className: string; +}): ReactElement => { + const storyPost = story.post as Post; + const { title: smartTitle } = useSmartTitle(storyPost); + const displayTitle = smartTitle?.trim() || story.title; + + return ( +

+ {displayTitle} +

+ ); +}; + +const StoryActions = ({ + post, + onOpenPostModal, +}: { + post: ExploreStory; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; +}): ReactElement => { + const storyPost = post as Post; + const { onUpvoteClick, onDownvoteClick, onBookmarkClick } = + useExplorePostActionCallbacks(); + const isUpvoteActive = post.userState?.vote === UserVote.Up; + const isDownvoteActive = post.userState?.vote === UserVote.Down; + + return ( + <> +
+ onUpvoteClick(storyPost)} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {(post.numUpvotes ?? 0) > 0 && ( + + )} + + + } + pressed={isDownvoteActive} + onClick={() => { + onDownvoteClick(storyPost).catch(() => null); + }} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + /> + onOpenPostModal?.(storyPost, event)} + pressed={post.commented} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {(post.numComments ?? 0) > 0 && ( + + )} + + onBookmarkClick(storyPost), + size: ButtonSize.Small, + className: 'btn-tertiary-bun pointer-events-auto', + variant: ButtonVariant.Tertiary, + }} + iconSize={IconSize.XSmall} + /> + +
+ + + ); +}; + +const TopicClusterCard = ({ + cluster, + onOpenPostModal, +}: { + cluster: TopicCluster; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; +}): ReactElement => { + return ( +
+
+ +

+ {cluster.topic} +

+
+
+ +
+ ); +}; + +const AgenticTopicClusterSection = ({ + storiesByCategory, + onOpenPostModal, +}: { + storiesByCategory?: Partial>; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; +}): ReactElement => { + const topicClusters = EXPLORE_TOPIC_CLUSTER_CATEGORIES.map((category) => { + const categoryStories = storiesByCategory?.[category.id] ?? []; + const mappedStories = categoryStories.map(mapToClusterStory); + const featuredIndex = mappedStories.findIndex((story) => !!story.image); + const featuredStory = + featuredIndex >= 0 ? mappedStories[featuredIndex] : mappedStories[0]; + const featured = featuredStory ?? { + id: `${category.id}-featured-fallback`, + publisher: `${category.label} Digest`, + title: `Latest ${category.label} stories`, + href: category.path, + publishedAt: undefined, + readTimeMinutes: 5, + upvotes: 0, + comments: 0, + }; + const skipIndex = featuredStory ? mappedStories.indexOf(featuredStory) : -1; + const related = mappedStories + .filter((_, index) => index !== skipIndex) + .slice(0, 4); + + return { + id: category.id, + topic: category.label, + topicHref: category.path, + featured, + related, + }; + }); + + return ( +
+ {topicClusters.map((cluster) => ( + + ))} +
+ ); +}; + +export default AgenticTopicClusterSection; diff --git a/packages/webapp/components/explore/ExploreNewsLayout.tsx b/packages/webapp/components/explore/ExploreNewsLayout.tsx new file mode 100644 index 00000000000..beae90b9edc --- /dev/null +++ b/packages/webapp/components/explore/ExploreNewsLayout.tsx @@ -0,0 +1,1799 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useQueryClient } from '@tanstack/react-query'; +import type { + ArenaTab, + RankedTool, + SentimentHighlightItem, +} from '@dailydotdev/shared/src/features/agents/arena/types'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { PostHighlight } from '@dailydotdev/shared/src/graphql/highlights'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { RelativeTime } from '@dailydotdev/shared/src/components/utilities/RelativeTime'; +import { BriefCardFeed } from '@dailydotdev/shared/src/components/cards/brief/BriefCard/BriefCardFeed'; +import { TopHero } from '@dailydotdev/shared/src/components/banners/HeroBottomBanner'; +import { + ArrowIcon, + DiscussIcon, + DownvoteIcon, + MenuIcon, + RefreshIcon, + TimerIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { useScrollManagement } from '@dailydotdev/shared/src/components/HorizontalScroll/useScrollManagement'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; +import { + BriefContextProvider, + useBriefContext, +} from '@dailydotdev/shared/src/components/cards/brief/BriefContext'; +import { useReadingReminderHero } from '@dailydotdev/shared/src/hooks/notifications/useReadingReminderHero'; +import { usePlusSubscription } from '@dailydotdev/shared/src/hooks'; +import { useJustBookmarked } from '@dailydotdev/shared/src/hooks/bookmark/useJustBookmarked'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { QuaternaryButton } from '@dailydotdev/shared/src/components/buttons/QuaternaryButton'; +import InteractionCounter from '@dailydotdev/shared/src/components/InteractionCounter'; +import { UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { UpvoteButtonIcon } from '@dailydotdev/shared/src/components/cards/common/UpvoteButtonIcon'; +import { BookmarkButton } from '@dailydotdev/shared/src/components/buttons/BookmarkButton'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; +import { PostOptionButton } from '@dailydotdev/shared/src/features/posts/PostOptionButton'; +import { ClickbaitShield } from '@dailydotdev/shared/src/components/cards/common/ClickbaitShield'; +import { useSmartTitle } from '@dailydotdev/shared/src/hooks/post/useSmartTitle'; +import { PostModalMap } from '@dailydotdev/shared/src/components/Feed'; +import { PostPosition } from '@dailydotdev/shared/src/hooks/usePostModalNavigation'; +import { PostContentReminder } from '@dailydotdev/shared/src/components/post/common/PostContentReminder'; +import PostTags from '@dailydotdev/shared/src/components/cards/common/PostTags'; +import { formatReadTime } from '@dailydotdev/shared/src/components/utilities'; +import { briefingUrl, plusUrl } from '@dailydotdev/shared/src/lib/constants'; +import { AgentsHighlightsSection } from '../agents/AgentsHighlightsSection'; +import { AgentsLeaderboardSection } from '../agents/AgentsLeaderboardSection'; +import { ExploreSocialStrips } from './ExploreSocialStrips'; +import AgenticTopicClusterSection from './AgenticTopicClusterSection'; +import { ExploreQuickActionsSection } from './ExploreQuickActionsSection'; +import type { ExploreCategoryId } from './exploreCategories'; +import { EXPLORE_CATEGORIES } from './exploreCategories'; +import { useExplorePostActionCallbacks } from './useExplorePostActionCallbacks'; +import { + getExploreCommunityAuthorMeta, + getExploreStoryTitle, +} from './exploreStoryHelpers'; +import type { ExploreStory } from './exploreTypes'; +import { ExploreTopCommentChip } from './ExploreTopCommentChip'; +import { ExploreTopNewsHeader } from './ExploreTopNewsHeader'; +import { NewExploreLayoutBanner } from './NewExploreLayoutBanner'; +import { + bookmarkProviderIcon, + bookmarkProviderText, + bookmarkProviderTextClassName, +} from '@dailydotdev/shared/src/components/cards/common/BookmarkProviderHeader'; + +export type { ExploreStory } from './exploreTypes'; + +interface StorySection { + id: string; + title: string; + href: string; + stories: ExploreStory[]; + totalStoriesCount: number; +} + +/** Matches {@link StorySectionBlock} sponsored-row merge (latest vs popular tail length). */ +function mergeSponsoredIntoSectionStories( + sectionStories: ExploreStory[], + sponsoredStory: ExploreStory | null | undefined, + isLatestSection: boolean, +): ExploreStory[] { + if (!sponsoredStory) { + return sectionStories; + } + + const nonSponsoredStories = sectionStories.filter( + (story) => story.id !== sponsoredStory.id, + ); + + return [ + nonSponsoredStories[0], + sponsoredStory, + ...nonSponsoredStories.slice(1, isLatestSection ? 4 : 6), + ].filter(Boolean) as ExploreStory[]; +} + +function getStoryIdsFromMergedSection( + sectionStories: ExploreStory[], + sponsoredStory: ExploreStory | null | undefined, + isLatestSection: boolean, +): Set { + const merged = mergeSponsoredIntoSectionStories( + sectionStories, + sponsoredStory, + isLatestSection, + ); + const capped = isLatestSection ? merged.slice(0, 5) : merged; + + return new Set(capped.map((story) => story.id)); +} + +function addIdsToSet(target: Set, ids: Iterable): void { + Array.from(ids).forEach((id) => { + target.add(id); + }); +} + +/** Must stay aligned with `getFeedQueryKey` in `ExplorePageContent.tsx`. */ +const getExploreFeedQueryKey = ( + categoryId: ExploreCategoryId, + section: 'latest' | 'popular' | 'upvoted' | 'discussed', +) => ['explore', categoryId, section] as const; + +interface ExploreNewsLayoutProps { + activeTabId: ExploreCategoryId; + includeExploreOnlySections?: boolean; + highlightsLoading: boolean; + highlights: PostHighlight[]; + digestSource?: Source | null; + latestStories: ExploreStory[]; + popularStories: ExploreStory[]; + upvotedStories: ExploreStory[]; + discussedStories: ExploreStory[]; + videoLatestStories: ExploreStory[]; + videoPopularStories: ExploreStory[]; + videoUpvotedStories: ExploreStory[]; + videoDiscussedStories: ExploreStory[]; + arenaTools: RankedTool[]; + arenaLoading: boolean; + arenaTab: ArenaTab; + onArenaTabChange?: (tab: ArenaTab) => void; + arenaHighlightsItems: SentimentHighlightItem[]; + categoryClusterStories?: Partial>; +} + +const PersonMeta = ({ + name, + image, +}: { + name: string; + image?: string | null; +}): ReactElement => ( + + {image ? ( + {name} + ) : ( + + {name.charAt(0)} + + )} + {name} + +); + +const metaFromSource = ( + story: ExploreStory, + source: Pick | undefined | null, +): { name: string; image?: string | null } | null => { + if (!source?.name) { + return null; + } + + if (source.name === 'Community Picks') { + return getExploreCommunityAuthorMeta(story); + } + + return { name: source.name, image: source.image }; +}; + +const metaFromCollectionSources = ( + story: ExploreStory, +): { name: string; image?: string | null } | null => { + const first = story.collectionSources?.[0]; + if (!first) { + return null; + } + + const name = first.name?.trim() || first.handle || null; + if (!name) { + return null; + } + + return { name, image: first.image }; +}; + +const getStoryOriginInfo = ( + story: ExploreStory, +): { name: string; image?: string | null } | null => + metaFromSource(story, story.source) ?? + metaFromSource(story, story.sharedPost?.source) ?? + getExploreCommunityAuthorMeta(story) ?? + metaFromCollectionSources(story); + +const hasStoryOrigin = ( + story: ExploreStory, + sourceLabelOverride?: string, + sourceFallbackLabel?: string, +): boolean => + Boolean( + sourceLabelOverride || getStoryOriginInfo(story) || sourceFallbackLabel, + ); + +const StoryOriginMeta = ({ + story, + sourceLabelOverride, + sourceFallbackLabel, +}: { + story: ExploreStory; + sourceLabelOverride?: string; + sourceFallbackLabel?: string; +}): ReactElement | null => { + if (sourceLabelOverride) { + return ( + {sourceLabelOverride} + ); + } + + const originInfo = getStoryOriginInfo(story); + + if (originInfo) { + return ; + } + + if (sourceFallbackLabel) { + return ( + {sourceFallbackLabel} + ); + } + + return null; +}; + +const getStoryReadPath = (story: ExploreStory): string => + story.permalink || story.sharedPost?.permalink || story.commentsPermalink; + +/** Refresh + remove — headline/thumbnail remain the tap targets for the post. */ +const ExploreAdPostActionRow = ({ + story, + treatAsSponsored, + onRefreshAd, + isRefreshingAd, +}: { + story: ExploreStory; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + /** Sponsored explore slot may not include `flags.ad`; still use ad-style actions. */ + treatAsSponsored?: boolean; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement => { + const { logSubscriptionEvent } = usePlusSubscription(); + const digestAd = story.flags?.ad; + const showAdActions = !!digestAd || !!treatAsSponsored; + + const removeAdsMenuOptions = useMemo( + () => [ + { + label: 'Remove ads', + anchorProps: { + href: plusUrl, + target: '_blank', + rel: 'noopener noreferrer', + onClick: () => { + logSubscriptionEvent({ + event_name: LogEvent.UpgradeSubscription, + target_id: TargetId.Ads, + }); + }, + }, + }, + ], + [logSubscriptionEvent], + ); + + if (!showAdActions) { + throw new Error('ExploreAdPostActionRow: expected ad or sponsored story'); + } + + return ( +
+ {!!onRefreshAd && ( + } + loading={isRefreshingAd} + aria-label="Refresh ad" + onClick={() => { + Promise.resolve(onRefreshAd()).catch(() => null); + }} + /> + )} + + +
+ ); +}; + +const StoryRow = ({ + story, + sourceLabelOverride, + showEngagement = true, + isSponsored = false, + imageOnRight = true, + showEngagementIcons = false, + sourceFallbackLabel, + headlineMaxLines = 3, + compactTopNews = false, + showTopCommentChip = false, + showBookmarkRevisit = false, + forceBookmarkRevisit = false, + onOpenPostModal, + onRefreshAd, + isRefreshingAd, +}: { + story: ExploreStory; + sourceLabelOverride?: string; + showEngagement?: boolean; + isSponsored?: boolean; + imageOnRight?: boolean; + showEngagementIcons?: boolean; + sourceFallbackLabel?: string; + /** 2 = tighter rows under “More stories” sections */ + headlineMaxLines?: 2 | 3; + /** Top-news column: no bottom border, no top padding per row */ + compactTopNews?: boolean; + /** Top comment preview under actions (Popular section) */ + showTopCommentChip?: boolean; + /** Apply bookmark revisit highlight treatment for saved posts. */ + showBookmarkRevisit?: boolean; + /** Always show bookmark revisit treatment (debug/validation mode). */ + forceBookmarkRevisit?: boolean; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement => { + const hasSourceMeta = hasStoryOrigin( + story, + sourceLabelOverride, + sourceFallbackLabel, + ); + const storyPost = story as Post; + const digestAd = story.flags?.ad; + const isAdPost = !!digestAd || isSponsored; + const hasStoryImage = !!story.image?.trim(); + const { onUpvoteClick, onDownvoteClick, onBookmarkClick } = + useExplorePostActionCallbacks(); + const isUpvoteActive = story.userState?.vote === UserVote.Up; + const isDownvoteActive = story.userState?.vote === UserVote.Down; + const upvoteCount = story.numUpvotes ?? 0; + const commentCount = story.numComments ?? 0; + const { title: smartTitle } = useSmartTitle(storyPost); + const { justBookmarked, wasBookmarked } = useJustBookmarked({ + bookmarked: story.bookmarked ?? false, + }); + const displayTitle = smartTitle?.trim() || getExploreStoryTitle(story); + const showClickbaitShield = !isAdPost && !!story.clickbaitTitleDetected; + const storyReadPath = getStoryReadPath(story); + const shouldHighlightBookmarkedStory = + showBookmarkRevisit && + (forceBookmarkRevisit || + (wasBookmarked && !justBookmarked && !!story.bookmarked)) && + !isAdPost; + + return ( +
+ {hasStoryImage && ( + + onOpenPostModal?.(storyPost, event)} + > + {getExploreStoryTitle(story)} + + + )} +
+ {shouldHighlightBookmarkedStory && ( + + {bookmarkProviderIcon} + {bookmarkProviderText} + + )} + + onOpenPostModal?.(storyPost, event)} + > +

+ {displayTitle} +

+
+ +
+ {isSponsored ? ( + + + {hasSourceMeta && } + Sponsored + + ) : ( + <> + + + {hasSourceMeta && !!story.createdAt && ( + + )} + {!!story.createdAt && ( + + + + )} + {!!story.readTime && ( + <> + + + + {formatReadTime(story.readTime)} + + + )} + {showClickbaitShield && } + + {!showEngagementIcons && showEngagement && !!story.numUpvotes && ( + <> + + {story.numUpvotes} upvotes + + )} + {!showEngagementIcons && + showEngagement && + !!story.numComments && ( + <> + + {story.numComments} comments + + )} + + )} +
+ {!isAdPost && ( +
+ +
+ )} + {isAdPost ? ( + + ) : ( +
+ onUpvoteClick(storyPost)} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {upvoteCount > 0 && ( + + )} + + + } + pressed={isDownvoteActive} + onClick={() => { + onDownvoteClick(storyPost).catch(() => null); + }} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + /> + ) => + onOpenPostModal?.(storyPost, event) + } + pressed={story.commented} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {commentCount > 0 && ( + + )} + + onBookmarkClick(storyPost), + size: ButtonSize.Small, + className: 'btn-tertiary-bun pointer-events-auto', + variant: ButtonVariant.Tertiary, + }} + iconSize={IconSize.XSmall} + /> + +
+ )} + {showTopCommentChip && !isAdPost && ( + + )} + {!isAdPost && } +
+
+ ); +}; + +const ExplorePostActionRow = ({ + story, + onOpenPostModal, + onRefreshAd, + isRefreshingAd, +}: { + story: ExploreStory; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement => { + const storyPost = story as Post; + const digestAd = story.flags?.ad; + const { onUpvoteClick, onDownvoteClick, onBookmarkClick } = + useExplorePostActionCallbacks(); + const isUpvoteActive = story.userState?.vote === UserVote.Up; + const isDownvoteActive = story.userState?.vote === UserVote.Down; + const upvoteCount = story.numUpvotes ?? 0; + const commentCount = story.numComments ?? 0; + + if (digestAd) { + return ( + + ); + } + + return ( + <> +
+ onUpvoteClick(storyPost)} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {upvoteCount > 0 && ( + + )} + + + } + pressed={isDownvoteActive} + onClick={() => { + onDownvoteClick(storyPost).catch(() => null); + }} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + /> + ) => + onOpenPostModal?.(storyPost, event) + } + pressed={story.commented} + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + + } + > + {commentCount > 0 && ( + + )} + + onBookmarkClick(storyPost), + size: ButtonSize.Small, + className: 'btn-tertiary-bun pointer-events-auto', + variant: ButtonVariant.Tertiary, + }} + iconSize={IconSize.XSmall} + /> + +
+ + + ); +}; + +const StorySectionBlock = ({ + section, + sponsoredStory, + onOpenPostModal, + domSectionId, + onRefreshAd, + isRefreshingAd, +}: { + section: StorySection; + sponsoredStory?: ExploreStory | null; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + /** Avoid duplicate `id` when the same section type is rendered twice (e.g. two “More stories” columns). */ + domSectionId?: string; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement => { + const isLatestSection = section.id === 'latest'; + const isPopularSection = section.id === 'popular'; + const isSponsoredSlotSection = isLatestSection || isPopularSection; + + let sectionPaddingClass = 'p-3 laptop:p-4'; + if (isLatestSection) { + sectionPaddingClass = + 'pb-3 pl-0 pr-0 pt-0 laptop:pb-4 laptop:pl-0 laptop:pr-0 laptop:pt-0'; + } else if (isPopularSection) { + sectionPaddingClass = + 'pb-3 pl-0 pr-0 pt-0 laptop:pb-4 laptop:pl-0 laptop:pr-0 laptop:pt-0'; + } + + const sectionBorderClass = + isLatestSection || isPopularSection + ? '' + : 'border border-border-subtlest-tertiary'; + const sourceLabelOverride = undefined; + const showEngagement = true; + const storiesMerged = isSponsoredSlotSection + ? mergeSponsoredIntoSectionStories( + section.stories, + sponsoredStory, + isLatestSection, + ) + : section.stories; + const storiesToRender = isLatestSection + ? storiesMerged.slice(0, 5) + : storiesMerged; + + return ( +
+ {isLatestSection && ( +
+

+ More stories +

+
+ )} + {!isLatestSection && !isPopularSection && ( +
+ + +

{section.title}

+
+ +
+ )} + {storiesToRender.length > 0 ? ( + storiesToRender.map((story, index) => ( + + )) + ) : ( +

No stories yet.

+ )} +
+ ); +}; + +const CompactSectionBlock = ({ + section, + onOpenPostModal, + onRefreshAd, + isRefreshingAd, +}: { + section: StorySection; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement => { + const isUpvotedSection = section.id === 'upvoted'; + const isDiscussedSection = section.id === 'discussed'; + const isHighlightedCompactSection = + section.id === 'upvoted' || section.id === 'discussed'; + const hasMoreStories = section.totalStoriesCount > section.stories.length; + + return ( +
+
+ + +

{section.title}

+
+ +
+ {section.stories.length > 0 ? ( + section.stories.map((story, index) => ( + + )) + ) : ( +

No stories yet.

+ )} + {hasMoreStories && ( + + + Show all + + + )} +
+ ); +}; + +const MoreStoriesStrip = ({ + stories, + onOpenPostModal, + onRefreshAd, + isRefreshingAd, +}: { + stories: ExploreStory[]; + onOpenPostModal?: (post: Post, event: MouseEvent) => void; + onRefreshAd?: () => void | Promise; + isRefreshingAd?: boolean; +}): ReactElement | null => { + if (!stories.length) { + return null; + } + + return ( + + ); +}; + +const ReadingBriefStripInner = (): ReactElement => { + const { brief } = useBriefContext(); + + if (!brief) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Reading brief +

+
+

+ A quick, high-signal recap tailored for you. +

+
+ +
+ + + Open reading brief + + +
+ ); +}; + +const ReadingBriefStrip = (): ReactElement => { + return ( + + + + ); +}; + +export const ExploreNewsLayout = ({ + activeTabId, + includeExploreOnlySections = true, + highlightsLoading, + highlights, + digestSource, + latestStories, + popularStories, + upvotedStories, + discussedStories, + videoLatestStories, + videoPopularStories, + videoUpvotedStories, + videoDiscussedStories, + arenaTools, + arenaLoading, + arenaTab, + onArenaTabChange, + arenaHighlightsItems, + categoryClusterStories, +}: ExploreNewsLayoutProps): ReactElement => { + const queryClient = useQueryClient(); + const [isRefreshingExploreAds, setIsRefreshingExploreAds] = useState(false); + const [categoriesScroller, setCategoriesScroller] = + useState(null); + const { isAtStart: isCategoriesAtStart, isAtEnd: isCategoriesAtEnd } = + useScrollManagement(categoriesScroller); + const showCategoriesScrollHint = !!categoriesScroller && !isCategoriesAtEnd; + const showCategoriesScrollHintLeft = + !!categoriesScroller && !isCategoriesAtStart; + const onScrollCategoriesRight = useCallback(() => { + if (!categoriesScroller) { + return; + } + categoriesScroller.scrollBy({ + left: categoriesScroller.clientWidth * 0.8, + behavior: 'smooth', + }); + }, [categoriesScroller]); + const onScrollCategoriesLeft = useCallback(() => { + if (!categoriesScroller) { + return; + } + categoriesScroller.scrollBy({ + left: -categoriesScroller.clientWidth * 0.8, + behavior: 'smooth', + }); + }, [categoriesScroller]); + + const onRefreshExploreAds = useCallback(async () => { + setIsRefreshingExploreAds(true); + try { + await Promise.all([ + queryClient.refetchQueries({ + queryKey: getExploreFeedQueryKey(activeTabId, 'latest'), + }), + queryClient.refetchQueries({ + queryKey: getExploreFeedQueryKey(activeTabId, 'popular'), + }), + queryClient.refetchQueries({ + queryKey: getExploreFeedQueryKey(activeTabId, 'upvoted'), + }), + queryClient.refetchQueries({ + queryKey: getExploreFeedQueryKey(activeTabId, 'discussed'), + }), + ]); + } finally { + setIsRefreshingExploreAds(false); + } + }, [activeTabId, queryClient]); + + const isVideosMode = activeTabId === 'videos'; + const isExplorePage = activeTabId === 'explore'; + const showExploreOnlySections = isExplorePage && includeExploreOnlySections; + const { + shouldShow: shouldShowReadingReminderHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + onEnable: onEnableReadingReminder, + onDismiss: onDismissReadingReminder, + } = useReadingReminderHero({ requireMobile: false }); + + const latestStoriesForView = useMemo( + () => (isVideosMode ? videoLatestStories : latestStories), + [isVideosMode, latestStories, videoLatestStories], + ); + const popularStoriesForView = useMemo( + () => (isVideosMode ? videoPopularStories : popularStories), + [isVideosMode, popularStories, videoPopularStories], + ); + const upvotedStoriesForView = useMemo( + () => (isVideosMode ? videoUpvotedStories : upvotedStories), + [isVideosMode, upvotedStories, videoUpvotedStories], + ); + const discussedStoriesForView = useMemo( + () => (isVideosMode ? videoDiscussedStories : discussedStories), + [isVideosMode, discussedStories, videoDiscussedStories], + ); + + const videoHighlights = useMemo(() => { + if (!isVideosMode) { + return []; + } + + const merged = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const uniqueStories = Array.from( + new Map(merged.map((story) => [story.id, story])).values(), + ); + + return uniqueStories.slice(0, 10).map((story) => ({ + channel: 'videos', + headline: getExploreStoryTitle(story), + highlightedAt: story.createdAt ?? new Date(0).toISOString(), + post: { + id: story.id, + commentsPermalink: story.commentsPermalink, + }, + })); + }, [ + isVideosMode, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + + const leadStory = useMemo(() => { + const hasImage = (s: ExploreStory) => !!s.image?.trim(); + return ( + latestStoriesForView.find(hasImage) ?? + popularStoriesForView.find(hasImage) ?? + latestStoriesForView[0] ?? + popularStoriesForView[0] ?? + null + ); + }, [latestStoriesForView, popularStoriesForView]); + const latestStoriesAfterLead = useMemo( + () => latestStoriesForView.filter((story) => story.id !== leadStory?.id), + [latestStoriesForView, leadStory?.id], + ); + const sponsoredStory = useMemo(() => { + const candidates = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const sponsoredCandidate = candidates.find((story) => !!story.flags?.ad); + + if (sponsoredCandidate) { + return sponsoredCandidate; + } + + return candidates.find((story) => story.id !== leadStory?.id) ?? null; + }, [ + leadStory?.id, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + const sponsoredPopularStory = useMemo(() => { + return popularStoriesForView.find((story) => !!story.flags?.ad) ?? null; + }, [popularStoriesForView]); + const topNewsStories = useMemo(() => { + if (!sponsoredStory) { + return latestStoriesAfterLead.slice(0, 4).map((story) => ({ + story, + isSponsored: false, + })); + } + + const nonSponsoredStories = latestStoriesAfterLead.filter( + (story) => story.id !== sponsoredStory.id, + ); + const merged = [ + nonSponsoredStories[0], + sponsoredStory, + ...nonSponsoredStories.slice(1, 3), + ].filter(Boolean) as ExploreStory[]; + + return merged.slice(0, 4).map((story) => ({ + story, + isSponsored: story.id === sponsoredStory.id, + })); + }, [latestStoriesAfterLead, sponsoredStory]); + const latestSectionStoriesForList = useMemo(() => { + const topIds = new Set(topNewsStories.map(({ story }) => story.id)); + + return latestStoriesAfterLead + .filter((story) => !topIds.has(story.id)) + .slice(0, 5); + }, [latestStoriesAfterLead, topNewsStories]); + const latestSection = useMemo( + () => ({ + id: 'latest', + title: 'Latest', + href: '/posts/latest', + stories: latestSectionStoriesForList, + totalStoriesCount: latestStoriesForView.length, + }), + [latestSectionStoriesForList, latestStoriesForView.length], + ); + const latestSectionSponsoredStory = useMemo(() => { + if (!sponsoredStory) { + return null; + } + + if (topNewsStories.some(({ story }) => story.id === sponsoredStory.id)) { + return null; + } + + return sponsoredStory; + }, [sponsoredStory, topNewsStories]); + const latestRenderedIds = useMemo( + () => + getStoryIdsFromMergedSection( + latestSectionStoriesForList, + latestSectionSponsoredStory, + true, + ), + [latestSectionStoriesForList, latestSectionSponsoredStory], + ); + const excludeBeforePopularPrimary = useMemo(() => { + const next = new Set(); + if (leadStory?.id) { + next.add(leadStory.id); + } + addIdsToSet( + next, + topNewsStories.map(({ story }) => story.id), + ); + addIdsToSet(next, latestRenderedIds); + + return next; + }, [leadStory?.id, topNewsStories, latestRenderedIds]); + const popularPrimarySponsored = useMemo(() => { + if ( + !sponsoredPopularStory || + excludeBeforePopularPrimary.has(sponsoredPopularStory.id) + ) { + return null; + } + + return sponsoredPopularStory; + }, [sponsoredPopularStory, excludeBeforePopularPrimary]); + const popularSectionPrimaryRaw = useMemo( + () => + popularStoriesForView + .filter((story) => !excludeBeforePopularPrimary.has(story.id)) + .slice(0, 5), + [popularStoriesForView, excludeBeforePopularPrimary], + ); + const popularPrimaryRenderedIds = useMemo( + () => + getStoryIdsFromMergedSection( + popularSectionPrimaryRaw, + popularPrimarySponsored, + false, + ), + [popularSectionPrimaryRaw, popularPrimarySponsored], + ); + const popularSection = useMemo( + () => ({ + id: 'popular', + title: 'More stories', + href: '/popular', + stories: popularSectionPrimaryRaw, + totalStoriesCount: popularStoriesForView.length, + }), + [popularSectionPrimaryRaw, popularStoriesForView.length], + ); + const excludeBeforeUpvoted = useMemo(() => { + const next = new Set(excludeBeforePopularPrimary); + addIdsToSet(next, popularPrimaryRenderedIds); + + return next; + }, [excludeBeforePopularPrimary, popularPrimaryRenderedIds]); + const upvotedSection = useMemo( + () => ({ + id: 'upvoted', + title: 'Most Upvoted', + href: '/upvoted', + stories: upvotedStoriesForView + .filter((story) => !excludeBeforeUpvoted.has(story.id)) + .slice(0, 6), + totalStoriesCount: upvotedStoriesForView.length, + }), + [upvotedStoriesForView, excludeBeforeUpvoted], + ); + const excludeBeforePopularSecondary = useMemo(() => { + const next = new Set(excludeBeforeUpvoted); + addIdsToSet( + next, + upvotedSection.stories.map((story) => story.id), + ); + + return next; + }, [excludeBeforeUpvoted, upvotedSection.stories]); + const popularSecondarySponsored = useMemo(() => { + if ( + !sponsoredPopularStory || + excludeBeforePopularSecondary.has(sponsoredPopularStory.id) + ) { + return null; + } + + return sponsoredPopularStory; + }, [sponsoredPopularStory, excludeBeforePopularSecondary]); + const popularSectionSecondaryRaw = useMemo( + () => + popularStoriesForView + .filter((story) => !excludeBeforePopularSecondary.has(story.id)) + .slice(0, 5), + [popularStoriesForView, excludeBeforePopularSecondary], + ); + const popularSecondaryRenderedIds = useMemo( + () => + getStoryIdsFromMergedSection( + popularSectionSecondaryRaw, + popularSecondarySponsored, + false, + ), + [popularSectionSecondaryRaw, popularSecondarySponsored], + ); + const popularSectionSecondary = useMemo( + () => ({ + id: 'popular', + title: 'More stories', + href: '/popular', + stories: popularSectionSecondaryRaw, + totalStoriesCount: popularStoriesForView.length, + }), + [popularSectionSecondaryRaw, popularStoriesForView.length], + ); + const excludeBeforeDiscussed = useMemo(() => { + const next = new Set(excludeBeforePopularSecondary); + addIdsToSet(next, popularSecondaryRenderedIds); + + return next; + }, [excludeBeforePopularSecondary, popularSecondaryRenderedIds]); + const discussedSection = useMemo( + () => ({ + id: 'discussed', + title: 'Best Discussions', + href: '/discussed', + stories: discussedStoriesForView + .filter((story) => !excludeBeforeDiscussed.has(story.id)) + .slice(0, 6), + totalStoriesCount: discussedStoriesForView.length, + }), + [discussedStoriesForView, excludeBeforeDiscussed], + ); + const moreStories = useMemo(() => { + const displayedIds = new Set(); + if (leadStory?.id) { + displayedIds.add(leadStory.id); + } + addIdsToSet( + displayedIds, + topNewsStories.map(({ story }) => story.id), + ); + addIdsToSet(displayedIds, latestRenderedIds); + addIdsToSet(displayedIds, popularPrimaryRenderedIds); + addIdsToSet( + displayedIds, + upvotedSection.stories.map((story) => story.id), + ); + addIdsToSet(displayedIds, popularSecondaryRenderedIds); + addIdsToSet( + displayedIds, + discussedSection.stories.map((story) => story.id), + ); + if (sponsoredStory?.id) { + displayedIds.add(sponsoredStory.id); + } + if (sponsoredPopularStory?.id) { + displayedIds.add(sponsoredPopularStory.id); + } + + const merged = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const uniqueStories = Array.from( + new Map(merged.map((story) => [story.id, story])).values(), + ); + + return uniqueStories + .filter((story) => !displayedIds.has(story.id)) + .slice(0, 5); + }, [ + leadStory?.id, + latestRenderedIds, + popularPrimaryRenderedIds, + popularSecondaryRenderedIds, + upvotedSection.stories, + discussedSection.stories, + sponsoredStory?.id, + sponsoredPopularStory?.id, + topNewsStories, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + const [selectedPost, setSelectedPost] = useState(null); + + const onOpenPostModal = useCallback( + (post: Post, event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + document.body.classList.add('hidden-scrollbar'); + setSelectedPost(post); + }, + [], + ); + + const onClosePostModal = useCallback(() => { + document.body.classList.remove('hidden-scrollbar'); + setSelectedPost(null); + }, []); + + useEffect(() => { + return () => { + document.body.classList.remove('hidden-scrollbar'); + }; + }, []); + + const SelectedPostModal = selectedPost + ? PostModalMap[selectedPost.type] + : null; + + return ( +
+ +
+
+
+ {EXPLORE_CATEGORIES.map((tab) => ( + + + {tab.label} + + + ))} +
+
+
+
+
+ +
+ +
+ {leadStory ? ( + + ) : ( +
+

+ No lead story yet. +

+
+ )} +
+ {topNewsStories.map(({ story, isSponsored }) => ( + + ))} +
+
+
+ +
+
+ +
+ +
+
+
+ {isExplorePage && } + +
+
+ + +
+
+ {showExploreOnlySections && ( +
+
+
+ +
+
+
+ )} +
+
+ + +
+
+
+ +
+ {showExploreOnlySections && ( + <> +
+ +
+
+ +
+ + )} + {isExplorePage && ( +
+
+ + +
+
+ )} + {showExploreOnlySections && ( +
+ +
+ )} + {isExplorePage && shouldShowReadingReminderHero && ( +
+ { + onEnableReadingReminder().catch(() => null); + }} + onClose={() => { + onDismissReadingReminder().catch(() => null); + }} + /> +
+ )} + {selectedPost && SelectedPostModal && ( + null} + onNextPost={async () => null} + onRequestClose={onClosePostModal} + /> + )} +
+ ); +}; diff --git a/packages/webapp/components/explore/ExplorePageContent.tsx b/packages/webapp/components/explore/ExplorePageContent.tsx new file mode 100644 index 00000000000..e662da51125 --- /dev/null +++ b/packages/webapp/components/explore/ExplorePageContent.tsx @@ -0,0 +1,438 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import type { QueryClient } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; +import { arenaOptions } from '@dailydotdev/shared/src/features/agents/arena/queries'; +import { computeRankings } from '@dailydotdev/shared/src/features/agents/arena/arenaMetrics'; +import type { ArenaTab } from '@dailydotdev/shared/src/features/agents/arena/types'; +import type { PostHighlight } from '@dailydotdev/shared/src/graphql/highlights'; +import { POST_HIGHLIGHTS_QUERY } from '@dailydotdev/shared/src/graphql/highlights'; +import { + ANONYMOUS_FEED_QUERY, + FEED_QUERY, + MOST_DISCUSSED_FEED_QUERY, + MOST_UPVOTED_FEED_QUERY, + RankingAlgorithm, + TAG_FEED_QUERY, + type FeedData, +} from '@dailydotdev/shared/src/graphql/feed'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { PostType } from '@dailydotdev/shared/src/types'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { sourceQueryOptions } from '@dailydotdev/shared/src/graphql/sources'; +import { + RequestKey, + StaleTime, + generateQueryKey, +} from '@dailydotdev/shared/src/lib/query'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { useScrollRestoration } from '@dailydotdev/shared/src/hooks'; +import { getPageSeoTitles } from '../layouts/utils'; +import { defaultOpenGraph } from '../../next-seo'; +import { ExploreNewsLayout } from './ExploreNewsLayout'; +import type { ExploreCategoryId } from './exploreCategories'; +import { + EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS, + getExploreCategoryById, +} from './exploreCategories'; +import { useExploreFeedTranslations } from './useExploreFeedTranslations'; + +const HIGHLIGHTS_CHANNEL = 'vibes'; +const STORIES_PER_SECTION = 12; +const UPVOTED_AND_DISCUSSED_PERIOD = 7; +const DEFAULT_EXPLORE_ARENA_TAB: ArenaTab = 'llms'; +const VIDEO_SUPPORTED_TYPES = [PostType.VideoYouTube]; + +const HIGHLIGHTS_QUERY_KEY = generateQueryKey( + RequestKey.PostHighlights, + undefined, + HIGHLIGHTS_CHANNEL, +); + +const getFeedQueryKey = ( + categoryId: ExploreCategoryId, + section: 'latest' | 'popular' | 'upvoted' | 'discussed', + isLoggedIn: boolean, +) => + ['explore', isLoggedIn ? 'logged-in' : 'anonymous', categoryId, section] as const; + +const getHighlightsQuery = () => + gqlClient.request<{ postHighlights: PostHighlight[] }>( + POST_HIGHLIGHTS_QUERY, + { + channel: HIGHLIGHTS_CHANNEL, + }, + ); + +const getLatestStoriesQuery = ({ + tag, + supportedTypes, + isLoggedIn, +}: { + tag?: string; + supportedTypes?: PostType[]; + isLoggedIn: boolean; +}) => { + if (tag) { + return () => + gqlClient.request(TAG_FEED_QUERY, { + loggedIn: isLoggedIn, + tag, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Time, + supportedTypes, + }); + } + + const feedQuery = isLoggedIn ? FEED_QUERY : ANONYMOUS_FEED_QUERY; + + return () => + gqlClient.request(feedQuery, { + loggedIn: isLoggedIn, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Time, + supportedTypes, + }); +}; + +const getPopularStoriesQuery = ({ + tag, + supportedTypes, + isLoggedIn, +}: { + tag?: string; + supportedTypes?: PostType[]; + isLoggedIn: boolean; +}) => { + if (tag) { + return () => + gqlClient.request(TAG_FEED_QUERY, { + loggedIn: isLoggedIn, + tag, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Popularity, + supportedTypes, + }); + } + + const feedQuery = isLoggedIn ? FEED_QUERY : ANONYMOUS_FEED_QUERY; + + return () => + gqlClient.request(feedQuery, { + loggedIn: isLoggedIn, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Popularity, + supportedTypes, + }); +}; + +const getUpvotedStoriesQuery = + ({ + tag, + supportedTypes, + isLoggedIn, + }: { + tag?: string; + supportedTypes?: PostType[]; + isLoggedIn: boolean; + }) => + () => + gqlClient.request(MOST_UPVOTED_FEED_QUERY, { + loggedIn: isLoggedIn, + first: STORIES_PER_SECTION, + period: UPVOTED_AND_DISCUSSED_PERIOD, + tag, + supportedTypes, + }); + +const getDiscussedStoriesQuery = + ({ + tag, + supportedTypes, + isLoggedIn, + }: { + tag?: string; + supportedTypes?: PostType[]; + isLoggedIn: boolean; + }) => + () => + gqlClient.request(MOST_DISCUSSED_FEED_QUERY, { + loggedIn: isLoggedIn, + first: STORIES_PER_SECTION, + period: UPVOTED_AND_DISCUSSED_PERIOD, + tag, + supportedTypes, + }); + +const getFeedQueriesForCategory = ( + categoryId: ExploreCategoryId, + isLoggedIn: boolean, +) => { + const category = getExploreCategoryById(categoryId); + const isVideosCategory = + !!category && 'isVideos' in category && !!category.isVideos; + const tag = category && 'tag' in category ? category.tag : undefined; + const supportedTypes = isVideosCategory ? VIDEO_SUPPORTED_TYPES : undefined; + + return { + latest: getLatestStoriesQuery({ tag, supportedTypes, isLoggedIn }), + popular: getPopularStoriesQuery({ tag, supportedTypes, isLoggedIn }), + upvoted: getUpvotedStoriesQuery({ tag, supportedTypes, isLoggedIn }), + discussed: getDiscussedStoriesQuery({ tag, supportedTypes, isLoggedIn }), + }; +}; + +export const prefetchExplorePageData = async ({ + queryClient, + categoryId, +}: { + queryClient: QueryClient; + categoryId: ExploreCategoryId; +}): Promise => { + const isLoggedIn = false; + const feedQueries = getFeedQueriesForCategory(categoryId, isLoggedIn); + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: HIGHLIGHTS_QUERY_KEY, + queryFn: getHighlightsQuery, + }), + queryClient.prefetchQuery( + sourceQueryOptions({ sourceId: 'agents_digest' }), + ), + queryClient.prefetchQuery( + arenaOptions({ groupId: DEFAULT_EXPLORE_ARENA_TAB }), + ), + queryClient.prefetchQuery(arenaOptions({ groupId: 'coding-agents' })), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'latest', isLoggedIn), + queryFn: feedQueries.latest, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'popular', isLoggedIn), + queryFn: feedQueries.popular, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'upvoted', isLoggedIn), + queryFn: feedQueries.upvoted, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'discussed', isLoggedIn), + queryFn: feedQueries.discussed, + }), + ]); +}; + +export const ExplorePageContent = ({ + activeCategoryId, + includeExploreOnlySections = true, +}: { + activeCategoryId: ExploreCategoryId; + includeExploreOnlySections?: boolean; +}): ReactElement => { + const { isLoggedIn } = useAuthContext(); + useScrollRestoration(); + const [arenaTab, setArenaTab] = useState(DEFAULT_EXPLORE_ARENA_TAB); + const feedQueries = useMemo( + () => getFeedQueriesForCategory(activeCategoryId, isLoggedIn), + [activeCategoryId, isLoggedIn], + ); + const isVideosCategory = activeCategoryId === 'videos'; + const isExploreCategory = activeCategoryId === 'explore'; + + const { data: highlightsData, isFetching: isFetchingHighlights } = useQuery({ + queryKey: HIGHLIGHTS_QUERY_KEY, + queryFn: getHighlightsQuery, + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: true, + refetchInterval: 60 * 1000, + }); + const { data: digestSource } = useQuery( + sourceQueryOptions({ sourceId: 'agents_digest' }), + ); + const { data: arenaData, isFetching: isFetchingArena } = useQuery( + arenaOptions({ groupId: arenaTab }), + ); + + const arenaRankings = useMemo( + () => + arenaData?.sentimentTimeSeries && arenaData.sentimentGroup + ? computeRankings( + arenaData.sentimentTimeSeries.entities.nodes, + arenaData.sentimentGroup.entities, + arenaData.sentimentTimeSeries.resolutionSeconds, + ) + : [], + [arenaData?.sentimentTimeSeries, arenaData?.sentimentGroup], + ); + const isArenaLoading = isFetchingArena && !arenaData; + + const latestQueryKey = useMemo( + () => getFeedQueryKey(activeCategoryId, 'latest', isLoggedIn), + [activeCategoryId, isLoggedIn], + ); + const popularQueryKey = useMemo( + () => getFeedQueryKey(activeCategoryId, 'popular', isLoggedIn), + [activeCategoryId, isLoggedIn], + ); + const upvotedQueryKey = useMemo( + () => getFeedQueryKey(activeCategoryId, 'upvoted', isLoggedIn), + [activeCategoryId, isLoggedIn], + ); + const discussedQueryKey = useMemo( + () => getFeedQueryKey(activeCategoryId, 'discussed', isLoggedIn), + [activeCategoryId, isLoggedIn], + ); + + const { data: latestStoriesData } = useQuery({ + queryKey: latestQueryKey, + queryFn: feedQueries.latest, + staleTime: StaleTime.Default, + }); + const { data: popularStoriesData } = useQuery({ + queryKey: popularQueryKey, + queryFn: feedQueries.popular, + staleTime: StaleTime.Default, + }); + const { data: upvotedStoriesData } = useQuery({ + queryKey: upvotedQueryKey, + queryFn: feedQueries.upvoted, + staleTime: StaleTime.Default, + }); + const { data: discussedStoriesData } = useQuery({ + queryKey: discussedQueryKey, + queryFn: feedQueries.discussed, + staleTime: StaleTime.Default, + }); + const topicClusterQueryKeys = useMemo( + () => + EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS.map((categoryId) => + getFeedQueryKey(categoryId, 'latest', isLoggedIn), + ), + [isLoggedIn], + ); + const topicClusterStoriesQueries = useQueries({ + queries: EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS.map((categoryId, index) => ({ + queryKey: topicClusterQueryKeys[index], + queryFn: getFeedQueriesForCategory(categoryId, isLoggedIn).latest, + staleTime: StaleTime.Default, + enabled: isExploreCategory, + })), + }); + + const translationSections = useMemo( + () => [ + { data: latestStoriesData, queryKey: latestQueryKey }, + { data: popularStoriesData, queryKey: popularQueryKey }, + { data: upvotedStoriesData, queryKey: upvotedQueryKey }, + { data: discussedStoriesData, queryKey: discussedQueryKey }, + ...EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS.map((_, index) => ({ + data: topicClusterStoriesQueries[index]?.data, + queryKey: topicClusterQueryKeys[index], + })), + ], + [ + latestStoriesData, + popularStoriesData, + upvotedStoriesData, + discussedStoriesData, + latestQueryKey, + popularQueryKey, + upvotedQueryKey, + discussedQueryKey, + topicClusterStoriesQueries, + topicClusterQueryKeys, + ], + ); + + useExploreFeedTranslations(translationSections); + + const latestStories = + latestStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const popularStories = + popularStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const upvotedStories = + upvotedStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const discussedStories = + discussedStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const sortedHighlights = useMemo( + () => + [...(highlightsData?.postHighlights ?? [])].sort( + (a, b) => + new Date(b.highlightedAt).getTime() - + new Date(a.highlightedAt).getTime(), + ), + [highlightsData?.postHighlights], + ); + const topicClusterStoriesByCategory = useMemo( + () => + EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS.reduce< + Partial> + >((acc, categoryId, index) => { + const categoryStories = + topicClusterStoriesQueries[index]?.data?.page?.edges?.map( + (edge) => edge.node, + ) ?? []; + + acc[categoryId] = categoryStories; + return acc; + }, {}), + [topicClusterStoriesQueries], + ); + + const exploreCategorySeo = useMemo(() => { + const category = getExploreCategoryById(activeCategoryId); + const titleBase = + activeCategoryId === 'explore' || !category + ? 'Explore developer news' + : `${category.label} developer news`; + + return getPageSeoTitles(titleBase); + }, [activeCategoryId]); + + const exploreCanonicalUrl = useMemo(() => { + const category = getExploreCategoryById(activeCategoryId); + if (!category) { + return undefined; + } + + const base = webappUrl.endsWith('/') ? webappUrl.slice(0, -1) : webappUrl; + return `${base}${category.path}`; + }, [activeCategoryId]); + + return ( + <> + + + + ); +}; diff --git a/packages/webapp/components/explore/ExploreQuickActionsSection.tsx b/packages/webapp/components/explore/ExploreQuickActionsSection.tsx new file mode 100644 index 00000000000..c7423460433 --- /dev/null +++ b/packages/webapp/components/explore/ExploreQuickActionsSection.tsx @@ -0,0 +1,273 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + BellIcon, + ChromeIcon, + DocsIcon, + EdgeIcon, + PhoneIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + appsUrl, + downloadBrowserExtension, +} from '@dailydotdev/shared/src/lib/constants'; +import { + fileValidation, + useUploadCv, +} from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; +import { useFileInput } from '@dailydotdev/shared/src/features/fileUpload/hooks/useFileInput'; +import { useFileValidation } from '@dailydotdev/shared/src/features/fileUpload/hooks/useFileValidation'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks'; +import { + BrowserName, + checkIsExtension, + getCurrentBrowserName, +} from '@dailydotdev/shared/src/lib/func'; +import { useEnableNotification } from '@dailydotdev/shared/src/hooks/notifications/useEnableNotification'; +import { + LogEvent, + NotificationCtaPlacement, + NotificationPromptSource, + Origin, +} from '@dailydotdev/shared/src/lib/log'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; + +const bellAnimationClass = + 'origin-top motion-safe:[animation:enable-notification-bell-ring_1.1s_ease-in-out_1.5s_infinite]'; + +const tileShellClass = classNames( + 'explore-quick-action-border group block w-full rounded-16 text-left', + 'transition duration-300 ease-out motion-safe:active:translate-y-0', + 'focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-blueCheese-default', +); + +const tileInnerClass = + 'flex min-h-[5.5rem] items-center gap-4 rounded-14 px-5 py-4'; + +const iconWrapClass = + 'flex h-14 w-14 shrink-0 items-center justify-center rounded-14 text-accent-blueCheese-default'; + +interface QuickActionLinkTileProps { + href: string; + rel?: string; + target?: string; + onClick?: () => void; + icon: ReactElement; + title: string; + description: string; +} + +const QuickActionLinkTile = ({ + href, + rel, + target, + onClick, + icon, + title, + description, +}: QuickActionLinkTileProps): ReactElement => ( + + + + {icon} + + + {title} + + {description} + + + + +); + +interface QuickActionButtonTileProps { + onClick: () => void; + disabled?: boolean; + icon: ReactElement; + title: string; + description: string; +} + +const QuickActionButtonTile = ({ + onClick, + disabled = false, + icon, + title, + description, +}: QuickActionButtonTileProps): ReactElement => ( + +); + +export const ExploreQuickActionsSection = (): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const browserName = getCurrentBrowserName(); + const isEdge = browserName === BrowserName.Edge; + + const { + shouldShowCta: showNotificationCta, + onEnable: onEnableNotifications, + } = useEnableNotification({ + source: NotificationPromptSource.ExplorePage, + placement: NotificationCtaPlacement.ExploreQuickActions, + }); + + const { onUpload, status, shouldShow: shouldShowCvUpload } = useUploadCv(); + const { validateFiles } = useFileValidation(fileValidation); + + const handleCvFiles = useCallback( + (files: FileList | null) => { + if (!files || files.length === 0) { + return; + } + + const { validFiles, errors } = validateFiles(files); + + if (errors.length > 0) { + const first = errors[0]; + const fileName = first.file ? ` (${first.file.name})` : ''; + displayToast(`${first.message}${fileName}`); + return; + } + + const file = validFiles[0]; + if (!file) { + return; + } + + onUpload(file).catch(() => null); + }, + [displayToast, onUpload, validateFiles], + ); + + const { input: cvFileInput, openFileInput: openCvFileInput } = useFileInput({ + onFiles: handleCvFiles, + accept: fileValidation.acceptedTypes, + disabled: status === 'pending', + }); + + const profilePercent = user?.profileCompletion?.percentage; + const isCvUploading = status === 'pending'; + + const uploadCvTitle = + profilePercent !== undefined + ? `Upload CV (${profilePercent}%)` + : 'Upload CV'; + + const extensionIcon = useMemo( + () => + isEdge ? ( + + ) : ( + + ), + [isEdge], + ); + + return ( +
+ {cvFileInput} +
+ {!checkIsExtension() && ( + + logEvent({ + event_name: LogEvent.DownloadExtension, + origin: Origin.ExplorePage, + }) + } + /> + )} + + {isLoggedIn && showNotificationCta && ( + + } + title="Enable notifications" + description="Get nudges for replies, mentions, and threads you care about." + onClick={() => { + onEnableNotifications().catch(() => null); + }} + /> + )} + + } + title="Get the mobile app" + description="Pick up where you left off on iOS and Android." + /> + + {isLoggedIn && + (shouldShowCvUpload ? ( + + ) : ( + + ) + } + title={uploadCvTitle} + description="Add your résumé so we can match you to better opportunities." + onClick={() => openCvFileInput()} + disabled={isCvUploading} + /> + ) : ( + } + title="Upload your CV" + description="Manage your profile and job preferences in settings." + /> + ))} +
+
+ ); +}; diff --git a/packages/webapp/components/explore/ExploreSocialStrips.tsx b/packages/webapp/components/explore/ExploreSocialStrips.tsx new file mode 100644 index 00000000000..081d0ba2d45 --- /dev/null +++ b/packages/webapp/components/explore/ExploreSocialStrips.tsx @@ -0,0 +1,752 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; +import type { Squad } from '@dailydotdev/shared/src/graphql/sources'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, + QUEST_ROTATION_UPDATE_SUBSCRIPTION, + QUEST_UPDATE_SUBSCRIPTION, +} from '@dailydotdev/shared/src/graphql/quests'; +import { getSquadStaticFields } from '@dailydotdev/shared/src/graphql/squads'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; +import { useClaimQuestReward } from '@dailydotdev/shared/src/hooks/useClaimQuestReward'; +import useSubscription from '@dailydotdev/shared/src/hooks/useSubscription'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { GitHubIcon } from '@dailydotdev/shared/src/components/icons/GitHub'; +import { CoreIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { ExploreCategoryId } from './exploreCategories'; +import { getExploreCategoryById } from './exploreCategories'; + +const TOP_SQUAD_PLACEHOLDER_IMAGE = + 'https://media.daily.dev/image/upload/v1672041320/squads/squad_placeholder.jpg'; +const TOP_ACTIVE_SQUADS_30D = [ + { name: 'PHP Dev', handle: 'phpdev' }, + { name: 'Machine Learning News', handle: 'mlnews' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + { name: 'Smarter Articles', handle: 'smarterarticles' }, + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }, + { name: 'DevOps Daily', handle: 'devopsdaily' }, + { name: 'All Pc Softs', handle: 'allpcsofts' }, + { name: 'Horde', handle: 'horde' }, + { name: 'Grimspin', handle: 'grimspin' }, + { name: 'Devs Together Strong', handle: 'devstogetherstrong' }, + { name: 'Lonely Programmer', handle: 'lonely_programmer' }, + { name: 'Dev World', handle: 'dev_world' }, + { name: 'Just Java', handle: 'justjava' }, + { name: 'Tech GSM Softwares', handle: 'techgsmsoftwares' }, + { name: 'Data Engineering', handle: 'sspdata' }, + { name: 'Zero To Mastery', handle: 'zerotomastery' }, + { name: 'Dev Squad', handle: 'devsquad' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, +] as const; +const CATEGORY_RELEVANT_SQUADS: Partial< + Record< + ExploreCategoryId, + readonly { + name: string; + handle: string; + }[] + > +> = { + videos: [ + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, + ], + agentic: [ + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, + ], + webdev: [ + { name: 'Zero To Mastery', handle: 'zerotomastery' }, + { name: 'Dev World', handle: 'dev_world' }, + { name: 'Smarter Articles', handle: 'smarterarticles' }, + ], + backend: [ + { name: 'DevOps Daily', handle: 'devopsdaily' }, + { name: 'Data Engineering', handle: 'sspdata' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + ], + databases: [{ name: 'Data Engineering', handle: 'sspdata' }], + career: [ + { name: 'Devs Together Strong', handle: 'devstogetherstrong' }, + { name: 'Lonely Programmer', handle: 'lonely_programmer' }, + ], + golang: [{ name: 'DevOps Daily', handle: 'devopsdaily' }], + rust: [{ name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }], + opensource: [ + { name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }, + ], + testing: [{ name: 'Smarter Articles', handle: 'smarterarticles' }], + php: [{ name: 'PHP Dev', handle: 'phpdev' }], + java: [ + { name: 'Just Java', handle: 'justjava' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + ], +}; +const TOP_SQUAD_SKELETON_KEYS = [ + 'top-squad-skeleton-1', + 'top-squad-skeleton-2', + 'top-squad-skeleton-3', + 'top-squad-skeleton-4', + 'top-squad-skeleton-5', + 'top-squad-skeleton-6', + 'top-squad-skeleton-7', + 'top-squad-skeleton-8', +]; +const QUEST_LOADING_KEYS = ['quest-loading-1', 'quest-loading-2']; + +const getProgressPercent = (value: number, target: number): number => + Math.min(Math.round((value / Math.max(target, 1)) * 100), 100); + +interface TopSquadStripItem { + id: string; + name: string; + permalink: string; + image: string; +} + +interface TopTagStripItem { + name: string; + slug: string; +} + +const TOP_ACTIVE_TAGS_30D: TopTagStripItem[] = [ + { name: 'AI', slug: 'ai' }, + { name: 'Webdev', slug: 'webdev' }, + { name: 'Backend', slug: 'backend' }, + { name: 'Databases', slug: 'databases' }, + { name: 'Career', slug: 'career' }, + { name: 'Golang', slug: 'golang' }, + { name: 'Rust', slug: 'rust' }, + { name: 'Opensource', slug: 'open-source' }, + { name: 'Testing', slug: 'testing' }, + { name: 'PHP', slug: 'php' }, + { name: 'Java', slug: 'java' }, + { name: 'Python', slug: 'python' }, + { name: 'JavaScript', slug: 'javascript' }, + { name: 'TypeScript', slug: 'typescript' }, + { name: 'DevOps', slug: 'devops' }, + { name: 'Security', slug: 'security' }, + { name: 'Cloud', slug: 'cloud' }, + { name: 'Kubernetes', slug: 'kubernetes' }, + { name: 'Next.js', slug: 'nextjs' }, + { name: 'React', slug: 'react' }, +]; +const SPONSORED_TAG_TOOLTIP_CONTENT = 'Sponsored by GitHub'; + +const TopSquadStories = ({ + squads, +}: { + squads: TopSquadStripItem[]; +}): ReactElement => { + const scrollRef = useRef(null); + const [showScrollLeft, setShowScrollLeft] = useState(false); + const [showScrollRight, setShowScrollRight] = useState(false); + + useEffect(() => { + const updateScrollControls = (): void => { + const element = scrollRef.current; + if (!element) { + return; + } + + const canScroll = + element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + const canScrollLeft = element.scrollLeft > 1; + setShowScrollLeft(canScrollLeft); + setShowScrollRight(canScroll); + }; + + updateScrollControls(); + + const element = scrollRef.current; + element?.addEventListener('scroll', updateScrollControls); + window.addEventListener('resize', updateScrollControls); + + return () => { + element?.removeEventListener('scroll', updateScrollControls); + window.removeEventListener('resize', updateScrollControls); + }; + }, [squads.length]); + + const handleScrollRight = (): void => { + scrollRef.current?.scrollBy({ + left: 220, + behavior: 'smooth', + }); + }; + const handleScrollLeft = (): void => { + scrollRef.current?.scrollBy({ + left: -220, + behavior: 'smooth', + }); + }; + + return ( +
+
+ {squads.map((squad, index) => ( + +
+ {squad.name} + + #{index + 1} + +
+ + {squad.name} + +
+ ))} +
+ {showScrollLeft && ( + + )} + {showScrollRight && ( + + )} +
+ ); +}; + +const TopTagStories = ({ tags }: { tags: TopTagStripItem[] }): ReactElement => { + const scrollRef = useRef(null); + const [showScrollLeft, setShowScrollLeft] = useState(false); + const [showScrollRight, setShowScrollRight] = useState(false); + + useEffect(() => { + const updateScrollControls = (): void => { + const element = scrollRef.current; + if (!element) { + return; + } + + const canScroll = + element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + const canScrollLeft = element.scrollLeft > 1; + setShowScrollLeft(canScrollLeft); + setShowScrollRight(canScroll); + }; + + updateScrollControls(); + + const element = scrollRef.current; + element?.addEventListener('scroll', updateScrollControls); + window.addEventListener('resize', updateScrollControls); + + return () => { + element?.removeEventListener('scroll', updateScrollControls); + window.removeEventListener('resize', updateScrollControls); + }; + }, [tags.length]); + + const handleScrollRight = (): void => { + scrollRef.current?.scrollBy({ + left: 220, + behavior: 'smooth', + }); + }; + const handleScrollLeft = (): void => { + scrollRef.current?.scrollBy({ + left: -220, + behavior: 'smooth', + }); + }; + + return ( +
+ + {showScrollLeft && ( + + )} + {showScrollRight && ( + + )} +
+ ); +}; + +const TopSquadStoriesSkeleton = (): ReactElement => ( +
+ {TOP_SQUAD_SKELETON_KEYS.map((key) => ( +
+
+
+
+ ))} +
+); + +type DailyQuestStripItem = { + quest: UserQuest; + isPlus: boolean; +}; + +const DailyQuestCard = ({ + item, +}: { + item: DailyQuestStripItem; +}): ReactElement => { + const { quest, isPlus } = item; + const progressPercent = getProgressPercent( + quest.progress, + quest.quest.targetCount, + ); + const visibleRewards = quest.rewards.filter( + (reward) => + reward.type === QuestRewardType.Xp || + reward.type === QuestRewardType.Cores, + ); + const tooltipContent = + quest.quest.description || `Complete: ${quest.quest.name}`; + const { + mutate: claimQuestReward, + isPending: isClaimPending, + variables: claimVariables, + } = useClaimQuestReward(); + const isClaimingThisQuest = + isClaimPending && claimVariables?.userQuestId === quest.userQuestId; + const isClaimed = quest.status === QuestStatus.Claimed || !!quest.claimedAt; + const canClaim = quest.claimable && !!quest.userQuestId && !isClaimed; + + return ( + +
+
+
+

+ {quest.quest.name} +

+ {isPlus && ( + + )} +
+
+
+
+

+ {quest.progress}/{quest.quest.targetCount} +

+ {visibleRewards.length > 0 && ( +
+ {visibleRewards.map((reward, index) => ( + + {reward.type === QuestRewardType.Xp ? ( + + xp + + ) : ( + + )} + +{reward.amount}{' '} + {reward.type === QuestRewardType.Xp ? 'XP' : 'Cores'} + + ))} +
+ )} + {canClaim && ( + + )} +
+ {isClaimed && ( +
+
+ + Claimed + +
+
+ )} +
+ + ); +}; + +interface ExploreSocialStripsProps { + showTopSquads?: boolean; + showTopTags?: boolean; + showProgress?: boolean; + activeCategoryId?: ExploreCategoryId; +} + +export const ExploreSocialStrips = ({ + showTopSquads = true, + showTopTags = false, + showProgress = true, + activeCategoryId = 'explore', +}: ExploreSocialStripsProps): ReactElement | null => { + const { isLoggedIn, user } = useAuthContext(); + const queryClient = useQueryClient(); + const squadSeeds = useMemo( + () => + activeCategoryId === 'explore' + ? TOP_ACTIVE_SQUADS_30D + : CATEGORY_RELEVANT_SQUADS[activeCategoryId] ?? [], + [activeCategoryId], + ); + const activeCategoryLabel = getExploreCategoryById(activeCategoryId)?.label; + + const topSquadQueries = useQueries({ + queries: squadSeeds.map(({ handle }) => ({ + queryKey: ['explore', 'top-active-squad', handle], + queryFn: () => getSquadStaticFields(handle), + enabled: showTopSquads, + })), + }); + + const isTopSquadsPending = topSquadQueries.some((query) => query.isPending); + + const topSquads = useMemo( + () => + squadSeeds.map(({ name, handle }, index) => { + const squadData = topSquadQueries[index]?.data as Squad | undefined; + + return { + id: squadData?.id ?? `top-active-squad-${index + 1}-${handle}`, + name: squadData?.name ?? name, + permalink: + squadData?.permalink ?? `https://app.daily.dev/squads/${handle}`, + image: squadData?.image ?? TOP_SQUAD_PLACEHOLDER_IMAGE, + }; + }), + [squadSeeds, topSquadQueries], + ); + const shouldRenderTopSquads = + showTopSquads && (isTopSquadsPending || topSquads.length > 0); + const shouldRenderTopTags = showTopTags; + const shouldRenderProgress = showProgress; + const questDashboardQueryKey = generateQueryKey( + RequestKey.QuestDashboard, + user, + ); + const invalidateQuestDashboard = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: questDashboardQueryKey, + exact: true, + }); + }, [queryClient, questDashboardQueryKey]); + + const { data: questDashboard, isPending: isQuestsPending } = + useQuestDashboard(); + + useSubscription( + () => ({ + query: QUEST_UPDATE_SUBSCRIPTION, + }), + { + next: invalidateQuestDashboard, + }, + [user?.id], + ); + + useSubscription( + () => ({ + query: QUEST_ROTATION_UPDATE_SUBSCRIPTION, + }), + { + next: invalidateQuestDashboard, + }, + [user?.id], + ); + + const dailyQuests = useMemo(() => { + if (!questDashboard) { + return []; + } + + const regularQuests = questDashboard.daily.regular + .filter((quest) => !quest.locked) + .map((quest) => ({ quest, isPlus: false })); + const plusQuests = questDashboard.daily.plus.map((quest) => ({ + quest, + isPlus: true, + })); + + return [...regularQuests, ...plusQuests]; + }, [questDashboard]); + + if (!shouldRenderTopSquads && !shouldRenderTopTags && !shouldRenderProgress) { + return null; + } + + return ( + <> + {shouldRenderTopSquads && ( +
+
+

+ {activeCategoryId === 'explore' + ? 'Top active squads' + : 'Relevant squads'} +

+

+ {activeCategoryId === 'explore' + ? '20 most active public squads over the last 30 day' + : `${squadSeeds.length} relevant public squads for ${ + activeCategoryLabel ?? 'this category' + }`} +

+
+ {isTopSquadsPending ? ( + + ) : ( + + )} +
+ )} + + {shouldRenderTopTags && ( +
+
+

+ Popular tags +

+
+ Popular tags across +
+
+ +
+ )} + + {shouldRenderProgress && ( +
+
+

+ Daily quests +

+
+ {(() => { + if (!isLoggedIn) { + return ( +

+ Sign in to track daily quests. +

+ ); + } + + if (!isQuestsPending && dailyQuests.length === 0) { + return ( +

+ No daily quests available right now. +

+ ); + } + + return ( +
+ {dailyQuests.map((item) => ( + + ))} + {isQuestsPending && + QUEST_LOADING_KEYS.map((key) => ( +
+ ))} +
+ ); + })()} +
+ )} + + ); +}; diff --git a/packages/webapp/components/explore/ExploreTopCommentChip.tsx b/packages/webapp/components/explore/ExploreTopCommentChip.tsx new file mode 100644 index 00000000000..4ecb906ff49 --- /dev/null +++ b/packages/webapp/components/explore/ExploreTopCommentChip.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + getCommentHash, + TOP_COMMENTS_QUERY, + type TopCommentsData, +} from '@dailydotdev/shared/src/graphql/comments'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + generateQueryKey, + RequestKey, + StaleTime, +} from '@dailydotdev/shared/src/lib/query'; +import { stripHtmlTags } from '@dailydotdev/shared/src/lib/strings'; + +/** Show preview only when comment body has more than this many words. */ +const MIN_COMMENT_WORDS = 15; + +const TOP_COMMENTS_FETCH = 25; + +function countWords(text: string): number { + const t = text.trim(); + if (!t) { + return 0; + } + + return t.split(/\s+/).length; +} + +export type ExploreTopCommentChipProps = { + postId: string; + commentsPermalink: string; + numComments: number; +}; + +export const ExploreTopCommentChip = ({ + postId, + commentsPermalink, + numComments, +}: ExploreTopCommentChipProps): ReactElement | null => { + const { data } = useQuery({ + queryKey: generateQueryKey( + RequestKey.PostComments, + undefined, + 'explore-top', + postId, + TOP_COMMENTS_FETCH, + ), + queryFn: () => + gqlClient.request(TOP_COMMENTS_QUERY, { + postId, + first: TOP_COMMENTS_FETCH, + }), + staleTime: StaleTime.Default, + enabled: numComments > 0 && Boolean(postId), + }); + + const comment = (data?.topComments ?? []).find((c) => { + const text = stripHtmlTags(c.contentHtml ?? '').trim(); + return text.length > 0 && countWords(text) > MIN_COMMENT_WORDS; + }); + const author = comment?.author; + if (!comment || !author) { + return null; + } + + const preview = stripHtmlTags(comment.contentHtml ?? '').trim(); + if (!preview) { + return null; + } + + const initial = + author.name?.trim()?.charAt(0) || author.username?.trim()?.charAt(0) || '?'; + + const href = `${commentsPermalink}${getCommentHash(comment.id)}`; + + return ( + + ); +}; diff --git a/packages/webapp/components/explore/ExploreTopNewsHeader.tsx b/packages/webapp/components/explore/ExploreTopNewsHeader.tsx new file mode 100644 index 00000000000..cedc9fd99c5 --- /dev/null +++ b/packages/webapp/components/explore/ExploreTopNewsHeader.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import MyFeedHeading from '@dailydotdev/shared/src/components/filters/MyFeedHeading'; +import { AchievementTrackerButton } from '@dailydotdev/shared/src/components/filters/AchievementTrackerButton'; +import { ToggleClickbaitShield } from '@dailydotdev/shared/src/components/buttons/ToggleClickbaitShield'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/common'; +import { + dateFormatInTimezone, + DEFAULT_TIMEZONE, +} from '@dailydotdev/shared/src/lib/timezones'; +import type { ExploreCategoryId } from './exploreCategories'; +import { getExploreCategoryById } from './exploreCategories'; + +export function ExploreTopNewsHeader({ + activeTabId, +}: { + activeTabId: ExploreCategoryId; +}): ReactElement { + const { user } = useAuthContext(); + const timezone = user?.timezone ?? DEFAULT_TIMEZONE; + const category = getExploreCategoryById(activeTabId); + const title = category?.label ?? 'Explore'; + const todayLabel = dateFormatInTimezone( + new Date(), + 'EEEE, MMMM d, yyyy', + timezone, + ); + const showDate = activeTabId === 'explore'; + + return ( +
+
+ + {title} + + {showDate && ( + + {todayLabel} + + )} +
+
+ + + +
+
+ ); +} diff --git a/packages/webapp/components/explore/NewExploreLayoutBanner.tsx b/packages/webapp/components/explore/NewExploreLayoutBanner.tsx new file mode 100644 index 00000000000..d0e7fee3745 --- /dev/null +++ b/packages/webapp/components/explore/NewExploreLayoutBanner.tsx @@ -0,0 +1,155 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { MiniCloseIcon } from '@dailydotdev/shared/src/components/icons'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { + ExploreLayoutPreference, + setExploreLayoutPreference, +} from '@dailydotdev/shared/src/lib/exploreLayoutPreference'; + +// TODO(prod): revisit whether dismiss should remain session-scoped. +const STORAGE_KEY = 'new_explore_layout_banner_dismissed'; + +const BENEFITS = [ + 'Top stories at a glance', + 'Smarter discovery per topic', + 'Faster scanning, inline actions', +]; + +export function NewExploreLayoutBanner(): ReactElement | null { + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const [dismissed, setDismissed] = useState(false); + const [isFetched, setIsFetched] = useState(false); + const impressionLogged = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + setDismissed(window.sessionStorage.getItem(STORAGE_KEY) === 'true'); + setIsFetched(true); + }, []); + + const showBanner = isFetched && !dismissed; + + useEffect(() => { + if (!showBanner || impressionLogged.current) { + return; + } + impressionLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_id: 'new_explore_layout_banner', + }); + }, [showBanner, logEvent]); + + if (!showBanner) { + return null; + } + + const onDismiss = () => { + logEvent({ + event_name: LogEvent.Click, + target_id: 'new_explore_layout_banner', + extra: JSON.stringify({ action: 'dismiss' }), + }); + setDismissed(true); + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(STORAGE_KEY, 'true'); + } + }; + + const onUndoSwitchToCards = async () => { + logEvent({ + event_name: LogEvent.Click, + target_id: 'new_explore_layout_banner', + extra: JSON.stringify({ action: 'undo_switch_to_cards' }), + }); + setDismissed(false); + setExploreLayoutPreference(ExploreLayoutPreference.New); + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(STORAGE_KEY); + } + }; + + const onSwitchToCards = () => { + logEvent({ + event_name: LogEvent.Click, + target_id: 'new_explore_layout_banner', + extra: JSON.stringify({ action: 'switch_to_cards' }), + }); + setDismissed(true); + setExploreLayoutPreference(ExploreLayoutPreference.Cards); + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(STORAGE_KEY, 'true'); + } + displayToast('You can always change it back on settings', { + action: { + copy: 'Undo', + onClick: onUndoSwitchToCards, + }, + }); + }; + + return ( +
+
+
+
+
+
+
+
+

+ You're viewing the new feed +

+
    + {BENEFITS.map((benefit) => ( +
  • + {benefit} +
  • + ))} +
+
+
+ +
+
+
+
+
+ ); +} diff --git a/packages/webapp/components/explore/exploreCategories.ts b/packages/webapp/components/explore/exploreCategories.ts new file mode 100644 index 00000000000..f0852c7e7de --- /dev/null +++ b/packages/webapp/components/explore/exploreCategories.ts @@ -0,0 +1,46 @@ +export const EXPLORE_CATEGORIES = [ + { id: 'explore', label: 'Explore', path: '/explore' }, + { id: 'videos', label: 'Videos', path: '/explore/videos', isVideos: true }, + { id: 'agentic', label: 'Agentic', path: '/explore/agentic', tag: 'agentic' }, + { id: 'webdev', label: 'Webdev', path: '/explore/webdev', tag: 'webdev' }, + { id: 'backend', label: 'Backend', path: '/explore/backend', tag: 'backend' }, + { + id: 'databases', + label: 'Databases', + path: '/explore/databases', + tag: 'databases', + }, + { id: 'career', label: 'Career', path: '/explore/career', tag: 'career' }, + { id: 'golang', label: 'Golang', path: '/explore/golang', tag: 'golang' }, + { id: 'rust', label: 'Rust', path: '/explore/rust', tag: 'rust' }, + { + id: 'opensource', + label: 'Opensource', + path: '/explore/opensource', + tag: 'open-source', + }, + { id: 'testing', label: 'Testing', path: '/explore/testing', tag: 'testing' }, + { id: 'php', label: 'PHP', path: '/explore/php', tag: 'php' }, + { id: 'java', label: 'Java', path: '/explore/java', tag: 'java' }, +] as const; + +export type ExploreCategory = (typeof EXPLORE_CATEGORIES)[number]; +export type ExploreCategoryId = ExploreCategory['id']; + +export const getExploreCategoryById = ( + id: string | undefined, +): ExploreCategory | undefined => + EXPLORE_CATEGORIES.find((category) => category.id === id); + +const TOPIC_CLUSTER_START_INDEX = EXPLORE_CATEGORIES.findIndex( + (category) => category.id === 'agentic', +); + +/** Categories rendered as agentic topic clusters (everything after Agentic in the nav). */ +export const EXPLORE_TOPIC_CLUSTER_CATEGORIES: ExploreCategory[] = + EXPLORE_CATEGORIES.slice(TOPIC_CLUSTER_START_INDEX + 1); + +export const EXPLORE_TOPIC_CLUSTER_CATEGORY_IDS = + EXPLORE_TOPIC_CLUSTER_CATEGORIES.map( + (category) => category.id, + ) as ExploreCategoryId[]; diff --git a/packages/webapp/components/explore/exploreStoryHelpers.ts b/packages/webapp/components/explore/exploreStoryHelpers.ts new file mode 100644 index 00000000000..3aa67003e9c --- /dev/null +++ b/packages/webapp/components/explore/exploreStoryHelpers.ts @@ -0,0 +1,48 @@ +import type { ExploreStory } from './exploreTypes'; + +export const getExploreStoryTitle = (story: ExploreStory): string => + story.title?.trim() || + story.sharedPost?.title?.trim() || + story.summary?.trim() || + 'Untitled story'; + +export const getExploreStoryImage = (story: ExploreStory): string | undefined => + story.image || story.sharedPost?.image || undefined; + +export const getExploreCommunityAuthorMeta = ( + story: ExploreStory, +): { name: string; image?: string | null } | null => { + const name = + story.author?.name || + story.scout?.name || + story.sharedPost?.author?.name || + story.creatorTwitterName || + story.creatorTwitter || + null; + + if (!name) { + return null; + } + + return { + name, + image: + story.author?.image || + story.scout?.image || + story.sharedPost?.author?.image || + story.creatorTwitterImage || + null, + }; +}; + +/** Community Picks rows in topic clusters: always a display name (falls back to “Unknown”). */ +export const getExploreCommunityPickPublisher = ( + story: ExploreStory, +): { name: string; image?: string } => { + const meta = getExploreCommunityAuthorMeta(story); + if (meta?.name) { + return { name: meta.name, image: meta.image ?? undefined }; + } + + return { name: 'Unknown', image: undefined }; +}; diff --git a/packages/webapp/components/explore/exploreTypes.ts b/packages/webapp/components/explore/exploreTypes.ts new file mode 100644 index 00000000000..2ea68ac582e --- /dev/null +++ b/packages/webapp/components/explore/exploreTypes.ts @@ -0,0 +1,31 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; + +export type ExploreStory = Pick< + Post, + | 'id' + | 'bookmarked' + | 'bookmark' + | 'commented' + | 'title' + | 'summary' + | 'type' + | 'flags' + | 'sharedPost' + | 'author' + | 'scout' + | 'permalink' + | 'commentsPermalink' + | 'createdAt' + | 'creatorTwitter' + | 'creatorTwitterImage' + | 'creatorTwitterName' + | 'readTime' + | 'image' + | 'source' + | 'collectionSources' + | 'numComments' + | 'numUpvotes' + | 'userState' + | 'clickbaitTitleDetected' + | 'translation' +>; diff --git a/packages/webapp/components/explore/useExploreFeedTranslations.ts b/packages/webapp/components/explore/useExploreFeedTranslations.ts new file mode 100644 index 00000000000..58c6e1035d5 --- /dev/null +++ b/packages/webapp/components/explore/useExploreFeedTranslations.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import type { QueryKey } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { + updateTitleTranslation, + useTranslation, +} from '@dailydotdev/shared/src/hooks/translation/useTranslation'; +import type { FeedData, Post } from '@dailydotdev/shared/src/graphql/posts'; + +export type ExploreFeedSection = { + data: FeedData | undefined; + queryKey: QueryKey; +}; + +/** + * Mirrors the smart-title fetching `useFeed` performs, but for the explore + * page's single-page `FeedData` queries. Without this, post titles never get + * replaced with the smart-shielded version, so clicking the Clickbait Shield + * fetches the original title back into the cache and the visible title never + * changes. + */ +export const useExploreFeedTranslations = ( + sections: ExploreFeedSection[], +): void => { + const queryClient = useQueryClient(); + const { fetchTranslations } = useTranslation({ skipLanguageFilter: true }); + const inFlightRef = useRef>(new Set()); + + useEffect(() => { + sections.forEach(({ data, queryKey }) => { + const edges = data?.page?.edges ?? []; + const postsToTranslate: Post[] = []; + edges.forEach((edge) => { + const node = edge?.node; + if ( + !node?.id || + !node?.clickbaitTitleDetected || + !!node.translation?.smartTitle || + inFlightRef.current.has(node.id) + ) { + return; + } + postsToTranslate.push(node); + }); + + if (postsToTranslate.length === 0) { + return; + } + + postsToTranslate.forEach((post) => inFlightRef.current.add(post.id)); + + (async () => { + try { + const results = await fetchTranslations(postsToTranslate); + if (!results?.length) { + return; + } + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData?.page?.edges) { + return oldData; + } + + const updatedEdges = oldData.page.edges.map((edge) => { + let updatedNode = edge.node; + results.forEach((translation) => { + if (translation.id !== updatedNode?.id) { + return; + } + updatedNode = updateTitleTranslation({ + post: updatedNode, + translation, + }); + }); + + if (updatedNode === edge.node) { + return edge; + } + + return { ...edge, node: updatedNode }; + }); + + return { + ...oldData, + page: { ...oldData.page, edges: updatedEdges }, + }; + }); + } finally { + postsToTranslate.forEach((post) => inFlightRef.current.delete(post.id)); + } + })(); + }); + }, [sections, queryClient, fetchTranslations]); +}; diff --git a/packages/webapp/components/explore/useExplorePostActionCallbacks.ts b/packages/webapp/components/explore/useExplorePostActionCallbacks.ts new file mode 100644 index 00000000000..ff8fcab5fd0 --- /dev/null +++ b/packages/webapp/components/explore/useExplorePostActionCallbacks.ts @@ -0,0 +1,135 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { useBookmarkPost } from '@dailydotdev/shared/src/hooks/useBookmarkPost'; +import { useVotePost } from '@dailydotdev/shared/src/hooks/vote/useVotePost'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; + +type ExplorePostActionCallbacks = { + onUpvoteClick: (post: Post) => void; + onDownvoteClick: (post: Post) => Promise; + onBookmarkClick: (post: Post) => void; +}; + +const getOptimisticUpvoteState = (post: Post): Partial => { + const vote = post.userState?.vote ?? UserVote.None; + const currentUpvotes = post.numUpvotes ?? 0; + + if (vote === UserVote.Up) { + return { + numUpvotes: Math.max(0, currentUpvotes - 1), + userState: { ...post.userState, vote: UserVote.None }, + }; + } + + return { + numUpvotes: currentUpvotes + 1, + userState: { ...post.userState, vote: UserVote.Up }, + }; +}; + +const getOptimisticDownvoteState = (post: Post): Partial => { + const vote = post.userState?.vote ?? UserVote.None; + const currentUpvotes = post.numUpvotes ?? 0; + + if (vote === UserVote.Down) { + return { + userState: { ...post.userState, vote: UserVote.None }, + }; + } + + if (vote === UserVote.Up) { + return { + numUpvotes: Math.max(0, currentUpvotes - 1), + userState: { ...post.userState, vote: UserVote.Down }, + }; + } + + return { + userState: { ...post.userState, vote: UserVote.Down }, + }; +}; + +const getOptimisticBookmarkState = (post: Post): Partial => ({ + bookmarked: !post.bookmarked, +}); + +export const useExplorePostActionCallbacks = (): ExplorePostActionCallbacks => { + const queryClient = useQueryClient(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { toggleBookmark } = useBookmarkPost(); + + const updatePostInExploreCache = useCallback( + (postId: string, updater: (p: Post) => Partial) => { + queryClient.setQueriesData({ queryKey: ['explore'] }, (data: unknown) => { + if (!data || typeof data !== 'object') { + return data; + } + + const feedData = data as { page?: { edges?: Array<{ node?: Post }> } }; + const edges = feedData?.page?.edges; + + if (!edges?.length) { + return data; + } + + let changed = false; + const nextEdges = edges.map((edge) => { + if (!edge?.node || edge.node.id !== postId) { + return edge; + } + + changed = true; + return { ...edge, node: { ...edge.node, ...updater(edge.node) } }; + }); + + if (!changed) { + return data; + } + + return { ...feedData, page: { ...feedData.page, edges: nextEdges } }; + }); + }, + [queryClient], + ); + + const onUpvoteClick = useCallback( + (post: Post) => { + updatePostInExploreCache(post.id, getOptimisticUpvoteState); + toggleUpvote({ + payload: post, + origin: Origin.ExplorePage, + }).catch(() => null); + }, + [updatePostInExploreCache, toggleUpvote], + ); + + const onDownvoteClick = useCallback( + async (post: Post) => { + updatePostInExploreCache(post.id, getOptimisticDownvoteState); + await toggleDownvote({ + payload: post, + origin: Origin.ExplorePage, + }); + }, + [updatePostInExploreCache, toggleDownvote], + ); + + const onBookmarkClick = useCallback( + (post: Post) => { + updatePostInExploreCache(post.id, getOptimisticBookmarkState); + toggleBookmark({ + post, + origin: Origin.ExplorePage, + }).catch(() => null); + }, + [updatePostInExploreCache, toggleBookmark], + ); + + return { + onUpvoteClick, + onDownvoteClick, + onBookmarkClick, + }; +}; diff --git a/packages/webapp/components/layouts/MainFeedPage.tsx b/packages/webapp/components/layouts/MainFeedPage.tsx index 2e31f979452..05ce2324f19 100644 --- a/packages/webapp/components/layouts/MainFeedPage.tsx +++ b/packages/webapp/components/layouts/MainFeedPage.tsx @@ -9,6 +9,13 @@ import type { GetDefaultFeedProps } from '@dailydotdev/shared/src/lib/feed'; import { getFeedName } from '@dailydotdev/shared/src/lib/feed'; import dynamic from 'next/dynamic'; import { getLayout } from './FeedLayout'; +import { ExplorePageContent } from '../explore/ExplorePageContent'; +import { + ExploreLayoutPreference, + exploreLayoutPreferenceChangedEvent, + getExploreLayoutPreference, +} from '@dailydotdev/shared/src/lib/exploreLayoutPreference'; +import useCustomDefaultFeed from '@dailydotdev/shared/src/hooks/feed/useCustomDefaultFeed'; const MainFeedLayout = dynamic( () => @@ -68,6 +75,10 @@ export default function MainFeedPage({ }: MainFeedPageProps): ReactElement { const router = useRouter(); const { user } = useContext(AuthContext); + const { isCustomDefaultFeed } = useCustomDefaultFeed(); + const [exploreLayoutPreference, setExploreLayoutPreference] = useState( + ExploreLayoutPreference.New, + ); const isFinderPage = router?.pathname === '/search/posts' || isFinder; const isMyFeedURL = router?.query?.slugOrId === user?.id; const [feedName, setFeedName] = useState( @@ -98,10 +109,48 @@ export default function MainFeedPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.pathname]); + useEffect(() => { + setExploreLayoutPreference(getExploreLayoutPreference()); + + const onPreferenceChange = () => { + setExploreLayoutPreference(getExploreLayoutPreference()); + }; + + window.addEventListener( + exploreLayoutPreferenceChangedEvent, + onPreferenceChange, + ); + + return () => { + window.removeEventListener( + exploreLayoutPreferenceChangedEvent, + onPreferenceChange, + ); + }; + }, []); + if (!feedName) { return <>; } + const shouldShowExploreMainLayout = + router.pathname === '/' && + !!user && + !Boolean(isCustomDefaultFeed) && + exploreLayoutPreference === ExploreLayoutPreference.New; + + if (shouldShowExploreMainLayout) { + return ( + <> + {children} + + + ); + } + return ( ( + +); + +const getExploreLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +ExplorePage.getLayout = getExploreLayout; +ExplorePage.layoutProps = { + screenCentered: false, + seo, +}; + +export default ExplorePage; + +export async function getStaticProps(): Promise< + GetStaticPropsResult +> { + const queryClient = new QueryClient(); + await prefetchExplorePageData({ queryClient, categoryId: 'explore' }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + revalidate: 600, + }; +} diff --git a/packages/webapp/pages/explore/[category].tsx b/packages/webapp/pages/explore/[category].tsx new file mode 100644 index 00000000000..465c0d58257 --- /dev/null +++ b/packages/webapp/pages/explore/[category].tsx @@ -0,0 +1,96 @@ +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 { NextSeoProps } from 'next-seo/lib/types'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { + ExplorePageContent, + prefetchExplorePageData, +} from '../../components/explore/ExplorePageContent'; +import type { ExploreCategoryId } from '../../components/explore/exploreCategories'; +import { + EXPLORE_CATEGORIES, + getExploreCategoryById, +} from '../../components/explore/exploreCategories'; +import { defaultOpenGraph } from '../../next-seo'; +import { getPageSeoTitles } from '../../components/layouts/utils'; + +interface ExploreCategoryPageProps { + activeCategoryId: ExploreCategoryId; + dehydratedState: DehydratedState; +} + +const seoTitles = getPageSeoTitles('Explore developer news'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { + ...defaultOpenGraph, + ...seoTitles.openGraph, + }, + description: + 'Scan happening now updates, latest stories, and top developer news in one place on daily.dev.', +}; + +const ExploreCategoryPage = ({ + activeCategoryId, +}: ExploreCategoryPageProps): ReactElement => ( + +); + +const getExploreLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +ExploreCategoryPage.getLayout = getExploreLayout; +ExploreCategoryPage.layoutProps = { + screenCentered: false, + seo, +}; + +export default ExploreCategoryPage; + +export async function getStaticPaths(): Promise { + const paths = EXPLORE_CATEGORIES.filter( + (category) => category.id !== 'explore', + ).map((category) => ({ + params: { category: category.id }, + })); + + return { + paths, + fallback: false, + }; +} + +export async function getStaticProps( + context: GetStaticPropsContext<{ category: string }>, +): Promise> { + const categoryParam = context.params?.category; + const category = getExploreCategoryById(categoryParam); + + if (!category || category.id === 'explore') { + return { + notFound: true, + }; + } + + const queryClient = new QueryClient(); + await prefetchExplorePageData({ + queryClient, + categoryId: category.id, + }); + + return { + props: { + activeCategoryId: category.id, + dehydratedState: dehydrate(queryClient), + }, + revalidate: 600, + }; +} diff --git a/packages/webapp/public/assets/brief-card-magic.png b/packages/webapp/public/assets/brief-card-magic.png new file mode 100644 index 00000000000..e118ee2bbcb Binary files /dev/null and b/packages/webapp/public/assets/brief-card-magic.png differ diff --git a/packages/webapp/public/assets/explore-top-news-hero.png b/packages/webapp/public/assets/explore-top-news-hero.png new file mode 100644 index 00000000000..8b50af6189d Binary files /dev/null and b/packages/webapp/public/assets/explore-top-news-hero.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d8f2cb637e..52e92e409f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -932,6 +932,9 @@ importers: lottie-react: specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@18.3.1) next: specifier: 15.5.14 version: 15.5.14(@babel/core@7.26.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.82.0) @@ -7281,6 +7284,11 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -8647,6 +8655,7 @@ packages: realistic-structured-clone@2.0.4: resolution: {integrity: sha512-lItAdBIFHUSe6fgztHPtmmWqKUgs+qhcYLi3wTRUl4OTB3Vb8aBVSjGfQZUvkmJCKoX3K9Wf7kyLp/F/208+7A==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} @@ -17066,6 +17075,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@1.7.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.19: