();
+ for (const marker of list.querySelectorAll(
+ "[data-channel-unread], [data-channel-activity]",
+ )) {
+ const row = marker.closest("button");
+ if (row) rows.add(row);
+ }
+ for (const row of rows) {
// A collapsed section represents its hidden rows at the summary. Clicking
// an edge cue expands that section before revealing the actual channel.
const closed = row.closest("details:not([open])");
const anchor = closed?.querySelector("summary") ?? row;
const rect = anchor.getBoundingClientRect();
if (!rect.height || !rect.width) continue;
- if (rect.bottom <= viewport.top) edges.above.push(row);
+ const attention =
+ row.getAttribute("data-channel-type") === "dm" ||
+ row.querySelector('[data-priority="true"]') !== null ||
+ row.querySelector("[data-channel-activity]") !== null;
+ const target = { row, attention };
+ if (rect.bottom <= viewport.top) edges.above.push(target);
else if (rect.top >= viewport.top + list.clientHeight)
- edges.below.push(row);
+ edges.below.push(target);
}
return edges;
}
@@ -54,7 +65,11 @@ export function SidebarUnread({
(["above", "below"] as const).every(
(edge) =>
previous[edge].length === next[edge].length &&
- previous[edge].every((row, i) => row === next[edge][i]),
+ previous[edge].every(
+ (target, i) =>
+ target.row === next[edge][i]?.row &&
+ target.attention === next[edge][i]?.attention,
+ ),
)
? previous
: next,
@@ -71,7 +86,13 @@ export function SidebarUnread({
subtree: true,
childList: true,
attributes: true,
- attributeFilter: ["open", "data-channel-unread"],
+ attributeFilter: [
+ "open",
+ "data-channel-unread",
+ "data-channel-activity",
+ "data-channel-type",
+ "data-priority",
+ ],
});
viewport.addEventListener("scroll", schedule, { passive: true });
schedule();
@@ -89,16 +110,17 @@ export function SidebarUnread({
const targets = unreadEdges(viewport)[edge];
const target = edge === "above" ? targets.at(-1) : targets[0];
if (!target) return;
- const section = target.closest("details");
+ const { row } = target;
+ const section = row.closest("details");
if (section) section.open = true;
- const rect = target.getBoundingClientRect();
+ const rect = row.getBoundingClientRect();
viewport.scrollTop +=
rect.top -
viewport.getBoundingClientRect().top -
(viewport.clientHeight - rect.height) / 2;
// Continue keyboard navigation at the revealed row, not the start of the
// roster. Its existing focus preparation still applies; focus is not selection.
- target.focus({ preventScroll: true });
+ row.focus({ preventScroll: true });
};
return (
@@ -121,10 +143,12 @@ export function SidebarUnread({
className={styles.unreadEdge}
data-edge={edge}
title={`Reveal the nearest unread channel ${edge} without opening it`}
+ aria-label={`Unread ${edge}`}
+ data-attention={edges[edge].some(({ attention }) => attention)}
onClick={() => reveal(edge)}
>
- Unread {edge}
+ Unread
);
})}
diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx
index 79c0f276..9dc0d50c 100644
--- a/src/bundled/channels/UnreadBadge.tsx
+++ b/src/bundled/channels/UnreadBadge.tsx
@@ -5,9 +5,11 @@ import styles from "./Channels.module.css";
export function UnreadBadge({
session,
channelId,
+ dm = false,
}: {
session: RelaySession;
channelId: string;
+ dm?: boolean;
}) {
const target = useMemo(
() => ({ kind: "channel" as const, channelId }),
@@ -21,24 +23,60 @@ export function UnreadBadge({
() => session.unread.snapshot(target),
[session, target],
);
+ const subscribeActivity = useCallback(
+ (listener: () => void) =>
+ session.unread.subscribeActivity(channelId, listener),
+ [session, channelId],
+ );
+ const getActivity = useCallback(
+ () => session.unread.activity(channelId),
+ [session, channelId],
+ );
const snapshot = useSyncExternalStore(subscribe, get, get);
+ const activity = useSyncExternalStore(
+ subscribeActivity,
+ getActivity,
+ getActivity,
+ );
const count = snapshot.observedCount;
const manual = snapshot.manual !== "none";
- if (!manual && !count) return null;
+ const unread = manual || (count ?? 0) > 0;
+ const threadCount = activity.items?.length ?? 0;
+ if (!unread && !threadCount) return null;
+ const priority = dm || (snapshot.attentionCount ?? 0) > 0;
+ const showUnreadDot = priority && threadCount === 0;
const label = manual
? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}`
: `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`;
+ const threadLabel = `${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${activity.freshness === "stale" ? "; may be out of date" : ""}`;
return (
-
- {manual ? "•" : (count ?? 0) > 99 ? "99+" : count}
-
+ <>
+ {unread && (
+
+ )}
+ {showUnreadDot && (
+
+ )}
+ {threadCount > 0 && (
+
+ )}
+ >
);
}
export function UnreadOptions({
diff --git a/src/features/messages/use-reading.test.ts b/src/features/messages/use-reading.test.ts
index e33a4178..91c2528b 100644
--- a/src/features/messages/use-reading.test.ts
+++ b/src/features/messages/use-reading.test.ts
@@ -73,10 +73,11 @@ function setup({ supported = true, focused = true, settled = true } = {}) {
observe: ReturnType;
dispose: ReturnType;
}[] = [];
+ let observe = async () => {};
const reading = vi.fn(() => {
const lease = {
view: vi.fn(),
- observe: vi.fn(async () => {}),
+ observe: vi.fn(() => observe()),
dispose: vi.fn(),
};
leases.push(lease);
@@ -105,6 +106,9 @@ function setup({ supported = true, focused = true, settled = true } = {}) {
position,
disconnected,
mutation: () => mutation(),
+ setObserve: (next: typeof observe) => {
+ observe = next;
+ },
setRows: (next: typeof rows) => {
rows = next;
},
@@ -169,10 +173,35 @@ it("scroll and content changes restart dwell; a row seen only at the end is not
vi.advanceTimersByTime(1);
expect(h.leases[2]?.observe).not.toHaveBeenCalled();
});
+it("active content reflow cannot revoke dwell already queued for durability", async () => {
+ const h = setup();
+ let release: (() => void) | undefined;
+ h.setObserve(
+ () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ vi.advanceTimersByTime(750);
+ expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]);
+
+ h.mutation();
+ expect(h.leases[0]?.dispose).not.toHaveBeenCalled();
+ h.doc.activeElement = new EventTarget();
+ h.element.dispatchEvent(
+ Object.assign(new Event("focusout"), { relatedTarget: null }),
+ );
+ expect(h.leases[0]?.dispose).toHaveBeenCalledTimes(1);
+ release?.();
+ await vi.runAllTimersAsync();
+ expect(h.leases[0]?.dispose).toHaveBeenCalledTimes(1);
+});
it("focus leaving the reading surface cancels pending evidence", () => {
const h = setup();
h.doc.activeElement = new EventTarget();
- h.element.dispatchEvent(new Event("focusout"));
+ h.element.dispatchEvent(
+ Object.assign(new Event("focusout"), { relatedTarget: null }),
+ );
vi.advanceTimersByTime(1000);
expect(h.leases[0]?.observe).not.toHaveBeenCalled();
});
diff --git a/src/features/messages/use-reading.ts b/src/features/messages/use-reading.ts
index 0e131634..1ba7d242 100644
--- a/src/features/messages/use-reading.ts
+++ b/src/features/messages/use-reading.ts
@@ -20,6 +20,7 @@ export function useReading({
let handle: ReadingHandle | undefined;
let timer: ReturnType | undefined;
let stopped = false;
+ const observing = new Set();
const active = () =>
!stopped &&
element.isConnected &&
@@ -34,6 +35,11 @@ export function useReading({
handle?.dispose();
handle = undefined;
}
+ function stop() {
+ cancel();
+ for (const observed of observing) observed.dispose();
+ observing.clear();
+ }
function visibleIds() {
const viewport = element.getBoundingClientRect();
return [...element.querySelectorAll("[data-message-id]")]
@@ -75,22 +81,37 @@ export function useReading({
const remained = ids.filter((id) => visible.has(id));
if (
remained.length &&
- session.unread.sync().capability === "frontier-sync"
- )
- void handle?.observe(remained).catch(() => {});
+ session.unread.sync().capability === "frontier-sync" &&
+ handle
+ ) {
+ // Dwell is already earned. Detach this lease so active-surface reflow
+ // can schedule the next interval without revoking queued durability.
+ const observed = handle;
+ handle = undefined;
+ observing.add(observed);
+ void observed
+ .observe(remained)
+ .catch(() => {})
+ .finally(() => {
+ if (observing.delete(observed)) observed.dispose();
+ });
+ }
}, 750);
}
- for (const event of [
- "scroll",
- "focusin",
- "focusout",
- "pointerdown",
- "keydown",
- ])
+ for (const event of ["scroll", "pointerdown", "keydown"])
element.addEventListener(event, schedule);
- window.addEventListener("blur", cancel);
+ const focusin = () => schedule();
+ const focusout = (event: FocusEvent) =>
+ event.relatedTarget && element.contains(event.relatedTarget as Node)
+ ? schedule()
+ : stop();
+ element.addEventListener("focusin", focusin);
+ element.addEventListener("focusout", focusout);
+ window.addEventListener("blur", stop);
window.addEventListener("focus", schedule);
- document.addEventListener("visibilitychange", schedule);
+ const visibility = () =>
+ document.visibilityState === "visible" ? schedule() : stop();
+ document.addEventListener("visibilitychange", visibility);
const mutation = new MutationObserver(schedule);
mutation.observe(element, {
childList: true,
@@ -102,20 +123,16 @@ export function useReading({
schedule();
return () => {
stopped = true;
- cancel();
+ stop();
mutation.disconnect();
resize.disconnect();
- for (const event of [
- "scroll",
- "focusin",
- "focusout",
- "pointerdown",
- "keydown",
- ])
+ for (const event of ["scroll", "pointerdown", "keydown"])
element.removeEventListener(event, schedule);
- window.removeEventListener("blur", cancel);
+ element.removeEventListener("focusin", focusin);
+ element.removeEventListener("focusout", focusout);
+ window.removeEventListener("blur", stop);
window.removeEventListener("focus", schedule);
- document.removeEventListener("visibilitychange", schedule);
+ document.removeEventListener("visibilitychange", visibility);
};
}, [session, channelId, scroller, settled]);
}
diff --git a/src/features/relay/unread-invalidation.test.ts b/src/features/relay/unread-invalidation.test.ts
index 826e7084..c8716856 100644
--- a/src/features/relay/unread-invalidation.test.ts
+++ b/src/features/relay/unread-invalidation.test.ts
@@ -169,20 +169,41 @@ it("lazily refreshes dormant selectors and preserves identity when their value i
it("keeps read-state and freshness invalidation global", async () => {
const h = await setup();
const before = h.targets.map(h.unread.snapshot);
+ const activity = h.unread.activity("c0");
+ const activityChanges = vi.fn();
+ h.unread.subscribeActivity("c0", activityChanges);
+ expect(activity).toMatchObject({
+ coverage: "observed",
+ freshness: "observed",
+ items: [],
+ });
+ h.reset();
const readChanges = vi.fn();
h.reads.subscribe(readChanges);
await h.reads.markLocalUnread("c0", () => true);
expect(readChanges).toHaveBeenCalled();
- expect(h.state).toHaveBeenCalledTimes(3 * readChanges.mock.calls.length);
expect(h.unread.snapshot(at(h.targets, 0)).manual).toBe("local-only");
expect(h.unread.snapshot(at(h.targets, 1))).toBe(before[1]);
+ expect(h.unread.activity("c0")).toBe(activity);
+ expect(activityChanges).not.toHaveBeenCalled();
h.owner.stale();
expect(
h.targets.map((target) => h.unread.snapshot(target).freshness),
).toEqual(["stale", "stale", "stale"]);
+ expect(h.unread.activity("c0")).toMatchObject({
+ freshness: "stale",
+ items: [],
+ });
+ expect(activityChanges).toHaveBeenCalledOnce();
+ const beforeFreshnessRecovery = h.listeners.map(
+ (listener) => listener.mock.calls.length,
+ );
h.reset();
h.owner.accept([message(h.peer, "c0", "fresh evidence", 12)]);
- expect(h.state).toHaveBeenCalledTimes(3);
+ expect(h.listeners.map((listener) => listener.mock.calls.length)).toEqual(
+ beforeFreshnessRecovery.map((count) => count + 1),
+ );
+ expect(activityChanges).toHaveBeenCalledTimes(2);
expect(
h.targets.map((target) => h.unread.snapshot(target).freshness),
).toEqual(["observed", "observed", "observed"]);
diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts
index 826aa5ea..be2baee4 100644
--- a/src/features/relay/unread.test.ts
+++ b/src/features/relay/unread.test.ts
@@ -7,6 +7,7 @@ import {
type ReadStateStorage,
} from "./read-state-storage";
import type { RelayEvent } from "./events";
+import type { ThreadActivitySnapshot } from "./unread";
import type { ChannelStoreOptions } from "./store";
import type { SavedHead } from "./persistence";
import type { ReadStateSigning } from "./read-state-host";
@@ -245,6 +246,42 @@ it("a deletion cannot use revoked unread evidence to delete an accessible target
expect(h.snapshot().observedCount).toBe(1);
});
+it("promotes mentions, broadcasts and participating-thread replies without promoting ordinary unread", () => {
+ const h = setup();
+ h.grant("room");
+ const root = message(h.viewer, "room", "root", 10);
+ const ordinary = message(h.alice, "room", "ordinary", 11);
+ h.emit([root, ordinary]);
+ expect(h.snapshot()).toMatchObject({ observedCount: 1, attentionCount: 0 });
+
+ const mentioned = message(h.alice, "room", "mentioned", 12, [
+ ["p", h.viewer.pubkey],
+ ]);
+ const broadcast = message(h.alice, "room", "broadcast", 13, [
+ ["e", root.id, "", "reply"],
+ ["broadcast", "1"],
+ ]);
+ const participatingReply = message(h.alice, "room", "reply", 14, [
+ ["e", root.id, "", "reply"],
+ ]);
+ const missingRootBroadcast = message(
+ h.alice,
+ "room",
+ "broadcast without retained root",
+ 15,
+ [
+ ["e", "f".repeat(64), "", "reply"],
+ ["broadcast", "1"],
+ ],
+ );
+ h.emit([mentioned, broadcast, participatingReply, missingRootBroadcast]);
+ expect(h.snapshot()).toMatchObject({ observedCount: 5, attentionCount: 4 });
+ expect(h.session.unread.activity("room").items).toHaveLength(1);
+ expect(
+ h.session.unread.attention("room", missingRootBroadcast.id),
+ ).toMatchObject({ status: "unknown", unread: true });
+});
+
it("late DM metadata updates an existing attention selector without expiring reading intent", async () => {
const h = setup();
h.grant("room");
@@ -337,6 +374,268 @@ it.each(["lowercase", "uppercase reply", "uppercase root", "last valid"])(
},
);
+it("groups unread thread activity by same-channel root and clears one item without clearing unrelated or manual unread", async () => {
+ const h = setup();
+ h.grant("room");
+ const firstRoot = message(h.viewer, "room", "first root", 10);
+ const secondRoot = message(h.viewer, "room", "second root", 11);
+ const firstReply = message(h.alice, "room", "first reply", 12, [
+ ["e", firstRoot.id, "", "reply"],
+ ]);
+ const latestFirstReply = message(h.alice, "room", "latest first reply", 14, [
+ ["e", firstReply.id, "", "reply"],
+ ]);
+ const secondReply = message(h.alice, "room", "second reply", 13, [
+ ["e", secondRoot.id, "", "reply"],
+ ]);
+ h.emit([
+ firstRoot,
+ secondRoot,
+ firstReply,
+ latestFirstReply,
+ secondReply,
+ message(h.alice, "room", "ordinary top level", 15),
+ ]);
+
+ expect(h.session.unread.activity("room")).toMatchObject({
+ channelId: "room",
+ coverage: "observed",
+ freshness: "observed",
+ items: [
+ {
+ channelId: "room",
+ rootId: firstRoot.id,
+ latestMessageId: latestFirstReply.id,
+ authorId: h.alice.pubkey,
+ createdAt: 14,
+ preview: "latest first reply",
+ unreadCount: 2,
+ },
+ {
+ channelId: "room",
+ rootId: secondRoot.id,
+ latestMessageId: secondReply.id,
+ authorId: h.alice.pubkey,
+ createdAt: 13,
+ preview: "second reply",
+ unreadCount: 1,
+ },
+ ],
+ });
+
+ await h.session.unread.markUnreadLocal(h.target);
+ await h.session.unread.markThrough(
+ { kind: "thread", channelId: "room", rootId: firstRoot.id },
+ latestFirstReply.id,
+ );
+
+ expect(h.session.unread.activity("room").items).toEqual([
+ expect.objectContaining({
+ rootId: secondRoot.id,
+ latestMessageId: secondReply.id,
+ }),
+ ]);
+ expect(h.snapshot()).toMatchObject({
+ observedCount: 2,
+ manual: "local-only",
+ });
+});
+
+it("activity previews use edited and unwrapped current message content and notify subscribers", () => {
+ const h = setup();
+ h.grant("room");
+ const root = message(h.viewer, "room", "root", 10);
+ const reply = message(h.alice, "room", "ORIGINAL", 11, [
+ ["e", root.id, "", "reply"],
+ ]);
+ h.emit([root, reply]);
+ const before = h.session.unread.activity("room");
+ const changes: ThreadActivitySnapshot[] = [];
+ h.session.unread.subscribeActivity("room", () =>
+ changes.push(h.session.unread.activity("room")),
+ );
+ h.emit([
+ signed(h.alice, {
+ kind: 40003,
+ content: "EDITED",
+ tags: [["e", reply.id]],
+ }),
+ ]);
+ expect(h.session.unread.activity("room")).not.toBe(before);
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe("EDITED");
+ expect(changes.at(-1)?.items?.[0]?.preview).toBe("EDITED");
+
+ const agentReply = signed(h.alice, {
+ kind: 40002,
+ content: JSON.stringify({ content: "unwrapped hello" }),
+ tags: [
+ ["h", "room"],
+ ["e", root.id, "", "reply"],
+ ],
+ created_at: 12,
+ });
+ h.emit([agentReply]);
+ expect(h.session.unread.activity("room").items?.[0]).toMatchObject({
+ latestMessageId: agentReply.id,
+ preview: "unwrapped hello",
+ });
+});
+
+it("repair-retained reference-only edits admit a later deletion without seeding a window", async () => {
+ const h = setup();
+ h.grant("room");
+ const root = message(h.viewer, "room", "root", 10);
+ const reply = message(h.alice, "room", "ORIGINAL", 11, [
+ ["e", root.id, "", "reply"],
+ ]);
+ const edit = signed(h.alice, {
+ kind: 40003,
+ content: "EDITED",
+ tags: [["e", reply.id]],
+ });
+ h.query.mockImplementation(async (filters) =>
+ filters[0]?.kinds?.includes(9) ? [root, reply, edit] : [],
+ );
+
+ await h.session.unread.ensure();
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe("EDITED");
+ const window = h.session.channels.window("room");
+ expect(window.rows).toHaveLength(0);
+ const changes: ThreadActivitySnapshot[] = [];
+ h.session.unread.subscribeActivity("room", () =>
+ changes.push(h.session.unread.activity("room")),
+ );
+
+ const deletion = (ids: readonly string[]) =>
+ signed(h.alice, {
+ kind: 5,
+ content: "",
+ tags: ids.map((id) => ["e", id]),
+ });
+ h.emit([deletion([edit.id, "f".repeat(64)])]);
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe("EDITED");
+ expect(changes).toEqual([]);
+
+ h.emit([deletion([edit.id])]);
+
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe(
+ "ORIGINAL",
+ );
+ expect(changes.map((snapshot) => snapshot.items?.[0]?.preview)).toEqual([
+ "ORIGINAL",
+ ]);
+ expect(h.session.channels.window("room")).toBe(window);
+});
+
+it("activity subscribers restore original content when a reference-only edit is deleted", () => {
+ const h = setup();
+ h.grant("room");
+ const root = message(h.viewer, "room", "root", 10);
+ const reply = message(h.alice, "room", "ORIGINAL", 11, [
+ ["e", root.id, "", "reply"],
+ ]);
+ const edit = signed(h.alice, {
+ kind: 40003,
+ content: "EDITED",
+ tags: [["e", reply.id]],
+ });
+ const deletion = signed(h.alice, {
+ kind: 5,
+ content: "",
+ tags: [["e", edit.id]],
+ });
+ h.emit([root, reply]);
+ const changes: ThreadActivitySnapshot[] = [];
+ h.session.unread.subscribeActivity("room", () =>
+ changes.push(h.session.unread.activity("room")),
+ );
+
+ h.emit([edit]);
+ expect(changes.at(-1)?.items?.[0]?.preview).toBe("EDITED");
+ h.emit([deletion]);
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe(
+ "ORIGINAL",
+ );
+ expect(changes.map((snapshot) => snapshot.items?.[0]?.preview)).toEqual([
+ "EDITED",
+ "ORIGINAL",
+ ]);
+});
+
+it("deletion-before-edit batches retain authorized ancestry without a transient activity change", () => {
+ const h = setup();
+ h.grant("room");
+ const root = message(h.viewer, "room", "root", 10);
+ const reply = message(h.alice, "room", "ORIGINAL", 11, [
+ ["e", root.id, "", "reply"],
+ ]);
+ const edit = signed(h.alice, {
+ kind: 40003,
+ content: "EDITED",
+ tags: [["e", reply.id]],
+ });
+ const deletion = signed(h.alice, {
+ kind: 5,
+ content: "",
+ tags: [["e", edit.id]],
+ });
+ h.emit([root, reply]);
+ const before = h.session.unread.activity("room");
+ const changed = vi.fn();
+ h.session.unread.subscribeActivity("room", changed);
+
+ h.emit([deletion, edit]);
+ expect(h.session.unread.activity("room")).toBe(before);
+ expect(h.session.unread.activity("room").items?.[0]?.preview).toBe(
+ "ORIGINAL",
+ );
+ expect(changed).not.toHaveBeenCalled();
+});
+
+it("resolves every activity item through its own channel hierarchy", () => {
+ const h = setup();
+ h.grant("room");
+ h.grant("other");
+ const roomRoot = message(h.viewer, "room", "room root", 10);
+ const otherRoot = message(h.viewer, "other", "other root", 10);
+ const valid = message(h.alice, "room", "room reply", 12, [
+ ["e", roomRoot.id, "", "reply"],
+ ]);
+ const foreign = message(h.alice, "room", "foreign ancestry", 13, [
+ ["e", otherRoot.id, "", "reply"],
+ ]);
+ h.emit([roomRoot, otherRoot, valid, foreign]);
+
+ expect(h.session.unread.activity("room").items).toEqual([
+ expect.objectContaining({
+ rootId: roomRoot.id,
+ latestMessageId: valid.id,
+ }),
+ ]);
+ expect(h.session.unread.activity("other").items).toEqual([]);
+});
+
+it("distinguishes unknown thread-activity evidence from observed evidence", () => {
+ const h = setup();
+ h.grant("room");
+ expect(h.session.unread.activity("room")).toMatchObject({
+ channelId: "room",
+ items: null,
+ coverage: "unknown",
+ freshness: "unknown",
+ });
+ const root = message(h.viewer, "room", "root", 10);
+ const reply = message(h.alice, "room", "retained reply", 11, [
+ ["e", root.id, "", "reply"],
+ ]);
+ h.emit([root, reply]);
+ expect(h.session.unread.activity("room")).toMatchObject({
+ items: [expect.objectContaining({ latestMessageId: reply.id })],
+ coverage: "observed",
+ freshness: "observed",
+ });
+});
+
it("canonical unread ancestry still requires retained same-channel content", async () => {
const h = setup();
h.grant("room");
diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts
index 9378a47d..9de0d352 100644
--- a/src/features/relay/unread.ts
+++ b/src/features/relay/unread.ts
@@ -13,6 +13,7 @@ import type {
ReadSyncSnapshot,
} from "./read-state";
import type { Priority, RelayReader } from "./reader";
+import { foldMessages } from "./fold";
import { threadReference } from "./thread-reference";
export type UnreadSnapshot = Readonly<{
@@ -32,6 +33,23 @@ export type MessageAttention = Readonly<{
unread: boolean;
viewing: boolean;
}>;
+export type ThreadActivityItem = Readonly<{
+ channelId: string;
+ rootId: string;
+ latestMessageId: string;
+ authorId: string;
+ createdAt: number;
+ preview: string;
+ unreadCount: number;
+}>;
+export type ThreadActivitySnapshot = Readonly<{
+ channelId: string;
+ /** null means activity evidence is unknown or access is denied. */
+ items: readonly ThreadActivityItem[] | null;
+ coverage: "unknown" | "observed";
+ freshness: "unknown" | "observed" | "stale";
+ error?: string | undefined;
+}>;
export type ReadingHandle = Readonly<{
/** Qualified visible rows only. This publishes no read intent and ends with the lease. */
view(messageIds: readonly string[], visible: () => boolean): void;
@@ -44,6 +62,8 @@ export interface UnreadCapability {
/** Same verified attention/frontier policy as badges, not a notification event source. */
attention(channelId: string, messageId: string): MessageAttention;
subscribe(target: ReadTarget, listener: () => void): () => void;
+ activity(channelId: string): ThreadActivitySnapshot;
+ subscribeActivity(channelId: string, listener: () => void): () => void;
sync(): ReadSyncSnapshot;
subscribeSync(listener: () => void): () => void;
ensure(): Promise;
@@ -64,6 +84,44 @@ const channelOf = (event: RelayEvent) => {
const tags = event.tags.filter(([name]) => name === "h");
return tags.length === 1 ? tags[0]?.[1] : undefined;
};
+const auxiliaryKind = (event: RelayEvent) =>
+ event.kind === 40003 || event.kind === 5 || event.kind === 9005;
+/** Resolve every owning channel through bounded reference-only auxiliary ancestry.
+ * A missing target, cycle, or unsupported intermediary fails closed. */
+function channelOwnership(find: (id: string) => RelayEvent | undefined) {
+ const memo = new Map | undefined>();
+ const visiting = new Set();
+ function owners(event: RelayEvent): ReadonlySet | undefined {
+ if (memo.has(event.id)) return memo.get(event.id);
+ if (visiting.has(event.id) || visiting.size >= 32) return;
+ visiting.add(event.id);
+ const direct = channelOf(event);
+ let resolved: Set | undefined;
+ if (contentKind(event)) {
+ if (direct) resolved = new Set([direct]);
+ } else if (auxiliaryKind(event)) {
+ resolved = direct ? new Set([direct]) : new Set();
+ const targets = event.tags.flatMap(([name, id]) =>
+ name === "e" && id ? [id] : [],
+ );
+ if (!direct && !targets.length) resolved = undefined;
+ for (const id of targets) {
+ const target = find(id);
+ const inherited = target && owners(target);
+ if (!inherited) {
+ resolved = undefined;
+ break;
+ }
+ for (const channel of inherited) resolved?.add(channel);
+ }
+ if (!resolved?.size) resolved = undefined;
+ }
+ visiting.delete(event.id);
+ memo.set(event.id, resolved);
+ return resolved;
+ }
+ return owners;
+}
/** Bounded verified evidence and one projection; no sidebar counters, sockets or implicit reads. */
export function createUnread({
reads,
@@ -90,6 +148,9 @@ export function createUnread({
const listeners = new Map void>>();
const snapshots = new Map();
const dirty = new Set();
+ const activityListeners = new Map void>>();
+ const activitySnapshots = new Map();
+ const activityDirty = new Set();
const handles = new Set<() => void>();
const views = new Map<
() => void,
@@ -204,6 +265,14 @@ export function createUnread({
? "thread"
: undefined;
}
+ function priority(entry: Evidence, dm: boolean) {
+ return (
+ !!category(entry, dm) ||
+ entry.event.tags.some(
+ ([name, value]) => name === "broadcast" && value === "1",
+ )
+ );
+ }
function attention(channelId: string, messageId: string): MessageAttention {
const unknown = Object.freeze({
status: "unknown",
@@ -280,7 +349,7 @@ export function createUnread({
for (const entry of byChannel.get(target.channelId) ?? []) {
if (!inTarget(entry.event, target) || !isUnread(entry, state)) continue;
count++;
- if (category(entry, dm)) attention++;
+ if (priority(entry, dm)) attention++;
}
const manual = reads.localUnread(key)
? "local-only"
@@ -307,6 +376,111 @@ export function createUnread({
a.freshness === b.freshness &&
a.manual === b.manual &&
a.error === b.error;
+ function computeActivity(channelId: string): ThreadActivitySnapshot {
+ if (!allowed(channelId) || !known.has(channelId))
+ return Object.freeze({
+ channelId,
+ items: null,
+ coverage: "unknown",
+ freshness: "unknown",
+ });
+ indexEvidence();
+ const state = reads.state();
+ const grouped = new Map();
+ const presented = new Map(
+ foldMessages(channelId, "", [...events.values()], {
+ includeReplies: true,
+ }).map((message) => [message.id, message.content]),
+ );
+ for (const evidence of byChannel.get(channelId) ?? []) {
+ const { event, rootId, mentioned } = evidence;
+ const broadcast = event.tags.some(
+ ([name, value]) => name === "broadcast" && value === "1",
+ );
+ if (
+ !rootId ||
+ (!mentioned && !broadcast && !participants.has(rootId)) ||
+ !isUnread(evidence, state)
+ )
+ continue;
+ const current = grouped.get(rootId);
+ const preview = presented.get(event.id) ?? event.content;
+ if (!current) {
+ grouped.set(
+ rootId,
+ Object.freeze({
+ channelId,
+ rootId,
+ latestMessageId: event.id,
+ authorId: event.pubkey,
+ createdAt: event.created_at,
+ preview,
+ unreadCount: 1,
+ }),
+ );
+ continue;
+ }
+ const latest =
+ event.created_at > current.createdAt ||
+ (event.created_at === current.createdAt &&
+ event.id < current.latestMessageId);
+ grouped.set(
+ rootId,
+ Object.freeze({
+ channelId,
+ rootId,
+ latestMessageId: latest ? event.id : current.latestMessageId,
+ authorId: latest ? event.pubkey : current.authorId,
+ createdAt: latest ? event.created_at : current.createdAt,
+ preview: latest ? preview : current.preview,
+ unreadCount: current.unreadCount + 1,
+ }),
+ );
+ }
+ return Object.freeze({
+ channelId,
+ items: Object.freeze(
+ [...grouped.values()].sort(
+ (a, b) =>
+ b.createdAt - a.createdAt ||
+ a.latestMessageId.localeCompare(b.latestMessageId),
+ ),
+ ),
+ coverage: "observed",
+ freshness,
+ ...(error ? { error } : {}),
+ });
+ }
+ const equalActivity = (
+ a: ThreadActivitySnapshot,
+ b: ThreadActivitySnapshot,
+ ) =>
+ a.coverage === b.coverage &&
+ a.freshness === b.freshness &&
+ a.error === b.error &&
+ ((a.items === null && b.items === null) ||
+ (a.items !== null &&
+ b.items !== null &&
+ a.items.length === b.items.length &&
+ a.items.every((item, index) => {
+ const other = b.items?.[index];
+ return (
+ item.rootId === other?.rootId &&
+ item.latestMessageId === other.latestMessageId &&
+ item.authorId === other.authorId &&
+ item.createdAt === other.createdAt &&
+ item.preview === other.preview &&
+ item.unreadCount === other.unreadCount
+ );
+ })));
+ function activity(channelId: string) {
+ const previous = activitySnapshots.get(channelId);
+ if (previous && !activityDirty.delete(channelId)) return previous;
+ const value = computeActivity(channelId);
+ if (previous && equalActivity(previous, value)) return previous;
+ activitySnapshots.set(channelId, value);
+ return value;
+ }
function snapshot(target: ReadTarget) {
const key = keyFor(target),
previous = snapshots.get(key);
@@ -325,9 +499,20 @@ export function createUnread({
snapshots.set(key, value);
return value;
}
+ function addActivityListener(channelId: string, listener: () => void) {
+ activity(channelId);
+ const set = activityListeners.get(channelId) ?? new Set();
+ set.add(listener);
+ activityListeners.set(channelId, set);
+ return () => {
+ set.delete(listener);
+ if (!set.size) activityListeners.delete(channelId);
+ };
+ }
function publish(channelIds?: ReadonlySet) {
if (closed) return;
const changed: string[] = [];
+ const changedActivity: string[] = [];
for (const [key, old] of snapshots) {
if (channelIds && !channelIds.has(old.target.channelId)) continue;
// Revisit dormant selectors lazily, retaining identity if unchanged.
@@ -341,9 +526,24 @@ export function createUnread({
changed.push(key);
}
}
+ for (const [channelId, old] of activitySnapshots) {
+ if (channelIds && !channelIds.has(channelId)) continue;
+ if (!activityListeners.has(channelId)) {
+ activityDirty.add(channelId);
+ continue;
+ }
+ const next = computeActivity(channelId);
+ if (!equalActivity(old, next)) {
+ activitySnapshots.set(channelId, next);
+ changedActivity.push(channelId);
+ }
+ }
// Replace/invalidate ALL affected projections before any reentrant callback.
for (const key of changed)
for (const listener of listeners.get(key) ?? []) notify(listener);
+ for (const channelId of changedActivity)
+ for (const listener of activityListeners.get(channelId) ?? [])
+ notify(listener);
}
const stopRead = reads.subscribe(publish);
function purge() {
@@ -351,22 +551,11 @@ export function createUnread({
epoch++;
const denied = new Set([...known].filter((channel) => !allowed(channel)));
for (const channel of denied) known.delete(channel);
- for (const [id, event] of events) {
- const channel = channelOf(event);
- if (
- channel
- ? !allowed(channel)
- : !event.tags.some(
- ([name, value]) =>
- name === "e" &&
- value &&
- (() => {
- const target = events.get(value);
- const owner = target && channelOf(target);
- return owner && allowed(owner);
- })(),
- )
- )
+ const retained = new Map(events);
+ const owners = channelOwnership((targetId) => retained.get(targetId));
+ for (const [id, event] of retained) {
+ const channels = owners(event);
+ if (!channels || [...channels].some((channel) => !allowed(channel)))
events.delete(id);
}
indexed = false;
@@ -462,19 +651,16 @@ export function createUnread({
const changed = new Set();
indexed = false;
const incoming = new Map(batch.map((event) => [event.id, event]));
+ const owners = channelOwnership((id) => incoming.get(id) ?? events.get(id));
for (const event of batch) {
- if (![9, 40002, 5, 9005].includes(event.kind) || events.has(event.id))
+ if (
+ ![9, 40002, 40003, 5, 9005].includes(event.kind) ||
+ events.has(event.id)
+ )
+ continue;
+ const channels = owners(event);
+ if (!channels || [...channels].some((channel) => !allowed(channel)))
continue;
- const channel =
- channelOf(event) ??
- event.tags
- .flatMap(([name, value]) =>
- name === "e" && value
- ? [channelOf(incoming.get(value) ?? events.get(value) ?? event)]
- : [],
- )
- .find(Boolean);
- if (!channel || !allowed(channel)) continue;
const size = new TextEncoder().encode(JSON.stringify(event)).byteLength;
if (events.size >= 4096 || bytes + size > 8 * 1024 * 1024) {
events.clear();
@@ -487,17 +673,12 @@ export function createUnread({
}
events.set(event.id, event);
bytes += size;
- known.add(channel);
- changed.add(channel);
- // A signed deletion may target readable messages in several channels.
- // Invalidate every affected projection, not only its explicit/first owner.
- if (event.kind === 5 || event.kind === 9005)
- for (const [name, id] of event.tags) {
- const target =
- name === "e" && id && (incoming.get(id) ?? events.get(id));
- const affected = target && channelOf(target);
- if (affected) changed.add(affected);
- }
+ for (const channel of channels) {
+ known.add(channel);
+ changed.add(channel);
+ }
+ // The recursively resolved owner set already includes every activity
+ // projection affected by a deletion, including delete-of-edit chains.
}
if (changed.size) {
const global = freshness !== "observed";
@@ -545,6 +726,8 @@ export function createUnread({
if (!set.size) listeners.delete(key);
};
},
+ activity,
+ subscribeActivity: addActivityListener,
sync: reads.snapshot,
subscribeSync: reads.subscribe,
ensure: () => refresh ?? (requested ? Promise.resolve() : repair()),
@@ -657,12 +840,16 @@ export function createUnread({
return {
capability,
// Private session evidence lookup; never seeds timeline windows or grants access.
+ // Reference-only auxiliaries inherit every owning channel through the same
+ // bounded, fail-closed ancestry used for retention.
event(id: string) {
+ if (closed) return;
const event = events.get(id);
- const channel = event && channelOf(event);
- return !closed && event && channel && allowed(channel)
- ? event
- : undefined;
+ if (!event) return;
+ const owners = channelOwnership((targetId) => events.get(targetId))(
+ event,
+ );
+ return owners && [...owners].every(allowed) ? event : undefined;
},
accept,
purge,
@@ -695,6 +882,9 @@ export function createUnread({
listeners.clear();
snapshots.clear();
dirty.clear();
+ activityListeners.clear();
+ activitySnapshots.clear();
+ activityDirty.clear();
events.clear();
reads.dispose();
},
diff --git a/src/plugins/author.ts b/src/plugins/author.ts
index d00925d4..983ccb5c 100644
--- a/src/plugins/author.ts
+++ b/src/plugins/author.ts
@@ -37,6 +37,8 @@ export type {
export type {
UnreadCapability,
UnreadSnapshot,
+ ThreadActivityItem,
+ ThreadActivitySnapshot,
ReadingHandle,
} from "../features/relay/unread";
export type { ReadTarget } from "../features/relay/read-state-model";
diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs
index 3a265bf2..c1e92d20 100644
--- a/tests/browser/fixture.mjs
+++ b/tests/browser/fixture.mjs
@@ -27,6 +27,7 @@ export const test = base.extend({
productionBroker: [false, { option: true }],
readState: [false, { option: true }],
threadUnread: [false, { option: true }],
+ threadUnreadMentions: [false, { option: true }],
exactMessages: [false, { option: true }],
sidebarUnread: [false, { option: true }],
savedSidebar: [false, { option: true }],
@@ -48,6 +49,7 @@ export const test = base.extend({
productionBroker,
readState,
threadUnread,
+ threadUnreadMentions,
exactMessages,
sidebarUnread,
savedSidebar,
@@ -257,6 +259,7 @@ export const test = base.extend({
[
["h", "alpha"],
["e", root.id.toUpperCase(), "", "reply"],
+ ...(threadUnreadMentions && index === 1 ? [["p", viewer]] : []),
],
`Unread reply ${index}`,
peerKey,
diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs
index 7417b265..72e82664 100644
--- a/tests/browser/navigation-groups.spec.mjs
+++ b/tests/browser/navigation-groups.spec.mjs
@@ -7,7 +7,7 @@ test.use({
largeSidebar: true,
developmentReact: true,
});
-test("Home → Messages keeps saved groups and scroll on every visible frame without re-decoding", async ({
+test("Home → Messages keeps saved groups, selected channel, and scroll on every visible frame without re-decoding", async ({
page,
app,
}) => {
@@ -19,6 +19,10 @@ test("Home → Messages keeps saved groups and scroll on every visible frame wit
await expect(
sidebar.locator("summary", { hasText: /Starred$/ }),
).toBeVisible();
+ await sidebar.locator('button[data-channel-id="beta"]').click();
+ await expect(
+ page.getByRole("textbox", { name: "Message #Beta", exact: true }),
+ ).toBeVisible();
const scroll = await sidebar.evaluate((element) => {
element.scrollTop = 1000;
return element.scrollTop;
@@ -60,6 +64,9 @@ test("Home → Messages keeps saved groups and scroll on every visible frame wit
.first()
.click();
await expect(sidebar).toBeVisible();
+ await expect(
+ page.getByRole("textbox", { name: "Message #Beta", exact: true }),
+ ).toBeVisible();
await page.waitForTimeout(300); // Keep the decode path held for the full interval.
// Wall time does not guarantee RAF callbacks on a busy runner. Wait for
// samples, not correct samples: every earlier frame stays in the assertion.
diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs
index e53cc60f..5fa7b4e3 100644
--- a/tests/browser/sidebar-unread.spec.mjs
+++ b/tests/browser/sidebar-unread.spec.mjs
@@ -12,13 +12,21 @@ const sidebar = (page) =>
const list = (page) =>
page.getByRole("navigation", { name: "Subscribed channels" });
const cue = (page, edge) =>
- sidebar(page).getByRole("button", { name: `Unread ${edge}`, exact: true });
+ sidebar(page).locator(`button[data-edge="${edge}"]`);
const row = (page, id) =>
list(page).locator(`button[data-channel-id="${id.toLowerCase()}"]`);
const scroll = (page, top) =>
list(page).evaluate((el, top) => {
el.scrollTop = top;
}, top);
+const scrollRowAbove = (page, id) =>
+ row(page, id).evaluate((el) => {
+ const viewport = el.closest("nav");
+ if (!viewport) throw new Error("Channel row is outside the channel list");
+ const rowRect = el.getBoundingClientRect();
+ const viewportRect = viewport.getBoundingClientRect();
+ viewport.scrollTop += rowRect.bottom - viewportRect.top + 1;
+ });
const inView = (page, id) =>
row(page, id).evaluate((el) => {
const rect = el.getBoundingClientRect();
@@ -111,9 +119,58 @@ test("edge pills follow scroll and reveal the nearest unread without selection o
} finally {
app.relay.releaseEose("alpha");
}
+ const ordinary = row(page, "alpha");
+ const ordinaryState = ordinary.locator("[data-channel-unread]");
+ const directed = row(page, "dm-090").locator("[data-channel-priority]");
+ await expect(ordinaryState).toHaveAttribute("data-priority", "false");
+ await expect(ordinaryState).toHaveAttribute(
+ "aria-label",
+ /observed unread messages/,
+ );
+ await expect(ordinary.locator("[data-channel-priority]")).toHaveCount(0);
+ await expect(ordinary.locator("span").first()).toHaveCSS(
+ "font-weight",
+ "500",
+ );
+ await expect(directed).toBeVisible();
+ await expect(directed).toHaveCSS("width", "6px");
+ await expect(
+ row(page, "dm-090").locator("[data-channel-unread]"),
+ ).toHaveAttribute("data-priority", "true");
+ await expect(
+ row(page, "dm-090").locator("[data-channel-dm-avatar]"),
+ ).toHaveCount(0);
await expect(row(page, "dm-090").getByRole("img")).toHaveCount(1);
+ await page.evaluate(() => {
+ document.documentElement.dataset.colorMode = "dark";
+ });
+ await sidebar(page).screenshot({
+ path: info.outputPath("sidebar-unread-hierarchy.png"),
+ });
await expect(cue(page, "below")).toBeVisible();
+ await expect(cue(page, "below")).toHaveText("Unread");
+ await expect(cue(page, "below")).toHaveAccessibleName("Unread below");
+ await expect(cue(page, "below")).toHaveAttribute("data-attention", "true");
+ const transitionProperties = await cue(page, "below").evaluate((el) =>
+ getComputedStyle(el).transitionProperty.split(", "),
+ );
+ expect(transitionProperties).toEqual([
+ "background-color",
+ "color",
+ "border-color",
+ ]);
await expect(cue(page, "above")).toHaveCount(0);
+ // Keep actionable DMs below while moving only ordinary unread above: priority
+ // is derived from the destinations on each edge, not from the whole roster.
+ await scrollRowAbove(page, "alpha");
+ await expect.poll(() => inView(page, "alpha")).toBe(false);
+ await expect(cue(page, "above")).toBeVisible();
+ await expect(cue(page, "above")).toHaveText("Unread");
+ await expect(cue(page, "above")).toHaveAccessibleName("Unread above");
+ await expect(cue(page, "above")).toHaveAttribute("data-attention", "false");
+ await expect(cue(page, "below")).toHaveAttribute("data-attention", "true");
+ await scroll(page, 0);
+ await expect(cue(page, "below")).toHaveAttribute("data-attention", "true");
await scroll(page, 1800);
await expect(cue(page, "above")).toBeVisible();
await expect(cue(page, "below")).toBeVisible();
@@ -300,38 +357,36 @@ test("session changes discard the previous sidebar targets and manual unread sti
await cue(page, "above").click();
await expect.poll(() => inView(page, "alpha")).toBe(true);
await expect(
- row(page, "alpha").getByRole("img", {
- name: "Marked unread on this device only",
- exact: true,
- }),
- ).toBeVisible();
+ row(page, "alpha").locator(
+ '[data-channel-unread][data-priority="false"]',
+ ),
+ ).toBeAttached();
} finally {
release();
await page.unroute("**/api/relay/secondary/sidebar-preferences");
}
});
-test("attention badge retains its channel row color in both modes", async ({
+test("priority dots use semantic primary color in both modes", async ({
page,
app,
}) => {
await open(page, app);
- const channel = row(page, "dm-030");
- const badge = channel.locator("[data-channel-unread]");
- await expect(badge).toBeAttached();
- // Use the actual rendered badge and its production CSS; only select the
- // attention presentation state, independently of mention admission behavior.
- await badge.evaluate((element) => {
- element.dataset.attention = "true";
- });
+ const dot = row(page, "dm-030").locator("[data-channel-priority]");
+ await expect(dot).toBeAttached();
for (const mode of ["light", "dark"]) {
await page.evaluate((mode) => {
document.documentElement.dataset.colorMode = mode;
}, mode);
- const color = await channel.evaluate(
- (element) => getComputedStyle(element).color,
- );
- await expect(badge).toHaveCSS("color", color);
- await expect(badge).toHaveCSS("outline-color", color);
+ const primary = await page.evaluate(() => {
+ const root = getComputedStyle(document.documentElement);
+ const probe = document.createElement("span");
+ probe.style.backgroundColor = root.getPropertyValue("--primary");
+ document.body.append(probe);
+ const color = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return color;
+ });
+ await expect(dot).toHaveCSS("background-color", primary);
}
});
diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs
index d38e5e1f..e5d8ea21 100644
--- a/tests/browser/thread-unread.spec.mjs
+++ b/tests/browser/thread-unread.spec.mjs
@@ -1,7 +1,44 @@
import { test, expect } from "./fixture.mjs";
import { open } from "./timeline.mjs";
-test.use({ productionBroker: true, readState: true, threadUnread: true });
+test.use({
+ productionBroker: true,
+ readState: true,
+ threadUnread: true,
+ largeSidebar: true,
+});
+test.describe("mentioned reply priority", () => {
+ test.use({ threadUnreadMentions: true });
+
+ test("a mention and broadcast remain distinguishable in Activity", async ({
+ page,
+ app,
+ }) => {
+ await open(page, app);
+ const alpha = page.locator('button[data-channel-id="alpha"]');
+ await expect(
+ alpha.getByRole("img", { name: /unread threads?/ }),
+ ).toBeVisible();
+ await alpha.hover();
+ const popover = page.getByRole("dialog", { name: "Activity in Alpha" });
+ await expect(popover).toBeVisible();
+ const items = popover.getByRole("button", {
+ name: /Open unread thread from/,
+ });
+ await expect(items).toHaveCount(2);
+ const names = await items.evaluateAll((rows) =>
+ rows.map((row) => row.getAttribute("aria-label")),
+ );
+
+ expect(
+ names.every((name) => name?.startsWith("Open unread thread from ")),
+ ).toBe(true);
+ expect(new Set(names.map((name) => name?.split(": ").at(-1)))).toEqual(
+ new Set(["Broadcast reply", "Unread reply 1"]),
+ );
+ });
+});
+
test("thread buttons show observed unread independently, clear only after reading, and expose hover/focus affordance", async ({
page,
app,
@@ -32,12 +69,61 @@ test("thread buttons show observed unread independently, clear only after readin
await expect(dot(first)).toBeVisible();
await expect(dot(other)).toBeVisible();
await expect(broadcast).toHaveAccessibleName(/Observed unread replies/);
+ const alpha = page.locator('button[data-channel-id="alpha"]');
+ const activity = alpha.getByRole("img", { name: /unread threads?/ });
+ await expect(activity).toBeVisible();
+ await expect(alpha.locator("span").first()).toHaveCSS("font-weight", "500");
+ await page.getByLabel("Conversation options", { exact: true }).click();
+ await page
+ .getByRole("button", { name: "Mark unread on this device", exact: true })
+ .click();
+ await page.getByLabel("Conversation options", { exact: true }).click();
+ await expect(
+ alpha.getByRole("img", { name: /Marked unread on this device only/ }),
+ ).toBeAttached();
+ await expect(activity).toHaveAccessibleName(/unread threads?/);
+ await page.evaluate(() => {
+ document.documentElement.dataset.colorMode = "dark";
+ });
+ await alpha.hover();
+ const popover = page.getByRole("dialog", { name: "Activity in Alpha" });
+ await expect(popover).toBeVisible();
+ await expect(
+ popover.getByText("Activity in Alpha", { exact: true }),
+ ).toHaveCount(0);
+ await popover.screenshot({
+ path: testInfo.outputPath("activity-popover.png"),
+ });
+ await expect(
+ popover.getByRole("button", { name: /Open unread thread from/ }),
+ ).toHaveCount(1);
const queries = () =>
app.report.queries.filter(({ filter }) => filter.depth_limit);
expect(queries()).toHaveLength(0); // Merely displaying buttons never fetches threads.
- const rect = await first.boundingBox();
+ await page.keyboard.press("Escape");
+ await alpha.focus();
+ await alpha.press("Enter");
+ await expect(popover).toBeVisible();
+ const item = popover
+ .getByRole("button", {
+ name: /Open unread thread from/,
+ })
+ .first();
+ await item.focus();
+ await expect(item).toBeFocused();
+ await item.press("Enter");
+ await expect(
+ page.getByRole("complementary", { name: "Thread", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Close thread", exact: true }).click();
+ await expect(alpha).toBeFocused();
+ const beforeRect = await first.boundingBox();
await first.hover();
- expect(await first.boundingBox()).toEqual(rect);
+ const afterRect = await first.boundingBox();
+ expect(afterRect).not.toBeNull();
+ expect(beforeRect).not.toBeNull();
+ expect(afterRect.width).toBe(beforeRect.width);
+ expect(afterRect.height).toBe(beforeRect.height);
await expect(first).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const hover = await first.evaluate((el) => {
const s = getComputedStyle(el);