mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sidebar): add More unread floating buttons (#771)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user