mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
**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>
992 lines
31 KiB
TypeScript
992 lines
31 KiB
TypeScript
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,
|
|
channelName: string,
|
|
kind?: number,
|
|
) {
|
|
await expect
|
|
.poll(async () => {
|
|
return page.evaluate(
|
|
({ currentChannelName, kind: k }) => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind?: number;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: currentChannelName,
|
|
kind: k,
|
|
}) ?? false
|
|
);
|
|
},
|
|
{ currentChannelName: channelName, kind },
|
|
);
|
|
})
|
|
.toBe(true);
|
|
}
|
|
|
|
async function getBadgeState(page: import("@playwright/test").Page) {
|
|
return page.evaluate(() => {
|
|
const w = window as Window & {
|
|
__BUZZ_E2E_APP_BADGE_STATE__?: string;
|
|
__BUZZ_E2E_APP_BADGE_COUNT__?: number;
|
|
};
|
|
return {
|
|
state: w.__BUZZ_E2E_APP_BADGE_STATE__ ?? "none",
|
|
count: w.__BUZZ_E2E_APP_BADGE_COUNT__ ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function waitForBadgeState(
|
|
page: import("@playwright/test").Page,
|
|
expected: { state: string; count?: number },
|
|
) {
|
|
await expect
|
|
.poll(async () => getBadgeState(page), { timeout: 5_000 })
|
|
.toEqual(
|
|
expect.objectContaining({
|
|
state: expected.state,
|
|
...(expected.count !== undefined ? { count: expected.count } : {}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function getSettledBadgeState(page: import("@playwright/test").Page) {
|
|
// The mock bridge seeds a couple of unread items during app startup. Let
|
|
// those settle before asserting deltas from newly emitted messages.
|
|
await page.waitForTimeout(2000);
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
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("primary navigation rows share the same inactive emphasis", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const primaryMenu = page.getByTestId("sidebar-primary-menu");
|
|
const inactiveRows = [
|
|
primaryMenu.getByRole("button", { name: "Inbox", exact: true }),
|
|
page.getByTestId("open-pulse-view"),
|
|
page.getByTestId("open-projects-view"),
|
|
page.getByTestId("open-agents-view"),
|
|
page.getByTestId("open-workflows-view"),
|
|
];
|
|
|
|
for (const row of inactiveRows) {
|
|
await expect(row).toHaveAttribute("data-active", "false");
|
|
await expect(row.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
await expect(row.locator("svg")).toHaveCSS("opacity", "0.8");
|
|
}
|
|
|
|
const pulse = page.getByTestId("open-pulse-view");
|
|
await pulse.click();
|
|
await expect(pulse).toHaveAttribute("data-active", "true");
|
|
await expect(pulse.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
await expect(pulse.locator("svg")).toHaveCSS("opacity", "1");
|
|
});
|
|
|
|
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 unread counts destinations and promotes without incrementing", 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).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);
|
|
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).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,
|
|
}) => {
|
|
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);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Regular message, no mention",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
const unreadChannel = page.getByTestId("channel-random");
|
|
await expect(unreadChannel).toHaveCSS("font-weight", "700");
|
|
await expect(unreadChannel.locator("[data-sidebar-row-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
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 its channel without a trailing numeral", 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);
|
|
|
|
await page.evaluate(
|
|
({ pubkey, mentionPubkey }) => {
|
|
for (const content of [
|
|
"Hey @tyler check this out",
|
|
"One more for @tyler",
|
|
]) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content,
|
|
kind: 40002,
|
|
pubkey,
|
|
mentionPubkeys: [mentionPubkey],
|
|
});
|
|
}
|
|
},
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
|
},
|
|
);
|
|
|
|
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")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2));
|
|
});
|
|
|
|
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 rootEventId = await page.evaluate(() => {
|
|
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Thread root from someone else",
|
|
kind: 40002,
|
|
pubkey: "deadbeef".repeat(8),
|
|
});
|
|
return root?.id;
|
|
});
|
|
|
|
await page.evaluate(
|
|
({ parentEventId, pubkey, mentionPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "In-thread ping for @tyler",
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
mentionPubkeys: [mentionPubkey],
|
|
});
|
|
},
|
|
{
|
|
parentEventId: rootEventId,
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
|
},
|
|
);
|
|
|
|
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 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();
|
|
await expect(
|
|
popover.getByTestId(`channel-activity-item-${rootEventId}`),
|
|
).toBeVisible();
|
|
await expect(popover).toContainText("In-thread ping for");
|
|
});
|
|
|
|
test("numeric badge increments for DM message", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "Hey, got a minute?",
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("interested thread reply shows the channel preview dot without incrementing Inbox", 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 baselineHomeBadge = await getSidebarHomeBadgeText(page);
|
|
|
|
const rootEventId = await page.evaluate(() => {
|
|
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Conversation I started",
|
|
kind: 40002,
|
|
pubkey: "deadbeef".repeat(8),
|
|
});
|
|
return root?.id;
|
|
});
|
|
|
|
await page.evaluate(
|
|
({ parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Thread reply to a followed conversation",
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
|
|
await expect
|
|
.poll(() => getSidebarHomeBadgeText(page))
|
|
.toBe(baselineHomeBadge);
|
|
await waitForBadgeState(page, baselineBadge);
|
|
});
|
|
|
|
test("broadcast reply bolds its channel without a trailing numeral", 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);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Broadcast reply to the channel",
|
|
kind: 40002,
|
|
pubkey,
|
|
extraTags: [
|
|
["broadcast", "1"],
|
|
["e", "some-root-event-id"],
|
|
],
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("mark-as-read via context menu clears channel unread indicator", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
|
|
// Wait for catch-up to settle, then record baseline badge state
|
|
// (other mock channels may have pre-existing unreads from seeded history)
|
|
await page.waitForTimeout(2000);
|
|
const baselineBadge = await getBadgeState(page);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Message to be marked read",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark as read").click();
|
|
|
|
await expect(page.getByTestId("channel-random")).not.toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, baselineBadge);
|
|
});
|
|
|
|
test("mark-as-unread via context menu bolds the channel", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark unread").click();
|
|
|
|
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")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("marking a message unread bolds its channel after leaving", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
|
|
const message = await page.evaluate(
|
|
({ pubkey }) =>
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Keep this channel message unread",
|
|
kind: 40002,
|
|
pubkey,
|
|
}),
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
if (!message) {
|
|
throw new Error("Mock message emitter is unavailable");
|
|
}
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Keep this channel message unread" });
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.hover();
|
|
await page.getByTestId(`more-actions-${message.id}`).click();
|
|
const toggle = page.getByTestId(`mark-read-toggle-${message.id}`);
|
|
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",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
|
});
|
|
|
|
test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Baseline: random has no unread dot
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
// Wait for ReadStateManager's live subscription (kind:30078) to be
|
|
// established before injecting events.
|
|
await expect
|
|
.poll(async () => {
|
|
return page.evaluate(() => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind?: number;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
kind: 30078,
|
|
}) ?? false
|
|
);
|
|
});
|
|
})
|
|
.toBe(true);
|
|
|
|
const REMOTE_CLIENT_ID = "other-device-client-id";
|
|
const REMOTE_SLOT_ID = "e2e00000000000000000000000000000";
|
|
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Step 1: seed a "read at now" state from the remote device so the
|
|
// local manager has a baseline value for this channel context.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt: ts,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now,
|
|
},
|
|
);
|
|
|
|
// Step 2: a remote rollback carries an older read timestamp in a newer
|
|
// event. NIP-RS read markers are monotonic, so this must be ignored.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts, createdAt }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now - 100,
|
|
createdAt: now + 5,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
|
|
// Local mark-unread remains an in-session affordance and should still bold
|
|
// the channel immediately without publishing a lower read timestamp.
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark unread").click();
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
|
|
// Step 3: remote advance clears the local forced-unread dot.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts, createdAt }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now + 10,
|
|
createdAt: now + 10,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
});
|