Polish sidebar unread hierarchy (#4573)

## Summary

- Keep selected sidebar rows regular by default; manually unread rows
become bold immediately.
- Apply a clearer dark-mode hierarchy: standard inactive rows at 75%,
muted rows at 45%, and unread rows at full emphasis.
- Keep hover text color stable while retaining the selected-row and
unread cues.

## Validation

- `pnpm typecheck`
- `pnpm build:e2e`
- Playwright: sidebar badge and channel-mute coverage

## Screenshots

Posted in the PR comments.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-04 16:39:45 +01:00
committed by GitHub
parent d0d4acd4fa
commit 540b58920c
19 changed files with 569 additions and 78 deletions

View File

@@ -632,8 +632,7 @@ export function AppShell() {
unreadChannelIds,
unreadChannelNotificationCount,
});
// Dispatch `buzz://message` deep links only from the main window. The
// companion is dedicated to its active Huddle route.
// Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route.
useMessageDeepLinks(!isHuddleRoom);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
@@ -929,6 +928,7 @@ export function AppShell() {
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}

View File

@@ -827,9 +827,9 @@ export function useUnreadChannels(
relayClient,
]);
// Unread = channels (excluding active) that have either been manually
// marked unread this session, or whose observed latest external trigger
// timestamp is strictly newer than their NIP-RS read marker.
// Unread = inactive channels, plus any channel manually marked unread this
// session. A manually marked active channel must remain visible as unread
// until the user explicitly marks it read again.
// High-priority unread = DMs or channels with a mention/broadcast newer
// than the read marker. Forced-unread channels are dot tier only (not
// high-priority). Both sets share identical deps and always invalidate
@@ -854,12 +854,11 @@ export function useUnreadChannels(
let unreadChannelNotificationCount = 0;
for (const channel of channels) {
if (channel.id === activeChannelId) continue;
const isForcedUnread = Object.hasOwn(
forcedUnreadRef.current,
channel.id,
);
if (channel.id === activeChannelId && !isForcedUnread) continue;
const observedEvents = observedUnreadEventsByChannelRef.current.get(
channel.id,

View File

@@ -37,7 +37,7 @@ const homeFeed = (feed) => ({
meta: { since: 0, total: 0, generatedAt: 0 },
});
test("home badge items include locally unread activity and agent rows", () => {
test("home badge excludes thread activity already shown in a channel preview", () => {
const items = buildHomeBadgeFeedItems(
homeFeed({
mentions: [feedItem("mention", "mention")],
@@ -51,7 +51,12 @@ test("home badge items include locally unread activity and agent rows", () => {
feedItem("read-agent", "agent_activity"),
],
}),
[feedItem("thread-activity")],
[
{
...feedItem("thread-activity"),
tags: ROOT_TAGS,
},
],
new Set(["locally-unread-activity", "locally-unread-agent"]),
);
@@ -60,7 +65,6 @@ test("home badge items include locally unread activity and agent rows", () => {
[
"mention",
"needs-action",
"thread-activity",
"locally-unread-activity",
"locally-unread-agent",
],

View File

@@ -24,9 +24,19 @@ export function buildHomeBadgeFeedItems(
extraInboxItems: readonly FeedItem[],
localUnreadFeedIds: ReadonlySet<string>,
): FeedItem[] {
// Thread activity is surfaced directly on its channel's hover preview. It
// should not also inflate the Inbox numeral, which is reserved for the
// Inbox's own high-priority activity.
const nonThreadExtraInboxItems = extraInboxItems.filter(
(item) => !isThreadReply(item.tags),
);
const items = feed
? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems]
: [...extraInboxItems];
? [
...feed.feed.mentions,
...feed.feed.needsAction,
...nonThreadExtraInboxItems,
]
: [...nonThreadExtraInboxItems];
if (feed && localUnreadFeedIds.size > 0) {
items.push(

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts";
import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts";
test("keeps every unread channel navigable while adding working activity", () => {
const activity = getOffscreenActivityChannelIds({
activeWorkingByChannelId: new Map([["working", {}]]),
previewActivityChannelIds: new Set(["preview"]),
unreadChannelIds: new Set(["dm", "forum", "stream"]),
});
assert.deepEqual([...activity.messageChannelIds].sort(), [
"dm",
"forum",
"preview",
"stream",
]);
assert.deepEqual([...activity.channelIds].sort(), [
"dm",
"forum",
"preview",
"stream",
"working",
]);
});
test("uses an activity-neutral overflow label when work contributes", () => {
assert.equal(
getSidebarActivityOverflowLabel({ activityCount: 2, messageCount: 1 }),
"2 new activity",
);
assert.equal(
getSidebarActivityOverflowLabel({ activityCount: 1, messageCount: 1 }),
undefined,
);
});

View File

@@ -0,0 +1,53 @@
import * as React from "react";
type OffscreenActivityChannelIds = {
messageChannelIds: ReadonlySet<string>;
channelIds: ReadonlySet<string>;
};
export function getOffscreenActivityChannelIds({
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
}: {
activeWorkingByChannelId: ReadonlyMap<string, unknown>;
previewActivityChannelIds: ReadonlySet<string>;
unreadChannelIds: ReadonlySet<string>;
}): OffscreenActivityChannelIds {
// Every unread row must remain navigable, including top-level stream and
// forum unreads that do not have thread-preview activity.
const messageChannelIds = new Set([
...unreadChannelIds,
...previewActivityChannelIds,
]);
return {
messageChannelIds,
channelIds: new Set([
...messageChannelIds,
...activeWorkingByChannelId.keys(),
]),
};
}
export function useOffscreenActivityChannelIds(args: {
activeWorkingByChannelId: ReadonlyMap<string, unknown>;
previewActivityChannelIds: ReadonlySet<string>;
unreadChannelIds: ReadonlySet<string>;
}) {
const {
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
} = args;
return React.useMemo(
() =>
getOffscreenActivityChannelIds({
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
}),
[activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds],
);
}

View File

@@ -0,0 +1,45 @@
import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
type ActivityOptions = Parameters<typeof useOffscreenActivityChannelIds>[0];
type ScrollRef = Parameters<typeof useUnreadOverflow>[0]["scrollRef"];
export function getSidebarActivityOverflowLabel({
activityCount,
messageCount,
}: {
activityCount: number;
messageCount: number;
}) {
return activityCount === messageCount
? undefined
: `${activityCount} new activity`;
}
export function useSidebarActivityOverflow({
scrollRef,
...activityOptions
}: ActivityOptions & { scrollRef: ScrollRef }) {
const { channelIds, messageChannelIds } =
useOffscreenActivityChannelIds(activityOptions);
const activityOverflow = useUnreadOverflow({
scrollRef,
unreadChannelIds: channelIds,
});
const messageOverflow = useUnreadOverflow({
scrollRef,
unreadChannelIds: messageChannelIds,
});
return {
...activityOverflow,
unreadAboveLabel: getSidebarActivityOverflowLabel({
activityCount: activityOverflow.unreadAboveCount,
messageCount: messageOverflow.unreadAboveCount,
}),
unreadBelowLabel: getSidebarActivityOverflowLabel({
activityCount: activityOverflow.unreadBelowCount,
messageCount: messageOverflow.unreadBelowCount,
}),
};
}

View File

@@ -22,7 +22,7 @@ import {
import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference";
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow";
import {
CreateSectionDialog,
DeleteSectionAlertDialog,
@@ -105,6 +105,7 @@ type AppSidebarProps = {
| "projects";
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
communities: Community[];
onAddCommunity: (community: Community) => void;
onAddCommunityOpenChange?: (open: boolean) => void;
@@ -194,6 +195,7 @@ export function AppSidebar({
selectedView,
unreadChannelCounts,
unreadChannelIds,
previewActivityChannelIds,
communities,
onAddCommunity,
onAddCommunityOpenChange,
@@ -250,6 +252,8 @@ export function AppSidebar({
const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
// biome-ignore format: keep compact to stay within file size limit
const { scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds });
React.useEffect(() => {
const scrollElement = scrollRef.current;
@@ -503,13 +507,6 @@ export function AppSidebar({
profile?.displayName?.trim() ||
fallbackDisplayName?.trim() ||
"Current identity";
const {
scrollToNextAbove,
scrollToNextBelow,
unreadAboveCount,
unreadBelowCount,
} = useUnreadOverflow({ scrollRef, unreadChannelIds });
const isCreatingAny =
createDialogKind === "stream"
? isCreatingChannel
@@ -591,6 +588,7 @@ export function AppSidebar({
{unreadAboveCount > 0 ? (
<MoreUnreadButton
count={unreadAboveCount}
label={unreadAboveLabel}
onClick={scrollToNextAbove}
position="top"
testId="sidebar-more-unread-above"
@@ -862,6 +860,7 @@ export function AppSidebar({
<MoreUnreadButton
bottomClassName="bottom-full"
count={unreadBelowCount}
label={unreadBelowLabel}
onClick={scrollToNextBelow}
position="bottom"
testId="sidebar-more-unread-below"

View File

@@ -98,13 +98,22 @@ export function AppSidebarPrimaryMenu({
<SidebarMenu className="pb-2">
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Inbox className="h-4 w-4" />
<SidebarMenuLabel>Inbox</SidebarMenuLabel>
<Inbox
className={
selectedView !== "home" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "home" ? "opacity-80" : undefined}
>
Inbox
</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
@@ -145,14 +154,23 @@ export function AppSidebarPrimaryMenu({
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<SidebarMenuLabel>Agents</SidebarMenuLabel>
<Bot
className={
selectedView !== "agents" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "agents" ? "opacity-80" : undefined}
>
Agents
</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">

View File

@@ -21,6 +21,10 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
const HOVER_OPEN_DELAY_MS = 250;
const HOVER_CLOSE_DELAY_MS = 180;
const ACTIVITY_POPOVER_MOTION_STYLE = {
"--tw-enter-scale": "1",
"--tw-exit-scale": "1",
} as React.CSSProperties;
function buildChannelActivityFeed(items: FeedItem[]): HomeFeedResponse {
return {
@@ -365,19 +369,17 @@ export function ChannelActivityPopover({
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while this wrapper supplies the popover anchor box. */}
<div
className="w-full min-w-0"
onBlur={closeWithDelay}
onContextMenu={() => setOpen(false)}
onFocus={openImmediately}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
>
{children}
</div>
</PopoverAnchor>
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while the wrapper keeps the preview interactive. */}
<div
className="w-full min-w-0"
onBlur={closeWithDelay}
onContextMenu={() => setOpen(false)}
onFocus={openImmediately}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
>
<PopoverAnchor asChild>{children}</PopoverAnchor>
</div>
<PopoverContent
align="start"
className="w-96 overflow-hidden p-0"
@@ -387,7 +389,8 @@ export function ChannelActivityPopover({
onMouseLeave={closeWithDelay}
onOpenAutoFocus={(event) => event.preventDefault()}
side="right"
sideOffset={8}
sideOffset={0}
style={ACTIVITY_POPOVER_MOTION_STYLE}
>
<section
aria-label="Channel activity"

View File

@@ -4,12 +4,14 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
export function MoreUnreadButton({
bottomClassName = "bottom-0",
count,
label,
onClick,
position,
testId,
}: {
bottomClassName?: string;
count: number;
label?: string;
onClick: () => void;
position: "top" | "bottom";
testId: string;
@@ -23,7 +25,8 @@ export function MoreUnreadButton({
>
<UnreadPill
direction={position === "top" ? "up" : "down"}
label={unreadCountLabel(count)}
emphasis="primary"
label={label ?? unreadCountLabel(count)}
onClick={onClick}
testId={testId}
/>

View File

@@ -214,10 +214,12 @@ function DmChannelIcon({
function SidebarChannelIcon({
channel,
className,
dmParticipants,
presenceStatus,
}: {
channel: Channel;
className?: string;
dmParticipants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
}) {
@@ -238,14 +240,14 @@ function SidebarChannelIcon({
}
if (channel.visibility === "private") {
return <Lock className="h-4 w-4" />;
return <Lock className={cn("h-4 w-4", className)} />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
return <FileText className={cn("h-4 w-4", className)} />;
}
return <Hash className="h-4 w-4" />;
return <Hash className={cn("h-4 w-4", className)} />;
}
export function ChannelMenuButton({
@@ -288,21 +290,24 @@ export function ChannelMenuButton({
(hasSidebarUnreadProjections
? unreadThreadChannelIds.has(channel.id)
: hasUnread);
const inactiveContentOpacity = cn(
!isActive && !hasTopLevelUnread && !isMuted && "opacity-80",
!isActive &&
isMuted &&
!hasTopLevelUnread &&
!hasThreadUnread &&
"opacity-50 dark:opacity-45",
);
const button = (
<SidebarMenuButton
className={cn(
"data-[active=true]:font-normal",
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-accent-foreground",
!isActive &&
hasTopLevelUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
!isActive &&
isMuted &&
!hasTopLevelUnread &&
!hasThreadUnread &&
"opacity-50",
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground",
hasTopLevelUnread &&
"font-bold text-sidebar-foreground hover:text-sidebar-foreground data-[active=true]:font-bold",
)}
data-channel-id={channel.id}
data-testid={`channel-${channel.name}`}
@@ -313,10 +318,16 @@ export function ChannelMenuButton({
>
<SidebarChannelIcon
channel={channel}
className={
channel.channelType === "dm" ? undefined : inactiveContentOpacity
}
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
<span className="min-w-0 flex-1 truncate" data-sidebar-row-label>
<span
className={cn("min-w-0 flex-1 truncate", inactiveContentOpacity)}
data-sidebar-row-label
>
{resolvedLabel}
</span>
{ephemeralDisplay ? (

View File

@@ -30,3 +30,6 @@
Must stay below every `@import`: CSS requires `@import` to precede other
at-rules, so placing this above them silently drops the rest of the sheet. */
@custom-variant hover (&:hover);
/* The app persists its selected theme on the root `.dark` class. Use that
class, rather than the system preference, for every `dark:` utility. */
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -1,9 +1,12 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
const UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4";
const PRIMARY_UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4";
export function unreadCountLabel(count: number) {
return `${count} new message${count === 1 ? "" : "s"}`;
@@ -11,11 +14,13 @@ export function unreadCountLabel(count: number) {
export function UnreadPill({
direction,
emphasis = "default",
label,
onClick,
testId,
}: {
direction: "up" | "down";
emphasis?: "default" | "primary";
label: string;
onClick: () => void;
testId: string;
@@ -23,12 +28,14 @@ export function UnreadPill({
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
return (
<Button
className={UNREAD_PILL_CLASS}
className={cn(
emphasis === "primary" ? PRIMARY_UNREAD_PILL_CLASS : UNREAD_PILL_CLASS,
)}
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="outline"
variant={emphasis === "primary" ? "default" : "outline"}
>
<Arrow aria-hidden />
{label}

View File

@@ -1,8 +1,10 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const SHOTS = "test-results/channel-row-decoration-pr";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
@@ -67,6 +69,13 @@ async function getSettledBadgeState(page: import("@playwright/test").Page) {
return getBadgeState(page);
}
async function getSidebarHomeBadgeText(page: import("@playwright/test").Page) {
return page
.getByTestId("sidebar-home-count")
.allTextContents()
.then((texts) => texts[0] ?? null);
}
function withAdditionalBadgeCount(baseline: { count: number }, count: number) {
return { state: "count", count: baseline.count + count };
}
@@ -79,6 +88,244 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("selected Inbox and Agents rows keep their highlight without bold text", async ({
page,
}) => {
await page.goto("/");
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveAttribute("data-active", "true");
await expect(inbox).toHaveCSS("font-weight", "400");
const agents = page.getByTestId("open-agents-view");
await agents.click();
await expect(agents).toHaveAttribute("data-active", "true");
await expect(agents).toHaveCSS("font-weight", "400");
});
test("hovering a channel keeps its text color", async ({ page }) => {
await page.goto("/");
const channel = page.getByTestId("channel-engineering");
const initialColor = await channel.evaluate(
(element) => getComputedStyle(element).color,
);
await channel.hover();
await expect(channel).toHaveCSS("color", initialColor);
});
test("direct-message rows become prominent only when unread", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await page.goto("/");
const directMessage = page.getByTestId("channel-alice-tyler");
await directMessage.click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
const label = directMessage.locator("[data-sidebar-row-label]");
await expect(directMessage).toHaveCSS("opacity", "1");
await expect(label).toHaveCSS("opacity", "0.8");
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(label).toHaveCSS("opacity", "1");
await expect(directMessage).toHaveCSS("font-weight", "700");
});
test("light mode reserves full opacity for unread text and avatars", async ({
page,
}) => {
await page.goto("/");
const directMessage = page.getByTestId("channel-alice-tyler");
await directMessage.click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveCSS("opacity", "1");
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
"opacity",
"0.8",
);
await expect(inbox.locator("svg")).toHaveCSS("opacity", "0.8");
await expect(directMessage).toHaveCSS("opacity", "1");
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"0.8",
);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message in light mode",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"1",
);
await expect(directMessage).toHaveCSS("font-weight", "700");
});
test("dark mode keeps selected labels regular and channel-level unread labels bold", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await page.goto("/");
await expect(page.locator("html")).toHaveClass(/dark/);
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveAttribute("data-active", "true");
await expect(inbox).toHaveCSS("font-weight", "400");
await expect(page.getByTestId("open-agents-view")).toHaveCSS("opacity", "1");
await expect(
page.getByTestId("open-agents-view").locator("[data-sidebar=menu-label]"),
).toHaveCSS("opacity", "0.8");
await expect(page.getByTestId("open-agents-view").locator("svg")).toHaveCSS(
"opacity",
"0.8",
);
await page.getByTestId("channel-general").click();
await expect(inbox).toHaveCSS("opacity", "1");
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
"opacity",
"0.8",
);
await waitForMockLiveSubscription(page, "random");
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "A dark-mode channel-level unread message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
const unreadChannel = page.getByTestId("channel-random");
const engineeringLabel = page
.getByTestId("channel-engineering")
.locator("[data-sidebar-row-label]");
await expect(engineeringLabel).toHaveCSS("opacity", "0.8");
await expect(
page.getByTestId("channel-engineering").locator("svg"),
).toHaveCSS("opacity", "0.8");
await expect(unreadChannel.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"1",
);
await expect(unreadChannel).toHaveCSS("font-weight", "700");
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-dark-unread.png`,
clip: { x: 0, y: 0, width: 320, height: 720 },
});
});
test("offscreen top-level unread shows the primary sidebar arrow", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 360 });
await page.goto("/");
await page.getByTestId("channel-random").click();
await waitForMockLiveSubscription(page, "random");
await page.getByTestId("channel-general").click();
const sidebarScroller = page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]');
await sidebarScroller.evaluate((element) => {
const random = element.querySelector<HTMLElement>(
'[data-testid="channel-random"]',
);
if (!random) throw new Error("Could not find #random in the sidebar");
// Keep #random just above the viewport so it is the next unread row.
element.scrollTop +=
random.getBoundingClientRect().bottom -
element.getBoundingClientRect().top +
1;
});
await expect(page.getByTestId("channel-random")).not.toBeInViewport();
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "A regular channel message",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
const activityArrow = page.getByTestId("sidebar-more-unread-above");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toHaveClass(/bg-primary/);
await activityArrow.click();
await expect(page.getByTestId("channel-random")).toBeInViewport();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-top-level-unread-arrow.png`,
clip: { x: 0, y: 0, width: 320, height: 360 },
});
});
test("offscreen unread DM shows the primary sidebar arrow", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
await page.setViewportSize({ width: 1280, height: 360 });
const sidebarScroller = page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]');
await sidebarScroller.evaluate((element) => {
element.scrollTop = 0;
});
await expect(page.getByTestId("channel-alice-tyler")).not.toBeInViewport();
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
const activityArrow = page.getByTestId("sidebar-more-unread-below");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toHaveClass(/bg-primary/);
});
test("regular message bolds inactive channel without numeric badge", async ({
page,
}) => {
@@ -102,11 +349,21 @@ test("regular message bolds inactive channel without numeric badge", async ({
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withDotOnlyBadge(baselineBadge));
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("channel-random")).toHaveAttribute(
"data-active",
"true",
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"400",
);
});
test("top-level @mention bolds the channel without a row badge", async ({
@@ -136,7 +393,7 @@ test("top-level @mention bolds the channel without a row badge", async ({
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
@@ -162,7 +419,7 @@ test("numeric badge increments for DM message", async ({ page }) => {
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("interested thread reply shows the channel thread dot", async ({
test("interested thread reply shows the channel preview dot without incrementing Inbox", async ({
page,
}) => {
await page.goto("/");
@@ -170,6 +427,7 @@ test("interested thread reply shows the channel thread dot", async ({
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
const baselineHomeBadge = await getSidebarHomeBadgeText(page);
const rootEventId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -196,7 +454,10 @@ test("interested thread reply shows the channel thread dot", async ({
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
await expect
.poll(() => getSidebarHomeBadgeText(page))
.toBe(baselineHomeBadge);
await waitForBadgeState(page, baselineBadge);
});
test("broadcast reply bolds the channel without a thread dot", async ({
@@ -226,7 +487,7 @@ test("broadcast reply bolds the channel without a thread dot", async ({
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
@@ -260,7 +521,7 @@ test("mark-as-read via context menu clears channel unread indicator", async ({
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
@@ -269,7 +530,7 @@ test("mark-as-read via context menu clears channel unread indicator", async ({
await expect(page.getByTestId("channel-random")).not.toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, baselineBadge);
@@ -288,7 +549,7 @@ test("mark-as-unread via context menu bolds the channel", async ({ page }) => {
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
@@ -327,10 +588,20 @@ test("marking a message unread bolds its channel after leaving", async ({
await expect(toggle).toHaveText("Mark unread");
await toggle.click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-active-manual-unread.png`,
clip: { x: 0, y: 0, width: 320, height: 720 },
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
});
@@ -438,7 +709,7 @@ test("remote read-state rollback is ignored while local mark-unread still increm
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));

View File

@@ -277,6 +277,12 @@ test.describe("channel activity hover preview", () => {
await seedChannelActivity(page, { extraThreadCount: 5 });
const popover = await openActivityPopover(page);
await waitForAnimations(page);
const enterScale = await popover.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--tw-enter-scale"),
);
expect(enterScale).toBe("1");
const heading = popover.getByRole("heading", {
name: "Channel activity",
});
@@ -537,7 +543,7 @@ test.describe("channel activity hover preview", () => {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
for (const [index, itemId] of inboxItemIds.entries()) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
@@ -546,13 +552,13 @@ test.describe("channel activity hover preview", () => {
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
}
}
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"600",
"700",
);
for (const itemId of inboxItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
@@ -562,7 +568,7 @@ test.describe("channel activity hover preview", () => {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click();
@@ -588,7 +594,7 @@ test.describe("channel activity hover preview", () => {
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"400",
);
await page.mouse.move(900, 680);
@@ -626,7 +632,7 @@ test.describe("channel activity hover preview", () => {
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"600",
"700",
);
});
@@ -688,7 +694,7 @@ test.describe("channel activity hover preview", () => {
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
});
@@ -722,7 +728,7 @@ test.describe("channel activity hover preview", () => {
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect.poll(() => getForcedUnreadSources(page)).toEqual(["inbox"]);
@@ -734,7 +740,7 @@ test.describe("channel activity hover preview", () => {
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect
.poll(() => getForcedUnreadSources(page))

View File

@@ -81,10 +81,31 @@ test.describe("channel muting", () => {
const engRow = page.getByTestId("channel-engineering");
await expect(engRow).toBeVisible();
await expect(engRow).toHaveCSS("opacity", "0.5");
await expect(engRow).toHaveCSS("opacity", "1");
await expect(engRow.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"0.5",
);
await expect(engRow.locator("svg.lucide-hash")).toHaveCSS("opacity", "0.5");
await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
});
test("02b — muted channels recede further in dark mode", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-random").click();
const mutedLabel = page
.getByTestId("channel-engineering")
.locator("[data-sidebar-row-label]");
await expect(mutedLabel).toHaveCSS("opacity", "0.45");
});
test("03 — muted channel with a top-level @mention is emphasized", async ({
page,
}) => {
@@ -125,7 +146,7 @@ test.describe("channel muting", () => {
await expect(page.getByTestId("channel-engineering")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(
page.getByTestId("channel-unread-dot-engineering"),

View File

@@ -2340,7 +2340,7 @@ test("sidebar shows unread indicator for newly active channels", async ({
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
@@ -2373,7 +2373,7 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
await expect(page.getByTestId("channel-watercooler")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);

View File

@@ -221,7 +221,7 @@ test.describe("unread pill & divider", () => {
// Forced channel unread uses channel-name emphasis, not the thread dot.
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
"700",
);
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);