feat(api+web): unread counts per topic + PATCH /read mark-as-read
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-05-02 19:06:01 +01:00
parent 7e71a61db4
commit a80eb6fcca
4 changed files with 175 additions and 7 deletions

View File

@@ -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({