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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user