diff --git a/desktop/src/features/sidebar/lib/useSidebarScrollLock.ts b/desktop/src/features/sidebar/lib/useSidebarScrollLock.ts new file mode 100644 index 000000000..09666354b --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarScrollLock.ts @@ -0,0 +1,40 @@ +import { useRouter } from "@tanstack/react-router"; +import * as React from "react"; + +/** + * Prevents TanStack Router's scroll restoration from moving the sidebar. + * + * The router registers a document-level capture listener for "scroll" that + * records every scrollable element's position. On navigation it restores those + * positions synchronously inside an "onRendered" event. We snapshot the + * sidebar's scrollTop in "onBeforeLoad" (before any restoration happens) and + * re-apply it in "onRendered" (after the router's restoration subscriber has + * already run, since our subscription is registered later). + */ +export function useSidebarScrollLock( + scrollRef: React.RefObject, +) { + const savedScrollTop = React.useRef(0); + const router = useRouter(); + + React.useEffect(() => { + const unsubBefore = router.subscribe("onBeforeLoad", () => { + const el = scrollRef.current; + if (el) { + savedScrollTop.current = el.scrollTop; + } + }); + + const unsubRendered = router.subscribe("onRendered", () => { + const el = scrollRef.current; + if (el && el.scrollTop !== savedScrollTop.current) { + el.scrollTop = savedScrollTop.current; + } + }); + + return () => { + unsubBefore(); + unsubRendered(); + }; + }, [router, scrollRef]); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7d5b60f48..a47d0c43f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -27,6 +27,7 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; +import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton"; import { @@ -418,6 +419,7 @@ export function AppSidebar({ const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const scrollRef = React.useRef(null); + useSidebarScrollLock(scrollRef); const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [createDialogKind, setCreateDialogKind] = React.useState(null); @@ -625,12 +627,13 @@ export function AppSidebar({ -
+
{unreadAboveCount > 0 ? ( } onClick={scrollToNextAbove} + position="top" testId="sidebar-more-unread-above" /> ) : null} @@ -740,6 +743,7 @@ export function AppSidebar({ count={unreadBelowCount} icon={} onClick={scrollToNextBelow} + position="bottom" testId="sidebar-more-unread-below" /> ) : null} diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index b73fac32f..0e3a8e2d2 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -3,28 +3,32 @@ import type * as React from "react"; import { Button } from "@/shared/ui/button"; const MORE_UNREAD_BUTTON_CLASS = - "h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"; + "h-7 min-h-7 gap-1.5 rounded-full border-0 bg-primary px-2.5 text-[11px] font-medium text-primary-foreground shadow-md hover:bg-primary/90 [&_svg]:size-3.5"; export function MoreUnreadButton({ count, icon, onClick, + position, testId, }: { count: number; icon: React.ReactNode; onClick: () => void; + position: "top" | "bottom"; testId: string; }) { return ( -
+