import { ref, computed, watch, type ComputedRef, type Ref } from "vue"; import { useQuery, useMutation } from "@vue/apollo-composable"; import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated"; import type { CommItem } from "~/composables/useContacts"; import type { CalendarEvent, EventLifecyclePhase } from "~/composables/useCalendar"; import { formatDay, isEventFinalStatus } from "~/composables/useCalendar"; export type CommPin = { id: string; contact: string; text: string; }; export function usePins(opts: { apolloAuthReady: ComputedRef; selectedCommThread: ComputedRef<{ id: string; contact: string; items: CommItem[] } | undefined>; selectedCommLifecycleEvents: ComputedRef>; visibleThreadItems: ComputedRef; }) { const { result: pinsResult, refetch: refetchPins } = useQuery( PinsQueryDocument, null, { enabled: opts.apolloAuthReady }, ); const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, { refetchQueries: [{ query: PinsQueryDocument }], }); const commPins = ref([]); const commPinToggling = ref(false); const commPinContextMenu = ref<{ open: boolean; x: number; y: number; entry: any | null; }>({ open: false, x: 0, y: 0, entry: null, }); watch(() => pinsResult.value?.pins, (v) => { if (v) commPins.value = v as CommPin[]; }, { immediate: true }); const selectedCommPins = computed(() => { if (!opts.selectedCommThread.value) return []; return commPins.value.filter((item) => item.contact === opts.selectedCommThread.value?.contact); }); function normalizePinText(value: string) { return String(value ?? "").replace(/\s+/g, " ").trim(); } function stripPinnedPrefix(value: string) { return String(value ?? "").replace(/^\s*(закреплено|pinned)\s*:\s*/i, "").trim(); } function isPinnedText(contact: string, value: string) { const contactName = String(contact ?? "").trim(); const text = normalizePinText(value); if (!contactName || !text) return false; return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text); } function entryPinText(entry: any): string { if (!entry) return ""; if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? "")); if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? ""); if (entry.kind === "eventLifecycle") { return normalizePinText(entry.event?.note || entry.event?.title || ""); } if (entry.kind === "call") return normalizePinText(entry.item?.text || ""); return normalizePinText(entry.item?.text || ""); } async function togglePinnedText(contact: string, value: string) { if (commPinToggling.value) return; const contactName = String(contact ?? "").trim(); const text = normalizePinText(value); if (!contactName || !text) return; commPinToggling.value = true; try { await doToggleContactPin({ contact: contactName, text }); } finally { commPinToggling.value = false; } } async function togglePinForEntry(entry: any) { const contact = opts.selectedCommThread.value?.contact ?? ""; const text = entryPinText(entry); await togglePinnedText(contact, text); } function isPinnedEntry(entry: any) { const contact = opts.selectedCommThread.value?.contact ?? ""; const text = entryPinText(entry); return isPinnedText(contact, text); } function closeCommPinContextMenu() { commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null, }; } function openCommPinContextMenu(event: MouseEvent, entry: any) { const text = entryPinText(entry); if (!text) return; const menuWidth = 136; const menuHeight = 46; const padding = 8; const maxX = Math.max(padding, window.innerWidth - menuWidth - padding); const maxY = Math.max(padding, window.innerHeight - menuHeight - padding); const x = Math.min(maxX, Math.max(padding, event.clientX)); const y = Math.min(maxY, Math.max(padding, event.clientY)); commPinContextMenu.value = { open: true, x, y, entry, }; } const commPinContextActionLabel = computed(() => { const entry = commPinContextMenu.value.entry; if (!entry) return "Pin"; return isPinnedEntry(entry) ? "Unpin" : "Pin"; }); async function applyCommPinContextAction() { const entry = commPinContextMenu.value.entry; if (!entry) return; closeCommPinContextMenu(); await togglePinForEntry(entry); } function onWindowPointerDownForCommPinMenu(event: PointerEvent) { if (!commPinContextMenu.value.open) return; const target = event.target as HTMLElement | null; if (target?.closest(".comm-pin-context-menu")) return; closeCommPinContextMenu(); } function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) { if (!commPinContextMenu.value.open) return; if (event.key === "Escape") { closeCommPinContextMenu(); } } const selectedCommPinnedStream = computed(() => { const pins = selectedCommPins.value.map((pin) => { const normalizedText = normalizePinText(stripPinnedPrefix(pin.text)); const sourceItem = [...opts.visibleThreadItems.value] .filter((item) => normalizePinText(item.text) === normalizedText) .sort((a, b) => b.at.localeCompare(a.at))[0] ?? null; return { id: `pin-${pin.id}`, kind: "pin" as const, text: pin.text, sourceItem, }; }); const rank = (phase: EventLifecyclePhase) => { if (phase === "awaiting_outcome") return 0; if (phase === "due_soon") return 1; if (phase === "scheduled") return 2; return 3; }; const events = opts.selectedCommLifecycleEvents.value .filter((item) => !isEventFinalStatus(item.event.isArchived)) .sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start)) .map((item) => ({ id: `event-${item.event.id}`, kind: "eventLifecycle" as const, event: item.event, phase: item.phase, })); return [...pins, ...events]; }); const latestPinnedItem = computed(() => selectedCommPinnedStream.value[0] ?? null); const latestPinnedLabel = computed(() => { if (!latestPinnedItem.value) return "No pinned items yet"; if (latestPinnedItem.value.kind === "pin") return stripPinnedPrefix(latestPinnedItem.value.text); return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`; }); return { commPins, commPinToggling, commPinContextMenu, selectedCommPins, selectedCommPinnedStream, togglePinnedText, togglePinForEntry, isPinnedText, isPinnedEntry, entryPinText, normalizePinText, stripPinnedPrefix, latestPinnedItem, latestPinnedLabel, closeCommPinContextMenu, openCommPinContextMenu, commPinContextActionLabel, applyCommPinContextAction, onWindowPointerDownForCommPinMenu, onWindowKeyDownForCommPinMenu, refetchPins, }; }