import { ref, computed, watch, watchEffect, type ComputedRef } from "vue"; import { useQuery, useMutation } from "@vue/apollo-composable"; import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated"; export type Contact = { id: string; name: string; avatar: string; channels: string[]; lastContactAt: string; lastMessageText: string; lastMessageChannel: string; hasUnread: boolean; description: string; }; export type CommItem = { id: string; at: string; contact: string; contactInboxId: string; sourceExternalId: string; sourceTitle: string; channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email"; kind: "message" | "call"; direction: "in" | "out"; text: string; audioUrl?: string; duration?: string; waveform?: number[]; transcript?: string[]; deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null; }; export type SortMode = "name" | "lastContact"; export function useContacts(opts: { apolloAuthReady: ComputedRef }) { const { result: contactsResult, refetch: refetchContacts } = useQuery( ContactsQueryDocument, null, { enabled: opts.apolloAuthReady }, ); const contacts = ref([]); watch( () => contactsResult.value?.contacts, (rawContacts) => { if (!rawContacts) return; contacts.value = [...rawContacts] as Contact[]; }, { immediate: true }, ); const contactSearch = ref(""); const selectedChannel = ref("All"); const sortMode = ref("name"); const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort()); function resetContactFilters() { contactSearch.value = ""; selectedChannel.value = "All"; sortMode.value = "name"; } const filteredContacts = computed(() => { const query = contactSearch.value.trim().toLowerCase(); const data = contacts.value.filter((contact) => { if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false; if (query) { const haystack = [contact.name, contact.description, contact.channels.join(" ")] .join(" ") .toLowerCase(); if (!haystack.includes(query)) return false; } return true; }); return data.sort((a, b) => { if (sortMode.value === "lastContact") { return b.lastContactAt.localeCompare(a.lastContactAt); } return a.name.localeCompare(b.name); }); }); const groupedContacts = computed(() => { if (sortMode.value === "lastContact") { return [["Recent", filteredContacts.value]] as [string, Contact[]][]; } const map = new Map(); for (const contact of filteredContacts.value) { const key = (contact.name[0] ?? "#").toUpperCase(); if (!map.has(key)) { map.set(key, []); } map.get(key)?.push(contact); } return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); }); const selectedContactId = ref(contacts.value[0]?.id ?? ""); watchEffect(() => { if (!filteredContacts.value.length) { selectedContactId.value = ""; return; } if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) { // Always pick the most recently active contact, regardless of current sort mode const mostRecent = [...filteredContacts.value].sort((a, b) => b.lastContactAt.localeCompare(a.lastContactAt), )[0]; if (mostRecent) selectedContactId.value = mostRecent.id; } }); 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>({}); function contactInitials(name: string) { const words = String(name ?? "") .trim() .split(/\s+/) .filter(Boolean); if (!words.length) return "?"; return words .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? "") .join(""); } function avatarSrcForThread(thread: { id: string; avatar: string }) { if (brokenAvatarByContactId.value[thread.id]) return ""; return String(thread.avatar ?? "").trim(); } function markAvatarBroken(contactId: string) { if (!contactId) return; brokenAvatarByContactId.value = { ...brokenAvatarByContactId.value, [contactId]: true, }; } return { contacts, contactSearch, selectedChannel, sortMode, selectedContactId, selectedContact, filteredContacts, groupedContacts, channels, resetContactFilters, brokenAvatarByContactId, avatarSrcForThread, markAvatarBroken, contactInitials, markContactRead, refetchContacts, }; }