import { ref, computed, watch, watchEffect, type ComputedRef } from "vue"; import { useQuery } from "@vue/apollo-composable"; import { ContactsQueryDocument, CommunicationsQueryDocument, } from "~~/graphql/generated"; export type Contact = { id: string; name: string; avatar: string; channels: string[]; lastContactAt: string; 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 { result: communicationsResult, refetch: refetchCommunications } = useQuery( CommunicationsQueryDocument, null, { enabled: opts.apolloAuthReady }, ); const contacts = ref([]); const commItems = ref([]); watch( [() => contactsResult.value?.contacts, () => communicationsResult.value?.communications], ([rawContacts, rawComms]) => { if (!rawContacts) return; const contactsList = [...rawContacts] as Contact[]; const commsList = (rawComms ?? []) as CommItem[]; const byName = new Map>(); for (const item of commsList) { if (!byName.has(item.contact)) byName.set(item.contact, new Set()); byName.get(item.contact)?.add(item.channel); } contacts.value = contactsList.map((c) => ({ ...c, channels: Array.from(byName.get(c.name) ?? c.channels ?? []), })); commItems.value = commsList; }, { 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)) { const first = filteredContacts.value[0]; if (first) selectedContactId.value = first.id; } }); const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value)); 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, commItems, contactSearch, selectedChannel, sortMode, selectedContactId, selectedContact, filteredContacts, groupedContacts, channels, resetContactFilters, brokenAvatarByContactId, avatarSrcForThread, markAvatarBroken, contactInitials, refetchContacts, refetchCommunications, }; }