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
|
// 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,106 +625,125 @@ export function AppSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{isLoading ? (
|
{unreadAboveCount > 0 ? (
|
||||||
<SidebarGroup>
|
<MoreUnreadButton
|
||||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
count={unreadAboveCount}
|
||||||
<SidebarGroupContent>
|
icon={<ArrowUp />}
|
||||||
<SidebarMenu data-testid="sidebar-loading">
|
onClick={scrollToNextAbove}
|
||||||
{skeletonRows.map((row) => (
|
testId="sidebar-more-unread-above"
|
||||||
<SidebarMenuSkeleton key={row} showIcon />
|
/>
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
) : null}
|
) : 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 ? (
|
{!isLoading ? (
|
||||||
<>
|
<>
|
||||||
<ChannelGroupSection
|
<ChannelGroupSection
|
||||||
browseAriaLabel="Browse channels"
|
browseAriaLabel="Browse channels"
|
||||||
browseTestId="browse-channels"
|
browseTestId="browse-channels"
|
||||||
createAriaLabel="Create a channel"
|
createAriaLabel="Create a channel"
|
||||||
groupClassName="pt-1"
|
groupClassName="pt-1"
|
||||||
hasUnread={unreadChannelIds.size > 0}
|
hasUnread={unreadChannelIds.size > 0}
|
||||||
isCollapsed={collapsedGroups.channels}
|
isCollapsed={collapsedGroups.channels}
|
||||||
isActiveChannel={selectedView === "channel"}
|
isActiveChannel={selectedView === "channel"}
|
||||||
items={streamChannels}
|
items={streamChannels}
|
||||||
listTestId="stream-list"
|
listTestId="stream-list"
|
||||||
onBrowse={onOpenBrowseChannels}
|
onBrowse={onOpenBrowseChannels}
|
||||||
onCreateClick={() => setCreateDialogKind("stream")}
|
onCreateClick={() => setCreateDialogKind("stream")}
|
||||||
onMarkAllRead={onMarkAllChannelsRead}
|
onMarkAllRead={onMarkAllChannelsRead}
|
||||||
onMarkChannelRead={onMarkChannelRead}
|
onMarkChannelRead={onMarkChannelRead}
|
||||||
onMarkChannelUnread={onMarkChannelUnread}
|
onMarkChannelUnread={onMarkChannelUnread}
|
||||||
onSelectChannel={onSelectChannel}
|
onSelectChannel={onSelectChannel}
|
||||||
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
|
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
|
||||||
selectedChannelId={selectedChannelId}
|
selectedChannelId={selectedChannelId}
|
||||||
title="Channels"
|
title="Channels"
|
||||||
unreadChannelIds={unreadChannelIds}
|
unreadChannelIds={unreadChannelIds}
|
||||||
/>
|
/>
|
||||||
<ChannelGroupSection
|
<ChannelGroupSection
|
||||||
browseAriaLabel="Browse forums"
|
browseAriaLabel="Browse forums"
|
||||||
browseTestId="browse-forums"
|
browseTestId="browse-forums"
|
||||||
createAriaLabel="Create a forum"
|
createAriaLabel="Create a forum"
|
||||||
hasUnread={unreadChannelIds.size > 0}
|
hasUnread={unreadChannelIds.size > 0}
|
||||||
isCollapsed={collapsedGroups.forums}
|
isCollapsed={collapsedGroups.forums}
|
||||||
isActiveChannel={selectedView === "channel"}
|
isActiveChannel={selectedView === "channel"}
|
||||||
items={forumChannels}
|
items={forumChannels}
|
||||||
listTestId="forum-list"
|
listTestId="forum-list"
|
||||||
onBrowse={onOpenBrowseForums}
|
onBrowse={onOpenBrowseForums}
|
||||||
onCreateClick={() => setCreateDialogKind("forum")}
|
onCreateClick={() => setCreateDialogKind("forum")}
|
||||||
onMarkAllRead={onMarkAllChannelsRead}
|
onMarkAllRead={onMarkAllChannelsRead}
|
||||||
onMarkChannelRead={onMarkChannelRead}
|
onMarkChannelRead={onMarkChannelRead}
|
||||||
onMarkChannelUnread={onMarkChannelUnread}
|
onMarkChannelUnread={onMarkChannelUnread}
|
||||||
onSelectChannel={onSelectChannel}
|
onSelectChannel={onSelectChannel}
|
||||||
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
|
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
|
||||||
selectedChannelId={selectedChannelId}
|
selectedChannelId={selectedChannelId}
|
||||||
title="Forums"
|
title="Forums"
|
||||||
unreadChannelIds={unreadChannelIds}
|
unreadChannelIds={unreadChannelIds}
|
||||||
/>
|
/>
|
||||||
<SidebarSection
|
<SidebarSection
|
||||||
action={
|
action={
|
||||||
<SidebarGroupAction
|
<SidebarGroupAction
|
||||||
aria-expanded={isNewDmOpen}
|
aria-expanded={isNewDmOpen}
|
||||||
aria-label="Start a direct message"
|
aria-label="Start a direct message"
|
||||||
className={cn(
|
className={cn(
|
||||||
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
|
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
|
||||||
SECTION_ACTION_VISIBILITY_CLASS,
|
SECTION_ACTION_VISIBILITY_CLASS,
|
||||||
)}
|
)}
|
||||||
data-testid="new-dm-trigger"
|
data-testid="new-dm-trigger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsNewDmOpen(true);
|
setIsNewDmOpen(true);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<PenSquare className="transition-transform" />
|
<PenSquare className="transition-transform" />
|
||||||
</SidebarGroupAction>
|
</SidebarGroupAction>
|
||||||
}
|
}
|
||||||
dmParticipantsByChannelId={dmParticipantsByChannelId}
|
dmParticipantsByChannelId={dmParticipantsByChannelId}
|
||||||
isCollapsed={collapsedGroups.directMessages}
|
isCollapsed={collapsedGroups.directMessages}
|
||||||
isActiveChannel={selectedView === "channel"}
|
isActiveChannel={selectedView === "channel"}
|
||||||
items={directMessages}
|
items={directMessages}
|
||||||
channelLabels={dmChannelLabels}
|
channelLabels={dmChannelLabels}
|
||||||
onHideDm={onHideDm}
|
onHideDm={onHideDm}
|
||||||
onMarkChannelRead={onMarkChannelRead}
|
onMarkChannelRead={onMarkChannelRead}
|
||||||
onMarkChannelUnread={onMarkChannelUnread}
|
onMarkChannelUnread={onMarkChannelUnread}
|
||||||
onSelectChannel={onSelectChannel}
|
onSelectChannel={onSelectChannel}
|
||||||
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
|
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
|
||||||
presenceByChannelId={dmPresenceByChannelId}
|
presenceByChannelId={dmPresenceByChannelId}
|
||||||
selectedChannelId={selectedChannelId}
|
selectedChannelId={selectedChannelId}
|
||||||
testId="dm-list"
|
testId="dm-list"
|
||||||
title="Direct Messages"
|
title="Direct Messages"
|
||||||
unreadChannelIds={unreadChannelIds}
|
unreadChannelIds={unreadChannelIds}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div className="px-3 py-2 text-sm text-destructive">
|
<div className="px-3 py-2 text-sm text-destructive">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
{unreadBelowCount > 0 ? (
|
||||||
|
<MoreUnreadButton
|
||||||
|
count={unreadBelowCount}
|
||||||
|
icon={<ArrowDown />}
|
||||||
|
onClick={scrollToNextBelow}
|
||||||
|
testId="sidebar-more-unread-below"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarContent>
|
</div>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<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