feat(sidebar): add More unread floating buttons (#771)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-28 11:05:02 -07:00
committed by GitHub
parent 8eedec7403
commit fec6a683ea
3 changed files with 360 additions and 96 deletions
@@ -0,0 +1,200 @@
import * as React from "react";
type VisibilityEntry = {
element: HTMLElement;
isIntersecting: boolean;
};
type UnreadDirection = "above" | "below";
type UnreadOverflowCounts = {
unreadAboveCount: number;
unreadBelowCount: number;
};
const EMPTY_COUNTS: UnreadOverflowCounts = {
unreadAboveCount: 0,
unreadBelowCount: 0,
};
function getChannelId(element: Element): string | null {
return element.getAttribute("data-channel-id");
}
function getUnreadElements(
root: HTMLDivElement,
unreadChannelIds: Set<string>,
): HTMLElement[] {
return Array.from(
root.querySelectorAll<HTMLElement>("[data-channel-id]"),
).filter((element) => {
const channelId = getChannelId(element);
return channelId !== null && unreadChannelIds.has(channelId);
});
}
function getRelativeTop(element: HTMLElement, root: HTMLDivElement): number {
return element.getBoundingClientRect().top - root.getBoundingClientRect().top;
}
function findNextUnreadElement({
direction,
root,
unreadChannelIds,
}: {
direction: UnreadDirection;
root: HTMLDivElement;
unreadChannelIds: Set<string>;
}): HTMLElement | null {
const rootHeight = root.getBoundingClientRect().height;
let nextElement: HTMLElement | null = null;
let nextTop =
direction === "above" ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
for (const element of getUnreadElements(root, unreadChannelIds)) {
const top = getRelativeTop(element, root);
if (direction === "above") {
if (top < 0 && top > nextTop) {
nextElement = element;
nextTop = top;
}
continue;
}
if (top > rootHeight && top < nextTop) {
nextElement = element;
nextTop = top;
}
}
return nextElement;
}
function deriveCounts(
visibilityById: Map<string, VisibilityEntry>,
root: HTMLDivElement,
): UnreadOverflowCounts {
let unreadAboveCount = 0;
let unreadBelowCount = 0;
const rootHeight = root.getBoundingClientRect().height;
for (const entry of visibilityById.values()) {
const top = getRelativeTop(entry.element, root);
if (entry.isIntersecting) continue;
if (top < 0) {
unreadAboveCount += 1;
} else if (top > rootHeight) {
unreadBelowCount += 1;
}
}
return { unreadAboveCount, unreadBelowCount };
}
export function useUnreadOverflow(args: {
scrollRef: React.RefObject<HTMLDivElement | null>;
unreadChannelIds: Set<string>;
}): UnreadOverflowCounts & {
scrollToNextAbove: () => void;
scrollToNextBelow: () => void;
} {
const { scrollRef, unreadChannelIds } = args;
const unreadChannelIdsRef = React.useRef(unreadChannelIds);
unreadChannelIdsRef.current = unreadChannelIds;
const [counts, setCounts] = React.useState(EMPTY_COUNTS);
React.useEffect(() => {
const root = scrollRef.current;
if (!root) {
setCounts(EMPTY_COUNTS);
return;
}
let intersectionObserver: IntersectionObserver | null = null;
const visibilityById = new Map<string, VisibilityEntry>();
const updateCounts = () => {
setCounts(deriveCounts(visibilityById, root));
};
const bindUnreadRows = () => {
intersectionObserver?.disconnect();
visibilityById.clear();
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const channelId = getChannelId(entry.target);
if (!channelId || !unreadChannelIds.has(channelId)) {
continue;
}
visibilityById.set(channelId, {
element: entry.target as HTMLElement,
isIntersecting: entry.isIntersecting,
});
}
updateCounts();
},
{ root, threshold: 0 },
);
for (const element of getUnreadElements(root, unreadChannelIds)) {
const channelId = getChannelId(element);
if (!channelId) continue;
visibilityById.set(channelId, {
element,
isIntersecting: false,
});
intersectionObserver.observe(element);
}
updateCounts();
};
const mutationObserver = new MutationObserver(bindUnreadRows);
mutationObserver.observe(root, { childList: true, subtree: true });
bindUnreadRows();
return () => {
intersectionObserver?.disconnect();
mutationObserver.disconnect();
};
}, [scrollRef, unreadChannelIds]);
const scrollToNextAbove = React.useCallback(() => {
const root = scrollRef.current;
if (!root) return;
findNextUnreadElement({
direction: "above",
root,
unreadChannelIds: unreadChannelIdsRef.current,
})?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [scrollRef]);
const scrollToNextBelow = React.useCallback(() => {
const root = scrollRef.current;
if (!root) return;
findNextUnreadElement({
direction: "below",
root,
unreadChannelIds: unreadChannelIdsRef.current,
})?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [scrollRef]);
return {
...counts,
scrollToNextAbove,
scrollToNextBelow,
};
}
+31 -1
View File
@@ -1,6 +1,8 @@
// biome-ignore format: keep compact to stay within file size limit // biome-ignore format: keep compact to stay within file size limit
import { import {
Activity, Activity,
ArrowDown,
ArrowUp,
Bot, Bot,
CheckCheck, CheckCheck,
CheckCircle2, CheckCircle2,
@@ -25,6 +27,8 @@ 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 { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { import {
ChannelMenuButton, ChannelMenuButton,
SidebarSection, SidebarSection,
@@ -413,6 +417,7 @@ export function AppSidebar({
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
const scrollRef = React.useRef<HTMLDivElement>(null);
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);
@@ -475,6 +480,12 @@ export function AppSidebar({
profile?.displayName?.trim() || profile?.displayName?.trim() ||
fallbackDisplayName?.trim() || fallbackDisplayName?.trim() ||
"Current identity"; "Current identity";
const {
scrollToNextAbove,
scrollToNextBelow,
unreadAboveCount,
unreadBelowCount,
} = useUnreadOverflow({ scrollRef, unreadChannelIds });
const isCreatingAny = const isCreatingAny =
createDialogKind === "stream" createDialogKind === "stream"
@@ -614,7 +625,16 @@ export function AppSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <div className="flex min-h-0 flex-1 flex-col">
{unreadAboveCount > 0 ? (
<MoreUnreadButton
count={unreadAboveCount}
icon={<ArrowUp />}
onClick={scrollToNextAbove}
testId="sidebar-more-unread-above"
/>
) : null}
<SidebarContent ref={scrollRef}>
{isLoading ? ( {isLoading ? (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Channels</SidebarGroupLabel> <SidebarGroupLabel>Channels</SidebarGroupLabel>
@@ -715,6 +735,16 @@ export function AppSidebar({
) : null} ) : null}
</SidebarContent> </SidebarContent>
{unreadBelowCount > 0 ? (
<MoreUnreadButton
count={unreadBelowCount}
icon={<ArrowDown />}
onClick={scrollToNextBelow}
testId="sidebar-more-unread-below"
/>
) : null}
</div>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -0,0 +1,34 @@
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";
export function MoreUnreadButton({
count,
icon,
onClick,
testId,
}: {
count: number;
icon: React.ReactNode;
onClick: () => void;
testId: string;
}) {
return (
<div className="flex justify-center py-1">
<Button
className={MORE_UNREAD_BUTTON_CLASS}
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
{icon}
{count} more unread
</Button>
</div>
);
}