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,
};
}
+126 -96
View File
@@ -1,6 +1,8 @@
// biome-ignore format: keep compact to stay within file size limit
import {
Activity,
ArrowDown,
ArrowUp,
Bot,
CheckCheck,
CheckCircle2,
@@ -25,6 +27,8 @@ 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 { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import {
ChannelMenuButton,
SidebarSection,
@@ -413,6 +417,7 @@ export function AppSidebar({
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
const scrollRef = React.useRef<HTMLDivElement>(null);
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
@@ -475,6 +480,12 @@ export function AppSidebar({
profile?.displayName?.trim() ||
fallbackDisplayName?.trim() ||
"Current identity";
const {
scrollToNextAbove,
scrollToNextBelow,
unreadAboveCount,
unreadBelowCount,
} = useUnreadOverflow({ scrollRef, unreadChannelIds });
const isCreatingAny =
createDialogKind === "stream"
@@ -614,106 +625,125 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{isLoading ? (
<SidebarGroup>
<SidebarGroupLabel>Channels</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid="sidebar-loading">
{skeletonRows.map((row) => (
<SidebarMenuSkeleton key={row} showIcon />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<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 ? (
<SidebarGroup>
<SidebarGroupLabel>Channels</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid="sidebar-loading">
{skeletonRows.map((row) => (
<SidebarMenuSkeleton key={row} showIcon />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{!isLoading ? (
<>
<ChannelGroupSection
browseAriaLabel="Browse channels"
browseTestId="browse-channels"
createAriaLabel="Create a channel"
groupClassName="pt-1"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
items={streamChannels}
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelIds={unreadChannelIds}
/>
<ChannelGroupSection
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
createAriaLabel="Create a forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelIds={unreadChannelIds}
/>
<SidebarSection
action={
<SidebarGroupAction
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className={cn(
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
type="button"
>
<PenSquare className="transition-transform" />
</SidebarGroupAction>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
testId="dm-list"
title="Direct Messages"
unreadChannelIds={unreadChannelIds}
/>
</>
) : null}
{!isLoading ? (
<>
<ChannelGroupSection
browseAriaLabel="Browse channels"
browseTestId="browse-channels"
createAriaLabel="Create a channel"
groupClassName="pt-1"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
items={streamChannels}
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelIds={unreadChannelIds}
/>
<ChannelGroupSection
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
createAriaLabel="Create a forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelIds={unreadChannelIds}
/>
<SidebarSection
action={
<SidebarGroupAction
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className={cn(
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
type="button"
>
<PenSquare className="transition-transform" />
</SidebarGroupAction>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
testId="dm-list"
title="Direct Messages"
unreadChannelIds={unreadChannelIds}
/>
</>
) : null}
{errorMessage ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
{errorMessage ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
</SidebarContent>
{unreadBelowCount > 0 ? (
<MoreUnreadButton
count={unreadBelowCount}
icon={<ArrowDown />}
onClick={scrollToNextBelow}
testId="sidebar-more-unread-below"
/>
) : null}
</SidebarContent>
</div>
<SidebarFooter>
<SidebarMenu>
@@ -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>
);
}