feat(web): unread badge on dashboard mesh cards
Universe page aggregates unread topic_message rows per mesh for the viewing user. Counts messages newer than topic_member.last_read_at (or all messages if the viewer never opened the topic) and excludes anything the viewer authored. One JOIN-grouped query, not N+1. Mesh card surfaces the count as a clay-rounded badge to the left of the role chip — matches the per-topic badge style on the mesh detail page so unread is the same visual idiom across the dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,13 @@ import {
|
|||||||
} from "@turbostarter/api/schema";
|
} from "@turbostarter/api/schema";
|
||||||
import { handle } from "@turbostarter/api/utils";
|
import { handle } from "@turbostarter/api/utils";
|
||||||
import { db } from "@turbostarter/db/server";
|
import { db } from "@turbostarter/db/server";
|
||||||
import { meshTopic } from "@turbostarter/db/schema/mesh";
|
import {
|
||||||
import { and, count, inArray, isNull } from "drizzle-orm";
|
meshMember,
|
||||||
|
meshTopic,
|
||||||
|
meshTopicMember,
|
||||||
|
meshTopicMessage,
|
||||||
|
} from "@turbostarter/db/schema/mesh";
|
||||||
|
import { and, count, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import { appConfig } from "~/config/app";
|
import { appConfig } from "~/config/app";
|
||||||
import { pathsConfig } from "~/config/paths";
|
import { pathsConfig } from "~/config/paths";
|
||||||
@@ -58,9 +63,61 @@ export default async function UniversePage() {
|
|||||||
.groupBy(meshTopic.meshId)
|
.groupBy(meshTopic.meshId)
|
||||||
: [];
|
: [];
|
||||||
const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)]));
|
const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)]));
|
||||||
|
|
||||||
|
// Aggregate unread per mesh for the viewing user. Every topic_message
|
||||||
|
// not authored by one of the viewer's member rows, in a topic whose
|
||||||
|
// last_read_at by the viewer is null or older than the message,
|
||||||
|
// counts as unread. The LEFT JOIN on topic_member is restricted to
|
||||||
|
// the viewer's own member ids so a NULL row reliably means "viewer
|
||||||
|
// never opened this topic" — every message in such a topic is unread.
|
||||||
|
const myMembers = user && meshIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: meshMember.id })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshMember.userId, user.id),
|
||||||
|
inArray(meshMember.meshId, meshIds),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const myMemberIds = myMembers.map((m) => m.id);
|
||||||
|
|
||||||
|
const unreadRows = myMemberIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
meshId: meshTopic.meshId,
|
||||||
|
n: count(meshTopicMessage.id),
|
||||||
|
})
|
||||||
|
.from(meshTopicMessage)
|
||||||
|
.innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId))
|
||||||
|
.leftJoin(
|
||||||
|
meshTopicMember,
|
||||||
|
and(
|
||||||
|
eq(meshTopicMember.topicId, meshTopicMessage.topicId),
|
||||||
|
inArray(meshTopicMember.memberId, myMemberIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(meshTopic.meshId, meshIds),
|
||||||
|
isNull(meshTopic.archivedAt),
|
||||||
|
sql`${meshTopicMessage.senderMemberId} <> ALL(${myMemberIds})`,
|
||||||
|
or(
|
||||||
|
isNull(meshTopicMember.lastReadAt),
|
||||||
|
sql`${meshTopicMessage.createdAt} > ${meshTopicMember.lastReadAt}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(meshTopic.meshId)
|
||||||
|
: [];
|
||||||
|
const unreadMap = new Map(unreadRows.map((r) => [r.meshId, Number(r.n)]));
|
||||||
|
|
||||||
const meshesWithTopics = activeMeshes.map((m) => ({
|
const meshesWithTopics = activeMeshes.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
topicCount: topicMap.get(m.id) ?? 0,
|
topicCount: topicMap.get(m.id) ?? 0,
|
||||||
|
unreadCount: unreadMap.get(m.id) ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MeshSummary {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
topicCount?: number;
|
topicCount?: number;
|
||||||
|
unreadCount?: number;
|
||||||
archivedAt: Date | string | null;
|
archivedAt: Date | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +114,26 @@ const MeshCard = ({
|
|||||||
{isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""}
|
{isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex items-center gap-2">
|
||||||
className={[
|
{mesh.unreadCount && mesh.unreadCount > 0 ? (
|
||||||
"whitespace-nowrap rounded-sm border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em]",
|
<span
|
||||||
roleClass(mesh.isOwner, mesh.myRole),
|
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"
|
||||||
"border-[var(--cm-border)]",
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
].join(" ")}
|
aria-label={`${mesh.unreadCount} unread across topics`}
|
||||||
>
|
>
|
||||||
{mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole}
|
{mesh.unreadCount > 99 ? "99+" : mesh.unreadCount}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"whitespace-nowrap rounded-sm border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em]",
|
||||||
|
roleClass(mesh.isOwner, mesh.myRole),
|
||||||
|
"border-[var(--cm-border)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user