Add channel activity hover menu (#3935)

## Summary
- show relevant unread threads and active agents when hovering a channel
- keep channel-level unread emphasis separate from thread activity dots
- make activity rows navigate to the thread and remove demo-only data

## Test plan
- `just ci` (all stages passed except the final duplicate native check,
which ran out of disk after its earlier clippy pass)
- `cd desktop && pnpm exec playwright test
tests/e2e/channel-activity-popover.spec.ts --project=smoke`

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-03 17:26:06 +01:00
committed by GitHub
parent f865c0054b
commit b0c6d6f744
31 changed files with 2304 additions and 211 deletions

View File

@@ -41,6 +41,7 @@ export default defineConfig({
"**/channel-mute.spec.ts",
"**/channel-star.spec.ts",
"**/channel-controls.spec.ts",
"**/channel-activity-popover.spec.ts",
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts";
import {
markAllReadSources,
shouldBounceForChannelNotification,
} from "./AppShell.helpers.ts";
test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => {
assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true);
@@ -27,3 +30,43 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
true,
);
});
test("markAllReadSources clears Inbox overrides and active thread activity", () => {
const calls = [];
markAllReadSources({
activeChannelId: "active-channel",
channelActivityItems: [
{ channelId: "another-channel", createdAt: 100 },
{ channelId: "active-channel", createdAt: 200 },
{ channelId: "active-channel", createdAt: 300 },
],
unreadFeedItemIds: new Set(["first-inbox-item", "second-inbox-item"]),
undoUnreadFeedItem: (itemId) => calls.push(`inbox:${itemId}`),
markAllChannelReadMarkers: () => calls.push("channels"),
markActiveChannelRead: (channelId, createdAt) =>
calls.push(`active:${channelId}:${createdAt}`),
});
assert.deepEqual(calls, [
"inbox:first-inbox-item",
"inbox:second-inbox-item",
"channels",
"active:active-channel:300",
]);
});
test("markAllReadSources skips the active marker without projected activity", () => {
const calls = [];
markAllReadSources({
activeChannelId: "active-channel",
channelActivityItems: [],
unreadFeedItemIds: new Set(),
undoUnreadFeedItem: () => calls.push("inbox"),
markAllChannelReadMarkers: () => calls.push("channels"),
markActiveChannelRead: () => calls.push("active"),
});
assert.deepEqual(calls, ["channels"]);
});

View File

