import { ref, computed, onMounted, onUnmounted } from 'vue'; import axios from 'axios'; import { getAccount, watchAccount } from '@wagmi/core'; import type { WatchAccountReturnType } from '@wagmi/core'; import { config } from '@/wagmi'; import type { Config } from '@wagmi/core'; import { DEFAULT_CHAIN_ID } from '@/config'; import { resolveGraphqlEndpoint } from '@/utils/graphqlRetry'; import logger from '@/utils/logger'; export interface SnatchEvent { id: string; timestamp: string; tokenAmount: string; ethAmount: string; txHash: string; } const GRAPHQL_TIMEOUT_MS = 15_000; const STORAGE_KEY_PREFIX = 'snatch-seen-'; const unseenSnatches = ref([]); const allRecentSnatches = ref([]); const isOpen = ref(false); let unwatchAccount: WatchAccountReturnType | null = null; let consumerCount = 0; function storageKey(address: string): string { return `${STORAGE_KEY_PREFIX}${address.toLowerCase()}`; } function getLastSeen(address: string): string { return localStorage.getItem(storageKey(address)) ?? '0'; } function setLastSeen(address: string, timestamp: string): void { localStorage.setItem(storageKey(address), timestamp); } async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise { const holder = address.toLowerCase(); const where: { holder: string; type: string; timestamp_gt?: string } = { holder, type: 'snatch_out' }; if (since !== null) { where.timestamp_gt = since; } const res = await axios.post( endpoint, { query: `query SnatchEvents($where: transactionsFilter!) { transactionss( where: $where orderBy: "timestamp" orderDirection: "desc" limit: 20 ) { items { id timestamp tokenAmount ethAmount txHash } } }`, variables: { where }, }, { timeout: GRAPHQL_TIMEOUT_MS } ); const errors = res.data?.errors; if (Array.isArray(errors) && errors.length > 0) { logger.info('useSnatchNotifications GraphQL errors', errors); return []; } return (res.data?.data?.transactionss?.items ?? []) as SnatchEvent[]; } async function refresh(chainId: number): Promise { const account = getAccount(config as Config); if (!account.address) { unseenSnatches.value = []; allRecentSnatches.value = []; return; } let endpoint: string; try { endpoint = resolveGraphqlEndpoint(chainId); } catch { unseenSnatches.value = []; allRecentSnatches.value = []; return; } try { const since = getLastSeen(account.address); const [unseen, all] = await Promise.all([ fetchSnatchEvents(endpoint, account.address, since), fetchSnatchEvents(endpoint, account.address, null), ]); unseenSnatches.value = unseen; allRecentSnatches.value = all; } catch (err) { logger.info('useSnatchNotifications fetch failed', err); } } function markSeen(): void { const account = getAccount(config as Config); if (!account.address) return; // Advance last-seen to the most recent event timestamp const latestTs = allRecentSnatches.value[0]?.timestamp ?? unseenSnatches.value[0]?.timestamp; if (latestTs) { setLastSeen(account.address, latestTs); } // Clear badge; allRecentSnatches stays populated so the panel still shows history unseenSnatches.value = []; } export function useSnatchNotifications(chainId: number = DEFAULT_CHAIN_ID) { const unseenCount = computed(() => unseenSnatches.value.length); function open(): void { isOpen.value = true; markSeen(); } function close(): void { isOpen.value = false; } function toggle(): void { if (isOpen.value) { close(); } else { open(); } } onMounted(async () => { consumerCount += 1; if (consumerCount === 1) { unwatchAccount = watchAccount(config as Config, { async onChange() { await refresh(chainId); }, }); } await refresh(chainId); }); onUnmounted(() => { consumerCount = Math.max(0, consumerCount - 1); if (consumerCount === 0 && unwatchAccount) { unwatchAccount(); unwatchAccount = null; } }); return { unseenCount, unseenSnatches, allRecentSnatches, isOpen, open, close, toggle, refresh: () => refresh(chainId), }; }