UNPKG

@coinmeca/wallet-provider

Version:

This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

931 lines 38.8 kB
"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { parseChainId, valid } from "@coinmeca/wallet-sdk/utils"; const TRACK_TRANSACTION = "TRACK_TRANSACTION"; const CLEAR_TRACKED_TRANSACTIONS = "CLEAR_TRACKED_TRANSACTIONS"; const POLL_INTERVAL_MS = 5000; const MAX_POLL_ATTEMPTS = 180; const POLLING_OWNER_TTL_MS = POLL_INTERVAL_MS * 3; const NOTIFICATION_EVENT_TTL_MS = 60000; const PERMISSION_PROMPT_COOLDOWN_MS = 60000; const PERMISSION_PROMPT_OWNER_TTL_MS = PERMISSION_PROMPT_COOLDOWN_MS; const POLLING_OWNER_PREFIX = "coinmeca.wallet.notification.owner:"; const NOTIFICATION_EVENT_PREFIX = "coinmeca.wallet.notification.event:"; const PERMISSION_PROMPT_COOLDOWN_KEY = "coinmeca.wallet.notification.permission-cooldown"; const PERMISSION_PROMPT_OWNER_KEY = "coinmeca.wallet.notification.permission-owner"; const NOTIFICATION_CHANNEL = "coinmeca.wallet.notification"; const TRACKABLE_TX_HASH = /^0x[0-9a-f]{64}$/i; const WORKER_PROTOCOL = "coinmeca.wallet.notification/v1"; const shortAddress = (address) => { const value = typeof address === "string" ? address.trim() : ""; return valid.address(value) ? `${value.slice(0, 6)}...${value.slice(-4)}` : value; }; const transactionCategory = (value) => { const category = typeof value === "string" ? value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ") : ""; return category; }; const notificationTitle = (category) => { const value = transactionCategory(category); const normalized = value.toLowerCase(); if (!normalized) return "Transaction Update"; if (normalized.includes("approve")) return "Approval Update"; if (normalized.includes("deploy")) return "Contract Deployment"; if (normalized.includes("contract")) return "Contract Interaction"; return `${value.charAt(0).toUpperCase()}${value.slice(1)} Update`; }; const notificationAction = (category) => { const value = transactionCategory(category).toLowerCase(); if (!value) return "transaction"; if (value.includes("approve")) return "approval"; if (value.includes("deploy")) return "contract deployment"; if (value.includes("contract")) return "contract interaction"; return value; }; const isTrackableTxHash = (value) => typeof value === "string" && TRACKABLE_TX_HASH.test(value.trim()); const pendingKey = (address, chainId, hash) => { if (!valid.address(address) || typeof chainId !== "number" || !isTrackableTxHash(hash)) return; return `${address.trim().toLowerCase()}:${chainId}:${hash.trim().toLowerCase()}`; }; const isSecureNotificationContext = () => { if (typeof window === "undefined") return false; const host = typeof window.location?.hostname === "string" ? window.location.hostname.toLowerCase() : ""; return window.isSecureContext || host === "localhost" || host === "127.0.0.1"; }; const notificationRpcUrl = (chain) => { const urls = Array.isArray(chain?.rpcUrls) ? chain.rpcUrls .filter((value) => typeof value === "string") .map((value) => value.trim()) .filter((value) => value !== "") : []; for (const value of urls) { try { const protocol = new URL(value).protocol.toLowerCase(); if (protocol === "http:" || protocol === "https:") return value; } catch { } } return; }; const pendingTransaction = (account, chain, chainId, receipt) => { const hash = typeof receipt?.hash === "string" ? receipt.hash.trim() : ""; const accountAddress = typeof account?.address === "string" ? account.address.trim() : ""; const category = transactionCategory(receipt?.category); const to = typeof receipt?.to === "string" ? receipt.to.trim() : ""; if (!isTrackableTxHash(hash) || !valid.address(accountAddress) || typeof chainId !== "number") return; return { hash, accountName: typeof account?.name === "string" ? account.name.trim() : "", accountAddress, chainName: typeof chain?.chainName === "string" ? chain.chainName.trim() : "", chainId: chainId.toString(), category, to, rpcUrl: notificationRpcUrl(chain), title: notificationTitle(category), url: typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}${window.location.hash}` : "/", }; }; const notificationMessage = (payload, status) => { const subject = [payload.chainName, payload.accountName || shortAddress(payload.accountAddress)] .filter((value) => typeof value === "string" && value.trim() !== "") .join(" / "); const prefix = subject ? `${subject}: ` : ""; const shortHash = `${payload.hash.slice(0, 10)}...`; const action = notificationAction(payload.category); const target = valid.address(payload.to) ? ` to ${shortAddress(payload.to)}` : ""; switch (status) { case "submitted": return `${prefix}${action}${target} submitted (${shortHash}).`; case "success": return `${prefix}${action}${target} confirmed (${shortHash}).`; default: return `${prefix}${action}${target} failed (${shortHash}).`; } }; const notificationTargetUrl = (payload) => { if (typeof window === "undefined") return payload.url; try { const target = new URL(payload.url || "/", window.location.origin); if (isTrackableTxHash(payload.hash)) target.searchParams.set("tx", payload.hash.trim()); if (valid.address(payload.accountAddress)) target.searchParams.set("address", payload.accountAddress.trim()); if (valid.chainId(payload.chainId)) target.searchParams.set("chainId", payload.chainId.trim()); if (typeof payload.rpcUrl === "string" && payload.rpcUrl.trim() !== "") target.searchParams.set("rpcUrl", payload.rpcUrl.trim()); return target.toString(); } catch { return window.location.href; } }; const directNotification = (payload, status) => { if (typeof window === "undefined" || !("Notification" in window)) return; const targetUrl = notificationTargetUrl(payload); try { const notification = new Notification(payload.title, { body: notificationMessage(payload, status), tag: `tx:${payload.hash.toLowerCase()}:${status}`, }); notification.onclick = () => { notification.close(); if (window.location.href !== targetUrl) window.location.href = targetUrl; window.focus(); }; } catch { } }; export const CoinmecaWalletNotificationBridge = ({ provider }) => { const [permission, setPermission] = useState("unsupported"); const [isVisible, setIsVisible] = useState(true); const [permissionPromptPending, setPermissionPromptPending] = useState(false); const instanceIdRef = useRef(`coinmeca-wallet-notification:${Date.now()}:${Math.random().toString(36).slice(2)}`); const channelRef = useRef(null); const workerKindRef = useRef(); const permissionStatusRef = useRef(null); const permissionPromptAtRef = useRef(0); const registrationRef = useRef(null); const initializedRef = useRef(false); const completedRef = useRef(new Set()); const pendingRef = useRef(new Map()); const pollersRef = useRef(new Map()); const trackedRef = useRef(new Set()); const canNotify = permission === "granted"; const useWorker = canNotify && !isVisible && !!registrationRef.current; const broadcast = useCallback((message) => { try { channelRef.current?.postMessage(message); } catch { } }, []); const hasPendingTransactions = useCallback(() => pendingRef.current.size > 0, []); const storageRecord = useCallback((key) => { if (typeof window === "undefined") return; try { const raw = window.localStorage.getItem(key); if (!raw) return; return JSON.parse(raw); } catch { return; } }, []); const setStorageRecord = useCallback((key, value) => { if (typeof window === "undefined") return false; try { window.localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } }, []); const removeStorageRecord = useCallback((key) => { if (typeof window === "undefined") return; try { window.localStorage.removeItem(key); } catch { } }, []); const sharedPermissionPromptAt = useCallback(() => { const current = storageRecord(PERMISSION_PROMPT_COOLDOWN_KEY); return typeof current?.at === "number" ? current.at : 0; }, [storageRecord]); const permissionPromptCooldownRemaining = useCallback(() => { const lastPromptAt = Math.max(permissionPromptAtRef.current, sharedPermissionPromptAt()); if (!lastPromptAt) return 0; return Math.max(0, PERMISSION_PROMPT_COOLDOWN_MS - (Date.now() - lastPromptAt)); }, [sharedPermissionPromptAt]); const markPermissionPromptCooldown = useCallback(() => { const now = Date.now(); permissionPromptAtRef.current = now; setStorageRecord(PERMISSION_PROMPT_COOLDOWN_KEY, { owner: instanceIdRef.current, at: now, }); return now; }, [setStorageRecord]); const claimPermissionPromptOwner = useCallback(() => { if (typeof window === "undefined") return true; const now = Date.now(); const current = storageRecord(PERMISSION_PROMPT_OWNER_KEY); if (current?.owner && current.owner !== instanceIdRef.current && current.expiresAt > now) return false; const next = { owner: instanceIdRef.current, expiresAt: now + PERMISSION_PROMPT_OWNER_TTL_MS, }; if (!setStorageRecord(PERMISSION_PROMPT_OWNER_KEY, next)) return true; const confirmed = storageRecord(PERMISSION_PROMPT_OWNER_KEY); return confirmed?.owner === instanceIdRef.current; }, [setStorageRecord, storageRecord]); const hasPermissionPromptOwner = useCallback(() => { const current = storageRecord(PERMISSION_PROMPT_OWNER_KEY); return current?.owner === instanceIdRef.current && current.expiresAt > Date.now(); }, [storageRecord]); const releasePermissionPromptOwner = useCallback(() => { const current = storageRecord(PERMISSION_PROMPT_OWNER_KEY); if (current?.owner === instanceIdRef.current) removeStorageRecord(PERMISSION_PROMPT_OWNER_KEY); }, [removeStorageRecord, storageRecord]); const requestPermission = useCallback(() => { if (typeof window === "undefined" || !("Notification" in window) || !isSecureNotificationContext() || permission !== "default" || !isVisible || !hasPendingTransactions()) { return; } if (permissionPromptCooldownRemaining() > 0) return; markPermissionPromptCooldown(); Notification.requestPermission() .then((next) => setPermission(next)) .catch(() => undefined); releasePermissionPromptOwner(); }, [ hasPendingTransactions, isVisible, markPermissionPromptCooldown, permission, permissionPromptCooldownRemaining, releasePermissionPromptOwner, ]); const schedulePermissionRequest = useCallback(() => { if (typeof window === "undefined" || !("Notification" in window) || !isSecureNotificationContext() || permission !== "default" || !isVisible || !hasPendingTransactions()) { return; } if (permissionPromptCooldownRemaining() > 0) return; if (!claimPermissionPromptOwner()) return; setPermissionPromptPending(true); }, [claimPermissionPromptOwner, hasPendingTransactions, isVisible, permission, permissionPromptCooldownRemaining]); const claimPollingOwner = useCallback((key) => { if (typeof window === "undefined") return true; const now = Date.now(); const ownerKey = `${POLLING_OWNER_PREFIX}${key}`; const current = storageRecord(ownerKey); if (current?.owner && current.owner !== instanceIdRef.current && current.expiresAt > now) return false; const next = { owner: instanceIdRef.current, expiresAt: now + POLLING_OWNER_TTL_MS, }; if (!setStorageRecord(ownerKey, next)) return true; const confirmed = storageRecord(ownerKey); return confirmed?.owner === instanceIdRef.current; }, [setStorageRecord, storageRecord]); const releasePollingOwner = useCallback((key) => { const ownerKey = `${POLLING_OWNER_PREFIX}${key}`; const current = storageRecord(ownerKey); if (current?.owner === instanceIdRef.current) removeStorageRecord(ownerKey); }, [removeStorageRecord, storageRecord]); const claimNotificationEvent = useCallback((key, status) => { if (typeof window === "undefined") return true; const now = Date.now(); const eventKey = `${NOTIFICATION_EVENT_PREFIX}${status}:${key}`; const current = storageRecord(eventKey); if (current?.at && now - current.at < NOTIFICATION_EVENT_TTL_MS) return false; const next = { owner: instanceIdRef.current, at: now, }; if (!setStorageRecord(eventKey, next)) return true; const confirmed = storageRecord(eventKey); return confirmed?.owner === instanceIdRef.current; }, [setStorageRecord, storageRecord]); const syncPermission = useCallback(() => { if (typeof window === "undefined" || !("Notification" in window) || !isSecureNotificationContext()) { setPermission("unsupported"); return; } setPermission(Notification.permission); }, []); const syncVisibility = useCallback(() => { if (typeof document === "undefined") return; setIsVisible(document.visibilityState === "visible"); }, []); const postWorkerMessage = useCallback(async (type, params) => { if (typeof window === "undefined" || !("serviceWorker" in navigator)) return false; const registration = registrationRef.current; if (!registration) return false; let target = registration.active || registration.waiting || registration.installing; const controller = navigator.serviceWorker.controller; if (!target && controller && (controller === registration.active || controller === registration.waiting || controller === registration.installing)) { target = controller; } if (!target) { await new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange); window.clearTimeout(timer); resolve(); }; const handleControllerChange = () => finish(); const timer = window.setTimeout(finish, 1500); navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange, { once: true }); }); target = registration.active || registration.waiting || registration.installing; const nextController = navigator.serviceWorker.controller; if (!target && nextController && (nextController === registration.active || nextController === registration.waiting || nextController === registration.installing)) { target = nextController; } } if (!target) return false; target.postMessage({ type, protocol: WORKER_PROTOCOL, params }); return true; }, []); const pingWorker = useCallback(async (registration, expectedKind) => { const target = registration.active || registration.waiting || registration.installing; if (!target) return false; return new Promise((resolve) => { const channel = new MessageChannel(); const finish = (result) => { window.clearTimeout(timer); channel.port1.onmessage = null; resolve(result); }; const timer = window.setTimeout(() => finish(false), 1500); channel.port1.onmessage = (event) => { const kind = event.data?.type === "COINMECA_WORKER_PONG" && event.data?.protocol === WORKER_PROTOCOL && event.data?.kind === expectedKind ? expectedKind : false; finish(kind); }; try { target.postMessage({ type: "COINMECA_WORKER_PING", protocol: WORKER_PROTOCOL }, [channel.port2]); } catch { finish(false); } }); }, []); const clearWorkerTracking = useCallback((notify = true) => { trackedRef.current.clear(); if (notify) void postWorkerMessage(CLEAR_TRACKED_TRANSACTIONS, {}); }, [postWorkerMessage]); const clearLocalPollers = useCallback(() => { pollersRef.current.forEach((controller, key) => { controller.abort(); releasePollingOwner(key); }); pollersRef.current.clear(); }, [releasePollingOwner]); const markCompletedFromPeer = useCallback((key) => { if (!key) return; completedRef.current.add(key); const poller = pollersRef.current.get(key); if (poller) { poller.abort(); pollersRef.current.delete(key); } trackedRef.current.delete(key); releasePollingOwner(key); }, [releasePollingOwner]); const resetTracking = useCallback((notify = true) => { initializedRef.current = false; completedRef.current.clear(); pendingRef.current.clear(); setPermissionPromptPending(false); clearLocalPollers(); clearWorkerTracking(notify); releasePermissionPromptOwner(); }, [clearLocalPollers, clearWorkerTracking, releasePermissionPromptOwner]); const fetchReceipt = useCallback(async (rpcUrl, txHash, signal) => { try { const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, signal, body: JSON.stringify({ jsonrpc: "2.0", method: "eth_getTransactionReceipt", params: [txHash], id: 1, }), }); const json = await response.json(); return json?.result; } catch { if (signal.aborted) return; return; } }, []); const delay = useCallback((ms, signal) => { if (signal.aborted) return Promise.resolve(); return new Promise((resolve) => { const timer = window.setTimeout(() => { signal.removeEventListener("abort", handleAbort); resolve(); }, ms); const handleAbort = () => { window.clearTimeout(timer); signal.removeEventListener("abort", handleAbort); resolve(); }; signal.addEventListener("abort", handleAbort, { once: true }); }); }, []); const syncResolvedReceipt = useCallback((key, payload, receipt) => { if (!provider || completedRef.current.has(key)) return; const current = provider.getReceipt(payload.hash, { address: payload.accountAddress, chainId: payload.chainId, }); const status = receipt?.status === "0x1" ? "success" : "failure"; completedRef.current.add(key); broadcast({ type: "completed", key }); if (claimNotificationEvent(key, status)) directNotification(payload, status); releasePollingOwner(key); try { provider.updateReceipt({ ...current, hash: payload.hash, chainId: payload.chainId, to: typeof receipt?.to === "string" ? receipt.to : current?.to, contractAddress: typeof receipt?.contractAddress === "string" ? receipt.contractAddress : current?.contractAddress, blockNumber: receipt?.blockNumber ? Number(receipt.blockNumber) : current?.blockNumber, gasUsed: receipt?.gasUsed ? Number(receipt.gasUsed) : current?.gasUsed, cumulativeGasUsed: receipt?.cumulativeGasUsed ? Number(receipt.cumulativeGasUsed) : current?.cumulativeGasUsed, effectiveGasPrice: receipt?.effectiveGasPrice ? Number(receipt.effectiveGasPrice) : current?.effectiveGasPrice, status, }, { address: payload.accountAddress, chainId: payload.chainId, }); } catch { } }, [broadcast, claimNotificationEvent, provider, releasePollingOwner]); const startLocalPolling = useCallback((key, payload) => { if (!provider || !payload.rpcUrl || pollersRef.current.has(key) || !claimPollingOwner(key)) return; const controller = new AbortController(); pollersRef.current.set(key, controller); void (async () => { try { for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { if (controller.signal.aborted || !claimPollingOwner(key)) return; const receipt = await fetchReceipt(payload.rpcUrl, payload.hash, controller.signal); if (receipt && typeof receipt?.status !== "undefined" && receipt?.status !== null) { syncResolvedReceipt(key, payload, receipt); return; } await delay(POLL_INTERVAL_MS, controller.signal); } } finally { if (pollersRef.current.get(key) === controller) pollersRef.current.delete(key); releasePollingOwner(key); } })(); }, [claimPollingOwner, delay, fetchReceipt, provider, releasePollingOwner, syncResolvedReceipt]); const scanTransactions = useCallback(() => { if (!provider || provider.isLocked || permission === "denied" || permission === "unsupported") { resetTracking(); return; } const chains = provider.chains || []; const accounts = provider.accounts() || []; const chainMap = new Map(); const nextPending = new Map(); const previousPending = pendingRef.current; const seeded = initializedRef.current; let hasPending = false; let hasNewPending = false; chains.forEach((chain) => { const chainId = typeof chain?.chainId !== "undefined" && valid.chainId(chain.chainId) ? parseChainId(chain.chainId) : undefined; if (typeof chainId === "number") chainMap.set(chainId, chain); }); accounts.forEach((account) => { const entries = Object.entries(account?.tx || {}); entries.forEach(([chainId, receipts]) => { if (!Array.isArray(receipts)) return; const resolvedChainId = valid.chainId(chainId) ? parseChainId(chainId) : undefined; if (typeof resolvedChainId !== "number") return; const chain = chainMap.get(resolvedChainId); receipts.forEach((receipt) => { const key = pendingKey(account?.address, resolvedChainId, receipt?.hash); if (!key) return; const payload = pendingTransaction(account, chain, resolvedChainId, receipt); if (!payload) return; if (receipt?.status === "pending") { hasPending = true; nextPending.set(key, payload); completedRef.current.delete(key); if (seeded && !previousPending.has(key)) hasNewPending = true; if (!canNotify) return; if (seeded && !previousPending.has(key) && (!useWorker || !payload.rpcUrl) && claimNotificationEvent(key, "submitted")) { directNotification(payload, "submitted"); } if (useWorker && payload.rpcUrl && !trackedRef.current.has(key)) { trackedRef.current.add(key); void postWorkerMessage(TRACK_TRANSACTION, { ...payload, url: workerKindRef.current === "root" ? "/activity" : payload.url, }); } if (!useWorker && payload.rpcUrl) startLocalPolling(key, payload); return; } if (!canNotify) return; const previous = previousPending.get(key); if ((receipt?.status === "success" || receipt?.status === "failure") && previous && !completedRef.current.has(key) && (!useWorker || !previous.rpcUrl)) { completedRef.current.add(key); broadcast({ type: "completed", key }); releasePollingOwner(key); if (claimNotificationEvent(key, receipt.status)) directNotification(payload, receipt.status); } }); }); }); initializedRef.current = true; pendingRef.current = nextPending; if (!hasPending) { setPermissionPromptPending(false); releasePermissionPromptOwner(); } if (!canNotify) { clearLocalPollers(); clearWorkerTracking(); if (hasPending && hasNewPending) schedulePermissionRequest(); return; } trackedRef.current.forEach((key) => { if (!nextPending.has(key)) trackedRef.current.delete(key); }); pollersRef.current.forEach((controller, key) => { if (useWorker || !nextPending.has(key)) { controller.abort(); pollersRef.current.delete(key); releasePollingOwner(key); } }); if (!useWorker) clearWorkerTracking(); }, [ broadcast, canNotify, claimNotificationEvent, clearLocalPollers, clearWorkerTracking, permission, postWorkerMessage, provider, releasePollingOwner, requestPermission, resetTracking, releasePermissionPromptOwner, schedulePermissionRequest, startLocalPolling, useWorker, ]); useEffect(() => { syncPermission(); syncVisibility(); if (typeof window === "undefined") return; window.addEventListener("focus", syncPermission); document.addEventListener("visibilitychange", syncPermission); document.addEventListener("visibilitychange", syncVisibility); return () => { window.removeEventListener("focus", syncPermission); document.removeEventListener("visibilitychange", syncPermission); document.removeEventListener("visibilitychange", syncVisibility); }; }, [syncPermission, syncVisibility]); useEffect(() => { if (permission !== "default") { setPermissionPromptPending(false); releasePermissionPromptOwner(); } }, [permission, releasePermissionPromptOwner]); useEffect(() => { if (isVisible || !permissionPromptPending) return; setPermissionPromptPending(false); releasePermissionPromptOwner(); }, [isVisible, permissionPromptPending, releasePermissionPromptOwner]); useEffect(() => { if (!isVisible || permission !== "default" || permissionPromptPending || pendingRef.current.size === 0) return; const remaining = permissionPromptCooldownRemaining(); if (remaining <= 0) { schedulePermissionRequest(); return; } const timer = window.setTimeout(() => { schedulePermissionRequest(); }, remaining + 1); return () => { window.clearTimeout(timer); }; }, [isVisible, permission, permissionPromptPending, permissionPromptCooldownRemaining, schedulePermissionRequest]); useEffect(() => { if (typeof window === "undefined" || !permissionPromptPending || permission !== "default" || !isVisible) { return; } const requestFromInteraction = () => { if (!hasPendingTransactions()) { setPermissionPromptPending(false); releasePermissionPromptOwner(); return; } if (!hasPermissionPromptOwner() && !claimPermissionPromptOwner()) { setPermissionPromptPending(false); return; } setPermissionPromptPending(false); requestPermission(); }; window.addEventListener("pointerdown", requestFromInteraction, { once: true }); window.addEventListener("keydown", requestFromInteraction, { once: true }); return () => { window.removeEventListener("pointerdown", requestFromInteraction); window.removeEventListener("keydown", requestFromInteraction); }; }, [ claimPermissionPromptOwner, hasPendingTransactions, hasPermissionPromptOwner, isVisible, permission, permissionPromptPending, releasePermissionPromptOwner, requestPermission, ]); useEffect(() => { if (typeof window === "undefined" || !("Notification" in window) || !isSecureNotificationContext() || !("permissions" in navigator) || typeof navigator.permissions?.query !== "function") { return; } let disposed = false; let status = null; const handleChange = () => syncPermission(); void navigator.permissions .query({ name: "notifications" }) .then((next) => { if (disposed) return; status = next; permissionStatusRef.current = next; if (typeof next.addEventListener === "function") next.addEventListener("change", handleChange); else next.onchange = handleChange; syncPermission(); }) .catch(() => undefined); return () => { disposed = true; if (!status) return; if (permissionStatusRef.current === status) permissionStatusRef.current = null; if (typeof status.removeEventListener === "function") status.removeEventListener("change", handleChange); else if (status.onchange === handleChange) status.onchange = null; }; }, [syncPermission]); useEffect(() => { if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") return; const channel = new BroadcastChannel(NOTIFICATION_CHANNEL); channelRef.current = channel; channel.onmessage = (event) => { const data = event.data; if (!data || typeof data !== "object") return; if (data.type === "scan") { scanTransactions(); return; } if (data.type === "completed" && typeof data.key === "string" && data.key) { markCompletedFromPeer(data.key); } }; return () => { channel.close(); if (channelRef.current === channel) channelRef.current = null; }; }, [markCompletedFromPeer, scanTransactions]); useEffect(() => { if (typeof window === "undefined") return; const onStorage = (event) => { const key = typeof event.key === "string" ? event.key : ""; if (!key) return; if (key === PERMISSION_PROMPT_OWNER_KEY) { if (event.newValue) { const current = storageRecord(PERMISSION_PROMPT_OWNER_KEY); if (current?.owner && current.owner !== instanceIdRef.current && permissionPromptPending) { setPermissionPromptPending(false); } return; } if (!event.newValue && isVisible && permission === "default" && pendingRef.current.size > 0) { schedulePermissionRequest(); } return; } if (key === PERMISSION_PROMPT_COOLDOWN_KEY) { if (permissionPromptPending) setPermissionPromptPending(false); return; } if (key.startsWith(`${NOTIFICATION_EVENT_PREFIX}success:`) || key.startsWith(`${NOTIFICATION_EVENT_PREFIX}failure:`)) { const completedKey = key.replace(/^coinmeca\.wallet\.notification\.event:(success|failure):/, ""); markCompletedFromPeer(completedKey); return; } if (key.startsWith(POLLING_OWNER_PREFIX) || key.startsWith(`${NOTIFICATION_EVENT_PREFIX}submitted:`)) { scanTransactions(); } }; window.addEventListener("storage", onStorage); return () => { window.removeEventListener("storage", onStorage); }; }, [isVisible, markCompletedFromPeer, permission, permissionPromptPending, scanTransactions, schedulePermissionRequest, storageRecord]); useEffect(() => { if (typeof window === "undefined" || !("serviceWorker" in navigator) || !isSecureNotificationContext()) return; let disposed = false; const register = async () => { const candidates = ["/sw.js"]; try { candidates.push(new URL("./notification-sw.js", import.meta.url).toString()); } catch { } for (const script of candidates) { try { const registration = await navigator.serviceWorker.register(script); if (disposed) return; const expectedKind = script === "/sw.js" ? "root" : "bundled"; const kind = await pingWorker(registration, expectedKind); if (!kind) continue; registrationRef.current = registration; workerKindRef.current = kind; scanTransactions(); return; } catch { continue; } } if (!disposed) { registrationRef.current = null; workerKindRef.current = undefined; } }; void register(); return () => { disposed = true; }; }, [pingWorker, scanTransactions]); useEffect(() => { if (permission === "denied" || permission === "unsupported") resetTracking(); else { scanTransactions(); if (canNotify) broadcast({ type: "scan" }); } }, [broadcast, canNotify, isVisible, permission, resetTracking, scanTransactions]); useEffect(() => { if (!provider) return; const update = () => { scanTransactions(); broadcast({ type: "scan" }); }; const resetWorker = () => { clearWorkerTracking(); update(); }; const resetAll = () => resetTracking(); provider.on("lock", resetAll); provider.on("unlock", update); provider.on("chainUpdated", resetWorker); provider.on("txUpdated", update); provider.on("storageUpdated", update); provider.on("storageCleared", resetAll); return () => { provider.off("lock", resetAll); provider.off("unlock", update); provider.off("chainUpdated", resetWorker); provider.off("txUpdated", update); provider.off("storageUpdated", update); provider.off("storageCleared", resetAll); }; }, [broadcast, clearWorkerTracking, provider, resetTracking, scanTransactions]); useEffect(() => { if (typeof window === "undefined") return; const cleanup = () => { resetTracking(false); }; window.addEventListener("pagehide", cleanup); window.addEventListener("beforeunload", cleanup); return () => { window.removeEventListener("pagehide", cleanup); window.removeEventListener("beforeunload", cleanup); }; }, [resetTracking]); useEffect(() => { return () => { resetTracking(false); }; }, [resetTracking]); return null; }; //# sourceMappingURL=notification.js.map