diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx
index e87af9e1af4..f7c00cd0297 100644
--- a/packages/shared/src/components/MainFeedLayout.tsx
+++ b/packages/shared/src/components/MainFeedLayout.tsx
@@ -77,6 +77,7 @@ import { ClientQuestEventType } from '../graphql/quests';
import { ProfileEmptyScreen } from './profile/ProfileEmptyScreen';
import { Origin } from '../lib/log';
import { ExploreTabs, tabToUrl, urlToTab } from './header';
+import { FeedExploreTabs } from './header/FeedExploreTabs';
import { QueryStateKeys, useQueryState } from '../hooks/utils/useQueryState';
import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout';
import useCustomDefaultFeed from '../hooks/feed/useCustomDefaultFeed';
@@ -85,8 +86,6 @@ 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(
() =>
@@ -250,7 +249,6 @@ export default function MainFeedLayout({
isPopular,
isAnyExplore,
isExploreLatest,
- isDiscussed,
isSortableFeed,
isCustomFeed,
isSearch: isSearchPage,
@@ -707,14 +705,11 @@ export default function MainFeedLayout({
);
}, [isLaptop, onTabChange, tab]);
- // v2 hoists the explore section tabs into the floating card's
- // page-header strip (matching the SquadDirectoryLayout pattern). The
- // inline FeedExploreComponent is suppressed below to avoid showing
- // the same tabs twice.
- // 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 reaches the Explore hub sections (Explore, Tags, Sources, Leaderboard,
+ // Discussions) from the sidebar's Explore panel, so the page header no longer
+ // carries a section-tab strip. The header now only hosts the Explore sort
+ // dropdown, so it's gated on isAnyExplore.
+ const showExploreV2PageHeader = isAnyExplore && isV2;
// v2 also hoists the regular page-header strip up here, OUTSIDE
// `FeedPageLayoutComponent`, so it can span the full floating-card
@@ -753,8 +748,9 @@ export default function MainFeedLayout({
<>
{showExploreV2PageHeader && (
-
- {isAnyExplore && }
+ {/* Sort options as pill tabs — same navbar as the Tags / Squad
+ directory pages, not the underlined TabContainer. */}
+
)}
{showFeedV2PageHeader && (
diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx
index d8c81cfbd12..d7e989a6d7d 100644
--- a/packages/shared/src/components/header/ExploreHubHeader.tsx
+++ b/packages/shared/src/components/header/ExploreHubHeader.tsx
@@ -1,19 +1,32 @@
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
+import { useRouter } from 'next/router';
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).
+// The Explore hub sections live in the sidebar's Explore panel, so the page
+// header is just the standard title strip (same as Analytics / Settings) —
+// no breadcrumb, no icon. The title is derived from the route.
+const hubTitles: { match: (path: string) => boolean; label: string }[] = [
+ { match: (path) => path.startsWith('/sources'), label: 'Sources' },
+ { match: (path) => path.startsWith('/users'), label: 'Leaderboard' },
+];
+
+// Shared v2 header for the Explore hub's directory pages (Sources, Leaderboard).
+// Optional children render as header actions (e.g. "Suggest source").
export function ExploreHubHeader({
children,
}: {
children?: ReactNode;
}): ReactElement {
+ const router = useRouter();
+ // asPath-first (the resolved URL) — consistent with FeedExploreTabs and
+ // correct for dynamic routes where pathname is the template.
+ const path = (router.asPath || router.pathname || '').split('?')[0];
+ const title =
+ hubTitles.find((entry) => entry.match(path))?.label ?? 'Explore';
+
return (
- } className="!py-0">
+
{children}
);
diff --git a/packages/shared/src/components/header/ExploreSectionTabs.tsx b/packages/shared/src/components/header/ExploreSectionTabs.tsx
deleted file mode 100644
index 31fa09ba867..00000000000
--- a/packages/shared/src/components/header/ExploreSectionTabs.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-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/FeedExploreHeader.tsx b/packages/shared/src/components/header/FeedExploreHeader.tsx
index f8a70eff8cf..b52c13b45ca 100644
--- a/packages/shared/src/components/header/FeedExploreHeader.tsx
+++ b/packages/shared/src/components/header/FeedExploreHeader.tsx
@@ -3,7 +3,7 @@ import React from 'react';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import { BreadCrumbs } from './BreadCrumbs';
-import { CalendarIcon, HotIcon } from '../icons';
+import { CalendarIcon, CompassIcon } from '../icons';
import { IconSize } from '../Icon';
import TabList from '../tabs/TabList';
import { Tab, TabContainer } from '../tabs/TabContainer';
@@ -91,7 +91,7 @@ export function FeedExploreHeader({
isListMode && 'tablet:pt-4 laptop:pt-5',
)}
>
- Explore
+ Explore
)}
+
+
+ {Object.entries(urlToTab).map(([url, label]) => (
+
+ ))}
+
{sortsWithPeriod.includes(activeTab) && (
}
buttonSize={ButtonSize.Small}
buttonVariant={ButtonVariant.Float}
- // Render the date filter as a true icon-only square button (the
- // shared Dropdown otherwise lays its trigger out as a full-width
- // value field). Matches the design system's icon-only Small spec
- // (`IconOnlySizeToClassName`) and the v2 layout's compact icon
- // buttons: 32px square, rounded-10, no padding.
className={{ button: '!size-8 !rounded-10 !p-0' }}
selectedIndex={period}
options={periodTexts}
@@ -50,19 +62,6 @@ export function ExploreSortDropdown(): ReactElement {
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/Compass/filled.svg b/packages/shared/src/components/icons/Compass/filled.svg
new file mode 100644
index 00000000000..a35933d348e
--- /dev/null
+++ b/packages/shared/src/components/icons/Compass/filled.svg
@@ -0,0 +1,8 @@
+
diff --git a/packages/shared/src/components/icons/Compass/index.tsx b/packages/shared/src/components/icons/Compass/index.tsx
new file mode 100644
index 00000000000..be896e9567d
--- /dev/null
+++ b/packages/shared/src/components/icons/Compass/index.tsx
@@ -0,0 +1,12 @@
+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 CompassIcon = (props: IconProps): ReactElement => (
+
+);
+
+export default CompassIcon;
diff --git a/packages/shared/src/components/icons/Compass/outlined.svg b/packages/shared/src/components/icons/Compass/outlined.svg
new file mode 100644
index 00000000000..901232ee7fe
--- /dev/null
+++ b/packages/shared/src/components/icons/Compass/outlined.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts
index a8506ac6745..feaea9e0065 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 './Compass';
export * from './Cookie';
export * from './Copy';
export * from './Core';
diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx
index 0b17ebff20a..dc821bb48c0 100644
--- a/packages/shared/src/components/notifications/NotificationsBell.tsx
+++ b/packages/shared/src/components/notifications/NotificationsBell.tsx
@@ -64,9 +64,15 @@ function NotificationsBell({
@@ -84,7 +90,7 @@ function NotificationsBell({
)}
{!railHideLabel && (
- Alerts
+ Activity
)}
diff --git a/packages/shared/src/components/notifications/Toast.tsx b/packages/shared/src/components/notifications/Toast.tsx
index d69971845be..91209163915 100644
--- a/packages/shared/src/components/notifications/Toast.tsx
+++ b/packages/shared/src/components/notifications/Toast.tsx
@@ -102,9 +102,11 @@ const Toast = ({
return;
}
- // Auto-dismiss (and the countdown ring) only when the setting is on and the
- // toast isn't explicitly persistent; otherwise it stays until dismissed.
- const shouldAutoDismiss = autoDismissNotifications && !toast.persistent;
+ // Auto-dismiss (and the countdown ring) when the setting is on — or a toast
+ // forces it — and the toast isn't explicitly persistent; otherwise it stays
+ // until dismissed.
+ const shouldAutoDismiss =
+ (autoDismissNotifications || toast.forceAutoDismiss) && !toast.persistent;
if (!toastRef.current) {
toastRef.current = toast;
@@ -133,9 +135,13 @@ const Toast = ({
return;
}
- // No running countdown when auto-dismiss is off or the toast is persistent,
- // so clear directly; otherwise let the timed animation play out.
- if (!autoDismissNotifications || acted.persistent) {
+ // No running countdown when auto-dismiss is off (and not forced) or the
+ // toast is persistent, so clear directly; otherwise let the timed animation
+ // play out.
+ if (
+ (!autoDismissNotifications && !acted.forceAutoDismiss) ||
+ acted.persistent
+ ) {
toastRef.current = null;
client.setQueryData(TOAST_NOTIF_KEY, null);
return;
@@ -192,7 +198,8 @@ const Toast = ({
// The dismiss ring is the auto-dismiss countdown made visible, so it shows
// exactly when the toast will auto-dismiss (setting on + not persistent).
// dashoffset drains 0→100 as the remaining time elapses.
- const shouldAutoDismiss = autoDismissNotifications && !isPersistentToast;
+ const shouldAutoDismiss =
+ (autoDismissNotifications || toast.forceAutoDismiss) && !isPersistentToast;
const showRing = shouldAutoDismiss && toast.timer > 0;
const remaining = toast.timer > 0 ? (timer / toast.timer) * 100 : 0;
const dashoffset = Math.min(100, Math.max(0, 100 - remaining));
diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
index e2bae5205de..80704bfac26 100644
--- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
+++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
@@ -225,11 +225,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => {
icon: EyeIcon,
href: `${settingsUrl}/customization/gamification`,
},
- streaks: {
- title: 'Streaks',
- icon: HotIcon,
- href: `${settingsUrl}/customization/streaks`,
- },
...(!optOutAchievements && {
achievements: {
title: 'Achievements',
diff --git a/packages/shared/src/components/quest/CompactQuestList.tsx b/packages/shared/src/components/quest/CompactQuestList.tsx
new file mode 100644
index 00000000000..f68a0818485
--- /dev/null
+++ b/packages/shared/src/components/quest/CompactQuestList.tsx
@@ -0,0 +1,220 @@
+import type { ReactElement } from 'react';
+import React, { useMemo } from 'react';
+import classNames from 'classnames';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import ProgressCircle from '../ProgressCircle';
+import { ArrowIcon, CoreIcon, ReputationIcon, VIcon } from '../icons';
+import { IconSize } from '../Icon';
+import Link from '../utilities/Link';
+import { webappUrl } from '../../lib/constants';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
+import type { QuestReward, UserQuest } from '../../graphql/quests';
+import { QuestRewardType, QuestStatus } from '../../graphql/quests';
+import { useQuestDashboard } from '../../hooks/useQuestDashboard';
+import { useClaimQuestReward } from '../../hooks/useClaimQuestReward';
+
+const rewardColorByType: Record = {
+ // XP reads as plain white, normal weight (de-emphasized); Cores/Reputation
+ // stay bold + colour-coded so they pop.
+ [QuestRewardType.Xp]: 'text-text-primary',
+ [QuestRewardType.Cores]: 'text-accent-cheese-default',
+ [QuestRewardType.Reputation]: 'text-accent-onion-default',
+};
+
+// The reward "value" of a quest — the thing that answers "what do I get?".
+const QuestRewardValue = ({
+ reward,
+}: {
+ reward: QuestReward;
+}): ReactElement => (
+
+ {reward.type === QuestRewardType.Cores && }
+ {reward.type === QuestRewardType.Reputation && (
+
+ )}
+
+ {reward.amount}
+ {reward.type === QuestRewardType.Xp ? ' XP' : ''}
+
+
+);
+
+interface CompactQuestRowProps {
+ quest: UserQuest;
+ isClaiming: boolean;
+ onClaim: (quest: UserQuest) => void;
+}
+
+const CompactQuestRow = ({
+ quest,
+ isClaiming,
+ onClaim,
+}: CompactQuestRowProps): ReactElement => {
+ const target = Math.max(quest.quest.targetCount, 1);
+ const value = Math.min(Math.max(quest.progress, 0), target);
+ const percentage = Math.min(100, Math.round((value / target) * 100));
+ const isClaimed =
+ quest.status === QuestStatus.Claimed || Boolean(quest.claimedAt);
+ const canClaim = quest.claimable && !!quest.userQuestId && !isClaimed;
+
+ return (
+ // Rounded hover pill + `hover:bg-surface-hover`, matching every other v2
+ // panel list row (the px-3 container gives the same ~12px inset as `mx-3`).
+
+ {/* Title row: title takes the full width with the open-details chevron in
+ the top-right corner. */}
+
+
+ {/* Stretched link — its `before` covers the whole row (the row is
+ `relative`), so clicking anywhere on the quest opens the Game
+ Center. The Claim button opts out via `relative z-1`; the
+ chevron/progress sit under the link so they navigate too. */}
+
+
+ {quest.quest.name}
+
+
+
+
+
+ {quest.quest.description && (
+
+ {quest.quest.description}
+
+ )}
+
+ {/* Bottom strip: XP/Cores rewards on the left; status aligned to the right
+ on the same row — claim/claimed, or the step count + small radial. */}
+
+
+ {quest.rewards.map((reward) => (
+
+ ))}
+
+
+ {canClaim && (
+
+ )}
+ {!canClaim && isClaimed && (
+
+
+ Claimed
+
+ )}
+ {!canClaim && !isClaimed && (
+ <>
+ {/* Step count on the LEFT of the radial. */}
+
+ {value}/{target}
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+// Compact quest list for the streak panel: rounded hover-pill rows (matching
+// the other v2 panels) — title + subtitle full width with the rewards beneath,
+// and a right column holding the open-details chevron + step count/radial (or
+// claim/claimed). Daily and weekly quests are merged into one list under the
+// panel's "Daily quests" header. Replaces the full QuestButton dashboard.
+export const CompactQuestList = (): ReactElement | null => {
+ const { data } = useQuestDashboard();
+ const {
+ mutate: claim,
+ isPending: isClaiming,
+ variables,
+ } = useClaimQuestReward();
+ const claimingId = isClaiming ? variables?.userQuestId : undefined;
+
+ const quests = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+ return [
+ ...data.daily.regular,
+ ...data.daily.plus,
+ ...data.weekly.regular,
+ ...data.weekly.plus,
+ ];
+ }, [data]);
+
+ if (!data) {
+ return null;
+ }
+
+ if (quests.length === 0) {
+ return (
+
+ You're all caught up — new quests tomorrow.
+
+ );
+ }
+
+ const onClaim = (quest: UserQuest) =>
+ claim({
+ userQuestId: quest.userQuestId as string,
+ questId: quest.quest.id,
+ questType: quest.quest.type,
+ });
+
+ return (
+
+ {quests.map((quest) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/shared/src/components/sidebar/RailMoreMenu.tsx b/packages/shared/src/components/sidebar/RailMoreMenu.tsx
new file mode 100644
index 00000000000..bd805375cf2
--- /dev/null
+++ b/packages/shared/src/components/sidebar/RailMoreMenu.tsx
@@ -0,0 +1,71 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { useRef } from 'react';
+import classNames from 'classnames';
+import { MenuIcon } from '../icons';
+import { IconSize } from '../Icon';
+import { railTabClass, railTabLabelClass } from './common';
+import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup';
+import { useOutsideClick } from '../../hooks/utils/useOutsideClick';
+import { RootPortal } from '../tooltips/Portal';
+import { useAnchoredRailPopup } from './useAnchoredRailPopup';
+
+// A rail "More" item: a three-dots + label button that opens a click dropdown
+// with the exact same chrome, placement and behavior as the Support/Settings
+// popups and the shortcuts customize tray. Used when the rail is too short to
+// show the tabs inline — the dropdown then holds the tabs (and a Shortcuts
+// category). Shares the rail popup group so it's mutually exclusive with the
+// other rail popups.
+export const RailMoreMenu = ({
+ label = 'More',
+ compact = false,
+ children,
+}: {
+ label?: string;
+ compact?: boolean;
+ children: ReactNode;
+}): ReactElement => {
+ const { isOpen, onUpdate, wrapHandler } = useInteractivePopup('sidebar-rail');
+ const btnRef = useRef(null);
+ const popupRef = useRef(null);
+ useOutsideClick(popupRef, () => onUpdate(false), isOpen);
+ const pos = useAnchoredRailPopup(btnRef, isOpen);
+
+ return (
+ <>
+
+ {isOpen && pos && (
+
+
+ {children}
+
+
+ )}
+ >
+ );
+};
diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx
index f5fe7fcff4e..499297cab1d 100644
--- a/packages/shared/src/components/sidebar/Section.tsx
+++ b/packages/shared/src/components/sidebar/Section.tsx
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ReactElement } from 'react';
-import React, { useRef } from 'react';
+import React, { useState } from 'react';
import type { ItemInnerProps, SidebarMenuItem } from './common';
import { NavHeader, NavSection } from './common';
import { SidebarItem } from './SidebarItem';
@@ -11,6 +11,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext';
import { isNullOrUndefined } from '../../lib/func';
import useSidebarRendered from '../../hooks/useSidebarRendered';
import Link from '../utilities/Link';
+import { HorizontalSeparator } from '../utilities';
export interface SectionCommonProps
extends Pick {
@@ -57,16 +58,19 @@ export function Section({
// always expanded.
const initialIsVisible =
!title || isNullOrUndefined(currentFlagValue) ? true : currentFlagValue;
- const isVisible = useRef(initialIsVisible);
+ // State (not a ref) so toggling re-renders even for sections without a
+ // persisted `flag` (e.g. the settings panel groups) — otherwise the
+ // collapse never visibly happens.
+ const [isVisible, setIsVisible] = useState(initialIsVisible);
const toggleFlag = () => {
- const nextIsVisible = !isVisible.current;
+ const nextIsVisible = !isVisible;
if (flag) {
updateFlag(flag, nextIsVisible);
}
- isVisible.current = nextIsVisible;
+ setIsVisible(nextIsVisible);
};
return (
@@ -97,7 +101,7 @@ export function Section({
type="button"
onClick={toggleFlag}
aria-label={`Toggle ${title}`}
- aria-expanded={!!isVisible.current}
+ aria-expanded={!!isVisible}
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"
>
@@ -122,7 +126,7 @@ export function Section({
compact
? 'opacity-0 transition-[transform,opacity] group-focus-within/section:opacity-100 group-hover/section:opacity-100'
: 'h-2.5 w-2.5 transition-transform',
- isVisible.current ? 'rotate-180' : 'rotate-90',
+ isVisible ? 'rotate-180' : 'rotate-90',
)}
/>
@@ -157,7 +161,7 @@ export function Section({
// 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
+ !title || isVisible || shouldAlwaysBeVisible
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
@@ -168,15 +172,22 @@ export function Section({
compact && 'gap-px',
)}
>
- {items.map((item) => (
-
- ))}
+ {items.map((item) =>
+ item.isSeparator ? (
+
+ ) : (
+
+ ),
+ )}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
index 066b1959b63..750fd313b7e 100644
--- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
+++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
@@ -10,6 +10,23 @@ import React, {
import { useRouter } from 'next/router';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import type { DragEndEvent } from '@dnd-kit/core';
+import {
+ SortableContext,
+ arrayMove,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import {
+ createSidebarSeparatorItem,
+ isSidebarItemActive,
ListIcon,
Nav,
railTabClass,
@@ -24,28 +41,21 @@ import type { SidebarCategoryId } from './sidebarCategory';
import { 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 { RecentSection } from './sections/RecentSection';
-import { CustomFeedSection } from './sections/CustomFeedSection';
+import { ExploreSection } from './sections/ExploreSection';
+import { ProfilePanelSection } from './sections/ProfilePanelSection';
import { SettingsPanelSection } from './sections/SettingsPanelSection';
import type { ComposerKind } from '../post/composer/types';
-import { QuestRailIcon } from '../quest/QuestRailIcon';
import { useClaimableQuestCount } from '../../hooks/useQuestDashboard';
import { Bubble } from '../tooltips/utils';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
-import { BookmarkSection } from './sections/BookmarkSection';
import { NetworkSection } from './sections/NetworkSection';
-import { GameCenterSection } from './sections/GameCenterSection';
+import { StreakQuestsSection } from './sections/StreakQuestsSection';
import { HelpWidget } from '../help/HelpWidget';
import {
- AnalyticsIcon,
- AppIcon,
BellIcon,
- BookmarkIcon,
BrowserGroupIcon,
+ CompassIcon,
CreditCardIcon,
- DevCardIcon,
DocsIcon,
EditIcon,
ExitIcon,
@@ -54,16 +64,14 @@ import {
GiftIcon,
HelpIcon,
HomeIcon,
- InviteIcon,
- JobIcon,
+ HotIcon,
+ JoystickIcon,
LinkIcon,
MegaphoneIcon,
- MenuIcon,
MicrophoneIcon,
MoveToIcon,
NewPostIcon,
PhoneIcon,
- PlusIcon,
PollIcon,
PrivacyIcon,
SearchIcon,
@@ -74,19 +82,26 @@ import {
TrendingIcon,
} from '../icons';
import { useSettingsBooleanFlag } from '../../hooks/useSettingsBooleanFlag';
-import { Origin, TargetId } from '../../lib/log';
import { IconSize } from '../Icon';
import { Tooltip } from '../tooltip/Tooltip';
import { RailHoverPanel } from './RailHoverPanel';
-import { StreakPopover } from './StreakPopover';
-import { StreakRing } from './StreakRing';
+import { StreakBadge } from './StreakBadge';
+import {
+ SidebarShortcutsDock,
+ useSidebarShortcutItems,
+} from './SidebarShortcutsDock';
+import { RailMoreMenu } from './RailMoreMenu';
+import {
+ SidebarDragStateProvider,
+ useSidebarDragState,
+} from './useSidebarDragState';
import { useSpotlight } from '../spotlight/SpotlightContext';
import { useAuthContext } from '../../contexts/AuthContext';
import NotificationsBell from '../notifications/NotificationsBell';
import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel';
import { ProfilePicture, ProfileImageSize } from '../ProfilePicture';
-import { SidebarProfileStats } from './SidebarProfileStats';
import Link from '../utilities/Link';
+import { SharedFeedPage, HorizontalSeparator } from '../utilities';
import {
appsUrl,
businessWebsiteUrl,
@@ -98,27 +113,28 @@ import {
termsOfService,
webappUrl,
} from '../../lib/constants';
-import { isAppleDevice } from '../../lib/func';
+import { isAppleDevice, isExtension } from '../../lib/func';
import LogoIcon from '../../svg/LogoIcon';
import InteractivePopup, {
InteractivePopupPosition,
} from '../tooltips/InteractivePopup';
import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup';
+import usePersistentContext from '../../hooks/usePersistentContext';
import { ProfileSection as ProfileMenuSection } from '../ProfileMenu/ProfileSection';
import type { ProfileSectionItemProps } from '../ProfileMenu/ProfileSectionItem';
-import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader';
import { ThemeSection } from '../ProfileMenu/sections/ThemeSection';
-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 { useSquadNavigation } from '../../hooks';
-import { useAddBookmarkFolder } from '../../hooks/bookmark/useAddBookmarkFolder';
+import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed';
import { useStreakRingState } from '../../hooks/streaks/useStreakRingState';
import { FeedbackWidget } from '../feedback';
-import { HorizontalSeparator } from '../utilities';
-import { Typography, TypographyType } from '../typography/Typography';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
type SidebarCategoryConfig = {
id: SidebarCategoryId;
@@ -130,8 +146,19 @@ type SidebarCategoryConfig = {
const sidebarCategories: SidebarCategoryConfig[] = [
{
id: SidebarCategory.Main,
- label: 'Home',
- defaultPath: webappUrl,
+ label: 'Explore',
+ defaultPath: `${webappUrl}posts`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ // Rendered via the avatar (not the tablist loop); listed here so panel
+ // title / label lookups resolve. The icon is unused — the avatar renders
+ // the user's profile picture.
+ id: SidebarCategory.Profile,
+ // Surfaced as the panel title and the avatar tooltip/label.
+ label: 'You',
icon: (active) => (
),
@@ -145,32 +172,23 @@ const sidebarCategories: SidebarCategoryConfig[] = [
),
},
{
+ // The reading-streak tab. Its panel leads with the streak details, then a
+ // Game Center link and the daily quests. Clicking the rail icon lands on
+ // Game Center; the streak/quests detail is one hover away in the panel.
id: SidebarCategory.GameCenter,
- 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
- // via the hover panel.
- defaultPath: `${webappUrl}daily-quests`,
- icon: (active) => ,
- },
- {
- id: SidebarCategory.Saved,
- label: 'Saved',
- defaultPath: `${webappUrl}bookmarks`,
+ label: 'Streak',
+ defaultPath: `${webappUrl}game-center`,
icon: (active) => (
-
+
),
},
];
const railButtonClass =
- 'flex size-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-[background-color,color,transform] duration-150 ease-out hover:bg-surface-hover hover:text-text-primary active:scale-90 motion-reduce:transition-none 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`;
@@ -186,6 +204,36 @@ const RAIL_TOOLTIP_COLLISION_PADDING = 4;
// slightly past the panel's top/bottom edge while arcing in without losing it.
const SAFE_ZONE_BUFFER = 26;
+// Wraps a rail category tab so it can be reordered by cursor drag. Drag
+// listeners sit on this outer element; the tab's own button stays the focus
+// target. PointerSensor's distance constraint (set on the DndContext) means a
+// plain click still selects the category instead of starting a drag.
+const SortableRailTab = ({
+ id,
+ children,
+}: {
+ id: string;
+ children: ReactNode;
+}): ReactElement => {
+ const { setNodeRef, listeners, transform, transition, isDragging } =
+ useSortable({ id });
+ return (
+
+ {children}
+
+ );
+};
+
interface RailHoverCardProps {
label: string;
children: ReactNode;
@@ -208,13 +256,19 @@ const RailHoverCard = ({
// up after navigation.
const [open, setOpen] = useState(false);
const suppressOpenRef = useRef(false);
+ // Never let the panel pop open while something is being dragged — its portal
+ // (z-tooltip) would render over the drag ghost.
+ const { isDragging } = useSidebarDragState();
- const handleOpenChange = useCallback((next: boolean) => {
- if (next && suppressOpenRef.current) {
- return;
- }
- setOpen(next);
- }, []);
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ if (next && (suppressOpenRef.current || isDragging)) {
+ return;
+ }
+ setOpen(next);
+ },
+ [isDragging],
+ );
const handleTriggerClick = useCallback(() => {
suppressOpenRef.current = true;
@@ -232,7 +286,7 @@ const RailHoverCard = ({
{panel}
@@ -332,7 +386,7 @@ const SidebarSupportButton = (): ReactElement => {
closeOutsideClick
onClose={() => onUpdate(false)}
position={InteractivePopupPosition.SidebarSupportMenu}
- className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
+ className="animate-rail-popup-in flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
>
@@ -376,94 +430,19 @@ const createMenuOptions: {
},
];
-// 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();
+// Account/app controls that used to live in the avatar dropdown now sit behind
+// a bottom-rail gear (sibling to Invite/Support). Profile-related items moved
+// to the avatar panel; this keeps the leftover account/app/billing actions.
+const SidebarSettingsButton = (): ReactElement => {
+ const { 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 settingsItems: ProfileSectionItemProps[] = [
+ { title: 'Settings', href: settingsDefaultPath, icon: SettingsIcon },
+ { title: 'Appearance', href: `${settingsUrl}/appearance`, icon: EyeIcon },
];
const billingItems: ProfileSectionItemProps[] = [
@@ -489,16 +468,6 @@ const SidebarProfileButton = ({
},
];
- 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',
@@ -509,134 +478,108 @@ const SidebarProfileButton = ({
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"
+ position={InteractivePopupPosition.SidebarSupportMenu}
+ className="animate-rail-popup-in flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
>
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)}
>
);
};
+// The avatar is a rail tab: it opens the Profile context panel (your feeds,
+// activity, pins, custom feeds) like every other category — no dropdown menu.
+// Styled identically to the category tabs (icon + label below, same hover and
+// selected states) — the profile picture stands in for the glyph icon.
+const SidebarProfileButton = ({
+ isSelected,
+ isPreviewing,
+ isCompact,
+ isExpanded,
+ panel,
+ onSelect,
+ onPreview,
+ onPreviewLeave,
+}: {
+ isSelected: boolean;
+ isPreviewing: boolean;
+ isCompact: boolean;
+ isExpanded: boolean;
+ panel: ReactElement;
+ onSelect: () => void;
+ onPreview: () => void;
+ onPreviewLeave: (event: React.MouseEvent) => void;
+}): ReactElement | null => {
+ const { user } = useAuthContext();
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
type SidebarDesktopV2Props = {
activePage?: string;
featureTheme?: {
@@ -665,14 +608,35 @@ export const SidebarDesktopV2 = ({
toggleSidebarExpanded,
loadedSettings,
optOutQuestSystem,
+ optOutReadingStreak,
+ isGamificationEnabled,
} = useSettingsContext();
+ // The reading-streak rail tab: hidden entirely when all gamification is off;
+ // when only streaks are off (but quests/levels/etc. remain) it stays but reads
+ // as the broader "Quests" / Game Center tab instead of a streak.
+ const showGameCenterTab = isGamificationEnabled;
+ const isStreakTabAStreak = !optOutReadingStreak;
+ // Short label under the rail tab icon (and the More-menu row): "Streak" /
+ // "Quests" — kept compact for the narrow rail (the tab usually shows the day
+ // count anyway).
+ const gameCenterLabel = isStreakTabAStreak ? 'Streak' : 'Quests';
+ // Fuller title for the panel + hover card: "Current Streak" when streaks are
+ // on (the panel leads with the current-streak hero), else "Daily Quests".
+ const gameCenterPanelTitle = isStreakTabAStreak
+ ? 'Current Streak'
+ : 'Daily Quests';
const { logEvent } = useLogContext();
const { isAvailable: isBannerAvailable } = useBanner();
const { open: openSpotlight } = useSpotlight();
- const { openModal } = useLazyModal();
- const { isLoggedIn } = useAuthContext();
- const { openNewSquad } = useSquadNavigation();
- const addBookmarkFolder = useAddBookmarkFolder();
+ const { openModal, modal } = useLazyModal();
+ const { isLoggedIn, user } = useAuthContext();
+ const { isCustomDefaultFeed } = useCustomDefaultFeed();
+ // The flat Home button targets the "For You" feed. On extension there's no
+ // router, so it always uses the explicit /my-feed path.
+ let myFeedPath = isCustomDefaultFeed ? '/my-feed' : '/';
+ if (isExtension) {
+ myFeedPath = '/my-feed';
+ }
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
@@ -686,65 +650,191 @@ export const SidebarDesktopV2 = ({
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);
+ // Drives the Streak tab's status: flame fills once you've read today and is
+ // tinted by state (safe / at-risk / critical / freeze); the label shows the
+ // day count. Reuses the same state machine the avatar streak ring used.
+ const {
+ isEnabled: isStreakEnabled,
+ state: streakState,
+ count: streakCount,
+ hasReadToday: streakReadToday,
+ copy: streakCopy,
+ } = useStreakRingState();
+
+ // The reorderable rail tabs (each opens a panel), including the avatar/"You"
+ // tab so it can be moved too. Order is user-customizable via drag-and-drop and
+ // persisted; logo / New post / settings are fixed and live outside this list.
+ const reorderableCategories = useMemo(
+ () =>
+ [
+ isLoggedIn ? SidebarCategory.Profile : null,
+ SidebarCategory.Main,
+ SidebarCategory.Squads,
+ isLoggedIn ? SidebarCategory.Notifications : null,
+ // Drops out of the rail entirely when all gamification is opted out.
+ showGameCenterTab ? SidebarCategory.GameCenter : null,
+ ].filter(Boolean) as SidebarCategoryId[],
+ [isLoggedIn, showGameCenterTab],
+ );
+ const [storedRailOrder, setStoredRailOrder] = usePersistentContext<
+ SidebarCategoryId[]
+ >('sidebar_rail_order', reorderableCategories);
+ // Reconcile the saved order against the valid set: drop unknown/stale ids and
+ // surface any newly-added category that isn't in the stored order yet at the
+ // front (e.g. the avatar tab for users who saved an order before it existed).
+ const railOrder = useMemo(() => {
+ const known = (storedRailOrder ?? []).filter((id) =>
+ reorderableCategories.includes(id),
+ );
+ const missing = reorderableCategories.filter((id) => !known.includes(id));
+ return [...missing, ...known];
+ }, [reorderableCategories, storedRailOrder]);
+
+ // Overflow, measured against the content-independent (flex-1) height of the
+ // lower region that holds the tabs + dock — so folding never changes the
+ // measurement and it can't oscillate. Above the threshold tabs are inline and
+ // the dock scrolls; below it (short viewport) the whole rail folds into one
+ // click "More" menu (tabs list + Shortcuts category).
+ const SHORTCUTS_MIN_INLINE = 3;
+ const lowerRegionRef = useRef(null);
+ const [regionHeight, setRegionHeight] = useState(Number.POSITIVE_INFINITY);
useEffect(() => {
- const list = navListRef.current;
- if (!list || typeof ResizeObserver === 'undefined') {
+ const region = lowerRegionRef.current;
+ if (!region || 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;
+ if (region.clientHeight > 0) {
+ setRegionHeight(region.clientHeight);
}
- const slots = Math.floor((list.clientHeight + GAP) / (itemHeight + GAP));
- setMaxNavSlots(Math.max(0, slots));
};
measure();
const observer = new ResizeObserver(measure);
- observer.observe(list);
+ observer.observe(region);
return () => observer.disconnect();
- }, [isCompact]);
+ }, []);
- const overflowOrder = useMemo(
- () =>
- [
- isLoggedIn ? SidebarCategory.Notifications : null,
- SidebarCategory.GameCenter,
- SidebarCategory.Saved,
- ].filter(Boolean) as SidebarCategoryId[],
- [isLoggedIn],
+ const { resolved: shortcutItems } = useSidebarShortcutItems();
+ const shortcutCount = isLoggedIn ? shortcutItems.length : 0;
+ const iconRowPx = 40 + 4; // shortcut dot row (height + gap)
+ const tabRowPx = (isCompact ? 44 : 56) + 4; // tab / "More" row (height + gap)
+ const SEP_PX = 12; // framing separator + its vertical margins
+ const tabCount = railOrder.length;
+ const minDockPx =
+ shortcutCount > 0 ? SEP_PX + SHORTCUTS_MIN_INLINE * iconRowPx : 0;
+ // Progressive overflow (a "priority+" rail). Stage 1: everything fits — all
+ // tabs inline plus a usefully-sized, scrollable shortcuts dock; no "More".
+ const fitsAllInline = regionHeight >= tabCount * tabRowPx + minDockPx;
+ // Otherwise a "More" tab (same row height) collects the overflow. Keep as
+ // many tabs inline as fit ABOVE that row and drop the lowest-priority tabs
+ // (end of railOrder) into More ONE AT A TIME as the viewport shrinks — never
+ // all at once, which left the rail looking empty. The inline dock is dropped
+ // here; its shortcuts move into the same More menu (one combined dropdown).
+ const tabsThatFitWithMore = Math.max(
+ 0,
+ Math.floor((regionHeight - tabRowPx) / tabRowPx),
+ );
+ const visibleTabCount = fitsAllInline
+ ? tabCount
+ : Math.min(tabCount, tabsThatFitWithMore);
+ const visibleCategoryIds = railOrder.slice(0, visibleTabCount);
+ const overflowTabIds = fitsAllInline ? [] : railOrder.slice(visibleTabCount);
+ // More is needed when any tab overflows, or when all tabs still fit inline
+ // but the shortcuts can't get a usable inline dock (so they collapse in).
+ const moreNeeded =
+ isLoggedIn &&
+ (overflowTabIds.length > 0 || (!fitsAllInline && shortcutCount > 0));
+ // Render the dock whenever it fits inline — even with zero shortcuts — so its
+ // customize "•••" button is present and can reveal on rail hover to let you
+ // add the first shortcut. (Gating on shortcutCount>0 hid the only entry point
+ // when empty.) The framing separator shows whenever the dock is rendered (not
+ // gated on shortcutCount) so adding the first shortcut doesn't shift anything:
+ // the empty "•••" and the with-shortcuts state look identical.
+ const showInlineDock = isLoggedIn && fitsAllInline;
+
+ const railSensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ );
+ const isDraggingRef = useRef(false);
+ // Shared "any sidebar drag active" flag. Every drag system (tab reorder, the
+ // shortcuts dock, and panel-row→dock) flips this so tooltips, hover-card
+ // panels and the panel-preview all stand down for the duration of a drag —
+ // they were rendering over the drag ghost and making it feel broken.
+ const [isAnyDragging, setIsAnyDragging] = useState(false);
+ const setSidebarDragging = useCallback((value: boolean) => {
+ isDraggingRef.current = value;
+ setIsAnyDragging(value);
+ }, []);
+ const dragStateValue = useMemo(
+ () => ({ isDragging: isAnyDragging, setDragging: setSidebarDragging }),
+ [isAnyDragging, setSidebarDragging],
+ );
+ const handleRailDragStart = useCallback(() => {
+ setSidebarDragging(true);
+ }, [setSidebarDragging]);
+ const handleRailDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ setSidebarDragging(false);
+ const { active, over } = event;
+ if (!over || active.id === over.id) {
+ return;
+ }
+ const oldIndex = railOrder.indexOf(active.id as SidebarCategoryId);
+ const newIndex = railOrder.indexOf(over.id as SidebarCategoryId);
+ if (oldIndex === -1 || newIndex === -1) {
+ return;
+ }
+ setStoredRailOrder(arrayMove(railOrder, oldIndex, newIndex));
+ },
+ [railOrder, setStoredRailOrder, setSidebarDragging],
);
- // 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 isFeedPage = activePage.includes('/feeds/');
-
- const resolvedCategory = useMemo((): SidebarCategoryId => {
+ // When the For You feed is the current page, the Home button reads as
+ // selected — fill its icon (secondary) instead of the outline.
+ const isHomeActive = isSidebarItemActive(activePage, myFeedPath);
+
+ const resolvedBaseCategory = useMemo((): SidebarCategoryId => {
+ // The home / For You feed is a logged-in user's personal hub, so it
+ // defaults to the Profile panel rather than Explore. Anonymous users (no
+ // profile panel) fall back to Explore.
+ if (isLoggedIn && isHomeActive) {
+ return SidebarCategory.Profile;
+ }
+ // The user's own profile page (`/` and its sub-pages) also keeps
+ // the Profile panel — the avatar navigates here, so it must resolve back to
+ // Profile (otherwise the optimistic pending category never clears).
+ const path = activePage.split('?')[0];
+ const ownProfileBase = user?.username ? `/${user.username}` : null;
+ if (
+ isLoggedIn &&
+ ownProfileBase &&
+ (path === ownProfileBase || path.startsWith(`${ownProfileBase}/`))
+ ) {
+ return SidebarCategory.Profile;
+ }
if (isFeedPage) {
return SidebarCategory.Main;
}
return getSidebarCategoryForPath(activePage);
- }, [activePage, isFeedPage]);
+ }, [activePage, isFeedPage, isHomeActive, isLoggedIn, user?.username]);
+
+ // Opening a single post (`/posts/[id]`) shouldn't change the sidebar context
+ // — the panel behind the post page keeps whatever you came from (History,
+ // a Squad, etc.). Remember the last non-post category (committed renders
+ // only, so it's concurrent-safe) and reuse it on posts.
+ const isPostPage = router.pathname === '/posts/[id]';
+ const lastNonPostCategoryRef = useRef(
+ SidebarCategory.Main,
+ );
+ useEffect(() => {
+ if (!isPostPage) {
+ lastNonPostCategoryRef.current = resolvedBaseCategory;
+ }
+ }, [isPostPage, resolvedBaseCategory]);
+ const resolvedCategory = isPostPage
+ ? lastNonPostCategoryRef.current
+ : resolvedBaseCategory;
// Optimistic override so a rail click feels instant even when
// router.push is async. Cleared once the URL catches up.
@@ -766,12 +856,19 @@ export const SidebarDesktopV2 = ({
// 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);
-
+ // Set on a "New post" click and held until the composer modal fully closes,
+ // so the panel stays on the create options through the open transition
+ // instead of briefly flashing back to the resolved category.
+ const [createPinned, setCreatePinned] = useState(false);
+
+ // Clear the optimistic override once the route actually settles (activePage
+ // changed). The category resolved from the URL is now authoritative — keeping
+ // a stale pending value would strand the panel on the wrong category until a
+ // refresh (e.g. after the avatar navigates and you then open Settings). The
+ // pending value still bridges the click→route-change gap for instant feedback.
useEffect(() => {
- if (pendingCategory !== null && pendingCategory === resolvedCategory) {
- setPendingCategory(null);
- }
- }, [pendingCategory, resolvedCategory]);
+ setPendingCategory(null);
+ }, [activePage]);
// Settings load client-side, so on a hard refresh `sidebarExpanded`
// flips from its `false` default to the user's stored value once
@@ -804,6 +901,61 @@ export const SidebarDesktopV2 = ({
? undefined
: '!transition-none';
+ // Shared "selected pill" that slides between rail tabs (FLIP-style shared
+ // layout) instead of the background jumping instantly. It tracks whichever
+ // tab carries `aria-selected` — robust across the heterogeneous tabs (avatar,
+ // category buttons, notifications bell) without each owning its own pill. We
+ // measure with getBoundingClientRect (transform-aware) relative to the
+ // tablist, so reorder/overflow just re-anchor it.
+ const tablistRef = useRef(null);
+ const [selectedPill, setSelectedPill] = useState({ y: 0, h: 0, show: false });
+ const [pillReady, setPillReady] = useState(false);
+ const visibleTabKey = visibleCategoryIds.join('|');
+ useEffect(() => {
+ const container = tablistRef.current;
+ if (!container) {
+ return undefined;
+ }
+ // Hide the pill mid-drag — its layout slot is in flux and the dragged tab
+ // is lifted; it re-anchors (and fades back) once the order settles.
+ if (isAnyDragging) {
+ setSelectedPill((prev) => (prev.show ? { ...prev, show: false } : prev));
+ return undefined;
+ }
+ const measure = () => {
+ const selected = container.querySelector('[aria-selected="true"]');
+ if (!(selected instanceof HTMLElement)) {
+ setSelectedPill((prev) => ({ ...prev, show: false }));
+ return;
+ }
+ const containerRect = container.getBoundingClientRect();
+ const rect = selected.getBoundingClientRect();
+ setSelectedPill({
+ y: rect.top - containerRect.top,
+ h: rect.height,
+ show: true,
+ });
+ };
+ // rAF: measure after layout settles. setTimeout: re-measure after dnd-kit's
+ // drop animation (~250ms) finishes, in case the selected tab was the one
+ // just dragged and is still transforming toward its final slot.
+ const raf = requestAnimationFrame(measure);
+ const settle = setTimeout(measure, 300);
+ return () => {
+ cancelAnimationFrame(raf);
+ clearTimeout(settle);
+ };
+ }, [selectedCategory, visibleTabKey, isCompact, isAnyDragging]);
+ // Enable the slide transition only after the first placement so the pill
+ // doesn't animate in from the top on mount (it just appears in place).
+ useEffect(() => {
+ if (!selectedPill.show || pillReady) {
+ return undefined;
+ }
+ const raf = requestAnimationFrame(() => setPillReady(true));
+ return () => cancelAnimationFrame(raf);
+ }, [selectedPill.show, pillReady]);
+
// 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
@@ -879,13 +1031,26 @@ 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));
- }, []);
+ // Avatar click opens the Profile panel and navigates to the user's profile
+ // page. Like a rail tab, it sets the pending category for instant feedback.
+ const onSelectProfile = useCallback(() => {
+ setPendingCategory(SidebarCategory.Profile);
+ if (!user) {
+ return;
+ }
+ const targetPath = `${webappUrl}${user.username}`;
+ Promise.resolve(router.push(targetPath)).catch(() => undefined);
+ }, [router, user]);
+
+ // The flat Home button switches to the "For You" feed. It mirrors the rail
+ // tabs' optimistic panel switch (Main = Explore) while the route resolves.
+ const onHomeClick = useCallback(() => {
+ // Home opens the Profile panel by default (logged in); Explore for anon.
+ setPendingCategory(
+ isLoggedIn ? SidebarCategory.Profile : SidebarCategory.Main,
+ );
+ onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/');
+ }, [isCustomDefaultFeed, isLoggedIn, onNavTabClick]);
// 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.
@@ -941,19 +1106,9 @@ export const SidebarDesktopV2 = ({
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(() => {
@@ -965,11 +1120,28 @@ export const SidebarDesktopV2 = ({
exitSafeZone();
}, [exitSafeZone]);
- // --- Prediction cone via pointer-events blocking -----------------------
+ // --- Prediction cone ---------------------------------------------------
// `commitPreview` maps a rail trigger key to the panel preview it shows.
+ // Hovering a panel-bearing icon is also what opens the collapsed peek — the
+ // rail no longer expands just because the cursor entered it, so empty space
+ // and panel-less icons (logo, Home, Search, Invite, Support, Settings) never
+ // pop the panel open.
const commitPreview = useCallback((key: string) => {
+ // While arcing toward the panel, ignore hover-switches but DON'T block
+ // pointer events — blocking the tabs swallowed real clicks (the panel is
+ // already open, so there's nothing to re-open here). Also ignore the hover
+ // that fires under the cursor mid-drag so reordering doesn't flip panels.
+ if (safeBlockedRef.current || isDraggingRef.current) {
+ return;
+ }
+ if (!peekSuppressedRef.current) {
+ setIsRailHovered(true);
+ }
if (key === 'create') {
setIsCreateHovered(true);
+ // Clear any category preview so a previously-hovered tab (e.g. Quests)
+ // doesn't keep its hover/preview state while the New post panel shows.
+ setHoveredCategory(null);
return;
}
setIsCreateHovered(false);
@@ -982,16 +1154,14 @@ export const SidebarDesktopV2 = ({
return;
}
// Triangle from the pointer to the panel's near (left) edge, padded
- // vertically. While the pointer stays inside it the tabs are inert.
+ // vertically. While the pointer stays inside it, hover-switches are
+ // ignored (via commitPreview's guard) — but tabs stay clickable.
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 = (
@@ -1081,11 +1251,6 @@ export const SidebarDesktopV2 = ({
/>
);
}
- if (category === SidebarCategory.Saved) {
- return (
-
- );
- }
if (category === SidebarCategory.Settings) {
return (
;
}
if (category === SidebarCategory.GameCenter) {
+ return ;
+ }
+ if (category === SidebarCategory.Profile) {
return (
-
);
}
return (
- <>
-
-
-
-
- >
+
);
};
@@ -1146,10 +1305,49 @@ export const SidebarDesktopV2 = ({
if (!category) {
return null;
}
- const isSelected = activeCategory === category.id;
+ // The "selected" (white) indicator tracks the committed category so it
+ // never moves while you hover/preview other tabs — you always know where
+ // you are. Hovering only previews the panel and shows the row's hover
+ // background; it doesn't claim the selected state.
+ const isSelected = selectedCategory === category.id;
+ const isPreviewing = !isSelected && activeCategory === category.id;
+ // The gamification tab. With reading streaks on it's the "Streak" tab: the
+ // state-driven StreakBadge stands in for the glyph and the day count is the
+ // label. With streaks off (but other gamification on) it reads as the
+ // broader "Quests"/Game Center — a joystick glyph + "Quests" label.
+ const isStreakTab = category.id === SidebarCategory.GameCenter;
+ const showStreakBadge =
+ isStreakTab && isStreakTabAStreak && isStreakEnabled;
+ const displayLabel = isStreakTab ? gameCenterLabel : category.label;
+ // The hover card + aria use the fuller panel title ("Current Streak");
+ // the tab icon's own label stays the short `displayLabel`.
+ const panelTitle = isStreakTab ? gameCenterPanelTitle : category.label;
+ let iconNode: ReactElement;
+ if (showStreakBadge) {
+ iconNode = (
+
+ );
+ } else if (isStreakTab && !isStreakTabAStreak) {
+ iconNode = (
+
+ );
+ } else {
+ iconNode = category.icon(isSelected);
+ }
+ const labelText =
+ showStreakBadge && streakCount > 0 ? `${streakCount}` : displayLabel;
+ const ariaLabel = showStreakBadge ? streakCopy : panelTitle;
return (
@@ -1159,7 +1357,7 @@ export const SidebarDesktopV2 = ({
id={`sidebar-category-${category.id}`}
data-sidebar-preview={category.id}
aria-controls="sidebar-context-panel"
- aria-label={category.label}
+ aria-label={ariaLabel}
aria-selected={isSelected}
onClick={() => onSelectCategory(category.id)}
onMouseEnter={() => {
@@ -1175,15 +1373,23 @@ export const SidebarDesktopV2 = ({
}}
className={classNames(
railTabClass,
- isSelected && 'bg-background-default !text-text-primary',
+ // The selected pill is the shared sliding indicator in the tablist;
+ // the button only owns its text color (a bg here would paint over
+ // the sliding pill and kill the morph). Every tab — streak included —
+ // uses the same white selected label.
+ isSelected && '!text-text-primary',
+ // `group/streaktab` scopes the StreakBadge's hover-white border to
+ // this tab. The tab background matches every other tab (float on
+ // hover, neutral pill when selected) — the streak's pink lives only
+ // on its square StreakBadge.
+ isStreakTab && 'group/streaktab',
+ isPreviewing && 'bg-surface-hover text-text-primary',
)}
>
- {category.icon(isSelected)}
+ {iconNode}
- {!isCompact && (
- {category.label}
- )}
+ {!isCompact && {labelText}}
{category.id === SidebarCategory.GameCenter && showQuestBadge && (
// Pin the badge to the button's top-right corner (not the icon's)
// so the quest level ring + number stay fully visible.
@@ -1196,43 +1402,9 @@ export const SidebarDesktopV2 = ({
);
};
- 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 (
-