mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(sidebar): simplify unread indicators and emphasize priority activity (#7134)
**Category:** fix **User Impact:** The sidebar now presents unread activity consistently: unread rooms are bold, offscreen activity is counted by destination, and DMs or directed activity receive stronger emphasis. **Problem:** Sidebar unread state was split across competing signals: agent work could trigger overflow, message totals inflated its count, non-DM rows showed redundant numerals, and routine room activity looked as urgent as DMs or directed messages. This made the sidebar noisy and made the overflow value harder to interpret. **Solution:** Use one stable offscreen unread control that counts rooms and DMs—not messages—and remove agent work from that signal. Keep ordinary room activity quiet; promote DMs, mentions, broadcasts, and relevant thread replies; preserve DM avatars and targeting; bold every unread room; and retain thread dots while removing non-DM numerals. <details> <summary>File changes</summary> **desktop/src-tauri/src/unread_catch_up.rs** Classifies relevant thread replies as priority activity during native unread catch-up so startup state matches live rendering. **desktop/src/app/AppShell.tsx** Passes the priority unread destination set into the sidebar. **desktop/src/features/channels/useUnreadChannels.ts** Projects unread destinations, priority state, and thread replies consistently while keeping Dock badge behavior separate from sidebar emphasis. **desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs** Removes tests for the superseded agent/activity overflow projection. **desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts** Removes the old agent-plus-message overflow projection so agent work alone no longer creates the unread indicator. **desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts** Replaces activity-volume overflow state with unread-destination overflow state. **desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs** Covers priority detection and destination-count labels for the new overflow projection. **desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts** Counts distinct offscreen unread destinations and determines quiet versus primary treatment per direction. **desktop/src/features/sidebar/ui/AppSidebar.tsx** Renders one stable overflow control, preserves protected-DM visibility filtering, and prioritizes visible unread DMs for previews and navigation. **desktop/src/features/sidebar/ui/AppSidebar.types.ts** Adds the priority unread destination set to the sidebar contract. **desktop/src/features/sidebar/ui/CustomChannelSection.tsx** Stops forwarding non-DM unread counts into custom channel rows. **desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs** Updates control coverage for explicit emphasis, destination labels, and DM targeting. **desktop/src/features/sidebar/ui/MoreUnreadButton.tsx** Applies quiet or primary treatment without changing geometry, accessibility text, DM avatars, or click behavior. **desktop/src/features/sidebar/ui/SidebarSection.tsx** Removes non-DM row numerals while retaining unread weight and thread preview affordances. **desktop/src/shared/ui/UnreadPill.tsx** Shares one composition between quiet and primary states so only color treatment changes. **desktop/tests/e2e/badge.spec.ts** Covers destination counting, promotion without count inflation, DM/thread priority, row bolding, and removed numerals; also captures the reviewed UI states. **desktop/tests/e2e/channel-activity-popover.spec.ts** Updates channel activity assertions for the numeral-free row treatment. **desktop/tests/e2e/channels.spec.ts** Updates channel unread expectations to use bold text rather than a row count. **desktop/tests/e2e/thread-unread.spec.ts** Keeps thread unread-dot and popover coverage while asserting the room itself is bold. </details> ## Reproduction steps 1. Open a workspace with enough sidebar destinations to scroll rooms above or below the viewport. 2. Receive ordinary unread activity in an offscreen room; verify one quiet `N unread` indicator appears and counts the room once regardless of message volume. 3. Receive a mention, broadcast, or relevant thread reply in an offscreen room; verify the same count becomes primary without changing its geometry or adding a badge. 4. Receive an unread DM, including thread-only activity; verify the indicator is primary, shows the DM avatar when available, and navigates to that DM. 5. Scroll the destination onscreen or mark it read; verify the count and treatment update from the remaining offscreen destinations. 6. Inspect unread non-DM rows; verify their names are bold, numeric badges are absent, and thread dots still open the unread-thread preview. ## Screenshots ### Ordinary unread room Routine offscreen room activity uses the quiet treatment.  ### Directed unread activity The same destination count becomes primary when an offscreen room has directed activity; geometry and label remain unchanged.  ### Thread-only unread DM A DM stays primary and retains its avatar even when only its thread has unread activity.  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -300,6 +300,7 @@ fn classify_batch(
|
||||
let broadcast = has_exact_tag(&event.tags, "broadcast", "1");
|
||||
let threaded = reference.parent_id.is_some() && !broadcast;
|
||||
let high_priority = item.channel.channel_type == "dm"
|
||||
|| threaded
|
||||
|| broadcast
|
||||
|| has_tag_value(&event.tags, "p", &self_pubkey);
|
||||
max_trigger = max_trigger.max(event.created_at);
|
||||
@@ -518,9 +519,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
observed_events
|
||||
.iter()
|
||||
.map(|event| event.id.as_str())
|
||||
.map(|event| (event.id.as_str(), event.high_priority))
|
||||
.collect::<Vec<_>>(),
|
||||
["external-reply"]
|
||||
[("external-reply", true)]
|
||||
);
|
||||
assert_eq!(discovered.participated, ["root"]);
|
||||
}
|
||||
|
||||
@@ -918,6 +918,7 @@ export function AppShell() {
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
{...{ highPriorityUnreadChannelIds }}
|
||||
previewActivityChannelIds={unreadThreadChannelIds}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/features/channels/useLiveChannelUpdates";
|
||||
import {
|
||||
countUnreadAppBadgeObservedEvents,
|
||||
countUnreadBadgeObservedEvents,
|
||||
countUnreadHighPriorityObservedEvents,
|
||||
countUnreadObservedEvents,
|
||||
hasUnreadTopLevelObservedEvent,
|
||||
@@ -426,13 +425,14 @@ export function useUnreadChannels(
|
||||
const handleChannelMessage = React.useCallback(
|
||||
(channelId: string, event: RelayEvent) => {
|
||||
const channel = channelsRef.current.find((ch) => ch.id === channelId);
|
||||
const isHighPriority =
|
||||
channel?.channelType === "dm" ||
|
||||
(normalizedPubkey !== null &&
|
||||
isHighPriorityEventForUser(event, normalizedPubkey));
|
||||
const isThreadedReply =
|
||||
getThreadReference(event.tags).parentId !== null &&
|
||||
!isBroadcastReply(event.tags);
|
||||
const isHighPriority =
|
||||
channel?.channelType === "dm" ||
|
||||
isThreadedReply ||
|
||||
(normalizedPubkey !== null &&
|
||||
isHighPriorityEventForUser(event, normalizedPubkey));
|
||||
const didRecordUnreadEvent = recordUnreadEvent(
|
||||
channelId,
|
||||
makeObservedUnreadEvent({
|
||||
@@ -852,38 +852,24 @@ export function useUnreadChannels(
|
||||
) {
|
||||
topLevelUnread.add(channel.id);
|
||||
}
|
||||
const badgeCount =
|
||||
nativeProjection?.badgeCount ??
|
||||
countUnreadBadgeObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
const appBadgeCount =
|
||||
nativeProjection?.appBadgeCount ??
|
||||
countUnreadAppBadgeObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
// Sidebar numerals on non-DM rows count every unread mention and
|
||||
// broadcast, including threaded ones. The Dock projection
|
||||
// (appBadgeCount) keeps excluding threaded replies because Home's
|
||||
// badge subtotal already counts those; reusing it here would hide
|
||||
// thread mentions from the channel row.
|
||||
const highPriorityCount =
|
||||
nativeProjection?.highPriorityCount ??
|
||||
countUnreadHighPriorityObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
counts.set(
|
||||
channel.id,
|
||||
channel.channelType === "dm" ? badgeCount : highPriorityCount,
|
||||
);
|
||||
counts.set(channel.id, unreadCount);
|
||||
unreadChannelNotificationCount += appBadgeCount;
|
||||
|
||||
// DM channels: any unread DM is high-priority. Non-DM: high-priority
|
||||
// only if at least one mention/broadcast remains unread in its own
|
||||
// channel/thread context.
|
||||
// only if at least one mention, broadcast, or relevant thread reply
|
||||
// remains unread in its own channel/thread context.
|
||||
if (channel.channelType === "dm" || highPriorityCount > 0) {
|
||||
highPriority.add(channel.id);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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("keeps working-only channels out of message overflow prioritization", () => {
|
||||
const activity = getOffscreenActivityChannelIds({
|
||||
activeWorkingByChannelId: new Map([["read-working-dm", {}]]),
|
||||
previewActivityChannelIds: new Set(),
|
||||
unreadChannelIds: new Set(["unread-channel"]),
|
||||
});
|
||||
|
||||
assert.deepEqual([...activity.messageChannelIds], ["unread-channel"]);
|
||||
assert.deepEqual(
|
||||
[...activity.channelIds],
|
||||
["unread-channel", "read-working-dm"],
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
unreadMessageAboveChannelIds: messageOverflow.unreadAboveChannelIds,
|
||||
unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds,
|
||||
unreadAboveLabel: getSidebarActivityOverflowLabel({
|
||||
activityCount: activityOverflow.unreadAboveCount,
|
||||
messageCount: messageOverflow.unreadAboveCount,
|
||||
}),
|
||||
unreadBelowLabel: getSidebarActivityOverflowLabel({
|
||||
activityCount: activityOverflow.unreadBelowCount,
|
||||
messageCount: messageOverflow.unreadBelowCount,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
hasHighPriorityOverflow,
|
||||
sidebarOverflowUnreadLabel,
|
||||
} from "./useSidebarUnreadOverflow.ts";
|
||||
|
||||
test("labels the destination total as unread", () => {
|
||||
assert.equal(sidebarOverflowUnreadLabel(3), "3 unread");
|
||||
});
|
||||
|
||||
test("promotes actionable unread and every offscreen DM", () => {
|
||||
const actionable = new Set(["mention"]);
|
||||
const dms = new Set(["dm"]);
|
||||
|
||||
assert.equal(hasHighPriorityOverflow(["channel"], actionable, dms), false);
|
||||
assert.equal(hasHighPriorityOverflow(["mention"], actionable, dms), true);
|
||||
assert.equal(hasHighPriorityOverflow(["dm"], actionable, dms), true);
|
||||
assert.equal(
|
||||
hasHighPriorityOverflow(["channel", "dm"], actionable, dms),
|
||||
true,
|
||||
);
|
||||
});
|
||||
69
desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts
Normal file
69
desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
|
||||
|
||||
type ScrollRef = Parameters<typeof useUnreadOverflow>[0]["scrollRef"];
|
||||
|
||||
/**
|
||||
* Returns whether any offscreen destination is a DM or has directed unread
|
||||
* activity, which should keep the sidebar overflow control emphasized.
|
||||
*/
|
||||
export function hasHighPriorityOverflow(
|
||||
offscreenChannelIds: readonly string[],
|
||||
highPriorityUnreadChannelIds: ReadonlySet<string>,
|
||||
dmChannelIds: ReadonlySet<string>,
|
||||
) {
|
||||
return offscreenChannelIds.some(
|
||||
(channelId) =>
|
||||
dmChannelIds.has(channelId) ||
|
||||
highPriorityUnreadChannelIds.has(channelId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Formats the accessible label for a distinct unread destination count. */
|
||||
export function sidebarOverflowUnreadLabel(count: number) {
|
||||
return `${count} unread`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects unread message and thread activity into offscreen destination sets.
|
||||
* Message and preview destinations are unioned and deduplicated; DMs and
|
||||
* destinations with directed unread activity receive high-priority treatment.
|
||||
*/
|
||||
export function useSidebarUnreadOverflow({
|
||||
dmChannelIds,
|
||||
highPriorityUnreadChannelIds,
|
||||
previewActivityChannelIds,
|
||||
scrollRef,
|
||||
unreadChannelIds,
|
||||
}: {
|
||||
dmChannelIds: ReadonlySet<string>;
|
||||
highPriorityUnreadChannelIds: ReadonlySet<string>;
|
||||
previewActivityChannelIds: ReadonlySet<string>;
|
||||
scrollRef: ScrollRef;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const messageChannelIds = React.useMemo(
|
||||
() => new Set([...unreadChannelIds, ...previewActivityChannelIds]),
|
||||
[previewActivityChannelIds, unreadChannelIds],
|
||||
);
|
||||
const messageOverflow = useUnreadOverflow({
|
||||
scrollRef,
|
||||
unreadChannelIds: messageChannelIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...messageOverflow,
|
||||
unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds,
|
||||
hasHighPriorityAbove: hasHighPriorityOverflow(
|
||||
messageOverflow.unreadAboveChannelIds,
|
||||
highPriorityUnreadChannelIds,
|
||||
dmChannelIds,
|
||||
),
|
||||
hasHighPriorityBelow: hasHighPriorityOverflow(
|
||||
messageOverflow.unreadBelowChannelIds,
|
||||
highPriorityUnreadChannelIds,
|
||||
dmChannelIds,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference";
|
||||
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
|
||||
import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget";
|
||||
import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow";
|
||||
import {
|
||||
sidebarOverflowUnreadLabel,
|
||||
useSidebarUnreadOverflow,
|
||||
} from "@/features/sidebar/lib/useSidebarUnreadOverflow";
|
||||
import {
|
||||
CreateSectionDialog,
|
||||
DeleteSectionAlertDialog,
|
||||
@@ -38,7 +41,6 @@ import {
|
||||
MoreUnreadButton,
|
||||
preferredUnreadTarget,
|
||||
} from "@/features/sidebar/ui/MoreUnreadButton";
|
||||
import { unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
|
||||
import {
|
||||
ChannelGroupSection,
|
||||
@@ -96,6 +98,7 @@ export function AppSidebar({
|
||||
selectedView,
|
||||
unreadChannelCounts,
|
||||
unreadChannelIds,
|
||||
highPriorityUnreadChannelIds,
|
||||
previewActivityChannelIds,
|
||||
communities,
|
||||
onAddCommunity,
|
||||
@@ -151,10 +154,22 @@ export function AppSidebar({
|
||||
const showSidebarUpdateCard =
|
||||
canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed;
|
||||
const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false);
|
||||
const allDirectMessages = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "dm"),
|
||||
[channels],
|
||||
);
|
||||
const directMessages = useProtectedVisibleDirectMessages(
|
||||
allDirectMessages,
|
||||
currentPubkey,
|
||||
);
|
||||
const dmChannelIds = React.useMemo(
|
||||
() => new Set(directMessages.map(({ id }) => id)),
|
||||
[directMessages],
|
||||
);
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
useSidebarScrollLock(scrollRef);
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
const { scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds });
|
||||
const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds });
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
@@ -368,14 +383,6 @@ export function AppSidebar({
|
||||
),
|
||||
[channels, sortModeFor],
|
||||
);
|
||||
const allDirectMessages = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "dm"),
|
||||
[channels],
|
||||
);
|
||||
const directMessages = useProtectedVisibleDirectMessages(
|
||||
allDirectMessages,
|
||||
currentPubkey,
|
||||
);
|
||||
const isSelectedDirectMessage =
|
||||
selectedView === "channel" &&
|
||||
directMessages.some((channel) => channel.id === selectedChannelId);
|
||||
@@ -540,7 +547,8 @@ export function AppSidebar({
|
||||
{unreadAboveCount > 0 ? (
|
||||
<MoreUnreadButton
|
||||
count={unreadAboveCount}
|
||||
label={unreadAboveLabel ?? unreadCountLabel(unreadAboveCount)}
|
||||
emphasis={hasHighPriorityAbove ? "primary" : "default"}
|
||||
label={sidebarOverflowUnreadLabel(unreadAboveCount)}
|
||||
onClick={scrollToNextAbove}
|
||||
position="top"
|
||||
testId="sidebar-more-unread-above"
|
||||
@@ -600,7 +608,6 @@ export function AppSidebar({
|
||||
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Starred"
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={onMuteChannel}
|
||||
@@ -634,7 +641,6 @@ export function AppSidebar({
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
selectedChannelId={selectedChannelId}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
sections={channelSections}
|
||||
assignments={channelAssignments}
|
||||
@@ -705,7 +711,6 @@ export function AppSidebar({
|
||||
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Channels"
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
sections={channelSections}
|
||||
assignments={channelAssignments}
|
||||
@@ -744,7 +749,6 @@ export function AppSidebar({
|
||||
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Forums"
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={onMuteChannel}
|
||||
@@ -814,7 +818,8 @@ export function AppSidebar({
|
||||
bottomClassName="bottom-full"
|
||||
count={unreadBelowCount}
|
||||
dmPreviews={unreadDmPreviewsBelow}
|
||||
label={unreadBelowLabel ?? unreadCountLabel(unreadBelowCount)}
|
||||
emphasis={hasHighPriorityBelow ? "primary" : "default"}
|
||||
label={sidebarOverflowUnreadLabel(unreadBelowCount)}
|
||||
onClick={() =>
|
||||
nextUnreadDmBelowId
|
||||
? scrollToChannel(nextUnreadDmBelowId)
|
||||
|
||||
@@ -48,6 +48,7 @@ export type AppSidebarProps = {
|
||||
| "projects";
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
highPriorityUnreadChannelIds: ReadonlySet<string>;
|
||||
previewActivityChannelIds: ReadonlySet<string>;
|
||||
communities: Community[];
|
||||
onAddCommunity: (community: Community) => void;
|
||||
|
||||
@@ -358,7 +358,6 @@ export function ChannelGroupSection({
|
||||
onSortModeChange,
|
||||
actionsTestId,
|
||||
title,
|
||||
unreadChannelCounts,
|
||||
unreadChannelIds,
|
||||
sections,
|
||||
assignments,
|
||||
@@ -406,7 +405,6 @@ export function ChannelGroupSection({
|
||||
onSortModeChange?: (mode: ChannelSortMode) => void;
|
||||
actionsTestId?: string;
|
||||
title: string;
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
hasUnread?: boolean;
|
||||
onMarkAllRead?: () => void;
|
||||
@@ -440,7 +438,6 @@ export function ChannelGroupSection({
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
@@ -453,7 +450,6 @@ export function ChannelGroupSection({
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
@@ -548,7 +544,6 @@ export function CustomChannelSection({
|
||||
isActiveChannel,
|
||||
activeWorkingByChannelId,
|
||||
selectedChannelId,
|
||||
unreadChannelCounts,
|
||||
unreadChannelIds,
|
||||
sections,
|
||||
assignments,
|
||||
@@ -585,7 +580,6 @@ export function CustomChannelSection({
|
||||
isActiveChannel: boolean;
|
||||
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
@@ -744,9 +738,6 @@ export function CustomChannelSection({
|
||||
channel.id,
|
||||
)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={
|
||||
unreadChannelCounts.get(channel.id) ?? 0
|
||||
}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel &&
|
||||
|
||||
@@ -77,17 +77,7 @@ describe("MoreUnreadButton model", () => {
|
||||
position: "bottom",
|
||||
targetChannelId: "dm",
|
||||
}),
|
||||
"Go to unread direct message from Alice. 2 new messages below.",
|
||||
);
|
||||
assert.equal(
|
||||
unreadDmAccessibleLabel({
|
||||
count: 2,
|
||||
dmPreviews: [preview("dm", "Alice")],
|
||||
label: "2 new activity",
|
||||
position: "bottom",
|
||||
targetChannelId: "dm",
|
||||
}),
|
||||
"Go to unread direct message from Alice. 2 new activity below.",
|
||||
"Go to unread direct message from Alice. 2 unread below.",
|
||||
);
|
||||
assert.equal(
|
||||
unreadDmAccessibleLabel({
|
||||
@@ -96,7 +86,7 @@ describe("MoreUnreadButton model", () => {
|
||||
position: "bottom",
|
||||
targetChannelId: "near-group",
|
||||
}),
|
||||
"2 new messages below",
|
||||
"2 unread below",
|
||||
);
|
||||
assert.equal(
|
||||
unreadDmAccessibleLabel({
|
||||
@@ -104,7 +94,7 @@ describe("MoreUnreadButton model", () => {
|
||||
dmPreviews: [],
|
||||
position: "top",
|
||||
}),
|
||||
"1 new message above",
|
||||
"1 unread above",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,6 +108,7 @@ describe("MoreUnreadButton model", () => {
|
||||
preview("dm-three", "Group DM"),
|
||||
preview("dm-four", "Dana"),
|
||||
],
|
||||
emphasis: "primary",
|
||||
onClick() {},
|
||||
position: "bottom",
|
||||
targetChannelId: "dm-one",
|
||||
@@ -126,13 +117,10 @@ describe("MoreUnreadButton model", () => {
|
||||
);
|
||||
|
||||
assert.match(markup, /class="[^"]*overflow-hidden[^"]*"/);
|
||||
assert.match(markup, /<span class="min-w-0 truncate">5 unread<\/span>/);
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="min-w-0 truncate">5 new messages<\/span>/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-label="Go to unread direct message from Alice\. 5 new messages below\."/,
|
||||
/aria-label="Go to unread direct message from Alice\. 5 unread below\."/,
|
||||
);
|
||||
assert.doesNotMatch(markup, />Next<\/span>/);
|
||||
assert.match(markup, />·<\/span>/);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { UnreadPill } from "@/shared/ui/UnreadPill";
|
||||
|
||||
export type UnreadDmPreview = {
|
||||
accessibleLabel: string;
|
||||
@@ -35,7 +35,7 @@ export function unreadDmAccessibleLabel({
|
||||
targetChannelId?: string;
|
||||
}) {
|
||||
const direction = position === "top" ? "above" : "below";
|
||||
const resolvedLabel = label ?? unreadCountLabel(count);
|
||||
const resolvedLabel = label ?? `${count} unread`;
|
||||
const targetPreview = dmPreviews.find(
|
||||
({ channelId }) => channelId === targetChannelId,
|
||||
);
|
||||
@@ -58,6 +58,7 @@ export function MoreUnreadButton({
|
||||
bottomClassName = "bottom-0",
|
||||
count,
|
||||
dmPreviews = [],
|
||||
emphasis,
|
||||
label,
|
||||
onClick,
|
||||
position,
|
||||
@@ -67,6 +68,7 @@ export function MoreUnreadButton({
|
||||
bottomClassName?: string;
|
||||
count: number;
|
||||
dmPreviews?: UnreadDmPreview[];
|
||||
emphasis: "default" | "primary";
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
position: "top" | "bottom";
|
||||
@@ -76,7 +78,7 @@ export function MoreUnreadButton({
|
||||
const positionClassName =
|
||||
position === "top" ? topChromeInset.top : bottomClassName;
|
||||
const visibleDmPreviews = visibleUnreadDmPreviews(dmPreviews);
|
||||
const resolvedLabel = label ?? unreadCountLabel(count);
|
||||
const resolvedLabel = label ?? `${count} unread`;
|
||||
const accessibleLabel = unreadDmAccessibleLabel({
|
||||
count,
|
||||
dmPreviews,
|
||||
@@ -91,9 +93,9 @@ export function MoreUnreadButton({
|
||||
>
|
||||
<UnreadPill
|
||||
accessibleLabel={accessibleLabel}
|
||||
className="max-w-full"
|
||||
className="max-w-full overflow-hidden text-xs"
|
||||
direction={position === "top" ? "up" : "down"}
|
||||
emphasis="primary"
|
||||
emphasis={emphasis}
|
||||
label={resolvedLabel}
|
||||
leading={
|
||||
visibleDmPreviews.length > 0 ? (
|
||||
|
||||
@@ -247,7 +247,6 @@ export function ChannelMenuButton({
|
||||
label,
|
||||
isActive,
|
||||
hasUnread,
|
||||
unreadCount = 0,
|
||||
activeWorking,
|
||||
isMuted,
|
||||
dmParticipants,
|
||||
@@ -258,7 +257,6 @@ export function ChannelMenuButton({
|
||||
label?: string;
|
||||
isActive: boolean;
|
||||
hasUnread: boolean;
|
||||
unreadCount?: number;
|
||||
activeWorking?: ActiveChannelTurnSummary;
|
||||
isMuted?: boolean;
|
||||
dmParticipants?: SidebarDmParticipant[];
|
||||
@@ -267,35 +265,19 @@ export function ChannelMenuButton({
|
||||
}) {
|
||||
const resolvedLabel = label ?? channel.name;
|
||||
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
|
||||
const {
|
||||
hasSidebarUnreadProjections,
|
||||
topLevelUnreadChannelIds,
|
||||
unreadThreadChannelIds,
|
||||
} = useAppShell();
|
||||
const hasTopLevelUnread =
|
||||
channel.channelType === "dm"
|
||||
? hasUnread
|
||||
: hasSidebarUnreadProjections
|
||||
? topLevelUnreadChannelIds.has(channel.id)
|
||||
: hasUnread;
|
||||
const { hasSidebarUnreadProjections, unreadThreadChannelIds } = useAppShell();
|
||||
const hasThreadUnread =
|
||||
channel.channelType !== "dm" &&
|
||||
(hasSidebarUnreadProjections
|
||||
? unreadThreadChannelIds.has(channel.id)
|
||||
: hasUnread);
|
||||
const showsUnreadCount =
|
||||
!isActive && channel.channelType !== "dm" && unreadCount > 0;
|
||||
const showsEphemeralBadge =
|
||||
Boolean(ephemeralDisplay) &&
|
||||
!activeWorking &&
|
||||
!isMuted &&
|
||||
!showsUnreadCount &&
|
||||
!hasThreadUnread;
|
||||
Boolean(ephemeralDisplay) && !activeWorking && !isMuted && !hasThreadUnread;
|
||||
const inactiveContentOpacity = cn(
|
||||
!isActive && !hasTopLevelUnread && !isMuted && "opacity-80",
|
||||
!isActive && !hasUnread && !isMuted && "opacity-80",
|
||||
!isActive &&
|
||||
isMuted &&
|
||||
!hasTopLevelUnread &&
|
||||
!hasUnread &&
|
||||
!hasThreadUnread &&
|
||||
"sidebar-muted-content opacity-50 dark:opacity-45",
|
||||
);
|
||||
@@ -307,7 +289,7 @@ export function ChannelMenuButton({
|
||||
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-foreground",
|
||||
hasTopLevelUnread &&
|
||||
hasUnread &&
|
||||
"font-bold text-sidebar-foreground hover:text-sidebar-foreground data-[active=true]:font-bold",
|
||||
)}
|
||||
data-channel-id={channel.id}
|
||||
@@ -373,13 +355,7 @@ export function ChannelMenuButton({
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{showsUnreadCount ? (
|
||||
<UnreadCountBadge
|
||||
channelName={channel.name}
|
||||
className="ml-auto"
|
||||
count={unreadCount}
|
||||
/>
|
||||
) : hasThreadUnread ? (
|
||||
{hasThreadUnread ? (
|
||||
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
@@ -502,7 +478,6 @@ export function SidebarSection({
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { ReactNode } from "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 max-w-[calc(100%_-_1rem)] overflow-hidden gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4";
|
||||
const UNREAD_PILL_COMPOSITION_CLASS =
|
||||
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border px-2 py-1 text-2xs font-medium tracking-[0.02em] shadow-xs [&_svg]:size-4";
|
||||
const DEFAULT_UNREAD_PILL_TREATMENT_CLASS =
|
||||
"border-border/70 bg-background/95 text-muted-foreground/70 backdrop-blur-sm hover:bg-muted/70 hover:text-foreground";
|
||||
const PRIMARY_UNREAD_PILL_TREATMENT_CLASS =
|
||||
"border-primary bg-primary text-primary-foreground hover:bg-primary/90";
|
||||
|
||||
export function unreadCountLabel(count: number) {
|
||||
return `${count} new message${count === 1 ? "" : "s"}`;
|
||||
@@ -37,7 +39,10 @@ export function UnreadPill({
|
||||
<Button
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(
|
||||
emphasis === "primary" ? PRIMARY_UNREAD_PILL_CLASS : UNREAD_PILL_CLASS,
|
||||
UNREAD_PILL_COMPOSITION_CLASS,
|
||||
emphasis === "primary"
|
||||
? PRIMARY_UNREAD_PILL_TREATMENT_CLASS
|
||||
: DEFAULT_UNREAD_PILL_TREATMENT_CLASS,
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -84,6 +84,25 @@ function withDotOnlyBadge(baseline: { state: string; count: number }) {
|
||||
return baseline.count > 0 ? baseline : { state: "dot", count: 0 };
|
||||
}
|
||||
|
||||
async function getUnreadPillComposition(
|
||||
pill: import("@playwright/test").Locator,
|
||||
) {
|
||||
return pill.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const icon = element.querySelector("svg")?.getBoundingClientRect();
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
gap: style.gap,
|
||||
height: element.getBoundingClientRect().height,
|
||||
iconHeight: icon?.height,
|
||||
iconWidth: icon?.width,
|
||||
letterSpacing: style.letterSpacing,
|
||||
paddingBlock: `${style.paddingTop} ${style.paddingBottom}`,
|
||||
paddingInline: `${style.paddingLeft} ${style.paddingRight}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
@@ -279,7 +298,7 @@ test("dark mode keeps selected labels regular and channel-level unread labels bo
|
||||
});
|
||||
});
|
||||
|
||||
test("offscreen top-level unread shows the primary sidebar arrow", async ({
|
||||
test("offscreen unread counts destinations and promotes without incrementing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 360 });
|
||||
@@ -319,7 +338,45 @@ test("offscreen top-level unread shows the primary sidebar arrow", async ({
|
||||
|
||||
const activityArrow = page.getByTestId("sidebar-more-unread-above");
|
||||
await expect(activityArrow).toBeVisible();
|
||||
await expect(activityArrow).toContainText("1 unread");
|
||||
await expect(activityArrow).not.toHaveClass(/bg-primary/);
|
||||
await expect(activityArrow).toHaveCSS("font-size", "12px");
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/sidebar-unread-overflow-default.png`,
|
||||
clip: { x: 0, y: 0, width: 320, height: 360 },
|
||||
});
|
||||
|
||||
const defaultComposition = await getUnreadPillComposition(activityArrow);
|
||||
|
||||
await page.evaluate(
|
||||
({ pubkey, mentionPubkey }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "random",
|
||||
content: "A priority mention for @tyler",
|
||||
kind: 40002,
|
||||
pubkey,
|
||||
mentionPubkeys: [mentionPubkey],
|
||||
});
|
||||
},
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
||||
},
|
||||
);
|
||||
|
||||
// A second message in the same destination promotes the pill but does not
|
||||
// increase the number of places awaiting review.
|
||||
await expect(activityArrow).toContainText("1 unread");
|
||||
await expect(activityArrow).toHaveClass(/bg-primary/);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/sidebar-unread-overflow-primary.png`,
|
||||
clip: { x: 0, y: 0, width: 320, height: 360 },
|
||||
});
|
||||
const primaryComposition = await getUnreadPillComposition(activityArrow);
|
||||
expect(primaryComposition).toEqual(defaultComposition);
|
||||
|
||||
await activityArrow.click();
|
||||
await expect(page.getByTestId("channel-random")).toBeInViewport();
|
||||
await waitForAnimations(page);
|
||||
@@ -357,9 +414,104 @@ test("offscreen unread DM shows the primary sidebar arrow", async ({
|
||||
|
||||
const activityArrow = page.getByTestId("sidebar-more-unread-below");
|
||||
await expect(activityArrow).toBeVisible();
|
||||
await expect(activityArrow).toContainText("1 unread");
|
||||
await expect(activityArrow).toHaveClass(/bg-primary/);
|
||||
});
|
||||
|
||||
test("thread-only activity in an offscreen DM stays primary", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await waitForMockLiveSubscription(page, "alice-tyler");
|
||||
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();
|
||||
|
||||
const initialReplyAt = Math.floor(Date.now() / 1000) - 10;
|
||||
const rootEventId = await page.evaluate((pubkey) => {
|
||||
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "alice-tyler",
|
||||
content: "A DM thread I started",
|
||||
kind: 40002,
|
||||
pubkey,
|
||||
});
|
||||
return root?.id;
|
||||
}, DEFAULT_MOCK_PUBKEY);
|
||||
if (!rootEventId) throw new Error("Mock message emitter is unavailable");
|
||||
|
||||
await page.evaluate(
|
||||
({ createdAt, parentEventId, pubkey }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "alice-tyler",
|
||||
content: "Initial DM thread reply",
|
||||
createdAt,
|
||||
kind: 40002,
|
||||
parentEventId,
|
||||
pubkey,
|
||||
});
|
||||
},
|
||||
{
|
||||
createdAt: initialReplyAt,
|
||||
parentEventId: rootEventId,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
},
|
||||
);
|
||||
|
||||
const threadSummary = page.getByTestId("message-thread-summary").first();
|
||||
await expect(threadSummary).toBeVisible();
|
||||
await threadSummary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
const threadReadSecond = await page.evaluate(() =>
|
||||
Math.floor(Date.now() / 1000),
|
||||
);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Math.floor(Date.now() / 1000)))
|
||||
.toBeGreaterThan(threadReadSecond);
|
||||
|
||||
await page.evaluate(
|
||||
({ parentEventId, pubkey }) => {
|
||||
const reply = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "alice-tyler",
|
||||
content: "A non-mention reply in the active DM thread",
|
||||
kind: 40002,
|
||||
parentEventId,
|
||||
pubkey,
|
||||
});
|
||||
if (!reply) throw new Error("Mock message emitter is unavailable");
|
||||
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
|
||||
category: "activity",
|
||||
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
channel_name: "alice-tyler",
|
||||
channel_type: "dm",
|
||||
content: reply.content,
|
||||
created_at: reply.created_at,
|
||||
id: reply.id,
|
||||
kind: reply.kind,
|
||||
pubkey: reply.pubkey,
|
||||
tags: reply.tags,
|
||||
});
|
||||
},
|
||||
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
|
||||
);
|
||||
|
||||
const activityArrow = page.getByTestId("sidebar-more-unread-below");
|
||||
await expect(activityArrow).toBeVisible();
|
||||
await expect(activityArrow).toContainText("1 unread");
|
||||
await expect(activityArrow).toHaveClass(/bg-primary/);
|
||||
await waitForAnimations(page);
|
||||
await activityArrow.screenshot({
|
||||
path: `${SHOTS}/sidebar-dm-thread-overflow-primary.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("regular message bolds inactive channel without numeric badge", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -402,16 +554,9 @@ test("regular message bolds inactive channel without numeric badge", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("top-level @mention shows an accent-colored numeric badge on its channel", async ({
|
||||
test("top-level @mention bolds its channel without a trailing numeral", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The badge must follow the user's accent selection, not a fixed red.
|
||||
// slack-ochin maps the generic destructive pair to white-on-white, so it
|
||||
// doubles as an adversarial theme: the badge still renders the accent.
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("buzz-theme", "slack-ochin");
|
||||
window.localStorage.setItem("buzz-accent-color", "#22c55e");
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
@@ -443,67 +588,18 @@ test("top-level @mention shows an accent-colored numeric badge on its channel",
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
const mentionBadge = page.getByTestId("channel-unread-random");
|
||||
await expect(mentionBadge).toHaveText("2 unread notifications");
|
||||
await expect(mentionBadge).toHaveClass(/bg-primary/);
|
||||
// The badge resolves to the applied accent (`--primary`), not a fixed hue.
|
||||
const accentMatch = await mentionBadge.evaluate((element) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = "hsl(var(--primary))";
|
||||
probe.style.color = "hsl(var(--primary-foreground))";
|
||||
document.body.appendChild(probe);
|
||||
const probeStyle = getComputedStyle(probe);
|
||||
const badgeStyle = getComputedStyle(element);
|
||||
const result = {
|
||||
badgeBg: badgeStyle.backgroundColor,
|
||||
accentBg: probeStyle.backgroundColor,
|
||||
badgeFg: badgeStyle.color,
|
||||
accentFg: probeStyle.color,
|
||||
};
|
||||
probe.remove();
|
||||
return result;
|
||||
});
|
||||
expect(accentMatch.badgeBg).toBe(accentMatch.accentBg);
|
||||
expect(accentMatch.badgeFg).toBe(accentMatch.accentFg);
|
||||
// Selected accent (#22c55e) actually landed — the badge is green here, not red.
|
||||
expect(accentMatch.badgeBg).toBe("rgb(34, 197, 94)");
|
||||
const badgeContrast = await mentionBadge.evaluate((element) => {
|
||||
const parseRgb = (value: string) =>
|
||||
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
|
||||
const luminance = (color: number[]) =>
|
||||
color
|
||||
.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045
|
||||
? value / 12.92
|
||||
: ((value + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, channel, index) =>
|
||||
sum + channel * [0.2126, 0.7152, 0.0722][index],
|
||||
0,
|
||||
);
|
||||
const style = getComputedStyle(element);
|
||||
const foreground = luminance(parseRgb(style.color));
|
||||
const background = luminance(parseRgb(style.backgroundColor));
|
||||
return (
|
||||
(Math.max(foreground, background) + 0.05) /
|
||||
(Math.min(foreground, background) + 0.05)
|
||||
);
|
||||
});
|
||||
expect(badgeContrast).toBeGreaterThanOrEqual(4.5);
|
||||
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
||||
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2));
|
||||
});
|
||||
|
||||
test("@mention inside a thread shows the numeric badge and keeps hover-to-preview", async ({
|
||||
test("@mention inside a thread bolds the room and keeps hover-to-preview", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "random");
|
||||
const baselineBadge = await getSettledBadgeState(page);
|
||||
|
||||
const rootEventId = await page.evaluate(() => {
|
||||
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
@@ -533,13 +629,14 @@ test("@mention inside a thread shows the numeric badge and keeps hover-to-previe
|
||||
},
|
||||
);
|
||||
|
||||
// The threaded mention must produce the numeric badge, not just the dot.
|
||||
const mentionBadge = page.getByTestId("channel-unread-random");
|
||||
await expect(mentionBadge).toHaveText("1 unread notification");
|
||||
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
|
||||
|
||||
// Hover-to-preview must survive the numeral replacing the dot: the channel
|
||||
// activity popover still opens and lists the mentioning reply.
|
||||
// Hover-to-preview is owned by the thread dot, not a trailing numeral.
|
||||
await page.getByTestId("channel-random").hover();
|
||||
const popover = page.getByTestId("channel-activity-popover-random");
|
||||
await expect(popover).toBeVisible();
|
||||
@@ -609,7 +706,7 @@ test("interested thread reply shows the channel preview dot without incrementing
|
||||
await waitForBadgeState(page, baselineBadge);
|
||||
});
|
||||
|
||||
test("broadcast reply shows a numeric channel badge without a thread dot", async ({
|
||||
test("broadcast reply bolds its channel without a trailing numeral", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
@@ -638,10 +735,7 @@ test("broadcast reply shows a numeric channel badge without a thread dot", async
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(page.getByTestId("channel-unread-random")).toHaveText(
|
||||
"1 unread notification",
|
||||
);
|
||||
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
||||
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
||||
});
|
||||
|
||||
|
||||
@@ -244,11 +244,14 @@ async function seedChannelActivity(
|
||||
);
|
||||
}
|
||||
|
||||
// The seeded activity includes an in-thread @mention, so the row shows the
|
||||
// numeric mention badge rather than the plain thread-activity dot. The
|
||||
// hover preview popover must still work either way.
|
||||
await expect(page.getByTestId("channel-unread-general")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
|
||||
// Thread activity owns the trailing row affordance; mentions additionally
|
||||
// bold the channel name but do not add a numeric badge.
|
||||
await expect(page.getByTestId("channel-general")).toHaveCSS(
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(page.getByTestId("channel-unread-general")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
|
||||
if (includeAgent) {
|
||||
await expect(page.getByTestId("channel-working-general")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1657,10 +1657,16 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
|
||||
},
|
||||
);
|
||||
|
||||
await expect(page.getByTestId(`channel-unread-${channelName}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`channel-${channelName}`)).toHaveCSS(
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(page.getByTestId(`channel-unread-${channelName}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`channel-ephemeral-${channelName}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("ephemeral countdown refreshes when switching channels after a clock jump", async ({
|
||||
|
||||
@@ -778,12 +778,16 @@ test.describe("thread unread indicator", () => {
|
||||
mentionPubkeys: [SELF_PUBKEY],
|
||||
createdAt: unreadTimestamp(),
|
||||
});
|
||||
// The reply mentions the user, so the row shows the numeric mention badge
|
||||
// (which subsumes the thread-activity dot).
|
||||
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
|
||||
// A thread reply keeps the channel bold and retains the thread-activity dot;
|
||||
// the room itself does not show a numeric badge.
|
||||
await expect(page.getByTestId("channel-all-replies")).toHaveCSS(
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("channel-unread-dot-all-replies"),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
|
||||
// View all-replies while the reply is unread.
|
||||
await page.getByTestId("channel-all-replies").click();
|
||||
@@ -793,7 +797,13 @@ test.describe("thread unread indicator", () => {
|
||||
// a channel sidebar unread indicator until the thread itself is read.
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-all-replies")).toHaveCSS(
|
||||
"font-weight",
|
||||
"700",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("channel-unread-dot-all-replies"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Regression guard for BUG-2 (clear-on-read): opening an unread thread marks
|
||||
|
||||
Reference in New Issue
Block a user