Files
buzz/desktop/tests/e2e/channel-activity-popover.spec.ts
Logan Johnson ad2a84131e fix(desktop): npub identity displays for mention, member, and workflow surfaces (#7495)
🤖
## Summary

Every Buzz account is identified by a long public key. Before this
change, when someone had no display name, surfaces fell back to
inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`,
sometimes a generic role label with no key — so the same person looked
different from surface to surface, and nothing looked like an npub
address. This PR applies the npub identity foundation from #7488 to the
everyday surfaces: a person without a display name now falls back to the
same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable
spelling of their public key (first 8 + last 4 characters of the full
npub) — across messages and mentions, reactions, huddles, member and
participant lists, the sidebar and channel activity, search, projects,
tray, notifications, and workflow surfaces.

- **Mentions and messages**: key-only mention chips render the compact
npub. Pasting a copied mention back still re-binds it byte-exactly to
the identity it declares, for both the new npub chips and legacy
hex-truncated chips copied by older clients — wrong, missing, or
tampered key qualification is rejected instead of silently degrading to
plain text.
- **Reactions and huddles**: huddle reaction events and the huddle
roster/participants render the compact npub for unnamed participants;
workflow reaction triggers describe authors with the same form.
- **Members and sidebar**: channel and community member lists,
add-member results and invites, the members sidebar, the
channel-activity popover, search, projects (assignees/reviewers/PR
panels), the tray menu, and desktop notifications all fall back to the
compact npub; titles and aria labels keep the machine-readable full
labels.
- **Profile labels**: panel/popover display names and owner handles fall
back to the compact npub (never raw hex) when there is no name;
linked-event (nevent) message metadata shows the npub-shaped author
fallback while the event lookup and event IDs are unchanged.
- **Workflows**: author-picker secondary labels, step destination keys,
and trigger-author references render compact npubs; event and blob IDs
keep their existing hex compacts (they are not identities).
- **Avatars stay distinct**: fallback avatars for key-only identities
derive initials from the key's tail, so prefixed role labels like
"Participant npub1…" no longer collapse every unnamed participant onto
the same initials; people with names keep their name initials.

Preserved exactly: display names and distinct avatars, internal hex keys
(storage/API forms unchanged), clipboard identity roundtrips, event/blob
ID compaction, private keys (no nsec path is touched), and nevent link
handling.

Scope: this PR changes what identity labels **display**, not identity
controls — profile/settings copy controls, the respond-to allowlist,
workflow key fields, and agent dialogs are the sibling slice #7489, and
the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>`
gate, strict input parsing) come from the foundation #7488.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for duplicates — none found;
the related work is the npub identity stack this slice belongs to.
- Base/dependency: stacks on #7488 (foundation) — this PR does not stand
alone on main.
- #7489 is a sibling slice on the same #7488 base
(profile/agent/workflow controls), not a dependency: this PR does not
require #7489, and #7489 does not require this PR — both only require
#7488.

### Testing

At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base:
`fix/desktop-npub-identity-d1a` @
`5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 —
production +277/−136, test support +379/−53):

- At this head: targeted `mentions.spec.ts` (1/1), the e2e build,
typecheck, and biome — green.
- 9 changed/related unit files: 100/100 green; typecheck, e2e build,
biome, and px text/truncation checks clean; huddle-roster focused run
green; channel-activity e2e 11/11; mutation checks confirm the fallback
wiring (removing it collapses shared initials and drops fallback rows).
- Known pre-existing local e2e failures, unchanged by this PR and
reproduced identically at the upstream merge-base: huddle-transcription
voice-menu attribution (25 pass / 1 fail) and the
`workflow-local-controls` 438px caret drift. Not claimed green locally.
- Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only
follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls`
races were fixed in the test drivers, and the 438px diff was shown to be
a stale Darwin snapshot baseline (name-row enable switch already absent
and `message_posted` already MessageSquare at recording commit
`9390e11c9`) and refreshed — the focused screenshot test, including
keyboard/caret assertions, now passes locally (twice). The full spec was
not rerun after the snapshot refresh; the huddle-transcription item
above is unchanged.

