mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
## 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>
294 lines
9.3 KiB
TypeScript
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 };
|
|
}
|