Files
buzz/desktop/src/features/home/useHomeInboxReadState.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

294 lines
9.3 KiB
TypeScript

import * as React from "react";
import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore";
import type { InboxItem } from "@/features/home/lib/inbox";
import {
getThreadReference,
isThreadReply,
} from "@/features/messages/lib/threading";
type UseHomeInboxReadStateOptions = {
/** Inbox items to project read-state across. */
items: InboxItem[];
/** NIP-RS read marker resolver for channel-backed items (unix seconds, or null when unknown). */
getChannelReadAt: (channelId: string) => number | null;
/** NIP-RS read marker resolver for thread-backed items (unix seconds, or null when unknown). */
getThreadReadAt: (rootId: string, channelId?: string | null) => number | null;
/** NIP-RS read marker resolver for per-message items (unix seconds, or null when unknown). */
getMessageReadAt?: (messageId: string) => number | null;
/** Invalidation signal for the channel-marker projection. */
readStateVersion: number;
/** Local fallback "done" set (used only for items with no channelId). */
localDoneSet: ReadonlySet<string>;
/** Per-item local unread override for inbox rows. */
localUnreadSet: ReadonlySet<string>;
/** Mark a channel read up to the given ISO timestamp (NIP-RS). */
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;
/** Advance a reply's per-message read marker to the given unix-seconds timestamp. */
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 alongside its channel emphasis. */
markUnreadLocal: (id: string) => void;
/** Local fallback: undo a non-channel item done. */
undoDoneLocal: (id: string) => void;
/** Clear the local inbox row unread override. */
undoUnreadLocal: (id: string) => void;
};
const EMPTY_ITEM_SET: ReadonlySet<string> = new Set();
function getInboxThreadRootId(item: InboxItem): string | null {
if (!isThreadReply(item.item.tags)) {
return null;
}
const thread = getThreadReference(item.item.tags);
return thread.rootId ?? thread.parentId;
}
export function getGroupedChannelReadTimestamp(
item: InboxItem,
): { channelId: string; timestamp: number } | null {
const channelId = item.item.channelId ?? null;
if (!channelId) {
return null;
}
let timestamp: number | null = null;
for (const groupItem of item.groupItems) {
if (groupItem.channelId !== channelId || isThreadReply(groupItem.tags)) {
continue;
}
timestamp = Math.max(timestamp ?? 0, groupItem.createdAt);
}
return timestamp === null ? null : { channelId, timestamp };
}
export function getGroupedInboxItemIds(item: InboxItem): string[] {
return [
...new Set([item.id, item.item.id, ...item.groupItems.map((i) => i.id)]),
];
}
export function hasGroupedUnreadOverride(
item: InboxItem,
localUnreadSet: ReadonlySet<string>,
): boolean {
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: {
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt?: (messageId: string) => number | null;
getThreadReadAt: (
rootId: string,
channelId?: string | null,
) => number | null;
},
): number | null {
const channelId = item.item.channelId;
const threadRootId = getInboxThreadRootId(item);
if (threadRootId) {
if (options.getMessageReadAt) {
return options.getMessageReadAt(item.item.id);
}
return options.getThreadReadAt(threadRootId, channelId);
}
return channelId ? options.getChannelReadAt(channelId) : null;
}
/**
* Projects Home inbox read-state from the shared NIP-RS read marker, with
* the local `useFeedItemState` done-set as a fallback for items that don't
* belong to a channel (reminders etc.).
*
* "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" 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,
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
readStateVersion,
localDoneSet,
localUnreadSet = EMPTY_ITEM_SET,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
markThreadRead,
markMessageRead,
markDoneLocal,
markUnreadLocal,
undoDoneLocal,
undoUnreadLocal,
}: UseHomeInboxReadStateOptions) {
const itemById = React.useMemo(
() => new Map(items.map((item) => [item.id, item])),
[items],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt
const effectiveDoneSet = React.useMemo<ReadonlySet<string>>(() => {
const result = new Set<string>();
for (const item of items) {
if (hasGroupedUnreadOverride(item, localUnreadSet)) {
continue;
}
const threadRootId = getInboxThreadRootId(item);
const readAt = resolveInboxItemReadAt(item, {
getChannelReadAt,
getMessageReadAt,
getThreadReadAt,
});
if (readAt !== null) {
if (item.latestActivityAt <= readAt) {
result.add(item.id);
}
continue;
}
if (threadRootId !== null || item.item.channelId) {
continue;
}
if (localDoneSet.has(item.id)) {
result.add(item.id);
}
}
return result;
}, [
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
items,
localDoneSet,
localUnreadSet,
readStateVersion,
]);
const markItemRead = React.useCallback(
(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>();
for (const reply of [item.item, ...item.groupItems]) {
if (!isThreadReply(reply.tags) || markedReplyIds.has(reply.id)) {
continue;
}
markedReplyIds.add(reply.id);
markMessageRead(reply.id, reply.createdAt);
}
markThreadRead(threadRootId, item.latestActivityAt);
const groupedChannelRead = getGroupedChannelReadTimestamp(item);
if (groupedChannelRead) {
markChannelRead(
groupedChannelRead.channelId,
new Date(groupedChannelRead.timestamp * 1_000).toISOString(),
{ preserveForcedUnread: true, topLevelOnly: true },
);
}
return;
}
if (item && channelId) {
markChannelRead(
channelId,
new Date(item.latestActivityAt * 1_000).toISOString(),
{ preserveForcedUnread: true },
);
return;
}
markDoneLocal(itemId);
},
[
itemById,
items,
clearChannelUnreadSource,
localUnreadSet,
markChannelRead,
markDoneLocal,
markMessageRead,
markThreadRead,
undoUnreadLocal,
],
);
const markItemUnread = React.useCallback(
(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");
}
},
[itemById, markChannelUnread, markUnreadLocal, undoDoneLocal],
);
return { effectiveDoneSet, markItemRead, markItemUnread };
}