From 6c2fb2ce35e2b987331cda8554cfdd59ac8ee098 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 17 Jun 2026 20:31:40 -0700 Subject: [PATCH 1/3] feat(desktop): unify unread pills into one shared UnreadPill component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message timeline and sidebar each rolled their own unread "jump" affordance: the timeline-top pill (outline/primary), the timeline-bottom "jump to latest" pill (muted/gray), and the sidebar "N more unread" pills (solid primary). Extract a single UnreadPill component styled like the timeline-top pill and use it in all four spots — message area top/bottom and sidebar top/bottom — so they read as one family. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/playwright.config.ts | 1 + .../features/messages/ui/MessageTimeline.tsx | 40 ++-- .../src/features/sidebar/ui/AppSidebar.tsx | 4 - .../features/sidebar/ui/MoreUnreadButton.tsx | 24 +-- desktop/src/shared/ui/UnreadPill.tsx | 37 ++++ .../e2e/unread-pill-unify-screenshots.spec.ts | 200 ++++++++++++++++++ 6 files changed, 260 insertions(+), 46 deletions(-) create mode 100644 desktop/src/shared/ui/UnreadPill.tsx create mode 100644 desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 8256c70c9..2409e63e8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", "**/unread-pill-screenshots.spec.ts", + "**/unread-pill-unify-screenshots.spec.ts", "**/thread-unread-screenshots.spec.ts", "**/animated-avatar-screenshots.spec.ts", "**/reminders-screenshots.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 73e96dfa7..6e549bc8c 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ArrowDown, ArrowUp, Hash } from "lucide-react"; +import { Hash } from "lucide-react"; import { selectTimelineBodySurface, @@ -11,9 +11,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; -import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; +import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; @@ -313,17 +313,12 @@ const MessageTimelineBase = React.forwardRef< channelChrome.top, )} > - + testId="message-unread-pill" + /> ) : null}
- + testId="message-scroll-to-latest" + />
) : null} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9262d27e3..358937cff 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,8 +1,6 @@ // biome-ignore format: keep compact to stay within file size limit import { Activity, - ArrowDown, - ArrowUp, Bot, FolderGit2, Home, @@ -556,7 +554,6 @@ export function AppSidebar({ {unreadAboveCount > 0 ? ( } onClick={scrollToNextAbove} position="top" testId="sidebar-more-unread-above" @@ -768,7 +765,6 @@ export function AppSidebar({ } onClick={scrollToNextBelow} position="bottom" testId="sidebar-more-unread-below" diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index e0b2d027b..403883482 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -1,21 +1,14 @@ -import type * as React from "react"; - -import { Button } from "@/shared/ui/button"; - -const MORE_UNREAD_BUTTON_CLASS = - "h-7 min-h-7 gap-1.5 rounded-full border-0 bg-primary px-2.5 text-2xs font-medium text-primary-foreground shadow-md hover:bg-primary/90 [&_svg]:size-4"; +import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; export function MoreUnreadButton({ bottomClassName = "bottom-0", count, - icon, onClick, position, testId, }: { bottomClassName?: string; count: number; - icon: React.ReactNode; onClick: () => void; position: "top" | "bottom"; testId: string; @@ -24,17 +17,12 @@ export function MoreUnreadButton({
- + testId={testId} + />
); } diff --git a/desktop/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx new file mode 100644 index 000000000..50f481c0b --- /dev/null +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -0,0 +1,37 @@ +import { ArrowDown, ArrowUp } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +const UNREAD_PILL_CLASS = + "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-primary/40 bg-primary/10 px-2.5 text-2xs font-medium text-primary shadow-xs backdrop-blur-sm hover:bg-primary/20 [&_svg]:size-4"; + +export function unreadCountLabel(count: number) { + return `${count} new message${count === 1 ? "" : "s"}`; +} + +export function UnreadPill({ + direction, + label, + onClick, + testId, +}: { + direction: "up" | "down"; + label: string; + onClick: () => void; + testId: string; +}) { + const Arrow = direction === "up" ? ArrowUp : ArrowDown; + return ( + + ); +} diff --git a/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts b/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts new file mode 100644 index 000000000..a2d351ad5 --- /dev/null +++ b/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts @@ -0,0 +1,200 @@ +import { expect, test } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/unread-pill-unify"; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ); + }) + .toBe(true); +} + +function emitMockMessage( + page: import("@playwright/test").Page, + channelName: string, + content: string, + createdAt?: number, +) { + return page.evaluate( + ({ ch, msg, pubkey, ts }) => { + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey: string; + createdAt?: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: ch, + content: msg, + pubkey, + createdAt: ts, + }); + }, + { + ch: channelName, + msg: content, + pubkey: TEST_IDENTITIES.alice.pubkey, + ts: createdAt, + }, + ); +} + +const UNREAD_OFFSET_SECONDS = 60; + +function unreadTimestamp() { + return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS; +} + +async function emitUnreadMessages( + page: import("@playwright/test").Page, + count: number, +) { + const base = unreadTimestamp(); + for (let index = 0; index < count; index += 1) { + await emitMockMessage( + page, + "general", + `Unread message ${index + 1}`, + base + index, + ); + } +} + +async function openGeneralWithUnreads( + page: import("@playwright/test").Page, + count: number, +) { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await emitUnreadMessages(page, count); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +} + +test.describe("unified unread pill — message area", () => { + test("message-area-top-pill", async ({ page }) => { + await openGeneralWithUnreads(page, 20); + + // Scroll up so the "N new messages" jump-to-oldest pill stays on screen. + await page.getByTestId("message-timeline").evaluate((el) => { + el.scrollTop = Math.floor(el.scrollHeight * 0.35); + }); + await page.waitForTimeout(300); + + const pill = page.getByTestId("message-unread-pill"); + await expect(pill).toBeVisible(); + await expect(pill).toContainText("20 new messages"); + + await page.screenshot({ path: `${SHOTS}/message-area-top-pill.png` }); + }); + + test("message-area-bottom-pill", async ({ page }) => { + await openGeneralWithUnreads(page, 60); + + // Let the channel settle (it auto-pins to the bottom on open), then scroll + // up so plenty of content sits below the fold — the condition for the + // bottom "jump to latest" pill to render. + await page.waitForTimeout(500); + await page.getByTestId("message-timeline").evaluate((el) => { + el.scrollTop = Math.floor(el.scrollHeight * 0.25); + el.dispatchEvent(new Event("scroll")); + }); + await page.waitForTimeout(500); + + const pill = page.getByTestId("message-scroll-to-latest"); + await expect(pill).toBeVisible(); + + await page.screenshot({ path: `${SHOTS}/message-area-bottom-pill.png` }); + }); +}); + +test.describe("unified unread pill — sidebar", () => { + test("sidebar-more-unread-pills", async ({ page }) => { + // A short viewport forces the channel list to overflow the sidebar scroll + // area so unread rows can sit above and below the fold. + await page.setViewportSize({ width: 1280, height: 460 }); + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Mark several inactive channels unread (above + below "general" in the + // list) so both the top and bottom "more unread" pills can appear. + for (const ch of ["agents", "all-replies", "engineering", "random"]) { + await waitForMockLiveSubscription(page, ch).catch(() => {}); + await page.getByTestId(`channel-${ch}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(ch); + await emitMockMessage( + page, + ch, + `Unread in ${ch}`, + unreadTimestamp(), + ); + } + + // Return to general so the others are inactive and show unread state. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Scroll the sidebar to the middle so unread rows fall off both ends. + await page.waitForTimeout(400); + await page + .locator('[data-sidebar="content"]') + .first() + .evaluate((el) => { + el.scrollTop = Math.floor(el.scrollHeight * 0.4); + el.dispatchEvent(new Event("scroll")); + }); + await page.waitForTimeout(500); + + await page.screenshot({ path: `${SHOTS}/sidebar-more-unread-pills.png` }); + + // Scroll back to the top so the unread rows below the fold drive the + // bottom "more unread" pill — capture it in the same unified style. + await page + .locator('[data-sidebar="content"]') + .first() + .evaluate((el) => { + el.scrollTop = 0; + el.dispatchEvent(new Event("scroll")); + }); + await page.waitForTimeout(500); + + const bottomPill = page.getByTestId("sidebar-more-unread-below"); + await expect(bottomPill).toBeVisible(); + + await page.screenshot({ + path: `${SHOTS}/sidebar-more-unread-bottom-pill.png`, + }); + }); +}); From 56894fdb90c2ca2cfa5d748a5ee84c39a2bd7a2e Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 18 Jun 2026 07:29:02 -0700 Subject: [PATCH 2/3] style(desktop): satisfy biome formatter in unread-pill e2e spec CI's biome check flagged a multi-line emitMockMessage call that the formatter collapses to a single line. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts b/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts index a2d351ad5..3968c3633 100644 --- a/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts +++ b/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts @@ -154,12 +154,7 @@ test.describe("unified unread pill — sidebar", () => { await waitForMockLiveSubscription(page, ch).catch(() => {}); await page.getByTestId(`channel-${ch}`).click(); await expect(page.getByTestId("chat-title")).toHaveText(ch); - await emitMockMessage( - page, - ch, - `Unread in ${ch}`, - unreadTimestamp(), - ); + await emitMockMessage(page, ch, `Unread in ${ch}`, unreadTimestamp()); } // Return to general so the others are inactive and show unread state. From 0cd043527e0f905cfc5cf89740acd85320aeea81 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 18 Jun 2026 07:37:08 -0700 Subject: [PATCH 3/3] test(desktop): drop the unread-pill screenshot e2e spec The unify screenshot spec existed only to capture PR images, which are now attached to the PR. Its assertions were thin and it relied on fixed waitForTimeout sleeps; the same testids are already exercised by the existing unread-pill and onboarding e2e specs, and the auto-scroll logic is covered by timelineSnapshot unit tests. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/playwright.config.ts | 1 - .../e2e/unread-pill-unify-screenshots.spec.ts | 195 ------------------ 2 files changed, 196 deletions(-) delete mode 100644 desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 2409e63e8..8256c70c9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,7 +45,6 @@ export default defineConfig({ "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", "**/unread-pill-screenshots.spec.ts", - "**/unread-pill-unify-screenshots.spec.ts", "**/thread-unread-screenshots.spec.ts", "**/animated-avatar-screenshots.spec.ts", "**/reminders-screenshots.spec.ts", diff --git a/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts b/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts deleted file mode 100644 index 3968c3633..000000000 --- a/desktop/tests/e2e/unread-pill-unify-screenshots.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; - -const SHOTS = "test-results/unread-pill-unify"; - -async function waitForMockLiveSubscription( - page: import("@playwright/test").Page, - channelName: string, -) { - await expect - .poll(async () => { - return page.evaluate( - ({ ch }) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { - channelName: string; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? - false, - { ch: channelName }, - ); - }) - .toBe(true); -} - -function emitMockMessage( - page: import("@playwright/test").Page, - channelName: string, - content: string, - createdAt?: number, -) { - return page.evaluate( - ({ ch, msg, pubkey, ts }) => { - ( - window as Window & { - __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { - channelName: string; - content: string; - pubkey: string; - createdAt?: number; - }) => unknown; - } - ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: ch, - content: msg, - pubkey, - createdAt: ts, - }); - }, - { - ch: channelName, - msg: content, - pubkey: TEST_IDENTITIES.alice.pubkey, - ts: createdAt, - }, - ); -} - -const UNREAD_OFFSET_SECONDS = 60; - -function unreadTimestamp() { - return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS; -} - -async function emitUnreadMessages( - page: import("@playwright/test").Page, - count: number, -) { - const base = unreadTimestamp(); - for (let index = 0; index < count; index += 1) { - await emitMockMessage( - page, - "general", - `Unread message ${index + 1}`, - base + index, - ); - } -} - -async function openGeneralWithUnreads( - page: import("@playwright/test").Page, - count: number, -) { - await installMockBridge(page); - await page.goto("/"); - - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await waitForMockLiveSubscription(page, "general"); - - await page.getByTestId("channel-random").click(); - await expect(page.getByTestId("chat-title")).toHaveText("random"); - - await emitUnreadMessages(page, count); - - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); -} - -test.describe("unified unread pill — message area", () => { - test("message-area-top-pill", async ({ page }) => { - await openGeneralWithUnreads(page, 20); - - // Scroll up so the "N new messages" jump-to-oldest pill stays on screen. - await page.getByTestId("message-timeline").evaluate((el) => { - el.scrollTop = Math.floor(el.scrollHeight * 0.35); - }); - await page.waitForTimeout(300); - - const pill = page.getByTestId("message-unread-pill"); - await expect(pill).toBeVisible(); - await expect(pill).toContainText("20 new messages"); - - await page.screenshot({ path: `${SHOTS}/message-area-top-pill.png` }); - }); - - test("message-area-bottom-pill", async ({ page }) => { - await openGeneralWithUnreads(page, 60); - - // Let the channel settle (it auto-pins to the bottom on open), then scroll - // up so plenty of content sits below the fold — the condition for the - // bottom "jump to latest" pill to render. - await page.waitForTimeout(500); - await page.getByTestId("message-timeline").evaluate((el) => { - el.scrollTop = Math.floor(el.scrollHeight * 0.25); - el.dispatchEvent(new Event("scroll")); - }); - await page.waitForTimeout(500); - - const pill = page.getByTestId("message-scroll-to-latest"); - await expect(pill).toBeVisible(); - - await page.screenshot({ path: `${SHOTS}/message-area-bottom-pill.png` }); - }); -}); - -test.describe("unified unread pill — sidebar", () => { - test("sidebar-more-unread-pills", async ({ page }) => { - // A short viewport forces the channel list to overflow the sidebar scroll - // area so unread rows can sit above and below the fold. - await page.setViewportSize({ width: 1280, height: 460 }); - await installMockBridge(page); - await page.goto("/"); - - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - // Mark several inactive channels unread (above + below "general" in the - // list) so both the top and bottom "more unread" pills can appear. - for (const ch of ["agents", "all-replies", "engineering", "random"]) { - await waitForMockLiveSubscription(page, ch).catch(() => {}); - await page.getByTestId(`channel-${ch}`).click(); - await expect(page.getByTestId("chat-title")).toHaveText(ch); - await emitMockMessage(page, ch, `Unread in ${ch}`, unreadTimestamp()); - } - - // Return to general so the others are inactive and show unread state. - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - // Scroll the sidebar to the middle so unread rows fall off both ends. - await page.waitForTimeout(400); - await page - .locator('[data-sidebar="content"]') - .first() - .evaluate((el) => { - el.scrollTop = Math.floor(el.scrollHeight * 0.4); - el.dispatchEvent(new Event("scroll")); - }); - await page.waitForTimeout(500); - - await page.screenshot({ path: `${SHOTS}/sidebar-more-unread-pills.png` }); - - // Scroll back to the top so the unread rows below the fold drive the - // bottom "more unread" pill — capture it in the same unified style. - await page - .locator('[data-sidebar="content"]') - .first() - .evaluate((el) => { - el.scrollTop = 0; - el.dispatchEvent(new Event("scroll")); - }); - await page.waitForTimeout(500); - - const bottomPill = page.getByTestId("sidebar-more-unread-below"); - await expect(bottomPill).toBeVisible(); - - await page.screenshot({ - path: `${SHOTS}/sidebar-more-unread-bottom-pill.png`, - }); - }); -});