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,
markAvatarBroken,
contactInitials,
markContactRead,
refetchContacts,
} = useContacts({ apolloAuthReady });
@@ -484,6 +485,7 @@ const commThreads = computed(() => {
channels,
lastAt: c.lastContactAt,
lastText: c.lastMessageText || "No messages yet",
hasUnread: c.hasUnread,
items: [] as CommItem[],
};
})
@@ -660,11 +662,12 @@ const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
},
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) {
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();
},
});
@@ -1160,6 +1163,7 @@ watch(selectedCommThreadId, () => {
clientTimelineItems.value = [];
return;
}
markContactRead(selectedCommThreadId.value);
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="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ thread.contact }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span>
<div class="flex min-w-0 flex-1 items-center gap-1">
<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>
<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) }}
</p>
</div>

View File

@@ -1,6 +1,6 @@
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
import { useQuery } from "@vue/apollo-composable";
import { ContactsQueryDocument } from "~~/graphql/generated";
import { useQuery, useMutation } from "@vue/apollo-composable";
import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated";
export type Contact = {
id: string;
@@ -10,6 +10,7 @@ export type Contact = {
lastContactAt: string;
lastMessageText: string;
lastMessageChannel: string;
hasUnread: boolean;
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 { 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>>({});
function contactInitials(name: string) {
@@ -159,6 +173,7 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
avatarSrcForThread,
markAvatarBroken,
contactInitials,
markContactRead,
refetchContacts,
};
}