mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
30
desktop/src/app/useChannelActivityProjection.test.mjs
Normal file
30
desktop/src/app/useChannelActivityProjection.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
145
desktop/src/app/useChannelActivityProjection.ts
Normal file
145
desktop/src/app/useChannelActivityProjection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
desktop/src/features/channels/forcedUnreadStore.test.mjs
Normal file
55
desktop/src/features/channels/forcedUnreadStore.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
45
desktop/src/features/channels/ui/useChannelOpenReadState.ts
Normal file
45
desktop/src/features/channels/ui/useChannelOpenReadState.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
448
desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
Normal file
448
desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
795
desktop/tests/e2e/channel-activity-popover.spec.ts
Normal file
795
desktop/tests/e2e/channel-activity-popover.spec.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user