desktop: float unread indicator + fix sidebar scroll jump (#777)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-28 15:15:19 -07:00
committed by GitHub
parent 3fbee555f0
commit 9db8f6ccb8
3 changed files with 53 additions and 5 deletions
@@ -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<HTMLDivElement | null>,
) {
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]);
}
@@ -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<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
@@ -625,12 +627,13 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
{unreadAboveCount > 0 ? (
<MoreUnreadButton
count={unreadAboveCount}
icon={<ArrowUp />}
onClick={scrollToNextAbove}
position="top"
testId="sidebar-more-unread-above"
/>
) : null}
@@ -740,6 +743,7 @@ export function AppSidebar({
count={unreadBelowCount}
icon={<ArrowDown />}
onClick={scrollToNextBelow}
position="bottom"
testId="sidebar-more-unread-below"
/>
) : null}
@@ -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 (
<div className="flex justify-center py-1">
<div
className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center py-1 ${position === "top" ? "top-0" : "bottom-0"}`}
>
<Button
className={MORE_UNREAD_BUTTON_CLASS}
className={`pointer-events-auto ${MORE_UNREAD_BUTTON_CLASS}`}
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="outline"
variant="ghost"
>
{icon}
{count} more unread