Label/copy text changes are asserted by the e2e specs (`mentions`,
`mention-recipients`, `pubkey-display-screenshots`,
`huddle-transcription`, `channel-activity-popover`,
`workflow-local-controls`) rather than new screenshots; the screenshot
spec pins the compact npub text forms.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:07:24 -04:00

866 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SELF_PUBKEY = "deadbeef".repeat(8);
const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_PUBKEY = TEST_IDENTITIES.charlie.pubkey;
type MockMessageEvent = {
id: string;
created_at: number;
pubkey: string;
};
type MockInboxFeedItem = {
content: string;
id: string;
tags: string[][];
};
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emitMockMessage(
page: Page,
content: string,
options: {
parentEventId?: string;
pubkey: string;
createdAt: number;
mentionPubkeys?: string[];
},
): Promise<MockMessageEvent> {
const event = await page.evaluate(
({ body, parentEventId, pubkey, createdAt, mentionPubkeys }) =>
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string;
pubkey: string;
createdAt: number;
mentionPubkeys?: string[];
}) => MockMessageEvent;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
parentEventId,
pubkey,
createdAt,
mentionPubkeys,
}),
{
body: content,
parentEventId: options.parentEventId,
pubkey: options.pubkey,
createdAt: options.createdAt,
mentionPubkeys: options.mentionPubkeys,
},
);
if (!event) {
throw new Error("Mock message emitter is unavailable");
}
return event;
}
async function pushMockInboxFeedItems(page: Page, items: MockInboxFeedItem[]) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: unknown })
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function",
);
await page.evaluate(
({ channelId, feedItems, senderPubkey }) => {
const pushFeedItem = (
window as Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention";
channel_id: string;
channel_name: string;
channel_type: "stream";
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => void;
}
).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed injection helper is unavailable");
}
const createdAt = Math.floor(Date.now() / 1_000) - 300;
for (const item of feedItems) {
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: item.content,
created_at: createdAt,
id: item.id,
kind: 9,
pubkey: senderPubkey,
tags: item.tags,
});
}
},
{
channelId: CHANNEL_GENERAL,
feedItems: items,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
}
async function getForcedUnreadSources(page: Page): Promise<string[]> {
return page.evaluate(
({ channelId, pubkey }) => {
const raw = window.localStorage.getItem(
`buzz-forced-unread.v1:${pubkey}`,
);
if (!raw) return [];
const entry = JSON.parse(raw)?.[channelId];
if (entry === undefined) return [];
return typeof entry === "object" && entry !== null
? (entry.sources ?? [])
: ["manual"];
},
{ channelId: CHANNEL_GENERAL, pubkey: SELF_PUBKEY },
);
}
async function seedChannelActivity(
page: Page,
{
extraThreadCount = 0,
includeAgent = true,
}: { extraThreadCount?: number; includeAgent?: boolean } = {},
) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const ownRoot = await emitMockMessage(
page,
"How should the handoff work for longer agent tasks?",
{
pubkey: SELF_PUBKEY,
createdAt: Math.floor(Date.now() / 1000) - 20,
},
);
const extraRoots = await Promise.all(
Array.from({ length: extraThreadCount }, (_, index) =>
emitMockMessage(page, `Overflow preview thread ${index + 1}`, {
pubkey: SELF_PUBKEY,
createdAt: Math.floor(Date.now() / 1000) - 19 + index,
}),
),
);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const unreadAt = Math.floor(Date.now() / 1000) + 60;
await emitMockMessage(
page,
"I tightened the empty state and added the direct thread link.",
{
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: unreadAt,
mentionPubkeys: [SELF_PUBKEY],
},
);
await emitMockMessage(
page,
"The updated interaction keeps context visible without opening the channel first.",
{
parentEventId: ownRoot.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: unreadAt + 1,
},
);
await Promise.all(
extraRoots.map((root, index) =>
emitMockMessage(
page,
`A new reply in overflow preview thread ${index + 1}`,
{
parentEventId: root.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: unreadAt + 2 + index,
},
),
),
);
if (includeAgent) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
);
await page.evaluate(
({ agentPubkey, channelId }) => {
(
window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
}
).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId,
turnId: "channel-hover-preview",
});
},
{ agentPubkey: AGENT_PUBKEY, channelId: CHANNEL_GENERAL },
);
}
// 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();
}
}
async function openActivityPopover(page: Page) {
await page.getByTestId("channel-general").hover();
const popover = page.getByTestId("channel-activity-popover-general");
await expect(popover).toBeVisible();
return popover;
}
test.describe("channel activity hover preview", () => {
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: "Charlie",
status: "running",
channelNames: ["general"],
},
],
});
});
test("shows unread channel activity and working agents, then opens the selected thread", async ({
page,
}) => {
await seedChannelActivity(page, { extraThreadCount: 5 });
const popover = await openActivityPopover(page);
await waitForAnimations(page);
const enterScale = await popover.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--tw-enter-scale"),
);
expect(enterScale).toBe("1");
const heading = popover.getByRole("heading", {
name: "Channel activity",
});
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("backdrop-filter", /blur/);
const activityScroll = popover.getByTestId("channel-activity-scroll");
await expect(activityScroll).toHaveCSS("overflow-y", "auto");
await expect(activityScroll.getByRole("heading")).toHaveCount(0);
await expect
.poll(() =>
activityScroll.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
)
.toBe(true);
await waitForAnimations(page);
const headingY = (await heading.boundingBox())?.y;
const scrollY = (await activityScroll.boundingBox())?.y;
const headingBottom = await heading.evaluate(
(element) => element.getBoundingClientRect().bottom,
);
expect(scrollY).toBeGreaterThanOrEqual(headingBottom - 1);
await activityScroll.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => activityScroll.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
const scrolledHeadingY = (await heading.boundingBox())?.y;
expect(headingY).toBeDefined();
expect(scrolledHeadingY).toBeDefined();
expect(Math.abs((scrolledHeadingY ?? 0) - (headingY ?? 0))).toBeLessThan(
0.5,
);
await activityScroll.evaluate((element) => {
element.scrollTop = 0;
});
await expect
.poll(() =>
activityScroll.evaluate(
(element) =>
getComputedStyle(element, "::-webkit-scrollbar-thumb")
.backgroundColor,
),
)
.toMatch(/0\.15\)/);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(7);
await expect(popover).toContainText(
"I tightened the empty state and added the direct thread link.",
);
await expect(popover.getByRole("heading")).toHaveCount(1);
await expect(
popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`),
).toContainText("Charlie");
// The authored name also supplies the row avatar's initials.
await expect(
popover
.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`)
.getByText("C", { exact: true }),
).toBeVisible();
await expect(
popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`),
).toContainText("Working");
const orderedRows = popover.locator(
'[data-testid^="channel-activity-agent-"], [data-testid^="channel-activity-item-"]',
);
await expect(orderedRows.first()).toHaveAttribute(
"data-testid",
`channel-activity-agent-${AGENT_PUBKEY}`,
);
await waitForAnimations(page);
await page.screenshot({
clip: { x: 0, y: 80, width: 720, height: 640 },
path: "test-results/channel-activity-hover/channel-activity-popover.png",
});
const targetRow = popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "direct thread link" });
await targetRow.getByRole("button", { name: /Open thread/ }).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel).toContainText("direct thread link");
});
test("unnamed working agents keep distinct key-tail initials, not AN", async ({
page,
}) => {
// npubEncode of the fixture keys: "a"×64 → npub1424…rcaj (tail RC),
// "b"×64 → npub1hwa…04hu (tail 04). Neither has a seeded profile, so
// the rows show the generated "Agent npub1…" fallback label.
const unnamedAgents = [
{ initials: "RC", label: "Agent npub1424…rcaj", pubkey: "a".repeat(64) },
{ initials: "04", label: "Agent npub1hwa…04hu", pubkey: "b".repeat(64) },
];
await page.goto("/");
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
);
for (const { pubkey } of unnamedAgents) {
await page.evaluate(
({ agentPubkey, channelId }) => {
(
window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
}
).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId,
turnId: "unnamed-agent-initials",
});
},
{ agentPubkey: pubkey, channelId: CHANNEL_GENERAL },
);
}
const popover = await openActivityPopover(page);
for (const { initials, label, pubkey } of unnamedAgents) {
const row = popover.getByTestId(`channel-activity-agent-${pubkey}`);
await expect(row).toContainText(label);
// The avatar abbreviates its key's visible tail (UserAvatar's fallback
// settles after its 200ms delay; expect retries past it), never the
// "Agent" word initials of the prefixed label.
await expect(row.getByText(initials, { exact: true })).toBeVisible();
await expect(row.getByText("AN", { exact: true })).toHaveCount(0);
}
});
test("removes the dot and preview after the final activity is read", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
const popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2);
for (let remaining = 1; remaining >= 0; remaining -= 1) {
const row = popover.getByTestId(/^channel-activity-item-/).first();
await row.hover();
await row.getByRole("button", { name: "Mark as read" }).click();
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(
remaining,
);
}
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
});
test("groups multiple unread replies against the previewed channel", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await emitMockMessage(
page,
"A second unread reply should stay grouped with this thread.",
{
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000) + 120,
mentionPubkeys: [SELF_PUBKEY],
},
);
const popover = await openActivityPopover(page);
const groupedRow = popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "direct thread link" });
await expect(groupedRow).toContainText("2 unread");
});
test("marks projected thread activity read from the active channel menu", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Mark as read" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Mark unread" }),
).toHaveCount(0);
await page.getByRole("menuitem", { name: "Mark as read" }).click();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
});
test("marks projected thread activity read in the active channel with Shift+Escape", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
const shortcutHandled = await page.evaluate(() => {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Escape",
shiftKey: true,
});
window.dispatchEvent(event);
return event.defaultPrevented;
});
expect(shortcutHandled).toBe(true);
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await page.getByTestId("channel-general").hover();
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
});
test("supports row actions and opens an agent's scoped activity", async ({
page,
}) => {
await seedChannelActivity(page);
let popover = await openActivityPopover(page);
const initialRows = popover.getByTestId(/^channel-activity-item-/);
await expect(initialRows).toHaveCount(2);
const firstRow = initialRows.first();
await firstRow.hover();
await firstRow.getByRole("button", { name: "Mark as read" }).click();
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1);
const remainingRow = popover.getByTestId(/^channel-activity-item-/).first();
await remainingRow.hover();
await remainingRow.getByRole("button", { name: "Remind me later" }).click();
await expect(
page.getByRole("dialog").getByText("Remind me later"),
).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
popover = await openActivityPopover(page);
await popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("agent-session-agent-name")).toContainText(
"Charlie",
);
await expect(page.getByTestId("agent-session-scope-label")).toContainText(
"#general",
);
});
test("keeps multiple Inbox threads visible after opening their channel", async ({
page,
}) => {
const inboxItemIds = [
"older-inbox-thread-for-hover",
"second-inbox-thread-for-hover",
];
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Inbox", exact: true }).click();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await pushMockInboxFeedItems(
page,
inboxItemIds.map((id, index) => ({
content:
index === 0
? "Older Inbox thread reopened for hover testing."
: "A second Inbox thread reopened for hover testing.",
id,
tags: [
["h", CHANNEL_GENERAL],
[
"e",
index === 0
? "older-inbox-thread-root"
: "second-inbox-thread-root",
"",
"root",
],
[
"e",
index === 0
? "older-inbox-thread-parent"
: "second-inbox-thread-parent",
"",
"reply",
],
["p", TEST_IDENTITIES.tyler.pubkey],
],
})),
);
for (const itemId of inboxItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await expect(inboxRow).toBeVisible();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
for (const [index, itemId] of inboxItemIds.entries()) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
}
}
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"700",
);
for (const itemId of inboxItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.mouse.move(900, 680);
let popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2);
await expect(popover).toContainText("Older Inbox thread reopened");
await expect(popover).toContainText("A second Inbox thread reopened");
await expect(popover.getByText("Thread", { exact: true })).toHaveCount(2);
await popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "Older Inbox thread reopened" })
.getByRole("button", { name: /Open thread/ })
.click();
await expect(popover).toBeHidden();
await page.mouse.move(900, 680);
popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1);
await expect(popover).not.toContainText("Older Inbox thread reopened");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"400",
);
await page.mouse.move(900, 680);
await expect(popover).toBeHidden();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const topLevelItemId = "top-level-inbox-item-for-channel-read";
await pushMockInboxFeedItems(page, [
{
content: "Top-level Inbox item reopened for channel read testing.",
id: topLevelItemId,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
]);
const topLevelInboxRow = page.getByTestId(
`home-inbox-item-${topLevelItemId}`,
);
await expect(topLevelInboxRow).toBeVisible();
await topLevelInboxRow.hover();
await topLevelInboxRow.getByRole("button", { name: "Mark unread" }).click();
await page.getByTestId("channel-general").click({ button: "right" });
await page.getByRole("menuitem", { name: "Mark as read" }).click();
await topLevelInboxRow.hover();
await expect(
topLevelInboxRow.getByRole("button", { name: "Mark unread" }),
).toBeVisible();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await page.getByTestId("channel-general").hover();
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"700",
);
});
test("reading a grouped Inbox thread preserves an unrelated manual unread", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const manualUnreadMessage = await emitMockMessage(
page,
"Keep this separate timeline message unread.",
{
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000),
},
);
const manualUnreadRow = page
.getByTestId("message-row")
.filter({ hasText: "Keep this separate timeline message unread." });
await manualUnreadRow.hover();
await page.getByTestId(`more-actions-${manualUnreadMessage.id}`).click();
await page
.getByTestId(`mark-read-toggle-${manualUnreadMessage.id}`)
.click();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const groupedRootId = "grouped-inbox-root-preserve-manual";
const groupedReplyId = "grouped-inbox-reply-preserve-manual";
await pushMockInboxFeedItems(page, [
{
content: "Grouped Inbox root for manual unread preservation.",
id: groupedRootId,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
{
content: "Grouped Inbox reply marked read independently.",
id: groupedReplyId,
tags: [
["h", CHANNEL_GENERAL],
["e", groupedRootId, "", "root"],
["e", groupedRootId, "", "reply"],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
]);
const groupedInboxRow = page.getByTestId(
`home-inbox-item-${groupedReplyId}`,
);
await expect(groupedInboxRow).toBeVisible();
await groupedInboxRow.hover();
await groupedInboxRow.getByRole("button", { name: "Mark as read" }).click();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
});
test("preserves Inbox ownership while another top-level row remains unread", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const topLevelItemIds = [
"first-top-level-inbox-owner",
"second-top-level-inbox-owner",
];
await pushMockInboxFeedItems(
page,
topLevelItemIds.map((id, index) => ({
content: `Top-level Inbox owner ${index + 1}.`,
id,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
})),
);
for (const itemId of topLevelItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await expect(inboxRow).toBeVisible();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect.poll(() => getForcedUnreadSources(page)).toEqual(["inbox"]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
for (const [index, itemId] of topLevelItemIds.entries()) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect
.poll(() => getForcedUnreadSources(page))
.toEqual(["inbox"]);
}
}
await expect.poll(() => getForcedUnreadSources(page)).toEqual([]);
});
test("surfaces future replies after the user reacts to a thread root", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const root = await emitMockMessage(
page,
"Reacting here means I care about future replies.",
{
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: Math.floor(Date.now() / 1_000) - 20,
},
);
const rootRow = page
.getByTestId("message-row")
.filter({ hasText: "Reacting here means I care" });
await expect(rootRow).toBeVisible();
await rootRow.hover();
const actionBar = page.getByTestId(`message-action-bar-${root.id}`);
await expect(actionBar).toBeVisible();
await actionBar.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();
await expect(
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
).toBeVisible();
await page.getByTestId("channel-random").click();
await emitMockMessage(
page,
"This follow-up appears because the root was reacted to.",
{
parentEventId: root.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000) + 60,
},
);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
const popover = await openActivityPopover(page);
await expect(popover).toContainText(
"This follow-up appears because the root was reacted to.",
);
});
});