165 lines
4.1 KiB
TypeScript
165 lines
4.1 KiB
TypeScript
|
|
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<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);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchUnseenSnatches(chainId: number, address: string): Promise<void> {
|
||
|
|
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<void> {
|
||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|