harb/web-app/src/composables/useSnatchNotifications.ts
openhands 2fffd2a567 fix: address review findings for snatch notifications (#151)
- 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>
2026-02-24 09:18:28 +00:00

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),
};
}