From 60d0859eb309321370400ddaa96fcaaa5ec637e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 24 Feb 2026 08:47:24 +0000 Subject: [PATCH] fix: Snatch notifications and position history (#151) Co-Authored-By: Claude Sonnet 4.6 --- web-app/src/components/NotificationBell.vue | 213 ++++++++++++++++++ .../components/collapse/CollapseHistory.vue | 107 +++++++-- .../src/components/layouts/NavbarHeader.vue | 2 + web-app/src/composables/usePositions.ts | 3 + .../src/composables/useSnatchNotifications.ts | 164 ++++++++++++++ web-app/src/views/StakeView.vue | 36 ++- 6 files changed, 509 insertions(+), 16 deletions(-) create mode 100644 web-app/src/components/NotificationBell.vue create mode 100644 web-app/src/composables/useSnatchNotifications.ts diff --git a/web-app/src/components/NotificationBell.vue b/web-app/src/components/NotificationBell.vue new file mode 100644 index 0000000..816b587 --- /dev/null +++ b/web-app/src/components/NotificationBell.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/web-app/src/components/collapse/CollapseHistory.vue b/web-app/src/components/collapse/CollapseHistory.vue index 043e9e1..ba5a20e 100644 --- a/web-app/src/components/collapse/CollapseHistory.vue +++ b/web-app/src/components/collapse/CollapseHistory.vue @@ -2,13 +2,19 @@ @@ -21,6 +27,15 @@ Profit{{ profit }} $KRK + @@ -28,9 +43,12 @@ diff --git a/web-app/src/components/layouts/NavbarHeader.vue b/web-app/src/components/layouts/NavbarHeader.vue index fe945af..230517f 100644 --- a/web-app/src/components/layouts/NavbarHeader.vue +++ b/web-app/src/components/layouts/NavbarHeader.vue @@ -24,6 +24,7 @@
+ avatar @@ -40,6 +41,7 @@ import IconLogin from '@/components/icons/IconLogin.vue'; // import ThemeToggle from "@/components/layouts/ThemeToggle.vue"; import NetworkChanger from '@/components/layouts/NetworkChanger.vue'; import ConnectButton from '@/components/layouts/ConnectButton.vue'; +import NotificationBell from '@/components/NotificationBell.vue'; import { inject, ref } from 'vue'; import { useAccount } from '@wagmi/vue'; import { useDark } from '@/composables/useDark'; diff --git a/web-app/src/composables/usePositions.ts b/web-app/src/composables/usePositions.ts index 1d5315f..9855c84 100644 --- a/web-app/src/composables/usePositions.ts +++ b/web-app/src/composables/usePositions.ts @@ -81,6 +81,8 @@ export interface Position { harbDeposit: bigint; iAmOwner: boolean; snatched: number; + closedAt?: string; + payout?: string; } const myClosedPositions: ComputedRef = computed(() => { @@ -208,6 +210,7 @@ export async function loadMyClosedPositions(chainId: number, endpointOverride: s totalSupplyEnd totalSupplyInit creationTime + closedAt } } }`, diff --git a/web-app/src/composables/useSnatchNotifications.ts b/web-app/src/composables/useSnatchNotifications.ts new file mode 100644 index 0000000..85ce1dd --- /dev/null +++ b/web-app/src/composables/useSnatchNotifications.ts @@ -0,0 +1,164 @@ +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), + }; +} diff --git a/web-app/src/views/StakeView.vue b/web-app/src/views/StakeView.vue index 7e7e930..1401ff4 100644 --- a/web-app/src/views/StakeView.vue +++ b/web-app/src/views/StakeView.vue @@ -70,6 +70,9 @@

Active Positions

+
+ No active positions — your position history is below. +
+
+

Position History

+
+ +
+