mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
🤖 ## 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>
866 lines
29 KiB
TypeScript
866 lines
29 KiB
TypeScript
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.",
|
||
);
|
||
});
|
||
});
|