feat(api+web): unread counts per topic + PATCH /read mark-as-read
PATCH /v1/topics/:name/read upserts topic_member.last_read_at for the api key's issuing member. The chat panel calls it on mount and on every inbound SSE message (5s debounce so we don't hammer it). GET /v1/topics now returns unread per topic — counts messages newer than last_read_at and not authored by the viewer. Mesh detail page shows a clay-rounded badge next to each topic name with the count (99+ ceiling). AuthedApiKey gains issuedByMemberId so endpoints can attribute side-effects to the minting member. Required because external api keys aren't tied to a specific peer member; only dashboard- and CLI-minted keys carry one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,7 @@ export function TopicChatPanel({
|
||||
const [lastEventAt, setLastEventAt] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastMarkReadAtRef = useRef<number>(0);
|
||||
|
||||
const headers = useMemo(
|
||||
() => ({
|
||||
@@ -140,6 +141,22 @@ export function TopicChatPanel({
|
||||
[apiKeySecret],
|
||||
);
|
||||
|
||||
// Mark the topic read up to now, but at most once per 5 seconds —
|
||||
// we'd otherwise hit /read on every inbound SSE message which is
|
||||
// wasteful (the wall-clock watermark advances either way).
|
||||
const markRead = useCallback(async () => {
|
||||
if (Date.now() - lastMarkReadAtRef.current < 5000) return;
|
||||
lastMarkReadAtRef.current = Date.now();
|
||||
try {
|
||||
await fetch(`/api/v1/topics/${encodeURIComponent(topicName)}/read`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
// Soft-fail — unread counts are advisory.
|
||||
}
|
||||
}, [headers, topicName]);
|
||||
|
||||
// One-shot history backfill on mount; the SSE stream is forward-only,
|
||||
// so any messages older than connect-time come from this fetch.
|
||||
const loadHistory = useCallback(async () => {
|
||||
@@ -164,7 +181,8 @@ export function TopicChatPanel({
|
||||
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
void markRead();
|
||||
}, [loadHistory, markRead]);
|
||||
|
||||
// SSE subscription with auto-reconnect. AbortController unwinds the
|
||||
// stream when the component unmounts or the topic/key changes.
|
||||
@@ -212,6 +230,7 @@ export function TopicChatPanel({
|
||||
if (seenIdsRef.current.has(m.id)) continue;
|
||||
seenIdsRef.current.add(m.id);
|
||||
setMessages((cur) => [...cur, m]);
|
||||
void markRead();
|
||||
} catch {
|
||||
// Drop malformed events silently — heartbeat-as-message
|
||||
// happens once per misconfigured proxy.
|
||||
@@ -236,7 +255,7 @@ export function TopicChatPanel({
|
||||
setStreamState("stopped");
|
||||
ctl.abort();
|
||||
};
|
||||
}, [apiKeySecret, topicName]);
|
||||
}, [apiKeySecret, topicName, markRead]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
|
||||
Reference in New Issue
Block a user