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:
@@ -4,10 +4,14 @@ import { notFound } from "next/navigation";
|
||||
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { meshTopic } from "@turbostarter/db/schema/mesh";
|
||||
import {
|
||||
meshTopic,
|
||||
meshTopicMember,
|
||||
meshTopicMessage,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
import { and, asc, count, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/server";
|
||||
@@ -52,6 +56,43 @@ export default async function MeshPage({
|
||||
.where(and(eq(meshTopic.meshId, id), isNull(meshTopic.archivedAt)))
|
||||
.orderBy(asc(meshTopic.name));
|
||||
|
||||
// Unread counts per topic for the viewing member. Skips messages the
|
||||
// viewer themselves sent (no point flagging "you" as unread). Topics
|
||||
// the viewer hasn't joined fall through to last_read_at = epoch via
|
||||
// the LEFT JOIN, which counts every message — that's the correct
|
||||
// first-visit behaviour for owner-created topics they haven't opened.
|
||||
const myMemberId = members.find((m) => m.isMe)?.id;
|
||||
const unreadByTopic = new Map<string, number>();
|
||||
if (myMemberId && topics.length > 0) {
|
||||
const counts = await db
|
||||
.select({
|
||||
topicId: meshTopicMessage.topicId,
|
||||
unread: count(meshTopicMessage.id),
|
||||
})
|
||||
.from(meshTopicMessage)
|
||||
.leftJoin(
|
||||
meshTopicMember,
|
||||
and(
|
||||
eq(meshTopicMember.topicId, meshTopicMessage.topicId),
|
||||
eq(meshTopicMember.memberId, myMemberId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
sql`${meshTopicMessage.topicId} = ANY(${topics.map((t) => t.id)})`,
|
||||
ne(meshTopicMessage.senderMemberId, myMemberId),
|
||||
or(
|
||||
isNull(meshTopicMember.lastReadAt),
|
||||
sql`${meshTopicMessage.createdAt} > ${meshTopicMember.lastReadAt}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(meshTopicMessage.topicId);
|
||||
for (const row of counts) {
|
||||
unreadByTopic.set(row.topicId, Number(row.unread));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader>
|
||||
@@ -166,7 +207,9 @@ export default async function MeshPage({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{topics.map((t) => (
|
||||
{topics.map((t) => {
|
||||
const unread = unreadByTopic.get(t.id) ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={pathsConfig.dashboard.user.meshes.topic(mesh.id, t.name)}
|
||||
@@ -180,6 +223,15 @@ export default async function MeshPage({
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t.visibility}
|
||||
</Badge>
|
||||
{unread > 0 ? (
|
||||
<span
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-[var(--cm-clay)] px-1.5 text-[10px] font-medium text-white"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
aria-label={`${unread} unread`}
|
||||
>
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{t.description ? (
|
||||
@@ -195,7 +247,8 @@ export default async function MeshPage({
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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