Files
buzz/desktop/src/app/useChannelActivityProjection.ts
klopez4212 b0c6d6f744 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>
2026-08-03 09:26:06 -07:00

146 lines
4.3 KiB
TypeScript

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,
};
}