Files
clientsflow/frontend/app/composables/useContacts.ts
Ruslan Bakiev 6e3763a5fd fix: refetch contacts after hiding inbox, redirect to most recent chat
After hiding a contact inbox, the contacts list now refetches immediately
so the hidden contact disappears reactively. When the current contact is
removed from the list, the selection jumps to the most recently active
contact (by lastContactAt) instead of the first item in the current sort.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:56:10 +07:00

183 lines
5.2 KiB
TypeScript

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<boolean> }) {
const { result: contactsResult, refetch: refetchContacts } = useQuery(
ContactsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const contacts = ref<Contact[]>([]);
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<SortMode>("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<string, Contact[]>();
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<Record<string, boolean>>({});
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,
};
}