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>
183 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|