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:
Ruslan Bakiev
2026-02-24 20:25:32 +07:00
parent 643d8d02ba
commit 5492e0d05c
10 changed files with 167 additions and 9 deletions

View File

@@ -108,6 +108,7 @@ const {
avatarSrcForThread, avatarSrcForThread,
markAvatarBroken, markAvatarBroken,
contactInitials, contactInitials,
markContactRead,
refetchContacts, refetchContacts,
} = useContacts({ apolloAuthReady }); } = useContacts({ apolloAuthReady });
@@ -484,6 +485,7 @@ const commThreads = computed(() => {
channels, channels,
lastAt: c.lastContactAt, lastAt: c.lastContactAt,
lastText: c.lastMessageText || "No messages yet", lastText: c.lastMessageText || "No messages yet",
hasUnread: c.hasUnread,
items: [] as CommItem[], items: [] as CommItem[],
}; };
}) })
@@ -660,11 +662,12 @@ const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]); await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
}, },
onNewMessage: (msg) => { onNewMessage: (msg) => {
// If the message is for the currently open thread → refresh its timeline // If the message is for the currently open thread → refresh its timeline + mark read
if (msg.contactId === selectedCommThreadId.value) { if (msg.contactId === selectedCommThreadId.value) {
void refreshSelectedClientTimeline(selectedCommThreadId.value); void refreshSelectedClientTimeline(selectedCommThreadId.value);
markContactRead(msg.contactId);
} }
// Refresh contacts to update sidebar preview (lastMessageText, lastAt) // Refresh contacts to update sidebar preview (lastMessageText, lastAt, hasUnread)
void refetchContacts(); void refetchContacts();
}, },
}); });
@@ -1160,6 +1163,7 @@ watch(selectedCommThreadId, () => {
clientTimelineItems.value = []; clientTimelineItems.value = [];
return; return;
} }
markContactRead(selectedCommThreadId.value);
void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined); void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined);
}); });

View File

@@ -142,10 +142,13 @@ function onSearchInput(event: Event) {
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ thread.contact }}</p> <div class="flex min-w-0 flex-1 items-center gap-1">
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span> <span v-if="thread.hasUnread" class="h-2 w-2 shrink-0 rounded-full bg-primary" />
<p class="min-w-0 flex-1 truncate text-xs" :class="thread.hasUnread ? 'font-bold' : 'font-semibold'">{{ thread.contact }}</p>
</div>
<span class="shrink-0 text-[10px]" :class="thread.hasUnread ? 'font-semibold text-primary' : 'text-base-content/55'">{{ formatThreadTime(thread.lastAt) }}</span>
</div> </div>
<p class="mt-0.5 min-w-0 truncate text-[11px] text-base-content/75"> <p class="mt-0.5 min-w-0 truncate text-[11px]" :class="thread.hasUnread ? 'font-semibold text-base-content' : 'text-base-content/75'">
{{ thread.lastText || threadChannelLabel(thread) }} {{ thread.lastText || threadChannelLabel(thread) }}
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue"; import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
import { useQuery } from "@vue/apollo-composable"; import { useQuery, useMutation } from "@vue/apollo-composable";
import { ContactsQueryDocument } from "~~/graphql/generated"; import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated";
export type Contact = { export type Contact = {
id: string; id: string;
@@ -10,6 +10,7 @@ export type Contact = {
lastContactAt: string; lastContactAt: string;
lastMessageText: string; lastMessageText: string;
lastMessageChannel: string; lastMessageChannel: string;
hasUnread: boolean;
description: string; description: string;
}; };
@@ -117,6 +118,19 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value)); const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
const { mutate: doMarkThreadRead } = useMutation(MarkThreadReadDocument);
function markContactRead(contactId: string) {
if (!contactId) return;
// Optimistically update local state
const idx = contacts.value.findIndex((c) => c.id === contactId);
if (idx >= 0 && contacts.value[idx]!.hasUnread) {
contacts.value[idx] = { ...contacts.value[idx]!, hasUnread: false };
}
// Fire-and-forget backend call
void doMarkThreadRead({ contactId }).catch(() => undefined);
}
const brokenAvatarByContactId = ref<Record<string, boolean>>({}); const brokenAvatarByContactId = ref<Record<string, boolean>>({});
function contactInitials(name: string) { function contactInitials(name: string) {
@@ -159,6 +173,7 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
avatarSrcForThread, avatarSrcForThread,
markAvatarBroken, markAvatarBroken,
contactInitials, contactInitials,
markContactRead,
refetchContacts, refetchContacts,
}; };
} }

View File

