diff --git a/packages/extension/src/newtab/App.tsx b/packages/extension/src/newtab/App.tsx index 5376f830e7a..7f78c81db37 100644 --- a/packages/extension/src/newtab/App.tsx +++ b/packages/extension/src/newtab/App.tsx @@ -38,7 +38,6 @@ import { ExtensionContextProvider } from '../contexts/ExtensionContext'; import CustomRouter from '../lib/CustomRouter'; import { version } from '../../package.json'; import MainFeedPage from './MainFeedPage'; -import HijackingLoginStrip from './HijackingLoginStrip'; import { BootDataProvider } from '../../../shared/src/contexts/BootProvider'; import { getContentScriptPermissionAndRegister } from '../lib/extensionScripts'; import { useContentScriptStatus } from '../../../shared/src/hooks'; @@ -67,7 +66,7 @@ const feedErrorFallback: ReactElement = ( ); -function HijackingPage({ +function OnboardingHijackPage({ onPageChanged, }: { onPageChanged: (page: string) => void; @@ -87,7 +86,6 @@ function HijackingPage({ onPageChanged={onPageChanged} initialPage="/" shouldInitializeCurrentPage={false} - shortcuts={} /> ); } @@ -108,8 +106,12 @@ function InternalApp(): ReactElement { const { growthbook } = useGrowthBookContext(); const isPageReady = (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; + // Logged-out users now stay on the regular MainFeedPage (with the + // sticky sign-in strip up top + the public Popular feed). Only users + // who have signed up but haven't finished onboarding still get the + // onboarding hijack so we keep nudging them through the flow. const shouldRedirectOnboarding = - isPageReady && (!user || !isOnboardingComplete) && !isTesting; + isPageReady && !!user && !isOnboardingComplete && !isTesting; useCheckLocation(); useCheckCoresRole(); @@ -150,7 +152,7 @@ function InternalApp(): ReactElement { return ( - + ); diff --git a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx deleted file mode 100644 index 087c8a528b7..00000000000 --- a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; -import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; -import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; -import HijackingLoginStrip from './HijackingLoginStrip'; - -jest.mock('@dailydotdev/shared/src/contexts/AuthContext', () => ({ - ...jest.requireActual('@dailydotdev/shared/src/contexts/AuthContext'), - useAuthContext: jest.fn(), -})); - -const LogContext = getLogContextStatic(); -const mockUseAuthContext = useAuthContext as jest.MockedFunction< - typeof useAuthContext ->; -const logEvent = jest.fn(); -const showLogin = jest.fn(); - -const defaultAuthContext = { - user: undefined, - isLoggedIn: false, - referral: undefined, - referralOrigin: undefined, - trackingId: undefined, - shouldShowLogin: false, - showLogin, - closeLogin: jest.fn(), - loginState: undefined, - logout: jest.fn(), - updateUser: jest.fn(), - loadingUser: false, - isFetched: true, - tokenRefreshed: false, - loadedUserFromCache: false, - getRedirectUri: jest.fn(), - anonymous: undefined, - visit: undefined, - firstVisit: undefined, - deleteAccount: jest.fn(), - refetchBoot: jest.fn(), - accessToken: undefined, - squads: [], - isAuthReady: true, - geo: undefined, - isAndroidApp: false, - isGdprCovered: false, - isValidRegion: true, - isFunnel: false, -} satisfies AuthContextData; - -const renderComponent = ( - authContext: Partial = {}, -): ReturnType => { - mockUseAuthContext.mockReturnValue({ - ...defaultAuthContext, - ...authContext, - }); - - return render( - - - , - ); -}; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('HijackingLoginStrip', () => { - it('shows a login CTA for logged out users', () => { - renderComponent(); - - expect( - screen.getByText('Unlock the full daily.dev experience'), - ).toBeVisible(); - expect( - screen.getByText('Log in to pick up where you left off.'), - ).toBeVisible(); - - const cta = screen.getByRole('button', { name: 'Log in to continue' }); - fireEvent.click(cta); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - expect(showLogin).toHaveBeenCalledWith({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: true }, - }); - }); - - it('shows an onboarding CTA for logged in users who still need onboarding', () => { - renderComponent({ user: loggedUser, isLoggedIn: true }); - - expect( - screen.getByText( - 'You still have a few onboarding steps left. Finish them to unlock the full experience.', - ), - ).toBeVisible(); - - const cta = screen.getByRole('link', { name: 'Continue onboarding' }); - const expectedUrl = new URL(onboardingUrl); - expectedUrl.searchParams.append('r', 'extension'); - - expect(cta).toHaveAttribute('href', expectedUrl.toString()); - - fireEvent.click(cta); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - expect(showLogin).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/extension/src/newtab/HijackingLoginStrip.tsx b/packages/extension/src/newtab/HijackingLoginStrip.tsx deleted file mode 100644 index 7185dbc58ac..00000000000 --- a/packages/extension/src/newtab/HijackingLoginStrip.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Button, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; -import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; -import feedStyles from '@dailydotdev/shared/src/components/Feed.module.css'; -import { cloudinaryReadingReminderCat } from '@dailydotdev/shared/src/lib/image'; - -export default function HijackingLoginStrip(): ReactElement { - const { showLogin, user } = useAuthContext(); - const { logEvent } = useLogContext(); - const isLoggedOut = !user; - const onboardingHref = (() => { - const base = new URL(onboardingUrl); - base.searchParams.append('r', 'extension'); - - return base.toString(); - })(); - - const logHijackingClick = (): void => { - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - }; - - return ( -
-
-
-
-
-
-
-
-
-
-

- Unlock the full daily.dev experience -

-

- {isLoggedOut - ? 'Log in to pick up where you left off.' - : 'You still have a few onboarding steps left. Finish them to unlock the full experience.'} -

- {isLoggedOut ? ( - - ) : ( - - )} -
-
-
- Sleeping cat on laptop -
-
-
-
-
- ); -} diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index 20f32e148d7..10536c4bf4e 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; import React, { useCallback, useContext, @@ -18,7 +18,6 @@ import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsCon import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { useFeedLayout } from '@dailydotdev/shared/src/hooks'; import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext'; import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; import useCustomDefaultFeed from '@dailydotdev/shared/src/hooks/feed/useCustomDefaultFeed'; @@ -30,6 +29,9 @@ import { CustomizeNewTabSidebar } from '@dailydotdev/shared/src/features/customi import { isFocusActiveAt } from '@dailydotdev/shared/src/features/customizeNewTab/lib/focusSchedule'; import { normaliseNewTabMode } from '@dailydotdev/shared/src/features/customizeNewTab/lib/newTabMode'; import { DndBanner } from '@dailydotdev/shared/src/components/DndBanner'; +import { ExtensionTopBanners } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionTopBanners'; +import { ExtensionSignInStrip } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionSignInStrip'; +import { ExtensionMockupPanel } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionMockupPanel'; import ShortcutLinks from './ShortcutLinks/ShortcutLinks'; import { CompanionPopupButton } from '../companion/CompanionPopupButton'; import { useCompanionSettings } from '../companion/useCompanionSettings'; @@ -50,7 +52,6 @@ export type MainFeedPageProps = { onPageChanged: (page: string) => unknown; initialPage?: string; shouldInitializeCurrentPage?: boolean; - shortcuts?: ReactNode; }; const normalizePage = (page: string): string => @@ -100,7 +101,6 @@ const MainFeedPageInner = ({ onPageChanged, initialPage, shouldInitializeCurrentPage = true, - shortcuts, }: MainFeedPageProps): ReactElement => { const { logEvent } = useLogContext(); const [isSearchOn, setIsSearchOn] = useState(false); @@ -109,7 +109,6 @@ const MainFeedPageInner = ({ getInitialFeedName(initialPage), ); const [searchQuery, setSearchQuery] = useState(); - const { shouldUseListFeedLayout } = useFeedLayout({ feedRelated: false }); useCompanionSettings(); const { isActive: isDndActive, showDnd, setShowDnd } = useDndContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); @@ -176,6 +175,7 @@ const MainFeedPageInner = ({ // visual signal that customizer changes affect THEIR feed, not just // a panel-shaped overlay. const { panelWidth } = useCustomizeNewTab(); + const shortcutLinks = ; return ( <> @@ -195,6 +195,15 @@ const MainFeedPageInner = ({ onNavTabClick={onNavTabClick} screenCentered={false} customBanner={isDndActive && } + topBanner={ + <> + +
+ {shortcutLinks} +
+ + + } additionalButtons={ !loadingUser && !optOutCompanion && } @@ -224,19 +233,13 @@ const MainFeedPageInner = ({ }} /> } - shortcuts={ - shortcuts ?? ( - - ) - } /> setShowDnd(false)} /> + ); }; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx deleted file mode 100644 index f5c6243b9cc..00000000000 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; -import { - cloudinaryShortcutsIconsGmail, - cloudinaryShortcutsIconsOpenai, - cloudinaryShortcutsIconsReddit, - cloudinaryShortcutsIconsStackoverflow, -} from '@dailydotdev/shared/src/lib/image'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import type { PropsWithChildren, ReactElement } from 'react'; -import React from 'react'; -import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils'; -import { useActions } from '@dailydotdev/shared/src/hooks'; - -function ShortcutItemPlaceholder({ children }: PropsWithChildren) { - return ( -
-
- {children} -
- -
- ); -} - -interface ShortcutGetStartedProps { - onTopSitesClick: () => void; - onCustomLinksClick: () => void; -} - -export const ShortcutGetStarted = ({ - onTopSitesClick, - onCustomLinksClick, -}: ShortcutGetStartedProps): ReactElement => { - const { githubShortcut } = useThemedAsset(); - const { completeAction, checkHasCompleted } = useActions(); - - const items = [ - cloudinaryShortcutsIconsGmail, - githubShortcut, - cloudinaryShortcutsIconsReddit, - cloudinaryShortcutsIconsOpenai, - cloudinaryShortcutsIconsStackoverflow, - ]; - - const completeActionThenFire = (callback?: () => void) => { - if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { - completeAction(ActionType.FirstShortcutsSession); - } - callback?.(); - }; - - return ( -
-

- Choose your most visited sites -

-
- {items.map((url) => ( - - {`Icon - - ))} - - - -
-
- - -
-
- ); -}; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index 48c3f3c4222..74adf90cc3a 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -186,23 +186,12 @@ describe('shortcut links component', () => { expect(screen.queryByText('Add shortcuts')).not.toBeInTheDocument(); }); - it('should display add shortcuts if settings is enabled and no customLinks added', async () => { - renderComponent({ - ...defaultBootData, - settings: { ...defaultSettings, customLinks: undefined }, - }); - - const addShortcuts = await screen.findByText('Add shortcuts'); - expect(addShortcuts).toBeVisible(); - - await waitFor(() => { - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Impression, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), - }); - }); - }); + // Note: the legacy "Add shortcuts" / "Choose your most visited sites" + // onboarding card is no longer rendered inside ShortcutLinks — it now + // lives in ExtensionTopBanners alongside the reading-reminder and + // upload-CV cards. The previous tests asserting that copy from this + // component have been removed; the onboarding card is covered by the + // top-banners surface. it('should display top sites if permission is previously granted', async () => { await act(async () => { @@ -226,52 +215,9 @@ describe('shortcut links component', () => { }); }); - it('should display top sites if permission is manually granted', async () => { - renderComponent({ - ...defaultBootData, - user: { - id: 'string', - firstVisit: 'string', - referrer: 'string', - }, - settings: { ...defaultSettings, customLinks: [] }, - }); - await act(() => new Promise((resolve) => setTimeout(resolve, 100))); - - const addShortcuts = await screen.findByText('Add shortcuts'); - fireEvent.click(addShortcuts); - - await screen.findByRole('dialog'); - - const mostVisitedSites = await screen.findByText('Most visited sites'); - expect(mostVisitedSites).toBeVisible(); - fireEvent.click(mostVisitedSites); - - const title = await screen.findByText('Show most visited sites'); - expect(title).toBeVisible(); - - const inputs = await screen.findAllByRole('textbox'); - expect(inputs.length).toEqual(8); - - inputs.forEach((input) => { - expect(input).toHaveAttribute('readonly'); - }); - - const next = await screen.findByText('Add the shortcuts'); - fireEvent.click(next); - - const saveChanges = await screen.findByText('Save'); - fireEvent.click(saveChanges); - - const shortcuts = await screen.findAllByRole('link'); - expect(shortcuts.length).toEqual(3); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.SaveShortcutAccess, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Browser }), - }); - }); + // Note: the previous "manually granted" flow drove the permissions + // modal via the inline "Add shortcuts" CTA. That CTA moved to + // ExtensionTopBanners, so the integration test is covered there now. it('should display custom shortcut links', async () => { renderComponent(); @@ -396,23 +342,9 @@ describe('shortcut links component', () => { }); }); - it('should show getting started for new users with no [actions and links]', async () => { - renderComponent({ - ...defaultBootData, - user: { - id: 'string', - firstVisit: 'string', - referrer: 'string', - createdAt: '2024-08-16T00:00:00.000Z', - }, - settings: { ...defaultSettings, customLinks: [] }, - }); - - const gettingStarted = await screen.findByText( - 'Choose your most visited sites', - ); - expect(gettingStarted).toBeVisible(); - }); + // Note: the "Choose your most visited sites" getting-started card for + // new users moved out of ShortcutLinks into ExtensionTopBanners; the + // visibility test is now owned by that surface. it('should hide getting started for old users with no [actions and links]', async () => { renderComponent({ diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 0bd68649ac9..5d077f2516c 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -17,7 +17,6 @@ import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/ import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; import { useIsShortcutsHubEnabled } from '@dailydotdev/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled'; import { ShortcutLinksList } from './ShortcutLinksList'; -import { ShortcutGetStarted } from './ShortcutGetStarted'; import { ShortcutLinksHub } from './ShortcutLinksHub'; import { ShortcutImportFlow } from './ShortcutImportFlow'; @@ -105,25 +104,19 @@ function LegacyShortcutLinks({ return ( <> - {!hideShortcuts && - (showGetStarted ? ( - - ) : ( - - ))} + {!hideShortcuts && !showGetStarted && ( + + )} {showPermissionsModal && } ); @@ -132,9 +125,8 @@ function LegacyShortcutLinks({ function NewShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement | null { - const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); + const { showTopSites, flags } = useSettingsContext(); const manager = useShortcutsManager(); - const { openModal } = useLazyModal(); useShortcutsMigration(); if (!showTopSites) { @@ -148,17 +140,10 @@ function NewShortcutLinks({ const showOnboarding = mode === 'manual' && manager.shortcuts.length === 0; if (showOnboarding) { - return ( - <> - - openModal({ type: LazyModal.ShortcutsManage }) - } - /> - - - ); + // Onboarding (no shortcuts yet) is now surfaced via the top hero + // banner row above the feed; render nothing here so it doesn't + // appear twice. + return ; } return ( diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index ef12cf9a813..a6e335e67fa 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useCallback, useContext, @@ -16,12 +16,12 @@ import { } from '../graphql/feed'; import { ClientQuestEventType } from '../graphql/quests'; import AuthContext from '../contexts/AuthContext'; -import { CustomFeedHeader, FeedPageHeader } from './utilities'; +import { CustomFeedHeader } from './utilities'; +import { PageHeader } from './layout/PageHeader'; import SearchEmptyScreen from './SearchEmptyScreen'; import type { FeedProps } from './Feed'; import Feed from './Feed'; import BookmarkEmptyScreen from './BookmarkEmptyScreen'; -import type { ButtonProps } from './buttons/Button'; import { Button, ButtonSize, ButtonVariant } from './buttons/Button'; import { ShareIcon, SortIcon } from './icons'; import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query'; @@ -43,6 +43,11 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { Dropdown } from './fields/Dropdown'; import { IconSize } from './Icon'; +const compactIconButtonClassName = + '!size-8 !rounded-10 !border-transparent !bg-transparent !p-0 hover:!bg-surface-hover'; +const compactTextButtonClassName = + '!h-8 !rounded-10 !border-transparent !bg-transparent !px-3 hover:!bg-surface-hover'; + export type BookmarkFeedLayoutProps = { isReminderOnly?: boolean; searchQuery?: string; @@ -60,17 +65,6 @@ const SharedBookmarksModal = dynamic( ), ); -const ShareBookmarksButton = ({ - children, - ...props -}: PropsWithChildren< - Pick, 'className' | 'onClick' | 'icon'> ->) => ( - -); - const bookmarkSortOptions = [ { label: 'Newest first', value: BookmarkSort.TimeDesc }, { label: 'Oldest first', value: BookmarkSort.TimeAsc }, @@ -190,54 +184,86 @@ export default function BookmarkFeedLayout({ return null; } + // Compose the header title slot: on laptop we inline the search field + // beside the title so the master header strip carries everything the + // user needs (title + search + actions). Mobile/tablet keep the + // search rendered as a row below the header. + const headerTitleSlot = ( +
+ + {title} + + {isLaptop && searchChildren && ( +
+ {searchChildren} +
+ )} +
+ ); + return ( {children} - - - {title} - - - - {searchChildren} + {!isSearchResults && ( } + icon={} iconOnly selectedIndex={selectedSort} options={bookmarkSortOptionLabels} onChange={(_, index) => setSelectedSort(index)} - buttonVariant={ButtonVariant.Float} - buttonSize={ButtonSize.Medium} + buttonVariant={ButtonVariant.Tertiary} + buttonSize={ButtonSize.Small} drawerProps={{ displayCloseButton: true }} /> )} {!isFolderPage && ( - } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + className={ + isLaptop ? compactTextButtonClassName : compactIconButtonClassName + } + icon={ + + } onClick={() => setShowSharedBookmarks(true)} > {isLaptop ? Share bookmarks : null} - + )} {folder && !isReminderOnly && ( )} - + + {!isLaptop && searchChildren && ( + + {searchChildren} + + )} {showSharedBookmarks && ( { ); }); - it('should hide post', async () => { - let mutationCalled = false; + it('should hide post and replace card with the hidden feedback panel', async () => { + let hideCalled = false; renderComponent([ createFeedMock({ pageInfo: defaultFeedPage.pageInfo, @@ -822,7 +823,7 @@ describe('Feed logged in', () => { variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, }, result: () => { - mutationCalled = true; + hideCalled = true; return { data: { _: true } }; }, }, @@ -833,12 +834,90 @@ describe('Feed logged in', () => { }); const contextBtn = await screen.findByText('Hide'); contextBtn.click(); - await waitFor(() => expect(mutationCalled).toBeTruthy()); + await waitFor(() => expect(hideCalled).toBeTruthy()); + expect( + await screen.findByText('Post hidden in your feed'), + ).toBeInTheDocument(); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); + }); + + it('should restore the post when clicking Undo on the hidden feedback panel', async () => { + let unhideCalled = false; + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: UNHIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => { + unhideCalled = true; + return { data: { _: true } }; + }, + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + const undoBtn = await screen.findByRole('button', { name: 'Undo' }); + fireEvent.click(undoBtn); + + await waitFor(() => expect(unhideCalled).toBeTruthy()); await waitFor(() => expect( - screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + screen.queryByText('Post hidden in your feed'), ).not.toBeInTheDocument(), ); + expect( + await screen.findByTitle( + 'Eminem Quotes Generator - Simple PHP RESTful API', + ), + ).toBeInTheDocument(); + }); + + it('should remove the post from the feed when clicking Done on the hidden feedback panel', async () => { + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + + const doneBtn = await screen.findByRole('button', { name: 'Done' }); + fireEvent.click(doneBtn); + + await waitFor(() => + expect( + screen.queryByText('Post hidden in your feed'), + ).not.toBeInTheDocument(), + ); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); }); it('should block a source', async () => { diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index f8b8602753c..7a8a9f7ad1d 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -28,12 +28,7 @@ import { useLogContext } from '../contexts/LogContext'; import { feedLogExtra, postLogEvent } from '../lib/feed'; import { usePostModalNavigation } from '../hooks/usePostModalNavigation'; import { useSharePost } from '../hooks/useSharePost'; -import { - LogEvent, - NotificationCtaPlacement, - Origin, - TargetId, -} from '../lib/log'; +import { LogEvent, NotificationCtaPlacement, Origin } from '../lib/log'; import { SharedFeedPage } from './utilities'; import type { FeedContainerProps } from './feeds/FeedContainer'; import { FeedContainer } from './feeds/FeedContainer'; @@ -64,7 +59,6 @@ import { useFeedBookmarkPost } from '../hooks/bookmark/useFeedBookmarkPost'; import usePlusEntry from '../hooks/usePlusEntry'; import { FeedCardContext } from '../features/posts/FeedCardContext'; import { - briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, featureReaderModal, @@ -105,7 +99,6 @@ export interface FeedProps isHorizontal?: boolean; feedContainerRef?: React.Ref; disableListFrame?: boolean; - disableBriefCard?: boolean; } interface RankVariables { @@ -153,13 +146,6 @@ const ReaderPostModal = dynamic( ), ); -const BriefCardFeed = dynamic( - () => - import( - /* webpackChunkName: "briefCardFeed" */ './cards/brief/BriefCard/BriefCardFeed' - ), -); - const ProfileCompletionCard = dynamic( () => import( @@ -208,7 +194,6 @@ export default function Feed({ isHorizontal = false, feedContainerRef, disableListFrame = false, - disableBriefCard = false, }: FeedProps): ReactElement { const origin = Origin.Feed; const { logEvent } = useLogContext(); @@ -240,29 +225,9 @@ export default function Feed({ (marketingCta?.variant !== MarketingCtaVariant.BriefCard || !hasDismissBriefCta); const { isSearchPageLaptop } = useSearchResultsLayout(); - const hasNoBriefAction = - isActionsFetched && !checkHasCompleted(ActionType.GeneratedBrief); - - const { - showProfileCompletionCard, - isLoading: isProfileCompletionCardLoading, - } = useProfileCompletionCard({ isMyFeed }); - const hasDismissedBriefCard = - isActionsFetched && checkHasCompleted(ActionType.DismissBriefCard); + const { showProfileCompletionCard } = useProfileCompletionCard({ isMyFeed }); - const shouldEvaluateBriefCard = - isMyFeed && - hasNoBriefAction && - !hasDismissedBriefCard && - !showProfileCompletionCard && - !isProfileCompletionCardLoading && - !disableBriefCard; - const { value: briefCardFeatureValue } = useConditionalFeature({ - feature: briefCardFeedFeature, - shouldEvaluate: shouldEvaluateBriefCard, - }); - const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue; const [getProducts] = useUpdateQuery(getProductsQueryOptions()); const adTemplate = currentSettings.adTemplate ?? featureFeedAdTemplate.defaultValue?.default ?? { adStart: 1 }; @@ -315,7 +280,7 @@ export default function Feed({ const { onMenuClick, postMenuIndex, postMenuLocation } = useFeedContextMenu(); const useList = isListMode && numCards > 1; const virtualizedNumCards = useList ? 1 : numCards; - const showFirstSlotCard = showProfileCompletionCard || showBriefCard; + const showFirstSlotCard = showProfileCompletionCard; const { onOpenModal, onCloseModal, @@ -368,7 +333,6 @@ export default function Feed({ ); const { adjustedHeroInsertIndex, - shouldShowTopHero, shouldShowInFeedHero, title: readingReminderTitle, subtitle: readingReminderSubtitle, @@ -377,7 +341,7 @@ export default function Feed({ } = useReadingReminderFeedHero({ itemCount: items.length, itemsPerRow: virtualizedNumCards, - firstSlotOffset: Number(showProfileCompletionCard || showBriefCard), + firstSlotOffset: Number(showProfileCompletionCard), }); useMutationSubscription({ @@ -670,15 +634,6 @@ export default function Feed({ const containerProps = isSearchPageLaptop ? {} : { - topContent: shouldShowTopHero ? ( - onEnableHero(NotificationCtaPlacement.TopHero)} - onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} - /> - ) : undefined, header, inlineHeader, className, @@ -687,7 +642,6 @@ export default function Feed({ actionButtons, isHorizontal, feedContainerRef, - showBriefCard, disableListFrame, }; @@ -705,14 +659,6 @@ export default function Feed({ }} /> )} - {showBriefCard && !showProfileCompletionCard && ( - - )} {items.map((item, index) => ( void; hideFeedActionButtons?: boolean; - disableBriefCard?: boolean; } const getQueryBasedOnLogin = ( @@ -221,7 +220,6 @@ export default function MainFeedLayout({ isFinder, onNavTabClick, hideFeedActionButtons, - disableBriefCard, }: MainFeedLayoutProps): ReactElement { useScrollRestoration(); const { sortingEnabled, loadedSettings } = useContext(SettingsContext); @@ -725,16 +723,7 @@ export default function MainFeedLayout({ commentClassName={commentClassName} /> ) : ( - feedProps && ( - - ) + feedProps && )} {children} diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 8a95549255e..f5d822edf8b 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -12,7 +12,12 @@ import type { MainLayoutHeaderProps } from './layout/MainLayoutHeader'; import MainLayoutHeader from './layout/MainLayoutHeader'; import { InAppNotificationElement } from './notifications/InAppNotification'; import { useNotificationContext } from '../contexts/NotificationsContext'; -import { LogEvent, NotificationTarget, TargetType } from '../lib/log'; +import { + LogEvent, + NotificationTarget, + TargetType, + NotificationCtaPlacement, +} from '../lib/log'; import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; @@ -24,7 +29,6 @@ import { ActiveFeedNameContextProvider, useActiveFeedNameContext, } from '../contexts'; -import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BootPopups } from './modals/BootPopups'; import { SmartComposerHotkey } from './post/SmartComposerHotkey'; import { SmartComposerDevToggle } from './post/SmartComposerDevToggle'; @@ -38,6 +42,8 @@ import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; +import { TopHero } from './banners/HeroBottomBanner'; +import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; const GoBackHeaderMobile = dynamic( () => @@ -66,6 +72,13 @@ export interface MainLayoutProps canGoBack?: string; hideBackButton?: boolean; hideFeedbackWidget?: boolean; + /** + * Slot rendered above the floating feed card, next to the existing + * reading-reminder TopHero. Used by the extension new tab to render + * its row of onboarding hero cards in the same outer chrome where + * the reminder card lives, so the card sits OUTSIDE the feed frame. + */ + topBanner?: ReactNode; } export const feeds = Object.values(SharedFeedPage); @@ -76,33 +89,53 @@ function MainLayoutComponent({ isNavItemsButton, customBanner, additionalButtons, - screenCentered = true, showSidebar = true, className, onLogoClick, onNavTabClick, canGoBack, hideFeedbackWidget = false, + topBanner, }: MainLayoutProps): ReactElement | null { const router = useRouter(); const { logEvent } = useLogContext(); - const { user, isAuthReady, showLogin } = useAuthContext(); + const { user, isAuthReady, isLoggedIn, showLogin } = useAuthContext(); const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); const { sidebarExpanded, autoDismissNotifications } = useContext(SettingsContext); + // The dual-sidebar layout takes ownership of the global header chrome + // (logo + search + user actions) for authenticated users on laptop+. + // When that's the case we hide the global header and switch the main + // content over to the floating card treatment (rounded, bordered, shadow) + // and hide the global feedback widget (the rail provides its own). + // On the extension we keep the floating card layout even for logged-out + // users so the new tab doesn't snap back to a header-on-top layout the + // moment the user logs out — login/signup are surfaced via the top + // hero card row instead. + const sidebarOwnsHeader = + (isLoggedIn || isExtension) && showSidebar && sidebarRendered; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); const page = router?.route?.substring(1).trim() as SharedFeedPage; const currentFeedName = feedName ?? page ?? SharedFeedPage.Popular; const { isCustomFeed } = useFeedName({ feedName: currentFeedName }); const { plusEntryAnnouncementBar } = usePlusEntry(); - const isLaptopXL = useViewSize(ViewSize.LaptopXL); - const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); useNotificationParams(); + const { + shouldShowTopHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + onEnableHero: onEnableReadingReminder, + onDismissHero: onDismissReadingReminder, + } = useReadingReminderFeedHero({ + itemCount: 0, + itemsPerRow: 1, + }); + useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { return; @@ -177,11 +210,8 @@ function MainLayoutComponent({ return null; } - const isScreenCentered = - isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; - return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -203,33 +233,69 @@ function MainLayoutComponent({
{isAuthReady && showSidebar && ( )} - {children} +
+ {shouldShowTopHero && ( + + onEnableReadingReminder(NotificationCtaPlacement.TopHero) + } + onClose={() => + onDismissReadingReminder(NotificationCtaPlacement.TopHero) + } + /> + )} + {topBanner} +
+ {children} +
+
- {!hideFeedbackWidget && } + {!hideFeedbackWidget && !sidebarOwnsHeader && }
); } diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx index 96b6ccc3cb2..ea6dbca74ca 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx @@ -12,20 +12,14 @@ import { checkIsExtension } from '../../lib/func'; import { LogoutReason } from '../../lib/user'; import { TargetId } from '../../lib/log'; -import { ProfileMenuFooter } from './ProfileMenuFooter'; import { UpgradeToPlus } from '../UpgradeToPlus'; import { ProfileMenuHeader } from './ProfileMenuHeader'; +import { ProfileMenuStats } from './ProfileMenuStats'; import { HorizontalSeparator } from '../utilities'; import { ProfileSection } from './ProfileSection'; -import { ResourceSection } from './sections/ResourceSection'; -import { AccountSection } from './sections/AccountSection'; -import { MainSection } from './sections/MainSection'; -import { ThemeSection } from './sections/ThemeSection'; import { FeedbackButtonSection } from './sections/FeedbackButtonSection'; import { useCustomizeNewTabMenuItem } from './sections/ExtensionSection'; -import { ProfileCompletion } from '../../features/profile/components/ProfileWidgets/ProfileCompletion'; -import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; const ExtensionSection = dynamic(() => import( @@ -35,15 +29,15 @@ const ExtensionSection = dynamic(() => interface ProfileMenuProps { onClose: () => void; + position?: InteractivePopupPosition; } export default function ProfileMenu({ onClose, + position = InteractivePopupPosition.ProfileMenu, }: ProfileMenuProps): ReactElement | null { const { events } = useRouter(); const { user, logout } = useAuthContext(); - const { showIndicator: showProfileCompletion } = - useProfileCompletionIndicator(); const customizeMenuItem = useCustomizeNewTabMenuItem(onClose); useEffect(() => { @@ -58,15 +52,21 @@ export default function ProfileMenu({ return null; } + const logoutItem = { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }; + return ( - {showProfileCompletion && } + - - ); } diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx new file mode 100644 index 00000000000..42ea11c0960 --- /dev/null +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ReputationUserBadge } from '../ReputationUserBadge'; +import { CoreIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import Link from '../utilities/Link'; +import { walletUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; + +export const ProfileMenuStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const hasCoresAccess = useHasAccessToCores(); + + if (!user) { + return null; + } + + if (!hasCoresAccess && !user.reputation) { + return null; + } + + return ( +
+ + {hasCoresAccess && ( + + + + {largeNumberFormat(user.balance?.amount ?? 0)} + + + )} +
+ ); +}; diff --git a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx deleted file mode 100644 index cadda4998fd..00000000000 --- a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; -import { ProfileSection } from '../ProfileSection'; -import { - CreditCardIcon, - InviteIcon, - SettingsIcon, - TrendingIcon, - OrganizationIcon, -} from '../../icons'; -import { settingsUrl } from '../../../lib/constants'; -import { useLazyModal } from '../../../hooks/useLazyModal'; -import { LazyModal } from '../../modals/common/types'; -import { useCanPurchaseCores } from '../../../hooks/useCoresFeature'; -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; - -type AccountSectionProps = { - /** - * Optional item rendered at the top of the section, above "Settings". - * Used by ProfileMenu to surface the "Customize new tab" entry inline - * so it sits inside the same visual block as Settings instead of in a - * separate section. - */ - prepended?: ProfileSectionItemProps | null; -}; - -export const AccountSection = ({ - prepended, -}: AccountSectionProps = {}): ReactElement => { - const { openModal } = useLazyModal(); - const canBuy = useCanPurchaseCores(); - - const items: ProfileSectionItemProps[] = [ - ...(prepended ? [prepended] : []), - { - title: 'Settings', - href: `${settingsUrl}/profile`, - icon: SettingsIcon, - }, - { - title: 'Subscriptions', - href: `${settingsUrl}/subscription`, - icon: CreditCardIcon, - }, - { - title: 'Organizations', - href: `${settingsUrl}/organization`, - icon: OrganizationIcon, - }, - { - title: 'Invite friends', - href: `${settingsUrl}/invite`, - icon: InviteIcon, - }, - ]; - - if (canBuy) { - items.push({ - title: 'Ads dashboard', - icon: TrendingIcon, - onClick: () => { - openModal({ type: LazyModal.AdsDashboard }); - }, - }); - } - - return ; -}; diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index b1d60cbb38a..b88fb29f78f 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { HorizontalSeparator } from '../../utilities'; import { ProfileSection } from '../ProfileSection'; import { useDndContext } from '../../../contexts/DndContext'; import { useSettingsContext } from '../../../contexts/SettingsContext'; @@ -21,19 +20,10 @@ import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../lib/log'; import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -export type ExtensionSectionProps = { - /** - * Called after the user picks any item in this section so the parent - * ProfileMenu collapses the dropdown. - */ - onClose?: () => void; -}; - /** * Hook returning the "Customize new tab" item when the feature flag is - * on, or `null` when it isn't. AccountSection consumes this to prepend - * the entry directly above "Settings" — placing it inside the existing - * section instead of giving it its own visual block. + * on, or `null` when it isn't. The ProfileMenu renders the entry as its + * own one-item section, sitting above the legacy ExtensionSection block. */ export const useCustomizeNewTabMenuItem = ( onClose?: () => void, @@ -63,8 +53,8 @@ export const useCustomizeNewTabMenuItem = ( /** * Legacy fallback for users in the control bucket of the customize * sidebar feature flag. When the flag is on, the customize entry is - * folded into AccountSection via `useCustomizeNewTabMenuItem` and this - * component renders nothing. + * surfaced via `useCustomizeNewTabMenuItem` and this component renders + * nothing. */ export const ExtensionSection = (): ReactElement | null => { const { isEnabled: isCustomizerEnabled } = useCustomizeNewTab(); @@ -81,27 +71,24 @@ export const ExtensionSection = (): ReactElement | null => { } return ( - <> - - openModal({ type: shortcutsModal }), - }, - { - title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, - icon: isDndActive ? PlayIcon : PauseIcon, - onClick: () => setShowDnd?.(true), - }, - { - title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, - icon: () => , - onClick: () => toggleOptOutCompanion(), - }, - ]} - /> - + openModal({ type: shortcutsModal }), + }, + { + title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, + icon: isDndActive ? PlayIcon : PauseIcon, + onClick: () => setShowDnd?.(true), + }, + { + title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, + icon: () => , + onClick: () => toggleOptOutCompanion(), + }, + ]} + /> ); }; diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx deleted file mode 100644 index 7ee1b64eab9..00000000000 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; - -import { ProfileSection } from '../ProfileSection'; -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -import { - AnalyticsIcon, - CoinIcon, - DevCardIcon, - MedalBadgeIcon, - UserIcon, -} from '../../icons'; -import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; - -export const MainSection = (): ReactElement => { - const hasAccessToCores = useHasAccessToCores(); - const { user } = useAuthContext(); - - const items: ProfileSectionItemProps[] = [ - { - title: 'Your profile', - href: `${webappUrl}${user?.username}`, - icon: UserIcon, - }, - ...(hasAccessToCores - ? [ - { - title: 'Core wallet', - href: walletUrl, - icon: CoinIcon, - } satisfies ProfileSectionItemProps, - ] - : []), - { - title: 'Achievements', - href: `${webappUrl}${user?.username}/achievements`, - icon: MedalBadgeIcon, - }, - { - title: 'DevCard', - href: `${settingsUrl}/customization/devcard`, - icon: DevCardIcon, - }, - { - title: 'Analytics', - href: `${webappUrl}analytics`, - icon: AnalyticsIcon, - }, - ]; - - return ; -}; diff --git a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx index 99519cae038..4ce1916e245 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx @@ -4,25 +4,34 @@ import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; import { DocsIcon, - FeedbackIcon, MegaphoneIcon, - TerminalIcon, + PhoneIcon, + PrivacyIcon, + ReputationLightningIcon, } from '../../icons'; import { + appsUrl, businessWebsiteUrl, docs, - feedback, - webappUrl, + reputation, + settingsUrl, } from '../../../lib/constants'; export const ResourceSection = (): ReactElement => { return ( { external: true, }, { - title: 'Docs', - icon: DocsIcon, - href: docs, + title: 'Apps', + icon: PhoneIcon, + href: appsUrl, external: true, }, { - title: 'Support', - icon: FeedbackIcon, - href: feedback, + title: 'Docs', + icon: DocsIcon, + href: docs, external: true, }, ]} diff --git a/packages/shared/src/components/banners/HeroBottomBanner.tsx b/packages/shared/src/components/banners/HeroBottomBanner.tsx index 015c9681f04..52f97b4f62e 100644 --- a/packages/shared/src/components/banners/HeroBottomBanner.tsx +++ b/packages/shared/src/components/banners/HeroBottomBanner.tsx @@ -1,65 +1,114 @@ import classNames from 'classnames'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; -import { Button, ButtonVariant } from '../buttons/Button'; -import { MiniCloseIcon } from '../icons'; -import feedStyles from '../Feed.module.css'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; import ReadingReminderCatLaptop from './ReadingReminderCatLaptop'; type TopHeroProps = { className?: string; + /** + * Small grey eyebrow above the bold headline. Omit for a single-line + * card that only shows the headline (`subtitle`). The original + * "Enable reminder" hero falls back to its legacy copy so existing + * webapp callers keep their two-line layout. + */ title?: string; subtitle?: string; - onCtaClick: () => void; - onClose: () => void; + onCtaClick?: () => void; + /** + * When omitted, the dismiss/close button is not rendered (use for + * cards that the user must not be able to hide, e.g. the logged-out + * sign-in card). + */ + onClose?: () => void; + /** + * Optional illustration rendered on the left side of the card. + * Defaults to the reading-reminder cat artwork to keep the original + * "Enable reminder" hero usage backwards-compatible. + */ + illustration?: ReactNode; + ctaLabel?: string; + ctaVariant?: ButtonVariant; + /** + * Optional custom action node rendered in place of the default + * single-CTA button. Use when the card needs multiple buttons + * (e.g. Log in + Sign up). + */ + actions?: ReactNode; }; +const defaultIllustration = ( + +); + export const TopHero = ({ className, - title = 'Never miss a learning day', + title, subtitle = 'Turn on your daily reading reminder and keep your routine.', onCtaClick, onClose, + illustration = defaultIllustration, + ctaLabel = 'Enable reminder', + ctaVariant = ButtonVariant.Primary, + actions, }: TopHeroProps): ReactElement => { return (
-
-
-
-
-
-
+ {illustration} +
+
+ {!!title && ( + + {title} + + )} + + {subtitle} + +
+ {actions ?? ( -
-
-
- -
-
-
+ variant={ctaVariant} + size={ButtonSize.Small} + onClick={onCtaClick} + > + {ctaLabel} + + )}
+ {onClose && ( + + )}
); }; diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 7fa5060f681..7b8379dc088 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -16,18 +16,30 @@ import { SidebarSettingsFlags } from '../../graphql/settings'; import { useLogContext } from '../../contexts/LogContext'; import type { Origin } from '../../lib/log'; import { LogEvent, TargetId } from '../../lib/log'; +import type { IconProps } from '../Icon'; import { useActiveFeedContext } from '../../contexts/ActiveFeedContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { webappUrl } from '../../lib/constants'; +import { AuthTriggers } from '../../lib/auth'; import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; import { Tooltip } from '../tooltip/Tooltip'; export const ToggleClickbaitShield = ({ origin, buttonProps = {}, + iconButtonProps, + iconSize, }: { origin: Origin; + /** Applied to both render paths (Plus icon-only and non-Plus icon+text). */ buttonProps?: ButtonProps<'button'>; + /** + * Applied only to the Plus (icon-only) render path on top of `buttonProps`. + * Use to keep the icon-only case sized like sibling icon-buttons in compact + * headers without forcing min-widths on the non-Plus "X/Y" text variant. + */ + iconButtonProps?: ButtonProps<'button'>; + iconSize?: IconProps['size']; }): ReactElement => { const queryClient = useQueryClient(); const { queryKey: feedQueryKey } = useActiveFeedContext(); @@ -36,9 +48,14 @@ export const ToggleClickbaitShield = ({ const { flags, updateFlag } = useSettingsContext(); const [loading, setLoading] = useState(false); const router = useRouter(); - const { user } = useAuthContext(); + const { user, showLogin } = useAuthContext(); const { maxTries, hasUsedFreeTrial, triesLeft } = useClickbaitTries(); const isClickbaitShieldEnabled = flags?.clickbaitShieldEnabled ?? false; + const triggerLogin = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: false }, + }); const commonIconProps: ButtonProps<'button'> = { size: ButtonSize.Medium, @@ -62,13 +79,17 @@ export const ToggleClickbaitShield = ({ {...commonIconProps} icon={ hasUsedFreeTrial ? ( - + ) : ( - + ) } onClick={() => { if (!user) { + triggerLogin(); return; } router.push( @@ -91,15 +112,20 @@ export const ToggleClickbaitShield = ({ > + + {isSidebar && ( + + )} +
+ ); + } + return ( + {feedSettingsButtonLabel} + + )} + {showToggleShortcuts && ( + <> + {shouldUseListFeedLayout ? ( + + ) : ( + + )} + )} ); diff --git a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx index c6b0366b18e..540d8159f1f 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx @@ -14,6 +14,7 @@ import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { useScrollTopClassName } from '../../hooks/useScrollTopClassName'; import { useFeedName } from '../../hooks/feed/useFeedName'; import useActiveNav from '../../hooks/useActiveNav'; +import { useAuthContext } from '../../contexts/AuthContext'; jest.mock('next/dynamic', () => () => { return function MockDynamicComponent() { @@ -54,6 +55,10 @@ jest.mock('../../hooks/feed/useFeedName', () => ({ jest.mock('../../hooks/useActiveNav', () => jest.fn()); +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + jest.mock('../header/MobileExploreHeader', () => ({ MobileExploreHeader: ({ path }: { path: string }) => (
{path}
@@ -68,6 +73,7 @@ const mockUseFeatureTheme = useFeatureTheme as jest.Mock; const mockUseScrollTopClassName = useScrollTopClassName as jest.Mock; const mockUseFeedName = useFeedName as jest.Mock; const mockUseActiveNav = useActiveNav as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; describe('MainLayoutHeader', () => { beforeEach(() => { @@ -84,6 +90,7 @@ describe('MainLayoutHeader', () => { isSearch: false, }); mockUseActiveNav.mockReturnValue({ profile: false }); + mockUseAuthContext.mockReturnValue({ isLoggedIn: false }); }); afterEach(() => { diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index ec13ee1791c..9f630683c5d 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -17,6 +17,8 @@ import FeedNav from '../feeds/FeedNav'; import { MobileExploreHeader } from '../header/MobileExploreHeader'; import useActiveNav from '../../hooks/useActiveNav'; import { SpotlightTrigger } from '../spotlight/SpotlightTrigger'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { isExtension } from '../../lib/func'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -35,12 +37,8 @@ function MainLayoutHeader({ sidebarRendered, additionalButtons, onLogoClick, -}: MainLayoutHeaderProps): ReactElement { +}: MainLayoutHeaderProps): ReactElement | null { const { loadedSettings } = useSettingsContext(); - // Header is `fixed` so it escapes the parent's `padding-right`. Read - // the customize sidebar width directly here and shrink the header to - // match — keeps it from sliding under the panel and lets it animate - // alongside the feed. const { panelWidth } = useCustomizeNewTab(); const [hasHydrated, setHasHydrated] = useState(false); const { streak, isStreaksEnabled } = useReadingStreak(); @@ -62,6 +60,16 @@ function MainLayoutHeader({ shouldUseLoadedSettings && isMobile && isSearchPage; const shouldRenderFeedNav = shouldUseLoadedSettings && isMobile && !isSearchPage; + const { isLoggedIn } = useAuthContext(); + // The dual-sidebar layout owns the logo, search, and action buttons on + // laptop+ for authenticated users. Skip the global header entirely so + // chrome isn't duplicated and the content card can sit flush to the top. + // On the extension we also hide it for logged-out users — the new tab + // surfaces login/signup via the top hero card row, so we don't want + // the page to snap back to a separate header bar after sign-out. + const shouldHideForSidebar = + isLaptop && !!sidebarRendered && (isLoggedIn || isExtension); + const customizerWidth = panelWidth ? `${panelWidth}px` : '0px'; useEffect(() => { setHasHydrated(true); @@ -87,6 +95,10 @@ function MainLayoutHeader({ ); }, [shouldUseLoadedSettings, isSearchPage, hasBanner]); + if (shouldHideForSidebar) { + return null; + } + if (shouldRenderFeedNav) { return ( <> @@ -111,9 +123,9 @@ function MainLayoutHeader({ style={{ ...(featureTheme ? featureTheme.navbar : undefined), right: panelWidth || undefined, - width: panelWidth ? `calc(100% - ${panelWidth}px)` : undefined, + width: panelWidth ? `calc(100% - ${customizerWidth})` : undefined, transition: panelWidth - ? 'right 200ms ease-in-out, width 200ms ease-in-out' + ? 'right 200ms ease-in-out, width 300ms ease-in-out' : undefined, }} > diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx new file mode 100644 index 00000000000..836a892178f --- /dev/null +++ b/packages/shared/src/components/layout/PageHeader.tsx @@ -0,0 +1,68 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +// Visual shell of the page-header strip. Exported so callers that +// need a custom internal layout (e.g. wide horizontal tabs that +// shouldn't be locked inside the title/actions slot) can compose +// their own `
` without duplicating the styling. +// +// `min-h-14` (56px = a Small button + py-3 on each side) locks the +// strip to a consistent height across pages so a header with action +// buttons (Profile's Save, API's Create token, ...) is the same +// height as a header that only renders a title (Content sources, +// Tags, ...). Without this, navigating between settings pages +// caused a 12px vertical shift of the page content below. +export const pageHeaderClassName = + 'flex min-h-14 w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3'; + +export interface PageHeaderProps { + /** + * Left-side title. A plain string is wrapped in a bold callout + * (matching the homepage feed header). For custom typography pass + * a ReactNode and it will render inside a truncating flex slot. + */ + title?: ReactNode; + /** + * Right-side actions (buttons, dropdowns, etc.). Docked to the + * end of the row with shrink-0 so the title takes the remaining + * space and truncates first. + */ + children?: ReactNode; + className?: string; +} + +/** + * Shared "page header" strip used at the top of the floating card on + * every primary page (home, squads, bookmarks, game center, ...). + * Matches the homepage feed list-frame: bottom border, px-6 py-3, + * title on the left, action buttons docked right. + */ +export const PageHeader = ({ + title, + children, + className, +}: PageHeaderProps): ReactElement => ( +
+ {title !== undefined && + (typeof title === 'string' ? ( + + {title} + + ) : ( + // ReactNode titles handle their own typography + overflow. + // Notably, no `truncate` wrapper here so callers can render + // full-height tab navigation (with bottom-aligned underlines) + // inside the title slot — the homepage feed-cards header was + // designed around composable left-side content, not just text. +
+ {title} +
+ ))} + {children !== undefined && ( +
+ {children} +
+ )} +
+); diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 41b5d0c98f7..497daa19467 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -8,6 +8,7 @@ import React, { useContext } from 'react'; import classed from '../../lib/classed'; import { SharedFeedPage } from '../utilities'; import MyFeedHeading from '../filters/MyFeedHeading'; +import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import type { DropdownProps } from '../fields/Dropdown'; import { Dropdown } from '../fields/Dropdown'; import { Button } from '../buttons/Button'; @@ -33,8 +34,8 @@ import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; import type { AllowedTags, TypographyProps } from '../typography/Typography'; import { Typography } from '../typography/Typography'; import { ToggleClickbaitShield } from '../buttons/ToggleClickbaitShield'; +import { BriefShortcutButton } from '../cards/brief/BriefShortcutButton'; import { LogEvent, Origin } from '../../lib/log'; -import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import { ActionType } from '../../graphql/actions'; import { BrowserName, @@ -84,35 +85,48 @@ export const SearchControlHeader = ({ const { user } = useAuthContext(); const { logEvent } = useLogContext(); const { sortingEnabled } = useContext(SettingsContext); - const { isUpvoted, isSortableFeed } = useFeedName({ feedName }); + const { isUpvoted, isPopular, isSortableFeed } = useFeedName({ feedName }); const isLaptop = useViewSize(ViewSize.Laptop); const isMobile = useViewSize(ViewSize.MobileL); const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const browserName = getCurrentBrowserName(); const isEdge = browserName === BrowserName.Edge; + const isExtension = checkIsExtension(); const feedsWithActions = [ SharedFeedPage.MyFeed, SharedFeedPage.Custom, SharedFeedPage.CustomForm, ]; - const hasFeedActions = feedsWithActions.includes(feedName as SharedFeedPage); + // The extension's logged-out new tab still needs the Feed + // settings / Brief / Clickbait Shield strip so the page looks + // identical to the logged-in version. Each button intercepts + // !user clicks below to open the auth modal instead of running + // its normal action, so surfacing them here is safe. + const hasFeedActions = + feedsWithActions.includes(feedName as SharedFeedPage) || + (isExtension && !user && isPopular); if (isMobile) { return null; } + const compactIconButtonClassName = + '!size-8 !rounded-10 !border-transparent !bg-transparent !p-0 hover:!bg-surface-hover'; + const compactTextButtonClassName = + '!h-8 !rounded-10 !border-transparent !bg-transparent !px-3 hover:!bg-surface-hover'; + const dropdownProps: Partial = { className: { label: 'hidden', chevron: 'hidden', - button: '!px-1', + button: compactIconButtonClassName, container: 'flex', }, shouldIndicateSelected: true, - buttonSize: isMobile ? ButtonSize.Small : ButtonSize.Medium, + buttonSize: ButtonSize.Small, iconOnly: true, - buttonVariant: isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary, + buttonVariant: ButtonVariant.Tertiary, }; const hasDismissedInstallExtension = checkHasCompleted( @@ -131,12 +145,18 @@ export const SearchControlHeader = ({ key="install-extension" tag="a" href={downloadBrowserExtension} - variant={isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary} - size={ButtonSize.Medium} - icon={isEdge ? : } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + isEdge ? ( + + ) : ( + + ) + } rel={anchorDefaultRel} target="_blank" - className="ml-auto" + className={isLaptop ? compactTextButtonClassName : undefined} onClick={() => logEvent({ event_name: LogEvent.DownloadExtension, @@ -149,19 +169,39 @@ export const SearchControlHeader = ({ - - -
+ return ( +
+
+ +
- {userAchievement.progress}/{target} + {userAchievement.achievement.name} - {userAchievement.achievement.points} pts + {userAchievement.achievement.description}
- + +
+ +
+ + {userAchievement.progress}/{target} + + + {userAchievement.achievement.points} pts +
- ); - })} -
- )} + +
+ ); + })} + + )} + + ); +}; + +export const AchievementPickerModal = ({ + achievements, + trackedAchievementId, + onTrack, + onUntrack, + onRequestClose, + ...props +}: AchievementPickerModalProps): ReactElement => { + return ( + + + + ); diff --git a/packages/shared/src/components/modals/common/Modal.tsx b/packages/shared/src/components/modals/common/Modal.tsx index 5a50ad776ae..fd7e100666a 100644 --- a/packages/shared/src/components/modals/common/Modal.tsx +++ b/packages/shared/src/components/modals/common/Modal.tsx @@ -16,7 +16,7 @@ import { import classed from '../../../lib/classed'; import { ModalStepsWrapper } from './ModalStepsWrapper'; import type { LogEvent } from '../../../lib/log'; -import { useViewSize, ViewSize } from '../../../hooks'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; import type { DrawerOnMobileProps } from '../../drawers'; import { Drawer } from '../../drawers'; import type { FormWrapperProps } from '../../fields/form'; diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 1122dc10e27..9afc5d799ec 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,8 +13,15 @@ import { webappUrl } from '../../lib/constants'; import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; +import { IconSize } from '../Icon'; -function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { +function NotificationsBell({ + compact, + rail, +}: { + compact?: boolean; + rail?: boolean; +}): ReactElement { const router = useRouter(); const atNotificationsPage = router.pathname === notificationsUrl; const { logEvent } = useLogContext(); @@ -31,6 +38,39 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option; + if (rail) { + return ( + + + + ); + } + return (
diff --git a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx index e026266c694..f2ccf551875 100644 --- a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx +++ b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx @@ -51,16 +51,14 @@ export const OpportunityEntryButton = () => { hasOpportunityAlert && hasNotClickedOpportunity ? OpportunityTooltip : SimpleTooltip; + const href = `${webappUrl}jobs/${ + hasOpportunityAlert ? alerts.opportunityId : '' + }`; return (
- + - - -
-
- ); -}; diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx new file mode 100644 index 00000000000..da7a34cae00 --- /dev/null +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -0,0 +1,175 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { useHidePost } from '../../../hooks/post/useHidePost'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import useTagAndSource from '../../../hooks/useTagAndSource'; +import useFeedSettings from '../../../hooks/useFeedSettings'; +import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../modals/common/types'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../buttons/Button'; +import CloseButton from '../../CloseButton'; +import { SourceAvatar } from '../../profile/source'; +import { GenericTagButton } from '../../filters/TagButton'; +import { Origin } from '../../../lib/log'; +import type { BlockTagSelection } from './common'; + +interface PostHiddenPanelProps { + post: Post; + className?: string; +} + +export function PostHiddenPanel({ + post, + className, +}: PostHiddenPanelProps): ReactElement { + const { feedId: customFeedId } = useCustomFeed(); + const { feedSettings } = useFeedSettings({ feedId: customFeedId }); + const { source } = post; + if (!source) { + throw new Error('PostHiddenPanel requires post.source'); + } + const isSourceAlreadyBlocked = + feedSettings?.excludeSources?.some(({ id }) => id === source.id) ?? false; + + const [shouldBlockSource, setShouldBlockSource] = useState( + isSourceAlreadyBlocked, + ); + const [tags, setTags] = useState(() => + (post.tags ?? []).reduce( + (acc, tag) => ({ ...acc, [tag]: false }), + {}, + ), + ); + + const { onUnhide, onConfirmDismiss } = useHidePost({ post }); + const { onClose } = useBlockPostPanel(post); + const { onBlockTags, onBlockSource } = useTagAndSource({ + origin: Origin.PostContextMenu, + postId: post.id, + shouldInvalidateQueries: false, + feedId: customFeedId, + }); + const { openModal } = useLazyModal(); + + const selectedTags = Object.entries(tags) + .filter(([, selected]) => selected) + .map(([tag]) => tag); + const willBlockSource = shouldBlockSource && !isSourceAlreadyBlocked; + + const handleDone = async () => { + if (selectedTags.length > 0) { + await onBlockTags({ tags: selectedTags, requireLogin: true }); + } + + if (willBlockSource) { + await onBlockSource({ source, requireLogin: true }); + } + + if (willBlockSource) { + onConfirmDismiss('unfollow'); + return; + } + + if (selectedTags.length > 0) { + onConfirmDismiss('block'); + return; + } + + onConfirmDismiss('done'); + }; + + const handleReport = () => { + onClose(true); + openModal({ + type: LazyModal.ReportPost, + props: { + post, + origin: Origin.PostContextMenu, + onReported: () => { + onConfirmDismiss('report'); + }, + }, + }); + }; + + return ( +
+ onConfirmDismiss('done')} + size={ButtonSize.Small} + /> +

Post hidden in your feed

+

+ Help us improve. Tell us what didn't work for you (optional). +

+ + {!isSourceAlreadyBlocked && ( + + )} + {(post.tags ?? []).map((tag) => ( + setTags({ ...tags, [tag]: !tags[tag] })} + tagItem={tag} + data-testid="hideBlockTagButton" + /> + ))} + + + + + + +
+ ); +} diff --git a/packages/shared/src/components/profile/ProfileButton.spec.tsx b/packages/shared/src/components/profile/ProfileButton.spec.tsx index 35571e16823..83cb4f8beea 100644 --- a/packages/shared/src/components/profile/ProfileButton.spec.tsx +++ b/packages/shared/src/components/profile/ProfileButton.spec.tsx @@ -60,22 +60,6 @@ it('should show "Reputation" tooltip on the reputation badge', () => { expect(screen.getByLabelText('Reputation')).toBeInTheDocument(); }); -it('should show settings option that opens modal', async () => { - renderComponent(); - - const profileBtn = await screen.findByRole('button', { - name: 'Profile settings', - }); - await act(async () => { - profileBtn.click(); - }); - - const settingsButton = await screen.findByRole('link', { - name: 'Settings', - }); - expect(settingsButton).toBeInTheDocument(); -}); - it('should click the logout button and logout', async () => { renderComponent(); diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 08c4675e293..f1a25e79e72 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -4,7 +4,9 @@ import classNames from 'classnames'; import dynamic from 'next/dynamic'; import { useAuthContext } from '../../contexts/AuthContext'; import { ProfilePictureWithIndicator } from './ProfilePictureWithIndicator'; -import { CoreIcon, SettingsIcon } from '../icons'; +import { ProfileImageSize } from '../ProfilePicture'; +import { ArrowIcon, CoreIcon, SettingsIcon } from '../icons'; +import { InteractivePopupPosition } from '../tooltips/InteractivePopup'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; import { ReputationUserBadge } from '../ReputationUserBadge'; @@ -28,11 +30,15 @@ const ProfileMenu = dynamic( interface ProfileButtonProps { className?: string; + avatarOnly?: boolean; + compact?: boolean; settingsIconOnly?: boolean; } export default function ProfileButton({ + avatarOnly, className, + compact, settingsIconOnly, }: ProfileButtonProps): ReactElement { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); @@ -59,7 +65,6 @@ export default function ProfileButton({ typeof animatedReputation === 'number' ? animatedReputation : user?.reputation; - const preciseBalance = formatCurrency(displayedBalance, { minimumFractionDigits: 0, }); @@ -197,83 +202,154 @@ export default function ProfileButton({ return <>; } - return ( - <> - {settingsIconOnly ? ( + const renderTrigger = (): ReactElement => { + if (settingsIconOnly) { + return ( - - -
+ onClick={wrapHandler(() => onUpdate(!isOpen))} + > + +
+ +
+
+ + ); + } + + if (compact) { + return ( + + ); + } + + return ( +
+ {isStreaksEnabled && streak && ( + + )} + {hasCoresAccess && ( + + Wallet +
+ {preciseBalance} Cores + + } > - - - - -
- -
-
- -
+ + + + + + )} + + + ); + }; + + return ( + <> + {renderTrigger()} + {isOpen && ( + onUpdate(false)} + position={ + compact + ? InteractivePopupPosition.SidebarProfileMenu + : InteractivePopupPosition.ProfileMenu + } + /> )} - {isOpen && onUpdate(false)} />} ); } diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index a1b2dd22d3b..668e98eb3fa 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -6,16 +6,10 @@ import { AddUserIcon, BellIcon, EditIcon, - DevCardIcon, EmbedIcon, - DocsIcon, - FeedbackIcon, AppIcon, - PrivacyIcon, - MegaphoneIcon, UserIcon, BlockIcon, - CoinIcon, CreditCardIcon, HashtagIcon, HotIcon, @@ -24,8 +18,6 @@ import { MailIcon, EyeIcon, NewTabIcon, - PhoneIcon, - ReputationLightningIcon, ExitIcon, OrganizationIcon, TrendingIcon, @@ -33,18 +25,9 @@ import { TerminalIcon, TourIcon, FeatherIcon, - JoystickIcon, } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; -import { - appsUrl, - businessWebsiteUrl, - docs, - reputation, - settingsUrl, - walletUrl, - webappUrl, -} from '../../lib/constants'; +import { settingsUrl, webappUrl } from '../../lib/constants'; import type { ProfileSectionItemProps, @@ -57,17 +40,16 @@ import type { WithClassNameProps } from '../utilities'; import { HorizontalSeparator } from '../utilities'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader'; +import { ThemeSection } from '../ProfileMenu/sections/ThemeSection'; import { ProfileImageSize } from '../ProfilePicture'; import { useViewSize, ViewSize } from '../../hooks'; import { TypographyColor, TypographyType } from '../typography/Typography'; -import { useHasAccessToCores } from '../../hooks/useCoresFeature'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, TargetId } from '../../lib/log'; import { VolunteeringIcon } from '../icons/Volunteering'; import { GraduationIcon } from '../icons/Graduation'; -import { MedalBadgeIcon } from '../icons/MedalBadge'; import { MedalIcon } from '../icons/Medal'; type MenuItems = Record< @@ -83,7 +65,6 @@ const defineMenuItems = (items: T): T => items; const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); - const { user } = useAuthContext(); const items = useMemo( () => @@ -207,12 +188,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { playground: { title: 'Gamification', items: { - gameCenter: { - title: 'Game Center', - icon: JoystickIcon, - href: `${webappUrl}game-center`, - external: true, - }, + // Game Center, Achievements, and DevCard are intentionally + // omitted here — they live in the sidebar Profile rail. The + // settings menu is for settings, not duplicate dashboard links. gamification: { title: 'Feature visibility', icon: EyeIcon, @@ -223,12 +201,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: HotIcon, href: `${settingsUrl}/customization/streaks`, }, - achievements: { - title: 'Achievements', - icon: MedalBadgeIcon, - href: `${webappUrl}${user?.username}/achievements`, - external: true, - }, hotTakes: { title: 'Hot Takes', icon: HotIcon, @@ -239,11 +211,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { onClose?.(); }, }, - devcard: { - title: 'DevCard', - icon: DevCardIcon, - href: `${settingsUrl}/customization/devcard`, - }, }, }, customization: { @@ -274,12 +241,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: OrganizationIcon, href: `${settingsUrl}/organization`, }, - coreWallet: { - title: 'Core Wallet', - icon: CoinIcon, - href: walletUrl, - external: true, - }, + // Core wallet lives in the sidebar Profile rail (when the user + // has access). Removed from the settings menu to avoid duplicate + // destinations. adsDashboard: { title: 'Ads dashboard', icon: TrendingIcon, @@ -287,45 +251,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { } as ProfileSectionItemPropsWithoutHref, }, }, - help: { - title: 'Help center', - items: { - feedback: { - title: 'Your Feedback', - icon: FeedbackIcon, - href: `${settingsUrl}/feedback`, - }, - privacy: { - title: 'Privacy', - icon: PrivacyIcon, - href: `${settingsUrl}/privacy`, - }, - reputation: { - title: 'Reputation', - icon: ReputationLightningIcon, - href: reputation, - external: true, - }, - advertise: { - title: 'Advertise', - icon: MegaphoneIcon, - href: businessWebsiteUrl, - external: true, - }, - apps: { - title: 'Apps', - icon: PhoneIcon, - href: appsUrl, - external: true, - }, - docs: { - title: 'Docs', - icon: DocsIcon, - href: docs, - external: true, - }, - }, - }, logout: { title: null, items: { @@ -337,7 +262,7 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { }, }, }), - [logEvent, onClose, openModal, user?.username], + [logEvent, onClose, openModal], ); return { items }; @@ -355,11 +280,12 @@ export const InnerProfileSettingsMenu = ({ }: WithClassNameProps & { onClose?: () => void }) => { const { asPath } = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); - const hasAccessToCores = useHasAccessToCores(); const { items: accountPageItems } = useAccountPageItems({ onClose }); return (
+
+ {children} +
+ + ); }; diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index a957620833b..bc51cbd7ae2 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -2,8 +2,12 @@ import type { ReactElement } from 'react'; import React, { useCallback, useState } from 'react'; import classnames from 'classnames'; import { ReadingStreakPopup } from './popup/ReadingStreakPopup'; -import type { ButtonIconPosition } from '../buttons/Button'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import { ReadingStreakIcon, WarningIcon } from '../icons'; import { SimpleTooltip } from '../tooltips'; import type { UserStreak } from '../../graphql/users'; @@ -17,6 +21,7 @@ import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; +import type { IconSize } from '../Icon'; import { IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; @@ -25,6 +30,8 @@ interface ReadingStreakButtonProps { isLoading: boolean; compact?: boolean; iconPosition?: ButtonIconPosition; + iconSize?: IconSize; + appendTooltipToBody?: boolean; className?: string; } @@ -34,6 +41,7 @@ interface CustomStreaksTooltipProps { shouldShowStreaks?: boolean; setShouldShowStreaks?: (value: boolean) => void; placement: TooltipPosition; + appendTooltipToBody?: boolean; } function CustomStreaksTooltip({ @@ -42,6 +50,7 @@ function CustomStreaksTooltip({ shouldShowStreaks, setShouldShowStreaks, placement, + appendTooltipToBody, }: CustomStreaksTooltipProps): ReactElement { return ( document.body : undefined} + zIndex={1000} container={{ paddingClassName: 'p-0', bgClassName: 'bg-accent-pepper-subtlest', @@ -57,7 +68,7 @@ function CustomStreaksTooltip({ className: 'border border-border-subtlest-tertiary rounded-16', }} content={} - onClickOutside={() => setShouldShowStreaks(false)} + onClickOutside={() => setShouldShowStreaks?.(false)} > {children} @@ -68,9 +79,11 @@ export function ReadingStreakButton({ streak, isLoading, compact, - iconPosition, + iconPosition = ButtonIconPosition.Left, + iconSize, + appendTooltipToBody, className, -}: ReadingStreakButtonProps): ReactElement { +}: ReadingStreakButtonProps): ReactElement | null { const { logEvent } = useLogContext(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); @@ -78,6 +91,7 @@ export function ReadingStreakButton({ const [shouldShowStreaks, setShouldShowStreaks] = useState(false); const hasReadToday = streak?.lastViewAt && + user && isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); const isTimezoneOk = useStreakTimezoneOk(); @@ -105,15 +119,16 @@ export function ReadingStreakButton({ <> ( + wrapper={(children) => ( - {children} + {children as ReactElement} )} > @@ -122,7 +137,10 @@ export function ReadingStreakButton({ type="button" iconPosition={iconPosition} icon={ - + {!isTimezoneOk && ( @@ -134,7 +152,7 @@ export function ReadingStreakButton({ } onClick={handleToggle} className={classnames( - 'gap-1', + 'gap-0.5', compact && 'text-accent-bacon-default', className, )} diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index b0a1cdb2b79..1e04e09d031 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -23,6 +23,8 @@ export enum InteractivePopupPosition { LeftCenter = 'leftCenter', LeftEnd = 'leftEnd', ProfileMenu = 'profileMenu', + SidebarProfileMenu = 'sidebarProfileMenu', + SidebarSupportMenu = 'sidebarSupportMenu', Screen = 'screen', } @@ -61,6 +63,8 @@ const positionClass: Record = { leftCenter: classNames(leftClass, centerClassY), leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), + sidebarProfileMenu: 'left-[4.75rem] top-12', + sidebarSupportMenu: 'bottom-3 left-[4.75rem]', screen: 'inset-0 w-screen h-screen', }; @@ -142,6 +146,8 @@ function InteractivePopup({ {...props} > {finalPosition !== InteractivePopupPosition.ProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarSupportMenu && onClose && ( + + ); + } + + return ( + + ); +}; diff --git a/packages/shared/src/features/extensionMockupPreview/ExtensionSignInStrip.tsx b/packages/shared/src/features/extensionMockupPreview/ExtensionSignInStrip.tsx new file mode 100644 index 00000000000..1b299969b3f --- /dev/null +++ b/packages/shared/src/features/extensionMockupPreview/ExtensionSignInStrip.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { + Typography, + TypographyType, +} from '../../components/typography/Typography'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { AuthTriggers } from '../../lib/auth'; +import { useExtensionMockup } from './useExtensionMockup'; + +// Sticky banner pinned to the top of the new tab for logged-out users. +// Sits above shortcuts and any other top-page modules so the auth CTAs +// are the first thing the user sees and remain reachable while +// scrolling. Shares the feed's primary background so it reads as part +// of the same surface (no glass / blur / shadow). +export const ExtensionSignInStrip = (): ReactElement | null => { + const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + const { signInStrip: forceSignInStrip } = useExtensionMockup(); + + if (!isAuthReady) { + return null; + } + if (isLoggedIn && !forceSignInStrip) { + return null; + } + + const onLogIn = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: true }, + }); + const onSignUp = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: false }, + }); + + return ( +
+
+ + Sign in to personalize your feed and save what matters. + +
+ + +
+
+
+ ); +}; diff --git a/packages/shared/src/features/extensionMockupPreview/ExtensionTopBanners.tsx b/packages/shared/src/features/extensionMockupPreview/ExtensionTopBanners.tsx new file mode 100644 index 00000000000..29b9803c85b --- /dev/null +++ b/packages/shared/src/features/extensionMockupPreview/ExtensionTopBanners.tsx @@ -0,0 +1,245 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { TopHero } from '../../components/banners/HeroBottomBanner'; +import { useReadingReminderHero } from '../../hooks/notifications/useReadingReminderHero'; +import { fileValidation, useUploadCv } from '../profile/hooks/useUploadCv'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../../components/modals/common/types'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useActions } from '../../hooks'; +import { ActionType } from '../../graphql/actions'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useIsShortcutsHubEnabled } from '../shortcuts/hooks/useIsShortcutsHubEnabled'; +import { useShortcutLinks } from '../shortcuts/hooks/useShortcutLinks'; +import { useThemedAsset } from '../../hooks/utils'; +import ReadingReminderCatLaptop from '../../components/banners/ReadingReminderCatLaptop'; +import { + cloudinaryShortcutsIconsGmail, + cloudinaryShortcutsIconsOpenai, + cloudinaryShortcutsIconsReddit, + uploadCvBgMobile, +} from '../../lib/image'; +import { useExtensionMockup } from './useExtensionMockup'; + +// Bare-illustration frame — slightly wider than tall so the CV cluster +// (rendered as a background image below) has horizontal room without +// cropping the calculator/laptop edges. `self-center` plus `items-center +// justify-center` keeps the other cards' illustrations centered too. +const illustrationFrameClass = + '!m-0 flex h-24 w-32 shrink-0 items-center justify-center self-center tablet:h-28 tablet:w-36'; + +const CvIllustration = (): ReactElement => ( +
+ +
+); + +// Compact cat illustration matched to `illustrationFrameClass` so the +// reminder card lines up height-wise with the CV / shortcuts cards in +// the extension top row (the `TopHero` default cat is sized for the +// taller webapp variant). +const CompactReminderCat = (): ReactElement => ( + +); + +const ShortcutsIllustration = (): ReactElement => { + const { githubShortcut } = useThemedAsset(); + // Cluster of four site icons echoes the in-feed onboarding row but + // compressed into the illustration frame so the cards line up. + const icons = [ + { src: cloudinaryShortcutsIconsGmail, rotate: '-rotate-12' }, + { src: githubShortcut, rotate: 'rotate-0' }, + { src: cloudinaryShortcutsIconsReddit, rotate: 'rotate-12' }, + { src: cloudinaryShortcutsIconsOpenai, rotate: '-rotate-6' }, + ]; + + return ( +
+
+ {icons.map(({ src, rotate }) => ( +
+ +
+ ))} +
+
+ ); +}; + +type UseShortcutsOnboardingResult = { + shouldShow: boolean; + onAddClick: () => void; +}; + +const useShortcutsOnboarding = (): UseShortcutsOnboardingResult => { + const hubEnabled = useIsShortcutsHubEnabled(); + const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const { openModal } = useLazyModal(); + const { completeAction, checkHasCompleted } = useActions(); + const { shortcutLinks } = useShortcutLinks(); + + // Once the user has at least one shortcut pinned (custom or top-site), + // the actual shortcuts row renders above these cards and the + // onboarding tile becomes redundant. Drop it so the remaining hero + // cards expand to fill the row. + const hasShortcuts = (shortcutLinks?.length ?? 0) > 0; + const shouldShow = !hasShortcuts; + + const completeFirstSession = () => { + if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { + completeAction(ActionType.FirstShortcutsSession); + } + }; + + const onAddClick = () => { + completeFirstSession(); + if (!showTopSites) { + toggleShowTopSites(); + } + openModal({ + type: hubEnabled ? LazyModal.ShortcutsManage : LazyModal.CustomLinks, + }); + }; + + return { shouldShow, onAddClick }; +}; + +export const ExtensionTopBanners = (): ReactElement | null => { + // The extension's top hero row is the only place this card appears + // on the new tab, so we bypass the temporal throttling that the + // webapp homepage uses to limit how often the reminder is shown. + const reminder = useReadingReminderHero({ + requireMobile: false, + bypassThrottling: true, + }); + const { isLoggedIn, isAuthReady } = useAuthContext(); + const { onUpload, shouldShow: shouldShowCv } = useUploadCv(); + const { completeAction } = useActions(); + const fileInputRef = useRef(null); + const shortcuts = useShortcutsOnboarding(); + const mockup = useExtensionMockup(); + + const showReminderCard = reminder.shouldShow || mockup.reminderCard; + const showCvCard = shouldShowCv || mockup.cvCard; + const showShortcutsCard = shortcuts.shouldShow || mockup.shortcutsCard; + const anyMockupForced = + mockup.reminderCard || mockup.cvCard || mockup.shortcutsCard; + + // Logged-out users normally see the dedicated sticky sign-in strip + // instead of these cards. In mockup mode any forced card overrides + // that gate so engineering can preview the layout while signed out. + if (!isAuthReady) { + return null; + } + if (!isLoggedIn && !anyMockupForced) { + return null; + } + + const cards: ReactElement[] = []; + + if (showReminderCard) { + cards.push( + } + onCtaClick={() => { + reminder.onEnable(); + }} + onClose={() => { + reminder.onDismiss(); + }} + />, + ); + } + + if (showCvCard) { + cards.push( + } + onCtaClick={() => fileInputRef.current?.click()} + onClose={() => completeAction(ActionType.ClosedProfileBanner)} + />, + ); + } + + if (showShortcutsCard) { + cards.push( + } + onCtaClick={shortcuts.onAddClick} + />, + ); + } + + if (cards.length === 0) { + return null; + } + + return ( + <> + `.${ext}`) + .join(',')} + className="hidden" + onChange={(event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + onUpload(file); + // Allow the user to re-pick the same file after an error. + // eslint-disable-next-line no-param-reassign + event.target.value = ''; + }} + /> +
+ {cards} +
+ + ); +}; diff --git a/packages/shared/src/features/extensionMockupPreview/useExtensionMockup.ts b/packages/shared/src/features/extensionMockupPreview/useExtensionMockup.ts new file mode 100644 index 00000000000..265a844aee7 --- /dev/null +++ b/packages/shared/src/features/extensionMockupPreview/useExtensionMockup.ts @@ -0,0 +1,70 @@ +import { useSyncExternalStore } from 'react'; + +export type ExtensionMockupState = { + signInStrip: boolean; + reminderCard: boolean; + cvCard: boolean; + shortcutsCard: boolean; +}; + +export type ExtensionMockupKey = keyof ExtensionMockupState; + +const STORAGE_KEY = 'extension-mockup-panel-state'; + +const defaultState: ExtensionMockupState = { + signInStrip: false, + reminderCard: true, + cvCard: true, + shortcutsCard: true, +}; + +const readFromStorage = (): ExtensionMockupState => { + if (typeof window === 'undefined') { + return defaultState; + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return defaultState; + } + const parsed = JSON.parse(raw) as Partial; + return { ...defaultState, ...parsed }; + } catch { + return defaultState; + } +}; + +let state: ExtensionMockupState = readFromStorage(); +const listeners = new Set<() => void>(); + +const emit = (next: ExtensionMockupState): void => { + state = next; + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // localStorage may be unavailable (private mode, quota); ignore. + } + } + listeners.forEach((listener) => listener()); +}; + +export const setExtensionMockup = ( + patch: Partial, +): void => { + emit({ ...state, ...patch }); +}; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const getSnapshot = (): ExtensionMockupState => state; +const getServerSnapshot = (): ExtensionMockupState => defaultState; + +export const useExtensionMockup = (): ExtensionMockupState => + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index ecae3b87b12..75fbcab6a2c 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -58,7 +58,7 @@ import useFeedSettings from '../../hooks/useFeedSettings'; import { useLogContext } from '../../contexts/LogContext'; import { usePostLogEvent } from '../../lib/feed'; import { useFeedCardContext } from './FeedCardContext'; -import useReportPost from '../../hooks/useReportPost'; +import { useHidePost } from '../../hooks/post/useHidePost'; import { useSharePost } from '../../hooks/useSharePost'; import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; import { useLazyModal } from '../../hooks/useLazyModal'; @@ -187,7 +187,10 @@ const PostOptionButtonContent = ({ const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); const { boostedBy } = useFeedCardContext(); - const { hidePost, unhidePost } = useReportPost(); + const { onHide } = useHidePost({ + post, + origin: Origin.PostContextMenu, + }); const { openSharePost } = useSharePost(origin); const { follow, unfollow, unblock, block } = useContentPreference(); const { openModal } = useLazyModal(); @@ -436,27 +439,6 @@ const PostOptionButtonContent = ({ showMessageAndRemovePost(copy, postIndex, onUndo), }); - const onHidePost = async (): Promise => { - const { successful } = await hidePost(post.id); - - if (!successful) { - return; - } - - logEvent( - postLogEvent(LogEvent.HidePost, post, { - extra: { origin: Origin.PostContextMenu }, - ...logOpts, - }), - ); - - showMessageAndRemovePost( - "🙈 This post won't show up on your feed anymore", - postIndex, - () => unhidePost(post.id), - ); - }; - const postOptions: MenuItemProps[] = [ { icon: , @@ -483,7 +465,9 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , label: 'Hide', - action: onHidePost, + action: () => { + onHide(); + }, }); postOptions.push({ diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 9395806461b..578ca75ef2e 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -43,6 +43,11 @@ export type SettingsFlags = { sidebarOtherExpanded: boolean; sidebarResourcesExpanded: boolean; sidebarBookmarksExpanded: boolean; + // Client-only flags — must stay optional so SettingsContext hydration + // can detect their absence (`flags?.[key] === undefined`) and overlay + // the persisted localStorage value without being shadowed by defaults. + sidebarRecentExpanded?: boolean; + sidebarSelectedCategory?: SidebarSelectedCategory; clickbaitShieldEnabled: boolean; timezoneMismatchIgnore?: string; prompt?: Record; @@ -64,9 +69,35 @@ export enum SidebarSettingsFlags { OtherExpanded = 'sidebarOtherExpanded', ResourcesExpanded = 'sidebarResourcesExpanded', BookmarksExpanded = 'sidebarBookmarksExpanded', + RecentExpanded = 'sidebarRecentExpanded', + SelectedCategory = 'sidebarSelectedCategory', ClickbaitShieldEnabled = 'clickbaitShieldEnabled', } +// Flag keys that the frontend manages locally only — the daily-api +// `SettingsFlagsPublicInput` schema does not accept these, so sending +// them via `updateUserSettings` makes the GraphQL server reject the +// whole mutation. They are persisted to localStorage instead and +// stripped from the remote payload before the request is dispatched. +export const CLIENT_ONLY_SETTINGS_FLAGS = [ + 'sidebarSelectedCategory', + 'sidebarRecentExpanded', +] as const satisfies ReadonlyArray; + +export type ClientOnlySettingsFlag = + (typeof CLIENT_ONLY_SETTINGS_FLAGS)[number]; + +export enum SidebarSelectedCategory { + Main = 'main', + Feeds = 'feeds', + Squads = 'squads', + Saved = 'saved', + Discover = 'discover', + Profile = 'profile', + Settings = 'settings', + GameCenter = 'gameCenter', +} + export type RemoteSettings = { openNewTab: boolean; theme: RemoteTheme; diff --git a/packages/shared/src/hooks/feed/useRecentPages.ts b/packages/shared/src/hooks/feed/useRecentPages.ts new file mode 100644 index 00000000000..32a56626db6 --- /dev/null +++ b/packages/shared/src/hooks/feed/useRecentPages.ts @@ -0,0 +1,379 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { del as delCache, get as getCache, set as setCache } from 'idb-keyval'; +import type { Source, Squad } from '../../graphql/sources'; +import type { PublicProfile } from '../../lib/user'; + +export const RECENT_PAGES_LIMIT = 5; +export const RECENT_PAGES_STORAGE_KEY = 'daily.recentPages'; + +export type RecentPageKind = 'squad' | 'tag' | 'source' | 'feed' | 'user'; + +export type RecentPageEntity = + | { + type: 'squad'; + handle: string; + image?: string | null; + name?: string | null; + } + | { + type: 'source'; + handle: string; + image?: string | null; + name?: string | null; + } + | { + type: 'user'; + username: string; + image?: string | null; + name?: string | null; + }; + +export interface RecentPage { + path: string; + title: string; + kind: RecentPageKind; + visitedAt: number; + entity?: RecentPageEntity; +} + +type RecentPageInput = Pick & + Partial>; + +type Detector = ( + query: Record, + asPath: string, +) => RecentPageInput | null; + +const firstValue = ( + value: string | string[] | undefined, +): string | undefined => { + if (Array.isArray(value)) { + return value[0]; + } + return value; +}; + +// Drop query/hash so e.g. ?openModal=... doesn't fragment a single feed into +// multiple recent entries. +const canonicalize = (asPath: string): string => asPath.split(/[?#]/)[0] ?? '/'; + +const detectors: Record = { + '/squads/[handle]': (query, asPath) => { + const handle = firstValue(query.handle); + if (!handle) { + return null; + } + return { path: canonicalize(asPath), title: `s/${handle}`, kind: 'squad' }; + }, + '/tags/[tag]': (query, asPath) => { + const tag = firstValue(query.tag); + if (!tag) { + return null; + } + return { path: canonicalize(asPath), title: `#${tag}`, kind: 'tag' }; + }, + '/sources/[source]': (query, asPath) => { + const source = firstValue(query.source); + if (!source) { + return null; + } + return { path: canonicalize(asPath), title: source, kind: 'source' }; + }, + '/feeds/[slugOrId]': (query, asPath) => { + const slugOrId = firstValue(query.slugOrId); + if (!slugOrId) { + return null; + } + return { path: canonicalize(asPath), title: slugOrId, kind: 'feed' }; + }, + // /[userId] is intentionally NOT detected here: the catch-all route also + // matches non-existent profiles (404s) and other top-level slugs. User + // visits are recorded explicitly from ProfileLayout once the profile has + // resolved successfully — see `useRecordRecentUserVisit`. +}; + +const detectRecentPage = ( + pathname: string, + query: Record, + asPath: string, +): RecentPageInput | null => { + const detector = detectors[pathname]; + if (!detector) { + return null; + } + return detector(query, asPath); +}; + +const isRecentPageKind = (value: unknown): value is RecentPageKind => + value === 'squad' || + value === 'tag' || + value === 'source' || + value === 'feed' || + value === 'user'; + +const sanitizeStoredEntity = (value: unknown): RecentPageEntity | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const entity = value as { + type?: unknown; + handle?: unknown; + username?: unknown; + image?: unknown; + name?: unknown; + }; + if (entity.type === 'user' && typeof entity.username === 'string') { + return { + type: 'user', + username: entity.username, + image: typeof entity.image === 'string' ? entity.image : undefined, + name: typeof entity.name === 'string' ? entity.name : undefined, + }; + } + + if ( + (entity.type === 'squad' || entity.type === 'source') && + typeof entity.handle === 'string' + ) { + return { + type: entity.type, + handle: entity.handle, + image: typeof entity.image === 'string' ? entity.image : undefined, + name: typeof entity.name === 'string' ? entity.name : undefined, + }; + } + + return undefined; +}; + +// Recent pages should never point to a single post. Earlier versions could +// store `/posts/{id}` under a squad/source entity when a post modal was opened +// on top of that entity's feed (router.asPath is swapped via shallow routing). +// Strip those out on read so legacy entries clear themselves over time. +const isFeedLikePath = (path: string): boolean => !path.startsWith('/posts/'); + +const sanitizeStoredPages = (value: unknown): RecentPage[] => { + if (!Array.isArray(value)) { + return []; + } + return value.reduce((pages, entry) => { + if ( + !entry || + typeof entry !== 'object' || + typeof (entry as RecentPage).path !== 'string' || + typeof (entry as RecentPage).title !== 'string' || + !isRecentPageKind((entry as RecentPage).kind) || + typeof (entry as RecentPage).visitedAt !== 'number' || + !isFeedLikePath((entry as RecentPage).path) + ) { + return pages; + } + + pages.push({ + path: (entry as RecentPage).path, + title: (entry as RecentPage).title, + kind: (entry as RecentPage).kind, + visitedAt: (entry as RecentPage).visitedAt, + entity: sanitizeStoredEntity((entry as RecentPage).entity), + }); + return pages; + }, []); +}; + +type Listener = (pages: RecentPage[]) => void; + +class RecentPagesStore { + private pages: RecentPage[] = []; + + private listeners = new Set(); + + private loaded = false; + + private loadPromise: Promise | null = null; + + load(): Promise { + if (this.loaded) { + return Promise.resolve(); + } + if (this.loadPromise) { + return this.loadPromise; + } + this.loadPromise = getCache(RECENT_PAGES_STORAGE_KEY) + .then((cached) => { + this.pages = sanitizeStoredPages(cached); + this.loaded = true; + this.notify(); + }) + .catch(() => { + this.loaded = true; + }); + return this.loadPromise; + } + + record(entry: RecentPageInput): void { + const previous = this.pages.find((page) => page.path === entry.path); + const nextEntry: RecentPage = { + ...entry, + title: entry.entity ? entry.title : previous?.title ?? entry.title, + entity: entry.entity ?? previous?.entity, + visitedAt: Date.now(), + }; + const next: RecentPage[] = [ + nextEntry, + ...this.pages.filter((page) => page.path !== entry.path), + ].slice(0, RECENT_PAGES_LIMIT); + if ( + this.pages.length === next.length && + this.pages.every( + (page, index) => + page.path === next[index].path && + page.visitedAt === next[index].visitedAt, + ) + ) { + return; + } + this.pages = next; + setCache(RECENT_PAGES_STORAGE_KEY, next).catch(() => undefined); + this.notify(); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + listener(this.pages); + return () => { + this.listeners.delete(listener); + }; + } + + clear(): void { + if (this.pages.length === 0) { + return; + } + this.pages = []; + delCache(RECENT_PAGES_STORAGE_KEY).catch(() => undefined); + this.notify(); + } + + private notify(): void { + this.listeners.forEach((listener) => listener(this.pages)); + } +} + +const recentPagesStore = new RecentPagesStore(); + +export const useRecentPagesTracker = (): void => { + const router = useRouter(); + + useEffect(() => { + recentPagesStore.load(); + }, []); + + useEffect(() => { + const entry = detectRecentPage( + router.pathname, + router.query, + router.asPath, + ); + if (!entry) { + return; + } + recentPagesStore.record(entry); + }, [router.pathname, router.query, router.asPath]); +}; + +export const useRecentPages = (): RecentPage[] => { + const [pages, setPages] = useState([]); + + useEffect(() => { + recentPagesStore.load(); + return recentPagesStore.subscribe(setPages); + }, []); + + return pages; +}; + +// Records a user-profile visit only after the profile has been resolved +// successfully, so we never write 404 paths or other top-level slugs that +// happen to match the /[userId] catch-all. +// +// We intentionally use the canonical entity URL (e.g. `/{username}`) instead +// of `router.asPath`. Opening a post modal on top of an entity page swaps the +// address-bar URL to `/posts/{id}` via shallow routing, and reading +// `router.asPath` here would otherwise persist the post URL under the +// entity's name and icon. +export const useRecordRecentUserVisit = ( + user: + | (Pick & { + username?: string | null; + }) + | null + | undefined, +): void => { + useEffect(() => { + if (!user?.username) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: `/${user.username}`, + title: user.name || `@${user.username}`, + kind: 'user', + entity: { + type: 'user', + username: user.username, + image: user.image, + name: user.name, + }, + }); + }, [user?.image, user?.name, user?.username]); +}; + +export const useRecordRecentSquadVisit = ( + squad: Pick | null | undefined, +): void => { + useEffect(() => { + if (!squad?.handle) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: `/squads/${squad.handle}`, + title: squad.name || `s/${squad.handle}`, + kind: 'squad', + entity: { + type: 'squad', + handle: squad.handle, + image: squad.image, + name: squad.name, + }, + }); + }, [squad?.handle, squad?.image, squad?.name]); +}; + +export const useRecordRecentSourceVisit = ( + source: Pick | null | undefined, +): void => { + useEffect(() => { + if (!source?.handle) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: `/sources/${source.handle}`, + title: source.name || source.handle, + kind: 'source', + entity: { + type: 'source', + handle: source.handle, + image: source.image, + name: source.name, + }, + }); + }, [source?.handle, source?.image, source?.name]); +}; + +export const clearRecentPages = (): void => { + recentPagesStore.clear(); +}; diff --git a/packages/shared/src/hooks/notifications/useReadingReminderHero.ts b/packages/shared/src/hooks/notifications/useReadingReminderHero.ts index 661c3e55247..61cf6560aa2 100644 --- a/packages/shared/src/hooks/notifications/useReadingReminderHero.ts +++ b/packages/shared/src/hooks/notifications/useReadingReminderHero.ts @@ -23,6 +23,13 @@ interface UseReadingReminderHero { interface UseReadingReminderHeroProps { requireMobile?: boolean; + /** + * Skip the temporal "registered today" / "seen today" / "shown in + * session" throttles. Use for placements that should appear as long + * as the user is logged in and hasn't subscribed or explicitly + * dismissed the reminder (e.g. the extension new tab top hero row). + */ + bypassThrottling?: boolean; } const DEFAULT_READING_REMINDER_HOUR = 9; @@ -59,6 +66,7 @@ const getIsRegisteredToday = (createdAt?: string | Date): boolean => { export const useReadingReminderHero = ({ requireMobile = true, + bypassThrottling = false, }: UseReadingReminderHeroProps = {}): UseReadingReminderHero => { const { isLoggedIn, user } = useAuthContext(); const { logEvent } = useLogContext(); @@ -87,7 +95,7 @@ export const useReadingReminderHero = ({ isLoggedIn && !isDigestLoading && !isSubscribedToReadingReminder && - !isRegisteredToday && + (bypassThrottling || !isRegisteredToday) && !isDismissed; const { value: copy } = useConditionalFeature({ feature: featureReadingReminderHeroCopy, @@ -96,16 +104,17 @@ export const useReadingReminderHero = ({ const hasSeenToday = getHasSeenToday(lastSeen); const [hasShownInSession, setHasShownInSession] = useState(false); - const shouldShowBase = shouldEvaluate && !hasSeenToday && isFetched; + const shouldShowBase = + shouldEvaluate && (bypassThrottling || !hasSeenToday) && isFetched; useEffect(() => { - if (!shouldShowBase || hasShownInSession) { + if (!shouldShowBase || hasShownInSession || bypassThrottling) { return; } setHasShownInSession(true); setLastSeen(new Date().toISOString()); - }, [hasShownInSession, setLastSeen, shouldShowBase]); + }, [bypassThrottling, hasShownInSession, setLastSeen, shouldShowBase]); const onEnable = useCallback(async () => { const timezone = diff --git a/packages/shared/src/hooks/post/useBlockPostPanel.ts b/packages/shared/src/hooks/post/useBlockPostPanel.ts index 04a9fafc0ab..b99cb1d1740 100644 --- a/packages/shared/src/hooks/post/useBlockPostPanel.ts +++ b/packages/shared/src/hooks/post/useBlockPostPanel.ts @@ -20,16 +20,23 @@ import { disabledRefetch, isNullOrUndefined } from '../../lib/func'; import { useToastNotification } from '../useToastNotification'; import { generateQueryKey, RequestKey } from '../../lib/query'; +export type BlockPanelMode = 'downvote' | 'hide'; + interface BlockData { showTagsPanel?: boolean; blocked?: DownvoteBlocked; + mode?: BlockPanelMode; +} + +interface ShowPanelOptions { + mode?: BlockPanelMode; } interface UseBlockPost { data: BlockData; blockedTags: number; onClose(forceClose?: boolean): void; - onShowPanel(): void; + onShowPanel(options?: ShowPanelOptions): void; onDismissPermanently(): void; onReport(): void; onUndo(): void; @@ -172,7 +179,11 @@ export const useBlockPostPanel = ( ); const onShowPanel = useCallback( - () => setShowTagsPanel({ showTagsPanel: true }), + (options?: ShowPanelOptions) => + setShowTagsPanel({ + showTagsPanel: true, + mode: options?.mode ?? 'downvote', + }), [setShowTagsPanel], ); diff --git a/packages/shared/src/hooks/post/useHidePost.ts b/packages/shared/src/hooks/post/useHidePost.ts new file mode 100644 index 00000000000..d6b0076e0c1 --- /dev/null +++ b/packages/shared/src/hooks/post/useHidePost.ts @@ -0,0 +1,94 @@ +import { useCallback, useContext, useMemo } from 'react'; +import type { Post } from '../../graphql/posts'; +import useReportPost from '../useReportPost'; +import { useBlockPostPanel } from './useBlockPostPanel'; +import { ActiveFeedContext } from '../../contexts/ActiveFeedContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { usePostLogEvent } from '../../lib/feed'; + +interface UseHidePostProps { + post: Post; + origin?: Origin; +} + +interface UseHidePost { + onHide: (overrideOrigin?: Origin) => Promise; + onUnhide: () => Promise; + onConfirmDismiss: (reason?: 'done' | 'unfollow' | 'block' | 'report') => void; +} + +export const useHidePost = ({ + post, + origin = Origin.PostContextMenu, +}: UseHidePostProps): UseHidePost => { + const { hidePost, unhidePost } = useReportPost(); + const { onShowPanel, onClose } = useBlockPostPanel(post); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { items, onRemovePost, logOpts } = useContext(ActiveFeedContext); + + const postIndex = useMemo( + () => + items.findIndex( + (item) => item.type === 'post' && item.post.id === post.id, + ), + [items, post.id], + ); + + const onHide = useCallback( + async (overrideOrigin?: Origin) => { + const { successful } = await hidePost(post.id); + + if (!successful) { + return false; + } + + logEvent( + postLogEvent(LogEvent.HidePost, post, { + extra: { origin: overrideOrigin ?? origin }, + ...logOpts, + }), + ); + + onShowPanel({ mode: 'hide' }); + return true; + }, + [hidePost, post, logEvent, postLogEvent, origin, logOpts, onShowPanel], + ); + + const onUnhide = useCallback(async () => { + logEvent( + postLogEvent(LogEvent.HidePostUndo, post, { + ...logOpts, + }), + ); + await unhidePost(post.id); + onClose(true); + }, [unhidePost, post, onClose, logEvent, postLogEvent, logOpts]); + + const onConfirmDismiss = useCallback( + (reason: 'done' | 'unfollow' | 'block' | 'report' = 'done') => { + const eventByReason: Record = { + done: LogEvent.HidePostConfirm, + unfollow: LogEvent.HidePostUnfollowSource, + block: LogEvent.HidePostBlockTags, + report: LogEvent.HidePostReport, + }; + + logEvent( + postLogEvent(eventByReason[reason], post, { + ...logOpts, + }), + ); + + onClose(true); + if (postIndex >= 0) { + onRemovePost?.(postIndex); + } + }, + [onClose, onRemovePost, postIndex, logEvent, postLogEvent, post, logOpts], + ); + + return { onHide, onUnhide, onConfirmDismiss }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 4659f503b93..510affb513b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -104,11 +104,6 @@ export const featureCores = new Feature('cores', isDevelopment); // does not necessarily mean they can't boost a post if they have access to cores export const featurePostBoostAds = new Feature('post_boost_ads', isDevelopment); -export const briefCardFeedFeature = new Feature( - 'brief_card_feed', - isDevelopment, -); - export const profileCompletionCardFeature = new Feature( 'profile_completion_card', false, diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 161206bb8ca..2aead331b2a 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -101,6 +101,11 @@ export enum Origin { export enum LogEvent { HidePost = 'hide post', + HidePostUnfollowSource = 'hide post unfollow source', + HidePostBlockTags = 'hide post block tags', + HidePostReport = 'hide post report', + HidePostUndo = 'hide post undo', + HidePostConfirm = 'hide post confirm', ReportSquad = 'report squad', Click = 'click', CommentPost = 'comment post', diff --git a/packages/webapp/__tests__/MainLayout.tsx b/packages/webapp/__tests__/MainLayout.tsx index d2f4b8406e9..334fb1efe6e 100644 --- a/packages/webapp/__tests__/MainLayout.tsx +++ b/packages/webapp/__tests__/MainLayout.tsx @@ -15,11 +15,14 @@ describe('MainLayout', () => { jest.spyOn(hooks, 'useViewSize').mockImplementation(() => true); }); - const renderLayout = (user: LoggedUser = null): RenderResult => { + const renderLayout = (user: LoggedUser | null = null): RenderResult => { const client = new QueryClient(); return render( - + , ); @@ -44,9 +47,9 @@ describe('MainLayout', () => { reputation: 5, permalink: 'https://app.daily.dev/ido', infoConfirmed: true, + balance: { amount: 0 }, }); const [el] = await screen.findAllByAltText(`idoshamun's profile`); expect(el).toHaveAttribute('src', 'https://daily.dev/ido.png'); - expect(screen.getByText('5')).toBeInTheDocument(); }); }); diff --git a/packages/webapp/__tests__/SquadFeedPage.tsx b/packages/webapp/__tests__/SquadFeedPage.tsx index c9d07d015ae..a14d96317df 100644 --- a/packages/webapp/__tests__/SquadFeedPage.tsx +++ b/packages/webapp/__tests__/SquadFeedPage.tsx @@ -317,7 +317,11 @@ describe('squad page', () => { describe('squad page header', () => { it('should show squad name', async () => { renderComponent(); - await screen.findByText(defaultSquad.name); + // The squad name now renders both in the slim PageHeader at the top of + // the page and in the big SquadPageHeader identity block, so we expect + // multiple matches. + const matches = await screen.findAllByText(defaultSquad.name); + expect(matches.length).toBeGreaterThan(0); }); it('should show squad handle', async () => { diff --git a/packages/webapp/components/layouts/MainFeedPage.tsx b/packages/webapp/components/layouts/MainFeedPage.tsx index 48bce4199ea..422ae033054 100644 --- a/packages/webapp/components/layouts/MainFeedPage.tsx +++ b/packages/webapp/components/layouts/MainFeedPage.tsx @@ -8,6 +8,9 @@ import { getShouldRedirect } from '@dailydotdev/shared/src/components/utilities' import type { GetDefaultFeedProps } from '@dailydotdev/shared/src/lib/feed'; import { getFeedName } from '@dailydotdev/shared/src/lib/feed'; import dynamic from 'next/dynamic'; +import { ExtensionMockupPanel } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionMockupPanel'; +import { ExtensionSignInStrip } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionSignInStrip'; +import { ExtensionTopBanners } from '@dailydotdev/shared/src/features/extensionMockupPreview/ExtensionTopBanners'; import { getLayout } from './FeedLayout'; const MainFeedLayout = dynamic( @@ -103,16 +106,19 @@ export default function MainFeedPage({ } return ( - -

{getFeedHeading(feedName)}

- {children} -
+ <> + +

{getFeedHeading(feedName)}

+ {children} +
+ + ); } @@ -131,4 +137,10 @@ export function getMainFeedLayout( export const mainFeedLayoutProps: MainLayoutProps = { mainPage: true, screenCentered: false, + topBanner: ( + <> + + + + ), }; diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index f3b3b3146c1..fb2241ba993 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -19,6 +19,7 @@ import Head from 'next/head'; import type { NextSeoProps } from 'next-seo'; import { ClientQuestEventType } from '@dailydotdev/shared/src/graphql/quests'; import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile'; +import { useRecordRecentUserVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; import { useTrackQuestClientEvent } from '@dailydotdev/shared/src/hooks/useTrackQuestClientEvent'; import CustomAuthBanner from '@dailydotdev/shared/src/components/auth/CustomAuthBanner'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; @@ -103,7 +104,7 @@ export default function ProfileLayout({ const { user: viewer } = useAuthContext(); const [trackedView, setTrackedView] = useState(false); const { logEvent } = useLogContext(); - const { referrerPost } = usePostReferrerContext(); + const referrerPost = usePostReferrerContext()?.referrerPost; useTrackQuestClientEvent({ eventType: ClientQuestEventType.ViewUserProfile, enabled: !!user && !!viewer?.id && viewer.id !== user.id, @@ -113,6 +114,10 @@ export default function ProfileLayout({ // Auto-collapse sidebar on small screens useProfileSidebarCollapse(); + // Record this profile visit in the sidebar Recent section only after the + // profile has resolved (avoids 404s and reserved top-level slugs). + useRecordRecentUserVisit(user); + useEffect(() => { if (trackedView || !user) { return; @@ -149,12 +154,14 @@ export default function ProfileLayout({ {children} ); @@ -165,7 +172,7 @@ export const getLayout = ( props: ProfileLayoutProps, ): ReactNode => getFooterNavBarLayout( - getMainLayout({page}, null, { + getMainLayout({page}, undefined, { screenCentered: false, customBanner: , }), @@ -184,7 +191,10 @@ export async function getStaticProps({ }: GetStaticPropsContext): Promise< GetStaticPropsResult> > { - const { userId } = params; + const userId = params?.userId; + if (!userId) { + return { props: { noindex: true }, revalidate: 60 }; + } try { const user = await getProfile(userId); if (!user) { diff --git a/packages/webapp/components/layouts/SettingsLayout/AccountPageContainer.tsx b/packages/webapp/components/layouts/SettingsLayout/AccountPageContainer.tsx index e6b2267ad9e..df93d5a7516 100644 --- a/packages/webapp/components/layouts/SettingsLayout/AccountPageContainer.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/AccountPageContainer.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { Button, @@ -14,7 +15,11 @@ import { AccountPageHeading, AccountPageSection, } from './common'; -import { navigationKey } from '.'; +import { + navigationKey, + SETTINGS_HEADER_ACTIONS_PORTAL_ID, + SETTINGS_HEADER_TITLE_PORTAL_ID, +} from '.'; interface ClassName { container?: string; @@ -23,7 +28,13 @@ interface ClassName { } interface AccountPageContainerProps { - title: string; + /** + * Page title rendered into the master settings PageHeader strip on + * laptop and into the inline `AccountPageHeading` on mobile. Accepts + * a ReactNode so callers can render richer chrome (e.g. tab + * navigation that replaces the title — see settings/notifications). + */ + title: ReactNode; actions?: ReactNode; children?: ReactNode; className?: ClassName; @@ -38,41 +49,114 @@ export const AccountPageContainer = ({ onBack, }: AccountPageContainerProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); + const isLaptop = useViewSize(ViewSize.Laptop); const [, setIsOpen] = useQueryState({ key: navigationKey, defaultValue: false, }); + // Capture the laptop PageHeader's portal targets imperatively after + // SettingsLayout has committed. Reading the DOM in a useEffect (rather + // than via `ref={setState}`) avoids running setState from a ref-detach + // during the React commit phase, which previously cascaded into the + // "Maximum update depth exceeded" error on settings pages. + const [titleNode, setTitleNode] = useState(null); + const [actionsNode, setActionsNode] = useState(null); + useEffect(() => { + if (!isLaptop) { + setTitleNode(null); + setActionsNode(null); + return; + } + setTitleNode(document.getElementById(SETTINGS_HEADER_TITLE_PORTAL_ID)); + setActionsNode(document.getElementById(SETTINGS_HEADER_ACTIONS_PORTAL_ID)); + }, [isLaptop]); - return ( - - + // Portal the page-specific title straight into the master PageHeader + // (no "Settings · " prefix — the dedicated sidebar already labels + // this surface as Settings, so the page strip should focus on where + // the user actually is). String titles get the standard bold callout + // styling; ReactNode titles are responsible for their own typography + // so consumers can render full-height tab nav etc. + const portaledTitle = + typeof title === 'string' ? ( + {title} + ) : ( + title + ); + + const portaledActions = ( + <> + {onBack && ( diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 406a21aea48..f5cc4def22e 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -24,15 +24,28 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; import { useCanPurchaseCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout'; import { getLayout as getMainLayout } from '../MainLayout'; +// Stable DOM ids for the per-page title / actions portal targets that +// `AccountPageContainer` writes into via `createPortal`. Using fixed +// ids (queried with `document.getElementById` from the consumer) avoids +// having to expose live DOM nodes via React state — the previous +// "ref={setState}" approach fired a state update during ref detach in +// the commit phase, which on settings pages cascaded into React's +// "Maximum update depth exceeded" guard. +export const SETTINGS_HEADER_TITLE_PORTAL_ID = 'settings-header-title-portal'; +export const SETTINGS_HEADER_ACTIONS_PORTAL_ID = + 'settings-header-actions-portal'; + const ProfileSettingsMenuMobile = dynamic( () => import( @@ -56,12 +69,19 @@ const ProfileSettingsMenuDesktop = dynamic( export const navigationKey = generateQueryKey( RequestKey.AccountNavigation, - null, + undefined, ); +// Mobile tray + dedicated sidebar still label this whole area as +// "Settings". The top page-header strip on the floating card, +// however, mirrors the *current page* (Profile, Account & Security, +// Job preferences, ...) — the dedicated sidebar already establishes +// that you're inside Settings, so repeating it in the page header +// just pushes the actually-useful page title out of the visible area. + export default function SettingsLayout({ children, -}: PropsWithChildren): ReactElement { +}: PropsWithChildren): ReactElement | null { const router = useRouter(); const { user: profile, isAuthReady } = useContext(AuthContext); const isMobile = useViewSize(ViewSize.MobileL); @@ -84,11 +104,30 @@ export default function SettingsLayout({ const { formRef } = useAuthForms(); + const renderSettingsMenu = (): ReactElement | null => { + if (isMobile) { + return ( + router.push(profile?.permalink ?? webappUrl)} + /> + ); + } + if (isLaptop) { + return null; + } + return ; + }; + if (!isAuthReady) { return null; } if (!profile) { + if (!formRef) { + return null; + } return (
+ ); + return ( <> {!isMobile && !isLaptop && ( @@ -134,13 +184,21 @@ export default function SettingsLayout({ />
)} + {isLaptop && ( + + + + )} {router.query.redirectTo && router.query.redirectCopy && ( + )} + + )} + - )} - + + + + + ); + })} + +); + const AccountNotificationsPage = (): ReactElement => { const { isLoadingPreferences } = useNotificationSettings(); + const [activeTab, setActiveTab] = useState('in-app'); + const isLaptop = useViewSize(ViewSize.Laptop); + + // Laptop: tabs replace the page title in the master PageHeader + // strip (matches FindSquad's directory navbar pattern). The `-my-3` + // shell cancels the header's vertical padding so the tab underline + // lands flush on the header's bottom border. + // Mobile / tablet: keep a plain "Notifications" title in the + // AccountPageHeading and stack the tab nav below it, since the + // mobile heading row is too short for a full tab strip with an + // underline indicator. + const title = isLaptop ? ( +
+ +
+ ) : ( + 'Notifications' + ); + + const body = + activeTab === 'in-app' ? ( + + ) : ( + + ); if (isLoadingPreferences) { - return
; + return ( + +
+ + ); } return ( - - - - - - - - - - + + {!isLaptop && ( +
+ +
+ )} + {body} +
); }; diff --git a/packages/webapp/pages/settings/profile/experience/edit.tsx b/packages/webapp/pages/settings/profile/experience/edit.tsx index d1acb8892dd..3c3595ba4c7 100644 --- a/packages/webapp/pages/settings/profile/experience/edit.tsx +++ b/packages/webapp/pages/settings/profile/experience/edit.tsx @@ -198,12 +198,13 @@ const Page = ({ experience }: PageProps): ReactElement => { }`} actions={ diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index dd735255ed5..82fa55e5049 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -66,6 +66,8 @@ import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { ArchiveEntryCard } from '@dailydotdev/shared/src/components/archive/ArchiveEntryCard'; import { ArchiveBreadcrumbs } from '@dailydotdev/shared/src/components/archive/ArchiveBreadcrumbs'; import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive'; +import { useRecordRecentSourceVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import Custom404 from '../404'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -220,6 +222,7 @@ const SourcePage = ({ const { shouldShowAuthBanner } = useOnboardingActions(); const shouldShowTagSourceSocialProof = shouldShowAuthBanner && isLaptop; const { user } = useContext(AuthContext); + useRecordRecentSourceVisit(source); const mostUpvotedQueryVariables = useMemo( () => ({ source: source?.id, @@ -265,6 +268,7 @@ const SourcePage = ({ dangerouslySetInnerHTML={{ __html: jsonLd }} /> + - -