mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user