@@ -87,8 +87,11 @@ export type Contact = {
avatar: Scalars['String']['output']; avatar: Scalars['String']['output'];
channels: Array<Scalars['String']['output']>; channels: Array<Scalars['String']['output']>;
description: Scalars['String']['output']; description: Scalars['String']['output'];
hasUnread: Scalars['Boolean']['output'];
id: Scalars['ID']['output']; id: Scalars['ID']['output'];
lastContactAt: Scalars['String']['output']; lastContactAt: Scalars['String']['output'];
lastMessageChannel: Scalars['String']['output'];
lastMessageText: Scalars['String']['output'];
name: Scalars['String']['output']; name: Scalars['String']['output'];
}; };
@@ -220,6 +223,7 @@ export type Mutation = {
logPilotNote: MutationResult; logPilotNote: MutationResult;
login: MutationResult; login: MutationResult;
logout: MutationResult; logout: MutationResult;
markThreadRead: MutationResult;
rollbackChangeSetItems: MutationResult; rollbackChangeSetItems: MutationResult;
rollbackLatestChangeSet: MutationResult; rollbackLatestChangeSet: MutationResult;
selectChatConversation: MutationResult; selectChatConversation: MutationResult;
@@ -277,6 +281,11 @@ export type MutationloginArgs = {
}; };
export type MutationmarkThreadReadArgs = {
contactId: Scalars['ID']['input'];
};
export type MutationrollbackChangeSetItemsArgs = { export type MutationrollbackChangeSetItemsArgs = {
changeSetId: Scalars['ID']['input']; changeSetId: Scalars['ID']['input'];
itemIds: Array<Scalars['ID']['input']>; itemIds: Array<Scalars['ID']['input']>;
@@ -464,7 +473,7 @@ export type ContactInboxesQueryQuery = { __typename?: 'Query', contactInboxes: A
export type ContactsQueryQueryVariables = Exact<{ [key: string]: never; }>; export type ContactsQueryQueryVariables = Exact<{ [key: string]: never; }>;
export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array<string>, lastContactAt: string, description: string }> }; export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array<string>, lastContactAt: string, lastMessageText: string, lastMessageChannel: string, hasUnread: boolean, description: string }> };
export type CreateCalendarEventMutationMutationVariables = Exact<{ export type CreateCalendarEventMutationMutationVariables = Exact<{
input: CreateCalendarEventInput; input: CreateCalendarEventInput;
@@ -544,6 +553,13 @@ export type LogoutMutationMutationVariables = Exact<{ [key: string]: never; }>;
export type LogoutMutationMutation = { __typename?: 'Mutation', logout: { __typename?: 'MutationResult', ok: boolean } }; export type LogoutMutationMutation = { __typename?: 'Mutation', logout: { __typename?: 'MutationResult', ok: boolean } };
export type MarkThreadReadMutationVariables = Exact<{
contactId: Scalars['ID']['input'];
}>;
export type MarkThreadReadMutation = { __typename?: 'Mutation', markThreadRead: { __typename?: 'MutationResult', ok: boolean } };
export type MeQueryQueryVariables = Exact<{ [key: string]: never; }>; export type MeQueryQueryVariables = Exact<{ [key: string]: never; }>;
@@ -921,6 +937,9 @@ export const ContactsQueryDocument = gql`
avatar avatar
channels channels
lastContactAt lastContactAt
lastMessageText
lastMessageChannel
hasUnread
description description
} }
} }
@@ -1399,6 +1418,35 @@ export function useLogoutMutationMutation(options: VueApolloComposable.UseMutati
return VueApolloComposable.useMutation<LogoutMutationMutation, LogoutMutationMutationVariables>(LogoutMutationDocument, options); return VueApolloComposable.useMutation<LogoutMutationMutation, LogoutMutationMutationVariables>(LogoutMutationDocument, options);
} }
export type LogoutMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<LogoutMutationMutation, LogoutMutationMutationVariables>; export type LogoutMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<LogoutMutationMutation, LogoutMutationMutationVariables>;
export const MarkThreadReadDocument = gql`
mutation MarkThreadRead($contactId: ID!) {
markThreadRead(contactId: $contactId) {
ok
}
}
`;
/**
* __useMarkThreadReadMutation__
*
* To run a mutation, you first call `useMarkThreadReadMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useMarkThreadReadMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useMarkThreadReadMutation({
* variables: {
* contactId: // value for 'contactId'
* },
* });
*/
export function useMarkThreadReadMutation(options: VueApolloComposable.UseMutationOptions<MarkThreadReadMutation, MarkThreadReadMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<MarkThreadReadMutation, MarkThreadReadMutationVariables>> = {}) {
return VueApolloComposable.useMutation<MarkThreadReadMutation, MarkThreadReadMutationVariables>(MarkThreadReadDocument, options);
}
export type MarkThreadReadMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<MarkThreadReadMutation, MarkThreadReadMutationVariables>;
export const MeQueryDocument = gql` export const MeQueryDocument = gql`
query MeQuery { query MeQuery {
me { me {

View File

@@ -7,6 +7,7 @@ query ContactsQuery {
lastContactAt lastContactAt
lastMessageText lastMessageText
lastMessageChannel lastMessageChannel
hasUnread
description description
} }
} }

View File

@@ -0,0 +1,5 @@
mutation MarkThreadRead($contactId: ID!) {
markThreadRead(contactId: $contactId) {
ok
}
}

View File

@@ -33,6 +33,7 @@ type Mutation {
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult! updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult! setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
markThreadRead(contactId: ID!): MutationResult!
} }
type MutationResult { type MutationResult {
@@ -173,6 +174,7 @@ type Contact {
lastContactAt: String! lastContactAt: String!
lastMessageText: String! lastMessageText: String!
lastMessageChannel: String! lastMessageChannel: String!
hasUnread: Boolean!
description: String! description: String!
} }

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "ContactThreadRead" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"contactId" TEXT NOT NULL,
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContactThreadRead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ContactThreadRead_userId_contactId_key" ON "ContactThreadRead"("userId", "contactId");
-- CreateIndex
CREATE INDEX "ContactThreadRead_teamId_userId_idx" ON "ContactThreadRead"("teamId", "userId");
-- AddForeignKey
ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -88,6 +88,7 @@ model Team {
clientTimelineEntries ClientTimelineEntry[] clientTimelineEntries ClientTimelineEntry[]
contactInboxes ContactInbox[] contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[] contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
} }
model User { model User {
@@ -103,6 +104,7 @@ model User {
aiConversations AiConversation[] @relation("ConversationCreator") aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor") aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[] contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
} }
model TeamMember { model TeamMember {
@@ -142,10 +144,28 @@ model Contact {
omniIdentities OmniContactIdentity[] omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[] contactInboxes ContactInbox[]
clientTimelineEntries ClientTimelineEntry[] clientTimelineEntries ClientTimelineEntry[]
contactThreadReads ContactThreadRead[]
@@index([teamId, updatedAt]) @@index([teamId, updatedAt])
} }
model ContactThreadRead {
id String @id @default(cuid())
teamId String
userId String
contactId String
readAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([userId, contactId])
@@index([teamId, userId])
}
model ContactNote { model ContactNote {
id String @id @default(cuid()) id String @id @default(cuid())
contactId String @unique contactId String @unique

View File

@@ -455,7 +455,7 @@ async function getContacts(auth: AuthContext | null) {
const messageWhere = visibleMessageWhere(hiddenInboxIds); const messageWhere = visibleMessageWhere(hiddenInboxIds);
const hiddenInboxIdSet = new Set(hiddenInboxIds); const hiddenInboxIdSet = new Set(hiddenInboxIds);
const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([ const [contactsRaw, contactInboxesRaw, communicationsRaw, threadReadsRaw] = await Promise.all([
prisma.contact.findMany({ prisma.contact.findMany({
where: { teamId: ctx.teamId }, where: { teamId: ctx.teamId },
include: { include: {
@@ -485,8 +485,14 @@ async function getContacts(auth: AuthContext | null) {
orderBy: { occurredAt: "asc" }, orderBy: { occurredAt: "asc" },
take: 2000, 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 channelsByContactId = new Map<string, Set<string>>();
const totalInboxesByContactId = new Map<string, number>(); const totalInboxesByContactId = new Map<string, number>();
const visibleInboxesByContactId = 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(), lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
lastMessageText: c.messages[0]?.content ?? "", lastMessageText: c.messages[0]?.content ?? "",
lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "", 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 ?? "", description: c.note?.content ?? "",
})); }));
} }
@@ -1513,6 +1522,23 @@ async function setContactInboxHidden(
return { ok: true }; 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[]) { async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
const ctx = requireAuth(auth); const ctx = requireAuth(auth);
const messageId = String(id ?? "").trim(); const messageId = String(id ?? "").trim();
@@ -1877,6 +1903,7 @@ export const crmGraphqlSchema = buildSchema(`
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult! updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult! setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
markThreadRead(contactId: ID!): MutationResult!
} }
type MutationResult { type MutationResult {
@@ -2017,6 +2044,7 @@ export const crmGraphqlSchema = buildSchema(`
lastContactAt: String! lastContactAt: String!
lastMessageText: String! lastMessageText: String!
lastMessageChannel: String! lastMessageChannel: String!
hasUnread: Boolean!
description: String! description: String!
} }
@@ -2229,4 +2257,9 @@ export const crmGraphqlRoot = {
args: { inboxId: string; hidden: boolean }, args: { inboxId: string; hidden: boolean },
context: GraphQLContext, context: GraphQLContext,
) => setContactInboxHidden(context.auth, args), ) => setContactInboxHidden(context.auth, args),
markThreadRead: async (
args: { contactId: string },
context: GraphQLContext,
) => markThreadRead(context.auth, args),
}; };