diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index cf7f6dcd930..e87af9e1af4 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -85,6 +85,8 @@ import { isDevelopment, isProductionAPI, webappUrl } from '../lib/constants'; import { checkIsExtension } from '../lib/func'; import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; +import { ExploreSectionTabs } from './header/ExploreSectionTabs'; +import { ExploreSortDropdown } from './header/ExploreSortDropdown'; const FeedExploreHeader = dynamic( () => @@ -248,6 +250,7 @@ export default function MainFeedLayout({ isPopular, isAnyExplore, isExploreLatest, + isDiscussed, isSortableFeed, isCustomFeed, isSearch: isSearchPage, @@ -708,7 +711,10 @@ export default function MainFeedLayout({ // page-header strip (matching the SquadDirectoryLayout pattern). The // inline FeedExploreComponent is suppressed below to avoid showing // the same tabs twice. - const showExploreV2PageHeader = isAnyExplore && isV2; + // The Discussions feed (/discussed) is part of the Explore hub — show the + // same section tabs there so the hub persists. The Sort dropdown is only + // for the actual Explore sorts, so it stays gated on isAnyExplore. + const showExploreV2PageHeader = (isAnyExplore || isDiscussed) && isV2; // v2 also hoists the regular page-header strip up here, OUTSIDE // `FeedPageLayoutComponent`, so it can span the full floating-card @@ -747,13 +753,8 @@ export default function MainFeedLayout({ <> {showExploreV2PageHeader && (
- + + {isAnyExplore && }
)} {showFeedV2PageHeader && ( diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 81a7dc9b0ae..e3ab6d50043 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -38,6 +38,7 @@ import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; +import { useRecordRecentPages } from '../hooks/useRecentPages'; import { isSidebarSettingsPath } from './sidebar/sidebarCategory'; import { HomepageTopBanners, @@ -105,8 +106,15 @@ function MainLayoutComponent({ const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); - const { sidebarExpanded, autoDismissNotifications, loadedSettings } = + const { sidebarExpanded, autoDismissNotifications, loadedSettings, flags } = useContext(SettingsContext); + const isSidebarCompact = !!flags?.sidebarCompact; + const v2CollapsedPadding = isSidebarCompact + ? 'tablet:pl-16 laptop:pl-16' + : 'tablet:pl-16 laptop:pl-20'; + const v2ExpandedPadding = isSidebarCompact + ? 'laptop:!pl-[19rem]' + : 'laptop:!pl-[20rem]'; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); const page = router?.route?.substring(1).trim() as SharedFeedPage; @@ -120,6 +128,7 @@ function MainLayoutComponent({ const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); const { isV2, isLoading: isLayoutVariantLoading } = useLayoutVariant(); + useRecordRecentPages(isV2); useNotificationParams(); // Settings pages render their navigation only inside the v2 context panel, @@ -148,6 +157,18 @@ function MainLayoutComponent({ setContentTransitionsEnabled(true); } }, [layoutSettled]); + // The v2 page uses a tinted background; the document root stays + // `background-default`, so overscroll past the feed reveals a darker strip. + // Flag the root while v2 is active so it can paint the same tint (laptop+, + // matching where the tinted page background applies — see base.css). + useEffect(() => { + if (!isV2) { + return undefined; + } + const root = globalThis.document?.documentElement; + root?.classList.add('layout-v2'); + return () => root?.classList.remove('layout-v2'); + }, [isV2]); // v2 (experiment) snaps the initial settle into place (transitions enable // one commit later, so only genuine toggles animate). The control variant // keeps animating on `layoutSettled` exactly as before. @@ -307,14 +328,12 @@ function MainLayoutComponent({ 'transition-[padding] duration-300 ease-in-out', !sidebarOwnsHeader && 'laptop:pt-16', showSidebar && - (isV2 ? 'tablet:pl-16 laptop:pl-16' : 'tablet:pl-16 laptop:pl-11'), + (isV2 ? v2CollapsedPadding : 'tablet:pl-16 laptop:pl-11'), className, isAuthReady && showSidebar && (sidebarExpanded || forceSidebarExpanded) && - (isV2 - ? 'laptop:!pl-[19rem]' - : !isScreenCentered && 'laptop:!pl-60'), + (isV2 ? v2ExpandedPadding : !isScreenCentered && 'laptop:!pl-60'), isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24', )} > @@ -341,7 +360,9 @@ function MainLayoutComponent({ // card without establishing a scroll container, so descendant // `position: sticky` elements (e.g. the post action bar) stick // to the viewport instead of being inert. - 'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5 laptop:shadow-2', + // No drop shadow — the subtle border defines the floating card + // in both themes; shadow-2 cast a heavy bottom shadow. + 'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5', !hasTopBanners && !topBanner && 'laptop:min-h-[calc(100vh-1.5rem)]', diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx index dc615137a9c..747eb71c3aa 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx @@ -21,16 +21,23 @@ import { IconSize } from '../Icon'; type Props = WithClassNameProps & { shouldOpenProfile?: boolean; profileImageSize?: ProfileImageSize; + // v2 sidebar dropdown tightens the name/handle gap; defaults to the v1 value. + compact?: boolean; }; export const ProfileMenuHeader = ({ className, shouldOpenProfile = false, profileImageSize = ProfileImageSize.Large, -}: Props): ReactElement => { + compact = false, +}: Props): ReactElement | null => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); + if (!user) { + return null; + } + return ( -
+
; title?: string; withSeparator?: boolean; + // Forwarded to every row; v2 reveals external-link icons on hover only. + linkIconHoverOnly?: boolean; } & WithClassNameProps; export const ProfileSection = ({ @@ -22,6 +24,7 @@ export const ProfileSection = ({ className, title, withSeparator, + linkIconHoverOnly, }: ProfileSectionProps): ReactElement => { return ( <> @@ -39,6 +42,7 @@ export const ProfileSection = ({ {items.map((item) => ( ))} diff --git a/packages/shared/src/components/ProfileMenu/ProfileSectionItem.tsx b/packages/shared/src/components/ProfileMenu/ProfileSectionItem.tsx index 77688c441aa..d79e6d0d53a 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileSectionItem.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileSectionItem.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { ReactElement } from 'react'; import classNames from 'classnames'; +import { useRouter } from 'next/router'; import type { WithClassNameProps } from '../utilities'; import Link from '../utilities/Link'; import { @@ -22,6 +23,9 @@ type ProfileSectionItemPropsCommon = WithClassNameProps & { icon?: (props: IconProps) => ReactElement; onClick?: () => void; isActive?: boolean; + // v2 reveals the external-link icon only on hover/focus for a cleaner column. + // Defaults to the v1 always-visible icon so the production menu is unchanged. + linkIconHoverOnly?: boolean; typography?: Partial<{ type: TypographyType; color: TypographyColor; @@ -51,25 +55,38 @@ export const ProfileSectionItem = ({ onClick, external, isActive, + linkIconHoverOnly, typography, }: ProfileSectionItemProps): ReactElement => { + const router = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); const tag = href ? TypographyTag.Link : TypographyTag.Button; const showLinkIcon = href && external; const openNewTab = showLinkIcon && !href.startsWith(webappUrl); + // Warm the destination chunk while the cursor is on the row so the click + // resolves fast. The dropdown is conditionally mounted, so Next's default + // viewport prefetch never gets a chance before the click otherwise. + const isInternal = !!href && !external && href.startsWith(webappUrl); + const prefetch = () => { + if (isInternal) { + router.prefetch(href).catch(() => undefined); + } + }; const content = ( tag={tag} color={typography?.color ?? TypographyColor.Tertiary} type={typography?.type ?? TypographyType.Subhead} className={classNames( - 'flex h-10 cursor-pointer items-center gap-2 rounded-10 px-1 tablet:h-8', + 'group flex h-10 cursor-pointer items-center gap-2 rounded-10 px-1 tablet:h-8', (href || onClick) && 'hover:bg-surface-float', isActive ? 'bg-surface-active' : undefined, className, )} {...combinedClicks(() => onClick?.())} {...(openNewTab && { target: '_blank', rel: anchorDefaultRel })} + onMouseEnter={prefetch} + onFocus={prefetch} > {Icon && ( )} diff --git a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx index 12793f169db..c7f597b4cbe 100644 --- a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx +++ b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import { useInView } from 'react-intersection-observer'; import type { Squad } from '../../../graphql/sources'; import type { SourcesQueryProps } from '../../../hooks/source/useSources'; @@ -26,6 +27,7 @@ import type { Ad } from '../../../graphql/posts'; import { AdPixel } from '../ad/common/AdPixel'; import { TargetType } from '../../../lib/log'; import { useAdQuery } from '../../../features/monetization/useAdQuery'; +import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; interface SquadHorizontalListProps { title: HorizontalScrollTitleProps; @@ -88,11 +90,12 @@ export function SquadsDirectoryFeed({ className, children, firstItemShouldBeAd = false, -}: SquadHorizontalListProps): ReactElement { +}: SquadHorizontalListProps): ReactElement | null { const { ref, inView } = useInView({ triggerOnce: true, }); const { user, isAuthReady } = useAuthContext(); + const { isV2 } = useLayoutVariant(); const { result } = useSources({ query, isEnabled: inView }); const { isFetched } = result; const isMobile = useViewSize(ViewSize.MobileL); @@ -107,7 +110,9 @@ export function SquadsDirectoryFeed({ ), enabled: firstItemShouldBeAd && isAuthReady && !user?.isPlus, }); - const { squad: squadAd } = useSquad({ handle: ad?.data?.source?.handle }); + const { squad: squadAd } = useSquad({ + handle: ad?.data?.source?.handle ?? '', + }); const flatSources = useMemo(() => { const map = getFlatteredNodes(result); @@ -148,8 +153,8 @@ export function SquadsDirectoryFeed({ const isAd = ad && index === 0; return ( - - + + {!!ad?.pixel && } @@ -163,7 +168,13 @@ export function SquadsDirectoryFeed({ return ( {children} @@ -175,12 +186,12 @@ export function SquadsDirectoryFeed({ (node.flags?.featured && linkToSeeAll.includes('featured')); return showFeaturedCard ? ( - + {!!ad?.pixel && } diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx new file mode 100644 index 00000000000..d8c81cfbd12 --- /dev/null +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -0,0 +1,20 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { PageHeader } from '../layout/PageHeader'; +import { ExploreSectionTabs } from './ExploreSectionTabs'; + +// Shared v2 header for the Explore hub's directory pages (Tags, Sources, +// Leaderboard, Best of). Keeps the section-tab strip and its height +// (`!py-0`) consistent in one place. Optional children render as header +// actions (e.g. the "Suggest source" button). +export function ExploreHubHeader({ + children, +}: { + children?: ReactNode; +}): ReactElement { + return ( + } className="!py-0"> + {children} + + ); +} diff --git a/packages/shared/src/components/header/ExploreSectionTabs.tsx b/packages/shared/src/components/header/ExploreSectionTabs.tsx new file mode 100644 index 00000000000..31fa09ba867 --- /dev/null +++ b/packages/shared/src/components/header/ExploreSectionTabs.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '../squads/layout/SquadDirectoryNavbar'; +import { ButtonSize } from '../buttons/Button'; +import { checkIsExtension } from '../../lib/func'; +import { webappUrl } from '../../lib/constants'; + +type ExploreSection = { + label: string; + // Bare app path — used both to active-match the current route (e.g. /tags or + // /tags/react keeps the Tags tab active) and as the webapp href. The href is + // resolved per-context below: the extension needs the webapp origin for the + // directory pages, the webapp navigates client-side from the bare path. + path: string; + // The Explore feed renders in-place in both the webapp and the extension, so + // it always links to the bare path. The other sections are webapp-only + // directory pages, so from the extension they must point at webappUrl. + inPlace?: boolean; +}; + +const sections: ExploreSection[] = [ + { label: 'Explore', path: '/posts', inPlace: true }, + { label: 'Tags', path: '/tags' }, + { label: 'Sources', path: '/sources' }, + { label: 'Leaderboard', path: '/users' }, + { label: 'Discussions', path: '/discussed' }, +]; + +// Primary navbar for the unified Explore hub (v2). Sits above the Explore +// feed's sort tabs and on the Tags/Sources/Leaderboard/Discussions pages so +// the sections stay one click apart after Discover was folded into Home. +export function ExploreSectionTabs(): ReactElement { + const router = useRouter(); + const currentPath = (router.asPath || router.pathname).split('?')[0]; + // The extension runs on the extension origin, so directory links must point + // at the webapp explicitly; the in-place Explore feed stays a bare path. + const isExtension = checkIsExtension(); + + return ( + + {sections.map((section) => { + const href = + isExtension && !section.inPlace + ? `${webappUrl}${section.path.slice(1)}` + : section.path; + + return ( + + ); + })} + + ); +} diff --git a/packages/shared/src/components/header/ExploreSortDropdown.tsx b/packages/shared/src/components/header/ExploreSortDropdown.tsx new file mode 100644 index 00000000000..bf69a4b813d --- /dev/null +++ b/packages/shared/src/components/header/ExploreSortDropdown.tsx @@ -0,0 +1,65 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { Dropdown } from '../fields/Dropdown'; +import { CalendarIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ExploreTabs, tabToUrl, urlToTab } from './FeedExploreHeader'; +import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; +import { periodTexts } from '../layout/common'; + +const sortLabels = Object.values(ExploreTabs); +const sortsWithPeriod: ExploreTabs[] = [ + ExploreTabs.MostUpvoted, + ExploreTabs.BestDiscussions, +]; + +// v2 Explore: switch the feed's ranking via a "Sort" dropdown rather than a +// second row of tabs — sorting a feed isn't navigating to a sibling page, so +// a dropdown reads cleaner (Reddit/GitHub pattern). Each sort is still its +// own route, so selecting one navigates. +export function ExploreSortDropdown(): ReactElement { + const router = useRouter(); + const currentPath = (router.asPath || router.pathname).split('?')[0]; + const activeTab = urlToTab[currentPath] ?? ExploreTabs.Popular; + const selectedIndex = Math.max(0, sortLabels.indexOf(activeTab)); + const [period, setPeriod] = useQueryState({ + key: [QueryStateKeys.FeedPeriod], + defaultValue: 0, + }); + + return ( + + {sortsWithPeriod.includes(activeTab) && ( + } + buttonSize={ButtonSize.Small} + buttonVariant={ButtonVariant.Float} + // Render the icon-only date filter as a square; the shared Dropdown + // defaults icon-only triggers to a full-width value field. + className={{ button: 'aspect-square !w-auto justify-center !px-0' }} + selectedIndex={period} + options={periodTexts} + onChange={(_, index) => setPeriod(index)} + buttonAriaLabel="Filter by date range" + /> + )} + { + const url = tabToUrl[value as ExploreTabs]; + if (url) { + router.push(url).catch(() => undefined); + } + }} + /> + + ); +} diff --git a/packages/shared/src/components/header/FeedExploreHeader.tsx b/packages/shared/src/components/header/FeedExploreHeader.tsx index 5ab6e6bb391..f8a70eff8cf 100644 --- a/packages/shared/src/components/header/FeedExploreHeader.tsx +++ b/packages/shared/src/components/header/FeedExploreHeader.tsx @@ -7,11 +7,6 @@ import { CalendarIcon, HotIcon } from '../icons'; import { IconSize } from '../Icon'; import TabList from '../tabs/TabList'; import { Tab, TabContainer } from '../tabs/TabContainer'; -import { - SquadDirectoryNavbar, - SquadDirectoryNavbarItem, -} from '../squads/layout/SquadDirectoryNavbar'; -import { ButtonSize } from '../buttons/Button'; import { checkIsExtension } from '../../lib/func'; import { getFeedName } from '../../lib/feed'; import { Dropdown } from '../fields/Dropdown'; @@ -59,10 +54,6 @@ interface FeedExploreHeaderProps { }; showBreadcrumbs?: boolean; showDropdown?: boolean; - // v2 only: render the tabs with the shared Squads directory navbar - // (Button + underline) so the explore header matches the canonical - // tabbed-header design. The v1/mobile callers keep the TabContainer look. - directoryTabs?: boolean; } const withDateRange = [ @@ -78,12 +69,10 @@ export function FeedExploreHeader({ className = {}, showBreadcrumbs = true, showDropdown = true, - directoryTabs = false, }: FeedExploreHeaderProps): ReactElement { const isExtension = checkIsExtension(); const router = useRouter(); const path = getFeedName(router.pathname); - const currentPathname = (router.asPath || router.pathname).split('?')[0]; const [period, setPeriod] = useQueryState({ key: [QueryStateKeys.FeedPeriod], defaultValue: 0, @@ -120,24 +109,7 @@ export function FeedExploreHeader({ onClick={setTab} /> )} - {!isExtension && directoryTabs && ( - - {Object.entries(urlToTab).map(([url, label]) => ( - - ))} - - )} - {!isExtension && !directoryTabs && ( + {!isExtension && ( - - Icon/Calendar/Filled - - - - \ No newline at end of file + +Icon/Calendar/Filled + + diff --git a/packages/shared/src/components/icons/Calendar/outlined.svg b/packages/shared/src/components/icons/Calendar/outlined.svg index b95637af6d0..62c1598f655 100644 --- a/packages/shared/src/components/icons/Calendar/outlined.svg +++ b/packages/shared/src/components/icons/Calendar/outlined.svg @@ -1,7 +1,10 @@ - - - Icon/Calendar/Outline - - - - \ No newline at end of file + +Icon/Calendar/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/Help/filled.svg b/packages/shared/src/components/icons/Help/filled.svg new file mode 100644 index 00000000000..76a9ae1aae6 --- /dev/null +++ b/packages/shared/src/components/icons/Help/filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/Help/index.tsx b/packages/shared/src/components/icons/Help/index.tsx new file mode 100644 index 00000000000..9b5f279992a --- /dev/null +++ b/packages/shared/src/components/icons/Help/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const HelpIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Help/outlined.svg b/packages/shared/src/components/icons/Help/outlined.svg new file mode 100644 index 00000000000..9655c5d3283 --- /dev/null +++ b/packages/shared/src/components/icons/Help/outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/NewPost/filled.svg b/packages/shared/src/components/icons/NewPost/filled.svg new file mode 100644 index 00000000000..a959653f4b7 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/filled.svg @@ -0,0 +1,4 @@ + +Icon/NewPost/Filled + + diff --git a/packages/shared/src/components/icons/NewPost/index.tsx b/packages/shared/src/components/icons/NewPost/index.tsx new file mode 100644 index 00000000000..119c6b9ce18 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const NewPostIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/NewPost/outlined.svg b/packages/shared/src/components/icons/NewPost/outlined.svg new file mode 100644 index 00000000000..88448019d43 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/outlined.svg @@ -0,0 +1,4 @@ + +Icon/NewPost/Outline + + diff --git a/packages/shared/src/components/icons/gift/filled.svg b/packages/shared/src/components/icons/gift/filled.svg index 2ff3f78043a..7a1b6529625 100644 --- a/packages/shared/src/components/icons/gift/filled.svg +++ b/packages/shared/src/components/icons/gift/filled.svg @@ -1,3 +1,8 @@ - + + + + + + diff --git a/packages/shared/src/components/icons/gift/outlined.svg b/packages/shared/src/components/icons/gift/outlined.svg index d6ae51251a6..923576a8755 100644 --- a/packages/shared/src/components/icons/gift/outlined.svg +++ b/packages/shared/src/components/icons/gift/outlined.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 37ba3f92871..a8506ac6745 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -72,6 +72,7 @@ export * from './Hamburger'; export * from './Hammer'; export * from './Hashnode'; export * from './Hashtag'; +export * from './Help'; export * from './Home'; export * from './Hot'; export * from './Image'; @@ -108,6 +109,7 @@ export * from './Minimize'; export * from './Minus'; export * from './Moon'; export * from './MoveTo'; +export * from './NewPost'; export * from './NewSquad'; export * from './NewTab'; export * from './NumberedList'; diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index 22037f4029f..47e9888c620 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -89,6 +89,7 @@ const resolveDefaultKind = ( export interface SmartComposerModalProps extends LazyModalCommonProps { initialUrl?: string; initialSquadHandle?: string; + initialKind?: ComposerKind; preview?: ExternalLinkPreview; editPost?: Post; } @@ -97,6 +98,7 @@ export function SmartComposerModal({ onRequestClose, initialUrl, initialSquadHandle, + initialKind, preview: initialPreview, editPost, ...props @@ -118,6 +120,9 @@ export function SmartComposerModal({ if (initialUrl) { return 'link'; } + if (initialKind) { + return initialKind; + } return resolveDefaultKind(flags?.defaultWriteTab, isStandupEnabled); }); // Settings load async; if the modal opens before they're ready, apply the @@ -129,7 +134,7 @@ export function SmartComposerModal({ return; } hasAppliedDefaultKind.current = true; - if (isEditing || initialUrl || hasUserChangedKind.current) { + if (isEditing || initialUrl || initialKind || hasUserChangedKind.current) { return; } setKind(resolveDefaultKind(flags?.defaultWriteTab, isStandupEnabled)); @@ -137,6 +142,7 @@ export function SmartComposerModal({ loadedSettings, isEditing, initialUrl, + initialKind, flags?.defaultWriteTab, isStandupEnabled, ]); diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 263d57185bf..0b17ebff20a 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -14,16 +14,20 @@ import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; import { IconSize } from '../Icon'; +import { railTabClass, railTabLabelClass } from '../sidebar/common'; function NotificationsBell({ compact, rail, noTooltip, + railHideLabel, active, }: { compact?: boolean; rail?: boolean; noTooltip?: boolean; + // v2 rail compact mode: hide the "Alerts" label under the bell. + railHideLabel?: boolean; // Optional override — the v2 sidebar wants the bell highlighted on // any page that owns the Notifications category (incl. its settings // sub-page), which extends past the bell's own internal check. @@ -61,23 +65,26 @@ function NotificationsBell({ href={`${webappUrl}notifications`} aria-label="Notifications" className={classNames( - 'focus-outline relative flex h-10 w-10 items-center justify-center rounded-12 transition-colors hover:bg-surface-hover hover:text-text-primary', - atNotificationsPage - ? 'bg-background-default text-text-primary' - : 'text-text-tertiary', + railTabClass, + atNotificationsPage && 'bg-background-default !text-text-primary', )} onClick={onNavigateNotifications} > - - {hasNotification && ( - - {getUnreadText(unreadCount)} - + + + {hasNotification && ( + + {getUnreadText(unreadCount)} + + )} + + {!railHideLabel && ( + Alerts )} diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 0410d53f747..f5fe7fcff4e 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -5,6 +5,7 @@ import type { ItemInnerProps, SidebarMenuItem } from './common'; import { NavHeader, NavSection } from './common'; import { SidebarItem } from './SidebarItem'; import { ArrowIcon, PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; import type { SettingsFlags } from '../../graphql/settings'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { isNullOrUndefined } from '../../lib/func'; @@ -17,6 +18,9 @@ export interface SectionCommonProps activePage: string; className?: string; flag?: keyof SettingsFlags; + // v2 sidebar polish: hover-only collapse arrow + 1px item gap. Defaults to + // the v1 always-visible arrow and no gap so the v1 sidebar is unchanged. + compact?: boolean; } interface SectionProps extends SectionCommonProps { @@ -40,6 +44,7 @@ export function Section({ isAlwaysOpenOnMobile, onAdd, addHref, + compact = false, }: SectionProps): ReactElement { const { flags, updateFlag } = useSettingsContext(); const { sidebarRendered } = useSidebarRendered(); @@ -65,10 +70,10 @@ export function Section({ }; return ( - + {title && ( - {/* Divider shown when sidebar is collapsed */} + {/* Divider shown when a collapsible (titled) section is collapsed */}
@@ -105,8 +110,18 @@ export function Section({ {title} @@ -138,12 +153,21 @@ export function Section({ id={flag ? `section-${flag}` : undefined} className={classNames( 'grid transition-[grid-template-rows,opacity] duration-300', - isVisible.current || shouldAlwaysBeVisible + // Collapsing only applies when there's a title (the header is the + // only toggle). A flagged-but-title-less section — e.g. the Squads + // and Saved panels — would otherwise get stuck hidden when its flag + // is false, with no arrow to re-expand it. + !title || isVisible.current || shouldAlwaysBeVisible ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', )} > -
+
{items.map((item) => ( ), }, - { - id: SidebarCategory.Discover, - label: 'Discover', - // Discover's first sub-page is the Explore feed (`/posts`); clicking - // the rail icon should land you on that page, matching the submenu. - defaultPath: `${webappUrl}posts`, - icon: (active) => ( - - ), - }, - { - id: SidebarCategory.Saved, - label: 'Saved', - defaultPath: `${webappUrl}bookmarks`, - icon: (active) => ( - - ), - }, { id: SidebarCategory.GameCenter, - label: 'Game Center', + label: 'Quests', // First sub-page in the Game Center category is the Daily quests // page (the panel that used to live in the sidebar). Clicking the // rail icon lands you there; Game Center proper is one click away @@ -123,28 +155,36 @@ const sidebarCategories: SidebarCategoryConfig[] = [ icon: (active) => , }, { - id: SidebarCategory.Profile, - label: 'Profile', + id: SidebarCategory.Saved, + label: 'Saved', + defaultPath: `${webappUrl}bookmarks`, icon: (active) => ( - + ), }, ]; const railButtonClass = - 'flex h-10 w-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; + 'flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; +// Shared group so the rail's click popups (support, profile menu, streak) are +// mutually exclusive — opening one closes the others. +const RAIL_POPUP_GROUP = 'sidebar-rail'; +// How long the urgency tooltip auto-surfaces when the streak turns critical. +const STREAK_CRITICAL_TOOLTIP_MS = 5000; const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; const settingsDefaultPath = `${settingsUrl}/profile`; const RAIL_HOVER_OPEN_DELAY = 300; const RAIL_HOVER_CLOSE_DELAY = 120; const RAIL_HOVER_SIDE_OFFSET = 12; -const RAIL_HOVER_PROFILE_ALIGN_OFFSET = -304; // The shared Tooltip primitive bakes in `collisionPadding={{ top: 75 }}` — // a leftover from the global-header layout. With the dual-sidebar there's // no top chrome to clip against, so a snug override re-centers tooltips // with their triggers. const RAIL_TOOLTIP_COLLISION_PADDING = 4; +// Vertical slack (px) added to the safe-zone triangle so the pointer can dip +// slightly past the panel's top/bottom edge while arcing in without losing it. +const SAFE_ZONE_BUFFER = 26; interface RailHoverCardProps { label: string; @@ -218,54 +258,58 @@ const RailHoverCard = ({ ); }; -const themeIconMap: Record< - ThemeMode, - React.ComponentType<{ - secondary?: boolean; - size?: IconSize; - 'aria-hidden'?: boolean; - }> -> = { - [ThemeMode.Dark]: MoonIcon, - [ThemeMode.Light]: SunIcon, - [ThemeMode.Auto]: ThemeAutoIcon, -}; +// Theme toggling now lives in the profile dropdown (ThemeSection, matching +// production). The rail slot is reused for a quick "Invite friends" shortcut. +const SidebarInviteButton = (): ReactElement => ( + + + + + + + +); -const SidebarThemeButton = (): ReactElement => { - const { setTheme, themeMode } = useSettingsContext(); - const { logEvent } = useLogContext(); - const isDark = themeMode === ThemeMode.Dark; - const nextMode = isDark ? ThemeMode.Light : ThemeMode.Dark; - const ActiveIcon = themeIconMap[themeMode]; - - const onToggleTheme = useCallback(() => { - logEvent({ - event_name: LogEvent.ChangeSettings, - target_type: TargetType.Theme, - target_id: nextMode, - }); - setTheme(nextMode); - }, [logEvent, nextMode, setTheme]); +const supportItems: ProfileSectionItemProps[] = [ + { + title: 'Get the mobile app', + href: appsUrl, + icon: PhoneIcon, + external: true, + }, + { + title: 'Get the browser extension', + href: downloadBrowserExtension, + icon: BrowserGroupIcon, + external: true, + }, + { + title: 'Changelog', + href: `${webappUrl}sources/daily_updates`, + icon: TerminalIcon, + }, + { title: 'Docs', href: docs, icon: DocsIcon, external: true }, + { title: 'Report a bug', href: feedback, icon: FlagIcon, external: true }, +]; - return ( - - - - ); -}; +const legalItems: ProfileSectionItemProps[] = [ + { + title: 'Privacy policy', + href: privacyPolicy, + icon: PrivacyIcon, + external: true, + }, + { + title: 'Terms of service', + href: termsOfService, + icon: DocsIcon, + external: true, + }, +]; const SidebarSupportButton = (): ReactElement => { - const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); + const { isOpen, onUpdate, wrapHandler } = + useInteractivePopup(RAIL_POPUP_GROUP); return ( <> @@ -280,7 +324,7 @@ const SidebarSupportButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -291,9 +335,302 @@ const SidebarSupportButton = (): ReactElement => { className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > - + - + + + )} + + ); +}; + +// Options for the rail "+" hover panel. Each opens the composer modal with the +// matching kind preselected (not a dedicated page). Built as SidebarMenuItem +// rows so the list matches the other category panels. +const createMenuOptions: { + title: string; + kind: ComposerKind; + icon: (active: boolean) => ReactElement; +}[] = [ + { + title: 'Free form', + kind: 'text', + icon: (active) => } />, + }, + { + title: 'Share a link', + kind: 'link', + icon: (active) => } />, + }, + { + title: 'Poll', + kind: 'poll', + icon: (active) => } />, + }, + { + title: 'Live', + kind: 'standup', + icon: (active) => ( + } /> + ), + }, +]; + +// Profile menu anchored to the bottom rail avatar. A curated, lean subset of +// the production ProfileMenu (built from the shared ProfileSection item rows) +// plus the rail-specific reputation/cores stats card. +const SidebarProfileButton = ({ + onPreviewHref, +}: { + onPreviewHref: (href: string) => void; +}): ReactElement | null => { + const { user, logout } = useAuthContext(); + const { isOpen, onUpdate, wrapHandler } = + useInteractivePopup(RAIL_POPUP_GROUP); + const { openModal } = useLazyModal(); + const canPurchaseCores = useCanPurchaseCores(); + // The reading streak is one connected colored shape behind the avatar: the + // border around the avatar + a peeking tab share the fill (state colour). The + // avatar opens the profile menu; the tab opens the streak calendar. + const { + isEnabled: isStreakEnabled, + isLoading: isStreakLoading, + streak, + state: streakState, + count: streakCount, + hasReadToday, + copy: streakCopy, + } = useStreakRingState(); + const { isOpen: isStreakOpen, onUpdate: setStreakOpen } = + useInteractivePopup(RAIL_POPUP_GROUP); + const streakChipRef = useRef(null); + // Only on critical: auto-open the streak tooltip to nudge the user for ~5s, + // then hide it (or sooner, the moment they hover the streak). Re-arms each + // time the streak re-enters the critical state. + const [autoOpenStreakTooltip, setAutoOpenStreakTooltip] = useState(false); + const prevStreakCriticalRef = useRef(false); + useEffect(() => { + const isCritical = streakState === 'critical'; + const wasCritical = prevStreakCriticalRef.current; + prevStreakCriticalRef.current = isCritical; + if (isCritical && !wasCritical) { + setAutoOpenStreakTooltip(true); + const timeout = setTimeout( + () => setAutoOpenStreakTooltip(false), + STREAK_CRITICAL_TOOLTIP_MS, + ); + return () => clearTimeout(timeout); + } + if (!isCritical) { + setAutoOpenStreakTooltip(false); + } + return undefined; + }, [streakState]); + + if (!user) { + return null; + } + + // Optimistically switch the context panel to the link's category on click — + // same instant feedback as a rail-tab click — so the panel doesn't visibly + // lag a slow route transition (especially the heavy Settings pages). + const withPreview = ( + items: ProfileSectionItemProps[], + ): ProfileSectionItemProps[] => + items.map((item) => { + if (!item.href || item.external) { + return item; + } + const { href, onClick } = item; + return { + ...item, + onClick: () => { + onPreviewHref(href); + onClick?.(); + }, + }; + }); + + const mainItems: ProfileSectionItemProps[] = [ + { title: 'Analytics', href: `${webappUrl}analytics`, icon: AnalyticsIcon }, + { title: 'Jobs', href: `${webappUrl}jobs`, icon: JobIcon }, + { + title: 'DevCard', + href: `${settingsUrl}/customization/devcard`, + icon: DevCardIcon, + }, + { + title: 'Invite friends', + href: `${settingsUrl}/invite`, + icon: InviteIcon, + }, + ]; + + const billingItems: ProfileSectionItemProps[] = [ + { + title: 'Subscriptions', + href: `${settingsUrl}/subscription`, + icon: CreditCardIcon, + }, + ...(canPurchaseCores + ? [ + { + title: 'Ads dashboard', + icon: TrendingIcon, + onClick: () => openModal({ type: LazyModal.AdsDashboard }), + } satisfies ProfileSectionItemProps, + ] + : []), + { + title: 'Advertise', + href: businessWebsiteUrl, + icon: MegaphoneIcon, + external: true, + }, + ]; + + const settingsItems: ProfileSectionItemProps[] = [ + { title: 'Settings', href: settingsDefaultPath, icon: SettingsIcon }, + { title: 'Appearance', href: `${settingsUrl}/appearance`, icon: EyeIcon }, + { + title: 'Feed settings', + href: `${settingsUrl}/feed/general`, + icon: AppIcon, + }, + ]; + + const logoutItems: ProfileSectionItemProps[] = [ + { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }, + ]; + + return ( + <> +
+ {isStreakEnabled ? ( + // Shared StreakRing renders the "border legend" visual (avatar in a + // bordered box; flame + count break through the bottom border). The + // avatar (profile menu) and the chip (streak popover) are two distinct + // buttons — we pass the avatar button + the chip's interactivity here; + // all state visuals live in StreakRing / useStreakRingState. + { + event.stopPropagation(); + setStreakOpen(!isStreakOpen); + }} + chipTooltip={streakCopy} + chipTooltipOpen={autoOpenStreakTooltip} + onMouseEnter={() => setAutoOpenStreakTooltip(false)} + avatar={ + + + + } + /> + ) : ( + + )} +
+ {isStreakOpen && streak && ( + setStreakOpen(false)} + /> + )} + {isOpen && ( + onUpdate(false)} + position={InteractivePopupPosition.SidebarProfileMenu} + className="flex max-h-[calc(100dvh-4rem)] w-72 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + > + + + + + + + + + + )} @@ -332,30 +669,103 @@ export const SidebarDesktopV2 = ({ const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); + const { openModal } = useLazyModal(); + const { isLoggedIn } = useAuthContext(); const { openNewSquad } = useSquadNavigation(); - const { isLoggedIn, user } = useAuthContext(); + const addBookmarkFolder = useAddBookmarkFolder(); + const { value: isCompact } = useSettingsBooleanFlag('sidebarCompact'); + // Compact mode reverts to the original icon-only widths (pre-label rail). + // Both width sets are known-good; MainLayout mirrors the collapsed/expanded + // padding so the content never overlaps the rail. + const railCollapsedWidth = isCompact ? 'laptop:w-16' : 'laptop:w-20'; + const railExpandedWidth = isCompact ? 'laptop:w-[19rem]' : 'laptop:w-[20rem]'; + const railNavWidth = isCompact ? 'w-16' : 'w-20'; + const railSeparatorLeft = isCompact ? 'left-16' : 'left-20'; + const railToggleClosedLeft = isCompact ? 'left-[3.5rem]' : 'left-[4.5rem]'; + const railToggleOpenLeft = isCompact ? 'left-[16.5rem]' : 'left-[17.5rem]'; const claimableQuestCount = useClaimableQuestCount(); const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0; + + // --- Vertical "More" overflow ------------------------------------------- + // When the rail is too short to fit every nav item, the lowest-priority + // items fold into the Settings button, which becomes a 3-dots "More" + // dropdown. Fold order: Saved, then Quests, then Alerts. Home, Squads and + // New post always stay. Measured against the nav list's height so it tracks + // the viewport like Slack's sidebar. + const navListRef = useRef(null); + const [maxNavSlots, setMaxNavSlots] = useState(Number.POSITIVE_INFINITY); + useEffect(() => { + const list = navListRef.current; + if (!list || typeof ResizeObserver === 'undefined') { + return undefined; + } + const GAP = 4; + const itemHeight = isCompact ? 44 : 56; + const measure = () => { + // Ignore zero-height measurements (e.g. a hidden/not-yet-laid-out mount) + // so we don't briefly fold every item into the "More" menu. + if (list.clientHeight <= 0) { + return; + } + const slots = Math.floor((list.clientHeight + GAP) / (itemHeight + GAP)); + setMaxNavSlots(Math.max(0, slots)); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(list); + return () => observer.disconnect(); + }, [isCompact]); + + const overflowOrder = useMemo( + () => + [ + isLoggedIn ? SidebarCategory.Notifications : null, + SidebarCategory.GameCenter, + SidebarCategory.Saved, + ].filter(Boolean) as SidebarCategoryId[], + [isLoggedIn], + ); + // Home, Squads and (logged-in) New post never fold. The 3-dots "More" + // button only appears when something overflows, so it costs a slot then. + const fixedNavSlots = 2 + (isLoggedIn ? 1 : 0); + const isNavOverflowing = maxNavSlots < fixedNavSlots + overflowOrder.length; + const visibleOverflowCount = isNavOverflowing + ? Math.max( + 0, + Math.min(overflowOrder.length, maxNavSlots - fixedNavSlots - 1), + ) + : overflowOrder.length; + const foldedNavIds = overflowOrder.slice(visibleOverflowCount); const activePage = activePageProp || router.asPath || router.pathname || ''; - const isUserProfileActive = - !!user?.username && activePage.includes(`/${user.username}`); const isFeedPage = activePage.includes('/feeds/'); const resolvedCategory = useMemo((): SidebarCategoryId => { if (isFeedPage) { return SidebarCategory.Main; } - if (isUserProfileActive) { - return SidebarCategory.Profile; - } return getSidebarCategoryForPath(activePage); - }, [activePage, isFeedPage, isUserProfileActive]); + }, [activePage, isFeedPage]); // Optimistic override so a rail click feels instant even when // router.push is async. Cleared once the URL catches up. const [pendingCategory, setPendingCategory] = useState(null); const selectedCategory = pendingCategory ?? resolvedCategory; + // On settings pages the sidebar collapses to a single full-width settings + // panel (no rail), so hover-preview is irrelevant — pin the panel to Settings. + const isSettingsSelected = selectedCategory === SidebarCategory.Settings; + + // Hovering a rail tab previews that category's panel without committing to + // it; the panel falls back to the selected/pinned category. Cleared when the + // cursor leaves the sidebar (see handleRailMouseLeave). + const [hoveredCategory, setHoveredCategory] = + useState(null); + const activeCategory = isSettingsSelected + ? SidebarCategory.Settings + : hoveredCategory ?? selectedCategory; + // Hovering the "+" previews the create-post options panel (takes precedence + // over a hovered category). Clicking "+" opens the composer modal instead. + const [isCreateHovered, setIsCreateHovered] = useState(false); useEffect(() => { if (pendingCategory !== null && pendingCategory === resolvedCategory) { @@ -370,6 +780,20 @@ export const SidebarDesktopV2 = ({ // in. Keep transitions off until settings have loaded so the sidebar // snaps into its final state on first paint and only genuine user // toggles animate afterwards. + const [isRailHovered, setIsRailHovered] = useState(false); + // After a click-to-collapse the cursor is still over the sidebar. Suppress + // the hover-peek until it actually leaves and re-enters, so the first click + // collapses instead of instantly re-expanding under the cursor. + const peekSuppressedRef = useRef(false); + // Prediction-cone "safe zone": while the pointer arcs from the active tab + // into the panel, block the rail tabs' pointer events so clipping a + // neighbouring tab can't switch the preview (menu-aim done with pointer + // blocking rather than fragile slope guesses). + const panelRef = useRef(null); + const sidebarRef = useRef(null); + const safeBlockedRef = useRef(false); + const safePolyRef = useRef | null>(null); + const lastPointerRef = useRef<{ x: number; y: number } | null>(null); const [transitionsEnabled, setTransitionsEnabled] = useState(false); useEffect(() => { if (loadedSettings) { @@ -382,11 +806,17 @@ export const SidebarDesktopV2 = ({ // Escape resets the pinned panel back to Main so the keyboard story // mirrors the click model — Tab+Enter opens a panel, Escape backs out. + // Scoped to when focus is inside the sidebar; otherwise a global Escape + // (closing a modal, blurring a field) would yank the panel back to Main. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setPendingCategory(SidebarCategory.Main); + if (event.key !== 'Escape') { + return; + } + if (!sidebarRef.current?.contains(document.activeElement)) { + return; } + setPendingCategory(SidebarCategory.Main); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); @@ -397,15 +827,13 @@ export const SidebarDesktopV2 = ({ sidebarExpanded: true, shouldShowLabel: true, activePage, + compact: true, }), [activePage], ); const getCategoryDefaultPath = useCallback( (category: SidebarCategoryId): string | null => { - if (category === SidebarCategory.Profile) { - return user?.username ? `${webappUrl}${user.username}` : null; - } if (category === SidebarCategory.Settings) { return settingsDefaultPath; } @@ -414,7 +842,7 @@ export const SidebarDesktopV2 = ({ null ); }, - [user?.username], + [], ); const onSelectCategory = useCallback( @@ -451,19 +879,205 @@ export const SidebarDesktopV2 = ({ [getCategoryDefaultPath, router], ); + // Profile-dropdown links navigate via `` and bypass `onSelectCategory`, + // so the panel would otherwise wait for the route to resolve before swapping. + // Map the link's path to its category and switch optimistically on click. + const onPreviewHref = useCallback((href: string) => { + const { pathname } = new URL(href, 'http://_'); + setPendingCategory(getSidebarCategoryForPath(pathname)); + }, []); + + // Remember the last non-settings location so "Back to app" returns the user + // where they were rather than always dumping them on the home feed. + const lastAppPathRef = useRef(webappUrl); + useEffect(() => { + if (getSidebarCategoryForPath(activePage) !== SidebarCategory.Settings) { + lastAppPathRef.current = activePage; + } + }, [activePage]); + + const onBackToApp = useCallback(() => { + setPendingCategory(SidebarCategory.Main); + Promise.resolve(router.push(lastAppPathRef.current)).catch(() => undefined); + }, [router]); + + // Entering settings collapses the rail, so any stale hover/create preview + // would otherwise leak into the settings panel — clear it. + useEffect(() => { + if (isSettingsSelected) { + setHoveredCategory(null); + setIsCreateHovered(false); + } + }, [isSettingsSelected]); + const onToggleExpanded = useCallback(() => { logEvent({ event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`, }); + if (sidebarExpanded) { + peekSuppressedRef.current = true; + setIsRailHovered(false); + } toggleSidebarExpanded(); }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); + // `[` toggles the sidebar open/closed (mirrors the collapse toggle button). + // Skipped while typing into a field so it doesn't hijack the bracket key. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== '[' || event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const target = event.target as HTMLElement | null; + if ( + target?.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '') + ) { + return; + } + onToggleExpanded(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onToggleExpanded]); + + const handleRailMouseEnter = useCallback(() => { + if (peekSuppressedRef.current) { + return; + } + setIsRailHovered(true); + }, []); + + const exitSafeZone = useCallback(() => { + safeBlockedRef.current = false; + safePolyRef.current = null; + if (navListRef.current) { + navListRef.current.style.pointerEvents = ''; + } + }, []); + + const handleRailMouseLeave = useCallback(() => { + peekSuppressedRef.current = false; + setIsRailHovered(false); + setHoveredCategory(null); + setIsCreateHovered(false); + lastPointerRef.current = null; + exitSafeZone(); + }, [exitSafeZone]); + + // --- Prediction cone via pointer-events blocking ----------------------- + // `commitPreview` maps a rail trigger key to the panel preview it shows. + const commitPreview = useCallback((key: string) => { + if (key === 'create') { + setIsCreateHovered(true); + return; + } + setIsCreateHovered(false); + setHoveredCategory(key as SidebarCategoryId); + }, []); + + const enterSafeZone = useCallback((x: number, y: number) => { + const panel = panelRef.current?.getBoundingClientRect(); + if (!panel || panel.width < 8) { + return; + } + // Triangle from the pointer to the panel's near (left) edge, padded + // vertically. While the pointer stays inside it the tabs are inert. + safePolyRef.current = [ + [x, y], + [panel.left, panel.top - SAFE_ZONE_BUFFER], + [panel.left, panel.bottom + SAFE_ZONE_BUFFER], + ]; + safeBlockedRef.current = true; + if (navListRef.current) { + navListRef.current.style.pointerEvents = 'none'; + } + }, []); + + const pointInPolygon = ( + x: number, + y: number, + poly: Array<[number, number]>, + ): boolean => { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + }; + + // Enter the safe zone when the pointer leaves the *active* trigger heading + // toward the panel (rightward). Pointer blocking then takes over. + const handlePreviewLeave = useCallback( + (key: string, event: React.MouseEvent) => { + if (safeBlockedRef.current) { + return; + } + const activeKey = isCreateHovered ? 'create' : activeCategory; + if (key !== activeKey) { + return; + } + const prev = lastPointerRef.current; + // Heading rightward off the active trigger = arcing toward the panel. + // enterSafeZone no-ops when the panel isn't actually open. + if (prev && event.clientX - prev.x > 0) { + enterSafeZone(event.clientX, event.clientY); + } + }, + [isCreateHovered, activeCategory, enterSafeZone], + ); + + const handleRailMouseMove = useCallback( + (event: React.MouseEvent) => { + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + if (!safeBlockedRef.current) { + return; + } + const panel = panelRef.current?.getBoundingClientRect(); + if (!panel) { + exitSafeZone(); + return; + } + const { clientX: x, clientY: y } = event; + const overPanel = + x >= panel.left && + x <= panel.right && + y >= panel.top && + y <= panel.bottom; + if (overPanel) { + // Reached the panel — keep the current preview, release the block. + exitSafeZone(); + return; + } + if (safePolyRef.current && pointInPolygon(x, y, safePolyRef.current)) { + return; + } + // Left the safe zone without reaching the panel — honour the trigger + // the pointer actually landed on. + exitSafeZone(); + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const trigger = el?.closest('[data-sidebar-preview]'); + const key = trigger?.getAttribute('data-sidebar-preview'); + if (key) { + commitPreview(key); + } + }, + [exitSafeZone, commitPreview], + ); + + useEffect(() => () => exitSafeZone(), [exitSafeZone]); + const renderCategorySection = (category: SidebarCategoryId): ReactElement => { if (category === SidebarCategory.Squads) { return ( ); } @@ -472,15 +1086,6 @@ export const SidebarDesktopV2 = ({ ); } - if (category === SidebarCategory.Discover) { - return ( - - ); - } if (category === SidebarCategory.Settings) { return ( ); } - if (category === SidebarCategory.Profile) { - return ( - <> -
- -
- - - ); - } - return ( <> + + ) => { + if (!isCollapsedHoverMode) { + return; + } + const target = event.target as HTMLElement; + if ( + target.closest( + 'a, button, input, select, textarea, [role="tab"], [role="button"], [role="menuitem"]', + ) + ) { + return; + } + onToggleExpanded(); + }; + + const renderCategoryTab = ( + categoryId: SidebarCategoryId, + ): ReactElement | null => { + const category = sidebarCategories.find((entry) => entry.id === categoryId); + if (!category) { + return null; + } + const isSelected = activeCategory === category.id; + return ( + + + + ); + }; + + const renderMorePanel = (): ReactElement => { + const rows = foldedNavIds.map((id) => { + if (id === SidebarCategory.Notifications) { + return { + key: id as string, + label: 'Notifications', + href: `${webappUrl}notifications`, + icon: , + }; + } + const category = sidebarCategories.find((entry) => entry.id === id); + return { + key: id as string, + label: category?.label ?? '', + href: category?.defaultPath ?? webappUrl, + icon: category?.icon(false) ?? null, + }; + }); + return ( +
+ {rows.map((row) => ( + + + + {row.icon} + + {row.label} + + + ))} +
+ ); + }; + + const createMenuItems = useMemo( + () => + createMenuOptions.map(({ title, kind, icon }) => ({ + icon, + title, + // SidebarItem/ClickableNavItem dispatches `action` (not `onClick`) and + // requires a `path` for link items — a path-less `onClick` row throws. + action: () => + openModal({ + type: LazyModal.SmartComposer, + props: { initialKind: kind }, + }), + })), + [openModal], + ); + + // The panel reflects the create-post options when hovering "+", otherwise + // the active category (hovered preview, else the selected/pinned one). const renderSelectedSection = (): ReactElement => - renderCategorySection(selectedCategory); + isCreateHovered ? ( +
+ ) : ( + renderCategorySection(activeCategory) + ); - const selectedLabel = sidebarCategories.find( - (category) => category.id === selectedCategory, + const activeLabel = sidebarCategories.find( + (category) => category.id === activeCategory, )?.label; - const isSettingsSelected = selectedCategory === SidebarCategory.Settings; const isNotificationsSelected = - selectedCategory === SidebarCategory.Notifications; - const isHomePanel = selectedCategory === SidebarCategory.Main; - const isSquadsPanel = selectedCategory === SidebarCategory.Squads; + activeCategory === SidebarCategory.Notifications; + const isHomePanel = + !isCreateHovered && activeCategory === SidebarCategory.Main; const isUtilityPanelSelected = !isHomePanel; const utilityPanelTitle = (() => { - if (isSettingsSelected) { + if (isCreateHovered) { + return 'New post'; + } + if (activeCategory === SidebarCategory.Settings) { return 'Settings'; } if (isNotificationsSelected) { return 'Notifications'; } - return selectedLabel ?? ''; + return activeLabel ?? ''; })(); - // Settings pages render their navigation only inside this context panel, so - // a collapsed sidebar would leave no way to move between settings sections. - // Force the panel open and hide the collapse toggle while on settings — - // without touching the user's stored `sidebarExpanded` preference, so the - // sidebar returns to its chosen state once they navigate away. - const forceExpanded = isSettingsSelected; - const isExpanded = sidebarExpanded || forceExpanded; + // Single-section panels (Squads/Saved) host their "+" add action in the panel + // title strip — next to the title — rather than as a row inside the section. + const panelAddAction = (() => { + if (isCreateHovered) { + return null; + } + if (activeCategory === SidebarCategory.Squads) { + return { + label: 'New Squad', + onClick: () => openNewSquad({ origin: Origin.Sidebar }), + }; + } + if (activeCategory === SidebarCategory.Saved) { + return { label: 'New folder', onClick: addBookmarkFolder }; + } + return null; + })(); return ( - {isExpanded && ( + {isExpanded && !isSettingsSelected && (
+ + )} {/* - Slide-between-anchors toggle button. Two positions: - - Open (panel right edge): ghost, no border/bg/shadow - - Closed (rail/panel boundary): bordered chip with shadow - Same SidebarArrowLeft glyph, rotated 180° when closed. + Slide-between-anchors toggle button. It tracks the *visible* right edge + (`isExpanded`) so it follows the panel when peeking and never collides + with the panel title. Its glyph/label reflect the *pinned* state + (`sidebarExpanded`) — i.e. what a click does: pin open vs collapse. + - Pinned open: ghost chip, arrow points left ("Close sidebar"). + - Collapsed: bordered chip, arrow points right ("Open sidebar"). Hidden on settings pages, where the panel is force-expanded and collapsing it would hide the only settings navigation. */} {!forceExpanded && ( + {sidebarExpanded ? 'Close sidebar' : 'Open sidebar'} + + [ + + + } collisionPadding={RAIL_TOOLTIP_COLLISION_PADDING} >