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

@@ -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,
};
}