feat: unread message tracking with blue dot indicator
Add ContactThreadRead model to track when users last viewed each contact thread. Contacts with messages newer than the last read time show a blue dot in the sidebar. Opening a thread automatically marks it as read via markThreadRead mutation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -455,7 +455,7 @@ async function getContacts(auth: AuthContext | null) {
|
||||
const messageWhere = visibleMessageWhere(hiddenInboxIds);
|
||||
const hiddenInboxIdSet = new Set(hiddenInboxIds);
|
||||
|
||||
const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([
|
||||
const [contactsRaw, contactInboxesRaw, communicationsRaw, threadReadsRaw] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
where: { teamId: ctx.teamId },
|
||||
include: {
|
||||
@@ -485,8 +485,14 @@ async function getContacts(auth: AuthContext | null) {
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: 2000,
|
||||
}),
|
||||
prisma.contactThreadRead.findMany({
|
||||
where: { teamId: ctx.teamId, userId: ctx.userId },
|
||||
select: { contactId: true, readAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const readAtByContactId = new Map(threadReadsRaw.map((r) => [r.contactId, r.readAt]));
|
||||
|
||||
const channelsByContactId = new Map<string, Set<string>>();
|
||||
const totalInboxesByContactId = new Map<string, number>();
|
||||
const visibleInboxesByContactId = new Map<string, number>();
|
||||
@@ -518,6 +524,9 @@ async function getContacts(auth: AuthContext | null) {
|
||||
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
|
||||
lastMessageText: c.messages[0]?.content ?? "",
|
||||
lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "",
|
||||
hasUnread: c.messages[0]?.occurredAt
|
||||
? (!readAtByContactId.has(c.id) || c.messages[0].occurredAt > readAtByContactId.get(c.id)!)
|
||||
: false,
|
||||
description: c.note?.content ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -1513,6 +1522,23 @@ async function setContactInboxHidden(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function markThreadRead(
|
||||
auth: AuthContext | null,
|
||||
input: { contactId: string },
|
||||
) {
|
||||
const ctx = requireAuth(auth);
|
||||
const contactId = String(input?.contactId ?? "").trim();
|
||||
if (!contactId) throw new Error("contactId is required");
|
||||
|
||||
await prisma.contactThreadRead.upsert({
|
||||
where: { userId_contactId: { userId: ctx.userId, contactId } },
|
||||
create: { teamId: ctx.teamId, userId: ctx.userId, contactId, readAt: new Date() },
|
||||
update: { readAt: new Date() },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
|
||||
const ctx = requireAuth(auth);
|
||||
const messageId = String(id ?? "").trim();
|
||||
@@ -1877,6 +1903,7 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
||||
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
||||
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
|
||||
markThreadRead(contactId: ID!): MutationResult!
|
||||
}
|
||||
|
||||
type MutationResult {
|
||||
@@ -2017,6 +2044,7 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
lastContactAt: String!
|
||||
lastMessageText: String!
|
||||
lastMessageChannel: String!
|
||||
hasUnread: Boolean!
|
||||
description: String!
|
||||
}
|
||||
|
||||
@@ -2229,4 +2257,9 @@ export const crmGraphqlRoot = {
|
||||
args: { inboxId: string; hidden: boolean },
|
||||
context: GraphQLContext,
|
||||
) => setContactInboxHidden(context.auth, args),
|
||||
|
||||
markThreadRead: async (
|
||||
args: { contactId: string },
|
||||
context: GraphQLContext,
|
||||
) => markThreadRead(context.auth, args),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user