From 03975a8e201c011ee9816b5af34ce04588d776d4 Mon Sep 17 00:00:00 2001 From: davidercruz Date: Wed, 27 May 2026 15:38:13 +0100 Subject: [PATCH 1/3] feat(onboarding): persona-driven swipe onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines the swipe onboarding flow with the persona picker and removes the user-facing free-text prompt. Users now pick up to three personas; the prompt sent to onboardingDiscoverPosts is built silently from the persona titles + tags + the user's experienceLevel from registration. The "Show popular posts" fallback is preserved. - PersonaSelector gains a 'seed' mode that emits onSelectionChange instead of immediately following persona tags (the legacy 'follow' mode is unchanged for the EditTag flow). - buildSwipePrompt composes a deterministic prompt string from the selected personas + experience level, with unit-test coverage. - Shared SwipePersonaIntro renders the pre-swipe panel for both the funnel step and the standalone /onboarding/swipe preview. - Swipe progress advances only on accepted posts (right-swipes); bar fills linearly 0→100% across 10 swipes (single milestone). --- .../components/filters/MyFeedHeading.spec.tsx | 254 +++ .../src/components/filters/MyFeedHeading.tsx | 90 +- .../modals/hotTakes/HotAndColdModal.spec.tsx | 43 + .../modals/hotTakes/HotAndColdModal.tsx | 1563 +++++++++++++++-- .../onboarding/PersonaSelector.spec.tsx | 17 +- .../components/onboarding/PersonaSelector.tsx | 48 +- .../shared/FunnelStepBackground.tsx | 6 +- .../onboarding/shared/FunnelStepper.tsx | 34 +- packages/shared/src/graphql/actions.ts | 1 + packages/shared/src/lib/featureManagement.ts | 12 + packages/shared/src/lib/feedSettings.ts | 25 + packages/shared/src/styles/base.css | 6 + packages/shared/src/styles/components.css | 1 + .../styles/components/swipe-onboarding.css | 152 ++ .../onboarding/FunnelSwipeOnboardingStep.tsx | 506 ++++++ .../SwipeOnboardingProgressHeader.tsx | 147 ++ .../onboarding/SwipePersonaIntro.tsx | 267 +++ packages/webapp/hooks/useAdaptiveSwipeDeck.ts | 349 ++++ packages/webapp/lib/buildSwipePrompt.spec.ts | 89 + packages/webapp/lib/buildSwipePrompt.ts | 39 + .../lib/swipeOnboardingEligiblePosts.spec.ts | 98 ++ .../lib/swipeOnboardingEligiblePosts.ts | 40 + .../lib/swipeOnboardingGuidance.spec.ts | 19 + .../webapp/lib/swipeOnboardingGuidance.ts | 19 + .../webapp/lib/swipeOnboardingPopularDeck.ts | 107 ++ packages/webapp/lib/swipingBackendApi.ts | 82 + packages/webapp/pages/_app.tsx | 15 +- packages/webapp/pages/onboarding.tsx | 82 +- packages/webapp/pages/onboarding/swipe.tsx | 20 + 29 files changed, 3939 insertions(+), 192 deletions(-) create mode 100644 packages/shared/src/components/filters/MyFeedHeading.spec.tsx create mode 100644 packages/shared/src/lib/feedSettings.ts create mode 100644 packages/shared/src/styles/components/swipe-onboarding.css create mode 100644 packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx create mode 100644 packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx create mode 100644 packages/webapp/components/onboarding/SwipePersonaIntro.tsx create mode 100644 packages/webapp/hooks/useAdaptiveSwipeDeck.ts create mode 100644 packages/webapp/lib/buildSwipePrompt.spec.ts create mode 100644 packages/webapp/lib/buildSwipePrompt.ts create mode 100644 packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts create mode 100644 packages/webapp/lib/swipeOnboardingEligiblePosts.ts create mode 100644 packages/webapp/lib/swipeOnboardingGuidance.spec.ts create mode 100644 packages/webapp/lib/swipeOnboardingGuidance.ts create mode 100644 packages/webapp/lib/swipeOnboardingPopularDeck.ts create mode 100644 packages/webapp/lib/swipingBackendApi.ts create mode 100644 packages/webapp/pages/onboarding/swipe.tsx diff --git a/packages/shared/src/components/filters/MyFeedHeading.spec.tsx b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx new file mode 100644 index 00000000000..64f9976a271 --- /dev/null +++ b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useActiveFeedNameContext } from '../../contexts'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks'; +import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser'; +import { ActionType } from '../../graphql/actions'; +import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings'; +import { SharedFeedPage } from '../utilities'; +import MyFeedHeading from './MyFeedHeading'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../contexts', () => ({ + useActiveFeedNameContext: jest.fn(), +})); + +jest.mock('../../contexts/SettingsContext', () => ({ + useSettingsContext: jest.fn(), +})); + +jest.mock('../../hooks', () => ({ + useActions: jest.fn(), + useFeedLayout: jest.fn(), + useViewSize: jest.fn(), + ViewSize: { + MobileL: 'mobile', + Laptop: 'laptop', + }, +})); + +jest.mock('../../features/shortcuts/hooks/useShortcutsUser', () => ({ + useShortcutsUser: jest.fn(), +})); + +jest.mock('../../hooks/feed/useCustomDefaultFeed', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../AlertDot', () => ({ + AlertDot: ({ className }: { className?: string }) => ( +
+ ), + AlertColor: { Bun: 'bg-accent-bun-default' }, +})); + +jest.mock('../feeds/FeedSettingsButton', () => ({ + FeedSettingsButton: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick: () => void; + }) => ( + + ), +})); + +jest.mock('../../lib/constants', () => ({ + ...jest.requireActual('../../lib/constants'), + webappUrl: 'https://app.daily.dev/', + settingsUrl: 'https://app.daily.dev/settings', +})); + +jest.mock('../../lib/feedSettings', () => ({ + getHasSeenTags: jest.fn(), + setHasSeenTags: jest.fn(), +})); + +const mockUseRouter = useRouter as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; +const mockUseActiveFeedNameContext = useActiveFeedNameContext as jest.Mock; +const mockUseSettingsContext = useSettingsContext as jest.Mock; +const mockUseActions = useActions as jest.Mock; +const mockUseFeedLayout = useFeedLayout as jest.Mock; +const mockUseViewSize = useViewSize as jest.Mock; +const mockUseShortcutsUser = useShortcutsUser as jest.Mock; +const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock; +const mockGetHasSeenTags = getHasSeenTags as jest.Mock; +const mockSetHasSeenTags = setHasSeenTags as jest.Mock; + +const push = jest.fn(); +const completeAction = jest.fn(); + +const renderComponent = () => render(); + +describe('MyFeedHeading', () => { + beforeEach(() => { + push.mockReset(); + push.mockResolvedValue(true); + completeAction.mockReset(); + completeAction.mockResolvedValue(undefined); + mockGetHasSeenTags.mockReset(); + mockGetHasSeenTags.mockReturnValue(null); + mockSetHasSeenTags.mockReset(); + + mockUseRouter.mockReturnValue({ + push, + pathname: '/', + query: {}, + }); + mockUseAuthContext.mockReturnValue({ + user: { id: 'user-1' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.MyFeed, + }); + mockUseSettingsContext.mockReturnValue({ + toggleShowTopSites: jest.fn(), + }); + mockUseActions.mockReturnValue({ + completeAction, + checkHasCompleted: jest.fn().mockReturnValue(false), + isActionsFetched: true, + }); + mockUseFeedLayout.mockReturnValue({ + shouldUseListFeedLayout: false, + }); + mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); + mockUseShortcutsUser.mockReturnValue({ + isOldUserWithNoShortcuts: false, + showToggleShortcuts: false, + }); + mockUseCustomDefaultFeed.mockReturnValue({ + isCustomDefaultFeed: false, + defaultFeedId: 'user-1', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('routes the home custom default feed to its edit page', async () => { + mockUseCustomDefaultFeed.mockReturnValue({ + isCustomDefaultFeed: true, + defaultFeedId: 'feed-1', + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/feed-1/edit', + ); + }); + + it('routes the home For you feed to the user edit page with the tags tab open', async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); + + it('routes the For you feed to the user edit page with the tags tab open', async () => { + mockUseRouter.mockReturnValue({ + push, + pathname: '/my-feed', + query: {}, + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); + + it('routes custom feeds to their slug or id edit page', async () => { + mockUseRouter.mockReturnValue({ + push, + pathname: '/feeds/[slugOrId]', + query: { slugOrId: 'feed-2' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.Custom, + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/feed-2/edit', + ); + }); + + it('shows the tags reminder dot for the For you feed when tags were not seen yet', () => { + mockGetHasSeenTags.mockReturnValue(false); + + renderComponent(); + + expect(screen.getByTestId('alert-dot')).toBeInTheDocument(); + }); + + it('does not show the tags reminder dot for custom feeds', () => { + mockGetHasSeenTags.mockReturnValue(false); + mockUseRouter.mockReturnValue({ + push, + pathname: '/feeds/[slugOrId]', + query: { slugOrId: 'feed-2' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.Custom, + }); + + renderComponent(); + + expect(screen.queryByTestId('alert-dot')).not.toBeInTheDocument(); + }); + + it('marks tags as seen before navigating from the For you feed settings button', async () => { + mockGetHasSeenTags.mockReturnValue(false); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(mockSetHasSeenTags).toHaveBeenCalledWith('user-1', true); + expect(completeAction).toHaveBeenCalledWith(ActionType.HasSeenTags); + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); +}); diff --git a/packages/shared/src/components/filters/MyFeedHeading.tsx b/packages/shared/src/components/filters/MyFeedHeading.tsx index 70f9a0d5cc9..333976ec477 100644 --- a/packages/shared/src/components/filters/MyFeedHeading.tsx +++ b/packages/shared/src/components/filters/MyFeedHeading.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { FilterIcon, PlusIcon } from '../icons'; import { @@ -12,9 +12,13 @@ import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { FeedSettingsButton } from '../feeds/FeedSettingsButton'; +import { AlertColor, AlertDot } from '../AlertDot'; +import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { useAuthContext } from '../../contexts/AuthContext'; import { settingsUrl, webappUrl } from '../../lib/constants'; +import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings'; import { SharedFeedPage } from '../utilities'; import { useActiveFeedNameContext } from '../../contexts'; @@ -26,7 +30,7 @@ function MyFeedHeading({ onOpenFeedFilters, }: MyFeedHeadingProps): ReactElement { const { push, pathname, query } = useRouter(); - const { completeAction } = useActions(); + const { completeAction, checkHasCompleted, isActionsFetched } = useActions(); const { toggleShowTopSites } = useSettingsContext(); const { isOldUserWithNoShortcuts, showToggleShortcuts } = useShortcutsUser(); const isMobile = useViewSize(ViewSize.MobileL); @@ -34,38 +38,90 @@ function MyFeedHeading({ const isLaptop = useViewSize(ViewSize.Laptop); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const { feedName } = useActiveFeedNameContext(); + const { user } = useAuthContext(); + const [hasSeenTagsState, setHasSeenTagsState] = useState( + null, + ); + + const hasSeenTagsAction = + isActionsFetched && checkHasCompleted(ActionType.HasSeenTags); const editFeedUrl = useMemo(() => { if (isCustomDefaultFeed && pathname === '/') { return `${webappUrl}feeds/${defaultFeedId}/edit`; } + if (feedName === SharedFeedPage.MyFeed && user?.id) { + return `${webappUrl}feeds/${user.id}/edit?dview=${FeedSettingsMenu.Tags}`; + } + if (feedName === SharedFeedPage.Custom) { return `${webappUrl}feeds/${query.slugOrId}/edit`; } return `${settingsUrl}/feed/general`; - }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query]); + }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query, user?.id]); + + useEffect(() => { + if (!user?.id) { + setHasSeenTagsState(null); + return; + } + + if (hasSeenTagsAction) { + setHasSeenTags(user.id, true); + setHasSeenTagsState(true); + return; + } + + setHasSeenTagsState(getHasSeenTags(user.id)); + }, [hasSeenTagsAction, user?.id]); + + const shouldShowTagsReminder = + feedName === SharedFeedPage.MyFeed && hasSeenTagsState === false; const onClick = useCallback(() => { + if (shouldShowTagsReminder && user?.id) { + setHasSeenTags(user.id, true); + setHasSeenTagsState(true); + completeAction(ActionType.HasSeenTags).catch(() => null); + } + onOpenFeedFilters?.(); return push(editFeedUrl); - }, [editFeedUrl, onOpenFeedFilters, push]); + }, [ + completeAction, + editFeedUrl, + onOpenFeedFilters, + push, + shouldShowTagsReminder, + user?.id, + ]); return ( <> - } - iconPosition={ - shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined - } - > - {!isMobile ? 'Feed settings' : null} - +
+ } + iconPosition={ + shouldUseListFeedLayout + ? ButtonIconPosition.Right + : ButtonIconPosition.Left + } + > + {!isMobile ? 'Feed settings' : null} + + {shouldShowTagsReminder && ( + + )} +
{showToggleShortcuts && (
} + bottomSlot={
Starter feed ready
} + />, + ); + + const modalBody = document.querySelector('section'); + expect(modalBody).toHaveClass('overflow-y-auto', 'overflow-x-hidden'); + expect(modalBody).not.toHaveClass( + 'tablet:!overflow-x-visible', + 'tablet:!overflow-y-visible', + ); + + // The onboarding panel duplicates its swipe actions (one block for + // mobile, one for tablet) so the row stays visible at every breakpoint; + // JSDOM renders both, so just assert presence of at least one of each. + expect( + screen.getAllByRole('button', { name: 'Not interesting' })[0], + ).toBeVisible(); + expect( + screen.getAllByRole('button', { name: 'Interesting' })[0], + ).toBeVisible(); + expect( + screen.getAllByRole('img', { name: 'daily.dev source icon' })[0], + ).toBeVisible(); + expect(screen.getAllByText('Starter feed ready')[0]).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index d1294480ad3..a4fe14ab405 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -1,5 +1,11 @@ -import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useSwipeable } from 'react-swipeable'; import classNames from 'classnames'; import type { ModalProps } from '../common/Modal'; @@ -11,7 +17,9 @@ import { useLogContext } from '../../../contexts/LogContext'; import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent, Origin } from '../../../lib/log'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { IconSize } from '../../Icon'; import { HotIcon } from '../../icons/Hot'; +import { MiniCloseIcon, VIcon } from '../../icons'; import { Typography, TypographyType, @@ -21,10 +29,19 @@ import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; +import { Loader } from '../../Loader'; +import LogoIcon from '../../../svg/LogoIcon'; import type { HotTake } from '../../../graphql/user/userHotTake'; import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common'; const SWIPE_THRESHOLD = 80; +const ONBOARDING_INTRO_INTERESTING_OFFSET = 56; +const ONBOARDING_INTRO_NOT_OFFSET = -56; +const ONBOARDING_INTRO_PHASE_MS = 160; +const ONBOARDING_INTRO_PAUSE_MS = 55; +const ONBOARDING_INTRO_START_DELAY_MS = 120; +/** Time from the start of one intro play to the start of the next (~4s; hint loop until user interacts). */ +const ONBOARDING_INTRO_REPEAT_INTERVAL_MS = 4000; const DISMISS_ANIMATION_MS = 340; const BUTTON_DISMISS_ANIMATION_MS = 620; const DISMISS_FLY_DISTANCE = 760; @@ -35,6 +52,575 @@ const SKIP_DISMISS_FLY_DISTANCE = 600; const SKIP_DRAG_ELASTICITY_FACTOR = 0.3; const COLD_ACCENT_COLOR = '#123a88'; const HOT_TAKE_CARD_HEIGHT = '28rem'; +/** Title3 × 3 lines (typo-title3 line-height 1.625rem in tailwind/typography.ts). */ +const ONBOARDING_CARD_TITLE_MIN_HEIGHT = '4.875rem'; +/** Fixed onboarding post card (source, title, and topic tags). */ +const ONBOARDING_POST_CARD_HEIGHT = 'clamp(13rem, 28dvh, 16rem)'; +/** Swipe stack area: card height plus back-card vertical offset (8px). */ +const ONBOARDING_SWIPE_AREA_HEIGHT = `calc(${ONBOARDING_POST_CARD_HEIGHT} + 0.5rem)`; +const ONBOARDING_SWIPE_FADE_DISTANCE = SWIPE_THRESHOLD * 4; + +const smoothstep01 = (t: number): number => { + const x = Math.min(Math.max(t, 0), 1); + return x * x * (3 - 2 * x); +}; + +const pauseMs = (ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(() => { + resolve(); + }, ms); + }); + +const runOnboardingIntroAnimation = ({ + signal, + onUpdate, +}: { + signal: { aborted: boolean }; + onUpdate: (value: number) => void; +}): Promise => { + const segment = (from: number, to: number, durationMs: number) => + new Promise((resolve) => { + const startTime = performance.now(); + const tick = (now: number) => { + if (signal.aborted) { + resolve(); + return; + } + const elapsed = now - startTime; + const t = durationMs <= 0 ? 1 : Math.min(elapsed / durationMs, 1); + const eased = smoothstep01(t); + onUpdate(from + (to - from) * eased); + if (t < 1) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + + return (async () => { + await segment( + 0, + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment( + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_NOT_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment(ONBOARDING_INTRO_NOT_OFFSET, 0, ONBOARDING_INTRO_PHASE_MS); + })(); +}; + +const ONBOARDING_BEHIND_PARTICLES_CSS = ` + @keyframes onboardingBehindParticle { + 0% { transform: translate3d(0, 0, 0) scale(1); opacity: 0; } + 10% { opacity: 0.9; } + 35% { transform: translate3d(var(--obp-tx), var(--obp-ty), 0) scale(1.08); opacity: 0.65; } + 65% { transform: translate3d(calc(var(--obp-ex) * 0.55), 3.5rem, 0) scale(0.65); opacity: 0.3; } + 100% { transform: translate3d(var(--obp-ex), 8rem, 0) scale(0.15); opacity: 0; } + } + @keyframes onboardingMagicSpark { + 0%, 100% { transform: translate3d(0, 0, 0) scale(0.75); opacity: 0.45; filter: blur(0); } + 20% { transform: translate3d(var(--oms-x1), var(--oms-y1), 0) scale(1.35); opacity: 1; filter: blur(0); } + 45% { transform: translate3d(var(--oms-x2), var(--oms-y2), 0) scale(1); opacity: 0.65; filter: blur(0); } + 70% { transform: translate3d(var(--oms-x3), var(--oms-y3), 0) scale(1.2); opacity: 0.9; filter: blur(0); } + } + @keyframes onboardingAuraDrift { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.35; } + 33% { transform: translate3d(0.35rem, -0.5rem, 0) scale(1.06); opacity: 0.55; } + 66% { transform: translate3d(-0.25rem, 0.35rem, 0) scale(0.96); opacity: 0.42; } + } +`; + +const ONBOARDING_BEHIND_PARTICLE_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + tx: string; + ty: string; + ex: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '6%', + top: '72%', + size: '0.25rem', + tx: '1.5rem', + ty: '-2.25rem', + ex: '0.5rem', + duration: 4.6, + delay: 0, + className: 'bg-accent-avocado-default/40', + }, + { + left: '92%', + top: '68%', + size: '0.1875rem', + tx: '-1.25rem', + ty: '-2rem', + ex: '-0.75rem', + duration: 4.1, + delay: 0.35, + className: 'bg-accent-bacon-default/35', + }, + { + left: '18%', + top: '88%', + size: '0.3125rem', + tx: '0.75rem', + ty: '-1.25rem', + ex: '1rem', + duration: 3.7, + delay: 0.7, + className: 'bg-text-tertiary/50', + }, + { + left: '78%', + top: '82%', + size: '0.25rem', + tx: '-0.5rem', + ty: '-1.75rem', + ex: '-1.25rem', + duration: 4.3, + delay: 0.2, + className: 'bg-accent-avocado-default/30', + }, + { + left: '44%', + top: '92%', + size: '0.1875rem', + tx: '1rem', + ty: '-0.75rem', + ex: '0.25rem', + duration: 3.4, + delay: 1.1, + className: 'bg-text-tertiary/40', + }, + { + left: '52%', + top: '78%', + size: '0.25rem', + tx: '-1.5rem', + ty: '-2.5rem', + ex: '-0.25rem', + duration: 4.8, + delay: 0.55, + className: 'bg-accent-bacon-default/25', + }, + { + left: '28%', + top: '58%', + size: '0.1875rem', + tx: '2rem', + ty: '0.25rem', + ex: '0.75rem', + duration: 5.1, + delay: 0.9, + className: 'bg-accent-avocado-default/25', + }, + { + left: '66%', + top: '52%', + size: '0.25rem', + tx: '-1.75rem', + ty: '0.5rem', + ex: '-1rem', + duration: 4.4, + delay: 1.4, + className: 'bg-text-tertiary/35', + }, +]; + +const ONBOARDING_MAGIC_SPARK_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + x1: string; + y1: string; + x2: string; + y2: string; + x3: string; + y3: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '12%', + top: '38%', + size: '0.1875rem', + x1: '0.4rem', + y1: '-1.25rem', + x2: '-0.35rem', + y2: '-2rem', + x3: '0.5rem', + y3: '-1rem', + duration: 2.8, + delay: 0, + className: 'bg-accent-avocado-default/90', + }, + { + left: '84%', + top: '42%', + size: '0.15625rem', + x1: '-0.45rem', + y1: '-1rem', + x2: '0.3rem', + y2: '-1.75rem', + x3: '-0.2rem', + y3: '-0.5rem', + duration: 3.2, + delay: 0.4, + className: 'bg-accent-bacon-default/85', + }, + { + left: '48%', + top: '28%', + size: '0.125rem', + x1: '0.25rem', + y1: '-0.75rem', + x2: '0.5rem', + y2: '-1.5rem', + x3: '0.1rem', + y3: '-1.1rem', + duration: 2.4, + delay: 0.8, + className: 'bg-accent-cabbage-default/80', + }, + { + left: '22%', + top: '48%', + size: '0.15625rem', + x1: '0.6rem', + y1: '0.25rem', + x2: '0.2rem', + y2: '-0.5rem', + x3: '0.75rem', + y3: '-0.25rem', + duration: 3.6, + delay: 0.15, + className: 'bg-accent-avocado-default/70', + }, + { + left: '72%', + top: '36%', + size: '0.1875rem', + x1: '-0.5rem', + y1: '-0.5rem', + x2: '-0.75rem', + y2: '-1.25rem', + x3: '-0.35rem', + y3: '-1.75rem', + duration: 2.9, + delay: 1.1, + className: 'bg-accent-bacon-default/75', + }, + { + left: '56%', + top: '44%', + size: '0.125rem', + x1: '-0.2rem', + y1: '-1.5rem', + x2: '0.4rem', + y2: '-2.25rem', + x3: '0.15rem', + y3: '-1.75rem', + duration: 3.4, + delay: 0.55, + className: 'bg-text-tertiary/80', + }, + { + left: '36%', + top: '32%', + size: '0.15625rem', + x1: '0.35rem', + y1: '-0.35rem', + x2: '-0.15rem', + y2: '-1rem', + x3: '0.45rem', + y3: '-1.4rem', + duration: 2.6, + delay: 1.3, + className: 'bg-accent-avocado-default/80', + }, + { + left: '64%', + top: '50%', + size: '0.125rem', + x1: '-0.3rem', + y1: '-1.1rem', + x2: '0.25rem', + y2: '-1.8rem', + x3: '-0.5rem', + y3: '-1.2rem', + duration: 3, + delay: 0.25, + className: 'bg-accent-cheese-default/75', + }, +]; + +const ONBOARDING_SCREEN_TRANSITION_KEYFRAMES = ` + @keyframes onboardingScreenEnter { + from { + opacity: 0; + transform: translateY(0.75rem) scale(0.985); + filter: blur(0.25rem); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } + } + + .onboarding-screen-enter { + animation: onboardingScreenEnter 0.34s cubic-bezier(0.16, 1, 0.3, 1) both; + } + + @media (prefers-reduced-motion: reduce) { + .onboarding-screen-enter { + animation: none; + opacity: 1; + transform: none; + filter: none; + } + } +`; + +const OnboardingCardBehindParticles = (): ReactElement => ( + <> + {/* eslint-disable-next-line react/no-unknown-property -- style tag for scoped keyframes */} + +
+
+
+
+ {ONBOARDING_MAGIC_SPARK_SPECS.map((spark) => ( + + ))} + {ONBOARDING_BEHIND_PARTICLE_SPECS.map((particle) => ( + + ))} +
+ +); + +const OnboardingSwipeEdgeHalos = ({ + deltaX, + isDragging, +}: { + deltaX: number; + isDragging: boolean; +}): ReactElement | null => { + if (!isDragging) { + return null; + } + + const intensity = Math.min( + Math.max(Math.abs(deltaX) / SWIPE_THRESHOLD, 0.18), + 1, + ); + const activeDirection = deltaX >= 0 ? 'right' : 'left'; + + const renderHalo = (direction: 'left' | 'right'): ReactElement => { + const isRight = direction === 'right'; + const accentColor = isRight + ? 'var(--theme-accent-avocado-default)' + : 'var(--theme-accent-bacon-default)'; + const strength = + direction === activeDirection ? intensity : intensity * 0.35; + + return ( + + ); + }; + + return <>{(['left', 'right'] as const).map(renderHalo)}; +}; + +const OnboardingSwipeHintButton = ({ + deltaX, + direction, + disabled, + onClick, +}: { + deltaX: number; + direction: 'left' | 'right'; + disabled: boolean; + onClick: () => void; +}): ReactElement => { + const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); + const isLeftDirection = direction === 'left'; + let visualStrength = 0; + if (isLeftDirection && deltaX < 0) { + visualStrength = swipeVisualIntensity; + } + if (!isLeftDirection && deltaX > 0) { + visualStrength = swipeVisualIntensity; + } + const accentColor = isLeftDirection + ? 'var(--theme-accent-bacon-default)' + : 'var(--theme-accent-avocado-default)'; + const isEmphasized = visualStrength > 0; + const restingClassName = isLeftDirection + ? 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-bacon-default enabled:hover:text-accent-bacon-default enabled:focus-visible:border-accent-bacon-default enabled:focus-visible:text-accent-bacon-default enabled:active:border-accent-bacon-default enabled:active:text-accent-bacon-default' + : 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-avocado-default enabled:hover:text-accent-avocado-default enabled:focus-visible:border-accent-avocado-default enabled:focus-visible:text-accent-avocado-default enabled:active:border-accent-avocado-default enabled:active:text-accent-avocado-default'; + + return ( + + ); +}; + +const OnboardingSwipeHintIcons = ({ + deltaX, + disabled, + onNotInteresting, + onInteresting, +}: { + deltaX: number; + disabled: boolean; + onNotInteresting: () => void; + onInteresting: () => void; +}): ReactElement => { + return ( +
+ + +
+ ); +}; const getElasticDelta = (delta: number): number => { const absoluteDelta = Math.abs(delta); @@ -794,6 +1380,216 @@ const HotTakeCard = ({ ); }; +const OnboardingPostCard = ({ + card, + isTop, + offset, + swipeDelta, + skipDeltaY = 0, + isDismissAnimating, + isDragging, + dismissDurationMs, + useInstantSwipeTransform = false, +}: { + card: OnboardingSwipeCard; + isTop: boolean; + offset: number; + swipeDelta: number; + skipDeltaY?: number; + isDismissAnimating: boolean; + isDragging: boolean; + dismissDurationMs: number; + useInstantSwipeTransform?: boolean; +}): ReactElement => { + const sourceName = card.source?.name || 'daily.dev'; + const sourceImage = card.source?.image; + const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; + let swipeDirection: 'left' | 'right' | null = null; + if (isTop && Math.abs(swipeDelta) > 20) { + swipeDirection = swipeDelta > 0 ? 'right' : 'left'; + } + const swipeIntensity = isTop + ? Math.min(Math.abs(swipeDelta) / SWIPE_THRESHOLD, 1) + : 0; + const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; + const translateX = isTop ? swipeDelta : 0; + const stackScale = isTop ? 1 : 1 - offset * 0.05; + const translateY = isTop ? 0 : offset * 8; + const dismissDistance = isSkipAnimating + ? SKIP_DISMISS_FLY_DISTANCE + : DISMISS_FLY_DISTANCE; + const dismissProgress = + isTop && isDismissAnimating + ? Math.min( + Math.abs(isSkipAnimating ? skipDeltaY : swipeDelta) / dismissDistance, + 1, + ) + : 0; + const swipeFadeProgress = + isTop && (isDragging || isDismissAnimating) + ? Math.min(Math.abs(swipeDelta) / ONBOARDING_SWIPE_FADE_DISTANCE, 1) + : 0; + const scale = isTop ? 1 - dismissProgress * 0.06 : stackScale; + const dismissLift = isTop ? dismissProgress * -22 : 0; + const translateYWithOutro = + translateY + dismissLift + (isTop ? skipDeltaY : 0); + + let transition = + 'transform 0.3s ease, opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease'; + if (isTop) { + if (isDismissAnimating) { + transition = `transform ${dismissDurationMs}ms cubic-bezier(0.16, 0.86, 0.22, 1), opacity ${dismissDurationMs}ms ease-out, filter ${dismissDurationMs}ms ease-out`; + } else if (isDragging || useInstantSwipeTransform) { + transition = 'none'; + } else { + transition = 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)'; + } + } + + let sourceAvatar: ReactElement; + if (sourceImage) { + sourceAvatar = ( + {`${sourceName} + ); + } else if (sourceName === 'daily.dev') { + sourceAvatar = ( +
+ +
+ ); + } else { + sourceAvatar =
; + } + + return ( +
event.preventDefault()} + style={{ + height: ONBOARDING_POST_CARD_HEIGHT, + transform: `translateX(${translateX}px) translateY(${translateYWithOutro}px) rotate(${rotation}deg) scale(${scale})`, + zIndex: 10 - offset, + transition, + opacity: isTop ? 1 - swipeFadeProgress : 1, + filter: + isTop && isDismissAnimating + ? `blur(${dismissProgress * 1.8}px)` + : undefined, + boxShadow: isTop + ? '0 1.25rem 2.75rem -0.75rem rgba(0, 0, 0, 0.45)' + : '0 0.75rem 1.75rem -0.75rem rgba(0, 0, 0, 0.32)', + }} + > +
+
+
+ {sourceAvatar} + + {sourceName} + +
+ {swipeDirection ? ( +
+ {swipeDirection === 'right' ? 'Good' : 'Meh'} +
+ ) : null} +
+
+ + {card.title || 'Popular developer story'} + +
+ {card.tags && card.tags.length > 0 && ( +
+ {card.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +}; + +const OnboardingFeedEmptyState = ({ + onRetry, + isRefetching, +}: { + onRetry?: () => void; + isRefetching: boolean; +}): ReactElement => ( +
+ {isRefetching ? : null} + + Couldn't load stories + + + Check your connection and try again. + + {onRetry ? ( + + ) : null} +
+); + const EmptyState = ({ onAddOwnHotTakeClick, username, @@ -832,10 +1628,77 @@ const EmptyState = ({
); +type SwipeActionDirection = 'left' | 'right' | 'skip'; + +export type OnboardingSwipeActionMeta = { + onboardingCardId?: string; +}; + +export interface OnboardingSwipeCard { + id: string; + title?: string; + summary?: string | null; + image?: string | null; + tags?: string[]; + source?: { + name?: string | null; + image?: string | null; + } | null; +} + +interface HotAndColdModalProps extends ModalProps { + title?: string; + bodyClassName?: string; + headerSlot?: ReactNode; + topSlot?: ReactNode; + progressSlot?: ReactNode; + bottomSlot?: ReactNode; + onboardingFooterSlot?: ReactNode; + onboardingContent?: ReactNode; + showHeader?: boolean; + showDefaultActions?: boolean; + showAddHotTakeButton?: boolean; + onSwipeAction?: ( + direction: SwipeActionDirection, + meta?: OnboardingSwipeActionMeta, + ) => void; + onboardingCards?: OnboardingSwipeCard[]; + onboardingCardsLoading?: boolean; + /** When set, dismissed onboarding cards are controlled by the parent (e.g. persist across view switches). */ + dismissedOnboardingCardIds?: Set; + onDismissedOnboardingCardsChange?: (next: Set) => void; + /** Refetch popular posts when the onboarding deck failed to load. */ + onOnboardingFeedRetry?: () => void; + /** True while onboarding deck query is fetching (initial or retry). */ + onboardingFeedRefetching?: boolean; + /** Renders onboarding swipe actions under the card or beside it on wider viewports. */ + onboardingActionLayout?: 'bottom' | 'sides'; +} + const HotAndColdModal = ({ onRequestClose, + title = 'Hot Takes', + bodyClassName, + headerSlot, + topSlot, + progressSlot, + bottomSlot, + onboardingFooterSlot, + onboardingContent, + showHeader = true, + showDefaultActions = true, + showAddHotTakeButton = true, + onSwipeAction, + onboardingCards, + onboardingCardsLoading = false, + dismissedOnboardingCardIds, + onDismissedOnboardingCardsChange, + onOnboardingFeedRetry, + onboardingFeedRefetching = false, + onboardingActionLayout = 'bottom', + className, ...props -}: ModalProps): ReactElement => { +}: HotAndColdModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = useDiscoverHotTakes(); const { toggleUpvote, toggleDownvote, cancelHotTakeVote } = useVoteHotTake(); @@ -853,6 +1716,53 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); + const [internalDismissedCardIds, setInternalDismissedCardIds] = useState< + Set + >(() => new Set()); + const dismissedCardIds = + dismissedOnboardingCardIds ?? internalDismissedCardIds; + const updateDismissedCardIds = useCallback( + (updater: (prev: Set) => Set) => { + if (onDismissedOnboardingCardsChange) { + const base = dismissedOnboardingCardIds ?? new Set(); + onDismissedOnboardingCardsChange(updater(base)); + return; + } + setInternalDismissedCardIds(updater); + }, + [dismissedOnboardingCardIds, onDismissedOnboardingCardsChange], + ); + const onboardingIntroRepeatCancelledRef = useRef(false); + const onboardingIntroAbortRef = useRef<{ aborted: boolean } | null>(null); + const [onboardingIntroDelta, setOnboardingIntroDelta] = useState(0); + + const abortOnboardingIntro = useCallback(() => { + onboardingIntroRepeatCancelledRef.current = true; + const { current } = onboardingIntroAbortRef; + if (current) { + current.aborted = true; + onboardingIntroAbortRef.current = null; + } + setOnboardingIntroDelta(0); + }, []); + + const hasOnboardingCards = !!onboardingCards; + const hasOnboardingContent = onboardingContent !== undefined; + const isOnboardingMode = hasOnboardingCards || hasOnboardingContent; + const availableOnboardingCards = useMemo( + () => + (onboardingCards ?? []).filter((card) => !dismissedCardIds.has(card.id)), + [dismissedCardIds, onboardingCards], + ); + const currentOnboardingCard = availableOnboardingCards[0]; + const nextOnboardingCard = availableOnboardingCards[1]; + const isModalLoading = isOnboardingMode ? onboardingCardsLoading : isLoading; + const isModalEmpty = isOnboardingMode + ? !isModalLoading && !hasOnboardingContent && !currentOnboardingCard + : isEmpty; + const swipeAreaHeight = isOnboardingMode + ? ONBOARDING_SWIPE_AREA_HEIGHT + : HOT_TAKE_CARD_HEIGHT; useEffect(() => { animatingTakeIdRef.current = animatingTakeId; @@ -879,6 +1789,111 @@ const HotAndColdModal = ({ }; }, []); + useEffect(() => { + if ( + !hasOnboardingCards || + isModalLoading || + !currentOnboardingCard || + onboardingIntroRepeatCancelledRef.current + ) { + return undefined; + } + + let effectCancelled = false; + let nextIterationTimeoutId: number | null = null; + + const runOneIntroIteration = (): void => { + if (effectCancelled || onboardingIntroRepeatCancelledRef.current) { + setOnboardingIntroDelta(0); + return; + } + const animSignal = { aborted: false }; + onboardingIntroAbortRef.current = animSignal; + const iterationStart = performance.now(); + runOnboardingIntroAnimation({ + signal: animSignal, + onUpdate: (value) => { + if ( + !animSignal.aborted && + !onboardingIntroRepeatCancelledRef.current + ) { + setOnboardingIntroDelta(value); + } + }, + }) + .then(() => { + if (onboardingIntroAbortRef.current === animSignal) { + onboardingIntroAbortRef.current = null; + } + if ( + animSignal.aborted || + effectCancelled || + onboardingIntroRepeatCancelledRef.current + ) { + setOnboardingIntroDelta(0); + return; + } + setOnboardingIntroDelta(0); + const elapsed = performance.now() - iterationStart; + const waitMs = Math.max( + 0, + ONBOARDING_INTRO_REPEAT_INTERVAL_MS - elapsed, + ); + nextIterationTimeoutId = window.setTimeout( + runOneIntroIteration, + waitMs, + ); + }) + .catch(() => null); + }; + + nextIterationTimeoutId = window.setTimeout(() => { + nextIterationTimeoutId = null; + runOneIntroIteration(); + }, ONBOARDING_INTRO_START_DELAY_MS); + + return () => { + effectCancelled = true; + if (nextIterationTimeoutId !== null) { + window.clearTimeout(nextIterationTimeoutId); + } + const { current } = onboardingIntroAbortRef; + if (current) { + current.aborted = true; + onboardingIntroAbortRef.current = null; + } + setOnboardingIntroDelta(0); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on card id only, not currentOnboardingCard reference + }, [hasOnboardingCards, isModalLoading, currentOnboardingCard?.id]); + + useEffect(() => { + if (hasOnboardingCards) { + return; + } + + abortOnboardingIntro(); + + if (flyTimerRef.current) { + clearTimeout(flyTimerRef.current); + flyTimerRef.current = null; + } + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + + animatingTakeIdRef.current = null; + setAnimatingTakeId(null); + setDismissDurationMs(DISMISS_ANIMATION_MS); + setIsAnimating(false); + setIsDragging(false); + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + }, [hasOnboardingCards, abortOnboardingIntro]); + const startDismissAnimation = useCallback( ({ takeId, @@ -924,20 +1939,44 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - dismissCurrent(); + if (hasOnboardingCards && currentOnboardingCard) { + updateDismissedCardIds((prev) => { + const next = new Set(prev); + next.add(currentOnboardingCard.id); + const deck = onboardingCards ?? []; + if (deck.length > 0 && deck.every((c) => next.has(c.id))) { + return new Set(); + } + return next; + }); + } else { + dismissCurrent(); + } setIsAnimating(false); dismissTimerRef.current = null; }, durationMs); }, - [dismissCurrent], + [ + currentOnboardingCard, + dismissCurrent, + hasOnboardingCards, + onboardingCards, + updateDismissedCardIds, + ], ); const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - if (!currentTake || isAnimating) { + const currentItemId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + const isButtonSource = source === 'button'; const durationMs = isButtonSource ? BUTTON_DISMISS_ANIMATION_MS @@ -946,21 +1985,27 @@ const HotAndColdModal = ({ logEvent({ event_name: LogEvent.VoteHotAndCold, - target_id: currentTake.id, - extra: JSON.stringify({ vote, direction, hotTakeId: currentTake.id }), + target_id: currentItemId, + extra: JSON.stringify({ vote, direction, hotTakeId: currentItemId }), }); - if (direction === 'right') { - toggleUpvote({ - payload: currentTake, - origin: Origin.HotAndCold, - }); - } else { - toggleDownvote({ - payload: currentTake, - origin: Origin.HotAndCold, - }); + if (!isOnboardingMode && currentTake) { + if (direction === 'right') { + toggleUpvote({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + } else { + toggleDownvote({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + } } + onSwipeAction?.( + direction, + hasOnboardingCards ? { onboardingCardId: currentItemId } : undefined, + ); let initialPush: number; let flyDistance: number; @@ -984,7 +2029,7 @@ const HotAndColdModal = ({ setSwipeDelta(initialPush); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs, flyDelayMs: isButtonSource ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSwipeDelta(flyDistance), @@ -992,30 +2037,93 @@ const HotAndColdModal = ({ }, [ currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, + onSwipeAction, swipeDelta, + abortOnboardingIntro, ], ); + useEffect(() => { + if ( + !props.isOpen || + !hasOnboardingCards || + hasOnboardingContent || + isModalLoading || + !currentOnboardingCard || + isAnimating + ) { + return undefined; + } + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.repeat) { + return; + } + + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + const { target } = event; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target.matches('input, textarea, select, [role="textbox"]')) + ) { + return; + } + + event.preventDefault(); + handleDismiss(event.key === 'ArrowRight' ? 'right' : 'left', 'button'); + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [ + currentOnboardingCard, + handleDismiss, + hasOnboardingCards, + hasOnboardingContent, + isAnimating, + isModalLoading, + props.isOpen, + ]); + const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - if (!currentTake || isAnimating) { + const currentItemId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + logEvent({ event_name: LogEvent.SkipHotTake, - target_id: currentTake.id, + target_id: currentItemId, }); - cancelHotTakeVote({ id: currentTake.id }); + if (!isOnboardingMode && currentTake) { + cancelHotTakeVote({ id: currentTake.id }); + } + onSwipeAction?.('skip'); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs: SKIP_DISMISS_ANIMATION_MS, flyDelayMs: source === 'button' ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSkipDelta(-SKIP_DISMISS_FLY_DISTANCE), @@ -1024,12 +2132,21 @@ const HotAndColdModal = ({ [ cancelHotTakeVote, currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, logEvent, + onSwipeAction, + abortOnboardingIntro, ], ); + const currentCardId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + const handleAddOwnHotTakeClick = useCallback( (e: React.MouseEvent) => { onRequestClose?.(e); @@ -1038,10 +2155,19 @@ const HotAndColdModal = ({ ); const isCurrentTakeAnimating = - !!currentTake && isAnimating && animatingTakeId === currentTake.id; + !!currentCardId && isAnimating && animatingTakeId === currentCardId; const cardSwipeDelta = isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; + const combinedOnboardingSwipeX = + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating + ? cardSwipeDelta + onboardingIntroDelta + : cardSwipeDelta; + const onboardingIntroPlaying = + hasOnboardingCards && + !isDragging && + !isCurrentTakeAnimating && + onboardingIntroDelta !== 0; const handleSwiped = (direction: 'left' | 'right') => { setIsDragging(false); @@ -1058,9 +2184,17 @@ const HotAndColdModal = ({ const handlers = useSwipeable({ onSwiping: (e) => { if (!isAnimating) { + if (isOnboardingMode && e.event.cancelable) { + e.event.preventDefault(); + } + abortOnboardingIntro(); setIsDragging(true); setSwipeDelta(e.deltaX); - if (e.deltaY < 0 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + if ( + !isOnboardingMode && + e.deltaY < 0 && + Math.abs(e.deltaY) > Math.abs(e.deltaX) + ) { setSkipDelta(getElasticDelta(e.deltaY)); } else { setSkipDelta(0); @@ -1073,6 +2207,13 @@ const HotAndColdModal = ({ onSwipedRight: () => handleSwiped('right'), onSwipedUp: () => { setIsDragging(false); + if (isOnboardingMode) { + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + return; + } if ( swipeDeltaYRef.current < 0 && Math.abs(swipeDeltaYRef.current) > SWIPE_THRESHOLD @@ -1092,118 +2233,306 @@ const HotAndColdModal = ({ touchEventOptions: { passive: false }, }); + const cardSwipeArea = ( +
+ {isOnboardingMode ? ( + <> + + {nextOnboardingCard && ( + + )} + {currentOnboardingCard && ( + + )} + + + ) : ( + <> + {nextTake && ( + + )} + {currentTake && ( + + )} + + )} +
+ ); + const showOnboardingSideActions = onboardingActionLayout === 'sides'; + const onboardingSwipeActions = showOnboardingSideActions ? ( +
+
+ handleDismiss('left', 'button')} + /> +
+
+ {cardSwipeArea} +
+
+ handleDismiss('right', 'button')} + /> +
+
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+
+ ) : ( + <> +
{cardSwipeArea}
+
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+ + ); + return ( - - - - {isLoading && ( -
+ + {showHeader && } + + {headerSlot ? ( +
{headerSlot}
+ ) : null} + {isModalLoading && ( +
+ {isOnboardingMode ? : null} - Loading hot takes... + {isOnboardingMode ? 'Loading stories…' : 'Loading hot takes...'}
)} - {!isLoading && isEmpty && ( + {!isModalLoading && isModalEmpty && isOnboardingMode && ( + + )} + + {!isModalLoading && isModalEmpty && !isOnboardingMode && ( )} - {!isLoading && !isEmpty && currentTake && ( + {!isModalLoading && !isModalEmpty && isOnboardingMode && ( <> -
- {nextTake && ( - - )} - -
- -
- + {onboardingFooterSlot ? ( +
+ {onboardingFooterSlot}
- )} + ) : null} )} + + {!isModalLoading && + !isModalEmpty && + !isOnboardingMode && + currentCardId && ( + <> + {topSlot} + {cardSwipeArea} + {showDefaultActions && ( +
+
+ )} + {bottomSlot} + {showAddHotTakeButton && user?.username && ( +
+ +
+ )} + + )} ); diff --git a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx index c27f3403765..5ca3b95bd8a 100644 --- a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx +++ b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx @@ -2,19 +2,6 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PersonaSelector } from './PersonaSelector'; -import { - broadcastPersonaSelection, - broadcastRecommendRequest, -} from './onboardingPopBus'; - -jest.mock('./onboardingPopBus', () => { - const actual = jest.requireActual('./onboardingPopBus'); - return { - ...actual, - broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection), - broadcastRecommendRequest: jest.fn(actual.broadcastRecommendRequest), - }; -}); const mockOnFollowTags = jest.fn().mockResolvedValue({ successful: true }); const mockOnUnfollowTags = jest.fn().mockResolvedValue({ successful: true }); @@ -67,7 +54,7 @@ describe('PersonaSelector', () => { expect(screen.getByText('Backend')).toBeInTheDocument(); }); - it('follows tags and broadcasts pop + recommend on click', async () => { + it('follows tags on click', async () => { renderComponent(); fireEvent.click(await screen.findByText('Frontend')); await waitFor(() => @@ -76,8 +63,6 @@ describe('PersonaSelector', () => { requireLogin: true, }), ); - expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']); - expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']); }); it('allows multi-select without unfollowing previous persona', async () => { diff --git a/packages/shared/src/components/onboarding/PersonaSelector.tsx b/packages/shared/src/components/onboarding/PersonaSelector.tsx index 49d838436d4..2c42078b37c 100644 --- a/packages/shared/src/components/onboarding/PersonaSelector.tsx +++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx @@ -13,24 +13,33 @@ import { RequestKey, StaleTime, generateQueryKey } from '../../lib/query'; import { Button, ButtonColor } from '../buttons/Button'; import { ButtonVariant } from '../buttons/common'; import { ElementPlaceholder } from '../ElementPlaceholder'; -import { - broadcastPersonaSelection, - broadcastRecommendRequest, -} from './onboardingPopBus'; export const MAX_PERSONAS = 3; +const personaButtonClassName = + 'w-full !justify-start text-left tablet:w-auto tablet:!justify-center'; + +export type PersonaSelectorMode = 'follow' | 'seed'; + interface PersonaSelectorProps { className?: string; feedId?: string; + mode?: PersonaSelectorMode; + initialActiveIds?: string[]; + onSelectionChange?: (selected: GQLPersona[]) => void; } export function PersonaSelector({ className, feedId, + initialActiveIds = [], + mode = 'follow', + onSelectionChange, }: PersonaSelectorProps): ReactElement | null { const { logEvent } = useLogContext(); - const [activeIds, setActiveIds] = useState>(new Set()); + const [activeIds, setActiveIds] = useState>( + () => new Set(initialActiveIds), + ); const { onFollowTags, onUnfollowTags } = useTagAndSource({ origin: Origin.OnboardingPersona, feedId, @@ -56,6 +65,13 @@ export function PersonaSelector({ staleTime: StaleTime.OneHour, }); + const emitSelection = (nextActiveIds: Set) => { + if (!onSelectionChange || !personas) { + return; + } + onSelectionChange(personas.filter((p) => nextActiveIds.has(p.id))); + }; + const handleClick = async (persona: GQLPersona) => { const isActive = activeIds.has(persona.id); const isAtCap = !isActive && activeIds.size >= MAX_PERSONAS; @@ -75,21 +91,25 @@ export function PersonaSelector({ }); if (isActive) { - await onUnfollowTags({ tags: persona.tags }); + if (mode === 'follow') { + await onUnfollowTags({ tags: persona.tags }); + } setActiveIds((prev) => { const next = new Set(prev); next.delete(persona.id); + emitSelection(next); return next; }); return; } - broadcastPersonaSelection(persona.tags); - await onFollowTags({ tags: persona.tags, requireLogin: true }); - broadcastRecommendRequest(persona.tags); + if (mode === 'follow') { + await onFollowTags({ tags: persona.tags, requireLogin: true }); + } setActiveIds((prev) => { const next = new Set(prev); next.add(persona.id); + emitSelection(next); return next; }); }; @@ -106,7 +126,7 @@ export function PersonaSelector({ aria-label="Pick a role to follow related tags" aria-busy={isPending} className={classNames( - 'flex w-full max-w-4xl flex-wrap justify-center gap-3', + 'flex w-full max-w-4xl flex-col gap-2 tablet:flex-row tablet:flex-wrap tablet:justify-center tablet:gap-3', className, )} > @@ -115,7 +135,7 @@ export function PersonaSelector({ ))} {!isPending && @@ -124,10 +144,10 @@ export function PersonaSelector({ const isDisabled = !isActive && isAtCap; const buttonContent = ( <> - + {persona.emoji} - {persona.title} + {persona.title} ); @@ -136,6 +156,7 @@ export function PersonaSelector({ + ) : null} + +
+ ); +} + +function SwipeOnboardingBackButton({ + onBack, +}: { + onBack: () => void; +}): ReactElement { + return ( +
+ +
+ ); +} + +function useAnimatedLoadingLabel(isActive: boolean): string { + const [labelIndex, setLabelIndex] = useState(0); + const [dotCount, setDotCount] = useState(1); + + useEffect(() => { + if (!isActive) { + setLabelIndex(0); + setDotCount(1); + return undefined; + } + + const interval = window.setInterval(() => { + setDotCount((currentDotCount) => { + if (currentDotCount === 3) { + setLabelIndex( + (currentLabelIndex) => + (currentLabelIndex + 1) % SWIPE_ONBOARDING_LOADING_LABELS.length, + ); + return 1; + } + + return currentDotCount + 1; + }); + }, 550); + + return () => window.clearInterval(interval); + }, [isActive]); + + return `${SWIPE_ONBOARDING_LOADING_LABELS[labelIndex]}${'.'.repeat( + dotCount, + )}`; +} + +function SwipeOnboardingStarterFeedReady({ + cta, + isCompleting, + onComplete, +}: { + cta?: string; + isCompleting: boolean; + onComplete: () => void; +}): ReactElement { + return ( + <> +
+

+ We have enough signal to build your first pass. You can keep refining + it after this. +

+ +
+
+ +
+ + ); +} + +function SwipeOnboardingCompleteView({ + cta, + isCompleting, + onComplete, +}: { + cta?: string; + isCompleting: boolean; + onComplete: () => void; +}): ReactElement { + return ( +
+
+

+ 100% complete +

+
+ +
+ ); +} + +function FunnelSwipeOnboardingStepComponent({ + parameters: { cta }, + onTransition, +}: FunnelStepEditTags): ReactElement { + const router = useRouter(); + const { user } = useAuthContext(); + const [swipesCount, setSwipesCount] = useState(0); + const [selectedPersonas, setSelectedPersonas] = useState([]); + const [promptLoading, setPromptLoading] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); + const [isSwipeMode, setIsSwipeMode] = useState(false); + const [isIntroExiting, setIsIntroExiting] = useState(false); + const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState< + Set + >(() => new Set()); + const animatedPromptLoadingLabel = useAnimatedLoadingLabel(promptLoading); + const { feedSettings } = useFeedSettings(); + const { onFollowTags } = useTagAndSource({ + origin: Origin.Onboarding, + }); + const { toggleBookmark } = useBookmarkPost(); + const { + cards: adaptiveCards, + getBookmarkablePost, + isLoading: isAdaptiveLoading, + startDeck, + handleSwipe: handleAdaptiveSwipe, + retryFetch, + selectedTags: adaptiveSelectedTags, + } = useAdaptiveSwipeDeck(); + + const handleStartSwipe = useCallback(async () => { + if (promptLoading) { + return; + } + + setPromptLoading(true); + try { + const personaTags = Array.from( + new Set(selectedPersonas.flatMap((persona) => persona.tags)), + ); + const recommendedTags = await recommendOnboardingTags( + personaTags, + SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT, + ).catch(() => []); + const initialTags = Array.from( + new Set([...personaTags, ...recommendedTags]), + ); + const prompt = buildSwipePrompt({ + personas: selectedPersonas, + experienceLevel: user?.experienceLevel, + }); + await startDeck({ prompt, initialTags }); + setIsIntroExiting(true); + await waitMs(SWIPE_ONBOARDING_TRANSITION_MS); + setIsSwipeMode(true); + } finally { + setPromptLoading(false); + } + }, [promptLoading, selectedPersonas, startDeck, user?.experienceLevel]); + + const bookmarkRightSwipePost = useCallback( + (cardId: string) => { + const bookmarkPost = getBookmarkablePost(cardId); + if (!bookmarkPost) { + return; + } + + // Capture the current card payload before deck state changes. + toggleBookmark({ + post: bookmarkPost, + origin: Origin.Onboarding, + disableToast: true, + }).catch(() => null); + }, + [getBookmarkablePost, toggleBookmark], + ); + + const handleSwipeInteraction = useCallback( + ( + direction: 'left' | 'right' | 'skip', + meta?: { onboardingCardId?: string }, + ) => { + if (direction !== 'left' && direction !== 'right') { + return; + } + if (direction === 'right') { + setSwipesCount((currentValue) => currentValue + 1); + } + if (meta?.onboardingCardId) { + if (direction === 'right') { + bookmarkRightSwipePost(meta.onboardingCardId); + } + handleAdaptiveSwipe(direction, meta.onboardingCardId); + } + }, + [bookmarkRightSwipePost, handleAdaptiveSwipe], + ); + + const tagsFromSwipes = useMemo( + () => adaptiveSelectedTags.slice(0, SWIPE_ONBOARDING_TAG_SEED_MAX), + [adaptiveSelectedTags], + ); + + const handleComplete = useCallback(async () => { + if (isCompleting) { + return; + } + + setIsCompleting(true); + const currentTags = feedSettings?.includeTags ?? []; + const currentTagsSet = new Set(currentTags); + const tagsToFollow = tagsFromSwipes.filter( + (tag) => !currentTagsSet.has(tag), + ); + const finalTags = [...currentTags, ...tagsToFollow]; + + try { + if (tagsToFollow.length) { + await onFollowTags({ tags: tagsToFollow }); + } + } catch { + // Let the funnel continue even if persisting tags fails. + } finally { + setIsCompleting(false); + } + + onTransition({ + type: FunnelStepTransitionType.Complete, + details: { + tags: finalTags, + }, + }); + }, [ + feedSettings?.includeTags, + isCompleting, + onFollowTags, + onTransition, + tagsFromSwipes, + ]); + + const canContinue = swipesCount >= SWIPE_ONBOARDING_MIN_TO_UNLOCK; + const isRefineComplete = swipesCount >= SWIPE_ONBOARDING_REFINE_TARGET; + + if (!isSwipeMode) { + return ( + <> +
+ +
+
+
+ +
+ +
+ + ); + } + + const handleBackFromSwipe = (): void => { + setIsIntroExiting(false); + setIsSwipeMode(false); + }; + + const handleCompleteClick = (): void => { + handleComplete().catch(() => null); + }; + + const bottomSlot = isRefineComplete ? null : ( + <> +
+ {canContinue ? ( + + ) : null} +
+
+ + {canContinue ? ( + + ) : null} +
+ + ); + + return ( + +
+ +
+
+ +
+ + ) + } + shouldCloseOnOverlayClick={false} + dismissedOnboardingCardIds={dismissedOnboardingCardIds} + onDismissedOnboardingCardsChange={setDismissedOnboardingCardIds} + onboardingActionLayout="sides" + onboardingCards={adaptiveCards} + onboardingCardsLoading={isAdaptiveLoading} + onboardingFeedRefetching={isAdaptiveLoading} + onboardingContent={ + isRefineComplete ? ( + + ) : undefined + } + onOnboardingFeedRetry={() => { + retryFetch(); + }} + onSwipeAction={(direction, meta) => { + handleSwipeInteraction(direction, meta); + }} + topSlot={ + isRefineComplete ? undefined : ( + <> +
+ +
+
+ + +
+ + ) + } + progressSlot={ + isRefineComplete ? undefined : ( +
+ +
+ ) + } + bottomSlot={bottomSlot} + onRequestClose={() => { + router.back(); + }} + /> + ); +} + +export const FunnelSwipeOnboardingStep = withIsActiveGuard( + FunnelSwipeOnboardingStepComponent, +); diff --git a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx new file mode 100644 index 00000000000..1f3ae5bb870 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx @@ -0,0 +1,147 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + getSwipeOnboardingBarProgress, + SWIPE_ONBOARDING_REFINE_TARGET, +} from '../../lib/swipeOnboardingGuidance'; + +/** Keep in sync with .swipe-onboarding-progress-label in swipe-onboarding.css */ +const PROGRESS_LABEL_TRANSITION_MS = 340; + +export type SwipeOnboardingProgressHeaderProps = { + /** Swipe count and/or tag selections — same scale as onboarding swipes (0–40+). */ + progressCount: number; +}; + +function getProgressGradientStyle(fillPercent: number): CSSProperties { + const gradientProgress = Math.max(fillPercent, 0.01); + + return { + backgroundImage: `linear-gradient( + 90deg, + var(--theme-accent-cabbage-default) 0%, + var(--theme-accent-avocado-default) ${gradientProgress * 0.22}%, + var(--theme-accent-cheese-default) ${gradientProgress * 0.44}%, + var(--theme-accent-cabbage-default) ${gradientProgress * 0.66}%, + var(--theme-accent-avocado-default) ${gradientProgress * 0.88}%, + var(--theme-accent-cheese-default) ${gradientProgress}%, + var(--theme-text-quaternary) ${gradientProgress}%, + var(--theme-text-quaternary) 100% + )`, + }; +} + +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updatePreference = (): void => { + setPrefersReducedMotion(mediaQuery.matches); + }; + + updatePreference(); + mediaQuery.addEventListener('change', updatePreference); + + return () => mediaQuery.removeEventListener('change', updatePreference); + }, []); + + return prefersReducedMotion; +} + +function useAnimatedProgressPercent( + targetPercent: number, + prefersReducedMotion: boolean, +): number { + const [displayPercent, setDisplayPercent] = useState(targetPercent); + const displayPercentRef = useRef(displayPercent); + displayPercentRef.current = displayPercent; + + useEffect(() => { + if (prefersReducedMotion) { + setDisplayPercent(targetPercent); + return undefined; + } + + const startPercent = displayPercentRef.current; + if (startPercent === targetPercent) { + return undefined; + } + + let animationFrame = 0; + const startTime = performance.now(); + + const tick = (now: number): void => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / PROGRESS_LABEL_TRANSITION_MS, 1); + const eased = 1 - (1 - progress) ** 3; + const nextPercent = Math.round( + startPercent + (targetPercent - startPercent) * eased, + ); + + setDisplayPercent(nextPercent); + + if (progress < 1) { + animationFrame = window.requestAnimationFrame(tick); + } + }; + + animationFrame = window.requestAnimationFrame(tick); + + return () => window.cancelAnimationFrame(animationFrame); + }, [prefersReducedMotion, targetPercent]); + + return displayPercent; +} + +const progressLabelClassName = + 'col-start-1 row-start-1 inline-block max-w-full whitespace-nowrap bg-clip-text text-[min(2rem,7vw)] font-black leading-[1.05] tracking-[-0.05em] text-transparent tablet:text-[2.25rem]'; + +export function SwipeOnboardingProgressHeader({ + progressCount, +}: SwipeOnboardingProgressHeaderProps): ReactElement { + const progress = getSwipeOnboardingBarProgress(progressCount); + const progressValue = Math.min(progressCount, SWIPE_ONBOARDING_REFINE_TARGET); + const targetPercent = Math.round(progress); + const isComplete = progressValue >= SWIPE_ONBOARDING_REFINE_TARGET; + const prefersReducedMotion = usePrefersReducedMotion(); + const displayPercent = useAnimatedProgressPercent( + targetPercent, + prefersReducedMotion, + ); + const progressAnnouncement = isComplete + ? '100% complete' + : `${displayPercent}% to complete`; + + return ( +
+ {progressAnnouncement} +

+ {displayPercent}% to complete +

+

+ 100% complete +

+
+ ); +} diff --git a/packages/webapp/components/onboarding/SwipePersonaIntro.tsx b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx new file mode 100644 index 00000000000..837cf4e2e49 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx @@ -0,0 +1,267 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MAX_PERSONAS, + PersonaSelector, +} from '@dailydotdev/shared/src/components/onboarding/PersonaSelector'; +import { MagicIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { RootPortal } from '@dailydotdev/shared/src/components/tooltips/Portal'; +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; + +interface SwipePersonaIntroProps { + initialSelectedPersonas?: GQLPersona[]; + onSelectionChange: (selected: GQLPersona[]) => void; + onStart: () => void; + loading: boolean; + loadingLabel?: string; +} + +const surfaceClassName = + 'w-full overflow-hidden rounded-[2rem] bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]'; + +const panelClassName = 'overflow-hidden'; + +const floatingEmojiPlacements = [ + { + className: 'left-3 top-[18%]', + tx: '4rem', + ty: '-3rem', + rz: '-18deg', + ry: '32deg', + }, + { + className: 'right-3 top-[24%]', + tx: '-4.5rem', + ty: '-2rem', + rz: '18deg', + ry: '-34deg', + }, + { + className: 'bottom-[14%] left-6', + tx: '5rem', + ty: '-4.5rem', + rz: '12deg', + ry: '28deg', + }, + { + className: 'bottom-[18%] right-6', + tx: '-5rem', + ty: '-4rem', + rz: '-12deg', + ry: '-28deg', + }, +] as const; + +type FloatingEmoji = { + id: string; + emoji: string; + placementIndex: number; +}; + +type SwipePersonaIntroHeadingVariant = 'persona' | 'swipe'; + +const swipePersonaIntroHeadingCopy: Record< + SwipePersonaIntroHeadingVariant, + { + eyebrow: string; + title: string; + description: ReactElement; + } +> = { + persona: { + eyebrow: 'Personalize your daily.dev feed', + title: 'What kind of dev are you?', + description: ( + <> + Pick up to 3 roles. +
+ We'll line up posts you'll actually want to read. + + ), + }, + swipe: { + eyebrow: 'Teach us what you like', + title: 'Swipe to tune your feed', + description: ( + <> + Swipe right → on posts you want more of. +
+ Swipe left ← on posts that are not for you. + + ), + }, +}; + +export function SwipePersonaIntroHeading({ + variant = 'persona', +}: { + variant?: SwipePersonaIntroHeadingVariant; +}): ReactElement { + const copy = swipePersonaIntroHeadingCopy[variant]; + + return ( +
+ + + {copy.eyebrow} + +

+ {copy.title} +

+

+ {copy.description} +

+
+ ); +} + +export function SwipePersonaIntro({ + initialSelectedPersonas = [], + onSelectionChange, + onStart, + loading, + loadingLabel, +}: SwipePersonaIntroProps): ReactElement { + const [selectedCount, setSelectedCount] = useState( + initialSelectedPersonas.length, + ); + const [floatingEmojis, setFloatingEmojis] = useState([]); + const selectedPersonasRef = useRef>( + new Set(initialSelectedPersonas.map((persona) => persona.id)), + ); + const floatingEmojiCountRef = useRef(0); + const cleanupTimersRef = useRef([]); + const shouldShowActions = selectedCount >= MAX_PERSONAS; + const handleSelectionChange = useCallback( + (selected: GQLPersona[]) => { + const previousIds = selectedPersonasRef.current; + const addedPersona = selected.find( + (persona) => !previousIds.has(persona.id), + ); + selectedPersonasRef.current = new Set( + selected.map((persona) => persona.id), + ); + + if (addedPersona) { + const count = floatingEmojiCountRef.current; + floatingEmojiCountRef.current += 1; + const floatingEmoji: FloatingEmoji = { + id: `${addedPersona.id}-${count}`, + emoji: addedPersona.emoji, + placementIndex: count % floatingEmojiPlacements.length, + }; + + setFloatingEmojis((current) => [...current, floatingEmoji]); + const timer = window.setTimeout(() => { + setFloatingEmojis((current) => + current.filter((item) => item.id !== floatingEmoji.id), + ); + }, 3200); + cleanupTimersRef.current.push(timer); + } + + setSelectedCount(selected.length); + onSelectionChange(selected); + }, + [onSelectionChange], + ); + + useEffect(() => { + const timersRef = cleanupTimersRef; + return () => { + timersRef.current.forEach((timer) => window.clearTimeout(timer)); + }; + }, []); + + const renderNextButton = (): ReactElement => ( + + ); + + return ( +
+ {floatingEmojis.length > 0 && ( +
+ {floatingEmojis.map((item) => { + const placement = floatingEmojiPlacements[item.placementIndex]; + + return ( + + {item.emoji} + + ); + })} +
+ )} +
+ +
+
+ persona.id, + )} + mode="seed" + onSelectionChange={handleSelectionChange} + /> +
+ {shouldShowActions ? ( + <> + +
+
+ {renderNextButton()} +
+
+
+
+ {renderNextButton()} +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts new file mode 100644 index 00000000000..ff7a63aa5b1 --- /dev/null +++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts @@ -0,0 +1,349 @@ +import { useCallback, useRef, useState } from 'react'; +import type { OnboardingSwipeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import type { PostSummary } from '../lib/swipingBackendApi'; +import { discoverPosts } from '../lib/swipingBackendApi'; +import { fetchSwipeOnboardingPopularDeck } from '../lib/swipeOnboardingPopularDeck'; + +// Scoring constants — ported from PostSwiper.tsx +const LIKE_SCORE = 1.5; +const DISLIKE_SCORE = -1; +const ADD_THRESHOLD = 3; +const SATURATE_THRESHOLD = 4.5; +const REMOVE_THRESHOLD = -5; +const IGNORE_AFTER = 10; +const SATURATE_AFTER = 5; +const PREFETCH_AFTER_SWIPES = 3; +const BATCH_SIZE = 8; + +function toSwipeCard(post: PostSummary): OnboardingSwipeCard { + return { + id: post.postId, + summary: post.summary, + title: post.title, + image: null, + tags: post.tags, + source: { + name: 'daily.dev', + image: null, + }, + }; +} + +function toBookmarkablePost(post: PostSummary): Post { + return { + id: post.postId, + title: post.title, + summary: post.summary, + permalink: post.url, + commentsPermalink: post.url, + image: '', + tags: post.tags, + bookmarked: false, + type: PostType.Article, + }; +} + +function postToSummary(post: Post): PostSummary { + const title = post.title ?? ''; + return { + postId: post.id, + title, + summary: post.summary ?? title, + tags: post.tags ?? [], + url: post.permalink ?? post.commentsPermalink ?? '', + sourceId: post.source?.id ?? '', + }; +} + +interface StartDeckOptions { + prompt?: string; + initialTags?: string[]; +} + +interface AdaptiveSwipeDeck { + cards: OnboardingSwipeCard[]; + getBookmarkablePost: (cardId: string) => Post | undefined; + isLoading: boolean; + startDeck: (options?: StartDeckOptions) => Promise; + handleSwipe: (direction: 'left' | 'right', cardId: string) => void; + retryFetch: () => Promise; + selectedTags: string[]; +} + +export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { + const [cards, setCards] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + + // Refs for mutable state that persists across batches + const tagScoresRef = useRef>({}); + const tagSeenCountRef = useRef>({}); + const seenIdsRef = useRef>(new Set()); + const likedTitlesRef = useRef([]); + const startDeckOptionsRef = useRef(undefined); + const prefetchedRef = useRef(null); + const swipesInBatchRef = useRef(0); + const prefetchTriggeredRef = useRef(false); + const selectedTagsRef = useRef([]); + // Keep a PostSummary lookup so we can access tags/title on swipe + const postLookupRef = useRef>(new Map()); + const batchSizeRef = useRef(0); + const isFetchingRef = useRef(false); + + selectedTagsRef.current = selectedTags; + + const getSaturatedTags = useCallback((): string[] => { + return Object.entries(tagScoresRef.current) + .filter(([, score]) => score >= SATURATE_THRESHOLD) + .map(([tag]) => tag); + }, []); + + const getConfirmedTags = useCallback((): string[] => { + const selected = new Set(selectedTagsRef.current); + return Object.entries(tagSeenCountRef.current) + .filter(([tag, count]) => selected.has(tag) && count >= SATURATE_AFTER) + .map(([tag]) => tag); + }, []); + + const registerPosts = useCallback((posts: PostSummary[]): void => { + posts.forEach((post) => { + seenIdsRef.current.add(post.postId); + postLookupRef.current.set(post.postId, post); + }); + }, []); + + const fetchPopularPosts = useCallback( + async (limit: number): Promise => { + const popularPosts = await fetchSwipeOnboardingPopularDeck(); + return popularPosts + .filter((post) => !seenIdsRef.current.has(post.id)) + .slice(0, limit) + .map(postToSummary); + }, + [], + ); + + const fetchAdaptiveOrPopular = useCallback( + async ( + fetchAdaptive: () => Promise, + limit = BATCH_SIZE, + ): Promise => { + try { + const adaptivePosts = await fetchAdaptive(); + if (adaptivePosts.length > 0) { + registerPosts(adaptivePosts); + return adaptivePosts; + } + } catch { + // Fall through to the popular deck when adaptive discovery fails. + } + + try { + const popularPosts = await fetchPopularPosts(limit); + registerPosts(popularPosts); + return popularPosts; + } catch { + return []; + } + }, + [fetchPopularPosts, registerPosts], + ); + + const doFetch = useCallback( + async (n = BATCH_SIZE): Promise => { + return fetchAdaptiveOrPopular(async () => { + const result = await discoverPosts({ + selectedTags: selectedTagsRef.current, + confirmedTags: getConfirmedTags(), + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n, + }); + return result.posts; + }, n); + }, + [fetchAdaptiveOrPopular, getConfirmedTags, getSaturatedTags], + ); + + const getBookmarkablePost = useCallback( + (cardId: string): Post | undefined => { + const post = postLookupRef.current.get(cardId); + return post ? toBookmarkablePost(post) : undefined; + }, + [], + ); + + const loadBatch = useCallback((posts: PostSummary[]) => { + setCards(posts.map(toSwipeCard)); + batchSizeRef.current = posts.length; + swipesInBatchRef.current = 0; + prefetchTriggeredRef.current = false; + }, []); + + const startDeck = useCallback( + async (options?: StartDeckOptions) => { + if (isFetchingRef.current) { + return; + } + isFetchingRef.current = true; + setIsLoading(true); + try { + if (options) { + startDeckOptionsRef.current = options; + } + // Seed with initial tags if provided + if (options?.initialTags?.length) { + setSelectedTags(options.initialTags); + selectedTagsRef.current = options.initialTags; + // Initialize tag scores for seeded tags + const scores: Record = {}; + options.initialTags.forEach((t) => { + scores[t] = ADD_THRESHOLD; + }); + tagScoresRef.current = scores; + } + const posts = await fetchAdaptiveOrPopular(async () => { + const result = await discoverPosts({ + prompt: options?.prompt ?? '', + selectedTags: selectedTagsRef.current, + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n: BATCH_SIZE, + }); + return result.posts; + }); + loadBatch(posts); + } finally { + setIsLoading(false); + isFetchingRef.current = false; + } + }, + [fetchAdaptiveOrPopular, getSaturatedTags, loadBatch], + ); + + const retryFetch = useCallback(async () => { + await startDeck(startDeckOptionsRef.current); + }, [startDeck]); + + const triggerPrefetch = useCallback(async () => { + if (prefetchedRef.current) { + return; + } + try { + prefetchedRef.current = await doFetch(); + } catch { + // Prefetch failure is non-critical + } + }, [doFetch]); + + const loadNextBatch = useCallback(async () => { + setIsLoading(true); + try { + let posts: PostSummary[]; + if (prefetchedRef.current) { + posts = prefetchedRef.current; + prefetchedRef.current = null; + } else { + posts = await doFetch(); + } + loadBatch(posts); + } finally { + setIsLoading(false); + } + }, [doFetch, loadBatch]); + + const handleSwipe = useCallback( + (direction: 'left' | 'right', cardId: string) => { + const post = postLookupRef.current.get(cardId); + if (!post) { + return; + } + + const delta = direction === 'right' ? LIKE_SCORE : DISLIKE_SCORE; + const { tags } = post; + + // Track liked posts + if (direction === 'right') { + likedTitlesRef.current.push(post.title); + } + + // --- Tag scoring (ported from PostSwiper.tsx) --- + const scores = { ...tagScoresRef.current }; + const currentSelected = [...selectedTagsRef.current]; + const selectedSet = new Set(currentSelected); + const promoted: string[] = []; + const demoted: string[] = []; + + tags.forEach((tag) => { + const isSelected = selectedSet.has(tag); + tagSeenCountRef.current[tag] = (tagSeenCountRef.current[tag] || 0) + 1; + + if (!isSelected && tagSeenCountRef.current[tag] >= IGNORE_AFTER) { + delete scores[tag]; + return; + } + + const newScore = (scores[tag] || 0) + delta; + scores[tag] = newScore; + + if (!isSelected && newScore >= ADD_THRESHOLD) { + promoted.push(tag); + delete scores[tag]; + delete tagSeenCountRef.current[tag]; + } else if (isSelected && newScore <= REMOVE_THRESHOLD) { + demoted.push(tag); + delete scores[tag]; + } else if ( + isSelected && + newScore < SATURATE_THRESHOLD && + tagSeenCountRef.current[tag] >= SATURATE_AFTER + ) { + scores[tag] = SATURATE_THRESHOLD; + } + }); + + tagScoresRef.current = scores; + + // Apply promotions and demotions + if (promoted.length > 0 || demoted.length > 0) { + const demotedSet = new Set(demoted); + const nextTags = [ + ...currentSelected.filter((t) => !demotedSet.has(t)), + ...promoted, + ]; + setSelectedTags(nextTags); + selectedTagsRef.current = nextTags; + } + + // --- Prefetch and batch management --- + swipesInBatchRef.current += 1; + if ( + swipesInBatchRef.current >= PREFETCH_AFTER_SWIPES && + !prefetchTriggeredRef.current + ) { + prefetchTriggeredRef.current = true; + triggerPrefetch(); + } + + // Auto-load next batch when all cards in current batch are swiped + if (swipesInBatchRef.current >= batchSizeRef.current) { + loadNextBatch(); + } + }, + [triggerPrefetch, loadNextBatch], + ); + + return { + cards, + getBookmarkablePost, + isLoading, + startDeck, + handleSwipe, + retryFetch, + selectedTags, + }; +} diff --git a/packages/webapp/lib/buildSwipePrompt.spec.ts b/packages/webapp/lib/buildSwipePrompt.spec.ts new file mode 100644 index 00000000000..fc1827d5ce4 --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.spec.ts @@ -0,0 +1,89 @@ +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { buildSwipePrompt } from './buildSwipePrompt'; + +const frontend: GQLPersona = { + id: 'frontend', + title: 'Frontend', + emoji: '🌐', + tags: ['react', 'typescript', 'css'], +}; + +const backend: GQLPersona = { + id: 'backend', + title: 'Backend', + emoji: '🖥️', + tags: ['node', 'typescript', 'sql'], +}; + +describe('buildSwipePrompt', () => { + it('returns empty string when no personas are picked', () => { + expect(buildSwipePrompt({ personas: [] })).toBe(''); + expect(buildSwipePrompt({})).toBe(''); + }); + + it('builds a prompt for a single persona without experience level', () => { + expect(buildSwipePrompt({ personas: [frontend] })).toBe( + "I'm a frontend engineer. I'm interested in: react, typescript, css.", + ); + }); + + it('includes the experience-level label when provided', () => { + expect( + buildSwipePrompt({ + personas: [frontend], + experienceLevel: 'MORE_THAN_2_YEARS', + }), + ).toBe( + "I'm a frontend engineer (Mid-level (2-3 years)). I'm interested in: react, typescript, css.", + ); + }); + + it('joins multiple personas and dedupes overlapping tags', () => { + expect( + buildSwipePrompt({ + personas: [frontend, backend], + }), + ).toBe( + "I work across frontend and backend. I'm interested in: react, typescript, css, node, sql.", + ); + }); + + it('omits the interests clause when personas have no tags', () => { + expect( + buildSwipePrompt({ + personas: [{ ...frontend, tags: [] }], + }), + ).toBe("I'm a frontend engineer."); + }); + + it('does not duplicate engineer for engineering persona titles', () => { + expect( + buildSwipePrompt({ + personas: [ + { + ...frontend, + title: 'Site Reliability Engineer', + tags: [], + }, + ], + }), + ).toBe("I'm a site reliability engineer."); + }); + + it('preserves engineer in multi-persona titles without stripping other roles', () => { + expect( + buildSwipePrompt({ + personas: [ + { + ...frontend, + title: 'Site Reliability Engineer', + tags: [], + }, + backend, + ], + }), + ).toBe( + "I work across site reliability engineer and backend. I'm interested in: node, typescript, sql.", + ); + }); +}); diff --git a/packages/webapp/lib/buildSwipePrompt.ts b/packages/webapp/lib/buildSwipePrompt.ts new file mode 100644 index 00000000000..52a7d66cd9c --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.ts @@ -0,0 +1,39 @@ +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { UserExperienceLevel } from '@dailydotdev/shared/src/lib/user'; + +interface BuildSwipePromptArgs { + personas?: GQLPersona[]; + experienceLevel?: keyof typeof UserExperienceLevel | null; +} + +const dedupe = (values: string[]): string[] => Array.from(new Set(values)); + +const normalizeRoleLabel = (role: string): string => + role.toLowerCase().replace(/\s+engineer$/, ''); + +export function buildSwipePrompt({ + personas, + experienceLevel, +}: BuildSwipePromptArgs): string { + if (!personas?.length) { + return ''; + } + + const roleClause = + personas.length === 1 + ? `I'm a ${normalizeRoleLabel(personas[0].title)} engineer` + : `I work across ${personas + .map((p) => p.title.toLowerCase()) + .join(' and ')}`; + + const experienceLabel = + experienceLevel && UserExperienceLevel[experienceLevel]; + const experienceClause = experienceLabel ? ` (${experienceLabel})` : ''; + + const tags = dedupe(personas.flatMap((p) => p.tags)); + const interestsClause = tags.length + ? ` I'm interested in: ${tags.join(', ')}.` + : ''; + + return `${roleClause}${experienceClause}.${interestsClause}`.trim(); +} diff --git a/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts new file mode 100644 index 00000000000..54e7f8ab0e6 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts @@ -0,0 +1,98 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { PostType } from '@dailydotdev/shared/src/types'; +import { + isSwipeOnboardingEligiblePost, + isSwipeOnboardingRelaxedEligiblePost, +} from './swipeOnboardingEligiblePosts'; + +function post( + type: PostType, + sourceType: SourceType, +): Pick { + return { + type, + source: { + id: 's', + type: sourceType, + } as Post['source'], + }; +} + +describe('isSwipeOnboardingEligiblePost', () => { + it('accepts article, video, and poll from machine sources', () => { + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.Machine)), + ).toBe(true); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.VideoYouTube, SourceType.Machine), + ), + ).toBe(true); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Poll, SourceType.Machine)), + ).toBe(true); + }); + + it('rejects non-machine sources', () => { + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.Squad)), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.User)), + ).toBe(false); + }); + + it('rejects collection, share, freeform, and social posts even from machine', () => { + expect( + isSwipeOnboardingEligiblePost( + post(PostType.Collection, SourceType.Machine), + ), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Share, SourceType.Machine)), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.Freeform, SourceType.Machine), + ), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.SocialTwitter, SourceType.Machine), + ), + ).toBe(false); + }); + + it('rejects when source is missing', () => { + expect( + isSwipeOnboardingEligiblePost({ + type: PostType.Article, + source: undefined, + }), + ).toBe(false); + }); +}); + +describe('isSwipeOnboardingRelaxedEligiblePost', () => { + it('accepts article types from any source', () => { + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Article, SourceType.Squad), + ), + ).toBe(true); + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Article, SourceType.User), + ), + ).toBe(true); + }); + + it('rejects types outside onboarding feed set', () => { + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Share, SourceType.Machine), + ), + ).toBe(false); + }); +}); diff --git a/packages/webapp/lib/swipeOnboardingEligiblePosts.ts b/packages/webapp/lib/swipeOnboardingEligiblePosts.ts new file mode 100644 index 00000000000..30e6e9909a8 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingEligiblePosts.ts @@ -0,0 +1,40 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { PostType } from '@dailydotdev/shared/src/types'; + +/** Post types we request for swipe onboarding (no shares, collections, freeform, etc.). */ +export const SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES: readonly PostType[] = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Poll, +]; + +const swipeOnboardingEligibleTypes: ReadonlySet = new Set( + SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES, +); + +/** + * Swipe onboarding should only surface publication (machine source) posts — not squads, + * user sources, collections, shares, or freeform. + */ +export function isSwipeOnboardingEligiblePost( + post: Pick, +): boolean { + if (!post.source || post.source.type !== SourceType.Machine) { + return false; + } + return swipeOnboardingEligibleTypes.has(post.type); +} + +/** + * Same allowed post types as strict onboarding, any source — used to pad the deck when + * machine-only results are thin (still excludes types not returned by the feed query). + */ +export function isSwipeOnboardingRelaxedEligiblePost( + post: Pick, +): boolean { + if (!post.source) { + return false; + } + return swipeOnboardingEligibleTypes.has(post.type); +} diff --git a/packages/webapp/lib/swipeOnboardingGuidance.spec.ts b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts new file mode 100644 index 00000000000..3f4eb3cad89 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts @@ -0,0 +1,19 @@ +import { getSwipeOnboardingBarProgress } from './swipeOnboardingGuidance'; + +describe('getSwipeOnboardingBarProgress', () => { + it('fills 0 to 100% linearly for swipes 0 to 9', () => { + expect(getSwipeOnboardingBarProgress(0)).toBe(0); + expect(getSwipeOnboardingBarProgress(2)).toBe(20); + expect(getSwipeOnboardingBarProgress(5)).toBe(50); + expect(getSwipeOnboardingBarProgress(9)).toBe(90); + }); + + it('clamps at 100% from 10 onwards', () => { + expect(getSwipeOnboardingBarProgress(10)).toBe(100); + expect(getSwipeOnboardingBarProgress(99)).toBe(100); + }); + + it('clamps negative swipe counts to 0', () => { + expect(getSwipeOnboardingBarProgress(-1)).toBe(0); + }); +}); diff --git a/packages/webapp/lib/swipeOnboardingGuidance.ts b/packages/webapp/lib/swipeOnboardingGuidance.ts new file mode 100644 index 00000000000..5a2620baf6c --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.ts @@ -0,0 +1,19 @@ +/** Minimum swipes before "Continue" unlocks. Also the point where the bar fills. */ +export const SWIPE_ONBOARDING_MIN_TO_UNLOCK = 10; + +/** Swipe count at which the bar is full and "all set" copy starts. */ +export const SWIPE_ONBOARDING_REFINE_TARGET = SWIPE_ONBOARDING_MIN_TO_UNLOCK; + +/** + * Progress bar fill (0 to 100). Linear from 0% to 100% across the first + * {@link SWIPE_ONBOARDING_MIN_TO_UNLOCK} swipes; clamps at 100% afterwards. + */ +export function getSwipeOnboardingBarProgress(progressCount: number): number { + const n = Math.max(0, progressCount); + + if (n >= SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return 100; + } + + return (n / SWIPE_ONBOARDING_MIN_TO_UNLOCK) * 100; +} diff --git a/packages/webapp/lib/swipeOnboardingPopularDeck.ts b/packages/webapp/lib/swipeOnboardingPopularDeck.ts new file mode 100644 index 00000000000..e54f0fdce6a --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingPopularDeck.ts @@ -0,0 +1,107 @@ +import type { Connection } from '@dailydotdev/shared/src/graphql/common'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { MOST_UPVOTED_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { + isSwipeOnboardingEligiblePost, + isSwipeOnboardingRelaxedEligiblePost, + SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES, +} from './swipeOnboardingEligiblePosts'; + +const PAGE_SIZE = 50; +const MAX_PAGES = 12; +const TARGET_STRICT_COUNT = 40; +/** After paging, if strict (machine) posts are below this, append relaxed same-type posts. */ +const STRICT_MIN_BEFORE_RELAXED = 24; +const MAX_DECK_POSTS = 100; + +type MostUpvotedFeedResponse = { + page?: Connection; +}; + +type SwipeOnboardingDeckState = { + seenIds: Set; + strictPosts: Post[]; + relaxedPosts: Post[]; +}; + +function appendEligiblePosts( + edges: NonNullable['edges']>, + state: SwipeOnboardingDeckState, +): void { + edges.reduce((acc, { node }) => { + if (acc.seenIds.has(node.id)) { + return acc; + } + + acc.seenIds.add(node.id); + + if (isSwipeOnboardingEligiblePost(node)) { + acc.strictPosts.push(node); + return acc; + } + + if (isSwipeOnboardingRelaxedEligiblePost(node)) { + acc.relaxedPosts.push(node); + } + + return acc; + }, state); +} + +async function collectSwipeOnboardingPosts( + state: SwipeOnboardingDeckState, + pages = 0, + cursor?: string, +): Promise { + if (pages >= MAX_PAGES) { + return; + } + + const data = await gqlClient.request( + MOST_UPVOTED_FEED_QUERY, + { + first: PAGE_SIZE, + period: 30, + ...(cursor !== undefined ? { after: cursor } : {}), + supportedTypes: [...SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES], + }, + ); + + const conn = data.page; + const edges = conn?.edges ?? []; + + appendEligiblePosts(edges, state); + + const hasNext = conn?.pageInfo?.hasNextPage === true; + const endCursor = conn?.pageInfo?.endCursor ?? undefined; + const shouldStop = + state.strictPosts.length >= TARGET_STRICT_COUNT || !hasNext || !endCursor; + + if (shouldStop) { + return; + } + + await collectSwipeOnboardingPosts(state, pages + 1, endCursor); +} + +/** + * Paginates mostUpvotedFeed for swipe onboarding: prefers machine-sourced posts, then + * fills with the same post types from any source when the strict list is short. + */ +export async function fetchSwipeOnboardingPopularDeck(): Promise { + const state: SwipeOnboardingDeckState = { + seenIds: new Set(), + strictPosts: [], + relaxedPosts: [], + }; + + await collectSwipeOnboardingPosts(state); + + if (state.strictPosts.length >= STRICT_MIN_BEFORE_RELAXED) { + return state.strictPosts.slice(0, MAX_DECK_POSTS); + } + + const merged = [...state.strictPosts, ...state.relaxedPosts]; + return merged.slice(0, MAX_DECK_POSTS); +} diff --git a/packages/webapp/lib/swipingBackendApi.ts b/packages/webapp/lib/swipingBackendApi.ts new file mode 100644 index 00000000000..cd09b61241c --- /dev/null +++ b/packages/webapp/lib/swipingBackendApi.ts @@ -0,0 +1,82 @@ +import { gql } from 'graphql-request'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ONBOARDING_RECOMMEND_TAGS_MUTATION } from '@dailydotdev/shared/src/graphql/feedSettings'; + +export interface PostSummary { + postId: string; + title: string; + summary: string; + tags: string[]; + url: string; + sourceId: string; +} + +export interface DiscoverPostsRequest { + prompt?: string; + selectedTags?: string[]; + confirmedTags?: string[]; + likedTitles?: string[]; + excludeIds?: string[]; + saturatedTags?: string[]; + n?: number; +} + +export interface DiscoverPostsResponse { + posts: PostSummary[]; + subPrompts: string[]; +} + +const ONBOARDING_DISCOVER_POSTS_MUTATION = gql` + mutation OnboardingDiscoverPosts( + $prompt: String + $selectedTags: [String!] + $confirmedTags: [String!] + $likedTitles: [String!] + $excludeIds: [String!] + $saturatedTags: [String!] + $n: Int + ) { + onboardingDiscoverPosts( + prompt: $prompt + selectedTags: $selectedTags + confirmedTags: $confirmedTags + likedTitles: $likedTitles + excludeIds: $excludeIds + saturatedTags: $saturatedTags + n: $n + ) { + posts { + postId + title + summary + tags + url + sourceId + } + subPrompts + } + } +`; + +export async function discoverPosts( + req: DiscoverPostsRequest, +): Promise { + const data = await gqlClient.request<{ + onboardingDiscoverPosts: DiscoverPostsResponse; + }>(ONBOARDING_DISCOVER_POSTS_MUTATION, req); + return data.onboardingDiscoverPosts; +} + +export async function recommendOnboardingTags( + selectedTags: string[], + n: number, +): Promise { + if (!selectedTags.length) { + return []; + } + + const data = await gqlClient.request<{ + onboardingRecommendTags: { tags: string[] }; + }>(ONBOARDING_RECOMMEND_TAGS_MUTATION, { selectedTags, n }); + return data.onboardingRecommendTags.tags; +} diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 381a4250cd5..eed342721db 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -120,6 +120,7 @@ const mainFeedPathnames = new Set([ const hotAndColdModalQueryKey = 'openModal'; const hotAndColdModalQueryValue = 'hottakes'; const hotAndColdModalLegacyQueryValue = 'hotAndCold'; +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; const isOnboardingExcludedPath = (pathname: string): boolean => onboardingExcludedPaths.some((path) => pathname.startsWith(path)); @@ -202,6 +203,14 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { (Array.isArray(hotAndColdModalQuery) && (hotAndColdModalQuery.includes(hotAndColdModalQueryValue) || hotAndColdModalQuery.includes(hotAndColdModalLegacyQueryValue))); + const swipeOnboardingPreviewQuery = + router.query[swipeOnboardingPreviewQueryKey]; + const isSwipeOnboardingPreviewForced = + swipeOnboardingPreviewQuery === '1' || + swipeOnboardingPreviewQuery === 'true' || + (Array.isArray(swipeOnboardingPreviewQuery) && + (swipeOnboardingPreviewQuery.includes('1') || + swipeOnboardingPreviewQuery.includes('true'))); useEffect(() => { if (!shouldOpenHotAndColdFromQuery) { @@ -250,7 +259,10 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { return; } - router.replace('/onboarding'); + const destination = isSwipeOnboardingPreviewForced + ? '/onboarding?swipeOnboardingPreview=1' + : '/onboarding'; + router.replace(destination); // `router.pathname` is depended on explicitly because the `router` ref is // stable across in-app navigations. }, [ @@ -260,6 +272,7 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { router.pathname, isOnboardingComplete, inlineLoginEnabled, + isSwipeOnboardingPreviewForced, ]); useEffect(() => { diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index fd07902fa3e..e1c8f2d2db3 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -26,6 +26,8 @@ import { import { ErrorBoundary } from '@dailydotdev/shared/src/components/ErrorBoundary'; import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { swipeOnboardingFeature } from '@dailydotdev/shared/src/lib/featureManagement'; import type { AuthOptionsProps, AuthProps, @@ -62,7 +64,9 @@ import { FunnelStepper } from '@dailydotdev/shared/src/features/onboarding/share import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { isLocalhost } from '@dailydotdev/shared/src/lib/config'; +import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; import { getPageSeoTitles } from '../components/layouts/utils'; +import { FunnelSwipeOnboardingStep } from '../components/onboarding/FunnelSwipeOnboardingStep'; import { defaultOpenGraph, defaultSeo } from '../next-seo'; const seoTitles = getPageSeoTitles('Get started'); @@ -78,6 +82,13 @@ type PageProps = { showCookieBanner?: boolean; }; +const isSwipeOnboardingPreviewQueryForced = ( + query: string | string[] | undefined, +): boolean => + query === '1' || + query === 'true' || + (Array.isArray(query) && (query.includes('1') || query.includes('true'))); + export const getServerSideProps: GetServerSideProps = async ({ query, req, @@ -158,14 +169,15 @@ const isValidAction = ( }; const useOnboardingAuth = () => { - const formRef = useRef(); + const formRef = useRef(null as unknown as HTMLFormElement); const isMobile = useViewSize(ViewSize.MobileL); const { isAuthReady, anonymous, loginState, isLoggedIn } = useAuthContext(); const router = useRouter(); - const action = isValidAction(router.query.action) && router.query.action; - const { - data: { funnelState }, - } = useOnboardingBoot(); + const action = isValidAction(router.query.action) + ? router.query.action + : undefined; + const { data } = useOnboardingBoot(); + const funnelState = data?.funnelState; const [auth, setAuth] = useAtom(authAtom); const { isLoginFlow, defaultDisplay } = auth; @@ -239,8 +251,10 @@ const useOnboardingAuth = () => { targetId: ExperimentWinner.OnboardingV4, onSuccessfulRegistration: () => updateAuth({ isAuthenticating: false }), onSuccessfulLogin: () => updateAuth({ isAuthenticating: false }), - onAuthStateUpdate: (props: AuthProps) => - updateAuth({ isAuthenticating: true, ...props }), + onAuthStateUpdate: (props: Partial) => { + const { isAuthenticating: incoming, ...rest } = props; + updateAuth({ isAuthenticating: incoming ?? true, ...rest }); + }, onboardingSignupButton: { size: isMobile ? ButtonSize.Medium : ButtonSize.Large, variant: ButtonVariant.Primary, @@ -268,7 +282,7 @@ const useOnboardingAuth = () => { }; }; -function Onboarding({ initialStepId }: PageProps): ReactElement { +function Onboarding({ initialStepId }: PageProps): ReactElement | null { const router = useRouter(); const { isAuthenticating, @@ -280,6 +294,30 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { const { isOnboardingComplete, isOnboardingActionsReady, completeStep } = useOnboardingActions(); const [isFunnelReady, setFunnelReady] = useState(false); + const { value: isSwipeOnboardingEnabled } = useConditionalFeature({ + feature: swipeOnboardingFeature, + shouldEvaluate: isAuthReady && !!funnelState, + }); + const swipeOnboardingPreviewQuery = router.query.swipeOnboardingPreview; + const isSwipeOnboardingPreviewForced = isSwipeOnboardingPreviewQueryForced( + swipeOnboardingPreviewQuery, + ); + const stepComponentOverrides = useMemo( + () => + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? { + [FunnelStepType.EditTags]: FunnelSwipeOnboardingStep, + } + : undefined, + [isSwipeOnboardingEnabled, isSwipeOnboardingPreviewForced], + ); + const swipeOnboardingStepId = useMemo( + () => + funnelState?.funnel.chapters + .flatMap((chapter) => chapter.steps) + .find((step) => step.type === FunnelStepType.EditTags)?.id, + [funnelState?.funnel.chapters], + ); const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); @@ -307,7 +345,7 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { return; } - if (isOnboardingComplete) { + if (isOnboardingComplete && !isSwipeOnboardingPreviewForced) { // If the user is logged in and has completed the onboarding steps, // AND no active stepId is there, redirect them to app. redirectToApp(router); @@ -322,6 +360,7 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isAuthReady, isAuthenticating, isFunnelReady, + isSwipeOnboardingPreviewForced, isLoggedIn, isOnboardingActionsReady, router, @@ -343,17 +382,22 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { ); } + if (!isFunnelReady || !funnelState) { + return null; + } + return ( - isFunnelReady && ( -
- - {/* */} -
- ) +
+ + {/* */} +
); } diff --git a/packages/webapp/pages/onboarding/swipe.tsx b/packages/webapp/pages/onboarding/swipe.tsx new file mode 100644 index 00000000000..674206a97ee --- /dev/null +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -0,0 +1,20 @@ +import type { GetServerSideProps } from 'next'; + +/** Legacy URL: keep redirecting old `/onboarding/swipe` links into the funnel. */ +export const getServerSideProps: GetServerSideProps = async ({ + resolvedUrl, +}) => { + const queryIndex = resolvedUrl.indexOf('?'); + const query = queryIndex === -1 ? '' : resolvedUrl.slice(queryIndex); + + return { + redirect: { + destination: `/onboarding${query}`, + permanent: false, + }, + }; +}; + +export default function SwipeOnboardingRedirect(): null { + return null; +} From ccb38e902732a0ed82f30afa1a0e985f3a164557 Mon Sep 17 00:00:00 2001 From: davidercruz Date: Wed, 27 May 2026 17:52:22 +0100 Subject: [PATCH 2/3] feat(swipe): simpler tag scoring + parallel seed + bigger batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag scoring rewritten: each tag is locked in/out at 3 sightings based on the sign of its accumulated score. Drops the old saturate/confirm/ ignore mechanics and their constants (ADD_THRESHOLD, SATURATE_THRESHOLD, REMOVE_THRESHOLD, IGNORE_AFTER, SATURATE_AFTER) and the saturatedTags/confirmedTags fields on the discoverPosts mutation (unused subPrompts response field also dropped). - BATCH_SIZE bumped from 8 to 12 so a typical 10-right-swipe onboarding fits in one batch. - PREFETCH_AFTER_SWIPES dropped from 3 to 1, and loadNextBatch only flips the loading UI when the prefetch isn't ready — no flash on smooth transitions. - handleStartSwipe now fires recommendOnboardingTags in parallel with startDeck (was serial). When recommendations land they're piped through the new appendSeedTags method on useAdaptiveSwipeDeck so subsequent batches benefit, without blocking initial deck render. - New roundRobinMerge helper (+ spec) balances per-persona recommendation and tag streams so a tag-dense persona doesn't dominate the seed. --- .../onboarding/FunnelSwipeOnboardingStep.tsx | 57 +++++++-- packages/webapp/hooks/useAdaptiveSwipeDeck.ts | 116 ++++++++++-------- packages/webapp/lib/roundRobinMerge.spec.ts | 39 ++++++ packages/webapp/lib/roundRobinMerge.ts | 26 ++++ packages/webapp/lib/swipingBackendApi.ts | 8 -- 5 files changed, 176 insertions(+), 70 deletions(-) create mode 100644 packages/webapp/lib/roundRobinMerge.spec.ts create mode 100644 packages/webapp/lib/roundRobinMerge.ts diff --git a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx index 3ae938cdb7e..092c01b0a2f 100644 --- a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx +++ b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx @@ -31,6 +31,7 @@ import { SWIPE_ONBOARDING_REFINE_TARGET, } from '../../lib/swipeOnboardingGuidance'; import { recommendOnboardingTags } from '../../lib/swipingBackendApi'; +import { roundRobinMerge } from '../../lib/roundRobinMerge'; const SWIPE_ONBOARDING_TAG_SEED_MAX = 25; const SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT = 10; @@ -249,6 +250,7 @@ function FunnelSwipeOnboardingStepComponent({ handleSwipe: handleAdaptiveSwipe, retryFetch, selectedTags: adaptiveSelectedTags, + appendSeedTags, } = useAdaptiveSwipeDeck(); const handleStartSwipe = useCallback(async () => { @@ -258,28 +260,63 @@ function FunnelSwipeOnboardingStepComponent({ setPromptLoading(true); try { - const personaTags = Array.from( - new Set(selectedPersonas.flatMap((persona) => persona.tags)), + // Fan out recommendations per persona so a tag-dense persona (e.g. + // AI/ML) doesn't dominate the seed when the user picks several roles. + const perPersonaCount = Math.max( + 1, + Math.ceil( + SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT / + Math.max(selectedPersonas.length, 1), + ), ); - const recommendedTags = await recommendOnboardingTags( - personaTags, - SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT, - ).catch(() => []); - const initialTags = Array.from( - new Set([...personaTags, ...recommendedTags]), + const personaTags = roundRobinMerge( + selectedPersonas.map((persona) => persona.tags), ); const prompt = buildSwipePrompt({ personas: selectedPersonas, experienceLevel: user?.experienceLevel, }); - await startDeck({ prompt, initialTags }); + + // Kick off recommendations and the deck fetch in parallel — both are + // LLM calls. The deck seeds with persona tags only; once the + // recommendation call resolves we append the extra tags to the deck's + // seed so subsequent batches benefit from the broader signal. + const recommendationsPromise = Promise.all( + selectedPersonas.map((persona) => + recommendOnboardingTags(persona.tags, perPersonaCount).catch( + () => [] as string[], + ), + ), + ).then((streams) => + roundRobinMerge(streams).slice( + 0, + SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT, + ), + ); + const deckPromise = startDeck({ prompt, initialTags: personaTags }); + + recommendationsPromise + .then((recommendedTags) => { + if (recommendedTags.length) { + appendSeedTags(recommendedTags); + } + }) + .catch(() => null); + + await deckPromise; setIsIntroExiting(true); await waitMs(SWIPE_ONBOARDING_TRANSITION_MS); setIsSwipeMode(true); } finally { setPromptLoading(false); } - }, [promptLoading, selectedPersonas, startDeck, user?.experienceLevel]); + }, [ + appendSeedTags, + promptLoading, + selectedPersonas, + startDeck, + user?.experienceLevel, + ]); const bookmarkRightSwipePost = useCallback( (cardId: string) => { diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts index ff7a63aa5b1..586f00ec929 100644 --- a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts +++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts @@ -6,16 +6,16 @@ import type { PostSummary } from '../lib/swipingBackendApi'; import { discoverPosts } from '../lib/swipingBackendApi'; import { fetchSwipeOnboardingPopularDeck } from '../lib/swipeOnboardingPopularDeck'; -// Scoring constants — ported from PostSwiper.tsx +// Scoring constants. +// Each tag is scored across swipes; once a tag has been seen +// TAG_DECISION_LIMIT times, it is locked in (positive score → keep/promote +// into selectedTags) or out (non-positive score → drop from selectedTags +// and never revisit). const LIKE_SCORE = 1.5; const DISLIKE_SCORE = -1; -const ADD_THRESHOLD = 3; -const SATURATE_THRESHOLD = 4.5; -const REMOVE_THRESHOLD = -5; -const IGNORE_AFTER = 10; -const SATURATE_AFTER = 5; -const PREFETCH_AFTER_SWIPES = 3; -const BATCH_SIZE = 8; +const TAG_DECISION_LIMIT = 3; +const PREFETCH_AFTER_SWIPES = 1; +const BATCH_SIZE = 12; function toSwipeCard(post: PostSummary): OnboardingSwipeCard { return { @@ -70,6 +70,7 @@ interface AdaptiveSwipeDeck { handleSwipe: (direction: 'left' | 'right', cardId: string) => void; retryFetch: () => Promise; selectedTags: string[]; + appendSeedTags: (tags: string[]) => void; } export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { @@ -93,19 +94,9 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { const isFetchingRef = useRef(false); selectedTagsRef.current = selectedTags; - - const getSaturatedTags = useCallback((): string[] => { - return Object.entries(tagScoresRef.current) - .filter(([, score]) => score >= SATURATE_THRESHOLD) - .map(([tag]) => tag); - }, []); - - const getConfirmedTags = useCallback((): string[] => { - const selected = new Set(selectedTagsRef.current); - return Object.entries(tagSeenCountRef.current) - .filter(([tag, count]) => selected.has(tag) && count >= SATURATE_AFTER) - .map(([tag]) => tag); - }, []); + // Tags whose decision (in or out) has been locked after TAG_DECISION_LIMIT + // sightings. Their scores are no longer updated by handleSwipe. + const tagDecidedRef = useRef>(new Set()); const registerPosts = useCallback((posts: PostSummary[]): void => { posts.forEach((post) => { @@ -156,16 +147,14 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { return fetchAdaptiveOrPopular(async () => { const result = await discoverPosts({ selectedTags: selectedTagsRef.current, - confirmedTags: getConfirmedTags(), likedTitles: likedTitlesRef.current, excludeIds: [...seenIdsRef.current], - saturatedTags: getSaturatedTags(), n, }); return result.posts; }, n); }, - [fetchAdaptiveOrPopular, getConfirmedTags, getSaturatedTags], + [fetchAdaptiveOrPopular], ); const getBookmarkablePost = useCallback( @@ -194,16 +183,13 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { if (options) { startDeckOptionsRef.current = options; } - // Seed with initial tags if provided + // Seed with initial tags if provided. Seeded tags are selected + // from the start; their score evolves with swipes from 0 and the + // tag is locked in/out after TAG_DECISION_LIMIT sightings. if (options?.initialTags?.length) { setSelectedTags(options.initialTags); selectedTagsRef.current = options.initialTags; - // Initialize tag scores for seeded tags - const scores: Record = {}; - options.initialTags.forEach((t) => { - scores[t] = ADD_THRESHOLD; - }); - tagScoresRef.current = scores; + tagScoresRef.current = {}; } const posts = await fetchAdaptiveOrPopular(async () => { const result = await discoverPosts({ @@ -211,7 +197,6 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { selectedTags: selectedTagsRef.current, likedTitles: likedTitlesRef.current, excludeIds: [...seenIdsRef.current], - saturatedTags: getSaturatedTags(), n: BATCH_SIZE, }); return result.posts; @@ -222,7 +207,7 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { isFetchingRef.current = false; } }, - [fetchAdaptiveOrPopular, getSaturatedTags, loadBatch], + [fetchAdaptiveOrPopular, loadBatch], ); const retryFetch = useCallback(async () => { @@ -241,7 +226,13 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { }, [doFetch]); const loadNextBatch = useCallback(async () => { - setIsLoading(true); + // Only flip the loading UI when we have to wait for the network; when + // the prefetch already landed, swap into it synchronously so the user + // never sees a "loading" flash between batches. + const hasPrefetched = !!prefetchedRef.current; + if (!hasPrefetched) { + setIsLoading(true); + } try { let posts: PostSummary[]; if (prefetchedRef.current) { @@ -252,7 +243,9 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { } loadBatch(posts); } finally { - setIsLoading(false); + if (!hasPrefetched) { + setIsLoading(false); + } } }, [doFetch, loadBatch]); @@ -271,7 +264,10 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { likedTitlesRef.current.push(post.title); } - // --- Tag scoring (ported from PostSwiper.tsx) --- + // --- Tag scoring --- + // Per tag on the swiped post: accumulate score across sightings; once + // a tag has been seen TAG_DECISION_LIMIT times, lock its membership in + // selectedTags based on the sign of the score and stop scoring it. const scores = { ...tagScoresRef.current }; const currentSelected = [...selectedTagsRef.current]; const selectedSet = new Set(currentSelected); @@ -279,30 +275,29 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { const demoted: string[] = []; tags.forEach((tag) => { + if (tagDecidedRef.current.has(tag)) { + return; + } + const isSelected = selectedSet.has(tag); tagSeenCountRef.current[tag] = (tagSeenCountRef.current[tag] || 0) + 1; + const newScore = (scores[tag] || 0) + delta; + scores[tag] = newScore; - if (!isSelected && tagSeenCountRef.current[tag] >= IGNORE_AFTER) { - delete scores[tag]; + if (tagSeenCountRef.current[tag] < TAG_DECISION_LIMIT) { return; } - const newScore = (scores[tag] || 0) + delta; - scores[tag] = newScore; + // Decision: lock in (positive) or drop (zero/negative). + tagDecidedRef.current.add(tag); + delete scores[tag]; - if (!isSelected && newScore >= ADD_THRESHOLD) { - promoted.push(tag); - delete scores[tag]; - delete tagSeenCountRef.current[tag]; - } else if (isSelected && newScore <= REMOVE_THRESHOLD) { + if (newScore > 0) { + if (!isSelected) { + promoted.push(tag); + } + } else if (isSelected) { demoted.push(tag); - delete scores[tag]; - } else if ( - isSelected && - newScore < SATURATE_THRESHOLD && - tagSeenCountRef.current[tag] >= SATURATE_AFTER - ) { - scores[tag] = SATURATE_THRESHOLD; } }); @@ -337,6 +332,22 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { [triggerPrefetch, loadNextBatch], ); + const appendSeedTags = useCallback((tags: string[]) => { + if (!tags.length) { + return; + } + const existing = new Set(selectedTagsRef.current); + const additions = tags.filter( + (tag) => !existing.has(tag) && !tagDecidedRef.current.has(tag), + ); + if (!additions.length) { + return; + } + const nextTags = [...selectedTagsRef.current, ...additions]; + selectedTagsRef.current = nextTags; + setSelectedTags(nextTags); + }, []); + return { cards, getBookmarkablePost, @@ -345,5 +356,6 @@ export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { handleSwipe, retryFetch, selectedTags, + appendSeedTags, }; } diff --git a/packages/webapp/lib/roundRobinMerge.spec.ts b/packages/webapp/lib/roundRobinMerge.spec.ts new file mode 100644 index 00000000000..98d297419f8 --- /dev/null +++ b/packages/webapp/lib/roundRobinMerge.spec.ts @@ -0,0 +1,39 @@ +import { roundRobinMerge } from './roundRobinMerge'; + +describe('roundRobinMerge', () => { + it('returns an empty array for no streams', () => { + expect(roundRobinMerge([])).toEqual([]); + }); + + it('returns the single stream verbatim when only one is supplied', () => { + expect(roundRobinMerge([['a', 'b', 'c']])).toEqual(['a', 'b', 'c']); + }); + + it('interleaves equal-length streams in round-robin order', () => { + expect( + roundRobinMerge([ + ['a1', 'a2'], + ['b1', 'b2'], + ['c1', 'c2'], + ]), + ).toEqual(['a1', 'b1', 'c1', 'a2', 'b2', 'c2']); + }); + + it('keeps interleaving past the shorter streams', () => { + expect(roundRobinMerge([['a1', 'a2', 'a3'], ['b1']])).toEqual([ + 'a1', + 'b1', + 'a2', + 'a3', + ]); + }); + + it('drops duplicates on first occurrence, preserving round-robin ordering', () => { + expect( + roundRobinMerge([ + ['react', 'typescript'], + ['typescript', 'node'], + ]), + ).toEqual(['react', 'typescript', 'node']); + }); +}); diff --git a/packages/webapp/lib/roundRobinMerge.ts b/packages/webapp/lib/roundRobinMerge.ts new file mode 100644 index 00000000000..2c639368688 --- /dev/null +++ b/packages/webapp/lib/roundRobinMerge.ts @@ -0,0 +1,26 @@ +/** + * Interleave the inputs round-robin. Duplicate values are dropped on first + * occurrence, preserving the round-robin ordering. Used to balance + * recommendations across personas so the densest persona doesn't dominate + * the deck seed. + */ +export function roundRobinMerge(streams: readonly T[][]): T[] { + const merged: T[] = []; + const seen = new Set(); + const maxLength = streams.reduce( + (max, stream) => Math.max(max, stream.length), + 0, + ); + + Array.from({ length: maxLength }).forEach((_, i) => { + streams.forEach((stream) => { + const value = stream[i]; + if (value !== undefined && !seen.has(value)) { + seen.add(value); + merged.push(value); + } + }); + }); + + return merged; +} diff --git a/packages/webapp/lib/swipingBackendApi.ts b/packages/webapp/lib/swipingBackendApi.ts index cd09b61241c..f8d8842a706 100644 --- a/packages/webapp/lib/swipingBackendApi.ts +++ b/packages/webapp/lib/swipingBackendApi.ts @@ -14,35 +14,28 @@ export interface PostSummary { export interface DiscoverPostsRequest { prompt?: string; selectedTags?: string[]; - confirmedTags?: string[]; likedTitles?: string[]; excludeIds?: string[]; - saturatedTags?: string[]; n?: number; } export interface DiscoverPostsResponse { posts: PostSummary[]; - subPrompts: string[]; } const ONBOARDING_DISCOVER_POSTS_MUTATION = gql` mutation OnboardingDiscoverPosts( $prompt: String $selectedTags: [String!] - $confirmedTags: [String!] $likedTitles: [String!] $excludeIds: [String!] - $saturatedTags: [String!] $n: Int ) { onboardingDiscoverPosts( prompt: $prompt selectedTags: $selectedTags - confirmedTags: $confirmedTags likedTitles: $likedTitles excludeIds: $excludeIds - saturatedTags: $saturatedTags n: $n ) { posts { @@ -53,7 +46,6 @@ const ONBOARDING_DISCOVER_POSTS_MUTATION = gql` url sourceId } - subPrompts } } `; From 7f8d7e49ba3dd71e648eb4d11f30f9c7500a3893 Mon Sep 17 00:00:00 2001 From: davidercruz Date: Wed, 27 May 2026 18:05:17 +0100 Subject: [PATCH 3/3] chore(swipe): default swipe_onboarding flag to false --- packages/shared/src/lib/featureManagement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 5bf688d6726..132e1e71734 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -196,7 +196,7 @@ export const featureCompanionDemoWidget = new Feature( false, ); -export const swipeOnboardingFeature = new Feature('swipe_onboarding', true); +export const swipeOnboardingFeature = new Feature('swipe_onboarding', false); export const featureUpvoteCountThreshold = new Feature<{ threshold: number;