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..c1d824c7e5a 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,88 @@ 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 : undefined + } + > + {!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', + ); + + expect( + screen.getByRole('button', { name: 'Not interesting' }), + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Interesting' })).toBeVisible(); + expect( + screen.getByRole('img', { name: 'daily.dev source icon' }), + ).toBeVisible(); + expect(screen.getByText('Starter feed ready')).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index d58821bd584..fd8fd68a267 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -31,6 +31,7 @@ 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'; const SWIPE_THRESHOLD = 80; @@ -54,9 +55,9 @@ 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 + 3-line title + 4:3 image + padding). */ -const ONBOARDING_POST_CARD_HEIGHT = '24rem'; +const ONBOARDING_POST_CARD_HEIGHT = 'clamp(19.5rem, 42dvh, 24rem)'; /** Swipe stack area: card height plus back-card vertical offset (8px). */ -const ONBOARDING_SWIPE_AREA_HEIGHT = '24.5rem'; +const ONBOARDING_SWIPE_AREA_HEIGHT = `calc(${ONBOARDING_POST_CARD_HEIGHT} + 0.5rem)`; const smoothstep01 = (t: number): number => { const x = Math.min(Math.max(t, 0), 1); @@ -453,6 +454,72 @@ const OnboardingCardBehindParticles = (): ReactElement => ( ); +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, @@ -464,81 +531,20 @@ const OnboardingSwipeHintIcons = ({ onNotInteresting: () => void; onInteresting: () => void; }): ReactElement => { - const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); - const leftVisualStrength = deltaX < 0 ? swipeVisualIntensity : 0; - const rightVisualStrength = deltaX > 0 ? swipeVisualIntensity : 0; - - const leftAccentColor = 'var(--theme-accent-bacon-default)'; - const rightAccentColor = 'var(--theme-accent-avocado-default)'; - const leftSwipeEmphasized = leftVisualStrength > 0; - const rightSwipeEmphasized = rightVisualStrength > 0; - return (
- - + />
); }; @@ -1322,6 +1328,8 @@ const OnboardingPostCard = ({ 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) { @@ -1361,6 +1369,30 @@ const OnboardingPostCard = ({ } } + let sourceAvatar: ReactElement; + if (sourceImage) { + sourceAvatar = ( + {`${sourceName} + ); + } else if (sourceName === 'daily.dev') { + sourceAvatar = ( +
+ +
+ ); + } else { + sourceAvatar =
; + } + return (
- {swipeDirection && ( -
- {swipeDirection === 'right' ? 'INTERESTING' : 'NOT'} -
- )}
-
- {card.source?.image ? ( - {card.source.name - ) : ( -
- )} - - {card.source?.name || 'daily.dev'} - +
+
+ {sourceAvatar} + + {sourceName} + +
+ {swipeDirection ? ( +
+ {swipeDirection === 'right' ? 'INTERESTING' : 'UNINTERESTING'} +
+ ) : null}
-
- {card.image ? ( - {card.title + {card.tags && card.tags.length > 0 && ( +
+ {card.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ {card.summary ? ( + <> + + TLDR + + + {card.summary} + + ) : ( -
+ <> + + TLDR + + + No summary available for this post yet. + + )}
@@ -1458,9 +1522,7 @@ const OnboardingFeedEmptyState = ({ isRefetching: boolean; }): ReactElement => (
- {isRefetching ? ( - - ) : null} + {isRefetching ? : null} 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 = ({ @@ -1576,6 +1642,7 @@ const HotAndColdModal = ({ headerSlot, topSlot, bottomSlot, + onboardingContent, showHeader = true, showDefaultActions = true, showAddHotTakeButton = true, @@ -1586,6 +1653,7 @@ const HotAndColdModal = ({ onDismissedOnboardingCardsChange, onOnboardingFeedRetry, onboardingFeedRefetching = false, + onboardingActionLayout = 'bottom', className, ...props }: HotAndColdModalProps): ReactElement => { @@ -1636,7 +1704,9 @@ const HotAndColdModal = ({ setOnboardingIntroDelta(0); }, []); - const isOnboardingMode = !!onboardingCards; + const hasOnboardingCards = !!onboardingCards; + const hasOnboardingContent = onboardingContent !== undefined; + const isOnboardingMode = hasOnboardingCards || hasOnboardingContent; const availableOnboardingCards = useMemo( () => (onboardingCards ?? []).filter((card) => !dismissedCardIds.has(card.id)), @@ -1646,7 +1716,7 @@ const HotAndColdModal = ({ const nextOnboardingCard = availableOnboardingCards[1]; const isModalLoading = isOnboardingMode ? onboardingCardsLoading : isLoading; const isModalEmpty = isOnboardingMode - ? !isModalLoading && !currentOnboardingCard + ? !isModalLoading && !hasOnboardingContent && !currentOnboardingCard : isEmpty; const swipeAreaHeight = isOnboardingMode ? ONBOARDING_SWIPE_AREA_HEIGHT @@ -1679,7 +1749,7 @@ const HotAndColdModal = ({ useEffect(() => { if ( - !isOnboardingMode || + !hasOnboardingCards || isModalLoading || !currentOnboardingCard || onboardingIntroRepeatCancelledRef.current @@ -1753,7 +1823,34 @@ const HotAndColdModal = ({ setOnboardingIntroDelta(0); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on card id only, not currentOnboardingCard reference - }, [isOnboardingMode, isModalLoading, currentOnboardingCard?.id]); + }, [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( ({ @@ -1800,7 +1897,7 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - if (isOnboardingMode && currentOnboardingCard) { + if (hasOnboardingCards && currentOnboardingCard) { updateDismissedCardIds((prev) => { const next = new Set(prev); next.add(currentOnboardingCard.id); @@ -1820,7 +1917,7 @@ const HotAndColdModal = ({ [ currentOnboardingCard, dismissCurrent, - isOnboardingMode, + hasOnboardingCards, onboardingCards, updateDismissedCardIds, ], @@ -1828,7 +1925,7 @@ const HotAndColdModal = ({ const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - const currentItemId = isOnboardingMode + const currentItemId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; @@ -1865,7 +1962,7 @@ const HotAndColdModal = ({ } onSwipeAction?.( direction, - isOnboardingMode ? { onboardingCardId: currentItemId } : undefined, + hasOnboardingCards ? { onboardingCardId: currentItemId } : undefined, ); let initialPush: number; @@ -1899,7 +1996,7 @@ const HotAndColdModal = ({ [ currentTake, currentOnboardingCard, - isOnboardingMode, + hasOnboardingCards, isAnimating, startDismissAnimation, toggleDownvote, @@ -1913,7 +2010,7 @@ const HotAndColdModal = ({ const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - const currentItemId = isOnboardingMode + const currentItemId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; @@ -1944,7 +2041,7 @@ const HotAndColdModal = ({ cancelHotTakeVote, currentTake, currentOnboardingCard, - isOnboardingMode, + hasOnboardingCards, isAnimating, startDismissAnimation, logEvent, @@ -1953,7 +2050,7 @@ const HotAndColdModal = ({ ], ); - const currentCardId = isOnboardingMode + const currentCardId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; const isCurrentTakeAnimating = @@ -1962,11 +2059,11 @@ const HotAndColdModal = ({ isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; const combinedOnboardingSwipeX = - isOnboardingMode && !isDragging && !isCurrentTakeAnimating + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating ? cardSwipeDelta + onboardingIntroDelta : cardSwipeDelta; const onboardingIntroPlaying = - isOnboardingMode && + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating && onboardingIntroDelta !== 0; @@ -2036,10 +2133,10 @@ const HotAndColdModal = ({
); + const showOnboardingSideActions = onboardingActionLayout === 'sides'; return ( } {headerSlot} @@ -2151,99 +2249,145 @@ const HotAndColdModal = ({ )} - {!isModalLoading && !isModalEmpty && currentCardId && ( + {!isModalLoading && !isModalEmpty && isOnboardingMode && ( <> - {!isOnboardingMode && topSlot} - {isOnboardingMode ? ( -
+
+
{topSlot} - {cardSwipeArea} -
- handleDismiss('right', 'button')} - onNotInteresting={() => handleDismiss('left', 'button')} - /> -
- {bottomSlot} -
- ) : ( - <> - {cardSwipeArea} - {showDefaultActions && ( -
- -
- )} - - )} +
+
)} + + {!isModalLoading && + !isModalEmpty && + !isOnboardingMode && + currentCardId && ( + <> + {topSlot} + {cardSwipeArea} + {showDefaultActions && ( +
+
+ )} + {bottomSlot} + {showAddHotTakeButton && user?.username && ( +
+ +
+ )} + + )}
); diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 9f19d54c9ae..a7281a622f0 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; +import classNames from 'classnames'; import { Origin } from '../../lib/log'; import type { FeedSettings } from '../../graphql/feedSettings'; import { TagSelection } from '../tags/TagSelection'; @@ -11,10 +12,12 @@ import { SearchField } from '../fields/SearchField'; interface EditTagProps { feedSettings: FeedSettings; headline?: string; + headlineClassName?: string; } export const EditTag = ({ feedSettings, headline, + headlineClassName, }: EditTagProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); @@ -31,7 +34,12 @@ export const EditTag = ({ return ( <> -

+

{headline || 'Pick tags that are relevant to you'}

+ generateStorageKey(StorageTopic.Onboarding, hasSeenTagsStorageKey, userId); + +export const getHasSeenTags = (userId?: string | null): boolean | null => { + if (!userId) { + return null; + } + + const value = storageWrapper.getItem(getHasSeenTagsStorageKey(userId)); + + if (value === null) { + return null; + } + + return value === 'true'; +}; + +export const setHasSeenTags = (userId: string, hasSeenTags: boolean): void => { + storageWrapper.setItem(getHasSeenTagsStorageKey(userId), String(hasSeenTags)); +}; diff --git a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx index 64ff996b483..3a5fcc4b594 100644 --- a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx +++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx @@ -10,16 +10,16 @@ import { getSwipeOnboardingBarProgress, getSwipeOnboardingGuidanceMessage, getSwipeOnboardingHeadline, + SWIPE_ONBOARDING_REFINE_TARGET, type SwipeOnboardingProgressCopyVariant, } from '../../lib/swipeOnboardingGuidance'; /** Typing speed; full headline refresh when swipe tier copy changes. */ const SWIPE_HEADLINE_TYPING_MS_PER_CHAR = 12; /** - * Stable min height = 3 × typo-title2 line-height (1.875rem) so headline changes do not - * shift the progress bar. + * Stable min height keeps the typed copy from jumping while the progress bar updates. */ -const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[5.625rem]'; +const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[4.75rem]'; function SwipeOnboardingTypingHeadline({ line1, @@ -59,14 +59,14 @@ function SwipeOnboardingTypingHeadline({ return (
{shownLine1} {shownLine2 !== undefined ? ( @@ -117,9 +117,10 @@ export function SwipeOnboardingProgressHeader({ const progress = getSwipeOnboardingBarProgress(progressCount); const { line1: headlineLine1, line2: headlineLine2 } = getSwipeOnboardingHeadline(progressCount, copyVariant); + const progressValue = Math.min(progressCount, SWIPE_ONBOARDING_REFINE_TARGET); return ( -
+
{/* eslint-disable-next-line react/no-unknown-property -- scoped keyframes for progress bar */}