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 { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton"; import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { import {
@@ -418,6 +419,7 @@ export function AppSidebar({
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] = const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null); React.useState<CreateChannelKind | null>(null);
@@ -625,12 +627,13 @@ export function AppSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<div className="flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
{unreadAboveCount > 0 ? ( {unreadAboveCount > 0 ? (
<MoreUnreadButton <MoreUnreadButton
count={unreadAboveCount} count={unreadAboveCount}
icon={<ArrowUp />} icon={<ArrowUp />}
onClick={scrollToNextAbove} onClick={scrollToNextAbove}
position="top"
testId="sidebar-more-unread-above" testId="sidebar-more-unread-above"
/> />
) : null} ) : null}
@@ -740,6 +743,7 @@ export function AppSidebar({
count={unreadBelowCount} count={unreadBelowCount}
icon={<ArrowDown />} icon={<ArrowDown />}
onClick={scrollToNextBelow} onClick={scrollToNextBelow}
position="bottom"
testId="sidebar-more-unread-below" testId="sidebar-more-unread-below"
/> />
) : null} ) : null}
@@ -3,28 +3,32 @@ import type * as React from "react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
const MORE_UNREAD_BUTTON_CLASS = 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({ export function MoreUnreadButton({
count, count,
icon, icon,
onClick, onClick,
position,
testId, testId,
}: { }: {
count: number; count: number;
icon: React.ReactNode; icon: React.ReactNode;
onClick: () => void; onClick: () => void;
position: "top" | "bottom";
testId: string; testId: string;
}) { }) {
return ( 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 <Button
className={MORE_UNREAD_BUTTON_CLASS} className={`pointer-events-auto ${MORE_UNREAD_BUTTON_CLASS}`}
data-testid={testId} data-testid={testId}
onClick={onClick} onClick={onClick}
size="sm" size="sm"
type="button" type="button"
variant="outline" variant="ghost"
> >
{icon} {icon}
{count} more unread {count} more unread