- Extract relativeTime and formatTokenAmount to helper.ts, eliminating duplicated logic between CollapseHistory and NotificationBell - Use formatUnits (via formatTokenAmount) instead of Number(BigInt)/1e18 to avoid precision loss on large token amounts - Fix allRecentSnatches emptying after mark-seen: now runs two parallel queries — one filtered by lastSeen timestamp (unseen badge count) and one unfiltered (panel history), so history is preserved after opening - Remove dead no-op watch block from useSnatchNotifications Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
167 lines
4.2 KiB
TypeScript
167 lines
4.2 KiB
TypeScript
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<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 fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise<SnatchEvent[]> {
|
|
const holder = address.toLowerCase();
|
|
const whereClause =
|
|
since !== null
|
|
? `{ holder: "${holder}", type: "snatch_out", timestamp_gt: "${since}" }`
|
|
: `{ holder: "${holder}", type: "snatch_out" }`;
|
|
const res = await axios.post(
|
|
endpoint,
|
|
{
|
|
query: `query SnatchEvents {
|
|
transactionss(
|
|
where: ${whereClause}
|
|
orderBy: "timestamp"
|
|
orderDirection: "desc"
|
|
limit: 20
|
|
) {
|
|
items { id timestamp tokenAmount ethAmount txHash }
|
|
}
|
|
}`,
|
|
},
|
|
{ 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;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|