From 197f6b24d127c2485b3689fa2af0d8b5086ede30 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 10:00:53 +0300 Subject: [PATCH 01/55] fix(mobile): the four step-0 shell fixes Step 0 of the mobile shell work (Storybook, Mobile UX / 10. The build): the fixes that need no design decision, shipped together. TabContainer's swipe decided from the total movement at touch end with a 40px threshold, so a thumb scrolling at a slight angle switched the Highlights channel. The swipe now locks its axis on the first 10px, commits past 56px inside a 27 degree cone or on a fast flick past 32px, and the surface takes touch-action: pan-y. The numbers live in shell/constants.ts. FooterNavBarLayout waited for the window load event before rendering the bar; it renders once hydrated. The footer tabs fire a click with the footer as target and the tab in extra, and Activity fires click notification icon with NotificationTarget.Footer, which existed and was never used. HighlightCardOptions and SquadOptionsButton were hidden until hover on every device, so their actions did not exist on phones; they use the laptop-and-mouse-only hover class the post cards use. The Explore period drawer gets a Close, the notification menu icon stops being rotated, Unfollow uses the remove-user icon, "Manade Ad" is "Manage ad" and the settings menu labels match their page titles. shell.css carries the shell's two curves and four durations and the press class (scale 0.96, 150ms) applied to the footer tabs, the plus button, the header controls, the feed chips and the post bar; the post titles balance. Two pre-existing strict type errors in the touched files are fixed so the strict guard passes. Co-Authored-By: Claude Fable 5.1 --- .../ProfileMenu/sections/AccountSection.tsx | 2 +- .../cards/common/SquadOptionsButton.tsx | 13 ++- .../cards/highlight/HighlightCardOptions.tsx | 6 +- .../comments/CommentActionButtons.tsx | 3 +- .../comments/CommentActionButtons.v2.tsx | 3 +- .../components/feeds/MobileFeedActions.tsx | 3 +- .../components/feeds/UnifiedMobileFeedNav.tsx | 2 +- .../components/header/FeedExploreHeader.tsx | 1 + .../notifications/NotificationItem.tsx | 2 +- .../components/post/MobilePostFloatingBar.tsx | 7 +- .../src/components/post/PostContent.tsx | 2 +- .../post/collection/CollectionPostContent.tsx | 2 +- .../components/post/focus/PostFocusCard.tsx | 2 +- .../profile/ProfileSettingsMenu.tsx | 10 +- .../shared/src/components/shell/constants.ts | 21 ++++ .../components/sidebar/SidebarDesktopV2.tsx | 2 +- .../sidebar/sections/SettingsPanelSection.tsx | 10 +- .../src/components/tabs/TabContainer.spec.tsx | 108 ++++++++++++++++++ .../src/components/tabs/TabContainer.tsx | 46 +++++++- packages/shared/src/styles/globals.css | 1 + packages/shared/src/styles/shell.css | 36 ++++++ .../webapp/__tests__/MobileFooterNavbar.tsx | 72 ++++++++++++ .../components/footer/FooterNavBarTabs.tsx | 10 +- .../components/footer/FooterPlusButton.tsx | 2 +- .../components/footer/MobileFooterNavbar.tsx | 27 ++++- packages/webapp/components/footer/common.ts | 4 +- .../components/layouts/FooterNavBarLayout.tsx | 11 +- 27 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 packages/shared/src/components/shell/constants.ts create mode 100644 packages/shared/src/styles/shell.css create mode 100644 packages/webapp/__tests__/MobileFooterNavbar.tsx diff --git a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx index b20f9f8c617..9fb96afdda6 100644 --- a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx @@ -25,7 +25,7 @@ export const AccountSection = (): ReactElement => { icon: SettingsIcon, }, { - title: 'Subscriptions', + title: 'Payment & Subscription', href: `${settingsUrl}/subscription`, icon: CreditCardIcon, }, diff --git a/packages/shared/src/components/cards/common/SquadOptionsButton.tsx b/packages/shared/src/components/cards/common/SquadOptionsButton.tsx index 23e2cc32b2c..313d64b2be7 100644 --- a/packages/shared/src/components/cards/common/SquadOptionsButton.tsx +++ b/packages/shared/src/components/cards/common/SquadOptionsButton.tsx @@ -27,6 +27,7 @@ import useFeedSettings from '../../../hooks/useFeedSettings'; import { useShareOrCopyLink } from '../../../hooks/useShareOrCopyLink'; import { LogEvent } from '../../../lib/log'; import { plusUrl } from '../../../lib/constants'; +import { visibleOnGroupHover } from './common'; interface SquadOptionsButtonProps { squad: Squad; @@ -39,7 +40,7 @@ export function SquadOptionsButton({ }: SquadOptionsButtonProps): ReactElement { const { openModal } = useLazyModal(); const { user } = useAuthContext(); - const { data: campaign } = useCampaignById(squad.flags.campaignId); + const { data: campaign } = useCampaignById(squad.flags?.campaignId ?? ''); const isBooster = campaign && user?.id === campaign.user.id; const [, onShareOrCopy] = useShareOrCopyLink({ link: squad.permalink, @@ -57,7 +58,11 @@ export function SquadOptionsButton({ const options = useMemo(() => { const blockAction = isSourceBlocked ? unblockSource : blockSource; const list: MenuItemProps[] = [ - { label: 'Share via', icon: , action: onShareOrCopy }, + { + label: 'Share via', + icon: , + action: () => onShareOrCopy(), + }, { label: 'Hide', icon: , @@ -81,7 +86,7 @@ export function SquadOptionsButton({ if (isBooster) { list.unshift({ - label: 'Manade Ad', + label: 'Manage ad', icon: , action: () => openModal({ @@ -110,7 +115,7 @@ export function SquadOptionsButton({ variant={ButtonVariant.Tertiary} icon={} size={ButtonSize.Small} - className={classNames('invisible z-1 group-hover:visible', className)} + className={classNames(visibleOnGroupHover, 'z-1', className)} /> diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index 0f8e49352cb..d930163b7c3 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { EyeCancelIcon, MenuIcon as KebabIcon, PinIcon } from '../../icons'; import { MenuIcon } from '../../MenuIcon'; +import { visibleOnGroupHover } from '../common/common'; import { DropdownMenu, DropdownMenuContent, @@ -70,10 +71,7 @@ const HighlightCardOptionsContent = ({ variant={ButtonVariant.Tertiary} size={ButtonSize.Small} icon={} - className={classNames( - 'invisible z-1 my-auto group-hover:visible', - className, - )} + className={classNames(visibleOnGroupHover, 'z-1 my-auto', className)} /> diff --git a/packages/shared/src/components/comments/CommentActionButtons.tsx b/packages/shared/src/components/comments/CommentActionButtons.tsx index 39080de4568..5fd6d3f317e 100644 --- a/packages/shared/src/components/comments/CommentActionButtons.tsx +++ b/packages/shared/src/components/comments/CommentActionButtons.tsx @@ -12,6 +12,7 @@ import { FlagIcon, DownvoteIcon, AddUserIcon, + RemoveUserIcon, BlockIcon, GiftIcon, MenuIcon, @@ -258,7 +259,7 @@ function CommentActionButtonsV1({ const isFollowingUser = isFollowingContent(author?.contentPreference); commentOptions.push({ - icon: , + icon: isFollowingUser ? : , label: `${isFollowingUser ? 'Unfollow' : 'Follow'} ${authorName}`, action: () => { const opts = { diff --git a/packages/shared/src/components/comments/CommentActionButtons.v2.tsx b/packages/shared/src/components/comments/CommentActionButtons.v2.tsx index 53cd6e9f485..cbc4ecce6ee 100644 --- a/packages/shared/src/components/comments/CommentActionButtons.v2.tsx +++ b/packages/shared/src/components/comments/CommentActionButtons.v2.tsx @@ -12,6 +12,7 @@ import { FlagIcon, DownvoteIcon, AddUserIcon, + RemoveUserIcon, BlockIcon, GiftIcon, MenuIcon, @@ -258,7 +259,7 @@ export default function CommentActionButtons({ const isFollowingUser = isFollowingContent(author?.contentPreference); commentOptions.push({ - icon: , + icon: isFollowingUser ? : , label: `${isFollowingUser ? 'Unfollow' : 'Follow'} ${authorName}`, action: () => { const opts = { diff --git a/packages/shared/src/components/feeds/MobileFeedActions.tsx b/packages/shared/src/components/feeds/MobileFeedActions.tsx index ec59b59d7e7..02b8ea5e018 100644 --- a/packages/shared/src/components/feeds/MobileFeedActions.tsx +++ b/packages/shared/src/components/feeds/MobileFeedActions.tsx @@ -55,6 +55,7 @@ export function MobileFeedActions(): ReactElement { + + )} + {!blocked && !isMobileAppHeader && ( + + )} + {isSameUser && hasCoresAccess && ( + { + router.push( + getPathnameWithQuery( + `${webappUrl}cores`, + new URLSearchParams({ + origin: Origin.Profile, + }), + ), + ); + }} + /> + )} + {canAward && ( + + )} + {isMobileAppHeader && !isPhone && } + {!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} + /> + )} + + ); + + const ownerActions = isSameUser && ( + <> + {isJobsEnabled && ( + + - - )} - {!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 && ( + + {sticky ? ( <> - {isJobsEnabled && ( - - } + /> + , + '/[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( + <> + + + , + '/plus', + ); + + expect(screen.queryByLabelText('Go back')).not.toBeInTheDocument(); + }); + + it('slides out of view while reading down and marks itself hidden', () => { + renderBlock(); + const header = screen + .getByLabelText('You') + .closest('header') as HTMLElement; + + act(() => { + scrollTo(200); + scrollTo(400); + jest.advanceTimersByTime(scroll.stop); + }); + + expect(header.style.transform).toContain('* 1)'); + expect(header).toHaveAttribute('aria-hidden', 'true'); + }); +}); diff --git a/packages/shared/src/components/shell/ShellBlock.tsx b/packages/shared/src/components/shell/ShellBlock.tsx new file mode 100644 index 00000000000..6ba5f9fce58 --- /dev/null +++ b/packages/shared/src/components/shell/ShellBlock.tsx @@ -0,0 +1,248 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import Link from '../utilities/Link'; +import Logo, { LogoPosition } from '../Logo'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks'; +import { ReadingStreakButton } from '../streak/ReadingStreakButton'; +import { QuestHeaderButton } from '../header/QuestHeaderButton'; +import { ButtonIconPosition } from '../buttons/common'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import { ArrowIcon, DevPlusIcon, SettingsIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useMobileAppHeader } from '../../features/getApp/hooks/useMobileAppHeader'; +import { MobileAppActions } from '../../features/getApp/components/MobileAppActions'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { LogEvent, TargetId } from '../../lib/log'; +import { plusUrl, webappUrl } from '../../lib/constants'; +import { ShellSquare } from './ShellSquare'; +import { motion, topButton } from './constants'; +import { revealShell, useShellScroll } from './useShellScroll'; +import { useShellPageConfig } from './ShellPageContext'; +import { ShellRoot, useShellBack } from './shellNav'; + +const rootTitles: Record, string> = { + [ShellRoot.Explore]: 'Explore', + [ShellRoot.Squads]: 'Squads', + [ShellRoot.Activity]: 'Activity', +}; + +const AvatarSquare = (): ReactElement | null => { + const { user } = useAuthContext(); + + if (!user) { + return null; + } + + return ( + + + + + + ); +}; + +const PlusSquare = (): ReactElement | null => { + const { isPlus, logSubscriptionEvent } = usePlusSubscription(); + + if (isPlus) { + return null; + } + + return ( + + + logSubscriptionEvent({ + event_name: LogEvent.UpgradeSubscription, + target_id: TargetId.MobileHeader, + }) + } + > + + + + + + ); +}; + +const RootRow = ({ root }: { root: ShellRoot }): ReactElement => { + const { user } = useAuthContext(); + const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); + const isMobileAppHeader = useMobileAppHeader(); + const { isPlus } = usePlusSubscription(); + const isHome = root === ShellRoot.Home; + + return ( +
+ {isHome ? ( + + ) : ( +

+ {rootTitles[root]} +

+ )} + {isHome && } + {isMobileAppHeader && } + {user && isHome && isStreaksEnabled && streak && ( + <> + + + + )} + {user && isHome && } + {user && root === ShellRoot.Activity && ( + + + + + + )} + +
+ ); +}; + +const PageRow = ({ + title, + actions, +}: { + title?: ReactNode; + actions?: ReactNode; +}): ReactElement => { + const goBack = useShellBack(); + const isMobileAppHeader = useMobileAppHeader(); + + return ( +
+ + + +

+ {title} +

+
+ {actions} + {isMobileAppHeader && } +
+
+ ); +}; + +export interface ShellBlockProps { + root?: ShellRoot; + row?: ReactNode; +} + +// The top block of a phone page: the brand or page row and the page's own +// row under it, one solid piece under the status bar that hides while +// reading down and returns on any scroll up. Content keeps a constant top +// padding from --shell-top, so hiding never reflows it. +export function ShellBlock({ + root, + row, +}: ShellBlockProps): ReactElement | null { + const router = useRouter(); + const config = useShellPageConfig(); + const { p, snapping } = useShellScroll(); + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) { + return undefined; + } + + const publish = () => { + document.documentElement.style.setProperty( + '--shell-top', + `${element.offsetHeight}px`, + ); + }; + publish(); + const observer = new ResizeObserver(publish); + observer.observe(element); + + return () => { + observer.disconnect(); + document.documentElement.style.removeProperty('--shell-top'); + }; + }, [root, row, config?.hidden]); + + // Arrival never hides the block, and a focused field keeps it in view. + useEffect(() => { + revealShell(); + }, [router.asPath]); + + useEffect(() => { + const onFocus = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (target?.matches?.('input, textarea, [contenteditable="true"]')) { + revealShell(); + } + }; + document.addEventListener('focusin', onFocus); + return () => document.removeEventListener('focusin', onFocus); + }, []); + + if (config?.hidden) { + return null; + } + + const hidden = p >= 0.99; + const duration = snapping ? motion.snap : motion.scrub; + + return ( +
+ {root ? ( + + ) : ( + + )} + {row} +
+ ); +} + +export default ShellBlock; diff --git a/packages/shared/src/components/shell/ShellCluster.spec.tsx b/packages/shared/src/components/shell/ShellCluster.spec.tsx new file mode 100644 index 00000000000..c3757161513 --- /dev/null +++ b/packages/shared/src/components/shell/ShellCluster.spec.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import AuthContext from '../../contexts/AuthContext'; +import { useNotificationContext } from '../../contexts/NotificationsContext'; +import type { LoggedUser } from '../../lib/user'; +import { ShellCluster } from './ShellCluster'; + +const mockLogEvent = jest.fn(); +const mockOpenModal = jest.fn(); +const mockShowLogin = jest.fn(); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../contexts/NotificationsContext', () => ({ + useNotificationContext: jest.fn(), +})); + +jest.mock('../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ openModal: mockOpenModal }), +})); + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +const user = { id: 'u1', username: 'ido' } as LoggedUser; + +const renderCluster = ( + pathname = '/', + loggedUser: LoggedUser | null = user, +) => { + jest.mocked(useRouter).mockReturnValue({ + pathname, + asPath: pathname, + query: {}, + } as unknown as NextRouter); + + return render( + + + , + ); +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useNotificationContext).mockReturnValue({ + unreadCount: 3, + } as unknown as ReturnType); +}); + +describe('ShellCluster', () => { + it('lights the root that owns the page', () => { + renderCluster('/squads/[handle]'); + + expect(screen.getByLabelText('Squads')).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(screen.getByLabelText('Home')).not.toHaveAttribute('aria-current'); + }); + + it('lights Home on a post', () => { + renderCluster('/posts/[id]'); + + expect(screen.getByLabelText('Home')).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + it('shows the unread count on Activity only', () => { + renderCluster('/'); + + expect(screen.getByLabelText('Activity')).toHaveTextContent('3'); + expect(screen.getByLabelText('Home')).not.toHaveTextContent('3'); + }); + + it('logs a click per tab with the footer as the target', () => { + renderCluster('/'); + + fireEvent.click(screen.getByLabelText('Explore')); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: 'click', + target_id: 'mobile footer', + extra: JSON.stringify({ tab: 'explore' }), + }); + }); + + it('logs the notification icon click from the footer', () => { + renderCluster('/'); + + fireEvent.click(screen.getByLabelText('Activity')); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: 'click notification icon', + target_id: 'footer', + extra: JSON.stringify({ notifications_number: 3 }), + }); + }); + + it('opens the composer from the Create square', () => { + renderCluster('/'); + + fireEvent.click(screen.getByLabelText('Create post')); + + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ type: 'smartComposer' }), + ); + }); + + it('sends a visitor to sign up from Home, Activity and Create', () => { + renderCluster('/posts', null); + + fireEvent.click(screen.getByLabelText('Home')); + fireEvent.click(screen.getByLabelText('Activity')); + fireEvent.click(screen.getByLabelText('Create post')); + + expect(mockShowLogin).toHaveBeenCalledTimes(3); + expect(mockOpenModal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/shell/ShellCluster.tsx b/packages/shared/src/components/shell/ShellCluster.tsx new file mode 100644 index 00000000000..1a0a55edf06 --- /dev/null +++ b/packages/shared/src/components/shell/ShellCluster.tsx @@ -0,0 +1,197 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import Link from '../utilities/Link'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useNotificationContext } from '../../contexts/NotificationsContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { BellIcon, CompassIcon, HomeIcon, PlusIcon, SquadIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Bubble } from '../tooltips/utils'; +import { railCountBubbleClass } from '../sidebar/common'; +import { getUnreadText } from '../notifications/utils'; +import { squadCategoriesPaths } from '../../lib/constants'; +import { LogEvent, NotificationTarget, TargetId } from '../../lib/log'; +import { AuthTriggers } from '../../lib/auth'; +import type { AuthTriggersType } from '../../lib/auth'; +import { cluster, lerp, motion } from './constants'; +import { revealShell, useShellScroll } from './useShellScroll'; +import { ShellRoot, owningRoot } from './shellNav'; + +interface ClusterTab { + root: ShellRoot; + label: string; + href: string; + Icon: typeof HomeIcon; + requiresLogin?: boolean; + trigger?: AuthTriggersType; +} + +export function ShellCluster({ + className, +}: { + className?: string; +}): ReactElement { + const router = useRouter(); + const { user, squads, showLogin } = useAuthContext(); + const { unreadCount } = useNotificationContext(); + const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); + const { p, snapping } = useShellScroll(); + const active = owningRoot(router?.pathname ?? ''); + const hasSquads = (squads?.length ?? 0) > 0; + + const tabs: ClusterTab[] = [ + { + root: ShellRoot.Home, + label: 'Home', + href: '/', + Icon: HomeIcon, + requiresLogin: true, + trigger: AuthTriggers.MainButton, + }, + { + root: ShellRoot.Explore, + label: 'Explore', + href: '/posts', + Icon: CompassIcon, + }, + { + root: ShellRoot.Squads, + label: 'Squads', + href: hasSquads + ? squadCategoriesPaths['My Squads'] + : squadCategoriesPaths.discover, + Icon: SquadIcon, + }, + { + root: ShellRoot.Activity, + label: 'Activity', + href: '/notifications', + Icon: BellIcon, + requiresLogin: true, + trigger: AuthTriggers.FromNotification, + }, + ]; + + const height = lerp(cluster.rest, cluster.compact, p); + const radius = lerp(cluster.radiusRest, cluster.radiusCompact, p); + const transition = `height ${snapping ? motion.snap : motion.scrub}ms ${ + motion.interaction + }, border-radius ${snapping ? motion.snap : motion.scrub}ms ${ + motion.interaction + }, padding ${snapping ? motion.snap : motion.scrub}ms ${motion.interaction}`; + + const onTabClick = (tab: ClusterTab) => (event: React.MouseEvent) => { + if (tab.root === ShellRoot.Activity) { + logEvent({ + event_name: LogEvent.ClickNotificationIcon, + target_id: NotificationTarget.Footer, + extra: JSON.stringify({ notifications_number: unreadCount }), + }); + } else { + logEvent({ + event_name: LogEvent.Click, + target_id: TargetId.MobileFooter, + extra: JSON.stringify({ tab: tab.root }), + }); + } + + if (!user && tab.requiresLogin) { + event.preventDefault(); + showLogin({ trigger: tab.trigger ?? AuthTriggers.MainButton }); + return; + } + + if (tab.root === active) { + event.preventDefault(); + revealShell(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const onCreate = () => { + if (!user) { + showLogin({ trigger: AuthTriggers.CreateSquad }); + return; + } + + openModal({ type: LazyModal.SmartComposer, props: {} }); + }; + + return ( +
+
+ {tabs.map((tab) => { + const isActive = tab.root === active; + + return ( + + { + if (event.key === ' ') { + event.preventDefault(); + event.currentTarget.click(); + } + }} + className={classNames( + 'shell-press flex min-w-0 flex-1 items-center justify-center text-text-primary', + !isActive && 'opacity-[0.72]', + )} + style={{ borderRadius: radius - cluster.padding }} + > + + + {tab.root === ShellRoot.Activity && !!unreadCount && ( + + {getUnreadText(unreadCount)} + + )} + + + + ); + })} +
+ +
+ ); +} + +export default ShellCluster; diff --git a/packages/shared/src/components/shell/ShellPageContext.tsx b/packages/shared/src/components/shell/ShellPageContext.tsx new file mode 100644 index 00000000000..e005b703f44 --- /dev/null +++ b/packages/shared/src/components/shell/ShellPageContext.tsx @@ -0,0 +1,61 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + createContext, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react'; + +export interface ShellPageConfig { + title?: ReactNode; + actions?: ReactNode; + // A page that draws its own top chrome on phones opts out of the block. + hidden?: boolean; +} + +interface ShellPageContextData { + config: ShellPageConfig | null; + setConfig: (config: ShellPageConfig | null) => void; +} + +const ShellPageContext = createContext({ + config: null, + setConfig: () => undefined, +}); + +export const ShellPageProvider = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const [config, setConfig] = useState(null); + const value = useMemo(() => ({ config, setConfig }), [config]); + + return ( + + {children} + + ); +}; + +export const useShellPageConfig = (): ShellPageConfig | null => + useContext(ShellPageContext).config; + +// A leaf declares what its top block shows by rendering this anywhere in +// its tree; the block in MainLayout picks it up. Unmounting clears it. +export const ShellPage = ({ + title, + actions, + hidden, +}: ShellPageConfig): null => { + const { setConfig } = useContext(ShellPageContext); + + useLayoutEffect(() => { + setConfig({ title, actions, hidden }); + }, [setConfig, title, actions, hidden]); + + useLayoutEffect(() => () => setConfig(null), [setConfig]); + + return null; +}; diff --git a/packages/shared/src/components/shell/ShellSquare.tsx b/packages/shared/src/components/shell/ShellSquare.tsx new file mode 100644 index 00000000000..fa46157670c --- /dev/null +++ b/packages/shared/src/components/shell/ShellSquare.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; + +type SquareTag = 'a' | 'button' | 'span'; + +interface ShellSquareProps + extends Omit, 'children'>, + Pick< + React.AnchorHTMLAttributes, + 'href' | 'target' | 'rel' + > { + tag?: SquareTag; + type?: 'button' | 'submit'; + disabled?: boolean; + children: ReactNode; +} + +// The 38px floating square of the top row: back, the page's actions, the +// avatar, the Plus door. The visible square keeps its size; `shell-hit` +// extends what the finger can reach to 44px. +export const ShellSquare = forwardRef( + ( + { tag = 'button', className, children, type, ...props }, + ref, + ): ReactElement => { + const Tag = tag as 'button'; + + return ( + } + type={tag === 'button' ? type ?? 'button' : undefined} + className={classNames( + 'shell-material shell-press shell-hit relative flex size-[2.375rem] shrink-0 items-center justify-center rounded-14 text-text-primary', + className, + )} + {...props} + > + {children} + + ); + }, +); +ShellSquare.displayName = 'ShellSquare'; diff --git a/packages/shared/src/components/shell/YouPage.tsx b/packages/shared/src/components/shell/YouPage.tsx new file mode 100644 index 00000000000..e46410f98a2 --- /dev/null +++ b/packages/shared/src/components/shell/YouPage.tsx @@ -0,0 +1,282 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import Link from '../utilities/Link'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import type { IconProps } from '../Icon'; +import { IconSize } from '../Icon'; +import { + AddUserIcon, + BookmarkIcon, + CoreFlatIcon, + DevCardIcon, + DevPlusIcon, + FilterIcon, + HelpIcon, + MagicIcon, + MegaphoneIcon, + ReadingStreakIcon, + SettingsIcon, + SquadIcon, + TimerIcon, + UserIcon, +} from '../icons'; +import { MedalIcon } from '../icons/Medal'; +import { Drawer } from '../drawers/Drawer'; +import { RootPortal } from '../tooltips/Portal'; +import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup'; +import { useReadingStreak } from '../../hooks/streaks'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { SubscriptionStatus } from '../../lib/plus'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { + docs, + plusUrl, + settingsUrl, + squadCategoriesPaths, + walletUrl, + webappUrl, +} from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; +import { ShellPage } from './ShellPageContext'; +import { ShellSquare } from './ShellSquare'; + +interface YouRowProps { + icon: (props: IconProps) => ReactElement; + label: string; + meta?: string; + href?: string; + onClick?: () => void; +} + +const YouRow = ({ + icon: Icon, + label, + meta, + href, + onClick, +}: YouRowProps): ReactElement => { + const content = ( + <> + + + + {label} + {meta && ( + + {meta} + + )} + + ); + const className = + 'shell-press flex h-12 w-full items-center gap-3 px-4 text-left text-text-primary typo-callout'; + + if (href) { + return ( + + {content} + + ); + } + + return ( + + ); +}; + +const YouGroup = ({ + title, + children, + className, +}: { + title?: string; + children: ReactNode; + className?: string; +}): ReactElement => ( +
+ {title && ( + + {title} + + )} + {children} +
+); + +const usePlusRow = (): { label: string; meta: string } => { + const { isPlus, status } = usePlusSubscription(); + + if (!isPlus) { + return { label: 'daily.dev Plus', meta: 'Upgrade' }; + } + if (status === SubscriptionStatus.Cancelled) { + return { label: 'daily.dev Plus', meta: 'Renew' }; + } + + return { label: 'daily.dev Plus', meta: 'Manage' }; +}; + +// The page behind the avatar: everything that is about the member, in +// the order decided in the Mobile UX review (9b), with the profile one tap +// away and Help as the page's one top action. +export function YouPage(): ReactElement | null { + const { user, squads } = useAuthContext(); + const { streak } = useReadingStreak(); + const [isStreakOpen, setIsStreakOpen] = useState(false); + const hasAccessToCores = useHasAccessToCores(); + const { optOutAchievements, optOutLevelSystem, optOutQuestSystem } = + useSettingsContext(); + const plusRow = usePlusRow(); + const hideGameCenter = + optOutAchievements && optOutLevelSystem && optOutQuestSystem; + + if (!user) { + return null; + } + + const profileUrl = `${webappUrl}${user.username}`; + + return ( +
+ + + + } + /> + + + + + {user.name} + + @{user.username} + {typeof user.reputation === 'number' && + ` · ${largeNumberFormat(user.reputation)} reputation`} + + + + + + + + View profile + + + + + + + + + + + + {!optOutAchievements && ( + + )} + {streak && ( + setIsStreakOpen(true)} + /> + )} + + + {!hideGameCenter && ( + + )} + + + {hasAccessToCores && ( + + )} + + + + {streak && ( + + setIsStreakOpen(false)}> + + + + )} +
+ ); +} + +export default YouPage; diff --git a/packages/shared/src/components/shell/constants.ts b/packages/shared/src/components/shell/constants.ts index 48d1c0a9927..eb4c7d93a9b 100644 --- a/packages/shared/src/components/shell/constants.ts +++ b/packages/shared/src/components/shell/constants.ts @@ -17,5 +17,39 @@ export const motion = { enter: 300, exit: 200, snap: 220, + scrub: 140, press: 0.96, }; + +export const scroll = { + travel: 64, + deadZone: 96, + hideTolerance: 24, + revealTolerance: 8, + stop: 300, + shrinkDistance: 96, +}; + +export const cluster = { + rest: 56, + compact: 44, + radiusRest: 22, + radiusCompact: 18, + inset: 20, + insetCompact: 40, + padding: 4, + gap: 8, + lift: 8, +}; + +export const topButton = { + size: 38, + radius: 14, + inset: 16, + gap: 8, +}; + +export const lerp = (from: number, to: number, p: number): number => + from + (to - from) * p; + +export const clamp = (value: number): number => Math.min(1, Math.max(0, value)); diff --git a/packages/shared/src/components/shell/shellNav.ts b/packages/shared/src/components/shell/shellNav.ts new file mode 100644 index 00000000000..36674ca72f5 --- /dev/null +++ b/packages/shared/src/components/shell/shellNav.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { useRouter } from 'next/router'; +import { squadCategoriesPaths, isDevelopment } from '../../lib/constants'; +import { withoutLayoutVariantPrefix } from '../../lib/layoutVariant'; + +export enum ShellRoot { + Home = 'home', + Explore = 'explore', + Squads = 'squads', + Activity = 'activity', +} + +export const rootHref: Record = { + [ShellRoot.Home]: '/', + [ShellRoot.Explore]: '/posts', + [ShellRoot.Squads]: squadCategoriesPaths.discover, + [ShellRoot.Activity]: '/notifications', +}; + +const explorePrefixes = ['/posts', '/search', '/tags', '/sources', '/users']; + +// The root that owns a URL is the tab that lights on it and the place back +// goes when there is no history: Home for posts, tags, sources and +// profiles; Squads for squads; Explore for search and the directories. +export const owningRoot = (pathname: string): ShellRoot => { + const path = withoutLayoutVariantPrefix(pathname ?? ''); + + if (path.startsWith('/squads')) { + return ShellRoot.Squads; + } + if (path.startsWith('/notifications')) { + return ShellRoot.Activity; + } + if (path.startsWith('/posts/[id]') || path.startsWith('/posts/')) { + return ShellRoot.Home; + } + if ( + explorePrefixes.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ) + ) { + return ShellRoot.Explore; + } + + return ShellRoot.Home; +}; + +const isSameSiteReferrer = (): boolean => { + const referrer = globalThis?.document?.referrer; + const origin = globalThis?.window?.location.origin; + + if (!referrer) { + return true; + } + + try { + return new URL(referrer).origin === origin; + } catch { + return false; + } +}; + +// One back for every leaf: the previous entry when it is ours, otherwise +// the root that owns the page, so a deep link always has a way up. +export const useShellBack = (): (() => void) => { + const router = useRouter(); + + return useCallback(() => { + const canGoBack = + globalThis?.history?.length > 1 && + (isSameSiteReferrer() || isDevelopment); + + if (canGoBack) { + router.back(); + return; + } + + router.push(rootHref[owningRoot(router.pathname)]); + }, [router]); +}; diff --git a/packages/shared/src/components/shell/useShellScroll.spec.tsx b/packages/shared/src/components/shell/useShellScroll.spec.tsx new file mode 100644 index 00000000000..430fc81faa9 --- /dev/null +++ b/packages/shared/src/components/shell/useShellScroll.spec.tsx @@ -0,0 +1,92 @@ +import { act, renderHook } from '@testing-library/react'; +import { revealShell, useShellScroll } from './useShellScroll'; +import { scroll } from './constants'; + +const scrollTo = (y: number) => { + Object.defineProperty(window, 'scrollY', { configurable: true, value: y }); + window.dispatchEvent(new Event('scroll')); +}; + +describe('useShellScroll', () => { + beforeEach(() => { + jest.useFakeTimers(); + scrollTo(0); + revealShell(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stays shown inside the dead zone', () => { + const { result } = renderHook(() => useShellScroll()); + + act(() => { + scrollTo(scroll.deadZone - 10); + }); + + expect(result.current.p).toBe(0); + }); + + it('hides after reading past the tolerance and snaps when the scroll stops', () => { + const { result } = renderHook(() => useShellScroll()); + + act(() => { + scrollTo(90); + scrollTo(100); + scrollTo(140); + }); + expect(result.current.p).toBeGreaterThan(0); + expect(result.current.p).toBeLessThan(1); + + act(() => { + jest.advanceTimersByTime(scroll.stop); + }); + expect(result.current).toEqual({ p: 1, snapping: true }); + }); + + it('comes back on a short scroll up, anywhere in the page', () => { + const { result } = renderHook(() => useShellScroll()); + + act(() => { + scrollTo(200); + scrollTo(400); + jest.advanceTimersByTime(scroll.stop); + }); + expect(result.current.p).toBe(1); + + act(() => { + scrollTo(400 - scroll.revealTolerance - scroll.travel); + jest.advanceTimersByTime(scroll.stop); + }); + expect(result.current.p).toBe(0); + }); + + it('ignores a nudge smaller than the hide tolerance', () => { + const { result } = renderHook(() => useShellScroll()); + + act(() => { + scrollTo(90); + scrollTo(100); + scrollTo(90 + scroll.hideTolerance - 4); + }); + + expect(result.current.p).toBe(0); + }); + + it('reveals on demand', () => { + const { result } = renderHook(() => useShellScroll()); + + act(() => { + scrollTo(200); + scrollTo(400); + jest.advanceTimersByTime(scroll.stop); + }); + expect(result.current.p).toBe(1); + + act(() => { + revealShell(); + }); + expect(result.current.p).toBe(0); + }); +}); diff --git a/packages/shared/src/components/shell/useShellScroll.ts b/packages/shared/src/components/shell/useShellScroll.ts new file mode 100644 index 00000000000..5a77c86ae73 --- /dev/null +++ b/packages/shared/src/components/shell/useShellScroll.ts @@ -0,0 +1,89 @@ +import { useSyncExternalStore } from 'react'; +import { clamp, scroll } from './constants'; + +export interface ShellScrollState { + p: number; + snapping: boolean; +} + +// One reader of the window scroll for the whole shell: the top block and +// the bottom cluster move on the same progress. 0 is shown and at rest, 1 +// is hidden and compact. Direction decides, not position: reading down +// past the tolerance hides, any short scroll up reveals, and nothing moves +// inside the dead zone at the top. +const rest: ShellScrollState = { p: 0, snapping: false }; +let state = rest; +let lastY = 0; +let target = 0; +let armed = 0; +let stopTimer: ReturnType | undefined; +const listeners = new Set<() => void>(); + +const emit = (next: ShellScrollState) => { + state = next; + listeners.forEach((listener) => listener()); +}; + +const settle = () => { + if (target > 0 && target < 1) { + target = target >= 0.5 ? 1 : 0; + emit({ p: target, snapping: true }); + } +}; + +const onScroll = () => { + const y = window.scrollY; + const delta = y - lastY; + lastY = y; + + if (y <= scroll.deadZone) { + target = 0; + armed = 0; + } else { + armed = Math.sign(armed) === Math.sign(delta) ? armed + delta : delta; + const tolerance = delta > 0 ? scroll.hideTolerance : scroll.revealTolerance; + if (Math.abs(armed) > tolerance) { + target = clamp(target + delta / scroll.travel); + } + } + + if (state.p !== target || state.snapping) { + emit({ p: target, snapping: false }); + } + + if (stopTimer) { + clearTimeout(stopTimer); + } + stopTimer = setTimeout(settle, scroll.stop); +}; + +const subscribe = (listener: () => void) => { + if (listeners.size === 0) { + lastY = window.scrollY; + window.addEventListener('scroll', onScroll, { passive: true }); + } + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + window.removeEventListener('scroll', onScroll); + } + }; +}; + +export const revealShell = (): void => { + target = 0; + armed = 0; + lastY = globalThis.window?.scrollY ?? 0; + if (state.p !== 0) { + emit({ p: 0, snapping: true }); + } +}; + +export const useShellScroll = (): ShellScrollState => + useSyncExternalStore( + subscribe, + () => state, + () => rest, + ); diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 13acc850155..8c7baf53d88 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -124,10 +124,10 @@ export const SquadDirectoryLayout = ( isV2Laptop && 'laptop:hidden', )} > -
- Squads +
+ Squads {isMobileAppHeader ? ( - + ) : ( } diff --git a/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx b/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx index de59ba43fd2..02c075435b8 100644 --- a/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx +++ b/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx @@ -13,6 +13,7 @@ import { TypographyTag, TypographyType, } from '../../../components/typography/Typography'; +import { ShellPage } from '../../../components/shell/ShellPageContext'; interface SquadSubPageHeaderProps { title: string; @@ -31,30 +32,33 @@ export const SquadSubPageHeader = ({ action, className, }: SquadSubPageHeaderProps): ReactElement => ( -
- -
+ ); diff --git a/packages/shared/src/styles/shell.css b/packages/shared/src/styles/shell.css index 49bcb0fb2cf..45104e624f5 100644 --- a/packages/shared/src/styles/shell.css +++ b/packages/shared/src/styles/shell.css @@ -34,3 +34,143 @@ transition-duration: 0s; } } + +/* + * The floating material of the shell: production's blur recipe (the + * blur-baseline surface over a 40px backdrop blur), a hairline ring from + * the border token and a soft transparent shadow. No glass rim. + */ +.shell-material { + background: var(--theme-blur-blur-baseline); + -webkit-backdrop-filter: blur(40px); + backdrop-filter: blur(40px); + box-shadow: + inset 0 0 0 1px var(--theme-border-subtlest-tertiary), + 0 4px 30px rgb(0 0 0 / 0.12); +} + +/* A 38px square the finger can hit at 44px; neighbours sit 8px apart so + two hit areas never overlap. */ +.shell-hit::after { + content: ''; + position: absolute; + inset: -3px; +} + +/* + * Sheets: in on the travel curve, out faster on ease-out, the scrim on its + * own shorter fade. Both are transitions so a close mid-open reverses from + * where it is. + */ +.shell-sheet-overlay { + transition: opacity var(--shell-duration-feedback) ease-out; +} + +.shell-sheet-panel { + transition: transform var(--shell-duration-enter) var(--shell-ease-travel); +} + +.shell-sheet-panel[data-closing='true'] { + transition: transform var(--shell-duration-exit) ease-out; +} + +.shell-sheet-panel[data-dragging='true'] { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .shell-sheet-overlay, + .shell-sheet-panel { + transition-duration: 0s; + } +} + +/* + * Menus on a phone: the Radix popover becomes a bottom sheet. The popper + * wrapper is pinned to the bottom edge, the content takes the full width + * with rounded top corners, and the open and close are the sheet's own + * timings. The scrim is drawn by the content so the menu stays one node. + */ +@media (max-width: 655.98px) { + [data-radix-popper-content-wrapper]:has(> .shell-menu-sheet) { + position: fixed !important; + inset: auto 0 0 0 !important; + transform: none !important; + min-width: 0 !important; + z-index: 100 !important; + } + + .shell-menu-sheet { + position: relative; + width: 100%; + max-width: none !important; + min-width: 0 !important; + border-radius: 1rem 1rem 0 0 !important; + border: 0 !important; + padding: 0.75rem 0.5rem calc(env(safe-area-inset-bottom, 0px) + 0.75rem) !important; + background: var(--theme-background-default) !important; + box-shadow: 0 -8px 40px rgb(0 0 0 / 0.24); + animation-duration: var(--shell-duration-enter) !important; + animation-timing-function: var(--shell-ease-travel) !important; + animation-name: shell-sheet-in !important; + transform-origin: bottom center; + } + + .shell-menu-sheet::before { + content: ''; + position: absolute; + left: 50%; + top: 0.375rem; + width: 2.25rem; + height: 0.25rem; + border-radius: 0.125rem; + background: var(--theme-border-subtlest-secondary); + transform: translateX(-50%); + } + + .shell-menu-sheet[data-state='closed'] { + animation-name: shell-sheet-out !important; + animation-duration: var(--shell-duration-exit) !important; + animation-timing-function: ease-out !important; + } + + .shell-menu-sheet [role='menuitem'], + .shell-menu-sheet [role='menuitemcheckbox'], + .shell-menu-sheet [role='menuitemradio'] { + height: 3rem; + padding-inline: 0.75rem; + font-size: 0.9375rem; + line-height: 1.25rem; + color: var(--theme-text-primary); + } + + .shell-menu-sheet [role='menuitem'] svg { + color: var(--theme-text-secondary); + } +} + +@keyframes shell-sheet-in { + from { + transform: translateY(100%); + } + + to { + transform: none; + } +} + +@keyframes shell-sheet-out { + from { + transform: none; + } + + to { + transform: translateY(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .shell-menu-sheet { + animation: none !important; + } +} diff --git a/packages/webapp/__tests__/MobileFooterNavbar.tsx b/packages/webapp/__tests__/MobileFooterNavbar.tsx deleted file mode 100644 index d50547325dc..00000000000 --- a/packages/webapp/__tests__/MobileFooterNavbar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import React from 'react'; -import type { NextRouter } from 'next/router'; -import { useRouter } from 'next/router'; -import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import { useNotificationContext } from '@dailydotdev/shared/src/contexts/NotificationsContext'; -import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; -import MobileFooterNavbar from '../components/footer/MobileFooterNavbar'; - -const mockLogEvent = jest.fn(); - -jest.mock('@dailydotdev/shared/src/contexts/LogContext', () => ({ - useLogContext: () => ({ logEvent: mockLogEvent }), -})); - -jest.mock('@dailydotdev/shared/src/contexts/NotificationsContext', () => ({ - useNotificationContext: jest.fn(), -})); - -jest.mock('next/router', () => ({ - useRouter: jest.fn(), -})); - -const user = { id: 'u1', username: 'ido' } as LoggedUser; - -const renderNavbar = () => - render( - - - , - ); - -beforeEach(() => { - jest.clearAllMocks(); - jest.mocked(useRouter).mockReturnValue({ - pathname: '/', - asPath: '/', - query: {}, - } as unknown as NextRouter); - jest.mocked(useNotificationContext).mockReturnValue({ - unreadCount: 3, - } as unknown as ReturnType); -}); - -describe('MobileFooterNavbar', () => { - it('logs a click per tab with the footer as the target', () => { - renderNavbar(); - - fireEvent.click(screen.getByText('Explore')); - - expect(mockLogEvent).toHaveBeenCalledWith({ - event_name: 'click', - target_id: 'mobile footer', - extra: JSON.stringify({ tab: 'explore' }), - }); - }); - - it('logs the notification icon click from the footer with the unread count', () => { - renderNavbar(); - - fireEvent.click(screen.getByText('Activity')); - - expect(mockLogEvent).toHaveBeenCalledWith({ - event_name: 'click notification icon', - target_id: 'footer', - extra: JSON.stringify({ notifications_number: 3 }), - }); - }); -}); diff --git a/packages/webapp/components/footer/FooterNavBarItem.tsx b/packages/webapp/components/footer/FooterNavBarItem.tsx deleted file mode 100644 index 04fd8414d44..00000000000 --- a/packages/webapp/components/footer/FooterNavBarItem.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import { ActiveTabIndicator } from '@dailydotdev/shared/src/components/utilities'; -import { Flipped } from 'react-flip-toolkit'; -import classNames from 'classnames'; - -export interface FooterNavBarItemProps { - className?: string; - isActive?: boolean; - children: ReactNode; -} - -export function FooterNavBarItem({ - className, - isActive, - children, -}: FooterNavBarItemProps): ReactElement { - return ( -
- {children} - - {isActive && } - -
- ); -} diff --git a/packages/webapp/components/footer/FooterNavBarTabs.tsx b/packages/webapp/components/footer/FooterNavBarTabs.tsx deleted file mode 100644 index 2a4b70d6280..00000000000 --- a/packages/webapp/components/footer/FooterNavBarTabs.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { isValidElement } from 'react'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; -import classNames from 'classnames'; -import Link from '@dailydotdev/shared/src/components/utilities/Link'; -import type { FooterNavBarContainerProps, FooterTab } from './common'; -import { getNavPath } from './common'; -import type { FooterNavBarItemProps } from './FooterNavBarItem'; -import { FooterNavBarItem } from './FooterNavBarItem'; - -interface TabProps extends Pick { - tab: FooterTab; -} - -const Tab = ({ tab, isActive }: TabProps) => { - const { user } = useAuthContext(); - - return ( - - - { - if (e.key === ' ') { - e.preventDefault(); - e.currentTarget.click(); - } - }} - > - {tab.icon?.(!!isActive)} - {tab.title} - - - - ); -}; - -export function FooterNavBarTabs({ - activeTab, - tabs, -}: FooterNavBarContainerProps): ReactElement { - return ( - <> - {tabs.map((tab) => { - if (isValidElement(tab)) { - return tab; - } - - const current = tab as FooterTab; - const isActive = current.title === activeTab; - return ; - })} - - ); -} diff --git a/packages/webapp/components/footer/FooterPlusButton.tsx b/packages/webapp/components/footer/FooterPlusButton.tsx deleted file mode 100644 index 21a22631701..00000000000 --- a/packages/webapp/components/footer/FooterPlusButton.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useRef, useState } from 'react'; -import classNames from 'classnames'; -import type { DrawerRef } from '@dailydotdev/shared/src/components/drawers'; -import { Drawer } from '@dailydotdev/shared/src/components/drawers'; -import type { - AllowedTags, - ButtonProps, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - EditIcon, - LinkIcon, - PlusIcon, - PollIcon, -} from '@dailydotdev/shared/src/components/icons'; -import { link } from '@dailydotdev/shared/src/lib/links'; -import { RootPortal } from '@dailydotdev/shared/src/components/tooltips/Portal'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; - -const ActionButton = ({ - children, - ...props -}: ButtonProps) => { - return ( -
-
- ); -}; - -interface FooterPlusButtonProps { - className?: string; -} - -export function FooterPlusButton({ - className, -}: FooterPlusButtonProps): ReactElement { - const { user } = useAuthContext(); - const drawerRef = useRef(null); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const props = user - ? { onClick: () => setIsDrawerOpen(true) } - : { tag: 'a' as AllowedTags, href: '/onboarding' }; - - return ( - <> - + ); +}; + +// Segments: which list. Plain tertiary text, the active one on a soft +// tonal fill with a hairline. `menu` marks the active segment that carries +// a chevron opening a sheet (Happening now's channels). +export const Segments = ({ + items, + menu, + onMenu, +}: { + items: RowItem[]; + menu?: string; + onMenu?: () => void; +}): ReactElement => ( + <> + {items.map((item) => ( + + {item.active && item.key === menu && ( + { + event.preventDefault(); + event.stopPropagation(); + onMenu?.(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + onMenu?.(); + } + }} + className="-mr-1 flex" + > + + + )} + + ))} + +); + +// Chips: narrow this list. Hairline outlined, the active one a primary +// button; a chip without an active state is a link to another page. +export const Chips = ({ items }: { items: RowItem[] }): ReactElement => ( + <> + {items.map((item) => ( + + ))} + +); + +// A menu: text and a chevron, opening a sheet. Reorder, period, a second +// dimension of the same list. +export const MenuLabel = ({ + label, + onClick, + className, +}: { + label: ReactNode; + onClick: () => void; + className?: string; +}): ReactElement => ( + +); + +// A sheet row: an icon-less line that is the only choice on the line. +export const SheetChoice = ({ items }: { items: RowItem[] }): ReactElement => ( +
+ {items.map((item) => { + const className = classNames( + 'shell-press flex h-12 w-full items-center px-4 text-left typo-callout', + item.active ? 'font-bold text-text-primary' : 'text-text-primary', + ); + const mark = item.active && ( + + ); + + if (item.href) { + return ( + + { + if (event.key === ' ') { + event.preventDefault(); + event.currentTarget.click(); + } + }} + className={className} + > + {item.label} + {mark} + + + ); + } + + return ( + + ); + })} +
+); diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 8c7baf53d88..95e37581793 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -11,6 +11,8 @@ import { SquadDirectoryNavbar, SquadDirectoryNavbarItem, } from './SquadDirectoryNavbar'; +import { ShellPage } from '../../shell/ShellPageContext'; +import { Chips, ShellRow } from '../../shell/ShellRow'; import { PlusIcon } from '../../icons'; import { useSquadDirectoryLayout } from './useSquadDirectoryLayout'; import { squadCategoriesPaths } from '../../../lib/constants'; @@ -135,7 +137,23 @@ export const SquadDirectoryLayout = ( /> )}
-
+ + ({ + key: category, + label: {category}, + href: path, + active: path === pathname || path === asPath, + }), + )} + /> + + } + /> +
{tabItems} diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 8c26c0d75d8..aa526676cdb 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -381,12 +381,14 @@ export const TagTopicPage = ({ )} {/* Full-bleed header strip — rendered outside the padded feed container so it spans flush to the edges like the main feed nav. */} - relatedTag.name) - .filter((name): name is string => !!name)} - /> +
+ relatedTag.name) + .filter((name): name is string => !!name)} + /> +
diff --git a/packages/shared/src/components/tags/TagsDirectoryPage.tsx b/packages/shared/src/components/tags/TagsDirectoryPage.tsx index 1b7753e8142..d22dca26476 100644 --- a/packages/shared/src/components/tags/TagsDirectoryPage.tsx +++ b/packages/shared/src/components/tags/TagsDirectoryPage.tsx @@ -10,6 +10,7 @@ import { Origin } from '../../lib/log'; import { TagCategorySection } from './TagCategorySection'; import { TagDirectorySearch } from './TagDirectorySearch'; import { TagPageNavbar } from './TagPageNavbar'; +import { ShellPage } from '../shell/ShellPageContext'; import { TagDirectory } from './TagDirectory'; import { TagDirectoryFilter } from './TagDirectoryFilter'; import { PublicPageSignupBanner } from '../auth/PublicPageSignupBanner'; @@ -112,10 +113,13 @@ export function TagsDirectoryPage({ return ( <> + {/* Tabbed page header (same design as the Squad directory). */} - tag.value) ?? []} - /> +
+ tag.value) ?? []} + /> +
diff --git a/packages/webapp/components/notifications/NotificationsFeed.tsx b/packages/webapp/components/notifications/NotificationsFeed.tsx index e722a702027..e129a823fce 100644 --- a/packages/webapp/components/notifications/NotificationsFeed.tsx +++ b/packages/webapp/components/notifications/NotificationsFeed.tsx @@ -56,6 +56,11 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { useStreakRecoverModal } from '@dailydotdev/shared/src/hooks/notifications/useStreakRecoverModal'; import { getNextPageParam } from '@dailydotdev/shared/src/lib/query'; import { useCampaignByIdModal } from '@dailydotdev/shared/src/hooks/notifications'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; +import { + Chips, + ShellRow, +} from '@dailydotdev/shared/src/components/shell/ShellRow'; import ProtectedPage from '../ProtectedPage'; import { NotificationFilterBar } from './NotificationFilterBar'; @@ -244,7 +249,31 @@ export const NotificationsFeed = (): ReactElement => { {/* On v2 the type filters live in the sidebar rail panel; on mobile layout (no rail) keep them as in-page tabs. */} {!isV2Laptop && (hasNotifications || !!activeCategory) && ( -
+ + onSelectCategory(null), + }, + ...filterCategories.map((category) => ({ + key: category, + label: notificationFilterCategoryLabel[category], + active: activeCategory === category, + onClick: () => onSelectCategory(category), + })), + ]} + /> + + } + /> + )} + {!isV2Laptop && (hasNotifications || !!activeCategory) && ( +
{
)} + diff --git a/packages/webapp/pages/sources/index.tsx b/packages/webapp/pages/sources/index.tsx index 630c75f044b..e80c0823096 100644 --- a/packages/webapp/pages/sources/index.tsx +++ b/packages/webapp/pages/sources/index.tsx @@ -20,6 +20,7 @@ import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { useRouter } from 'next/router'; import { BreadCrumbs } from '@dailydotdev/shared/src/components/header/BreadCrumbs'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { ExploreHubHeader } from '@dailydotdev/shared/src/components/header/ExploreHubHeader'; import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; @@ -133,10 +134,11 @@ const SourcesPage = ({ }} /> + {!isV2Laptop && ( -
- +
+ Sources {suggestSourceButton} diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index 574825f1ee1..1d96ff86940 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -27,6 +27,7 @@ import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/ import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; import { MobileAppFooterAnchor } from '@dailydotdev/shared/src/features/getApp/components/MobileAppFooterAnchor'; import { MobileAppFooterAnchorPlace } from '@dailydotdev/shared/src/features/getApp/mobileAppFooter'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; import { getLayout } from '../components/layouts/MainLayout'; import { defaultOpenGraph } from '../next-seo'; @@ -95,6 +96,7 @@ const LeaderboardPage = ({ <> {isV2Laptop && } + {!isV2Laptop && (
From 6e574559078513f1f8fad69bc47f7fc95e6d7f37 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 13:15:57 +0300 Subject: [PATCH 04/55] feat(mobile): the buttons ride in the block On review every phone page still kept a button row of its own under the block: New Squad under the Squads title, the channel line and copy link under Happening now, Search and the menu in the squad hero, Follow, Award and the menu twice on a profile, Sort and Share in the Bookmarks header, the full-width Filters on search results. They move into the block. ShellPage now portals its actions into a slot the block exposes instead of passing the nodes through context, because the block renders outside the page's providers and the squad menu reads SquadPageContext. The hero copies are gated by viewport in JS rather than CSS so each label exists once in the DOM, which is also what the page tests query. The streak was never hidden: the local proxy's fake member had no streak response. The proxy stubs one now so the shots show it. Co-Authored-By: Claude Fable 5.1 --- .../src/components/BookmarkFeedLayout.tsx | 22 +++-- .../shared/src/components/MainFeedLayout.tsx | 24 +++++- .../components/highlights/HighlightsPage.tsx | 18 +--- .../components/layout/MainLayoutHeader.tsx | 3 +- .../shared/src/components/profile/Header.tsx | 84 ++++++++++--------- .../src/components/profile/ProfileActions.tsx | 79 +++++++++-------- .../search/SearchMobileFiltersButton.tsx | 36 +++++--- .../src/components/shell/ExploreSortMenu.tsx | 2 +- .../src/components/shell/ShellBlock.tsx | 21 ++--- .../src/components/shell/ShellPageContext.tsx | 31 +++++-- .../squads/layout/SquadDirectoryLayout.tsx | 42 +++++++--- .../squads/components/header/SquadActions.tsx | 46 +++++++--- .../components/header/SquadProfileHeader.tsx | 12 ++- 13 files changed, 265 insertions(+), 155 deletions(-) diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index c75e712a295..1b6b39f5af3 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -102,6 +102,7 @@ export default function BookmarkFeedLayout({ DEFAULT_BOOKMARK_SORT_INDEX, ); const isLaptop = useViewSize(ViewSize.Laptop); + const isPhone = useViewSize(ViewSize.MobileL); const { isV2 } = useLayoutVariant(); const isV2Laptop = isV2; const isSearchResults = !!searchQuery; @@ -204,7 +205,7 @@ export default function BookmarkFeedLayout({ label: 'hidden', chevron: 'hidden', button: isV2Laptop ? undefined : '!px-1', - container: isV2Laptop ? 'flex' : 'ml-4 flex', + container: isV2Laptop || isPhone ? 'flex' : 'ml-4 flex', }} shouldIndicateSelected icon={} @@ -220,7 +221,7 @@ export default function BookmarkFeedLayout({ const shareButton = !isFolderPage && ( @@ -300,9 +303,9 @@ export default function BookmarkFeedLayout({ )} > {searchChildren} - {sortDropdown} - {shareButton} - {folderMenu} + {!isPhone && sortDropdown} + {!isPhone && shareButton} + {!isPhone && folderMenu} )} @@ -315,6 +318,13 @@ export default function BookmarkFeedLayout({ )} + {sortDropdown} + {shareButton} + {folderMenu} +
+ } row={ + import( + /* webpackChunkName: "spotlightTrigger" */ './spotlight/SpotlightTrigger' + ), +); const FeedExploreHeader = dynamic( () => @@ -730,7 +738,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) { @@ -903,7 +912,18 @@ export default function MainFeedLayout({ )} {isAnyExplore && !showExploreV2PageHeader && } {isSearchOn && !isSearchPageLaptop && search} - {isSearchOn && !isSearchPageLaptop && ( + {isSearchOn && !isSearchPageLaptop && isPhone && ( + } + row={ +
+ +
+ } + /> + )} + {isSearchOn && !isSearchPageLaptop && !isPhone && (
{ const channelLabel = channels.find((c) => c.channel === channel)?.displayName; const isPhone = useViewSize(ViewSize.MobileL); - const [isChannelsOpen, setIsChannelsOpen] = useState(false); const activeTab = isAllTab ? ALL_HIGHLIGHTS_LABEL : channelLabel ?? MAJOR_HEADLINES_LABEL; @@ -193,19 +190,6 @@ export const HighlightsPage = (): ReactElement => { - {isPhone && ( -
- setIsChannelsOpen(true)} - /> - - setIsChannelsOpen(false)} - /> -
- )} { if (isExploreRoot) { return ShellRoot.Explore; diff --git a/packages/shared/src/components/profile/Header.tsx b/packages/shared/src/components/profile/Header.tsx index f3921fc6529..5045180cdc4 100644 --- a/packages/shared/src/components/profile/Header.tsx +++ b/packages/shared/src/components/profile/Header.tsx @@ -157,6 +157,42 @@ export function Header({ }); } + const optionsMenu = !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} + /> + ); + const actions = ( <> {isSameUser && ( @@ -208,41 +244,7 @@ export function Header({ /> )} {isMobileAppHeader && !isPhone && } - {!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} - /> - )} + {optionsMenu} ); @@ -306,10 +308,14 @@ export function Header({ - {actions} - {ownerActions} -
+ isSameUser ? ( +
+ {actions} + {ownerActions} +
+ ) : ( + optionsMenu + ) } /> ); diff --git a/packages/shared/src/components/profile/ProfileActions.tsx b/packages/shared/src/components/profile/ProfileActions.tsx index d81d537948d..31a9cf1611b 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 { useViewSize, ViewSize } 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 = useViewSize(ViewSize.MobileL); 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/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 index ac5dfd30a35..dbede54ac3d 100644 --- a/packages/shared/src/components/shell/ExploreSortMenu.tsx +++ b/packages/shared/src/components/shell/ExploreSortMenu.tsx @@ -71,7 +71,7 @@ export function ExploreSortMenu(): ReactElement { })); return ( -
+
setIsOpen(true)} /> {withPeriod.includes(current.key) && ( , string> = { @@ -82,6 +82,7 @@ const PlusSquare = (): ReactElement | null => { const RootRow = ({ root }: { root: ShellRoot }): ReactElement => { const { user } = useAuthContext(); + const setActionsSlot = useShellActionsSlot(); const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); const isMobileAppHeader = useMobileAppHeader(); const { isPlus } = usePlusSubscription(); @@ -117,19 +118,15 @@ const RootRow = ({ root }: { root: ShellRoot }): ReactElement => { )} +
); }; -const PageRow = ({ - title, - actions, -}: { - title?: ReactNode; - actions?: ReactNode; -}): ReactElement => { +const PageRow = ({ title }: { title?: ReactNode }): ReactElement => { const goBack = useShellBack(); + const setActionsSlot = useShellActionsSlot(); const isMobileAppHeader = useMobileAppHeader(); return ( @@ -151,7 +148,7 @@ const PageRow = ({ className="flex shrink-0 items-center" style={{ gap: topButton.gap }} > - {actions} +
{isMobileAppHeader && }
@@ -235,11 +232,7 @@ export function ShellBlock({ transition: `transform ${duration}ms ${motion.interaction}`, }} > - {root ? ( - - ) : ( - - )} + {root ? : } {config?.row ?? row} ); diff --git a/packages/shared/src/components/shell/ShellPageContext.tsx b/packages/shared/src/components/shell/ShellPageContext.tsx index ea0f1fac3a4..a955d6cbb66 100644 --- a/packages/shared/src/components/shell/ShellPageContext.tsx +++ b/packages/shared/src/components/shell/ShellPageContext.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState, } from 'react'; +import { createPortal } from 'react-dom'; export interface ShellPageConfig { title?: ReactNode; @@ -19,11 +20,15 @@ export interface ShellPageConfig { interface ShellPageContextData { config: ShellPageConfig | null; setConfig: (config: ShellPageConfig | null) => void; + actionsSlot: HTMLElement | null; + setActionsSlot: (element: HTMLElement | null) => void; } const ShellPageContext = createContext({ config: null, setConfig: () => undefined, + actionsSlot: null, + setActionsSlot: () => undefined, }); export const ShellPageProvider = ({ @@ -32,7 +37,11 @@ export const ShellPageProvider = ({ children: ReactNode; }): ReactElement => { const [config, setConfig] = useState(null); - const value = useMemo(() => ({ config, setConfig }), [config]); + const [actionsSlot, setActionsSlot] = useState(null); + const value = useMemo( + () => ({ config, setConfig, actionsSlot, setActionsSlot }), + [config, actionsSlot], + ); return ( @@ -44,6 +53,12 @@ export const ShellPageProvider = ({ export const useShellPageConfig = (): ShellPageConfig | null => useContext(ShellPageContext).config; +// The block hands its actions slot to the context; pages portal their +// actions into it so they keep the page's own providers. +export const useShellActionsSlot = (): (( + element: HTMLElement | null, +) => void) => useContext(ShellPageContext).setActionsSlot; + // A leaf declares what its top block shows by rendering this anywhere in // its tree; the block in MainLayout picks it up. Unmounting clears it. export const ShellPage = ({ @@ -51,14 +66,18 @@ export const ShellPage = ({ actions, row, hidden, -}: ShellPageConfig): null => { - const { setConfig } = useContext(ShellPageContext); +}: ShellPageConfig): ReactElement | null => { + const { setConfig, actionsSlot } = useContext(ShellPageContext); useLayoutEffect(() => { - setConfig({ title, actions, row, hidden }); - }, [setConfig, title, actions, row, hidden]); + setConfig({ title, row, hidden }); + }, [setConfig, title, row, hidden]); useLayoutEffect(() => () => setConfig(null), [setConfig]); - return null; + if (!actions || !actionsSlot) { + return null; + } + + return createPortal(actions, actionsSlot); }; diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 95e37581793..589e00efb5e 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -12,6 +12,9 @@ import { SquadDirectoryNavbarItem, } from './SquadDirectoryNavbar'; import { ShellPage } from '../../shell/ShellPageContext'; +import { ShellSquare } from '../../shell/ShellSquare'; +import { IconSize } from '../../Icon'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; import { Chips, ShellRow } from '../../shell/ShellRow'; import { PlusIcon } from '../../icons'; import { useSquadDirectoryLayout } from './useSquadDirectoryLayout'; @@ -52,6 +55,19 @@ const NewSquadButton = ( ); }; +const NewSquadSquare = (): ReactElement => { + const { openNewSquad } = useSquadNavigation(); + + return ( + openNewSquad({ origin: Origin.SquadDirectory })} + > + + + ); +}; + export const SquadDirectoryLayout = ( props: SquadDirectoryLayoutProps, ): ReactElement => { @@ -63,6 +79,7 @@ export const SquadDirectoryLayout = ( const { isV2 } = useLayoutVariant(); const isV2Laptop = isV2; const isMobileAppHeader = useMobileAppHeader(); + const isPhone = useViewSize(ViewSize.MobileL); useEffect(() => { const element = document?.getElementById?.(`squad-item-discover-${id}`); @@ -126,18 +143,21 @@ export const SquadDirectoryLayout = ( isV2Laptop && 'laptop:hidden', )} > -
- Squads - {isMobileAppHeader ? ( - - ) : ( - } - variant={ButtonVariant.Primary} - /> - )} -
+ {!isPhone && ( +
+ Squads + {isMobileAppHeader ? ( + + ) : ( + } + variant={ButtonVariant.Primary} + /> + )} +
+ )} } row={ { const { squad } = useSquadPageContext(); @@ -101,6 +104,8 @@ export const SquadActions = (): ReactElement => { const canEdit = verifyPermission(squad, SourcePermissions.Edit); const editUrl = getSquadManageUrl(squad.handle, SquadManageSection.Details); const isMobileAppHeader = useMobileAppHeader(); + // On a phone search and the menu are the block's actions. + const isPhone = useViewSize(ViewSize.MobileL); return (
@@ -151,16 +156,18 @@ export const SquadActions = (): ReactElement => { /> - -
{viewer === SquadViewer.Blocked && (
From 66cd759a9c99feaa6b6ca7f0fb7bb12f41c5e8dd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 13:49:35 +0300 Subject: [PATCH 05/55] feat(mobile): every page's header lives in the block A route-by-route pass at 393px over the member app found the pages that still drew a header row of their own under the block: Sources (a full-width Suggest new source button), Jobs, the Core wallet, the briefings, squad moderation, post analytics, the gear rankings, the best-of archives (breadcrumbs and a copy link row), the experience lists (a second back arrow), the squad form (Cancel and Create under a back square), the leaderboard detail (no title at all), the tag explore page (no title), and the composer page (the block over the composer's own chrome). Each now registers its title and actions with the block on a phone and keeps its row for tablet and up; the composer hides the block. FormWrapper gets an inBlock mode for page forms so the squad form's submit rides in the block and the back square replaces Cancel. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/MainFeedLayout.tsx | 3 + .../components/archive/ArchiveBreadcrumbs.tsx | 5 +- .../archive/ArchiveFeedPage.spec.tsx | 6 ++ .../components/archive/ArchiveFeedPage.tsx | 38 ++++++++--- .../components/archive/ArchiveIndexPage.tsx | 29 +++++--- .../components/fields/form/FormWrapper.tsx | 42 +++++++++--- .../opportunity/OpportunityHeader.tsx | 47 +++++++------ .../shared/src/components/squads/Details.tsx | 2 + .../ProfileExperienceDetailPage.tsx | 33 ++++++---- packages/webapp/pages/briefing/index.tsx | 29 +++++++- packages/webapp/pages/gear/index.tsx | 4 +- .../pages/posts/[id]/analytics/index.tsx | 66 +++++++++++-------- packages/webapp/pages/sources/index.tsx | 20 ++++-- packages/webapp/pages/squads/create.tsx | 26 ++++---- packages/webapp/pages/squads/moderate.tsx | 30 ++++++--- packages/webapp/pages/users/[id].tsx | 2 + packages/webapp/pages/wallet.tsx | 11 +++- 17 files changed, 282 insertions(+), 111 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 7bbdb25a701..c5090fa02ec 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -912,6 +912,9 @@ export default function MainFeedLayout({ )} {isAnyExplore && !showExploreV2PageHeader && } {isSearchOn && !isSearchPageLaptop && search} + {isExploreTag && isPhone && ( + + )} {isSearchOn && !isSearchPageLaptop && isPhone && (
  1. diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx index 3e4a57559ec..4c08743df10 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx @@ -8,6 +8,12 @@ import { ArchivePeriodType, ArchiveScopeType } from '../../graphql/archive'; import { LogEvent, Origin } from '../../lib/log'; import { ShareProvider } from '../../lib/share'; +// The copy link sits in the phone block, which this render has no slot for. +jest.mock('../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../hooks/useViewSize'), + useViewSize: () => false, +})); + it('logs a copy link on a monthly best-of page as an archive share', () => { const logEvent = jest.fn(); Object.assign(navigator, { diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.tsx index b745b27ce96..44817e3b608 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -12,6 +12,8 @@ import Link from '../utilities/Link'; import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; import { ArchiveCopyLinkButton } from './ArchiveCopyLinkButton'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { ShellPage } from '../shell/ShellPageContext'; interface ArchiveFeedPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -97,6 +99,8 @@ export function ArchiveFeedPage({ } as ArchiveScopeInfo); const items = (archive?.items ?? []).filter((item) => item.post); + const isPhone = useViewSize(ViewSize.MobileL); + return (
    - {/* Header */} + {isPhone && ( + + } + /> + )}
    -

    - Best of {scopeName} — {periodLabel} +

    + {isPhone ? periodLabel : `Best of ${scopeName} \u2014 ${periodLabel}`}

    - + {!isPhone && ( + + )}
    {/* Top navigation */} diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.tsx index 26a8756f6b7..be0d46f5cbb 100644 --- a/packages/shared/src/components/archive/ArchiveIndexPage.tsx +++ b/packages/shared/src/components/archive/ArchiveIndexPage.tsx @@ -14,6 +14,8 @@ import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; import { ElementPlaceholder } from '../ElementPlaceholder'; import { ArchiveCopyLinkButton } from './ArchiveCopyLinkButton'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { ShellPage } from '../shell/ShellPageContext'; interface ArchiveIndexPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -160,19 +162,30 @@ export function ArchiveIndexPage({ className, }: ArchiveIndexPageProps): ReactElement { const groups = groupArchivesByYear(archives); + const isPhone = useViewSize(ViewSize.MobileL); + const copyLink = ( + + ); return (
    - {/* Header */} + {isPhone && ( + + )}
    -

    - Best of {scopeName} — Archive +

    + {isPhone ? 'Archive' : `Best of ${scopeName} \u2014 Archive`}

    - + {!isPhone && copyLink}
    {/* Archive grid by year */} diff --git a/packages/shared/src/components/fields/form/FormWrapper.tsx b/packages/shared/src/components/fields/form/FormWrapper.tsx index 7f713c405d2..ee75cc3ebf5 100644 --- a/packages/shared/src/components/fields/form/FormWrapper.tsx +++ b/packages/shared/src/components/fields/form/FormWrapper.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { ButtonProps } from '../../buttons/Button'; import { Button, ButtonVariant } from '../../buttons/Button'; import { PageHeader, PageHeaderTitle } from '../../layout/common'; +import { ShellPage } from '../../shell/ShellPageContext'; interface Copy { left?: string; @@ -25,6 +26,9 @@ export interface FormWrapperProps { title?: string | React.ReactNode; isHeaderTitle?: boolean; headerRef?: MutableRefObject; + // 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; } export function FormWrapper({ @@ -38,6 +42,7 @@ export function FormWrapper({ title, isHeaderTitle, headerRef, + inBlock = false, }: FormWrapperProps): ReactElement { const { left = 'Cancel', right = 'Submit' } = copy; const titleElement = ( @@ -52,6 +57,34 @@ export function FormWrapper({ ); + const submitButton = ( + + ); + + if (inBlock) { + return ( +
    + + {headerActions} + {submitButton} +
    + } + /> + {children} +
    + ); + } + return (
    {headerActions} - + {submitButton}
    {!isHeaderTitle && title && titleElement} diff --git a/packages/shared/src/components/opportunity/OpportunityHeader.tsx b/packages/shared/src/components/opportunity/OpportunityHeader.tsx index 961b3a659b2..62bc2b537cf 100644 --- a/packages/shared/src/components/opportunity/OpportunityHeader.tsx +++ b/packages/shared/src/components/opportunity/OpportunityHeader.tsx @@ -5,34 +5,43 @@ import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import Link from '../utilities/Link'; import { settingsUrl, webappUrl } from '../../lib/constants'; import { FilterIcon } from '../icons'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { ShellPage } from '../shell/ShellPageContext'; const jobPreferenceUrl = `${settingsUrl}/job-preferences`; const howItWorksUrl = `${webappUrl}jobs/how-it-works`; export const OpportunityHeader = (): ReactElement => { + const isPhone = useViewSize(ViewSize.MobileL); + const actions = ( +
    + + + + +
    + ); + + // 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/squads/Details.tsx b/packages/shared/src/components/squads/Details.tsx index d628488d557..4d972d9bea4 100644 --- a/packages/shared/src/components/squads/Details.tsx +++ b/packages/shared/src/components/squads/Details.tsx @@ -156,6 +156,8 @@ export function SquadDetails({ return ( - + {isPhone ? : }
    -
    - -
    + {!isPhone && ( +
    + +
    + )}
    { )}
    - {!isV2Laptop && ( + {isMobile && ( + + {isNotPlus && !emptyFeed && !hasTodayBrief && ( +
    + } + /> + )} + {!isV2Laptop && !isMobile && (
    - + + Gear
    diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index a62b91b0908..9fbb0f14dd2 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -1,4 +1,9 @@ import type { ReactElement } from 'react'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; import React, { useEffect, useMemo } from 'react'; import type { GetServerSideProps } from 'next'; import type { NextSeoProps } from 'next-seo'; @@ -202,6 +207,7 @@ const PostAnalyticsPage = ({ const queryClient = useQueryClient(); const { showPrompt } = usePrompt(); const router = useRouter(); + const isPhone = useViewSize(ViewSize.MobileL); const { user, isAuthReady } = useAuthContext(); const { post, isLoading } = usePostById({ @@ -430,38 +436,44 @@ const PostAnalyticsPage = ({ ); }, [campaignCompleted, campaign]); + const boostAction = isBoosting ? ( + + ) : ( + + ); + return (
    - - - -
    ); diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 746f1e71be0..a28737026f4 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -24,6 +24,7 @@ import { pageHeaderClassName } from '../../layout/PageHeader'; import { PublicPageSignupBanner } from '../../auth/PublicPageSignupBanner'; import { useMobileAppHeader } from '../../../features/getApp/hooks/useMobileAppHeader'; import { MobileAppActions } from '../../../features/getApp/components/MobileAppActions'; +import { useAuthContext } from '../../../contexts/AuthContext'; type SquadDirectoryLayoutProps = PropsWithChildren & ComponentProps<'section'>; @@ -80,6 +81,7 @@ export const SquadDirectoryLayout = ( const isV2Laptop = isV2; const isMobileAppHeader = useMobileAppHeader(); const isPhone = useViewSize(ViewSize.MobileL); + const { user } = useAuthContext(); useEffect(() => { const element = document?.getElementById?.(`squad-item-discover-${id}`); @@ -157,7 +159,7 @@ export const SquadDirectoryLayout = ( )} } + actions={!!user && } row={ openModal({ type: LazyModal.NewSource })} - > - - + user && ( + openModal({ type: LazyModal.NewSource })} + > + + + ) } /> {!isV2Laptop && !isPhone && ( From d4609deb959acb6b06812452b297d466da5167ab Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 15:09:52 +0300 Subject: [PATCH 10/55] feat(mobile): the bar lifts under a finger A finger on the bar scales the whole bar to 1.04 from its bottom edge for as long as it stays down (150ms ease-out in, 220ms on the no-bounce curve out), the lift Instagram's and iOS 26's bars give; the per-tab 0.96 press goes, since the bar now answers as one piece. The pill slides on the same no-bounce curve, per chapter 9l. Reduced motion drops the lift. Co-Authored-By: Claude Fable 5.1 --- .../components/shell/ShellCluster.spec.tsx | 14 ++++++++++ .../src/components/shell/ShellCluster.tsx | 27 ++++++++++++++++--- .../shared/src/components/shell/constants.ts | 2 ++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/shell/ShellCluster.spec.tsx b/packages/shared/src/components/shell/ShellCluster.spec.tsx index a7984fba05d..3d1e370dc72 100644 --- a/packages/shared/src/components/shell/ShellCluster.spec.tsx +++ b/packages/shared/src/components/shell/ShellCluster.spec.tsx @@ -118,6 +118,20 @@ describe('ShellCluster', () => { rect.mockRestore(); }); + it('lifts the bar while a finger is on it', () => { + renderCluster('/'); + const track = screen.getByLabelText('Home').parentElement as HTMLElement; + const bar = screen.getByRole('navigation', { name: 'Main' }); + + firePointer('pointerdown', track, 40); + expect(bar).toHaveAttribute('data-pressed', 'true'); + expect(bar).toHaveStyle({ transform: 'scale(1.04)' }); + + firePointer('pointerup', track, 40); + expect(bar).not.toHaveAttribute('data-pressed'); + expect(bar).toHaveStyle({ transform: 'scale(1)' }); + }); + it('leaves a plain tap to the link', () => { renderCluster('/'); const track = screen.getByLabelText('Home').parentElement as HTMLElement; diff --git a/packages/shared/src/components/shell/ShellCluster.tsx b/packages/shared/src/components/shell/ShellCluster.tsx index c9ff43c6b96..4f1fc56db87 100644 --- a/packages/shared/src/components/shell/ShellCluster.tsx +++ b/packages/shared/src/components/shell/ShellCluster.tsx @@ -53,6 +53,9 @@ export function ShellCluster({ const [drag, setDrag] = useState<{ left: number; index: number } | null>( null, ); + // A finger on the bar lifts the whole bar a touch (scale 1.04) for as + // long as it stays down, the way Instagram's and iOS 26's bars do. + const [pressed, setPressed] = useState(false); const tabs: ClusterTab[] = [ { @@ -154,6 +157,7 @@ export function ShellCluster({ startX: event.clientX, moved: false, }; + setPressed(true); }; const onPointerMove = (event: React.PointerEvent) => { @@ -182,6 +186,15 @@ export function ShellCluster({ const endDrag = () => { pointer.current = null; setDrag(null); + setPressed(false); + }; + + // Without capture (a plain press that never moved) the finger can leave + // the bar; the lift ends with it. + const onPointerLeave = () => { + if (pointer.current && !pointer.current.moved) { + endDrag(); + } }; const onPointerUp = (event: React.PointerEvent) => { @@ -241,12 +254,17 @@ export function ShellCluster({ >
    {/* The selected tab's pill: behind the lit tab at rest, under the @@ -273,7 +292,7 @@ export function ShellCluster({ : `translateX(${activeIndex * 100}%)`, transition: drag ? 'none' - : `transform ${motion.snap}ms ${motion.travel}, border-radius ${motion.snap}ms ${motion.interaction}`, + : `transform ${motion.snap}ms ${motion.interaction}, border-radius ${motion.snap}ms ${motion.interaction}`, }} /> )} @@ -295,7 +314,7 @@ export function ShellCluster({ event.currentTarget.click(); } }} - className="shell-press relative flex min-w-0 flex-1 items-center justify-center text-text-primary" + className="relative flex min-w-0 flex-1 items-center justify-center text-text-primary" style={{ borderRadius: radius - cluster.padding }} > diff --git a/packages/shared/src/components/shell/constants.ts b/packages/shared/src/components/shell/constants.ts index 64a85fa8c0b..427492e81d9 100644 --- a/packages/shared/src/components/shell/constants.ts +++ b/packages/shared/src/components/shell/constants.ts @@ -42,6 +42,8 @@ export const cluster = { lift: 8, // A held finger has to travel this far before the indicator follows it. dragStart: 6, + // The whole bar lifts this much while a finger is on it. + pressScale: 1.04, }; export const topButton = { From 448734f56c550115cb707d873a27fdae555e8864 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 15:24:28 +0300 Subject: [PATCH 11/55] fix(lint): import order on the Sources page Co-Authored-By: Claude Fable 5.1 --- packages/webapp/pages/sources/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/pages/sources/index.tsx b/packages/webapp/pages/sources/index.tsx index 57af6670db3..e78755c78ff 100644 --- a/packages/webapp/pages/sources/index.tsx +++ b/packages/webapp/pages/sources/index.tsx @@ -27,11 +27,11 @@ import { ExploreHubHeader } from '@dailydotdev/shared/src/components/header/Expl import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; import { SourceTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { getLayout } from '../../components/layouts/MainLayout'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { defaultOpenGraph } from '../../next-seo'; import { getPageSeoTitles } from '../../components/layouts/utils'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; const seoTitles = getPageSeoTitles('Top sources for developer content'); const seo: NextSeoProps = { From 105840afc9eda3cad6db0a8dee7123f0251caba5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 16:30:44 +0300 Subject: [PATCH 12/55] fix(mobile): the pill stops at the bar's right edge while lifted The finger is measured on screen while the bar is scaled to 1.04, but the pill moves in the bar's own pixels, so at the far right it ran past the edge by the scale difference. The finger now maps through the layout width, so both edges clamp the same. Also: a color-scheme meta keyed to the theme beside theme-color, and a valid apple-mobile-web-app-status-bar-style value (black or default) in place of the hex it had, so Safari's chrome and the standalone status bar follow the app's background. Co-Authored-By: Claude Fable 5.1 --- .../src/components/shell/ShellCluster.tsx | 17 +++++++++++------ packages/webapp/pages/_app.tsx | 5 ++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/shell/ShellCluster.tsx b/packages/shared/src/components/shell/ShellCluster.tsx index 4f1fc56db87..89fca42bd2b 100644 --- a/packages/shared/src/components/shell/ShellCluster.tsx +++ b/packages/shared/src/components/shell/ShellCluster.tsx @@ -132,18 +132,23 @@ export function ShellCluster({ const activeIndex = tabs.findIndex((tab) => tab.root === active); + // The finger is measured on screen while the bar is lifted (scaled), but + // the pill moves in the bar's own, unscaled pixels: map through the + // layout width so the pill stops at the right edge as it does at the left. const tabAt = (clientX: number) => { - const rect = trackRef.current?.getBoundingClientRect(); - if (!rect || !rect.width) { + const track = trackRef.current; + const rect = track?.getBoundingClientRect(); + if (!track || !rect || !rect.width) { return null; } - const width = rect.width / tabs.length; - const x = clientX - rect.left; + const trackWidth = track.offsetWidth || rect.width; + const width = trackWidth / tabs.length; + const x = ((clientX - rect.left) / rect.width) * trackWidth; return { - left: Math.min(Math.max(x - width / 2, 0), rect.width - width), + left: Math.min(Math.max(x - width / 2, 0), trackWidth - width), index: Math.min( tabs.length - 1, - Math.floor(clamp(x / rect.width) * tabs.length), + Math.floor(clamp(x / trackWidth) * tabs.length), ), }; }; diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 2c605ef23bc..880a8f191f5 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -40,6 +40,7 @@ import { useScrollbarWidth } from '@dailydotdev/shared/src/hooks/useScrollbarWid import { PushNotificationContextProvider } from '@dailydotdev/shared/src/contexts/PushNotificationContext'; import { SerwistProvider } from '@serwist/turbopack/react'; import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils'; +import { useIsLightTheme } from '@dailydotdev/shared/src/hooks/utils/useThemedAsset'; import { DndContextProvider } from '@dailydotdev/shared/src/contexts/DndContext'; import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone'; import { installDomMutationGuard } from '@dailydotdev/shared/src/lib/domMutationGuard'; @@ -279,6 +280,7 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { const { layoutProps } = Component as ComponentGetLayout; const { themeColor } = useThemedAsset(); + const isLightTheme = useIsLightTheme(); const seo = (pageProps?.seo || layoutProps?.seo) as Record; const showAppStoreBanner = !router.pathname.startsWith('/helloworld'); @@ -299,9 +301,10 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { content="initial-scale=1.0, width=device-width, viewport-fit=cover" /> + From ddf92419d44c4aae85ca1018f14d2f504325fa20 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 1 Oct 2026 16:34:11 +0300 Subject: [PATCH 13/55] fix(mobile): the swipe cone gates the fast path too, and review notes Chris's review: a fast diagonal flick that happened to lock sideways in its first 10px could still change the channel through the velocity path, which skipped the cone. The cone now gates both paths, with a spec for that flick; the swipe specs stamp event.timeStamp (react-swipeable's clock) so the distance and speed paths are each tested on their own. Also from the review: the swipe surface keeps pinch zoom (touch-pinch-zoom); the footer tabs log their own target (mobile footer nav) with whether the tap was a member's, so they separate from the app footer's events; the shell constants and stylesheet carry only what this PR consumes (the motion values come with their consumers in the shell PR), and the explanatory blocks moved to the PR description. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/shell/constants.ts | 15 +--- .../src/components/tabs/TabContainer.spec.tsx | 78 +++++++++++++------ .../src/components/tabs/TabContainer.tsx | 12 ++- packages/shared/src/lib/log.ts | 1 + packages/shared/src/styles/shell.css | 25 +----- .../webapp/__tests__/MobileFooterNavbar.tsx | 4 +- .../components/footer/MobileFooterNavbar.tsx | 6 +- 7 files changed, 69 insertions(+), 72 deletions(-) diff --git a/packages/shared/src/components/shell/constants.ts b/packages/shared/src/components/shell/constants.ts index 48d1c0a9927..e0950311a75 100644 --- a/packages/shared/src/components/shell/constants.ts +++ b/packages/shared/src/components/shell/constants.ts @@ -1,7 +1,4 @@ -// The numbers of the phone shell, from the Mobile UX review in Storybook -// (stories/mobile-ux/spec.ts, chapter 10). Components read them from here -// so no file carries its own copy. - +// The numbers of the phone shell (Mobile UX review, chapter 10). export const swipe = { lockDistance: 10, commitDistance: 56, @@ -9,13 +6,3 @@ export const swipe = { velocity: 0.3, velocityDistance: 32, }; - -export const motion = { - interaction: 'cubic-bezier(0.2, 0, 0, 1)', - travel: 'cubic-bezier(0.32, 0.72, 0, 1)', - feedback: 150, - enter: 300, - exit: 200, - snap: 220, - press: 0.96, -}; diff --git a/packages/shared/src/components/tabs/TabContainer.spec.tsx b/packages/shared/src/components/tabs/TabContainer.spec.tsx index 29b9cffea42..6bfbd46df0a 100644 --- a/packages/shared/src/components/tabs/TabContainer.spec.tsx +++ b/packages/shared/src/components/tabs/TabContainer.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { RenderResult } from '@testing-library/react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, createEvent } from '@testing-library/react'; import nock from 'nock'; import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; @@ -208,9 +208,23 @@ describe('tab container component', () => { }); describe('swipeable tab container', () => { - const touch = (x: number, y: number) => ({ - touches: [{ clientX: x, clientY: y }], - }); + // react-swipeable reads event.timeStamp, which jsdom fills with real + // time; stamping it puts the velocity under the test's control. + const stamped = ( + type: 'touchStart' | 'touchMove' | 'touchEnd', + element: HTMLElement, + x: number, + y: number, + at: number, + ) => { + const init = + type === 'touchEnd' + ? { changedTouches: [] } + : { touches: [{ clientX: x, clientY: y }] }; + const event = createEvent[type](element, init); + Object.defineProperty(event, 'timeStamp', { value: at }); + fireEvent(element, event); + }; const swipeOn = ( element: HTMLElement, @@ -218,10 +232,18 @@ describe('swipeable tab container', () => { durationMs: number, ) => { const [start, ...rest] = points; - fireEvent.touchStart(element, touch(...start)); - rest.forEach((point) => fireEvent.touchMove(element, touch(...point))); - jest.advanceTimersByTime(durationMs); - fireEvent.touchEnd(element, { changedTouches: [] }); + stamped('touchStart', element, start[0], start[1], 1000); + rest.forEach(([x, y], index) => + stamped( + 'touchMove', + element, + x, + y, + 1000 + (durationMs * (index + 1)) / rest.length, + ), + ); + const [lastX, lastY] = points[points.length - 1]; + stamped('touchEnd', element, lastX, lastY, 1000 + durationMs); }; // A Tab without a className renders its children straight into the swipe @@ -231,14 +253,6 @@ describe('swipeable tab container', () => { return screen.getByText('Sample'); }; - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - it('ignores a scroll that drifts sideways past the old threshold', () => { const surface = mountSwipeable(); @@ -257,7 +271,7 @@ describe('swipeable tab container', () => { expect(onActiveClick).not.toHaveBeenCalled(); }); - it('moves to the next tab on a horizontal swipe inside the cone', () => { + it('moves to the next tab on a slow swipe past the commit distance', () => { const surface = mountSwipeable(); swipeOn( @@ -265,19 +279,17 @@ describe('swipeable tab container', () => { [ [200, 100], [188, 101], - [150, 104], - [130, 108], + [160, 104], + [136, 108], ], - 400, + 600, ); expect(screen.getByText('Test')).toBeInTheDocument(); expect(onActiveClick).toHaveBeenCalledWith('Second', undefined); }); - // jsdom stamps events with real time, so a drag here is always fast; the - // nudge stays under the distance the fast path needs too. - it('does nothing on a horizontal nudge that stops short', () => { + it('does nothing on a slow horizontal nudge that stops short', () => { const surface = mountSwipeable(); swipeOn( @@ -285,7 +297,7 @@ describe('swipeable tab container', () => { [ [200, 100], [188, 100], - [170, 100], + [160, 100], ], 600, ); @@ -313,4 +325,22 @@ describe('swipeable tab container', () => { expect(screen.getByText('Sample')).toBeInTheDocument(); expect(onActiveClick).toHaveBeenCalledWith('First', undefined); }); + + it('ignores a fast diagonal flick that locked sideways', () => { + const surface = mountSwipeable(); + + swipeOn( + surface, + [ + [100, 100], + [111, 108], + [125, 150], + [140, 190], + ], + 100, + ); + + expect(screen.getByText('Sample')).toBeInTheDocument(); + expect(onActiveClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/shared/src/components/tabs/TabContainer.tsx b/packages/shared/src/components/tabs/TabContainer.tsx index a6002b1f963..f284abfd847 100644 --- a/packages/shared/src/components/tabs/TabContainer.tsx +++ b/packages/shared/src/components/tabs/TabContainer.tsx @@ -164,10 +164,8 @@ export function TabContainer({ [tabs, currentActive, labels, navigateToUrl, onActiveChange], ); - // The axis is decided once, on the first movement past the lock distance: - // a gesture that starts vertical is a scroll and is ignored to the end, - // whatever it does afterwards. A horizontal one changes the tab only when - // it travels far enough inside the cone, or fast enough. + // The axis locks on the first movement; only a horizontal gesture that + // stays inside the cone can change the tab, by distance or by speed. const swipeAxis = useRef<'x' | 'y' | null>(null); const swipeHandlers = useSwipeable({ onSwipeStart: () => { @@ -189,11 +187,11 @@ export function TabContainer({ } const inCone = absX > swipe.coneRatio * absY; - const farEnough = absX > swipe.commitDistance && inCone; + const farEnough = absX > swipe.commitDistance; const fastEnough = velocity > swipe.velocity && absX > swipe.velocityDistance; - if (!farEnough && !fastEnough) { + if (!inCone || (!farEnough && !fastEnough)) { return; } @@ -280,7 +278,7 @@ export function TabContainer({
    {render}
    diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index e1053d04eab..fea25de681b 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -721,6 +721,7 @@ export enum TargetId { ReaderInstallPrompt = 'reader install prompt', ReaderPermissionPrompt = 'reader permission prompt', MobileFooter = 'mobile footer', + MobileFooterNav = 'mobile footer nav', MobileSheet = 'mobile sheet', } diff --git a/packages/shared/src/styles/shell.css b/packages/shared/src/styles/shell.css index 49bcb0fb2cf..27aa4280744 100644 --- a/packages/shared/src/styles/shell.css +++ b/packages/shared/src/styles/shell.css @@ -1,28 +1,9 @@ -/* - * The phone shell: motion tokens and press feedback (Mobile UX review, - * chapters 9l and 10). Two curves for the whole shell: interaction for what - * the finger drives or toggles, travel for what crosses the screen. - */ -:root { - --shell-ease-interaction: cubic-bezier(0.2, 0, 0, 1); - --shell-ease-travel: cubic-bezier(0.32, 0.72, 0, 1); - --shell-duration-feedback: 150ms; - --shell-duration-enter: 300ms; - --shell-duration-exit: 200ms; - --shell-duration-snap: 220ms; -} - -/* - * Press feedback for tappable chrome: the control scales to 0.96 under the - * finger and eases back on release. `scale` is listed beside the button - * transitions so a `.btn` keeps its colour transitions. - */ +/* Press feedback for the phone shell's tappable chrome. */ .shell-press { touch-action: manipulation; -webkit-tap-highlight-color: transparent; - transition: var(--shell-duration-feedback) ease-out; - transition-property: border-color, background-color, box-shadow, color, - scale; + transition: 150ms ease-out; + transition-property: border-color, background-color, box-shadow, color, scale; } .shell-press:active { diff --git a/packages/webapp/__tests__/MobileFooterNavbar.tsx b/packages/webapp/__tests__/MobileFooterNavbar.tsx index d50547325dc..a65a25e7eb9 100644 --- a/packages/webapp/__tests__/MobileFooterNavbar.tsx +++ b/packages/webapp/__tests__/MobileFooterNavbar.tsx @@ -53,8 +53,8 @@ describe('MobileFooterNavbar', () => { expect(mockLogEvent).toHaveBeenCalledWith({ event_name: 'click', - target_id: 'mobile footer', - extra: JSON.stringify({ tab: 'explore' }), + target_id: 'mobile footer nav', + extra: JSON.stringify({ tab: 'explore', logged_in: true }), }); }); diff --git a/packages/webapp/components/footer/MobileFooterNavbar.tsx b/packages/webapp/components/footer/MobileFooterNavbar.tsx index 19d143e7f63..b102dd0a7a2 100644 --- a/packages/webapp/components/footer/MobileFooterNavbar.tsx +++ b/packages/webapp/components/footer/MobileFooterNavbar.tsx @@ -71,8 +71,8 @@ const MobileFooterNavbar = (): ReactElement => { const logTabClick = (tab: string) => logEvent({ event_name: LogEvent.Click, - target_id: TargetId.MobileFooter, - extra: JSON.stringify({ tab }), + target_id: TargetId.MobileFooterNav, + extra: JSON.stringify({ tab, logged_in: !!user }), }); return [ @@ -124,7 +124,7 @@ const MobileFooterNavbar = (): ReactElement => { onClick: () => logTabClick('squads'), }, ]; - }, [logEvent, squadsUrl, unreadCount]); + }, [logEvent, squadsUrl, unreadCount, user]); const activeTab = useMemo(() => { const tabTitles = new Set( From 070289ef3756ee64a17a3a7d8818a5f2aca69b2d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 2 Oct 2026 11:27:00 +0300 Subject: [PATCH 14/55] feat(mobile): step 1 complete, the menus as the plan drew them The post menu on a phone is one sheet with two levels: Share, Read it later, Follow the source, Not interested and Report on the first, then the owner's rows with Delete in the error colour, then More. Not interested gathers hide, block source, block author, block tags and the content type; More holds the rest. The sub-level slides in over the first and its title row carries the back chevron; swipe down or the scrim closes the whole sheet from either level. Options are grouped by a stable id, so every handler and event is the one it was; desktop keeps the flat menu. One squad menu serves the page and the card: SquadOptionsMenu gains Share and Notifications rows and a styleable trigger, the card wraps it in the page context it reads, and the card-only SquadHeaderMenu goes. Copy link lives inside Share on the tag and source pages. Report post, user and comment, Add to custom feed, Move bookmark, Bookmark folder and Bookmarks sharing open as sheets on phones, and sheets drop their bottom Close button. The block's last Float buttons (profile menu, squad search and menu, Bookmarks sort and share) are the 38px squares. The bar publishes --shell-bottom and the footer spacer reads it. Co-Authored-By: Claude Fable 5.1 --- .../src/components/BookmarkFeedLayout.tsx | 13 +- .../cards/entity/SquadEntityCard.tsx | 20 +- .../shared/src/components/drawers/Drawer.tsx | 2 +- .../shared/src/components/dropdown/common.tsx | 2 + .../modals/SharedBookmarksModal.tsx | 11 +- .../modals/bookmark/BookmarkFolderModal.tsx | 1 + .../modals/bookmark/MoveBookmarkModal.tsx | 2 +- .../modals/feed/AddToCustomFeedModal.tsx | 2 +- .../modals/report/ReportCommentModal.tsx | 1 + .../modals/report/ReportPostModal.tsx | 1 + .../modals/report/ReportUserModal.tsx | 1 + .../shared/src/components/profile/Header.tsx | 6 + .../src/components/shell/ShellCluster.tsx | 13 +- .../squads/SquadHeaderMenu.spec.tsx | 134 ----------- .../src/components/squads/SquadHeaderMenu.tsx | 213 ------------------ .../src/components/tags/TagTopicPage.tsx | 9 +- .../src/features/posts/PostOptionButton.tsx | 37 ++- .../src/features/posts/PostOptionsSheet.tsx | 155 +++++++++++++ .../features/posts/postOptionGroups.spec.ts | 67 ++++++ .../src/features/posts/postOptionGroups.ts | 44 ++++ .../squads/components/header/SquadActions.tsx | 7 +- .../components/header/SquadOptionsMenu.tsx | 55 ++++- .../components/layouts/FooterNavBarLayout.tsx | 9 +- packages/webapp/pages/sources/[source].tsx | 7 +- 24 files changed, 434 insertions(+), 378 deletions(-) delete mode 100644 packages/shared/src/components/squads/SquadHeaderMenu.spec.tsx delete mode 100644 packages/shared/src/components/squads/SquadHeaderMenu.tsx create mode 100644 packages/shared/src/features/posts/PostOptionsSheet.tsx create mode 100644 packages/shared/src/features/posts/postOptionGroups.spec.ts create mode 100644 packages/shared/src/features/posts/postOptionGroups.ts diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index 1b6b39f5af3..1e8ccb8f8eb 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -199,12 +199,17 @@ 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 && ( setSelectedSort(index)} - buttonVariant={isV2Laptop ? ButtonVariant.Tertiary : ButtonVariant.Float} + buttonVariant={ + isV2Laptop || isPhone ? ButtonVariant.Tertiary : ButtonVariant.Float + } buttonSize={isV2Laptop ? ButtonSize.Small : ButtonSize.Medium} drawerProps={{ displayCloseButton: true }} /> @@ -221,7 +228,7 @@ export default function BookmarkFeedLayout({ const shareButton = !isFolderPage && ( - ))} -
    - ), -})); - -const mockedDeleteSquad = jest.mocked(deleteSquad); - -describe('SquadHeaderMenu', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('keeps the delete prompt visible while the delete request is pending', async () => { - const squad = generateTestSquad({ - currentMember: { - ...generateTestSquad().currentMember!, - permissions: [SourcePermissions.Delete], - }, - }); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - queryClient.setQueryData(BOOT_QUERY_KEY, { squads: [squad] } as Boot); - mockedDeleteSquad.mockReturnValue(new Promise(() => undefined)); - - render( - - - - , - ); - await waitFor(() => - expect(queryClient.getQueryState(PROMPT_KEY)?.fetchStatus).toBe('idle'), - ); - - await userEvent.click(screen.getByRole('button', { name: 'Delete Squad' })); - await userEvent.click( - await screen.findByRole('button', { name: 'Yes, delete Squad' }), - ); - - const promptButton = screen.getByRole('button', { - name: 'Yes, delete Squad', - }); - expect(promptButton).toHaveAttribute('aria-busy', 'true'); - expect(promptButton).toBeDisabled(); - expect(screen.getByText(`Delete ${squad.name}`)).toBeInTheDocument(); - }); -}); diff --git a/packages/shared/src/components/squads/SquadHeaderMenu.tsx b/packages/shared/src/components/squads/SquadHeaderMenu.tsx deleted file mode 100644 index a35013856be..00000000000 --- a/packages/shared/src/components/squads/SquadHeaderMenu.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import { useRouter } from 'next/router'; -import { useMutation } from '@tanstack/react-query'; -import classNames from 'classnames'; -import type { Squad } from '../../graphql/sources'; -import { SourcePermissions, SourceMemberRole } from '../../graphql/sources'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { LazyModal } from '../modals/common/types'; -import { useDeleteSquad } from '../../hooks/useDeleteSquad'; -import { useLeaveSquad, useSquadNavigation } from '../../hooks'; -import { verifyPermission } from '../../graphql/squads'; -import { - SettingsIcon, - TrashIcon, - FeedbackIcon, - TourIcon, - LinkIcon, - ExitIcon, - FlagIcon, - HashtagIcon, - MenuIcon, -} from '../icons'; -import { squadFeedback } from '../../lib/constants'; -import { useSquadInvitation } from '../../hooks/useSquadInvitation'; -import { Origin } from '../../lib/log'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { ContentPreferenceType } from '../../graphql/contentPreference'; -import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; -import type { IconProps } from '../Icon'; -import { IconSize } from '../Icon'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuOptions, - DropdownMenuTrigger, -} from '../dropdown/DropdownMenu'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import type { MenuItemProps } from '../dropdown/common'; - -const IconWrapper = ({ - Icon, -}: { - Icon: (props: IconProps) => ReactElement; -}): ReactElement => ; - -interface SquadHeaderMenuProps { - className?: { - button?: string; - }; - squad: Squad; - showDeletion?: boolean; -} - -export default function SquadHeaderMenu({ - squad, - className, - showDeletion = true, -}: SquadHeaderMenuProps): ReactElement { - const { isLoggedIn } = useAuthContext(); - const { logAndCopyLink } = useSquadInvitation({ - squad, - origin: Origin.SquadPage, - }); - const router = useRouter(); - const { openModal } = useLazyModal(); - const { editSquad } = useSquadNavigation(); - const { follow, unfollow } = useContentPreference(); - - const { onDeleteSquad } = useDeleteSquad({ - squad, - callback: () => router.replace('/'), - }); - - const { mutateAsync: onLeaveSquad } = useMutation({ - mutationFn: useLeaveSquad({ squad }), - onSuccess: (left) => { - if (!left) { - return; - } - - router.replace('/'); - }, - }); - - const items = useMemo(() => { - const canEditSquad = verifyPermission(squad, SourcePermissions.Edit); - const canDeleteSquad = - verifyPermission(squad, SourcePermissions.Delete) && showDeletion; - - const list: MenuItemProps[] = [ - { - icon: , - action: () => - openModal({ - type: LazyModal.AddToCustomFeed, - props: { - onAdd: (feedId) => - follow({ - id: squad.id, - entity: ContentPreferenceType.Source, - entityName: squad.handle, - feedId, - }), - onUndo: (feedId) => - unfollow({ - id: squad.id, - entity: ContentPreferenceType.Source, - entityName: squad.handle, - feedId, - }), - onCreateNewFeed: () => - router.push( - `/feeds/new?entityId=${squad.id}&entityType=${ContentPreferenceType.Source}`, - ), - }, - }), - label: 'Add to custom feed', - }, - ]; - - if (canEditSquad) { - list.push({ - icon: , - action: () => editSquad({ handle: squad.handle }), - label: 'Squad settings', - }); - } - - if (!squad.currentMember && squad.public && isLoggedIn) { - list.push({ - icon: , - action: () => logAndCopyLink(), - label: 'Invitation link', - }); - } - - list.push({ - icon: , - action: () => openModal({ type: LazyModal.SquadTour }), - label: 'Learn how Squads work', - }); - - if (squad.currentMember) { - list.push({ - icon: , - anchorProps: { - href: `${squadFeedback}#user_id=${squad?.currentMember?.user?.id}&squad_id=${squad.id}`, - target: '_blank', - }, - label: 'Feedback', - }); - } - - list.push({ - icon: , - action: () => - openModal({ type: LazyModal.ReportSource, props: { squad } }), - label: 'Report Squad', - }); - - if (canDeleteSquad) { - list.push({ - icon: , - action: onDeleteSquad, - label: 'Delete Squad', - }); - } - - if ( - squad.currentMember && - squad.currentMember.role !== SourceMemberRole.Admin - ) { - list.push({ - icon: , - action: () => { - onLeaveSquad({}); - }, - label: 'Leave Squad', - }); - } - - return list; - }, [ - editSquad, - onDeleteSquad, - logAndCopyLink, - onLeaveSquad, - openModal, - squad, - isLoggedIn, - follow, - unfollow, - router, - showDeletion, - ]); - - return ( - - - )} - + {!isPhone && ( + + )} push( diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index 6210c59b70e..03478af987d 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -3,6 +3,8 @@ import type { ReactElement } from 'react'; import { useRouter } from 'next/router'; import classNames from 'classnames'; import { useQueryClient } from '@tanstack/react-query'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { PostOptionsSheet } from './PostOptionsSheet'; import { AddUserIcon, BellAddIcon, @@ -445,6 +447,8 @@ const PostOptionButtonContent = ({ ); }; + const isPhone = useViewSize(ViewSize.MobileL); + const contentTypeItem = useFeedContentTypeAction({ post, customFeedId, @@ -455,6 +459,7 @@ const PostOptionButtonContent = ({ const postOptions: MenuItemProps[] = [ { icon: , + id: 'share', label: 'Share via', action: () => openSharePost({ @@ -467,6 +472,7 @@ const PostOptionButtonContent = ({ if (canViewPostAnalytics({ user, post })) { postOptions.push({ icon: , + id: 'analytics', label: 'Post analytics', anchorProps: { href: `${webappUrl}posts/${post.id}/analytics`, @@ -477,12 +483,14 @@ const PostOptionButtonContent = ({ if (!isBriefPost) { postOptions.push({ icon: , + id: 'hide', label: 'Hide', action: onHide, }); postOptions.push({ icon: , + id: 'report', label: 'Report', action: async () => openModal({ @@ -501,6 +509,7 @@ const PostOptionButtonContent = ({ if (isBriefPost) { postOptions.push({ icon: , + id: 'settings', label: 'Settings', action: () => { router?.push(`${settingsUrl}/notifications`); @@ -538,6 +547,7 @@ const PostOptionButtonContent = ({ secondary={isBoosted} /> ), + id: 'boost', label: isBoosted ? 'Manage ad' : 'Boost post', action: isBoosted ? onManageBoost : onBoostPost, }); @@ -557,6 +567,7 @@ const PostOptionButtonContent = ({ secondary={post?.userState?.vote === UserVote.Down} /> ), + id: 'downvote', label: 'Downvote', action: onToggleDownvotePost, }); @@ -572,6 +583,7 @@ const PostOptionButtonContent = ({ icon: ( ), + id: 'later', label: hasPostReminder ? 'Edit reminder' : 'Read it later', action: () => { openModal({ @@ -583,6 +595,7 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , + id: 'translate', label: 'Translate', action: () => { if (isPlus) { @@ -614,6 +627,7 @@ const PostOptionButtonContent = ({ // Remove postOptions.push({ icon: , + id: 'remove-reminder', label: 'Remove reminder', action: () => { onRemoveReminder(post.id); @@ -624,6 +638,7 @@ const PostOptionButtonContent = ({ if (post?.bookmark) { postOptions.push({ icon: , + id: 'move', label: 'Move to...', action: () => { if (!isPlus) { @@ -666,6 +681,7 @@ const PostOptionButtonContent = ({ if (shouldShowSubscribe) { postOptions.push({ icon: , + id: 'follow-source', label: `${isFollowing ? 'Unfollow' : 'Follow'} ${source.name}`, action: toggleFollow, }); @@ -677,6 +693,7 @@ const PostOptionButtonContent = ({ Icon={haveNotificationsOn ? BellSubscribedIcon : BellAddIcon} /> ), + id: 'notify-source', label: haveNotificationsOn ? `Remove notifications from ${source.name}` : `Notify on new post from ${source.name}`, @@ -696,6 +713,7 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , + id: 'follow-author', label: `${isFollowingUser ? 'Unfollow' : 'Follow'} ${authorName}`, action: () => { const opts = { @@ -727,6 +745,7 @@ const PostOptionButtonContent = ({ if (!isBriefPost && source.name && !isSourceUserSource(source)) { postOptions.push({ icon: , + id: 'block-source', label: getBlockLabel(source.name, { isCustomFeed, isBlocked: isSourceBlocked, @@ -738,6 +757,7 @@ const PostOptionButtonContent = ({ if (author && authorName && author.id !== user?.id) { postOptions.push({ icon: , + id: 'block-author', label: getBlockLabel(authorName, { isCustomFeed, isBlocked: isBlockedAuthor, @@ -787,7 +807,7 @@ const PostOptionButtonContent = ({ } if (contentTypeItem) { - postOptions.push(contentTypeItem); + postOptions.push({ ...contentTypeItem, id: 'content-type' }); } if (!isBriefPost) { @@ -799,6 +819,7 @@ const PostOptionButtonContent = ({ } postOptions.push({ icon: , + id: 'block-tag', label: isBlocked ? `Unblock #${tag}` : `Block #${tag}`, action: () => onToggleTagBlock(tag, isBlocked), }); @@ -812,6 +833,7 @@ const PostOptionButtonContent = ({ ) { postOptions.push({ icon: , + id: 'edit', label: 'Edit post', action: () => { // Moderation items keep the dedicated page: it edits a pending @@ -833,6 +855,7 @@ const PostOptionButtonContent = ({ if (onConfirmDeletePost) { postOptions.push({ icon: , + id: 'delete', label: 'Delete post', action: onConfirmDeletePost, }); @@ -844,6 +867,7 @@ const PostOptionButtonContent = ({ icon: ( ), + id: 'backward', label: 'Send backward', action: () => onSwapPinnedPost({ swapWithId: nextPost.id }), }); @@ -854,6 +878,7 @@ const PostOptionButtonContent = ({ icon: ( ), + id: 'forward', label: 'Bring forward', action: () => onSwapPinnedPost({ swapWithId: prevPost.id }), }); @@ -863,6 +888,7 @@ const PostOptionButtonContent = ({ if (allowPin && onPinPost) { postOptions.unshift({ icon: , + id: 'pin', label: post.pinnedAt ? 'Unpin from top' : 'Pin to top', action: onPinPost, }); @@ -871,6 +897,7 @@ const PostOptionButtonContent = ({ if (isModerator) { postOptions.push({ icon: , + id: 'ban', label: 'Ban', action: banPostPrompt, }); @@ -879,6 +906,7 @@ const PostOptionButtonContent = ({ const promoteFlag = post.flags?.promoteToPublic; postOptions.push({ icon: , + id: 'promote', label: promoteFlag ? 'Demote' : 'Promote', action: promotePostPrompt, }); @@ -888,6 +916,7 @@ const PostOptionButtonContent = ({ const isClickbait = post.clickbaitTitleDetected; postOptions.push({ icon: , + id: 'clickbait', label: isClickbait ? 'Remove clickbait' : 'Mark as clickbait', action: clickbaitPostPrompt, }); @@ -896,7 +925,11 @@ const PostOptionButtonContent = ({ return ( - + {isPhone ? ( + + ) : ( + + )} ); }; diff --git a/packages/shared/src/features/posts/PostOptionsSheet.tsx b/packages/shared/src/features/posts/PostOptionsSheet.tsx new file mode 100644 index 00000000000..fd119878af6 --- /dev/null +++ b/packages/shared/src/features/posts/PostOptionsSheet.tsx @@ -0,0 +1,155 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { MenuItemProps } from '../../components/dropdown/common'; +import { + DropdownMenuItem, + DropdownMenuOptions, +} from '../../components/dropdown/DropdownMenu'; +import { + ArrowIcon, + BlockIcon, + MenuIcon as DotsIcon, +} from '../../components/icons'; +import { MenuIcon } from '../../components/MenuIcon'; +import { IconSize } from '../../components/Icon'; +import { motion } from '../../components/shell/constants'; +import { groupPostOptions } from './postOptionGroups'; + +type Level = 'root' | 'not-interested' | 'more'; + +const Divider = (): ReactElement => ( +
    +); + +// A row that opens a sub-level instead of closing the menu. +const LevelRow = ({ + icon, + label, + meta, + onOpen, +}: { + icon: ReactElement; + label: string; + meta?: string; + onOpen: () => void; +}): ReactElement => ( + { + event.preventDefault(); + onOpen(); + }} + > + + +); + +const destructive = (option: MenuItemProps): MenuItemProps => + option.id === 'delete' + ? { + ...option, + Wrapper: ({ children }) => ( + {children} + ), + } + : option; + +// The post menu on a phone: one sheet with two levels. The sub-level slides +// in from the right over the first; its title row carries the back chevron +// that slides the first level back. Swipe down or the scrim closes the +// whole sheet from either level. +export const PostOptionsSheet = ({ + options, +}: { + options: MenuItemProps[]; +}): ReactElement => { + const [level, setLevel] = useState('root'); + const { primary, notInterested, owner, more } = groupPostOptions(options); + const sub = level === 'not-interested' ? notInterested : more; + const subTitle = level === 'not-interested' ? 'Not interested in' : 'More'; + const firstLevel = [...primary.filter((option) => option.id !== 'report')]; + const report = primary.find((option) => option.id === 'report'); + + return ( +
    +
    +
    + + {notInterested.length > 0 && ( + } + label="Not interested" + onOpen={() => setLevel('not-interested')} + /> + )} + {report && } + {owner.length > 0 && ( + <> + + + + )} + {more.length > 0 && ( + <> + + } + label="More" + meta={more + .slice(0, 3) + .map((option) => option.label) + .join(', ')} + onOpen={() => setLevel('more')} + /> + + )} +
    +
    + { + event.preventDefault(); + setLevel('root'); + }} + className={classNames('font-bold')} + > + + + + +
    +
    +
    + ); +}; + +export default PostOptionsSheet; diff --git a/packages/shared/src/features/posts/postOptionGroups.spec.ts b/packages/shared/src/features/posts/postOptionGroups.spec.ts new file mode 100644 index 00000000000..822f15b7b8a --- /dev/null +++ b/packages/shared/src/features/posts/postOptionGroups.spec.ts @@ -0,0 +1,67 @@ +import type { MenuItemProps } from '../../components/dropdown/common'; +import { groupPostOptions } from './postOptionGroups'; + +const option = (id: string): MenuItemProps => ({ id, label: id }); + +describe('groupPostOptions', () => { + it('puts the five first-level rows in their order and the rest behind Not interested and More', () => { + const groups = groupPostOptions( + [ + 'analytics', + 'hide', + 'report', + 'boost', + 'downvote', + 'later', + 'translate', + 'follow-source', + 'notify-source', + 'follow-author', + 'block-source', + 'block-author', + 'content-type', + 'block-tag', + 'edit', + 'delete', + 'pin', + 'share', + ].map(option), + ); + + expect(groups.primary.map((o) => o.id)).toEqual([ + 'share', + 'later', + 'follow-source', + 'report', + ]); + expect(groups.notInterested.map((o) => o.id)).toEqual([ + 'hide', + 'block-source', + 'block-author', + 'content-type', + 'block-tag', + ]); + expect(groups.owner.map((o) => o.id)).toEqual([ + 'edit', + 'delete', + 'analytics', + 'boost', + 'pin', + ]); + expect(groups.more.map((o) => o.id)).toEqual([ + 'downvote', + 'translate', + 'notify-source', + 'follow-author', + ]); + }); + + it('keeps an option without an id in More', () => { + const groups = groupPostOptions([ + { label: 'Something new' }, + option('share'), + ]); + + expect(groups.more.map((o) => o.label)).toEqual(['Something new']); + }); +}); diff --git a/packages/shared/src/features/posts/postOptionGroups.ts b/packages/shared/src/features/posts/postOptionGroups.ts new file mode 100644 index 00000000000..ff427908230 --- /dev/null +++ b/packages/shared/src/features/posts/postOptionGroups.ts @@ -0,0 +1,44 @@ +import type { MenuItemProps } from '../../components/dropdown/common'; + +export interface PostOptionGroups { + primary: MenuItemProps[]; + notInterested: MenuItemProps[]; + owner: MenuItemProps[]; + more: MenuItemProps[]; +} + +const primaryOrder = ['share', 'later', 'follow-source', 'report']; +const notInterestedIds = [ + 'hide', + 'block-source', + 'block-author', + 'block-tag', + 'content-type', +]; +const ownerOrder = ['edit', 'delete', 'analytics', 'boost', 'pin']; + +const sortBy = (ids: string[]) => (a: MenuItemProps, b: MenuItemProps) => + ids.indexOf(a.id ?? '') - ids.indexOf(b.id ?? ''); + +// The phone's post menu: seven rows at most on the first level (Share, +// Read it later, Follow the source, Not interested, Report), then the +// owner's rows, then More for everything else; Not interested gathers +// every way of seeing less of this (chapter 4b). Options keep their +// handlers; only the grouping is decided here, by id. +export const groupPostOptions = ( + options: MenuItemProps[], +): PostOptionGroups => { + const primary = options + .filter((option) => primaryOrder.includes(option.id ?? '')) + .sort(sortBy(primaryOrder)); + const notInterested = options.filter((option) => + notInterestedIds.includes(option.id ?? ''), + ); + const owner = options + .filter((option) => ownerOrder.includes(option.id ?? '')) + .sort(sortBy(ownerOrder)); + const placed = new Set([...primary, ...notInterested, ...owner]); + const more = options.filter((option) => !placed.has(option)); + + return { primary, notInterested, owner, more }; +}; diff --git a/packages/shared/src/features/squads/components/header/SquadActions.tsx b/packages/shared/src/features/squads/components/header/SquadActions.tsx index 30849405d82..94a62e3b75a 100644 --- a/packages/shared/src/features/squads/components/header/SquadActions.tsx +++ b/packages/shared/src/features/squads/components/header/SquadActions.tsx @@ -197,7 +197,12 @@ export const SquadBlockActions = (): ReactElement => { > - {!isMobileAppHeader && } + {!isMobileAppHeader && ( + + )} ); }; diff --git a/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx b/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx index 8cde9b7a826..5ce2a1d5630 100644 --- a/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx +++ b/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx @@ -17,10 +17,11 @@ import { useGetSquadAwardAdmin } from '../../../../hooks/useCoresFeature'; import { useAuthContext } from '../../../../contexts/AuthContext'; import { ContentPreferenceType } from '../../../../graphql/contentPreference'; import { squadFeedback } from '../../../../lib/constants'; -import { Origin } from '../../../../lib/log'; +import { LogEvent, Origin } from '../../../../lib/log'; import type { LoggedUser } from '../../../../lib/user'; import { AnalyticsIcon, + BellIcon, ExitIcon, FeedbackIcon, FlagIcon, @@ -29,6 +30,7 @@ import { MedalBadgeIcon, MenuIcon, SettingsIcon, + ShareIcon, TimerIcon, TourIcon, UserIcon, @@ -49,6 +51,9 @@ import { } from '../../../../components/buttons/Button'; import Link from '../../../../components/utilities/Link'; import { useSquadPageContext } from '../../SquadPageContext'; +import { useShareOrCopyLink } from '../../../../hooks/useShareOrCopyLink'; +import { ReferralCampaignKey } from '../../../../lib/referral'; +import { getSquadShareText } from '../widgets/SquadShareWidget'; import { isJoinedViewer, isStaffViewer, SquadViewer } from '../../lib/viewer'; import { getSquadManageUrl, SquadManageSection } from '../../lib/routes'; import { getSquadId } from '../../lib/features'; @@ -100,7 +105,15 @@ const getManageEntries = (squad: Squad): ManageEntry[] => { return entries; }; -export const SquadOptionsMenu = (): ReactElement => { +export const SquadOptionsMenu = ({ + className, + variant = ButtonVariant.Subtle, + size = ButtonSize.Small, +}: { + className?: string; + variant?: ButtonVariant; + size?: ButtonSize; +} = {}): ReactElement => { const router = useRouter(); const { squad, viewer } = useSquadPageContext(); const { user, isLoggedIn } = useAuthContext(); @@ -111,10 +124,22 @@ export const SquadOptionsMenu = (): ReactElement => { origin: Origin.SquadPage, }); const awardAdmin = useGetSquadAwardAdmin({ sendingUser: user, squad }); + const [, onShare] = useShareOrCopyLink({ + link: squad.permalink, + text: getSquadShareText(squad), + cid: ReferralCampaignKey.ShareSource, + logObject: (provider) => ({ + event_name: LogEvent.ShareSource, + target_id: squad.id, + extra: JSON.stringify({ provider, origin: Origin.SquadPage }), + }), + }); const { mutateAsync: onLeaveSquad } = useMutation({ mutationFn: useLeaveSquad({ squad }), onSuccess: (left) => { - if (left) { + // From the squad's own page there is nothing left to show; from a + // card elsewhere the page stays. + if (left && router.pathname.startsWith('/squads/[handle]')) { router.replace('/'); } }, @@ -188,6 +213,24 @@ export const SquadOptionsMenu = (): ReactElement => { }); } + list.unshift({ + icon: , + label: 'Share', + action: () => onShare(), + }); + + if (isJoined) { + list.splice(1, 0, { + icon: , + label: 'Notifications', + action: () => + openModal({ + type: LazyModal.SquadNotifications, + props: { squad }, + }), + }); + } + list.push({ icon: , label: 'Learn how Squads work', @@ -228,6 +271,7 @@ export const SquadOptionsMenu = (): ReactElement => { isLoggedIn, logAndCopyLink, onLeaveSquad, + onShare, openModal, router, squad, @@ -242,8 +286,9 @@ export const SquadOptionsMenu = (): ReactElement => { asChild > + + + + + + , + ); + + 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/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/post/collection/CollectionPillSources.tsx b/packages/shared/src/components/post/collection/CollectionPillSources.tsx index 5f4a0bc7d90..24521ad0925 100644 --- a/packages/shared/src/components/post/collection/CollectionPillSources.tsx +++ b/packages/shared/src/components/post/collection/CollectionPillSources.tsx @@ -46,6 +46,7 @@ export const CollectionPillSources = ({ })} total={totalSources} size={size} + rounded="full" limit={limit} > {sources.map((source) => ( diff --git a/packages/shared/src/components/post/freeform/write/WritePostHeader.tsx b/packages/shared/src/components/post/freeform/write/WritePostHeader.tsx index 5bee70a90a2..d66f1196d19 100644 --- a/packages/shared/src/components/post/freeform/write/WritePostHeader.tsx +++ b/packages/shared/src/components/post/freeform/write/WritePostHeader.tsx @@ -15,7 +15,9 @@ export function WritePostHeader({ return (
    -

    {isEdit ? 'Edit' : 'New'} post

    +

    + {isEdit ? 'Edit' : 'New'} post +

    {squad && squad.type === SourceType.Squad && ( <> diff --git a/packages/shared/src/components/shell/ShellBlock.spec.tsx b/packages/shared/src/components/shell/ShellBlock.spec.tsx index c249c3037c8..b6b0a9ab678 100644 --- a/packages/shared/src/components/shell/ShellBlock.spec.tsx +++ b/packages/shared/src/components/shell/ShellBlock.spec.tsx @@ -132,5 +132,33 @@ describe('ShellBlock', () => { expect(header.style.transform).toContain('* 1)'); expect(header).toHaveAttribute('aria-hidden', 'true'); + expect(document.documentElement).toHaveClass('shell-edge'); + + act(() => { + scrollTo(300); + jest.advanceTimersByTime(scroll.stop); + }); + + expect(document.documentElement).not.toHaveClass('shell-edge'); + }); + + it('carries the offline strip while the network is down', () => { + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: false, + }); + renderBlock(); + + expect(screen.getByRole('status')).toHaveTextContent("You're offline"); + + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true, + }); + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); }); diff --git a/packages/shared/src/components/shell/ShellBlock.tsx b/packages/shared/src/components/shell/ShellBlock.tsx index 6bda5f313c2..74eeb4f9a26 100644 --- a/packages/shared/src/components/shell/ShellBlock.tsx +++ b/packages/shared/src/components/shell/ShellBlock.tsx @@ -20,6 +20,7 @@ import { plusUrl, webappUrl } from '../../lib/constants'; import { ShellSquare } from './ShellSquare'; import { motion, topButton } from './constants'; import { revealShell, useShellScroll } from './useShellScroll'; +import { useOnline } from './useOnline'; import { useShellActionsSlot, useShellPageConfig } from './ShellPageContext'; import { ShellRoot, useShellBack } from './shellNav'; @@ -171,7 +172,9 @@ export function ShellBlock({ const router = useRouter(); const config = useShellPageConfig(); const { p, snapping } = useShellScroll(); + const online = useOnline(); const ref = useRef(null); + const hidden = !config?.hidden && p >= 0.99; useEffect(() => { const element = ref.current; @@ -193,7 +196,14 @@ export function ShellBlock({ observer.disconnect(); document.documentElement.style.removeProperty('--shell-top'); }; - }, [root, row, config?.hidden, config?.row]); + }, [root, row, config?.hidden, config?.row, online]); + + // Once the block is gone the status area turns into a soft scroll edge + // (safeArea.css paints it solid while the block stands under it). + useEffect(() => { + document.documentElement.classList.toggle('shell-edge', hidden); + return () => document.documentElement.classList.remove('shell-edge'); + }, [hidden]); // Arrival never hides the block, and a focused field keeps it in view. useEffect(() => { @@ -215,7 +225,6 @@ export function ShellBlock({ return null; } - const hidden = p >= 0.99; const duration = snapping ? motion.snap : motion.scrub; return ( @@ -232,6 +241,14 @@ export function ShellBlock({ transition: `transform ${duration}ms ${motion.interaction}`, }} > + {!online && ( +
    + You're offline +
    + )} {root ? : } {config?.row ?? row}
    diff --git a/packages/shared/src/components/shell/useOnline.ts b/packages/shared/src/components/shell/useOnline.ts new file mode 100644 index 00000000000..9ea61d20f10 --- /dev/null +++ b/packages/shared/src/components/shell/useOnline.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from 'react'; + +const subscribe = (listener: () => void) => { + window.addEventListener('online', listener); + window.addEventListener('offline', listener); + + return () => { + window.removeEventListener('online', listener); + window.removeEventListener('offline', listener); + }; +}; + +export const useOnline = (): boolean => + useSyncExternalStore( + subscribe, + () => navigator.onLine, + () => true, + ); diff --git a/packages/shared/src/components/spotlight/Spotlight.tsx b/packages/shared/src/components/spotlight/Spotlight.tsx index a9eb4c6bc44..17c7c3aa7eb 100644 --- a/packages/shared/src/components/spotlight/Spotlight.tsx +++ b/packages/shared/src/components/spotlight/Spotlight.tsx @@ -85,6 +85,14 @@ interface RowProps { const rowBaseClass = 'group/spotlight-row mx-2 flex min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-10 px-3 text-left aria-disabled:cursor-not-allowed aria-disabled:opacity-40 data-[selected=true]:bg-surface-hover'; +// People are rounded squares at ProfilePicture's radius for the size; +// sources keep the circle. +const avatarRadius = { + full: 'rounded-full', + '8': 'rounded-6', + person: 'rounded-8', +}; + const TypedAvatar = ({ src, alt, @@ -93,13 +101,13 @@ const TypedAvatar = ({ }: { src?: string; alt: string; - rounded: 'full' | '8'; + rounded: 'full' | '8' | 'person'; className?: string; }): ReactElement => ( @@ -192,7 +200,11 @@ const buildRowParts = ( case 'user': return { leading: ( - + ), body: ( ({ - onSubmitForm: null, + onSubmitForm: () => undefined, isPosting: false, squad: null, - post: null, enableUpload: false, - formRef: null, draft: {}, - updateDraft: null, - formId: null, }); export const useWritePostContext = (): WritePostProps => @@ -81,15 +77,19 @@ export const useWritePostContext = (): WritePostProps => interface WritePostContextProviderProps extends WritePostProps { rightCopy?: string; + // The page name the phone block shows beside its back button. + title?: string; } export const WritePostContextProvider = ({ children, formId, rightCopy, + title, ...props }: PropsWithChildren): ReactElement => { const isLaptop = useViewSize(ViewSize.Laptop); + const isPhone = useViewSize(ViewSize.MobileL); const router = useRouter(); return ( @@ -99,6 +99,8 @@ export const WritePostContextProvider = ({ wrapper={(component) => ( router.back() }} @@ -115,7 +117,7 @@ export const WritePostContextProvider = ({ ) : undefined } - form={formId} + form={formId ?? ''} > {component} diff --git a/packages/shared/src/styles/shell.css b/packages/shared/src/styles/shell.css index 97d7261abe9..5634075e60e 100644 --- a/packages/shared/src/styles/shell.css +++ b/packages/shared/src/styles/shell.css @@ -181,3 +181,16 @@ animation: none !important; } } + +/* + * The status edge: while the top block is hidden the status area stops + * being a solid frame and becomes a soft scroll edge (blur and fade), so + * content passes under the clock and the page reads as endless. + */ +:root.shell-edge body::before { + background: linear-gradient(var(--theme-background-default), transparent); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + -webkit-mask-image: linear-gradient(black 30%, transparent); + mask-image: linear-gradient(black 30%, transparent); +} diff --git a/packages/webapp/pages/posts/[id]/edit.tsx b/packages/webapp/pages/posts/[id]/edit.tsx index e45dcc3bd4a..a09503f783b 100644 --- a/packages/webapp/pages/posts/[id]/edit.tsx +++ b/packages/webapp/pages/posts/[id]/edit.tsx @@ -197,6 +197,7 @@ function EditPost(): ReactElement { onSubmitForm={onClickSubmit} formId={formId} schedule={canReschedule ? schedule : undefined} + title="Edit post" enableUpload > diff --git a/packages/webapp/pages/tools/[slug].tsx b/packages/webapp/pages/tools/[slug].tsx index 261b2cfbfad..3c4e2910745 100644 --- a/packages/webapp/pages/tools/[slug].tsx +++ b/packages/webapp/pages/tools/[slug].tsx @@ -749,7 +749,6 @@ const ToolPage = ({ Date: Fri, 2 Oct 2026 13:43:10 +0300 Subject: [PATCH 20/55] fix(mobile): the bar owns its touches and the menu sheet gets a scrim Checked in iOS Safari and in Chrome with a Pixel profile. A plain tap on the bar worked in both, but a held finger never dragged in Safari: Safari turns a long press on a link into its URL preview and cancels the pointer, and -webkit-touch-callout: none does not stop it. The track now prevents the default of touchstart, so nothing native starts from a touch, and a touch tap is resolved on release the way a drag is; a mouse click still goes through the link. The phone menu sheet had no scrim in either browser. The popper wrapper now draws the Drawer's scrim behind the sheet; Radix's will-change on the wrapper would have made it the scrim's containing block, so it is cleared, and a tap on the scrim is a tap outside the content, which closes the menu. Co-Authored-By: Claude Fable 5.1 --- .../components/shell/ShellCluster.spec.tsx | 58 +++++++++++++------ .../src/components/shell/ShellCluster.tsx | 53 ++++++++++++----- packages/shared/src/styles/shell.css | 52 ++++++++++++++++- 3 files changed, 131 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/components/shell/ShellCluster.spec.tsx b/packages/shared/src/components/shell/ShellCluster.spec.tsx index ac7cd2b56f1..53820706bd1 100644 --- a/packages/shared/src/components/shell/ShellCluster.spec.tsx +++ b/packages/shared/src/components/shell/ShellCluster.spec.tsx @@ -33,13 +33,31 @@ const user = { id: 'u1', username: 'ido' } as LoggedUser; // jsdom has no PointerEvent; a MouseEvent with the pointer fields set is // what React's pointer handlers read. -const firePointer = (type: string, element: Element, clientX: number) => { +const firePointer = ( + type: string, + element: Element, + clientX: number, + pointerType = 'touch', +) => { const event = new MouseEvent(type, { bubbles: true, clientX }); Object.defineProperty(event, 'pointerId', { value: 1 }); - Object.defineProperty(event, 'pointerType', { value: 'touch' }); + Object.defineProperty(event, 'pointerType', { value: pointerType }); fireEvent(element, event); }; +const mockTrackRect = () => + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 320, + top: 0, + height: 48, + right: 320, + bottom: 48, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + const renderCluster = ( pathname = '/', loggedUser: LoggedUser | null = user, @@ -83,19 +101,7 @@ describe('ShellCluster', () => { }); it('follows a held finger along the bar and selects the tab under it', () => { - const rect = jest - .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockReturnValue({ - left: 0, - width: 320, - top: 0, - height: 48, - right: 320, - bottom: 48, - x: 0, - y: 0, - toJSON: () => ({}), - } as DOMRect); + const rect = mockTrackRect(); renderCluster('/'); const home = screen.getByLabelText('Home'); const track = home.parentElement as HTMLElement; @@ -134,14 +140,30 @@ describe('ShellCluster', () => { expect(bar).toHaveStyle({ transform: 'scale(1)' }); }); - it('leaves a plain tap to the link', () => { + it('resolves a touch tap on release and swallows the click after it', () => { + const rect = mockTrackRect(); renderCluster('/'); const track = screen.getByLabelText('Home').parentElement as HTMLElement; - firePointer('pointerdown', track, 40); - firePointer('pointerup', track, 42); + firePointer('pointerdown', track, 120); + firePointer('pointerup', track, 122); + fireEvent.click(screen.getByLabelText('Explore')); + + expect(mockPush).toHaveBeenCalledTimes(1); + expect(mockPush).toHaveBeenCalledWith('/posts'); + rect.mockRestore(); + }); + + it('leaves a mouse tap to the link', () => { + const rect = mockTrackRect(); + renderCluster('/'); + const track = screen.getByLabelText('Home').parentElement as HTMLElement; + + firePointer('pointerdown', track, 120, 'mouse'); + firePointer('pointerup', track, 122, 'mouse'); expect(mockPush).not.toHaveBeenCalled(); + rect.mockRestore(); }); it('lights the root that owns the page', () => { diff --git a/packages/shared/src/components/shell/ShellCluster.tsx b/packages/shared/src/components/shell/ShellCluster.tsx index 18244709f4b..937701e5483 100644 --- a/packages/shared/src/components/shell/ShellCluster.tsx +++ b/packages/shared/src/components/shell/ShellCluster.tsx @@ -125,22 +125,48 @@ export function ShellCluster({ } }; - const onTabClick = (tab: ClusterTab) => (event: React.MouseEvent) => { + // What a tab does when chosen; the link's own navigation covers the + // remaining case (a mouse click on another root). + const activate = (tab: ClusterTab): boolean => { logTab(tab); if (!user && tab.requiresLogin) { - event.preventDefault(); showLogin({ trigger: tab.trigger ?? AuthTriggers.MainButton }); - return; + return true; } if (tab.root === active) { - event.preventDefault(); revealShell(); window.scrollTo({ top: 0, behavior: 'smooth' }); + return true; + } + + return false; + }; + + const onTabClick = (tab: ClusterTab) => (event: React.MouseEvent) => { + if (activate(tab)) { + event.preventDefault(); } }; + // iOS Safari turns a finger held on a link into a URL preview and cancels + // the pointer, so the bar owns its touches: nothing native starts from + // them, and a touch tap is resolved on release like a drag is. + useEffect(() => { + const track = trackRef.current; + if (!track) { + return undefined; + } + const onTouchStart = (event: TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + } + }; + track.addEventListener('touchstart', onTouchStart, { passive: false }); + return () => track.removeEventListener('touchstart', onTouchStart); + }, []); + const activeIndex = tabs.findIndex((tab) => tab.root === active); // The finger is measured on screen while the bar is lifted (scaled), but @@ -221,21 +247,21 @@ export function ShellCluster({ const { moved } = current; const target = tabAt(event.clientX); endDrag(); - if (!moved || !target) { + if (!target || (!moved && event.pointerType !== 'touch')) { return; } - // The browser fires a click for the release; the drag already chose. + // A click may still follow the release; the pointer already chose. suppressClick.current = true; + window.setTimeout(() => { + suppressClick.current = false; + }, motion.snap); const tab = tabs[target.index]; - if (tab.root === active) { + if (moved && tab.root === active) { return; } - logTab(tab); - if (!user && tab.requiresLogin) { - showLogin({ trigger: tab.trigger ?? AuthTriggers.MainButton }); - return; + if (!activate(tab)) { + router.push(tab.href); } - router.push(tab.href); }; const onClickCapture = (event: React.MouseEvent) => { @@ -285,13 +311,14 @@ export function ShellCluster({ >
    event.preventDefault()} > {/* The selected tab's pill: behind the lit tab at rest, under the finger while it is held and moved along the bar. */} diff --git a/packages/shared/src/styles/shell.css b/packages/shared/src/styles/shell.css index 5634075e60e..a1e7fe5c2c8 100644 --- a/packages/shared/src/styles/shell.css +++ b/packages/shared/src/styles/shell.css @@ -56,6 +56,16 @@ 0 4px 30px rgb(0 0 0 / 0.12); } +/* The bar's track is dragged along, so a held finger must stay a pointer: + no link callout or preview on a long press (iOS Safari cancels the touch + to show it) and nothing to select. */ +.shell-drag, +.shell-drag * { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + /* A 38px square the finger can hit at 44px; neighbours sit 8px apart so two hit areas never overlap. */ .shell-hit::after { @@ -96,17 +106,37 @@ * Menus on a phone: the Radix popover becomes a bottom sheet. The popper * wrapper is pinned to the bottom edge, the content takes the full width * with rounded top corners, and the open and close are the sheet's own - * timings. The scrim is drawn by the content so the menu stays one node. + * timings. The scrim is drawn by the popper wrapper so the menu stays one + * node; a tap on it is a tap outside the content, which closes the menu. */ @media (max-width: 655.98px) { [data-radix-popper-content-wrapper]:has(> .shell-menu-sheet) { position: fixed !important; inset: auto 0 0 0 !important; transform: none !important; + /* Radix's will-change would make the wrapper the scrim's containing + block and shrink it to the sheet. */ + will-change: auto !important; min-width: 0 !important; z-index: 100 !important; } + [data-radix-popper-content-wrapper]:has(> .shell-menu-sheet)::before { + content: ''; + position: fixed; + inset: 0; + /* overlay-quaternary-onion, the Drawer's scrim */ + background: rgb(98 74 211 / 0.24); + animation: shell-scrim-in var(--shell-duration-feedback) ease-out both; + } + + [data-radix-popper-content-wrapper]:has( + > .shell-menu-sheet[data-state='closed'] + )::before { + animation-name: shell-scrim-out; + animation-duration: var(--shell-duration-exit); + } + .shell-menu-sheet { position: relative; width: 100%; @@ -176,6 +206,26 @@ } } +@keyframes shell-scrim-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes shell-scrim-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { .shell-menu-sheet { animation: none !important; From c038018c035913d2213ebf84c7a34a57453efac1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 2 Oct 2026 13:45:44 +0300 Subject: [PATCH 21/55] fix(mobile): no focus ring on the sheet panel Safari draws its focus ring around the dialog when the sheet takes focus on open; the panel takes the Modal's focus:outline-none. Co-Authored-By: Claude Fable 5.1 --- packages/shared/src/components/drawers/Drawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/drawers/Drawer.tsx b/packages/shared/src/components/drawers/Drawer.tsx index c2b353fd489..4db054f0381 100644 --- a/packages/shared/src/components/drawers/Drawer.tsx +++ b/packages/shared/src/components/drawers/Drawer.tsx @@ -431,7 +431,7 @@ function BaseDrawer({ style={wrapperKeyboardStyle} data-closing={isClosing || undefined} className={classNames( - 'shell-sheet-panel drawer-padding absolute flex w-full flex-col overflow-y-auto overscroll-contain bg-background-default', + 'shell-sheet-panel drawer-padding absolute flex w-full flex-col overflow-y-auto overscroll-contain bg-background-default focus:outline-none', isFullScreen ? 'inset-0 h-[calc(var(--drawer-viewport-height)_-_var(--safe-area-top,0px))]' : 'max-h-[calc(100%-5rem)]', From d94ec247af1d7bb7ba32f986f757385460e3a2a8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 2 Oct 2026 13:56:27 +0300 Subject: [PATCH 22/55] fix(marketplace): the plugin pages take the phone block Main's marketplace pages render MobileFeedActions on phones, which this branch replaced with the shell block; the layout now registers each page's title with the block instead. Co-Authored-By: Claude Fable 5.1 --- .../components/marketplace/MarketplacePageLayout.tsx | 9 +++++---- packages/webapp/pages/marketplace/[id].tsx | 2 +- packages/webapp/pages/marketplace/index.tsx | 2 +- packages/webapp/pages/marketplace/submissions.tsx | 2 +- packages/webapp/pages/marketplace/submit.tsx | 5 ++++- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/webapp/components/marketplace/MarketplacePageLayout.tsx b/packages/webapp/components/marketplace/MarketplacePageLayout.tsx index 303671157a0..dd5b1f3303c 100644 --- a/packages/webapp/components/marketplace/MarketplacePageLayout.tsx +++ b/packages/webapp/components/marketplace/MarketplacePageLayout.tsx @@ -2,22 +2,23 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; -import { MobileFeedActions } from '@dailydotdev/shared/src/components/feeds/MobileFeedActions'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; import { MarketplaceFeatureGate } from './MarketplaceFeatureGate'; interface MarketplacePageLayoutProps { children: ReactNode; className?: string; + // The page name the phone block shows beside its back button. + title: string; } export const MarketplacePageLayout = ({ children, className, + title, }: MarketplacePageLayoutProps): ReactElement => ( -
    - -
    + diff --git a/packages/webapp/pages/marketplace/[id].tsx b/packages/webapp/pages/marketplace/[id].tsx index 1874fd7d602..1477e7756a6 100644 --- a/packages/webapp/pages/marketplace/[id].tsx +++ b/packages/webapp/pages/marketplace/[id].tsx @@ -83,7 +83,7 @@ const PluginPage = ({ plugin }: PluginPageProps): ReactElement => { const isAuthor = user?.id === plugin.author.id; return ( - +