2026-02-24 09:18:28 +00:00
|
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
2026-02-24 08:47:24 +00:00
|
|
|
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<SnatchEvent[]>([]);
|
|
|
|
|
const allRecentSnatches = ref<SnatchEvent[]>([]);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 09:18:28 +00:00
|
|
|
async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise<SnatchEvent[]> {
|
|
|
|
|
const holder = address.toLowerCase();
|
2026-02-24 20:44:17 +00:00
|
|
|
const where: { holder: string; type: string; timestamp_gt?: string } = { holder, type: 'snatch_out' };
|
|
|
|
|
if (since !== null) {
|
|
|
|
|
where.timestamp_gt = since;
|
|
|
|
|
}
|
2026-02-24 09:18:28 +00:00
|
|
|
const res = await axios.post(
|
|
|
|
|
endpoint,
|
|
|
|
|
{
|
2026-02-24 20:44:17 +00:00
|
|
|
query: `query SnatchEvents($where: transactionsFilter!) {
|
2026-02-24 09:18:28 +00:00
|
|
|
transactionss(
|
2026-02-24 20:44:17 +00:00
|
|
|
where: $where
|
2026-02-24 09:18:28 +00:00
|
|
|
orderBy: "timestamp"
|
|
|
|
|
orderDirection: "desc"
|
|
|
|
|
limit: 20
|
|
|
|
|
) {
|
|
|
|
|
items { id timestamp tokenAmount ethAmount txHash }
|
|
|
|
|
}
|
|
|
|
|
}`,
|
2026-02-24 20:44:17 +00:00
|
|
|
variables: { where },
|
2026-02-24 09:18:28 +00:00
|
|
|
},
|
|
|
|
|
{ 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<void> {
|
|
|
|
|
const account = getAccount(config as Config);
|
|
|
|
|
if (!account.address) {
|
|
|
|
|
unseenSnatches.value = [];
|
|
|
|
|
allRecentSnatches.value = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 08:47:24 +00:00
|
|
|
let endpoint: string;
|
|
|
|
|
try {
|
|
|
|
|
endpoint = resolveGraphqlEndpoint(chainId);
|
|
|
|
|
} catch {
|
|
|
|
|
unseenSnatches.value = [];
|
|
|
|
|
allRecentSnatches.value = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-24 09:18:28 +00:00
|
|
|
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;
|
2026-02-24 08:47:24 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
logger.info('useSnatchNotifications fetch failed', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markSeen(): void {
|
|
|
|
|
const account = getAccount(config as Config);
|
2026-02-24 09:18:28 +00:00
|
|
|
if (!account.address) return;
|
2026-02-24 08:47:24 +00:00
|
|
|
|
2026-02-24 09:18:28 +00:00
|
|
|
// Advance last-seen to the most recent event timestamp
|
|
|
|
|
const latestTs = allRecentSnatches.value[0]?.timestamp ?? unseenSnatches.value[0]?.timestamp;
|
2026-02-24 08:47:24 +00:00
|
|
|
if (latestTs) {
|
|
|
|
|
setLastSeen(account.address, latestTs);
|
|
|
|
|
}
|
2026-02-24 09:18:28 +00:00
|
|
|
// Clear badge; allRecentSnatches stays populated so the panel still shows history
|
2026-02-24 08:47:24 +00:00
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|