import { ref, computed, onMounted, onUnmounted, watch } 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 fetchUnseenSnatches(chainId: number, address: string): Promise { const since = getLastSeen(address); let endpoint: string; try { endpoint = resolveGraphqlEndpoint(chainId); } catch { unseenSnatches.value = []; allRecentSnatches.value = []; return; } try { const res = await axios.post( endpoint, { query: `query UnseenSnatches($holder: String!, $since: BigInt!) { transactionss( where: { holder: $holder, type: "snatch_out", timestamp_gt: $since } orderBy: "timestamp" orderDirection: "desc" limit: 20 ) { items { id timestamp tokenAmount ethAmount txHash } } }`, variables: { holder: address.toLowerCase(), since }, }, { timeout: GRAPHQL_TIMEOUT_MS } ); const errors = res.data?.errors; if (Array.isArray(errors) && errors.length > 0) { logger.info('useSnatchNotifications GraphQL errors', errors); return; } const items: SnatchEvent[] = res.data?.data?.transactionss?.items ?? []; unseenSnatches.value = items; allRecentSnatches.value = items; } catch (err) { logger.info('useSnatchNotifications fetch failed', err); } } async function refresh(chainId: number): Promise { const account = getAccount(config as Config); if (!account.address) { unseenSnatches.value = []; allRecentSnatches.value = []; return; } await fetchUnseenSnatches(chainId, account.address); } function markSeen(): void { const account = getAccount(config as Config); if (!account.address || allRecentSnatches.value.length === 0) return; // Use the most recent timestamp from current results const latestTs = allRecentSnatches.value[0]?.timestamp; if (latestTs) { setLastSeen(account.address, latestTs); } 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; } }); // Re-fetch when bell is closed to clear unseen after markSeen watch(isOpen, async newVal => { if (!newVal) return; }); return { unseenCount, unseenSnatches, allRecentSnatches, isOpen, open, close, toggle, refresh: () => refresh(chainId), }; }