@@ -86,6 +86,41 @@ export function shouldBounceForChannelNotification(tags: string[][]): boolean {
return !isThreadReply(tags);
}
export function markAllReadSources({
activeChannelId,
channelActivityItems,
markAllChannelReadMarkers,
markActiveChannelRead,
undoUnreadFeedItem,
unreadFeedItemIds,
}: {
activeChannelId: string | null;
channelActivityItems: ReadonlyArray<{
channelId: string | null;
createdAt: number;
}>;
markAllChannelReadMarkers: () => void;
markActiveChannelRead: (channelId: string, createdAt: number) => void;
undoUnreadFeedItem: (itemId: string) => void;
unreadFeedItemIds: ReadonlySet<string>;
}) {
for (const itemId of unreadFeedItemIds) {
undoUnreadFeedItem(itemId);
}
markAllChannelReadMarkers();
if (!activeChannelId) return;
let latestActivityAt: number | null = null;
for (const item of channelActivityItems) {
if (item.channelId !== activeChannelId) continue;
latestActivityAt = Math.max(latestActivityAt ?? 0, item.createdAt);
}
if (latestActivityAt !== null) {
markActiveChannelRead(activeChannelId, latestActivityAt);
}
}
export function toSearchHit(
target: DesktopNotificationTarget,
): SearchHit | null {

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import * as BuzzTheme from "@/app/BuzzThemeSurfaces";
import { AppShellOverlays } from "@/app/AppShellOverlays";
@@ -15,7 +15,7 @@ import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useChannelActivityProjection } from "@/app/useChannelActivityProjection";
import { useTauriWindowDrag } from "@/app/useTauriWindowDrag";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
@@ -26,7 +26,6 @@ import {
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { msgContextKey } from "@/features/channels/readState/readStateFormat";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
@@ -324,10 +323,12 @@ export function AppShell() {
} = useThreadFollows(identityQuery.data?.pubkey);
const {
markAllChannelsRead,
markAllChannelsRead: markAllChannelReadMarkers,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
unreadChannelIds,
topLevelUnreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
@@ -338,6 +339,7 @@ export function AppShell() {
participatedRootIds,
authoredRootIds,
mentionedRootIds,
recordThreadInteraction,
threadActivityItems,
mutedRootIds,
muteThread,
@@ -356,55 +358,45 @@ export function AppShell() {
followedRootIds,
});
const getThreadReadAt = React.useCallback(
(rootId: string, channelId?: string | null) => {
const threadReadAt = getOwnReadAt(`thread:${rootId}`);
if (!channelId) {
return threadReadAt;
}
const channelReadAt = getChannelReadAt(channelId);
if (threadReadAt === null) {
return channelReadAt;
}
if (channelReadAt === null) {
return threadReadAt;
}
return Math.max(threadReadAt, channelReadAt);
},
[getChannelReadAt, getOwnReadAt],
);
const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) => {
markChannelRead(
`thread:${rootId}`,
new Date(timestamp * 1_000).toISOString(),
);
},
[markChannelRead],
);
// Per-message read frontier (LP4 v3): effective(msg:<id>) folds through the
// channel, so a channel-read clears messages older than the top-level frontier.
const getMessageReadAt = React.useCallback(
(messageId: string) => getChannelReadAt(msgContextKey(messageId)),
[getChannelReadAt],
);
const markMessageRead = React.useCallback(
(messageId: string, timestamp: number) =>
markChannelRead(
msgContextKey(messageId),
new Date(timestamp * 1_000).toISOString(),
),
[markChannelRead],
);
const threadActivityFeedItems = useThreadActivityFeedItems(
const {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
} = useChannelActivityProjection({
channels,
feed: homeFeedQuery.data?.feed,
unreadFeedItemIds: feedItemState.unreadSet,
getChannelReadAt,
getOwnReadAt,
markChannelRead,
readStateVersion,
threadActivityItems,
mutedRootIds,
channels,
);
});
const markAllChannelsRead = React.useCallback(() => {
markAllReadSources({
activeChannelId: activeChannel?.id ?? null,
channelActivityItems: unreadThreadFeedItems,
markAllChannelReadMarkers,
markActiveChannelRead: (channelId, createdAt) =>
markChannelRead(channelId, new Date(createdAt * 1_000).toISOString()),
undoUnreadFeedItem: feedItemState.undoUnread,
unreadFeedItemIds: feedItemState.unreadSet,
});
}, [
activeChannel?.id,
feedItemState.undoUnread,
feedItemState.unreadSet,
markAllChannelReadMarkers,
markChannelRead,
unreadThreadFeedItems,
]);
// Badge count consumes the shared NIP-RS read-state from useUnreadChannels.
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
@@ -709,6 +701,7 @@ export function AppShell() {
markAllChannelsRead,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
openBrowseChannels: handleOpenBrowseChannels,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
@@ -721,6 +714,7 @@ export function AppShell() {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
readStateVersion,
setContextParentResolver,
@@ -728,9 +722,15 @@ export function AppShell() {
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
recordThreadInteraction,
isThreadMuted: (rootId) => mutedRootIds.has(rootId),
threadActivityItems,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
topLevelUnreadChannelIds,
hasSidebarUnreadProjections: true,
feedItemState,
onOpenSettings: handleOpenSettings,
}}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore";
import type { ContextParentResolver } from "@/features/channels/readState/readStateManager";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItemState } from "@/features/home/useFeedItemState";
@@ -12,9 +13,16 @@ type AppShellContextValue = {
markChannelRead: (
channelId: string,
readAt: string | null | undefined,
options?: { topLevelOnly?: boolean },
options?: {
preserveForcedUnread?: boolean;
topLevelOnly?: boolean;
},
) => void;
markChannelUnread: (channelId: string, source?: ForcedUnreadSource) => void;
clearChannelUnreadSource: (
channelId: string,
source: ForcedUnreadSource,
) => void;
markChannelUnread: (channelId: string) => void;
openBrowseChannels: () => void;
openCreateChannel: () => void;
openChannelManagement: (channelId?: string) => void;
@@ -31,6 +39,11 @@ type AppShellContextValue = {
// read. Uses `msg:<id>` context keys folded through the active channel by the
// parent resolver (LP4 v3 per-message badge model).
getMessageReadAt: (messageId: string) => number | null;
// Read frontier for a channel-activity item, scoped to that item's own
// message and channel rather than the currently mounted channel resolver.
getChannelActivityItemReadAt: (
item: Pick<FeedItem, "channelId" | "id">,
) => number | null;
// Advance a single message's read marker to the given unix-seconds timestamp.
markMessageRead: (messageId: string, timestamp: number) => void;
// Bump-counter that invalidates whenever the read marker changes. Include
@@ -43,9 +56,25 @@ type AppShellContextValue = {
unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
recordThreadInteraction: (rootId: string) => void;
isThreadMuted: (rootId: string) => boolean;
threadActivityItems: ThreadActivityItem[];
threadActivityFeedItems: FeedItem[];
// Home-feed items explicitly reopened from Inbox. Kept separate from live
// thread activity so older rows can be projected into channel hover cards
// without duplicating the Home feed itself.
locallyUnreadFeedItems: FeedItem[];
// Thread rows that remain unread until their own message/thread marker is
// advanced. Unlike the broad channel unread set, this includes the active
// channel so simply landing in it does not hide the wayfinding signal.
unreadThreadFeedItems: FeedItem[];
unreadThreadChannelIds: ReadonlySet<string>;
// Ordinary unread channel-level activity. Sidebar rows use this for text
// emphasis only; thread activity owns the dot.
topLevelUnreadChannelIds: ReadonlySet<string>;
// Lets isolated component tests retain the legacy hasUnread fallback while
// the mounted shell uses the split projections above.
hasSidebarUnreadProjections: boolean;
feedItemState: FeedItemState;
// Open the Settings panel at the given section. Available on all surfaces
// that render under AppShell (channel, home, projects, pulse, agents).
@@ -57,6 +86,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
markAllChannelsRead: () => {},
markChannelRead: () => {},
markChannelUnread: () => {},
clearChannelUnreadSource: () => {},
openBrowseChannels: () => {},
openCreateChannel: () => {},
openChannelManagement: () => {},
@@ -64,6 +94,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
getThreadReadAt: () => null,
markThreadRead: () => {},
getMessageReadAt: () => null,
getChannelActivityItemReadAt: () => null,
markMessageRead: () => {},
readStateVersion: 0,
setContextParentResolver: () => {},
@@ -71,9 +102,15 @@ const AppShellContext = React.createContext<AppShellContextValue>({
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
recordThreadInteraction: () => {},
isThreadMuted: () => false,
threadActivityItems: [],
threadActivityFeedItems: [],
locallyUnreadFeedItems: [],
unreadThreadFeedItems: [],
unreadThreadChannelIds: EMPTY_SET,
topLevelUnreadChannelIds: EMPTY_SET,
hasSidebarUnreadProjections: false,
feedItemState: {
doneSet: EMPTY_SET,
markDone: () => {},

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveChannelActivityFeedItemReadAt } from "./useChannelActivityProjection.ts";
test("channel activity read state folds the item's own message and channel markers", () => {
const markers = new Map([
["msg:reply-general", 100],
["general", 200],
["random", 500],
]);
assert.equal(
resolveChannelActivityFeedItemReadAt(
{ id: "reply-general", channelId: "general" },
(contextId) => markers.get(contextId) ?? null,
),
200,
);
});
test("channel activity read state honors a channel marker without a message marker", () => {
assert.equal(
resolveChannelActivityFeedItemReadAt(
{ id: "reply-general", channelId: "general" },
(contextId) => (contextId === "general" ? 300 : null),
),
300,
);
});

View File

@@ -0,0 +1,145 @@
import * as React from "react";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import {
maxReadAt,
msgContextKey,
} from "@/features/channels/readState/readStateFormat";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import { isThreadReply } from "@/features/messages/lib/threading";
import type { Channel, FeedItem, HomeFeed } from "@/shared/api/types";
type ReadTimestamp = (contextKey: string) => number | null;
type MarkChannelRead = (
contextKey: string,
readAt: string | null | undefined,
options?: { topLevelOnly?: boolean },
) => void;
type UseChannelActivityProjectionOptions = {
channels: Channel[];
feed: HomeFeed | undefined;
unreadFeedItemIds: ReadonlySet<string>;
getChannelReadAt: ReadTimestamp;
getOwnReadAt: ReadTimestamp;
markChannelRead: MarkChannelRead;
readStateVersion: number;
threadActivityItems: ThreadActivityItem[];
mutedRootIds: ReadonlySet<string>;
};
export function resolveChannelActivityFeedItemReadAt(
item: Pick<FeedItem, "channelId" | "id">,
getOwnReadAt: ReadTimestamp,
): number | null {
return maxReadAt(
getOwnReadAt(msgContextKey(item.id)),
item.channelId ? getOwnReadAt(item.channelId) : null,
);
}
export function useChannelActivityProjection({
channels,
feed,
unreadFeedItemIds,
getChannelReadAt,
getOwnReadAt,
markChannelRead,
readStateVersion,
threadActivityItems,
mutedRootIds,
}: UseChannelActivityProjectionOptions) {
const getThreadReadAt = React.useCallback(
(rootId: string, channelId?: string | null) => {
const threadReadAt = getOwnReadAt(`thread:${rootId}`);
if (!channelId) return threadReadAt;
const channelReadAt = getChannelReadAt(channelId);
if (threadReadAt === null) return channelReadAt;
if (channelReadAt === null) return threadReadAt;
return Math.max(threadReadAt, channelReadAt);
},
[getChannelReadAt, getOwnReadAt],
);
const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) =>
markChannelRead(
`thread:${rootId}`,
new Date(timestamp * 1_000).toISOString(),
),
[markChannelRead],
);
const getMessageReadAt = React.useCallback(
(messageId: string) => getChannelReadAt(msgContextKey(messageId)),
[getChannelReadAt],
);
const getChannelActivityItemReadAt = React.useCallback(
(item: Pick<FeedItem, "channelId" | "id">) =>
resolveChannelActivityFeedItemReadAt(item, getOwnReadAt),
[getOwnReadAt],
);
const markMessageRead = React.useCallback(
(messageId: string, timestamp: number) =>
markChannelRead(
msgContextKey(messageId),
new Date(timestamp * 1_000).toISOString(),
),
[markChannelRead],
);
const threadActivityFeedItems = useThreadActivityFeedItems(
threadActivityItems,
mutedRootIds,
channels,
);
const locallyUnreadFeedItems = React.useMemo(() => {
if (!feed || unreadFeedItemIds.size === 0) return [];
return [
...feed.mentions,
...feed.needsAction,
...feed.activity,
...feed.agentActivity,
].filter((item) => unreadFeedItemIds.has(item.id));
}, [feed, unreadFeedItemIds]);
const unreadThreadFeedItems = React.useMemo(() => {
void readStateVersion;
const candidatesById = new Map<string, FeedItem>(
threadActivityFeedItems.map((item) => [item.id, item]),
);
for (const item of locallyUnreadFeedItems)
candidatesById.set(item.id, item);
return [...candidatesById.values()].filter(
(item) =>
isThreadReply(item.tags) &&
(unreadFeedItemIds.has(item.id) ||
item.createdAt > (getChannelActivityItemReadAt(item) ?? 0)),
);
}, [
getChannelActivityItemReadAt,
locallyUnreadFeedItems,
readStateVersion,
threadActivityFeedItems,
unreadFeedItemIds,
]);
const unreadThreadChannelIds = React.useMemo(
() =>
new Set(
unreadThreadFeedItems.flatMap((item) =>
item.channelId ? [item.channelId] : [],
),
) as ReadonlySet<string>,
[unreadThreadFeedItems],
);
return {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
};
}

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
addForcedUnreadSource,
forcedUnreadMarker,
removeForcedUnreadSource,
} from "./forcedUnreadStore.ts";
test("adding an Inbox owner preserves an existing manual force", () => {
const entry = addForcedUnreadSource(null, 120, "inbox");
assert.deepEqual(entry, {
markerAtWhenForced: null,
sources: ["manual", "inbox"],
});
});
test("clearing Inbox ownership leaves a manual force intact", () => {
const entry = {
markerAtWhenForced: 120,
sources: ["manual", "inbox"],
};
assert.deepEqual(removeForcedUnreadSource(entry, "inbox"), {
markerAtWhenForced: 120,
sources: ["manual"],
});
});
test("clearing manual ownership leaves an Inbox force intact", () => {
const entry = {
markerAtWhenForced: 120,
sources: ["manual", "inbox"],
};
assert.deepEqual(removeForcedUnreadSource(entry, "manual"), {
markerAtWhenForced: 120,
sources: ["inbox"],
});
});
test("clearing the only force owner removes the entry", () => {
const entry = {
markerAtWhenForced: 120,
sources: ["inbox"],
};
assert.equal(removeForcedUnreadSource(entry, "inbox"), undefined);
});
test("legacy persisted entries retain their read-marker baseline", () => {
assert.equal(forcedUnreadMarker(120), 120);
assert.equal(forcedUnreadMarker(null), null);
});

View File

@@ -1,14 +1,17 @@
import * as React from "react";
/**
* Per-pubkey localStorage record store for channels manually marked unread via
* right-click → "mark unread". Keyed by channelId (not thread-root). Persisted
* so the sidebar badge survives reload and the rail observer can read it for
* inactive communities.
* Per-pubkey localStorage record store for channels forced unread by manual or
* Inbox actions. Keyed by channelId (not thread-root). Persisted so the sidebar
* badge survives reload and the rail observer can read it for inactive
* communities.
*
* Each entry stores the channel's own NIP-RS read marker (unix seconds) at the
* moment mark-unread was invoked, or null if no marker existed yet. The rail
* observer gates the forced-unread OR on this baseline: if the observed synced
* read marker has since advanced past markerAtWhenForced, the cross-device read
* wins and the dot is not lit.
* moment the first force was added, or null if no marker existed yet, plus the
* sources that currently own the force. The rail observer gates the
* forced-unread OR on this baseline: if the observed synced read marker has
* since advanced past markerAtWhenForced, the cross-device read wins and the
* dot is not lit.
*
* On identity change, the in-memory map is swapped to the current pubkey's
* persisted data. Old pubkey data is NOT wiped from localStorage.
@@ -17,8 +20,58 @@
* a retrograde "unread" state. localStorage is best-effort (per-device).
*/
/** channelId → NIP-RS read marker (unix seconds) at force-time, or null */
export type ForcedUnreadMap = Record<string, number | null>;
export type ForcedUnreadSource = "inbox" | "manual";
export type ForcedUnreadEntry =
| number
| null
| {
markerAtWhenForced: number | null;
sources: ForcedUnreadSource[];
};
/** channelId → forced-unread entry. Numbers/null are the legacy v1 shape. */
export type ForcedUnreadMap = Record<string, ForcedUnreadEntry>;
function entrySources(entry: ForcedUnreadEntry): ForcedUnreadSource[] {
return typeof entry === "object" && entry !== null
? entry.sources
: ["manual"];
}
export function forcedUnreadMarker(entry: ForcedUnreadEntry): number | null {
return typeof entry === "object" && entry !== null
? entry.markerAtWhenForced
: entry;
}
export function addForcedUnreadSource(
entry: ForcedUnreadEntry | undefined,
markerAtWhenForced: number | null,
source: ForcedUnreadSource,
): ForcedUnreadEntry {
if (entry !== undefined && entrySources(entry).includes(source)) return entry;
return {
markerAtWhenForced:
entry === undefined ? markerAtWhenForced : forcedUnreadMarker(entry),
sources: [
...new Set([...(entry === undefined ? [] : entrySources(entry)), source]),
],
};
}
export function removeForcedUnreadSource(
entry: ForcedUnreadEntry,
source: ForcedUnreadSource,
): ForcedUnreadEntry | undefined {
const sources = entrySources(entry);
if (!sources.includes(source)) return entry;
const remainingSources = sources.filter((candidate) => candidate !== source);
return remainingSources.length > 0
? {
markerAtWhenForced: forcedUnreadMarker(entry),
sources: remainingSources,
}
: undefined;
}
const STORAGE_PREFIX = "buzz-forced-unread.v1";
const storageKey = (pubkey: string) => `${STORAGE_PREFIX}:${pubkey}`;
@@ -37,8 +90,27 @@ export const forcedUnreadStore = {
return {};
const result: ForcedUnreadMap = {};
for (const [k, v] of Object.entries(parsed)) {
if (typeof k === "string" && (v === null || typeof v === "number")) {
result[k] = v as number | null;
if (typeof k !== "string") continue;
if (v === null || typeof v === "number") {
result[k] = v;
continue;
}
if (
typeof v === "object" &&
v !== null &&
"markerAtWhenForced" in v &&
(v.markerAtWhenForced === null ||
typeof v.markerAtWhenForced === "number") &&
"sources" in v &&
Array.isArray(v.sources)
) {
const sources = v.sources.filter(
(source: unknown): source is ForcedUnreadSource =>
source === "inbox" || source === "manual",
);
if (sources.length > 0) {
result[k] = { markerAtWhenForced: v.markerAtWhenForced, sources };
}
}
}
return result;
@@ -54,3 +126,44 @@ export const forcedUnreadStore = {
}
},
};
export function useForcedUnreadActions(
forcedUnreadRef: React.MutableRefObject<ForcedUnreadMap>,
getOwnTimestamp: (channelId: string) => number | null,
pubkey: string | undefined,
onChange: () => void,
) {
const persist = React.useCallback(() => {
if (pubkey) forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
onChange();
}, [forcedUnreadRef, onChange, pubkey]);
const markChannelUnread = React.useCallback(
(channelId: string, source: ForcedUnreadSource = "manual") => {
const current = Object.hasOwn(forcedUnreadRef.current, channelId)
? forcedUnreadRef.current[channelId]
: undefined;
const next = addForcedUnreadSource(
current,
getOwnTimestamp(channelId),
source,
);
if (next === current) return;
forcedUnreadRef.current[channelId] = next;
persist();
},
[forcedUnreadRef, getOwnTimestamp, persist],
);
const clearChannelUnreadSource = React.useCallback(
(channelId: string, source: ForcedUnreadSource) => {
if (!Object.hasOwn(forcedUnreadRef.current, channelId)) return;
const current = forcedUnreadRef.current[channelId];
const next = removeForcedUnreadSource(current, source);
if (next === current) return;
if (next === undefined) delete forcedUnreadRef.current[channelId];
else forcedUnreadRef.current[channelId] = next;
persist();
},
[forcedUnreadRef, persist],
);
return { clearChannelUnreadSource, markChannelUnread };
}

View File

@@ -80,6 +80,7 @@ import { useMessageProfiles } from "./useMessageProfiles";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelOpenReadState } from "./useChannelOpenReadState";
import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
@@ -99,7 +100,7 @@ export function ChannelScreen({
const { goHome } = useAppNavigation();
const { activeCommunity } = useCommunities();
const {
markChannelRead,
clearChannelUnreadSource,
markChannelUnread,
getChannelReadAt,
getMessageReadAt,
@@ -112,6 +113,7 @@ export function ChannelScreen({
unfollowThread,
isFollowingThread,
isNotifiedForThread,
recordThreadInteraction,
isThreadMuted,
readStateVersion,
} = useAppShell();
@@ -207,12 +209,11 @@ export function ChannelScreen({
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
React.useEffect(() => {
if (!activeChannelId || activeChannel?.isMember === false) {
return;
}
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
useChannelOpenReadState(
activeChannelId,
activeChannel?.isMember,
activeReadAt,
);
React.useEffect(() => {
if (!activeChannelId) {
setContextParentResolver(null);
@@ -262,7 +263,6 @@ export function ChannelScreen({
resolvedMessages,
threadReplyEvents,
);
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
threadReplyEvents,
@@ -472,6 +472,7 @@ export function ChannelScreen({
threadReplyTargetId,
expandedThreadReplyIds,
openThreadMessages: threadPanelData.visibleReplies,
clearChannelUnreadSource,
getChannelReadAt,
getMessageReadAt,
markChannelUnread,
@@ -484,8 +485,7 @@ export function ChannelScreen({
timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages],
);
// Event id awaiting the empty-edit "Delete message?" confirmation (non-null
// while the dialog is open); see handleEditSave.
// Event id awaiting the empty-edit deletion confirmation.
const [emptyDeleteId, setEmptyDeleteId] = React.useState<string | null>(null);
const {
handleCancelEdit,
@@ -508,6 +508,7 @@ export function ChannelScreen({
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
markRevealedRepliesRead,
recordThreadInteraction,
openThreadHeadId: effectiveOpenThreadHeadId,
onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
onRequestEmptyEditDelete: setEmptyDeleteId,
@@ -688,7 +689,6 @@ export function ChannelScreen({
threadReplyTargetId,
threadReplyTargetMessage,
});
const hasAuxiliaryPanel = Boolean(
effectiveOpenThreadHeadId ||
openAgentSessionPubkey ||

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getTopLevelInboxUnreadOverrideIds } from "./useChannelOpenReadState.ts";
test("opening a channel clears only its top-level Inbox overrides", () => {
assert.deepEqual(
getTopLevelInboxUnreadOverrideIds(
[
{ id: "top-level", channelId: "general", tags: [] },
{
id: "thread-reply",
channelId: "general",
tags: [
["e", "root", "", "root"],
["e", "parent", "", "reply"],
],
},
{ id: "other-channel", channelId: "random", tags: [] },
],
"general",
),
["top-level"],
);
});

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { isThreadReply } from "@/features/messages/lib/threading";
import type { FeedItem } from "@/shared/api/types";
/**
* Inbox overrides for top-level rows are consumed by opening the channel.
* Thread-reply overrides intentionally remain until their thread is read.
*/
export function getTopLevelInboxUnreadOverrideIds(
items: FeedItem[],
channelId: string,
): string[] {
return items.flatMap((item) =>
item.channelId === channelId && !isThreadReply(item.tags) ? [item.id] : [],
);
}
export function useChannelOpenReadState(
activeChannelId: string | null,
isChannelMember: boolean | undefined,
activeReadAt: string | null,
) {
const { feedItemState, locallyUnreadFeedItems, markChannelRead } =
useAppShell();
React.useEffect(() => {
if (!activeChannelId || isChannelMember === false) return;
for (const itemId of getTopLevelInboxUnreadOverrideIds(
locallyUnreadFeedItems,
activeChannelId,
)) {
feedItemState.undoUnread(itemId);
}
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
}, [
activeChannelId,
activeReadAt,
feedItemState.undoUnread,
isChannelMember,
locallyUnreadFeedItems,
markChannelRead,
]);
}

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore";
import {
buildCreatedAtByMessageId,
buildDirectReplyIdsByParentId,
@@ -36,6 +37,10 @@ type UseChannelUnreadStateOptions = {
openThreadMessages?: MainTimelineEntry[];
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt: (messageId: string) => number | null;
clearChannelUnreadSource: (
channelId: string,
source: ForcedUnreadSource,
) => void;
markChannelUnread: (channelId: string) => void;
markMessageRead: (messageId: string, timestamp: number) => void;
isThreadMuted: (rootId: string) => boolean;
@@ -64,6 +69,7 @@ export function useChannelUnreadState({
openThreadMessages,
getChannelReadAt,
getMessageReadAt,
clearChannelUnreadSource,
markChannelUnread,
markMessageRead,
isThreadMuted,
@@ -452,16 +458,26 @@ export function useChannelUnreadState({
const createdAt = createdAtByMessageId.get(id);
if (createdAt !== undefined) markMessageRead(id, createdAt);
}
if (activeChannelId && forcedUnreadMsgRef.current.size === 0) {
clearChannelUnreadSource(activeChannelId, "manual");
}
forceUnreadRender();
},
[createdAtByMessageId, getReplyDescendantIdsForMessage, markMessageRead],
[
activeChannelId,
clearChannelUnreadSource,
createdAtByMessageId,
getReplyDescendantIdsForMessage,
markMessageRead,
],
);
// Mark a message and its whole subtree UNREAD (LP4 v3 menu action). Markers
// are monotonic and cannot move backward, so this writes NO marker: it adds
// the ids to the session-local forced-unread overlay the badge predicates OR
// in. Cleared on channel-leave; does not survive reload (symmetric with the
// shipped channel mark-unread).
// in. It also forces the channel-level unread projection so leaving the
// channel restores its bold sidebar emphasis. The per-message overlay is
// cleared on channel-leave; the channel-level force survives until reopen.
const handleMarkMessageUnread = React.useCallback(
(messageId: string) => {
for (const id of [
@@ -470,9 +486,12 @@ export function useChannelUnreadState({
]) {
forcedUnreadMsgRef.current.add(id);
}
if (activeChannelId) {
markChannelUnread(activeChannelId);
}
forceUnreadRender();
},
[getReplyDescendantIdsForMessage],
[activeChannelId, getReplyDescendantIdsForMessage, markChannelUnread],
);
return {

View File

@@ -78,6 +78,19 @@ export function countUnreadObservedEvents(
return count;
}
export function hasUnreadTopLevelObservedEvent(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,
): boolean {
if (!eventsById) return false;
for (const event of eventsById.values()) {
if (event.rootId !== null) continue;
const readAt = getReadAt(event);
if (readAt === null || event.createdAt > readAt) return true;
}
return false;
}
export function countUnreadBadgeObservedEvents(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,

View File

@@ -7,6 +7,7 @@ import {
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
hasUnreadTopLevelObservedEvent,
observedUnreadEventReadAt,
recordObservedUnreadEvent,
} from "./unreadChannelCounts.ts";
@@ -368,6 +369,30 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => {
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1);
});
test("hasUnreadTopLevelObservedEvent_ignoresUnreadThreadReplies", () => {
const events = new Map([
["top-old", observed("top-old", 250)],
["thread-new", observed("thread-new", 500, "root-1")],
]);
assert.equal(
hasUnreadTopLevelObservedEvent(events, readAtFor(300, new Map())),
false,
);
});
test("hasUnreadTopLevelObservedEvent_detectsUnreadTopLevelMessage", () => {
const events = new Map([
["top-new", observed("top-new", 350)],
["thread-new", observed("thread-new", 500, "root-1")],
]);
assert.equal(
hasUnreadTopLevelObservedEvent(events, readAtFor(300, new Map())),
true,
);
});
test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => {
const events = new Map([
["plain", observed("plain", 500, null, false, false)],

View File

@@ -7,6 +7,7 @@ import type {
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { resolveThreadReplyTarget } from "@/features/messages/hooks";
import type { TimelineMessage } from "@/features/messages/types";
/**
* Stable callback references for ChannelPane so that keystroke-driven
@@ -24,6 +25,7 @@ export function useChannelPaneHandlers({
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
markRevealedRepliesRead,
recordThreadInteraction,
onOptimisticOpenThreadHeadIdChange,
onRequestEmptyEditDelete,
openThreadHeadId,
@@ -43,6 +45,7 @@ export function useChannelPaneHandlers({
getFirstReplyIdForMessage: (messageId: string) => string | null;
getReplyDescendantIdsForMessage: (messageId: string) => string[];
markRevealedRepliesRead: (messageId: string) => void;
recordThreadInteraction: (rootId: string) => void;
onOptimisticOpenThreadHeadIdChange: React.Dispatch<
React.SetStateAction<string | null | undefined>
>;
@@ -344,14 +347,21 @@ export function useChannelPaneHandlers({
);
const handleToggleReaction = React.useCallback(
async (message: { id: string }, emoji: string, remove: boolean) => {
async (
message: Pick<TimelineMessage, "id" | "rootId">,
emoji: string,
remove: boolean,
) => {
await toggleMutateRef.current({
emoji,
eventId: message.id,
remove,
});
if (!remove) {
recordThreadInteraction(message.rootId ?? message.id);
}
},
[],
[recordThreadInteraction],
);
return {

View File

@@ -9,8 +9,8 @@ import {
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
hasUnreadTopLevelObservedEvent,
makeObservedUnreadEvent,
mapsEqual,
observedUnreadEventReadAt,
recordObservedUnreadEvent,
type ObservedUnreadEvent,
@@ -20,6 +20,7 @@ import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
import {
forcedUnreadStore,
type ForcedUnreadMap,
useForcedUnreadActions,
} from "@/features/channels/forcedUnreadStore";
import {
getThreadReference,
@@ -33,6 +34,7 @@ import {
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind";
import {
@@ -125,14 +127,6 @@ export function resolveObservedUnreadRootId(tags: string[][]): string | null {
return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId;
}
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const item of a) {
if (!b.has(item)) return false;
}
return true;
}
export function useUnreadChannels(
channels: Channel[],
activeChannel: Channel | null,
@@ -310,9 +304,18 @@ export function useUnreadChannels(
(
channelId: string,
readAt: string | null | undefined,
{ topLevelOnly = false }: { topLevelOnly?: boolean } = {},
{
preserveForcedUnread = false,
topLevelOnly = false,
}: {
preserveForcedUnread?: boolean;
topLevelOnly?: boolean;
} = {},
) => {
if (Object.hasOwn(forcedUnreadRef.current, channelId)) {
if (
!preserveForcedUnread &&
Object.hasOwn(forcedUnreadRef.current, channelId)
) {
delete forcedUnreadRef.current[channelId];
if (pubkey) {
forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
@@ -342,22 +345,13 @@ export function useUnreadChannels(
[markContextRead, pubkey],
);
// Manually mark a channel unread (e.g., right-click → "mark unread"). Persists
// the current NIP-RS read marker as baseline to localStorage so the rail
// observer can detect when a cross-device read has since covered the force.
// NIP-RS markers are monotonic, so we do not publish a lower timestamp.
const markChannelUnread = React.useCallback(
(channelId: string) => {
if (!Object.hasOwn(forcedUnreadRef.current, channelId)) {
forcedUnreadRef.current[channelId] = getOwnTimestamp(channelId);
if (pubkey) {
forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
}
bumpLatestVersion();
}
},
[getOwnTimestamp, pubkey],
);
const { clearChannelUnreadSource, markChannelUnread } =
useForcedUnreadActions(
forcedUnreadRef,
getOwnTimestamp,
pubkey,
bumpLatestVersion,
);
// Record the thread root of an EXTERNAL message that @-mentioned the user.
// Keyed on the thread root so the badge gate trips for a mention recipient
@@ -477,6 +471,22 @@ export function useUnreadChannels(
[normalizedPubkey],
);
const recordThreadInteraction = React.useCallback(
(rootId: string) => {
const normalizedRootId = rootId.trim();
if (!normalizedRootId) return;
const target = participatedRootIdsRef.current;
const sizeBefore = target.size;
target.add(normalizedRootId);
if (target.size === sizeBefore) return;
if (normalizedPubkey !== null) {
participationStore.write(normalizedPubkey, target);
}
bumpMembershipVersion();
},
[normalizedPubkey],
);
const handleThreadReplyNotification = React.useCallback(
(channelId: string, event: RelayEvent) => {
// Guard: don't merge into a ref whose scope has drifted from the current
@@ -830,6 +840,7 @@ export function useUnreadChannels(
if (!isReadStateReady) {
return {
unreadChannelIds: new Set<string>(),
topLevelUnreadChannelIds: new Set<string>(),
highPriorityUnreadChannelIds: new Set<string>(),
unreadChannelCounts: new Map<string, number>(),
unreadChannelNotificationCount: 0,
@@ -837,6 +848,7 @@ export function useUnreadChannels(
}
const unread = new Set<string>();
const topLevelUnread = new Set<string>();
const highPriority = new Set<string>();
const counts = new Map<string, number>();
let unreadChannelNotificationCount = 0;
@@ -844,15 +856,10 @@ export function useUnreadChannels(
for (const channel of channels) {
if (channel.id === activeChannelId) continue;
if (Object.hasOwn(forcedUnreadRef.current, channel.id)) {
// Forced-unread is dot tier only — not high-priority.
unread.add(channel.id);
counts.set(channel.id, 1);
unreadChannelNotificationCount += 1;
continue;
}
if (latestByChannelRef.current.get(channel.id) === undefined) continue;
const isForcedUnread = Object.hasOwn(
forcedUnreadRef.current,
channel.id,
);
const observedEvents = observedUnreadEventsByChannelRef.current.get(
channel.id,
@@ -866,13 +873,25 @@ export function useUnreadChannels(
(messageId) => getOwnTimestamp(`msg:${messageId}`),
);
const unreadCount = countUnreadObservedEvents(
observedEvents,
readAtForObservedEvent,
);
if (unreadCount === 0) continue;
const unreadCount =
latestByChannelRef.current.get(channel.id) === undefined
? 0
: countUnreadObservedEvents(observedEvents, readAtForObservedEvent);
if (unreadCount === 0) {
if (!isForcedUnread) continue;
unread.add(channel.id);
topLevelUnread.add(channel.id);
counts.set(channel.id, 1);
unreadChannelNotificationCount += 1;
continue;
}
unread.add(channel.id);
if (
hasUnreadTopLevelObservedEvent(observedEvents, readAtForObservedEvent)
) {
topLevelUnread.add(channel.id);
}
const badgeCount = countUnreadBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
@@ -900,6 +919,7 @@ export function useUnreadChannels(
return {
unreadChannelIds: unread,
topLevelUnreadChannelIds: topLevelUnread,
highPriorityUnreadChannelIds: highPriority,
unreadChannelCounts: counts,
unreadChannelNotificationCount,
@@ -914,37 +934,14 @@ export function useUnreadChannels(
readStateVersion,
]);
// Stabilize Set references: only replace when contents actually change,
// so downstream memos don't re-run on every render when sets are equal.
const prevUnreadRef = React.useRef<ReadonlySet<string>>(new Set());
const prevHighPriorityRef = React.useRef<ReadonlySet<string>>(new Set());
const prevUnreadCountsRef = React.useRef<ReadonlyMap<string, number>>(
new Map(),
const unreadChannelIds = useStableSet(rawUnread.unreadChannelIds);
const topLevelUnreadChannelIds = useStableSet(
rawUnread.topLevelUnreadChannelIds,
);
const unreadChannelIds = setsEqual(
rawUnread.unreadChannelIds,
prevUnreadRef.current,
)
? prevUnreadRef.current
: rawUnread.unreadChannelIds;
prevUnreadRef.current = unreadChannelIds;
const highPriorityUnreadChannelIds = setsEqual(
const highPriorityUnreadChannelIds = useStableSet(
rawUnread.highPriorityUnreadChannelIds,
prevHighPriorityRef.current,
)
? prevHighPriorityRef.current
: rawUnread.highPriorityUnreadChannelIds;
prevHighPriorityRef.current = highPriorityUnreadChannelIds;
const unreadChannelCounts = mapsEqual(
rawUnread.unreadChannelCounts,
prevUnreadCountsRef.current,
)
? prevUnreadCountsRef.current
: rawUnread.unreadChannelCounts;
prevUnreadCountsRef.current = unreadChannelCounts;
);
const unreadChannelCounts = useStableMap(rawUnread.unreadChannelCounts);
const unreadChannelNotificationCount =
rawUnread.unreadChannelNotificationCount;
@@ -992,12 +989,14 @@ export function useUnreadChannels(
return {
unreadChannelIds,
topLevelUnreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
markAllChannelsRead,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
// Exposed so other surfaces (e.g. Home) can project per-item read state
// off the same NIP-RS read marker without instantiating a second
// ReadStateManager. readStateVersion is the invalidation signal callers
@@ -1009,6 +1008,7 @@ export function useUnreadChannels(
participatedRootIds,
authoredRootIds,
mentionedRootIds,
recordThreadInteraction,
threadActivityItems: projectActivityForScope(
threadActivityScopeRef.current,
currentActivityScope,

View File

@@ -1,5 +1,6 @@
import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
import {
forcedUnreadMarker,
forcedUnreadStore,
type ForcedUnreadMap,
} from "@/features/channels/forcedUnreadStore";
@@ -238,7 +239,9 @@ export async function fetchCommunityUnread(args: {
// runs while the community is active, so the store may not be pruned for
// inactive communities).
if (!hasUnread && Object.hasOwn(forcedUnreadMap, channel.id)) {
const markerAtWhenForced = forcedUnreadMap[channel.id];
const markerAtWhenForced = forcedUnreadMarker(
forcedUnreadMap[channel.id],
);
if (
readAt === null ||
(markerAtWhenForced !== null && readAt <= markerAtWhenForced)

View File

@@ -234,13 +234,16 @@ export function HomeView({
inboxListWidthPx,
} = useResizableInboxListWidth();
const {
clearChannelUnreadSource,
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
feedItemState,
markChannelRead,
markChannelUnread,
markMessageRead,
markThreadRead,
recordThreadInteraction,
readStateVersion,
} = useAppShell();
const { doneSet, markDone, markUnread, undoDone, undoUnread, unreadSet } =
@@ -383,7 +386,9 @@ export function HomeView({
readStateVersion,
localDoneSet: doneSet,
localUnreadSet: unreadSet,
clearChannelUnreadSource,
markChannelRead,
markChannelUnread,
markMessageRead,
markThreadRead,
markDoneLocal: markDone,
@@ -861,6 +866,13 @@ export function HomeView({
eventId: message.id,
remove,
});
if (!remove) {
recordThreadInteraction(
selectedItem?.conversationId ??
message.rootId ??
message.id,
);
}
await threadContext.refreshReactions();
await channelMessagesQuery.refetch();
onRefresh();

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import {
getGroupedChannelReadTimestamp,
getGroupedInboxItemIds,
hasRemainingChannelUnreadOverride,
hasGroupedUnreadOverride,
resolveInboxItemReadAt,
} from "./useHomeInboxReadState.ts";
@@ -125,6 +126,47 @@ test("grouped unread override matches any item represented by the row", () => {
);
});
test("remaining channel unread override ignores the row being cleared", () => {
const firstReply = feedItem({
id: "first-reply",
createdAt: 200,
tags: [
["h", CHANNEL_ID],
["e", "first-root", "", "root"],
["e", "first-parent", "", "reply"],
],
});
const secondReply = feedItem({
id: "second-reply",
createdAt: 300,
tags: [
["h", CHANNEL_ID],
["e", "second-root", "", "root"],
["e", "second-parent", "", "reply"],
],
});
const items = [inboxItem([firstReply]), inboxItem([secondReply])];
assert.equal(
hasRemainingChannelUnreadOverride(
items,
new Set(["first-reply", "second-reply"]),
CHANNEL_ID,
new Set(["first-reply"]),
),
true,
);
assert.equal(
hasRemainingChannelUnreadOverride(
items,
new Set(["first-reply"]),
CHANNEL_ID,
new Set(["first-reply"]),
),
false,
);
});
test("thread inbox row without a marker ignores local done fallback", () => {
const replyItem = feedItem({
id: "reply-event",

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore";
import type { InboxItem } from "@/features/home/lib/inbox";
import {
getThreadReference,
@@ -25,6 +26,17 @@ type UseHomeInboxReadStateOptions = {
markChannelRead: (
channelId: string,
readAt: string | null | undefined,
options?: {
preserveForcedUnread?: boolean;
topLevelOnly?: boolean;
},
) => void;
/** Force a channel's unread indicator without rolling back its NIP-RS marker. */
markChannelUnread: (channelId: string, source?: ForcedUnreadSource) => void;
/** Remove only the named owner of a channel's forced-unread indicator. */
clearChannelUnreadSource: (
channelId: string,
source: ForcedUnreadSource,
) => void;
/** Advance the thread read marker to the given unix-seconds timestamp. */
markThreadRead: (rootId: string, timestamp: number) => void;
@@ -32,7 +44,7 @@ type UseHomeInboxReadStateOptions = {
markMessageRead: (messageId: string, timestamp: number) => void;
/** Local fallback: mark a non-channel item done. */
markDoneLocal: (id: string) => void;
/** Local inbox row override: mark an item unread without touching the channel. */
/** Local inbox row override: mark an item unread alongside its channel emphasis. */
markUnreadLocal: (id: string) => void;
/** Local fallback: undo a non-channel item done. */
undoDoneLocal: (id: string) => void;
@@ -83,6 +95,21 @@ export function hasGroupedUnreadOverride(
return getGroupedInboxItemIds(item).some((id) => localUnreadSet.has(id));
}
export function hasRemainingChannelUnreadOverride(
items: InboxItem[],
localUnreadSet: ReadonlySet<string>,
channelId: string,
clearedItemIds: ReadonlySet<string>,
): boolean {
return items.some(
(candidate) =>
candidate.item.channelId === channelId &&
getGroupedInboxItemIds(candidate).some(
(id) => localUnreadSet.has(id) && !clearedItemIds.has(id),
),
);
}
export function resolveInboxItemReadAt(
item: InboxItem,
options: {
@@ -114,8 +141,8 @@ export function resolveInboxItemReadAt(
* "Mark as read" on channel-backed items is routed through `markChannelRead`;
* thread rows advance the same per-message markers as the channel thread
* panel, plus the aggregate `thread:<root>` marker for compatibility. "Mark
* unread" is item-local: it only reopens the specific inbox row and must not
* light up the channel.
* unread" keeps its per-item local override and also restores the source
* channel's forced-unread indicator so channel surfaces agree with Inbox.
*/
export function useHomeInboxReadState({
items,
@@ -126,6 +153,8 @@ export function useHomeInboxReadState({
localDoneSet,
localUnreadSet = EMPTY_ITEM_SET,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
markThreadRead,
markMessageRead,
markDoneLocal,
@@ -182,9 +211,22 @@ export function useHomeInboxReadState({
(itemId: string) => {
const item = itemById.get(itemId);
const localUnreadIds = item ? getGroupedInboxItemIds(item) : [itemId];
const clearedItemIds = new Set(localUnreadIds);
for (const id of localUnreadIds) {
undoUnreadLocal(id);
}
const channelId = item?.item.channelId ?? null;
if (
channelId &&
!hasRemainingChannelUnreadOverride(
items,
localUnreadSet,
channelId,
clearedItemIds,
)
) {
clearChannelUnreadSource(channelId, "inbox");
}
const threadRootId = item ? getInboxThreadRootId(item) : null;
if (item && threadRootId) {
const markedReplyIds = new Set<string>();
@@ -201,16 +243,17 @@ export function useHomeInboxReadState({
markChannelRead(
groupedChannelRead.channelId,
new Date(groupedChannelRead.timestamp * 1_000).toISOString(),
{ preserveForcedUnread: true, topLevelOnly: true },
);
}
return;
}
const channelId = item?.item.channelId ?? null;
if (item && channelId) {
markChannelRead(
channelId,
new Date(item.latestActivityAt * 1_000).toISOString(),
{ preserveForcedUnread: true },
);
return;
}
@@ -218,6 +261,9 @@ export function useHomeInboxReadState({
},
[
itemById,
items,
clearChannelUnreadSource,
localUnreadSet,
markChannelRead,
markDoneLocal,
markMessageRead,
@@ -230,8 +276,17 @@ export function useHomeInboxReadState({
(itemId: string) => {
undoDoneLocal(itemId);
markUnreadLocal(itemId);
const item = itemById.get(itemId);
const channelId = item?.item.channelId ?? null;
// The Inbox override owns the durable thread dot, while the channel force
// restores timeline-level emphasis after the user leaves the channel.
// Opening the channel clears the bolding but leaves the thread dot until
// the thread itself is opened or marked read.
if (channelId && item) {
markChannelUnread(channelId, "inbox");
}
},
[markUnreadLocal, undoDoneLocal],
[itemById, markChannelUnread, markUnreadLocal, undoDoneLocal],
);
return { effectiveDoneSet, markItemRead, markItemUnread };

View File

@@ -2,7 +2,10 @@ import * as React from "react";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { useWorkingChannels } from "@/features/agents/agentWorkingSignal";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function resolveActiveWorkingChannelNames(
@@ -27,10 +30,15 @@ export function useActiveWorkingChannelsById(): ReadonlyMap<
ActiveChannelTurnSummary
> {
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgents = React.useMemo(
() => managedAgentsQuery.data ?? [],
[managedAgentsQuery.data],
);
const namedAgents = React.useMemo(
() => [...(relayAgentsQuery.data ?? []), ...managedAgents],
[managedAgents, relayAgentsQuery.data],
);
// Unified working signal: observer-derived turns primary, bot typing as
// fallback — so the sidebar badge appears even for agents whose observer
@@ -42,11 +50,11 @@ export function useActiveWorkingChannelsById(): ReadonlyMap<
activeWorkingChannels.map((summary) => {
const resolvedSummary = resolveActiveWorkingChannelNames(
summary,
managedAgents,
namedAgents,
);
return [resolvedSummary.channelId, resolvedSummary];
}),
),
[activeWorkingChannels, managedAgents],
[activeWorkingChannels, namedAgents],
);
}

View File

@@ -0,0 +1,448 @@
import * as React from "react";
import { Clock, Loader2, MailOpen } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { buildInboxItems, type InboxItem } from "@/features/home/lib/inbox";
import { getGroupedInboxItemIds } from "@/features/home/useHomeInboxReadState";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useNow } from "@/shared/lib/useNow";
import { Markdown } from "@/shared/ui/markdown";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const HOVER_OPEN_DELAY_MS = 250;
const HOVER_CLOSE_DELAY_MS = 180;
function buildChannelActivityFeed(items: FeedItem[]): HomeFeedResponse {
return {
feed: {
mentions: items.filter((item) => item.category === "mention"),
needsAction: items.filter((item) => item.category === "needs_action"),
activity: items.filter(
(item) =>
item.category !== "mention" &&
item.category !== "needs_action" &&
item.category !== "agent_activity",
),
agentActivity: items.filter((item) => item.category === "agent_activity"),
},
meta: {
since: 0,
total: items.length,
generatedAt: 0,
},
};
}
function RowActionButton({
children,
label,
onClick,
}: {
children: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
aria-label={label}
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-4"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClick();
}}
title={label}
type="button"
>
{children}
</button>
);
}
function ThreadPreviewRow({
item,
onMarkRead,
onOpen,
onRemindLater,
}: {
item: InboxItem;
onMarkRead: () => void;
onOpen: () => void;
onRemindLater: () => void;
}) {
return (
<div
className="group/activity-row relative border-t border-border/50 first:border-t-0"
data-testid={`channel-activity-item-${item.conversationId}`}
>
<button
aria-label={`Open thread from ${item.senderLabel}`}
className="absolute inset-0 z-0 w-full text-left"
onClick={onOpen}
type="button"
/>
<div className="pointer-events-none relative z-10 flex min-w-0 items-start gap-2.5 px-3 py-3 transition-colors group-hover/activity-row:bg-muted/50 group-focus-within/activity-row:bg-muted/50">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-9 w-9 shrink-0"
displayName={item.senderLabel}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-4 text-foreground">
{item.senderLabel}
</span>
<span className="shrink-0 text-xs leading-4 text-muted-foreground/70 transition-opacity group-hover/activity-row:opacity-0 group-focus-within/activity-row:opacity-0">
{item.timestampLabel}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs leading-4 text-muted-foreground">
<span>Thread</span>
{item.unreadCount > 1 ? (
<>
<span aria-hidden="true">·</span>
<span>{item.unreadCount} unread</span>
</>
) : null}
</div>
<Markdown
className="inbox-preview-markdown mt-1.5 line-clamp-2 text-sm font-medium leading-5 text-foreground"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}
/>
</div>
</div>
<div className="pointer-events-none absolute right-2 top-2 z-20 flex items-center gap-0.5 rounded-full bg-muted/95 p-0.5 opacity-0 shadow-xs transition-opacity group-hover/activity-row:pointer-events-auto group-hover/activity-row:opacity-100 group-focus-within/activity-row:pointer-events-auto group-focus-within/activity-row:opacity-100">
<RowActionButton label="Mark as read" onClick={onMarkRead}>
<MailOpen />
</RowActionButton>
<RowActionButton label="Remind me later" onClick={onRemindLater}>
<Clock />
</RowActionButton>
</div>
</div>
);
}
function WorkingAgentRow({
avatarUrl,
elapsed,
name,
onOpen,
pubkey,
}: {
avatarUrl: string | null;
elapsed: string;
name: string;
onOpen: () => void;
pubkey: string;
}) {
return (
<button
className="flex w-full min-w-0 items-start gap-2.5 border-t border-border/50 px-3 py-3 text-left transition-colors first:border-t-0 hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-hidden"
data-testid={`channel-activity-agent-${pubkey}`}
onClick={onOpen}
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
className="h-9 w-9 shrink-0"
displayName={name}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-4 text-foreground">
{name}
</span>
<span className="shrink-0 text-xs leading-4 text-muted-foreground/70">
{elapsed}
</span>
</div>
<span className="mt-0.5 flex items-center gap-1.5 text-xs leading-4 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary/70" />
Working
</span>
</div>
</button>
);
}
function WorkingAgentRows({
activeWorking,
channelId,
onOpen,
profiles,
}: {
activeWorking: ActiveChannelTurnSummary;
channelId: string;
onOpen: (pubkey: string, channelId: string) => void;
profiles?: UserProfileLookup;
}) {
const now = useNow(1000);
const elapsed = formatElapsed(now - activeWorking.anchorAt);
const alignedAgentNames =
activeWorking.agentNames?.length === activeWorking.agentPubkeys.length
? activeWorking.agentNames
: null;
return activeWorking.agentPubkeys.map((pubkey, index) => {
const profile = profiles?.[normalizePubkey(pubkey)];
const name =
profile?.displayName?.trim() ||
alignedAgentNames?.[index] ||
`Agent ${truncatePubkey(pubkey)}`;
return (
<WorkingAgentRow
avatarUrl={profile?.avatarUrl ?? null}
elapsed={elapsed}
key={pubkey}
name={name}
onOpen={() => onOpen(pubkey, channelId)}
pubkey={pubkey}
/>
);
});
}
export function ChannelActivityPopover({
activeWorking,
channel,
children,
}: {
activeWorking?: ActiveChannelTurnSummary;
channel: Channel;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const {
clearChannelUnreadSource,
getChannelActivityItemReadAt,
locallyUnreadFeedItems,
markMessageRead,
markThreadRead,
unreadThreadFeedItems,
feedItemState,
} = useAppShell();
const { undoUnread } = feedItemState;
const identityQuery = useIdentityQuery();
const { goChannel } = useAppNavigation();
const { openAgentActivity } = useOpenAgentActivity();
const { openReminder } = useRemindLater();
const unreadChannelFeedItems = React.useMemo(() => {
return unreadThreadFeedItems.filter(
(item) => item.channelId === channel.id,
);
}, [channel.id, unreadThreadFeedItems]);
const profilePubkeys = React.useMemo(
() => [
...new Set([
...unreadChannelFeedItems.map((item) => item.pubkey),
...(activeWorking?.agentPubkeys ?? []),
]),
],
[activeWorking?.agentPubkeys, unreadChannelFeedItems],
);
const profilesQuery = useUsersBatchQuery(open ? profilePubkeys : [], {
enabled: open,
});
const profiles = profilesQuery.data?.profiles;
const activityReadAtByMessageId = React.useMemo(
() =>
new Map(
unreadChannelFeedItems.map((item) => [
item.id,
getChannelActivityItemReadAt(item),
]),
),
[getChannelActivityItemReadAt, unreadChannelFeedItems],
);
const activityItems = React.useMemo(() => {
if (!open) return [];
return buildInboxItems({
channels: [channel],
currentPubkey: identityQuery.data?.pubkey,
feed: buildChannelActivityFeed(unreadChannelFeedItems),
getMessageReadAt: (messageId) =>
activityReadAtByMessageId.get(messageId) ?? null,
profiles,
});
}, [
channel,
activityReadAtByMessageId,
identityQuery.data?.pubkey,
open,
profiles,
unreadChannelFeedItems,
]);
const hasContent =
unreadChannelFeedItems.length > 0 ||
(activeWorking?.agentPubkeys.length ?? 0) > 0;
const clearHoverTimer = React.useCallback(() => {
if (hoverTimerRef.current !== null) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
}, []);
const openWithDelay = React.useCallback(() => {
if (!hasContent) return;
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer, hasContent]);
const openImmediately = React.useCallback(() => {
if (!hasContent) return;
clearHoverTimer();
setOpen(true);
}, [clearHoverTimer, hasContent]);
const closeWithDelay = React.useCallback(() => {
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(false);
}, HOVER_CLOSE_DELAY_MS);
}, [clearHoverTimer]);
const keepOpen = React.useCallback(() => {
clearHoverTimer();
}, [clearHoverTimer]);
React.useEffect(() => () => clearHoverTimer(), [clearHoverTimer]);
React.useEffect(() => {
if (!hasContent) {
setOpen(false);
}
}, [hasContent]);
const clearUnreadOverride = React.useCallback(
(item: InboxItem) => {
const clearedItemIds = new Set(getGroupedInboxItemIds(item));
for (const itemId of clearedItemIds) {
undoUnread(itemId);
}
const channelId = item.item.channelId ?? null;
const hasAnotherChannelOverride = locallyUnreadFeedItems.some(
(feedItem) =>
feedItem.channelId === channelId && !clearedItemIds.has(feedItem.id),
);
if (channelId && !hasAnotherChannelOverride) {
clearChannelUnreadSource(channelId, "inbox");
}
},
[clearChannelUnreadSource, locallyUnreadFeedItems, undoUnread],
);
const handleMarkRead = React.useCallback(
(item: InboxItem) => {
clearUnreadOverride(item);
for (const reply of item.groupItems) {
markMessageRead(reply.id, reply.createdAt);
}
markThreadRead(item.conversationId, item.latestActivityAt);
},
[clearUnreadOverride, markMessageRead, markThreadRead],
);
if (!hasContent) {
return children;
}
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while this wrapper supplies the popover anchor box. */}
<div
className="w-full min-w-0"
onBlur={closeWithDelay}
onContextMenu={() => setOpen(false)}
onFocus={openImmediately}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
>
{children}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-96 overflow-hidden p-0"
data-testid={`channel-activity-popover-${channel.name}`}
onFocusCapture={keepOpen}
onMouseEnter={keepOpen}
onMouseLeave={closeWithDelay}
onOpenAutoFocus={(event) => event.preventDefault()}
side="right"
sideOffset={8}
>
<section
aria-label="Channel activity"
className="flex max-h-96 min-h-0 flex-col overflow-hidden"
>
<h3
className="relative z-20 shrink-0 border-b border-border/70 bg-background/95 px-3 py-2 text-sm font-semibold text-foreground backdrop-blur-md supports-[backdrop-filter]:bg-background/90"
data-testid="channel-activity-header"
>
Channel activity
</h3>
<div
className="buzz-channel-activity-scrollbar min-h-0 overflow-y-auto overscroll-contain"
data-testid="channel-activity-scroll"
>
{activeWorking?.agentPubkeys.length ? (
<WorkingAgentRows
activeWorking={activeWorking}
channelId={channel.id}
onOpen={(pubkey, channelId) => {
setOpen(false);
openAgentActivity(pubkey, { channelId });
}}
profiles={profiles}
/>
) : null}
{activityItems.length > 0
? activityItems.map((item) => (
<ThreadPreviewRow
item={item}
key={item.conversationId}
onMarkRead={() => handleMarkRead(item)}
onOpen={() => {
clearUnreadOverride(item);
setOpen(false);
void goChannel(channel.id, {
messageId: item.id,
threadRootId: item.conversationId,
});
}}
onRemindLater={() => {
setOpen(false);
openReminder({
authorPubkey: item.item.pubkey,
channelId: channel.id,
eventId: item.id,
preview: item.preview.slice(0, 100),
});
}}
/>
))
: null}
</div>
</section>
</PopoverContent>
</Popover>
);
}

View File

@@ -15,6 +15,7 @@ import {
TriangleAlert,
} from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import {
useArchiveChannelMutation,
useChannelMembersQuery,
@@ -178,6 +179,22 @@ export function ChannelContextMenuItems({
onDeleteChannel?: (channel: Channel) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const {
feedItemState,
hasSidebarUnreadProjections,
locallyUnreadFeedItems,
unreadThreadChannelIds,
} = useAppShell();
const channelUnreadOverrideIds = locallyUnreadFeedItems.flatMap((item) =>
item.channelId === channel.id && feedItemState.unreadSet.has(item.id)
? [item.id]
: [],
);
const hasProjectedUnread =
hasUnread ||
(channel.channelType !== "dm" &&
hasSidebarUnreadProjections &&
unreadThreadChannelIds.has(channel.id));
const canLoadOwnerActions =
channel.channelType !== "dm" && Boolean(onDeleteChannel);
const membersQuery = useChannelMembersQuery(channel.id, canLoadOwnerActions);
@@ -204,7 +221,7 @@ export function ChannelContextMenuItems({
canDeleteChannel,
);
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
const showReadToggle = hasProjectedUnread
? Boolean(onMarkChannelRead)
: Boolean(onMarkChannelUnread);
const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel);
@@ -230,12 +247,15 @@ export function ChannelContextMenuItems({
/>
) : null}
{showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
{hasProjectedUnread && onMarkChannelRead ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
onMarkChannelRead(channel.id, channel.lastMessageAt),
)
deferMenuAction(() => {
for (const itemId of channelUnreadOverrideIds) {
feedItemState.undoUnread(itemId);
}
onMarkChannelRead(channel.id, channel.lastMessageAt);
})
}
>
<ContextMenuIconSlot>
@@ -243,7 +263,7 @@ export function ChannelContextMenuItems({
</ContextMenuIconSlot>
<span>Mark as read</span>
</ContextMenuItem>
) : !hasUnread && onMarkChannelUnread ? (
) : !hasProjectedUnread && onMarkChannelUnread ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() => onMarkChannelUnread(channel.id))

View File

@@ -36,6 +36,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { ChannelActivityPopover } from "@/features/sidebar/ui/ChannelActivityPopover";
import { useAppShell } from "@/app/AppShellContext";
const SECTION_LABEL_BUTTON_CLASS =
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
@@ -138,7 +140,8 @@ function ChannelWorkingBadge({
return (
<span
className={cn(
"hidden max-w-32 shrink-0 truncate rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums motion-safe:animate-pulse group-data-[collapsible=icon]:hidden sm:inline-flex",
"max-w-32 shrink-0 truncate rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums motion-safe:animate-pulse group-data-[collapsible=icon]:hidden",
"hidden sm:inline-flex",
isActive
? "bg-sidebar-active-foreground/20 text-sidebar-active-foreground"
: "bg-primary/10 text-primary",
@@ -250,7 +253,6 @@ export function ChannelMenuButton({
label,
isActive,
hasUnread,
unreadCount = 0,
activeWorking,
isMuted,
dmParticipants,
@@ -270,17 +272,37 @@ export function ChannelMenuButton({
}) {
const resolvedLabel = label ?? channel.name;
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
const {
hasSidebarUnreadProjections,
topLevelUnreadChannelIds,
unreadThreadChannelIds,
} = useAppShell();
const hasTopLevelUnread =
channel.channelType === "dm"
? hasUnread
: hasSidebarUnreadProjections
? topLevelUnreadChannelIds.has(channel.id)
: hasUnread;
const hasThreadUnread =
channel.channelType !== "dm" &&
(hasSidebarUnreadProjections
? unreadThreadChannelIds.has(channel.id)
: hasUnread);
return (
const button = (
<SidebarMenuButton
className={cn(
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-accent-foreground",
!isActive &&
hasUnread &&
hasTopLevelUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
!isActive && isMuted && !hasUnread && "opacity-50",
!isActive &&
isMuted &&
!hasTopLevelUnread &&
!hasThreadUnread &&
"opacity-50",
)}
data-channel-id={channel.id}
data-testid={`channel-${channel.name}`}
@@ -321,19 +343,21 @@ export function ChannelMenuButton({
)}
/>
) : null}
{hasUnread && !isActive && channel.channelType !== "dm" ? (
unreadCount > 0 ? (
<UnreadCountBadge
channelName={channel.name}
className="ml-auto"
count={unreadCount}
/>
) : (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
)
{hasThreadUnread ? (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
) : null}
</SidebarMenuButton>
);
if (!activeWorking && !hasThreadUnread) {
return button;
}
return (
<ChannelActivityPopover activeWorking={activeWorking} channel={channel}>
{button}
</ChannelActivityPopover>
);
}
export function SidebarSection({

View File

@@ -33,6 +33,27 @@
);
}
/* Channel activity keeps its chrome outside the scrolling surface. The slim,
* low-contrast thumb belongs only to the activity list beneath the header. */
.buzz-channel-activity-scrollbar {
scrollbar-gutter: stable;
}
.buzz-channel-activity-scrollbar::-webkit-scrollbar {
width: 8px;
}
.buzz-channel-activity-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.buzz-channel-activity-scrollbar::-webkit-scrollbar-thumb {
background-clip: content-box;
background-color: hsl(var(--foreground) / 0.15);
border: 2px solid transparent;
border-radius: 999px;
}
/*
* Main-content scroll areas that have sticky blurred chrome inside them.
* Native overlay scrollbars paint UNDER composited descendants

View File

@@ -105,11 +105,11 @@ test("regular message bolds inactive channel without numeric badge", async ({
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withDotOnlyBadge(baselineBadge));
});
test("numeric badge increments for @mention in inactive channel", async ({
test("top-level @mention bolds the channel without a row badge", async ({
page,
}) => {
await page.goto("/");
@@ -134,7 +134,11 @@ test("numeric badge increments for @mention in inactive channel", async ({
},
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
@@ -158,7 +162,7 @@ test("numeric badge increments for DM message", async ({ page }) => {
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("numeric badge increments for interested thread reply in inactive channel", async ({
test("interested thread reply shows the channel thread dot", async ({
page,
}) => {
await page.goto("/");
@@ -190,11 +194,12 @@ test("numeric badge increments for interested thread reply in inactive channel",
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("numeric badge increments for broadcast reply in inactive channel", async ({
test("broadcast reply bolds the channel without a thread dot", async ({
page,
}) => {
await page.goto("/");
@@ -219,7 +224,12 @@ test("numeric badge increments for broadcast reply in inactive channel", async (
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
@@ -265,9 +275,7 @@ test("mark-as-read via context menu clears channel unread indicator", async ({
await waitForBadgeState(page, baselineBadge);
});
test("mark-as-unread via context menu increments numeric badge", async ({
page,
}) => {
test("mark-as-unread via context menu bolds the channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -278,10 +286,55 @@ test("mark-as-unread via context menu increments numeric badge", async ({
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("marking a message unread bolds its channel after leaving", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random");
const message = await page.evaluate(
({ pubkey }) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Keep this channel message unread",
kind: 40002,
pubkey,
}),
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
if (!message) {
throw new Error("Mock message emitter is unavailable");
}
const messageRow = page
.getByTestId("message-row")
.filter({ hasText: "Keep this channel message unread" });
await expect(messageRow).toBeVisible();
await messageRow.hover();
await page.getByTestId(`more-actions-${message.id}`).click();
const toggle = page.getByTestId(`mark-read-toggle-${message.id}`);
await expect(toggle).toHaveText("Mark unread");
await toggle.click();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
});
test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({
page,
}) => {
@@ -379,11 +432,15 @@ test("remote read-state rollback is ignored while local mark-unread still increm
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
// Local mark-unread remains an in-session affordance and should still show
// the dot immediately without publishing a lower read timestamp.
// Local mark-unread remains an in-session affordance and should still bold
// the channel immediately without publishing a lower read timestamp.
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
// Step 3: remote advance clears the local forced-unread dot.

View File

@@ -0,0 +1,795 @@
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 },
);
}
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);
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");
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("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",
"600",
);
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",
"600",
);
}
}
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"600",
);
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",
"600",
);
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",
"600",
);
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",
"600",
);
});
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",
"600",
);
});
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",
"600",
);
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",
"600",
);
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: /^React with / })
.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.",
);
});
});

View File

@@ -85,7 +85,7 @@ test.describe("channel muting", () => {
await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
});
test("03 — muted channel with @mention shows unread dot", async ({
test("03 — muted channel with a top-level @mention is emphasized", async ({
page,
}) => {
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
@@ -123,7 +123,13 @@ test.describe("channel muting", () => {
},
);
await expect(page.getByTestId("channel-unread-engineering")).toBeVisible();
await expect(page.getByTestId("channel-engineering")).toHaveCSS(
"font-weight",
"600",
);
await expect(
page.getByTestId("channel-unread-dot-engineering"),
).toHaveCount(0);
});
test("04 — context menu shows Unmute channel when muted", async ({

View File

@@ -711,7 +711,7 @@ test.describe("thread unread indicator", () => {
await expect(badge).toContainText("3");
});
// Thread-only replies now also light the channel sidebar badge. Viewing the
// Thread-only replies now also light the channel sidebar dot. Viewing the
// channel should leave unopened thread replies unread until the thread is read.
test("11-thread-reply-lights-sidebar-badge-after-channel-view", async ({
page,
@@ -748,15 +748,15 @@ test.describe("thread unread indicator", () => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The crux: leave general. The unopened thread reply should still keep a
// numeric channel sidebar badge until the thread itself is read.
// channel sidebar dot until the thread itself is read.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("channel-unread-general")).toBeVisible();
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
});
// Regression guard for the all-replies window: when the loaded window holds
// ONLY thread replies (the top-level root has scrolled past the history
// limit), thread-only activity should still light the channel sidebar badge.
// limit), thread-only activity should still light the channel sidebar dot.
//
// The `all-replies` fixture carries a far-future `lastMessageAt` (standing in
// for the backend's reply-inclusive MAX) with no top-level message in its
@@ -768,8 +768,7 @@ test.describe("thread unread indicator", () => {
// Emit ONE reply whose parent root is NOT in the window (orphan parent id),
// so the loaded window is all-replies: no top-level message exists for
// `latestActiveMessage` to find. The reply mentions the current user so it
// clears the notify gate and creates Inbox activity without lighting the
// channel sidebar dot.
// clears the notify gate and creates Inbox thread activity.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "all-replies");
@@ -779,17 +778,21 @@ test.describe("thread unread indicator", () => {
mentionPubkeys: [SELF_PUBKEY],
createdAt: unreadTimestamp(),
});
await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0);
await expect(
page.getByTestId("channel-unread-dot-all-replies"),
).toBeVisible();
// View all-replies while the reply is unread.
await page.getByTestId("channel-all-replies").click();
await expect(page.getByTestId("chat-title")).toHaveText("all-replies");
// The crux: leave the channel. Its unopened thread reply should still keep
// a numeric channel sidebar badge until the thread itself is read.
// a channel sidebar dot until the thread itself is read.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
await expect(
page.getByTestId("channel-unread-dot-all-replies"),
).toBeVisible();
});
// Regression guard for BUG-2 (clear-on-read): opening an unread thread marks

View File

@@ -198,9 +198,12 @@ test.describe("unread pill & divider", () => {
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
// The unread indicator only renders on inactive channels, so it appears
// once general is no longer the active channel.
await expect(page.getByTestId("channel-unread-general")).toBeVisible();
// Forced channel unread uses channel-name emphasis, not the thread dot.
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");