diff --git a/packages/shared/package.json b/packages/shared/package.json index 5bd885d6030..8d9bb8e5d8a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,6 +15,7 @@ "peerDependencies": { "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.35.3", + "aria-hidden": "^1.2.6", "classnames": "^2.3.1", "date-fns": "^2.22.1", "date-fns-tz": "1.2.2", diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index 955127cbb76..f6463d07fbd 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -7,7 +7,9 @@ import React, { useState, } from 'react'; import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; import classNames from 'classnames'; +import { webappUrl } from '../lib/constants'; import { BookmarkSort, BOOKMARKS_FEED_QUERY, @@ -25,9 +27,13 @@ import { Button, ButtonSize, ButtonVariant } from './buttons/Button'; import { ShareIcon, SortIcon } from './icons'; import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query'; import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; +import { useIsPhone } from '../hooks/useViewSize'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { PageHeader } from './layout/PageHeader'; import { BookmarkSection } from './sidebar/sections/BookmarkSection'; +import { ShellPage } from './shell/ShellPageContext'; +import { Segments, ShellRow } from './shell/ShellRow'; +import { useBookmarkFolderList } from '../hooks/bookmark'; import PlusMobileEntryBanner from './marketing/banners/PlusMobileEntryBanner'; import { DigestBookmarkBanner } from './marketing/banners/DigestBookmarkBanner'; import { @@ -80,6 +86,9 @@ export default function BookmarkFeedLayout({ isReminderOnly, }: BookmarkFeedLayoutProps): ReactElement | null { const [isHydrated, setIsHydrated] = useState(false); + const router = useRouter(); + const { folders: bookmarkFolders } = useBookmarkFolderList(); + const bookmarkPath = (router.asPath ?? router.pathname ?? '').split('?')[0]; const { shouldUseListFeedLayout, FeedPageLayoutComponent, @@ -94,6 +103,7 @@ export default function BookmarkFeedLayout({ DEFAULT_BOOKMARK_SORT_INDEX, ); const isLaptop = useViewSize(ViewSize.Laptop); + const isPhone = useIsPhone(); const { isV2 } = useLayoutVariant(); const isV2Laptop = isV2; const isSearchResults = !!searchQuery; @@ -190,21 +200,29 @@ export default function BookmarkFeedLayout({ return null; } + // In the block the controls are the shell's 38px squares. + const blockButtonClassName = isPhone + ? 'shell-material !size-[2.375rem] !rounded-14 !p-0' + : undefined; + const sortDropdown = !isSearchResults && ( } iconOnly selectedIndex={selectedSort} options={bookmarkSortOptionLabels} onChange={(_, index) => setSelectedSort(index)} - buttonVariant={isV2Laptop ? ButtonVariant.Tertiary : ButtonVariant.Float} + buttonVariant={ + isV2Laptop || isPhone ? ButtonVariant.Tertiary : ButtonVariant.Float + } buttonSize={isV2Laptop ? ButtonSize.Small : ButtonSize.Medium} drawerProps={{ displayCloseButton: true }} /> @@ -212,7 +230,7 @@ export default function BookmarkFeedLayout({ const shareButton = !isFolderPage && ( @@ -292,9 +312,13 @@ export default function BookmarkFeedLayout({ )} > {searchChildren} - {sortDropdown} - {shareButton} - {folderMenu} + {!isPhone && ( + + {sortDropdown} + {shareButton} + {folderMenu} + + )} )} @@ -305,7 +329,46 @@ export default function BookmarkFeedLayout({ onRequestClose={() => setShowSharedBookmarks(false)} /> )} -
+ + {sortDropdown} + {shareButton} + {folderMenu} +
+ } + row={ + + ({ + key: list.id, + label: list.name, + href: `${webappUrl}bookmarks/${list.id}`, + active: bookmarkPath === `/bookmarks/${list.id}`, + replace: true, + })), + ]} + /> + + } + /> +
{ return
; }; +// On a phone the post menu is a two-level sheet: hide and the block rows sit +// behind Not interested; desktop keeps them on the one level. +const openNotInterested = async () => { + await screen.findByText('Share via'); + const row = screen.queryByText('Not interested'); + if (row) { + fireEvent.click(row); + } +}; + beforeEach(() => { queryClient.clear(); jest.restoreAllMocks(); @@ -961,6 +971,7 @@ describe('Feed logged in', () => { fireEvent.keyDown(menuBtn, { key: ' ', }); + await openNotInterested(); const contextBtn = await screen.findByText('Hide'); contextBtn.click(); await waitFor(() => expect(hideCalled).toBeTruthy()); @@ -1000,6 +1011,7 @@ describe('Feed logged in', () => { const [menuBtn] = await screen.findAllByLabelText('Options'); fireEvent.keyDown(menuBtn, { key: ' ' }); + await openNotInterested(); (await screen.findByText('Hide')).click(); const undoBtn = await screen.findByRole('button', { name: 'Undo' }); fireEvent.click(undoBtn); @@ -1034,6 +1046,7 @@ describe('Feed logged in', () => { const [menuBtn] = await screen.findAllByLabelText('Options'); fireEvent.keyDown(menuBtn, { key: ' ' }); + await openNotInterested(); (await screen.findByText('Hide')).click(); const closeBtn = await screen.findByTestId('postHiddenPanelClose'); @@ -1066,6 +1079,7 @@ describe('Feed logged in', () => { const [menuBtn] = await screen.findAllByLabelText('Options'); fireEvent.keyDown(menuBtn, { key: ' ' }); + await openNotInterested(); (await screen.findByText('Hide')).click(); const doneBtn = await screen.findByTestId('postHiddenPanelDone'); @@ -1121,6 +1135,7 @@ describe('Feed logged in', () => { ); expect(data).toBeTruthy(); }); + await openNotInterested(); (await screen.findByText('Hide')).click(); fireEvent.click(await screen.findByTestId('hideBlockSourceButton')); @@ -1211,6 +1226,7 @@ describe('Feed logged in', () => { ); expect(data).toBeTruthy(); }); + await openNotInterested(); (await screen.findByText('Hide')).click(); fireEvent.click(await screen.findByTestId('hideBlockSourceButton')); @@ -1264,6 +1280,7 @@ describe('Feed logged in', () => { ); expect(data).toBeTruthy(); }); + await openNotInterested(); const contextBtn = await screen.findByText('Block Echo JS'); fireEvent.click(contextBtn); await waitForNock(); @@ -1315,6 +1332,7 @@ describe('Feed logged in', () => { ); expect(data).toBeTruthy(); }); + await openNotInterested(); const contextBtn = await screen.findByText('Unblock Echo JS'); fireEvent.click(contextBtn); @@ -1359,6 +1377,7 @@ describe('Feed logged in', () => { ); expect(data).toBeTruthy(); }); + await openNotInterested(); const contextBtn = await screen.findByText('Block #javascript'); fireEvent.click(contextBtn); diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index c4ee9fe944d..dee12f18dff 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -70,6 +70,7 @@ import { useViewSize, ViewSize, } from '../hooks'; +import { useIsPhone } from '../hooks/useViewSize'; import { feedNameToHeading, v2FeedSideInsetClass } from './feeds/FeedContainer'; import { pageHeaderClassName } from './layout/PageHeader'; import { @@ -105,6 +106,16 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import SearchMobileFiltersButton from './search/SearchMobileFiltersButton'; +import { ExploreSortMenu } from './shell/ExploreSortMenu'; +import { ShellPage } from './shell/ShellPageContext'; + +const SpotlightTrigger = dynamic( + () => + import( + /* webpackChunkName: "spotlightTrigger" */ './spotlight/SpotlightTrigger' + ), +); + const FeedExploreHeader = dynamic( () => import(/* webpackChunkName: "feedExploreHeader" */ './header').then( @@ -264,6 +275,7 @@ export default function MainFeedLayout({ }); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const isLaptop = useViewSize(ViewSize.Laptop); + const isPhone = useIsPhone(); const { isV2 } = useLayoutVariant(); const feedVersion = useFeature(feature.feedVersion); const { time, contentCurationFilter, postTypesFilter } = @@ -730,7 +742,8 @@ export default function MainFeedLayout({ // (see `enableSsrSafeLayout`), so keying the spacing to it made the // gap change size on navigation and settle differently on reload. const disableTopPadding = - isFinder || (shouldUseListFeedLayout && !isAnyExplore); + isFinder || + (shouldUseListFeedLayout && (!isAnyExplore || (isPhone && hasMounted))); const onTabChange = useCallback( (clickedTab: ExploreTabs) => { if (clickedTab === ExploreTabs.BestOf && isExtension) { @@ -764,6 +777,10 @@ export default function MainFeedLayout({ ); } + if (isPhone) { + return ; + } + return ( ); - }, [isLaptop, onTabChange, tab]); + }, [isLaptop, isPhone, onTabChange, tab]); // v2 reaches the Explore hub sections (Explore, Tags, Sources, Leaderboard, // Discussions) from the sidebar's Explore panel, so the page header no longer @@ -899,7 +916,21 @@ export default function MainFeedLayout({ > {isAnyExplore && !showExploreV2PageHeader && } {isSearchOn && !isSearchPageLaptop && search} - {isSearchOn && !isSearchPageLaptop && ( + {isExploreTag && isPhone && ( + + )} + {isSearchOn && !isSearchPageLaptop && isPhone && ( + } + row={ +
+ +
+ } + /> + )} + {isSearchOn && !isSearchPageLaptop && !isPhone && (
@@ -335,156 +337,162 @@ function MainLayoutComponent({ isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; return ( -
- {canGoBack && } - {customBanner} - {isBannerAvailable && } - - - - - - - - {plusEntryAnnouncementBar && ( - - )} - - {/* Temporary while layout v2 is experimental: production users are on - v1, so render its header in the initial HTML instead of waiting for - feature resolution and delaying the post page's LCP. */} - {!sidebarOwnsHeader && ( - - )} -
+
`, v2's floating card, tablet, laptop — is what gives the - // dock a full-height column to be pushed to the end of. Borders and - // padding count toward it, so the header padding above comes out of - // the same 100vh rather than adding to it. - // Literal, not built from `DOCK_CLASS`: Tailwind scans source text - // and generates nothing for an interpolated class name. - 'has-[.feed-dock]:min-h-screen', + 'antialiased', + // The phone block is fixed; everything in this column, the + // banners included, starts under it. + !sidebarOwnsHeader && 'pt-[var(--shell-top,0px)] tablet:pt-0', + isV2 && + 'laptop:bg-[color-mix(in_srgb,var(--theme-surface-secondary)_3%,var(--theme-background-default))]', )} > - {isAuthReadyOrCached && isLayoutChromeResolved && showSidebar && ( - } + {customBanner} + {isBannerAvailable && } + + + + + + + + {plusEntryAnnouncementBar && ( + + )} + + {/* Temporary while layout v2 is experimental: production users are on + v1, so render its header in the initial HTML instead of waiting for + feature resolution and delaying the post page's LCP. */} + {!sidebarOwnsHeader && ( + )} - {sidebarOwnsHeader ? ( -
- {topBanner} -
} +
`, v2's floating card, tablet, laptop — is what gives the + // dock a full-height column to be pushed to the end of. Borders and + // padding count toward it, so the header padding above comes out of + // the same 100vh rather than adding to it. + // Literal, not built from `DOCK_CLASS`: Tailwind scans source text + // and generates nothing for an interpolated class name. + 'has-[.feed-dock]:min-h-screen', + )} + > + {isAuthReadyOrCached && isLayoutChromeResolved && showSidebar && ( + + )} + {sidebarOwnsHeader ? (
- - {children} + {topBanner} +
+
+ + {children} +
-
- ) : ( - children - )} -
- {!hideFeedbackWidget && !sidebarOwnsHeader && } -
+ ) : ( + children + )} +
+ {!hideFeedbackWidget && !sidebarOwnsHeader && } +
+ ); } diff --git a/packages/shared/src/components/NoSidebarLayout.tsx b/packages/shared/src/components/NoSidebarLayout.tsx index d878b2f62b4..974af726511 100644 --- a/packages/shared/src/components/NoSidebarLayout.tsx +++ b/packages/shared/src/components/NoSidebarLayout.tsx @@ -5,6 +5,7 @@ import Link from './utilities/Link'; import { Button } from './buttons/Button'; import { ButtonSize, ButtonVariant } from './buttons/common'; import { ArrowIcon } from './icons'; +import { ShellPage } from './shell/ShellPageContext'; /** * Layout that doesn't show a sidebar @@ -24,8 +25,9 @@ export function NoSidebarLayout({ }): ReactNode { return (
+ {!hideBackButton && } {!hideBackButton && ( -
+
+ + + + + + , + ); + + fireEvent.keyDown(screen.getByRole('button', { name: 'Options' }), { + key: 'Enter', + }); + + return { share, report }; +}; + +// Each option is a Radix item around its own button; the buttons are the +// rows a finger or a keyboard reaches. +const menuButtons = () => + screen.getAllByRole('menuitem').filter((item) => item.tagName === 'BUTTON'); + +describe('DropdownMenu', () => { + it('opens a popover on a desktop with the items wired to their actions', () => { + const { share } = renderMenu(false); + + const items = menuButtons(); + expect(items.map((item) => item.textContent?.trim())).toEqual([ + 'Share', + 'Report', + ]); + expect(items[0].closest('.shell-menu-sheet')).toBeNull(); + + fireEvent.click(items[0]); + expect(share).toHaveBeenCalledTimes(1); + }); + + it('opens the same items as a bottom sheet on a phone', () => { + const { report } = renderMenu(true); + + const items = menuButtons(); + expect(items.map((item) => item.textContent?.trim())).toEqual([ + 'Share', + 'Report', + ]); + expect(items[0].closest('.shell-menu-sheet')).not.toBeNull(); + + fireEvent.click(items[1]); + expect(report).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/components/dropdown/DropdownMenu.tsx b/packages/shared/src/components/dropdown/DropdownMenu.tsx index d45ce34832e..fcea5e0bc24 100644 --- a/packages/shared/src/components/dropdown/DropdownMenu.tsx +++ b/packages/shared/src/components/dropdown/DropdownMenu.tsx @@ -1,5 +1,12 @@ import type { ReactNode } from 'react'; -import React, { isValidElement, useState } from 'react'; +import React, { + createContext, + useCallback, + isValidElement, + useContext, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import type { DropdownMenuContentProps as RadixDropdownMenuContentProps, @@ -24,6 +31,8 @@ import type { MenuItemProps } from './common'; import { useRequestProtocol } from '../../hooks/useRequestProtocol'; import { getCompanionWrapper } from '../../lib/extension'; import { useScrollFade } from '../../hooks/useScrollFade'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { attachSheetDrag } from '../shell/sheetDrag'; export const DropdownMenuItem = classed( DropdownMenuItemRoot, @@ -74,30 +83,56 @@ export const DropdownMenuTrigger = React.forwardRef< }); DropdownMenuTrigger.displayName = 'DropdownMenuTrigger'; +const assignRef = (ref: React.ForwardedRef, node: T | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + Object.assign(ref, { current: node }); + } +}; + +// Lets the phone sheet close itself (a drag past a third) through the +// root's own open state. +const DropdownMenuCloseContext = createContext<() => void>(() => undefined); + export const DropdownMenu = React.forwardRef( ({ children, ...props }, _forwardedRef) => { if (_forwardedRef) { // DropdownMenu is kept as forwardRef-compatible even though Radix root has no ref target here. } const [open, setOpen] = useState(false); + const isPhone = useViewSize(ViewSize.MobileL); + // On a phone the menu is a sheet: modal, so a tap on the scrim closes it + // without reaching the page, and never closed by the scroll the + // collapsing address bar fires. useEventListener(globalThis.window, 'scroll', () => { + if (isPhone) { + return; + } props.onOpenChange?.(false); setOpen(false); }); + const close = () => { + props.onOpenChange?.(false); + setOpen(false); + }; + return ( - { - props.onOpenChange?.(value); - setOpen(value); - }} - modal={false} - {...props} - > - {children} - + + { + props.onOpenChange?.(value); + setOpen(value); + }} + modal={isPhone} + {...props} + > + {children} + + ); }, ); @@ -123,17 +158,65 @@ export const DropdownMenuContent = React.forwardRef< const { isCompanion } = useRequestProtocol(); const container = isCompanion ? getCompanionWrapper() : undefined; const scrollFadeRef = useScrollFade(); + const isPhone = useViewSize(ViewSize.MobileL); + const close = useContext(DropdownMenuCloseContext); + const closeRef = useRef(close); + closeRef.current = close; + const scrollRef = useRef(null); + const detachDrag = useRef<() => void>(); + const setScrollRef = (node: HTMLDivElement | null) => { + scrollRef.current = node; + scrollFadeRef(node); + }; + // Radix mounts the content from its own open state, not from a render + // of this wrapper, so an effect here can run before the panel exists + // (WebKit showed it: the effect saw no panel and the sheet never + // dragged). The ref callback sees the node the moment it arrives. + // Stable, or React would re-run it on every render of the content and + // re-attach the drag in the middle of a gesture. + const panelRef = useRef(null); + const setPanelRef = useCallback( + (node: HTMLDivElement | null) => { + assignRef(forwardedRef, node); + // Radix recomposes its refs on every render, so this is called with + // null and the same node again and again; the drag stays attached + // until a different node arrives. + if (!node || node === panelRef.current) { + return; + } + panelRef.current = node; + detachDrag.current?.(); + detachDrag.current = undefined; + if (isPhone) { + detachDrag.current = attachSheetDrag(node, () => closeRef.current(), { + scroller: () => scrollRef.current, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isPhone], + ); + return ( { + props.onCloseAutoFocus?.(event); + if (isPhone) { + event.preventDefault(); + } + }} + ref={setPanelRef} className={classNames( styles.DropdownMenuContent, 'overflow-hidden', variant === 'field' ? styles.DropdownMenuContentField : styles.DropdownMenuContentAction, + isPhone && 'shell-menu-sheet', className, )} align={align} @@ -141,12 +224,14 @@ export const DropdownMenuContent = React.forwardRef< collisionPadding={collisionPadding ?? 24} >
{children} diff --git a/packages/shared/src/components/dropdown/common.tsx b/packages/shared/src/components/dropdown/common.tsx index d284b8d8eea..37409418159 100644 --- a/packages/shared/src/components/dropdown/common.tsx +++ b/packages/shared/src/components/dropdown/common.tsx @@ -5,6 +5,8 @@ export interface MenuItemProps< TArgs extends Array = Array, TAnchorProps = AnchorHTMLAttributes, > { + // A stable key a menu can group by; labels change with state. + id?: string; icon?: ReactNode; label: string; action?: (...args: TArgs) => TReturn; diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index 1a761733256..efb33b42343 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -138,7 +138,6 @@ export function FeedbackWidget({ key={member.username} user={member} size={ProfileImageSize.Small} - rounded="full" className={classNames( 'border-2 border-background-default group-hover:border-surface-hover', index !== 0 && '-ml-3', @@ -181,7 +180,6 @@ export function FeedbackWidget({ key={member.username} user={member} size={ProfileImageSize.Medium} - rounded="full" className={classNames( 'border-2 border-text-primary', index !== 0 && '-ml-4', diff --git a/packages/shared/src/components/feeds/FeedNav.spec.tsx b/packages/shared/src/components/feeds/FeedNav.spec.tsx index d3746a4eba1..19f3654308a 100644 --- a/packages/shared/src/components/feeds/FeedNav.spec.tsx +++ b/packages/shared/src/components/feeds/FeedNav.spec.tsx @@ -94,11 +94,6 @@ jest.mock('../../features/getApp/hooks/useMobileAppHeader', () => ({ useMobileAppHeader: () => false, })); -jest.mock('./MobileFeedActions', () => ({ - MobileFeedActions: () =>
, - hideLoggedOutRowClassName: '-translate-y-10', -})); - jest.mock('../fields/Dropdown', () => ({ Dropdown: () => ( + ) : ( + activeView && + ); + + // On a phone the modal covers the block, so it draws the block's page + // row itself: back (to the sections menu, then to the feed), the name, + // Save. + if (isMobile) { + let rowTitle = 'Feed settings'; + if (activeView) { + rowTitle = + feed?.type === FeedType.Custom + ? feed.flags?.name ?? 'Feed settings' + : 'For You'; + } - if (!shouldDiscard) { + return ( +
+ { + if (!activeView) { + onBackToFeed({ action: 'discard' }); return; } - - if (isMobile) { + const shouldDiscard = await onDiscard({ activeView }); + if (shouldDiscard) { setActiveView?.(undefined); - } else { - onBackToFeed({ action: 'discard' }); } }} > - Cancel - - {showPlusCta ? ( - - ) : ( - + + +

+ {rowTitle} +

+ {activeView && ( +
+ {saveNode} +
)}
+ ); + } + + if (!activeView) { + return null; + } + + const actions = ( +
+ + {saveNode} +
+ ); + + return ( + + + {actions} ); }; diff --git a/packages/shared/src/components/feeds/MobileFeedActions.spec.tsx b/packages/shared/src/components/feeds/MobileFeedActions.spec.tsx deleted file mode 100644 index b55702e34fb..00000000000 --- a/packages/shared/src/components/feeds/MobileFeedActions.spec.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { QueryClient } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import loggedUser from '../../../__tests__/fixture/loggedUser'; -import { TestBootProvider } from '../../../__tests__/helpers/boot'; -import { useReadingStreak } from '../../hooks/streaks'; -import { MobileFeedActions } from './MobileFeedActions'; - -const mockQuestButton = jest.fn(() => ( -
Quest button
-)); - -jest.mock('next/dynamic', () => () => { - return function MockDynamicComponent() { - return null; - }; -}); - -jest.mock('next/router', () => ({ - useRouter: () => ({ - push: jest.fn(), - pathname: '/', - asPath: '/', - query: {}, - }), -})); - -jest.mock('../../hooks/streaks', () => ({ - useReadingStreak: jest.fn(), -})); - -jest.mock('../layout/HeaderLogo', () => ({ - __esModule: true, - default: function HeaderLogoMock(): ReactElement { - return
; - }, -})); - -jest.mock('../profile/ProfilePictureWithIndicator', () => ({ - ProfilePictureWithIndicator: - function ProfilePictureWithIndicatorMock(): ReactElement { - return
; - }, -})); - -jest.mock('../streak/ReadingStreakButton', () => ({ - ReadingStreakButton: function ReadingStreakButtonMock(): ReactElement { - return
Streak button
; - }, -})); - -jest.mock('../quest/QuestButton', () => ({ - QuestButton: (props: { compact?: boolean }): ReactElement => - mockQuestButton(props), -})); - -const mockUseReadingStreak = useReadingStreak as jest.Mock; - -const renderComponent = ({ - optOutQuestSystem = false, -}: { - optOutQuestSystem?: boolean; -} = {}) => - render( - - - , - ); - -describe('MobileFeedActions', () => { - beforeEach(() => { - mockQuestButton.mockClear(); - mockUseReadingStreak.mockReturnValue({ - streak: { current: 2, lastViewAt: new Date().toISOString() }, - isLoading: false, - isStreaksEnabled: true, - }); - }); - - it('should render the quest entry next to the streak button', () => { - renderComponent({ optOutQuestSystem: false }); - - const actionButtons = screen.getAllByTestId(/^(streak|quest)-button$/); - - expect(actionButtons).toHaveLength(2); - expect(actionButtons[0]).toHaveAttribute('data-testid', 'streak-button'); - expect(actionButtons[1]).toHaveAttribute('data-testid', 'quest-button'); - expect(mockQuestButton).toHaveBeenCalledWith( - expect.objectContaining({ compact: true }), - ); - }); - - it('should hide the quest entry when opted out from quests', () => { - renderComponent({ optOutQuestSystem: true }); - - expect(screen.queryByTestId('quest-button')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/shared/src/components/feeds/MobileFeedActions.tsx b/packages/shared/src/components/feeds/MobileFeedActions.tsx deleted file mode 100644 index cf1085a8b10..00000000000 --- a/packages/shared/src/components/feeds/MobileFeedActions.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; -import dynamic from 'next/dynamic'; -import { useRouter } from 'next/router'; -import classNames from 'classnames'; -import Link from '../utilities/Link'; -import { ReadingStreakButton } from '../streak/ReadingStreakButton'; -import { useReadingStreak } from '../../hooks/streaks'; -import { ButtonIconPosition, ButtonVariant } from '../buttons/common'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { ProfilePictureWithIndicator } from '../profile/ProfilePictureWithIndicator'; -import HeaderLogo from '../layout/HeaderLogo'; -import { LogoPosition } from '../Logo'; -import { webappUrl } from '../../lib/constants'; -import { Button } from '../buttons/Button'; -import { SettingsIcon } from '../icons'; -import { RootPortal } from '../tooltips/Portal'; -import { QuestHeaderButton } from '../header/QuestHeaderButton'; -import { useMobileAppHeader } from '../../features/getApp/hooks/useMobileAppHeader'; -import { MobileAppActions } from '../../features/getApp/components/MobileAppActions'; - -const ProfileSettingsMenuMobile = dynamic( - () => - import( - /* webpackChunkName: "profileSettingsMenuMobile" */ '../profile/ProfileSettingsMenu' - ).then((mod) => mod.ProfileSettingsMenuMobile), - { ssr: false }, -); - -// The logged-out row is pinned to this height so FeedNav slides it away by -// exactly that much; change both together. -const loggedOutRowHeight = 'h-10'; -export const hideLoggedOutRowClassName = '-translate-y-10'; - -export function MobileFeedActions(): ReactElement { - const router = useRouter(); - const { user } = useAuthContext(); - const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); - const [isMenuOpen, setIsMenuOpen] = useState(false); - const isMobileAppHeader = useMobileAppHeader(); - - return ( -
- router.push('/')} - /> - - {isStreaksEnabled && streak && ( - - )} - - {isMobileAppHeader && } - {user && ( - <> -
- ); -} diff --git a/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx b/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx index e733bd86b90..8f0123bf067 100644 --- a/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx @@ -1,6 +1,7 @@ import type { ReactElement, FormEvent } from 'react'; import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '../../modals/common/Modal'; +import { ModalHeader } from '../../modals/common/ModalHeader'; import { ModalKind, ModalSize } from '../../modals/common/types'; import { TextField } from '../TextField'; import { Button, ButtonVariant } from '../../buttons/Button'; @@ -55,12 +56,11 @@ export const LinkModal = ({ onRequestClose={onClose} size={ModalSize.Small} kind={ModalKind.FlexibleCenter} + isDrawerOnMobile parentSelector={() => document.body} > +
-

- {initialUrl ? 'Edit link' : 'Add link'} -

; + // A page form on a phone: the block carries the title and the submit, + // its back square stands in for the left button. + inBlock?: boolean; + // A form inside a bottom sheet: the header stays pinned under the grabber + // while the body scrolls, and the submit sits pinned at the bottom, under + // the thumb. + inSheet?: boolean; } export function FormWrapper({ @@ -38,6 +46,8 @@ export function FormWrapper({ title, isHeaderTitle, headerRef, + inBlock = false, + inSheet = false, }: FormWrapperProps): ReactElement { const { left = 'Cancel', right = 'Submit' } = copy; const titleElement = ( @@ -52,6 +62,81 @@ export function FormWrapper({ ); + const submitButton = ( + + ); + + if (inSheet) { + return ( +
+ + {title && ( + + {title} + + )} + {headerActions && ( +
+ {headerActions} +
+ )} +
+ {children} +
+ + +
+
+ ); + } + + if (inBlock) { + return ( +
+ + {headerActions} + {submitButton} +
+ } + /> + {children} +
+ ); + } + return (
{headerActions} - + {submitButton}
{!isHeaderTitle && title && titleElement} diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 8e3df72d0ad..65e95a4a002 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -13,6 +13,7 @@ import { } from '../../graphql/highlights'; import { Origin } from '../../lib/log'; import { Tab, TabContainer } from '../tabs/TabContainer'; +import { useIsPhone } from '../../hooks/useViewSize'; import { CopyHighlightsLink } from './CopyHighlightsLink'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; @@ -176,13 +177,14 @@ export const HighlightsPage = (): ReactElement => { const majorLoading = isFetching && !data; const channelLabel = channels.find((c) => c.channel === channel)?.displayName; + const isPhone = useIsPhone(); const activeTab = isAllTab ? ALL_HIGHLIGHTS_LABEL : channelLabel ?? MAJOR_HEADLINES_LABEL; return (
-
+

Happening Now

@@ -190,6 +192,7 @@ export const HighlightsPage = (): ReactElement => {
{ + if (isExploreRoot) { + return ShellRoot.Explore; + } + if (squads) { + return ShellRoot.Squads; + } + if (notifications) { + return ShellRoot.Activity; + } + if (isHomeRoot) { + return ShellRoot.Home; + } + return undefined; + })(); + + const row = (() => { + if (root === ShellRoot.Explore) { + return ( + <> +
+ +
+ + + + + ); + } + if (root === ShellRoot.Home) { + return ; + } + return undefined; + })(); + + return ; + } + if (shouldRenderFeedNav) { return ( <> @@ -108,8 +183,7 @@ function MainLayoutHeader({ ? 'sticky top-[var(--mobile-app-header-offset,0px)] w-full bg-background-default transition-[top] duration-200 ease-out tablet:pl-16' : 'fixed top-0 h-14 flex-row content-center items-center justify-center gap-3 border-b border-border-subtlest-tertiary bg-background-default px-4 py-3 tablet:px-8 laptop:left-0 laptop:h-16 laptop:w-full laptop:px-4', 'z-header', - !isMobileSearchPage && - (isMobileProfile ? 'hidden laptop:flex' : 'flex'), + !isMobileSearchPage && (profile ? 'hidden laptop:flex' : 'flex'), hasBanner && 'laptop:[--safe-area-top-offset:2rem]', !isMobileSearchPage && isSearchPage && 'mb-16 laptop:mb-0', !isMobileSearchPage && scrollClassName, diff --git a/packages/shared/src/components/modals/DirtyFormModal.spec.tsx b/packages/shared/src/components/modals/DirtyFormModal.spec.tsx index ef86495d41f..a2fab920d27 100644 --- a/packages/shared/src/components/modals/DirtyFormModal.spec.tsx +++ b/packages/shared/src/components/modals/DirtyFormModal.spec.tsx @@ -1,8 +1,16 @@ import React from 'react'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DirtyFormModal from './DirtyFormModal'; +// The sheet mounts on the root portal, which reads the request protocol +// from the query cache. +const renderWithClient = (ui: React.ReactElement) => + render( + {ui}, + ); + const mockCloseModal = jest.fn(); jest.mock('../../hooks/useLazyModal', () => ({ @@ -10,7 +18,7 @@ jest.mock('../../hooks/useLazyModal', () => ({ })); const renderModal = (onSave: () => void | Promise) => - render( + renderWithClient( { }), ); - render( + renderWithClient( bookmarksSharingData?.bookmarksSharing?.rssUrl, + () => bookmarksSharingData?.bookmarksSharing?.rssUrl ?? '', ); if (!isFetched || !bookmarksSharingData?.bookmarksSharing) { @@ -57,7 +57,12 @@ export default function SharedBookmarksModal({ } return ( - + copyRssUrl()} /> } - value={bookmarksSharingData?.bookmarksSharing?.rssUrl} + value={bookmarksSharingData?.bookmarksSharing?.rssUrl ?? ''} readOnly />
diff --git a/packages/shared/src/components/modals/bookmark/BookmarkFolderModal.tsx b/packages/shared/src/components/modals/bookmark/BookmarkFolderModal.tsx index 72cf1dd6ab6..3472842dfa5 100644 --- a/packages/shared/src/components/modals/bookmark/BookmarkFolderModal.tsx +++ b/packages/shared/src/components/modals/bookmark/BookmarkFolderModal.tsx @@ -88,6 +88,7 @@ const BookmarkFolderModal = ({ }, copy: { right: `${folder ? 'Update' : 'Create'} folder` }, }} + isDrawerOnMobile kind={Modal.Kind.FlexibleCenter} size={Modal.Size.Small} {...rest} diff --git a/packages/shared/src/components/modals/bookmark/MoveBookmarkModal.tsx b/packages/shared/src/components/modals/bookmark/MoveBookmarkModal.tsx index 3ccaa022910..807fb258eb2 100644 --- a/packages/shared/src/components/modals/bookmark/MoveBookmarkModal.tsx +++ b/packages/shared/src/components/modals/bookmark/MoveBookmarkModal.tsx @@ -90,7 +90,7 @@ const MoveBookmarkModal = ({ }; return ( - + - {feeds?.edges?.length > 0 && - feeds.edges.map((feed) => ( + {(feeds?.edges?.length ?? 0) > 0 && + feeds?.edges.map((feed) => ( ))} diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index ba6bcb0e281..e389949d1c2 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -454,6 +454,10 @@ export function SmartComposerModal({ [handleSubmit, isSubmitBlocked, kind, logEvent, selectedIds.length], ); + // On a phone the header's icon buttons are the block's squares. + const squareClassName = isLaptop + ? undefined + : 'shell-material !size-[2.375rem] !rounded-14 !p-0'; const scheduleButtonNode = canSchedule ? ( ) : null; const postButtonNode = ( @@ -528,10 +533,11 @@ export function SmartComposerModal({ disabled={isInFlight || isEditing} />
-
+
{scheduleInHeader && scheduleButtonNode} {kind === 'text' && ( @@ -545,6 +551,7 @@ export function SmartComposerModal({ size={ButtonSize.Small} variant={ButtonVariant.Tertiary} icon={} + className={squareClassName} pressed={isMarkdownMode} onClick={() => textFormRef.current?.toggleMarkdownMode()} aria-label={ @@ -580,6 +587,7 @@ export function SmartComposerModal({ { handleClose(event); }} diff --git a/packages/shared/src/components/modals/report/ReportCommentModal.tsx b/packages/shared/src/components/modals/report/ReportCommentModal.tsx index 1fe7a682747..e868e6d1d97 100644 --- a/packages/shared/src/components/modals/report/ReportCommentModal.tsx +++ b/packages/shared/src/components/modals/report/ReportCommentModal.tsx @@ -67,6 +67,7 @@ export function ReportCommentModal({ return ( (); + const inputRef = useRef(null); const [selectedTags, setSelectedTags] = useState(() => []); const reportOptionsForActiveReason = useCallback( (reason: string) => { @@ -173,11 +173,12 @@ export function ReportPostModal({ onReported(post, { index, shouldBlockSource: inputRef.current?.checked }); } - props.onRequestClose(event); + props.onRequestClose?.(event); }; return ( (); + const inputRef = useRef(null); const onLeaveSquad = useLeaveSquad({ squad }); const { mutateAsync: onReport } = useMutation({ mutationFn: ({ reason, comment }: SubmitReportProps) => sendSourceReport({ - id: squad.id, + id: squad.id ?? '', reason, comment, }), @@ -69,14 +69,15 @@ export function ReportSourceModal({ } onReported?.(); - onRequestClose(null); + onRequestClose?.(null as unknown as React.MouseEvent); }, }); - const isUserMember = squads.some((s) => s.id === squad.id); + const isUserMember = squads?.some((s) => s.id === squad.id) ?? false; return ( { - return ( -
- - Jobs - -
- - - + const isPhone = useIsPhone(); + const { user } = useAuthContext(); + const actions = ( +
+ + + + {user && (
+ )} +
+ ); + + // On a phone the title and its actions are the block's. + if (isPhone) { + return ; + } + + return ( +
+ + Jobs + + {actions}
); }; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx index 25e1f9cd393..478026eb7aa 100644 --- a/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx @@ -40,7 +40,7 @@ export function LinkedProfileSection({ ); } - const avatarBorderRadius = type === 'company' ? 'rounded-8' : 'rounded-full'; + const avatarBorderRadius = type === 'company' ? 'rounded-8' : 'rounded-10'; const renderSubtitle = (): ReactNode => { if (!subtitle) { diff --git a/packages/shared/src/components/popover/GifPopover.tsx b/packages/shared/src/components/popover/GifPopover.tsx index b0aba7b4408..89102de7dd2 100644 --- a/packages/shared/src/components/popover/GifPopover.tsx +++ b/packages/shared/src/components/popover/GifPopover.tsx @@ -17,6 +17,7 @@ import { GenericLoaderSpinner } from '../utilities/loaders'; import { IconSize } from '../Icon'; import { useViewSize, ViewSize } from '../../hooks'; import { Drawer } from '../drawers'; +import { ShellSquare } from '../shell/ShellSquare'; const searchSuggestions = [ 'Nodding zoom', @@ -157,6 +158,12 @@ const GifPopover = ({ }); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); + // The picker opens on pictures, not on a message: the favorites when + // there are some, a suggestion's results otherwise. + const [defaultQuery] = useState( + () => + searchSuggestions[Math.floor(Math.random() * searchSuggestions.length)], + ); const [savedSelection, setSavedSelection] = React.useState<[number, number]>([ 0, 0, ]); @@ -173,18 +180,19 @@ const GifPopover = ({ favorites, isFetchingFavorites, } = useGif({ - query, + query: query || defaultQuery, limit: '20', favoritesEnabled: open, }); + const showFavorites = !query && favorites?.length > 0; const [debounceNextPage] = useDebounceFn(() => { - if (inView && data?.length > 0 && !isFetchingNextPage && query) { + if (inView && data?.length > 0 && !isFetchingNextPage && !showFavorites) { fetchNextPage(); } }, 500); - const gifsToDisplay = !query ? favorites : data; - const isLoadingGifs = !query ? isFetchingFavorites : isLoading; + const gifsToDisplay = showFavorites ? favorites : data; + const isLoadingGifs = showFavorites ? isFetchingFavorites : isLoading; const handleOpenChange = (isOpen: boolean) => { if (isOpen && textareaRef?.current) { @@ -226,7 +234,7 @@ const GifPopover = ({ favorite, scrollRef, handleGifClick, - showLoadingSpinner: !!query, + showLoadingSpinner: !showFavorites, }; if (!isTablet) { @@ -244,15 +252,17 @@ const GifPopover = ({ isFullScreen className={{ wrapper: 'flex flex-col p-4' }} > -
- +
+ GIFs -
diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index a78bacb9714..da78d84a545 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -12,6 +12,7 @@ import { useMobileAppHeader, useMobileAppHeaderIconOnlyRead, } from '../../features/getApp/hooks/useMobileAppHeader'; +import { useIsPhone } from '../../hooks/useViewSize'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -46,6 +47,7 @@ export function BasePostContent({ const postPageNavigationProps = isPostPage ? navigationProps : undefined; const onReadArticle = postPageNavigationProps?.onReadArticle; const isMobileAppHeader = useMobileAppHeader(); + const isPhone = useIsPhone(); const isIconOnlyRead = useMobileAppHeaderIconOnlyRead(); if (!id && !isFallback) { @@ -68,6 +70,7 @@ export function BasePostContent({ contextMenuId="post-page-header-actions" onReadArticle={onReadArticle} buttonSize={ButtonSize.Small} + inBlock={isPhone} hideOptions={isMobileAppHeader} inlineActions={isIconOnlyRead} hideSubscribeAction={isMobileAppHeader} diff --git a/packages/shared/src/components/post/GoBackHeaderMobile.tsx b/packages/shared/src/components/post/GoBackHeaderMobile.tsx index 12eb4f697a8..202050a57a7 100644 --- a/packages/shared/src/components/post/GoBackHeaderMobile.tsx +++ b/packages/shared/src/components/post/GoBackHeaderMobile.tsx @@ -5,34 +5,17 @@ import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { ArrowIcon } from '../icons'; import type { WithClassNameProps } from '../utilities'; -import { isDevelopment } from '../../lib/constants'; import Logo, { LogoPosition } from '../Logo'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { useScrollTopClassName } from '../../hooks/useScrollTopClassName'; import { useViewSize, ViewSize } from '../../hooks'; +import { useIsPhone } from '../../hooks/useViewSize'; import { useMobileAppHeader } from '../../features/getApp/hooks/useMobileAppHeader'; import { MobileAppActions } from '../../features/getApp/components/MobileAppActions'; +import { ShellPage } from '../shell/ShellPageContext'; +import { canGoBackInApp } from '../shell/shellNav'; import { useHideOnScrollDown } from '../../features/getApp/hooks/useHideOnScrollDown'; -const checkSameSite = () => { - const referrer = globalThis?.document?.referrer; - const origin = globalThis?.window?.location.origin; - - if (!referrer) { - return true; // empty referrer means you are from the same site or from blank tab or no-referrer header was used :/ - } - - if (!origin) { - return false; - } - - try { - return new URL(referrer).origin === origin; - } catch { - return false; - } -}; - export const GoBackButton = ({ className, showLogo = true, @@ -47,8 +30,7 @@ export const GoBackButton = ({ const goHome = useCallback(() => router.push('/'), [router]); const featureTheme = useFeatureTheme(); - const canGoBack = - globalThis?.history?.length > 1 && (checkSameSite() || isDevelopment); + const canGoBack = canGoBackInApp(); const goBack = useCallback(() => { if (canGoBack) { @@ -87,6 +69,7 @@ export const GoBackButton = ({ }; interface GoBackHeaderMobileProps extends WithClassNameProps { + title?: string; // Off where the bar is pinned inside a sticky parent, which would keep its // empty slot on screen. hideOnScroll?: boolean; @@ -95,23 +78,31 @@ interface GoBackHeaderMobileProps extends WithClassNameProps { export function GoBackHeaderMobile({ children, className, + title, hideOnScroll = true, }: PropsWithChildren): ReactElement | null { const router = useRouter(); const isLaptop = useViewSize(ViewSize.Laptop); + const isPhone = useIsPhone(); const featureTheme = useFeatureTheme(); const scrollClassName = useScrollTopClassName({ enabled: !!featureTheme }); const isMobileAppHeader = useMobileAppHeader(); - const isHidden = useHideOnScrollDown(isMobileAppHeader && hideOnScroll); + const isHidden = useHideOnScrollDown( + isMobileAppHeader && hideOnScroll && !isPhone, + ); if (isLaptop || !router?.isReady || !globalThis?.history) { return null; } + if (isPhone) { + return ; + } + return ( diff --git a/packages/shared/src/components/post/MobilePostFloatingBar.tsx b/packages/shared/src/components/post/MobilePostFloatingBar.tsx index fb3ee45d774..ec84d0d097c 100644 --- a/packages/shared/src/components/post/MobilePostFloatingBar.tsx +++ b/packages/shared/src/components/post/MobilePostFloatingBar.tsx @@ -34,17 +34,11 @@ export interface MobilePostFloatingBarProps { className?: string; } -// Mirrors the floating "Share your thoughts" container that this bar replaces: -// `bg-surface-float` is the gray translucent tint that bar effectively rendered -// once Tailwind layered `bg-blur-baseline` underneath, paired with the same -// backdrop blur and shadow used by the mobile footer chrome. `justify-between` +// The shell's action glass, shared with the Create square. `justify-between` // + `px-2` spreads the icons edge-to-edge with a small pad so the outermost // icons don't kiss the rounded corner. -const containerClasses = classNames( - 'flex w-full items-center justify-between rounded-16 border border-border-subtlest-tertiary px-2 py-1', - 'bg-surface-float backdrop-blur-[2.5rem]', - 'shadow-[0_0.25rem_1.5rem_0_var(--theme-shadow-shadow1)]', -); +const containerClasses = + 'shell-material shell-material-action flex w-full items-center justify-between rounded-16 px-2 py-1'; // `QuaternaryButton` renders its children inside a sibling `
+ ) : ( + optionsMenu + ) + } + /> + ); + } + return (
- <> - - {showIdentity ? ( - <> - -
-

{user.name}

-

- {largeNumberFormat(user.reputation)} Reputation -

-
- - ) : ( -

Profile

- )} - -
- {isSameUser && ( - - - - )} - {!blocked && !isMobileAppHeader && ( - - )} - {isSameUser && hasCoresAccess && ( - { - router.push( - getPathnameWithQuery( - `${webappUrl}cores`, - new URLSearchParams({ - origin: Origin.Profile, - }), - ), - ); - }} - /> - )} - {canAward && ( - - )} - {isMobileAppHeader && } - {!isSameUser && ( - - follow({ - id: user.id, - entity: ContentPreferenceType.User, - entityName: user.username || '', - feedId, - }) - } - onUndo={(feedId) => - unfollow({ - id: user.id, - entity: ContentPreferenceType.User, - entityName: user.username || '', - feedId, - }) - } - onCreateNewFeed={() => - router.push( - `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, - ) - } - shareProps={{ - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - }} - additionalOptions={options} - /> - )} -
- {isSameUser && ( + + {showIdentity ? ( <> - {isJobsEnabled && ( - -
); } diff --git a/packages/shared/src/components/profile/ProfileActions.tsx b/packages/shared/src/components/profile/ProfileActions.tsx index d81d537948d..b5cfac8e833 100644 --- a/packages/shared/src/components/profile/ProfileActions.tsx +++ b/packages/shared/src/components/profile/ProfileActions.tsx @@ -38,6 +38,7 @@ import { Tooltip } from '../tooltip/Tooltip'; import { useAuthContext } from '../../contexts/AuthContext'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import type { MenuItemProps } from '../dropdown/common'; +import { useIsPhone } from '../../hooks/useViewSize'; export interface HeaderProps { user: PublicProfile; @@ -59,6 +60,8 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { sendingUser: loggedUser, receivingUser: user as LoggedUser, }); + // On a phone the menu is the block's; the hero keeps Follow and Award. + const isPhone = useIsPhone(); const onReportUser = React.useCallback( (defaultBlocked = false) => { @@ -67,7 +70,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { props: { offendingUser: { id: user.id, - username: user.username, + username: user.username || '', }, defaultBlockUser: defaultBlocked, }, @@ -87,12 +90,12 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { ? unblock({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', }) : block({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', }), }, { @@ -172,40 +175,42 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { variant={ButtonVariant.Secondary} /> )} - - follow({ - id: user.id, - entity: ContentPreferenceType.User, - entityName: user.username, - feedId, - }) - } - onUndo={(feedId) => - unfollow({ - id: user.id, - entity: ContentPreferenceType.User, - entityName: user.username, - feedId, - }) - } - onCreateNewFeed={() => - router.push( - `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, - ) - } - shareProps={{ - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - }} - additionalOptions={options} - /> + {!isPhone && ( + + follow({ + id: user.id, + entity: ContentPreferenceType.User, + entityName: user.username || '', + feedId, + }) + } + onUndo={(feedId) => + unfollow({ + id: user.id, + entity: ContentPreferenceType.User, + entityName: user.username || '', + feedId, + }) + } + onCreateNewFeed={() => + router.push( + `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, + ) + } + shareProps={{ + text: `Check out ${user.name}'s profile on daily.dev`, + link: user.permalink, + cid: ReferralCampaignKey.ShareProfile, + logObject: () => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + }), + }} + additionalOptions={options} + /> + )}
); diff --git a/packages/shared/src/components/profile/ProfileBackButton.spec.tsx b/packages/shared/src/components/profile/ProfileBackButton.spec.tsx index 7acca43f9c1..9ba9e7c9b01 100644 --- a/packages/shared/src/components/profile/ProfileBackButton.spec.tsx +++ b/packages/shared/src/components/profile/ProfileBackButton.spec.tsx @@ -1,11 +1,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { isPWA } from '../../lib/func'; -import { - useViewSize, - useViewSizeClient, - ViewSize, -} from '../../hooks/useViewSize'; +import { useViewSizeClient, ViewSize } from '../../hooks/useViewSize'; import { ProfileDesktopPwaBackButton, ProfileMobileBackButton, @@ -40,7 +36,6 @@ jest.mock('../post/GoBackHeaderMobile', () => ({ ), })); -const mockUseViewSize = useViewSize as jest.MockedFunction; const mockUseViewSizeClient = useViewSizeClient as jest.MockedFunction< typeof useViewSizeClient >; @@ -52,7 +47,9 @@ describe('ProfileMobileBackButton', () => { }); it('renders below laptop widths', () => { - mockUseViewSize.mockImplementation((size) => size !== ViewSize.Laptop); + mockUseViewSizeClient.mockImplementation( + (size) => size !== ViewSize.Laptop, + ); render(); @@ -63,7 +60,9 @@ describe('ProfileMobileBackButton', () => { }); it('does not render at laptop widths', () => { - mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); + mockUseViewSizeClient.mockImplementation( + (size) => size === ViewSize.Laptop, + ); render(); diff --git a/packages/shared/src/components/profile/ProfileBackButton.tsx b/packages/shared/src/components/profile/ProfileBackButton.tsx index 1a9977664a3..e8ae2bdcdd4 100644 --- a/packages/shared/src/components/profile/ProfileBackButton.tsx +++ b/packages/shared/src/components/profile/ProfileBackButton.tsx @@ -1,22 +1,28 @@ import type { ReactElement } from 'react'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; -import { useViewSize, useViewSizeClient, ViewSize } from '../../hooks'; +import { useViewSizeClient, ViewSize } from '../../hooks'; import { isPWA } from '../../lib/func'; import type { WithClassNameProps } from '../utilities'; import { GoBackButton } from '../post/GoBackHeaderMobile'; +// The server cannot know the viewport, so the button is in the HTML and +// CSS hides it on a laptop; the hook only unmounts it once hydrated. export const ProfileMobileBackButton = ({ className, }: WithClassNameProps): ReactElement | null => { - const isLaptop = useViewSize(ViewSize.Laptop); + const isLaptop = useViewSizeClient(ViewSize.Laptop); if (isLaptop) { return null; } return ( - + ); }; diff --git a/packages/shared/src/components/profile/UserMetadata.tsx b/packages/shared/src/components/profile/UserMetadata.tsx index 55b9330d6c5..874a1d17a4b 100644 --- a/packages/shared/src/components/profile/UserMetadata.tsx +++ b/packages/shared/src/components/profile/UserMetadata.tsx @@ -82,7 +82,6 @@ export function UserMetadata({ image: company.image, id: company.name, }} - rounded="full" /> new Set(claimedStampRotationIds), [claimedStampRotationIds], @@ -1066,9 +1071,26 @@ export const QuestButton = ({ ); } + const panel = ( + + ); + return ( <> - +
+ {isPhone && ( + setIsOpen(false)} + appendOnRoot + className={{ drawer: 'p-0' }} + > + {panel} + + )} {rewardFlightLayers.map((layer) => ( ) : ( diff --git a/packages/shared/src/components/search/SearchMobileFiltersButton.tsx b/packages/shared/src/components/search/SearchMobileFiltersButton.tsx index 65ff250fa4f..8c65ac076a9 100644 --- a/packages/shared/src/components/search/SearchMobileFiltersButton.tsx +++ b/packages/shared/src/components/search/SearchMobileFiltersButton.tsx @@ -1,6 +1,8 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useState } from 'react'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ShellSquare } from '../shell/ShellSquare'; +import { IconSize } from '../Icon'; import { FilterIcon, MiniCloseIcon } from '../icons'; import { Drawer, DrawerPosition } from '../drawers'; import { @@ -22,20 +24,34 @@ const SearchMobileFilterSection = ({ ); -const SearchMobileFiltersButton = (): ReactElement => { +// `square` is the block's form: the icon alone on the floating square. +const SearchMobileFiltersButton = ({ + square = false, +}: { + square?: boolean; +}): ReactElement => { const [isOpen, setIsOpen] = useState(false); return ( <> - + {square ? ( + setIsOpen(true)} + > + + + ) : ( + + )} setIsOpen(false)} diff --git a/packages/shared/src/components/shell/ExploreSortMenu.tsx b/packages/shared/src/components/shell/ExploreSortMenu.tsx new file mode 100644 index 00000000000..ce4eb5f6002 --- /dev/null +++ b/packages/shared/src/components/shell/ExploreSortMenu.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { useRouter } from 'next/router'; +import { Drawer } from '../drawers/Drawer'; +import { RootPortal } from '../tooltips/Portal'; +import { Dropdown } from '../fields/Dropdown'; +import { CalendarIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { periodTexts } from '../layout/common'; +import { useQueryState, QueryStateKeys } from '../../hooks/utils/useQueryState'; +import { withoutLayoutVariantPrefix } from '../../lib/layoutVariant'; +import type { RowItem } from './ShellRow'; +import { MenuLabel, SheetChoice } from './ShellRow'; + +const sorts: { key: string; label: string; href: string; paths: string[] }[] = [ + { + key: 'popular', + label: 'Popular', + href: '/posts', + paths: ['/posts', '/popular'], + }, + { + key: 'upvoted', + label: 'By upvotes', + href: '/posts/upvoted', + paths: ['/posts/upvoted', '/upvoted'], + }, + { + key: 'discussed', + label: 'By comments', + href: '/posts/discussed', + paths: ['/posts/discussed', '/discussed'], + }, + { + key: 'latest', + label: 'By date', + href: '/posts/latest', + paths: ['/posts/latest'], + }, + { + key: 'best-of', + label: 'Best of', + href: '/posts/best-of', + paths: ['/posts/best-of'], + }, +]; + +const withPeriod = ['upvoted', 'discussed']; + +// The Explore feed's order on a phone: a text menu on the feed's own line +// (reorder one list), with the period inside for the two sorts that have +// one. Replaces the sort tabs under the search row. +export function ExploreSortMenu(): ReactElement { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [period, setPeriod] = useQueryState({ + key: [QueryStateKeys.FeedPeriod], + defaultValue: 0, + }); + const pathname = withoutLayoutVariantPrefix(router.pathname); + const current = + sorts.find((sort) => sort.paths.includes(pathname)) ?? sorts[0]; + + const items: RowItem[] = sorts.map((sort) => ({ + key: sort.key, + label: sort.label, + href: sort.href, + active: sort.key === current.key, + replace: true, + onClick: () => setIsOpen(false), + })); + + return ( +
+ setIsOpen(true)} /> + {withPeriod.includes(current.key) && ( + } + selectedIndex={period} + options={periodTexts} + drawerProps={{ displayCloseButton: true }} + onChange={(_, index) => setPeriod(index)} + /> + )} + + setIsOpen(false)} + title="Sort" + className={{ drawer: 'py-1' }} + > + + + +
+ ); +} + +export default ExploreSortMenu; diff --git a/packages/shared/src/components/shell/HomeSegments.tsx b/packages/shared/src/components/shell/HomeSegments.tsx new file mode 100644 index 00000000000..bdfc1867db2 --- /dev/null +++ b/packages/shared/src/components/shell/HomeSegments.tsx @@ -0,0 +1,174 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { useRouter } from 'next/router'; +import { useQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { useFeeds } from '../../hooks'; +import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { useSortedFeeds } from '../../hooks/feed/useSortedFeeds'; +import { highlightsPageQueryOptions } from '../../graphql/highlights'; +import { webappUrl } from '../../lib/constants'; +import { withoutLayoutVariantPrefix } from '../../lib/layoutVariant'; +import { LogEvent } from '../../lib/log'; +import { PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Drawer } from '../drawers/Drawer'; +import { RootPortal } from '../tooltips/Portal'; +import type { RowItem } from './ShellRow'; +import { Segments, SheetChoice, ShellRow } from './ShellRow'; + +export const highlightsUrl = `${webappUrl}highlights`; + +const happeningNowKey = 'happening-now'; + +// The channel sheet behind the Happening now segment: Headlines, All, then +// the channels the page already fetches. +export const HappeningNowSheet = ({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}): ReactElement => { + const router = useRouter(); + const { data } = useQuery({ + ...highlightsPageQueryOptions(), + enabled: isOpen, + }); + const channels = data?.channelConfigurations ?? []; + const path = (router.asPath ?? router.pathname ?? '').split('?')[0]; + + const items: RowItem[] = [ + { + key: 'headlines', + label: 'Headlines', + href: highlightsUrl, + active: path === '/highlights', + replace: true, + onClick: onClose, + }, + { + key: 'all', + label: 'All', + href: `${highlightsUrl}/all`, + active: path === '/highlights/all', + replace: true, + onClick: onClose, + }, + ...channels.map((channel) => ({ + key: channel.channel, + label: channel.displayName, + href: `${highlightsUrl}/${channel.channel}`, + active: path === `/highlights/${channel.channel}`, + replace: true, + onClick: onClose, + })), + ]; + + return ( + + + + + + ); +}; + +// The Home row: For you, Happening now with its channel sheet, Following, +// the member's custom feeds, and the plus that adds one. Everything else +// the strip carried lives on Explore, on You or in the bar now. +export function HomeSegments(): ReactElement { + const router = useRouter(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { feeds } = useFeeds(); + const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); + const sortedFeeds = useSortedFeeds({ edges: feeds?.edges }); + const [isChannelsOpen, setIsChannelsOpen] = useState(false); + const pathname = withoutLayoutVariantPrefix(router.pathname); + const path = (router.asPath ?? router.pathname ?? '').split('?')[0]; + const forYouHref = isCustomDefaultFeed ? `${webappUrl}my-feed` : webappUrl; + const isHighlights = pathname.startsWith('/highlights'); + + const items: RowItem[] = []; + + if (user) { + items.push({ + key: 'for-you', + label: 'For you', + href: forYouHref, + active: pathname === '/' || pathname === '/my-feed', + replace: true, + }); + } + + items.push({ + key: happeningNowKey, + label: 'Happening now', + href: highlightsUrl, + active: isHighlights, + replace: true, + }); + + if (user) { + items.push({ + key: 'following', + label: 'Following', + href: `${webappUrl}following`, + active: pathname === '/following', + replace: true, + }); + + sortedFeeds.forEach(({ node: feed }) => { + const isDefault = isCustomDefaultFeed && feed.id === defaultFeedId; + const href = isDefault ? webappUrl : `${webappUrl}feeds/${feed.id}`; + items.push({ + key: `feed-${feed.id}`, + label: feed.flags?.name || `Feed ${feed.id}`, + href, + active: isDefault + ? pathname === '/' + : path === `/feeds/${feed.id}` || path === `/feeds/${feed.id}/edit`, + replace: true, + onClick: () => + logEvent({ + event_name: LogEvent.ClickFeedTagChip, + target_id: feed.id, + extra: JSON.stringify({ origin: feed.flags?.origin }), + }), + }); + }); + + items.push({ + key: 'new-feed', + label: , + ariaLabel: 'New custom feed', + href: `${webappUrl}feeds/new`, + active: pathname === '/feeds/new', + }); + } + + return ( + <> + + setIsChannelsOpen(true)} + /> + + setIsChannelsOpen(false)} + /> + + ); +} + +export default HomeSegments; diff --git a/packages/shared/src/components/shell/ShellBlock.spec.tsx b/packages/shared/src/components/shell/ShellBlock.spec.tsx new file mode 100644 index 00000000000..e6c48af17aa --- /dev/null +++ b/packages/shared/src/components/shell/ShellBlock.spec.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import AuthContext from '../../contexts/AuthContext'; +import type { LoggedUser } from '../../lib/user'; +import { ShellBlock } from './ShellBlock'; +import { ShellPage, ShellPageProvider } from './ShellPageContext'; +import { ShellRoot } from './shellNav'; +import { revealShell } from './useShellScroll'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('../../hooks/streaks', () => ({ + useReadingStreak: () => ({ + streak: undefined, + isLoading: false, + isStreaksEnabled: false, + }), +})); + +jest.mock('../../features/getApp/hooks/useMobileAppHeader', () => ({ + useMobileAppHeader: () => false, +})); + +jest.mock('../header/QuestHeaderButton', () => ({ + QuestHeaderButton: () => } + /> + , + '/[userId]/posts', + ); + + expect(screen.getByLabelText('Go back')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Posts' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Share' })).toBeInTheDocument(); + }); + + it('renders nothing for a page that draws its own chrome', () => { + renderBlock( + <> + +