diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx
index feeafd84127..a6d014434b1 100644
--- a/packages/shared/src/components/MainFeedLayout.tsx
+++ b/packages/shared/src/components/MainFeedLayout.tsx
@@ -88,6 +88,8 @@ import { useReadingReminderHero } from '../hooks/notifications/useReadingReminde
import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent';
import { useReadingReminderVariation } from '../hooks/notifications/useReadingReminderVariation';
import { useLayoutVariant } from '../hooks/layout/useLayoutVariant';
+import { ExploreSectionTabs } from './header/ExploreSectionTabs';
+import { ExploreSortDropdown } from './header/ExploreSortDropdown';
const FeedExploreHeader = dynamic(
() =>
@@ -263,6 +265,7 @@ export default function MainFeedLayout({
isPopular,
isAnyExplore,
isExploreLatest,
+ isDiscussed,
isSortableFeed,
isCustomFeed,
isSearch: isSearchPage,
@@ -726,7 +729,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
@@ -765,13 +771,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..e6aa9e709cf 100644
--- a/packages/shared/src/components/MainLayout.tsx
+++ b/packages/shared/src/components/MainLayout.tsx
@@ -301,19 +301,25 @@ function MainLayoutComponent({
/>
)}
{
+}: Props): ReactElement | null => {
const { user } = useAuthContext();
const { isPlus } = usePlusSubscription();
+ if (!user) {
+ return null;
+ }
+
return (
-
+
)}
diff --git a/packages/shared/src/components/fields/Dropdown.tsx b/packages/shared/src/components/fields/Dropdown.tsx
index 750ec33ceca..4a5458285e2 100644
--- a/packages/shared/src/components/fields/Dropdown.tsx
+++ b/packages/shared/src/components/fields/Dropdown.tsx
@@ -143,12 +143,14 @@ export function Dropdown({
size={buttonSize}
disabled={disabled}
className={classNames(
- // `!pl-4 !pr-2.5` overrides the Button's built-in Large padding (px-6)
- // so the value lines up with the other fields' 16px text inset and the
- // chevron sits tight to the right edge instead of floating 24px in.
- 'group flex w-full items-center !pl-4 !pr-2.5 font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary',
+ 'group flex items-center font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary',
+ // Value dropdowns get `!pl-4 !pr-2.5` so the value lines up with other
+ // fields' 16px text inset and the chevron sits tight to the right edge.
+ // Icon-only dropdowns drop that and render as a square instead.
+ iconOnly
+ ? 'aspect-square justify-center !px-0'
+ : 'w-full !pl-4 !pr-2.5',
className?.button,
- iconOnly && 'items-center justify-center',
)}
onClick={fullScreen ? handleMenuTrigger : undefined}
onKeyDown={handleKeyboard}
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..c17a808461c
--- /dev/null
+++ b/packages/shared/src/components/header/ExploreSectionTabs.tsx
@@ -0,0 +1,53 @@
+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';
+
+type ExploreSection = {
+ label: string;
+ path: string;
+ // The tab is active when the current path equals `match` or sits under it
+ // (e.g. /tags/react keeps the Tags tab active).
+ match: string;
+};
+
+const sections: ExploreSection[] = [
+ { label: 'Explore', path: '/posts', match: '/posts' },
+ { label: 'Tags', path: '/tags', match: '/tags' },
+ { label: 'Sources', path: '/sources', match: '/sources' },
+ { label: 'Leaderboard', path: '/users', match: '/users' },
+ { label: 'Discussions', path: '/discussed', match: '/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];
+
+ return (
+
+ {sections.map((section) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/shared/src/components/header/ExploreSortDropdown.tsx b/packages/shared/src/components/header/ExploreSortDropdown.tsx
new file mode 100644
index 00000000000..f58d5fb0c70
--- /dev/null
+++ b/packages/shared/src/components/header/ExploreSortDropdown.tsx
@@ -0,0 +1,62 @@
+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}
+ 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/icons/Calendar/filled.svg b/packages/shared/src/components/icons/Calendar/filled.svg
index d16a2ad4697..32501a76984 100644
--- a/packages/shared/src/components/icons/Calendar/filled.svg
+++ b/packages/shared/src/components/icons/Calendar/filled.svg
@@ -1,7 +1,4 @@
-
-
\ No newline at end of file
+
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 @@
-
-
\ No newline at end of file
+
diff --git a/packages/shared/src/components/icons/Compose/filled.svg b/packages/shared/src/components/icons/Compose/filled.svg
new file mode 100644
index 00000000000..00edd46e4be
--- /dev/null
+++ b/packages/shared/src/components/icons/Compose/filled.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/shared/src/components/icons/Compose/index.tsx b/packages/shared/src/components/icons/Compose/index.tsx
new file mode 100644
index 00000000000..b74ceb92f03
--- /dev/null
+++ b/packages/shared/src/components/icons/Compose/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 ComposeIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Compose/outlined.svg b/packages/shared/src/components/icons/Compose/outlined.svg
new file mode 100644
index 00000000000..22afb48bea1
--- /dev/null
+++ b/packages/shared/src/components/icons/Compose/outlined.svg
@@ -0,0 +1,4 @@
+
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..93e33ffac6f
--- /dev/null
+++ b/packages/shared/src/components/icons/Help/filled.svg
@@ -0,0 +1,4 @@
+
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..d23deb8399f
--- /dev/null
+++ b/packages/shared/src/components/icons/Help/outlined.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts
index 37ba3f92871..571b4b789fc 100644
--- a/packages/shared/src/components/icons/index.ts
+++ b/packages/shared/src/components/icons/index.ts
@@ -32,6 +32,7 @@ export * from './Codeberg';
export * from './CodePen';
export * from './Coin';
export * from './CommunityPicksIcon';
+export * from './Compose';
export * from './Cookie';
export * from './Copy';
export * from './Core';
@@ -72,6 +73,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';
diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx
index 0410d53f747..5d091bb3737 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';
@@ -12,7 +13,7 @@ import useSidebarRendered from '../../hooks/useSidebarRendered';
import Link from '../utilities/Link';
export interface SectionCommonProps
- extends Pick {
+ extends Pick {
sidebarExpanded: boolean;
activePage: string;
className?: string;
@@ -37,6 +38,7 @@ export function Section({
isItemsButton,
className,
flag,
+ compact,
isAlwaysOpenOnMobile,
onAdd,
addHref,
@@ -65,7 +67,7 @@ export function Section({
};
return (
-
+
{title && (
{/* Divider shown when sidebar is collapsed */}
@@ -84,7 +86,10 @@ export function Section({
// with the items below it (items have `mx-3`), so "Feeds v"
// and the feed entries share the same x. Without this the
// header was indented less than the items.
- 'group/section ml-3 mr-2 flex min-h-9 flex-1 items-center justify-between py-1.5 pl-1 transition-opacity duration-300',
+ 'ml-3 mr-2 flex min-h-9 flex-1 items-center justify-between py-1.5 pl-1 transition-opacity duration-300',
+ // v2 compact: tighter, left-aligned header to match the smaller
+ // nav rows (mx-2) and Linear's denser list.
+ compact && '!ml-2 !min-h-6 !py-0.5',
sidebarExpanded ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
@@ -94,20 +99,33 @@ export function Section({
aria-label={`Toggle ${title}`}
aria-expanded={!!isVisible.current}
aria-controls={flag ? `section-${flag}` : undefined}
- className="flex items-center gap-1 rounded-6 px-1 py-0.5 transition-colors hover:bg-surface-hover hover:text-text-primary"
+ className="group/toggle flex items-center gap-1 rounded-6 px-1 py-0.5 transition-colors hover:bg-surface-hover hover:text-text-primary"
>
{title}
@@ -143,7 +161,7 @@ export function Section({
: 'grid-rows-[0fr] opacity-0',
)}
>
-
+
{items.map((item) => (
))}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
index e25c98ceab1..0130e222afc 100644
--- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
+++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
@@ -1,286 +1,207 @@
import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react';
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/router';
-import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
-import { Nav, SidebarAside, SidebarScrollWrapper } from './common';
-import { getSidebarCategoryForPath, SidebarCategory } from './sidebarCategory';
-import type { SidebarCategoryId } from './sidebarCategory';
+import { Nav, SidebarAside, SidebarScrollWrapper, ListIcon } from './common';
+import type { SidebarMenuItem } from './common';
+import { Section } from './Section';
+import { isSidebarSettingsPath } from './sidebarCategory';
import { ThemeMode, useSettingsContext } from '../../contexts/SettingsContext';
import { useLogContext } from '../../contexts/LogContext';
import { useBanner } from '../../hooks/useBanner';
-import { MainSection } from './sections/MainSection';
+import { PinnedSection } from './sections/PinnedSection';
import { CustomFeedSection } from './sections/CustomFeedSection';
-import { DiscoverSection } from './sections/DiscoverSection';
-import { ProfileSection } from './sections/ProfileSection';
-import { SidebarProfileCompletion } from './SidebarProfileCompletion';
-import { SettingsPanelSection } from './sections/SettingsPanelSection';
-import { CreatePostButton } from '../post/write';
-import { QuestRailIcon } from '../quest/QuestRailIcon';
-import { useClaimableQuestCount } from '../../hooks/useQuestDashboard';
-import { Bubble } from '../tooltips/utils';
-import { ButtonSize } from '../buttons/Button';
import { BookmarkSection } from './sections/BookmarkSection';
import { NetworkSection } from './sections/NetworkSection';
-import { GameCenterSection } from './sections/GameCenterSection';
+import { SettingsPanelSection } from './sections/SettingsPanelSection';
+import { useClaimableQuestCount } from '../../hooks/useQuestDashboard';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { HelpWidget } from '../help/HelpWidget';
import {
- BookmarkIcon,
- FeedbackIcon,
+ AnalyticsIcon,
+ AppIcon,
+ BellIcon,
+ BrowserGroupIcon,
+ ComposeIcon,
+ CreditCardIcon,
+ DevCardIcon,
+ DevPlusIcon,
+ DocsIcon,
+ ExitIcon,
+ EyeIcon,
+ FlagIcon,
+ HelpIcon,
HomeIcon,
HotIcon,
+ InviteIcon,
+ JobIcon,
+ JoystickIcon,
+ MagicIcon,
+ MegaphoneIcon,
MoonIcon,
- PlusIcon,
+ MoveToIcon,
+ PhoneIcon,
+ PrivacyIcon,
SearchIcon,
SettingsIcon,
- SidebarArrowLeft,
- SourceIcon,
+ SquadIcon,
SunIcon,
- UserIcon,
+ TerminalIcon,
+ TrendingIcon,
} from '../icons';
-import { ThemeAutoIcon } from '../icons/ThemeAuto';
-import { useSquadNavigation } from '../../hooks';
-import { LogEvent, Origin, TargetType } from '../../lib/log';
+import { usePlusSubscription } from '../../hooks/usePlusSubscription';
+import { LogEvent, TargetId, TargetType } from '../../lib/log';
import { IconSize } from '../Icon';
import { Tooltip } from '../tooltip/Tooltip';
-import { RailHoverPanel } from './RailHoverPanel';
import { useSpotlight } from '../spotlight/SpotlightContext';
import { useAuthContext } from '../../contexts/AuthContext';
-import NotificationsBell from '../notifications/NotificationsBell';
-import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel';
+import { useNotificationContext } from '../../contexts/NotificationsContext';
import { ProfilePicture, ProfileImageSize } from '../ProfilePicture';
-import { SidebarHeaderStats } from './SidebarHeaderStats';
+import { SidebarProfileStats } from './SidebarProfileStats';
+import { SidebarStreakButton } from './SidebarStreakButton';
import Link from '../utilities/Link';
-import { settingsUrl, webappUrl } from '../../lib/constants';
-import { isAppleDevice } from '../../lib/func';
+import {
+ appsUrl,
+ businessWebsiteUrl,
+ docs,
+ downloadBrowserExtension,
+ feedback,
+ privacyPolicy,
+ settingsUrl,
+ termsOfService,
+ webappUrl,
+} from '../../lib/constants';
+import { isAppleDevice, isExtension } from '../../lib/func';
+import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed';
+import { OtherFeedPage } from '../../lib/query';
+import { SharedFeedPage, HorizontalSeparator } from '../utilities';
import LogoIcon from '../../svg/LogoIcon';
import InteractivePopup, {
InteractivePopupPosition,
} from '../tooltips/InteractivePopup';
import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup';
-import { ResourceSection } from '../ProfileMenu/sections/ResourceSection';
-import { ProfileMenuFooter } from '../ProfileMenu/ProfileMenuFooter';
+import { ProfileSection as ProfileMenuSection } from '../ProfileMenu/ProfileSection';
+import type { ProfileSectionItemProps } from '../ProfileMenu/ProfileSectionItem';
+import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader';
+import { UpgradeToPlus } from '../UpgradeToPlus';
+import { LogoutReason } from '../../lib/user';
+import { useLazyModal } from '../../hooks/useLazyModal';
+import { LazyModal } from '../modals/common/types';
+import { useCanPurchaseCores } from '../../hooks/useCoresFeature';
import { FeedbackWidget } from '../feedback';
-import { HorizontalSeparator } from '../utilities';
-import { Typography, TypographyType } from '../typography/Typography';
-
-type SidebarCategoryConfig = {
- id: SidebarCategoryId;
- label: string;
- icon: (active: boolean) => ReactElement;
- defaultPath?: string;
-};
-
-const sidebarCategories: SidebarCategoryConfig[] = [
- {
- id: SidebarCategory.Main,
- label: 'Home',
- defaultPath: webappUrl,
- icon: (active) => (
-
- ),
- },
- {
- id: SidebarCategory.Squads,
- label: 'Squads',
- defaultPath: `${webappUrl}squads/discover`,
- icon: (active) => (
-
- ),
- },
- {
- 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',
- // 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
- // via the hover panel.
- defaultPath: `${webappUrl}daily-quests`,
- icon: (active) => ,
- },
- {
- id: SidebarCategory.Profile,
- label: 'Profile',
- icon: (active) => (
-
- ),
- },
-];
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
-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';
const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K'];
+// Sidebar collapse/expand shortcut (plain bracket key, like Linear).
+const sidebarToggleShortcut = '[';
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;
-
-interface RailHoverCardProps {
- label: string;
- children: ReactNode;
- panel: ReactElement;
- enabled?: boolean;
- alignOffset?: number;
-}
-
-const RailHoverCard = ({
- label,
- children,
- panel,
- enabled = true,
- alignOffset,
-}: RailHoverCardProps) => {
- // Controlled open + suppression flag: after a click on the trigger we
- // close the panel and block reopens until the pointer actually leaves
- // the trigger. Otherwise Radix's openDelay timer re-fires while the
- // cursor still rests on the just-clicked item and the panel pops back
- // up after navigation.
- const [open, setOpen] = useState(false);
- const suppressOpenRef = useRef(false);
-
- const handleOpenChange = useCallback((next: boolean) => {
- if (next && suppressOpenRef.current) {
- return;
- }
- setOpen(next);
- }, []);
-
- const handleTriggerClick = useCallback(() => {
- suppressOpenRef.current = true;
- setOpen(false);
- }, []);
-
- const handleTriggerPointerLeave = useCallback(() => {
- suppressOpenRef.current = false;
- }, []);
-
- if (!enabled) {
- return <>{children}>;
- }
- return (
-
-
- {children}
-
-
-
- {panel}
-
-
-
- );
-};
+// Resizable panel bounds (px). 304px = 19rem keeps the default in step with
+// the `var(--sidebar-width, 19rem)` fallback used before settings load.
+const SIDEBAR_DEFAULT_WIDTH = 304;
+const SIDEBAR_MIN_WIDTH = 240;
+const SIDEBAR_MAX_WIDTH = 420;
+// Collapsed icon-rail width (px) — must match the `w-14` class + MainLayout.
+const SIDEBAR_RAIL_WIDTH = 56;
+// Pulling the open panel narrower than this collapses it to the rail.
+const SIDEBAR_COLLAPSE_AT = 180;
-const themeIconMap: Record<
- ThemeMode,
- React.ComponentType<{
- secondary?: boolean;
- size?: IconSize;
- 'aria-hidden'?: boolean;
- }>
-> = {
- [ThemeMode.Dark]: MoonIcon,
- [ThemeMode.Light]: SunIcon,
- [ThemeMode.Auto]: ThemeAutoIcon,
-};
+// Compact square icon button (Linear-sized: 32px hit area, 16px glyph) shared
+// by the header search and the footer support controls.
+const iconButtonClass =
+ 'focus-outline flex size-8 items-center justify-center rounded-10 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary [&_svg]:!size-4';
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 Icon = isDark ? SunIcon : MoonIcon;
- const onToggleTheme = useCallback(() => {
+ const onToggle = () => {
+ const nextMode = isDark ? ThemeMode.Light : ThemeMode.Dark;
logEvent({
event_name: LogEvent.ChangeSettings,
target_type: TargetType.Theme,
target_id: nextMode,
});
setTheme(nextMode);
- }, [logEvent, nextMode, setTheme]);
+ };
return (
);
};
+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 },
+];
+
+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();
return (
<>
-
+
{isOpen && (
@@ -291,9 +212,163 @@ const SidebarSupportButton = (): ReactElement => {
className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
>
-
+
-
+
+
+ )}
+ >
+ );
+};
+
+// Profile switcher in the sidebar header (avatar + name), opening a curated
+// menu built from the shared ProfileSection rows: reputation/cores stats,
+// account links, and log out.
+const SidebarProfileButton = ({
+ iconOnly = false,
+}: {
+ iconOnly?: boolean;
+} = {}): ReactElement | null => {
+ const { user, logout } = useAuthContext();
+ const { isPlus } = usePlusSubscription();
+ const { isOpen, onUpdate, wrapHandler } = useInteractivePopup();
+ const { openModal } = useLazyModal();
+ const canPurchaseCores = useCanPurchaseCores();
+
+ if (!user) {
+ return null;
+ }
+
+ const mainItems: ProfileSectionItemProps[] = [
+ {
+ title: 'Your profile',
+ href: `${webappUrl}${user.username}`,
+ icon: EyeIcon,
+ },
+ { 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: 'Feed settings',
+ href: `${settingsUrl}/feed/general`,
+ icon: AppIcon,
+ },
+ ];
+
+ const logoutItems: ProfileSectionItemProps[] = [
+ {
+ title: 'Log out',
+ icon: ExitIcon,
+ onClick: () => logout(LogoutReason.ManualLogout),
+ },
+ ];
+
+ return (
+ <>
+
+ {isOpen && (
+ onUpdate(false)}
+ position={InteractivePopupPosition.SidebarProfileMenu}
+ className="flex max-h-[calc(100dvh-6rem)] w-72 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
+ >
+
+
+
+
+
+
+
+
+
+
+
)}
>
@@ -328,47 +403,28 @@ export const SidebarDesktopV2 = ({
toggleSidebarExpanded,
loadedSettings,
optOutQuestSystem,
+ flags,
+ updateFlag,
} = useSettingsContext();
const { logEvent } = useLogContext();
const { isAvailable: isBannerAvailable } = useBanner();
const { open: openSpotlight } = useSpotlight();
- const { openNewSquad } = useSquadNavigation();
- const { isLoggedIn, user } = useAuthContext();
+ const { isLoggedIn } = useAuthContext();
+ const { openModal } = useLazyModal();
+ const { unreadCount } = useNotificationContext();
+ const { isCustomDefaultFeed } = useCustomDefaultFeed();
const claimableQuestCount = useClaimableQuestCount();
- const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0;
+ const showQuests = isLoggedIn && !optOutQuestSystem;
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]);
- // 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;
+ // Settings pages render their navigation only inside this panel, so a
+ // collapsed sidebar would strand them with no way to move between sections.
+ // Force it open there without touching the stored preference.
+ const forceExpanded = isSidebarSettingsPath(activePage);
- useEffect(() => {
- if (pendingCategory !== null && pendingCategory === resolvedCategory) {
- setPendingCategory(null);
- }
- }, [pendingCategory, resolvedCategory]);
-
- // Settings load client-side, so on a hard refresh `sidebarExpanded`
- // flips from its `false` default to the user's stored value once
- // `loadedSettings` resolves. The width/opacity transitions below would
- // animate that initial settle, making the sidebar appear to slide/fade
- // in. Keep transitions off until settings have loaded so the sidebar
- // snaps into its final state on first paint and only genuine user
+ // Settings load client-side, so on a hard refresh `sidebarExpanded` settles
+ // from its `false` default to the stored value. Keep transitions off until
+ // settings load so the panel snaps into place on first paint and only real
// toggles animate afterwards.
const [transitionsEnabled, setTransitionsEnabled] = useState(false);
useEffect(() => {
@@ -380,557 +436,663 @@ export const SidebarDesktopV2 = ({
? undefined
: '!transition-none';
- // Escape resets the pinned panel back to Main so the keyboard story
- // mirrors the click model — Tab+Enter opens a panel, Escape backs out.
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- setPendingCategory(SidebarCategory.Main);
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, []);
+ // While dragging the rail grip we preview the open panel (full layout) before
+ // the open state is committed on release — so the icons jump to the left and
+ // labels fade in, instead of the rail growing with centered icons.
+ const [railPreview, setRailPreview] = useState(false);
- const defaultRenderSectionProps = useMemo(
- () => ({
- sidebarExpanded: true,
- shouldShowLabel: true,
- activePage,
- }),
- [activePage],
- );
+ // Pinned-open vs collapsed. Collapsed isn't hidden — it's a persistent
+ // icon-only rail (production-style); hovering it does NOT open the panel.
+ // The panel only opens when pinned (resize grip / "[" / feed-header button).
+ // Settings force the full panel open. `isPanelOpen` = labels + full layout
+ // (also true mid drag-to-open preview, before the open state commits).
+ const isExpanded = sidebarExpanded || forceExpanded;
+ const isPanelOpen = isExpanded || railPreview;
- const getCategoryDefaultPath = useCallback(
- (category: SidebarCategoryId): string | null => {
- if (category === SidebarCategory.Profile) {
- return user?.username ? `${webappUrl}${user.username}` : null;
- }
- if (category === SidebarCategory.Settings) {
- return settingsDefaultPath;
- }
- return (
- sidebarCategories.find((entry) => entry.id === category)?.defaultPath ??
- null
- );
- },
- [user?.username],
+ // The panel width is resizable and persisted. The live value lives in the
+ // `--sidebar-width` CSS variable so the sidebar and the main content (which
+ // pads by the same variable in MainLayout) resize together, 1:1, while
+ // dragging — without re-rendering on every pointer move.
+ const resolvedWidth = Math.min(
+ SIDEBAR_MAX_WIDTH,
+ Math.max(SIDEBAR_MIN_WIDTH, flags?.sidebarWidth ?? SIDEBAR_DEFAULT_WIDTH),
);
- const onSelectCategory = useCallback(
- (category: SidebarCategoryId) => {
- setPendingCategory(category);
+ useEffect(() => {
+ document.documentElement.style.setProperty(
+ '--sidebar-width',
+ `${resolvedWidth}px`,
+ );
+ }, [resolvedWidth, sidebarExpanded]);
- // Click navigates to the category's first sub-page (its
- // `defaultPath`) — it no longer auto-expands the sidebar. The
- // sidebar's open/closed state is controlled solely by the user
- // via the dedicated toggle button.
- const targetPath = getCategoryDefaultPath(category);
- if (!targetPath) {
+ // Collapse/expand shortcut: the plain "[" key (like Linear). Ignored with
+ // modifiers (so ⌘/Ctrl+[ stays browser "back") and while typing.
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
- const targetPathname = new URL(targetPath, 'http://_').pathname;
- const currentPathname = activePage.split('?')[0];
- if (targetPathname !== currentPathname) {
- // `Promise.resolve` wraps the call so `.catch` still works when
- // the next/router test mock returns `undefined` from `push`.
- Promise.resolve(router.push(targetPath)).catch(() => undefined);
+ if (event.key !== '[') {
+ return;
}
+ const target = event.target as HTMLElement | null;
+ if (
+ target?.closest('input, textarea, select, [contenteditable="true"]')
+ ) {
+ return;
+ }
+ event.preventDefault();
+ logEvent({
+ event_name: `${sidebarExpanded ? 'close' : 'open'} sidebar`,
+ });
+ toggleSidebarExpanded();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [logEvent, sidebarExpanded, toggleSidebarExpanded]);
+
+ // Highlights the resize grip while actively dragging (toggled at drag
+ // start/end only — never on pointer move, so it doesn't re-render mid-drag).
+ const [isResizing, setIsResizing] = useState(false);
+
+ // Once the open state actually commits, drop the preview flag. Keeping the
+ // preview on until `isExpanded` flips avoids a one-frame collapse flash
+ // between releasing the drag and the settings update landing.
+ useEffect(() => {
+ if (isExpanded) {
+ setRailPreview(false);
+ }
+ }, [isExpanded]);
+
+ // Grip drag from the COLLAPSED rail: previews the open panel as soon as the
+ // pointer moves (full layout animates in, icons stay left — the rail does
+ // NOT grow with centered icons), then commits open on release. A plain click
+ // opens too. Dragging back over the rail cancels.
+ const onRailGripMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ const root = document.documentElement;
+ const startX = event.clientX;
+ let lastCursorX: number | null = null;
+ let moved = false;
+ document.body.style.userSelect = 'none';
+
+ const onMove = (moveEvent: MouseEvent) => {
+ lastCursorX = moveEvent.clientX;
+ if (!moved && Math.abs(moveEvent.clientX - startX) > 6) {
+ moved = true;
+ // Snap straight to the open width and let the CSS transition animate
+ // the expand (transitions stay enabled — this isn't a 1:1 resize).
+ root.style.setProperty('--sidebar-width', `${resolvedWidth}px`);
+ setRailPreview(true);
+ }
+ };
+ const onUp = () => {
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ document.body.style.userSelect = '';
+
+ // Dragged back over the rail → cancel, stay collapsed.
+ if (moved && lastCursorX != null && lastCursorX < SIDEBAR_RAIL_WIDTH) {
+ setRailPreview(false);
+ return;
+ }
+ root.style.setProperty('--sidebar-width', `${resolvedWidth}px`);
+ logEvent({ event_name: 'open sidebar' });
+ toggleSidebarExpanded();
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
},
- [activePage, getCategoryDefaultPath, router],
+ [resolvedWidth, logEvent, toggleSidebarExpanded],
);
- const onPrefetchCategory = useCallback(
- (category: SidebarCategoryId) => {
- const targetPath = getCategoryDefaultPath(category);
- if (!targetPath) {
+ // Grip drag from the OPEN panel: live 1:1 resize; pull past the collapse
+ // threshold to close. Transitions are suppressed only during the active
+ // drag so the panel tracks the cursor exactly.
+ const onResizeHandleMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ if (!isExpanded) {
+ onRailGripMouseDown(event);
return;
}
- router.prefetch(targetPath).catch(() => undefined);
+ event.preventDefault();
+ const root = document.documentElement;
+ let lastCursorX: number | null = null;
+ setIsResizing(true);
+ root.classList.add('sidebar-resizing');
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const onMove = (moveEvent: MouseEvent) => {
+ lastCursorX = moveEvent.clientX;
+ const width = Math.min(
+ SIDEBAR_MAX_WIDTH,
+ Math.max(SIDEBAR_MIN_WIDTH, moveEvent.clientX),
+ );
+ root.style.setProperty('--sidebar-width', `${width}px`);
+ };
+ const onUp = () => {
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ setIsResizing(false);
+ root.classList.remove('sidebar-resizing');
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+
+ if (lastCursorX == null) {
+ return;
+ }
+ if (lastCursorX < SIDEBAR_COLLAPSE_AT) {
+ logEvent({ event_name: 'close sidebar' });
+ toggleSidebarExpanded();
+ return;
+ }
+ const width = Math.min(
+ SIDEBAR_MAX_WIDTH,
+ Math.max(SIDEBAR_MIN_WIDTH, lastCursorX),
+ );
+ root.style.setProperty('--sidebar-width', `${width}px`);
+ updateFlag('sidebarWidth', width);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
},
- [getCategoryDefaultPath, router],
+ [
+ isExpanded,
+ onRailGripMouseDown,
+ logEvent,
+ toggleSidebarExpanded,
+ updateFlag,
+ ],
);
- const onToggleExpanded = useCallback(() => {
- logEvent({
- event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`,
- });
- toggleSidebarExpanded();
- }, [logEvent, sidebarExpanded, toggleSidebarExpanded]);
+ const defaultRenderSectionProps = useMemo(
+ () => ({
+ // When the panel is open, sections show titles + labels; collapsed to
+ // the rail they become icon-only with tooltips and divider headers.
+ sidebarExpanded: isPanelOpen,
+ shouldShowLabel: isPanelOpen,
+ activePage,
+ // Compact (Linear-style) rows + section headers for the single panel.
+ compact: true,
+ }),
+ [activePage, isPanelOpen],
+ );
- const renderCategorySection = (category: SidebarCategoryId): ReactElement => {
- if (category === SidebarCategory.Squads) {
- return (
-
- );
- }
- if (category === SidebarCategory.Saved) {
- return (
-
- );
- }
- if (category === SidebarCategory.Discover) {
- return (
-
- );
- }
- if (category === SidebarCategory.Settings) {
- return (
-
- );
- }
- if (category === SidebarCategory.Notifications) {
- return ;
- }
- if (category === SidebarCategory.GameCenter) {
- return (
-
- );
- }
- if (category === SidebarCategory.Profile) {
- return (
- <>
-
-
-
-
- >
- );
+ const countBadge = useCallback(
+ (count: number): SidebarMenuItem['rightIcon'] =>
+ count > 0
+ ? () => (
+
+ {count > 99 ? '99+' : count}
+
+ )
+ : undefined,
+ [],
+ );
+
+ // The primary nav, in an explicit (v2-only) order:
+ // For You, Following, Notifications, Quests, Explore, Happening Now, History.
+ // Built here rather than via the shared MainSection so the order — which
+ // interleaves Notifications/Quests with the feed items — stays v2-specific
+ // and doesn't affect the v1 sidebar.
+ const primaryItems: SidebarMenuItem[] = useMemo(() => {
+ // Mirror MainSection's "For You" target so navigation + active state match.
+ let myFeedPath = isCustomDefaultFeed ? '/my-feed' : '/';
+ if (isExtension) {
+ myFeedPath = '/my-feed';
}
- return (
- <>
-
-
- >
- );
- };
+ const items: (SidebarMenuItem | false)[] = [
+ isLoggedIn
+ ? {
+ title: 'For You',
+ path: myFeedPath,
+ action: () =>
+ onNavTabClick?.(
+ isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/',
+ ),
+ icon: (active: boolean) => (
+ } />
+ ),
+ }
+ : {
+ title: 'Home',
+ path: '/',
+ action: () => onNavTabClick?.('/'),
+ icon: (active: boolean) => (
+ } />
+ ),
+ },
+ {
+ title: 'Following',
+ path: '/following',
+ action: () => onNavTabClick?.(OtherFeedPage.Following),
+ requiresLogin: true,
+ icon: (active: boolean) => (
+ } />
+ ),
+ },
+ isLoggedIn && {
+ title: 'Notifications',
+ path: `${webappUrl}notifications`,
+ isForcedLink: true,
+ icon: (active: boolean) => (
+ } />
+ ),
+ rightIcon: countBadge(unreadCount),
+ },
+ showQuests && {
+ title: 'Quests',
+ path: `${webappUrl}game-center`,
+ isForcedLink: true,
+ icon: (active: boolean) => (
+ } />
+ ),
+ rightIcon: countBadge(claimableQuestCount),
+ },
+ {
+ title: 'Explore',
+ path: '/posts',
+ action: () => onNavTabClick?.(OtherFeedPage.Explore),
+ icon: (active: boolean) => (
+ } />
+ ),
+ },
+ {
+ title: 'Happening Now',
+ path: `${webappUrl}highlights`,
+ isForcedLink: true,
+ requiresLogin: true,
+ icon: (active: boolean) => (
+ } />
+ ),
+ },
+ {
+ title: 'History',
+ path: `${webappUrl}history`,
+ isForcedLink: true,
+ requiresLogin: true,
+ icon: (active: boolean) => (
+ } />
+ ),
+ },
+ ];
- const renderSelectedSection = (): ReactElement =>
- renderCategorySection(selectedCategory);
-
- const selectedLabel = sidebarCategories.find(
- (category) => category.id === selectedCategory,
- )?.label;
- const isSettingsSelected = selectedCategory === SidebarCategory.Settings;
- const isNotificationsSelected =
- selectedCategory === SidebarCategory.Notifications;
- const isHomePanel = selectedCategory === SidebarCategory.Main;
- const isSquadsPanel = selectedCategory === SidebarCategory.Squads;
- const isUtilityPanelSelected = !isHomePanel;
- const utilityPanelTitle = (() => {
- if (isSettingsSelected) {
- return 'Settings';
- }
- if (isNotificationsSelected) {
- return 'Notifications';
- }
- return selectedLabel ?? '';
- })();
-
- // 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;
+ return items.filter(Boolean) as SidebarMenuItem[];
+ }, [
+ claimableQuestCount,
+ countBadge,
+ isCustomDefaultFeed,
+ isLoggedIn,
+ onNavTabClick,
+ showQuests,
+ unreadCount,
+ ]);
return (
- {isExpanded && (
-
- )}
-
-
- {/*
- 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.
- Hidden on settings pages, where the panel is force-expanded and
- collapsing it would hide the only settings navigation.
- */}
- {!forceExpanded && (
-
-
+ {/* Definite-height, clipped flex column so the nav scrolls and the
+ header/footer stay pinned on-screen regardless of list length. */}
+
+ {/* Settings take over the panel with a single Back control. When
+ open, the full profile/streak/compose top bar shows; collapsed,
+ the rail shows the same controls as centered icons. */}
+ {forceExpanded && (
+
-