From 672747cc7daf6deecd57d597f681877326ffd3c7 Mon Sep 17 00:00:00 2001 From: gerlofvanek Date: Wed, 13 Aug 2025 10:39:14 +0200 Subject: [PATCH] GUI: Updated toasts and added notifications history + Various fixes. --- basicswap/basicswap.py | 151 ++++-- basicswap/basicswap_util.py | 1 + .../static/js/modules/notification-manager.js | 445 ++++++++++++++++-- basicswap/templates/header.html | 93 +++- basicswap/templates/settings.html | 12 +- basicswap/templates/style.html | 17 +- basicswap/templates/wallet.html | 72 ++- basicswap/ui/page_settings.py | 8 + basicswap/ui/page_wallet.py | 25 + 9 files changed, 729 insertions(+), 95 deletions(-) diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 13ad65f..90956b9 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -1784,61 +1784,86 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): f"Invalid swap type for: {coin_from.name} -> {coin_to.name}" ) - def notify(self, event_type, event_data, cursor=None) -> None: - show_event = event_type not in self._disabled_notification_types - if event_type == NT.OFFER_RECEIVED: - offer_id: bytes = bytes.fromhex(event_data["offer_id"]) - self.log.debug(f"Received new offer {self.log.id(offer_id)}") - if self.ws_server and show_event: - event_data["event"] = "new_offer" - self.ws_server.send_message_to_all(json.dumps(event_data)) - elif event_type == NT.BID_RECEIVED: - offer_id: bytes = bytes.fromhex(event_data["offer_id"]) - offer_type: str = event_data["type"] - bid_id: bytes = bytes.fromhex(event_data["bid_id"]) - self.log.info( - f"Received valid bid {self.log.id(bid_id)} for {offer_type} offer {self.log.id(offer_id)}" - ) - if self.ws_server and show_event: - event_data["event"] = "new_bid" - self.ws_server.send_message_to_all(json.dumps(event_data)) - elif event_type == NT.BID_ACCEPTED: - bid_id: bytes = bytes.fromhex(event_data["bid_id"]) - self.log.info(f"Received valid bid accept for {self.log.id(bid_id)}") - if self.ws_server and show_event: - event_data["event"] = "bid_accepted" - self.ws_server.send_message_to_all(json.dumps(event_data)) - else: - self.log.warning(f"Unknown notification {event_type}") - + def _process_notification_safe(self, event_type, event_data) -> None: try: + show_event = event_type not in self._disabled_notification_types + if event_type == NT.OFFER_RECEIVED: + offer_id: bytes = bytes.fromhex(event_data["offer_id"]) + self.log.debug(f"Received new offer {self.log.id(offer_id)}") + if self.ws_server and show_event: + event_data["event"] = "new_offer" + self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.BID_RECEIVED: + offer_id: bytes = bytes.fromhex(event_data["offer_id"]) + offer_type: str = event_data["type"] + bid_id: bytes = bytes.fromhex(event_data["bid_id"]) + self.log.info( + f"Received valid bid {self.log.id(bid_id)} for {offer_type} offer {self.log.id(offer_id)}" + ) + if self.ws_server and show_event: + event_data["event"] = "new_bid" + self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.BID_ACCEPTED: + bid_id: bytes = bytes.fromhex(event_data["bid_id"]) + self.log.info(f"Received valid bid accept for {self.log.id(bid_id)}") + if self.ws_server and show_event: + event_data["event"] = "bid_accepted" + self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.SWAP_COMPLETED: + bid_id: bytes = bytes.fromhex(event_data["bid_id"]) + self.log.info(f"Swap completed for bid {self.log.id(bid_id)}") + event_data["event"] = "swap_completed" + + if self.ws_server and show_event: + self.ws_server.send_message_to_all(json.dumps(event_data)) + else: + self.log.warning(f"Unknown notification {event_type}") + now: int = self.getTime() - use_cursor = self.openDB(cursor) - self.add( - Notification( - active_ind=1, - created_at=now, - event_type=int(event_type), - event_data=bytes(json.dumps(event_data), "UTF-8"), - ), - use_cursor, - ) + use_cursor = self.openDB(None) + try: + self.add( + Notification( + active_ind=1, + created_at=now, + event_type=int(event_type), + event_data=bytes(json.dumps(event_data), "UTF-8"), + ), + use_cursor, + ) - use_cursor.execute( - "DELETE FROM notifications WHERE record_id NOT IN (SELECT record_id FROM notifications WHERE active_ind=1 ORDER BY created_at ASC LIMIT ?)", - (self._keep_notifications,), - ) + use_cursor.execute( + "DELETE FROM notifications WHERE record_id NOT IN (SELECT record_id FROM notifications WHERE active_ind=1 ORDER BY created_at ASC LIMIT ?)", + (self._keep_notifications,), + ) - if show_event: - self._notifications_cache[now] = (event_type, event_data) - while len(self._notifications_cache) > self._show_notifications: - # dicts preserve insertion order in Python 3.7+ - self._notifications_cache.pop(next(iter(self._notifications_cache))) + if show_event: + self._notifications_cache[now] = (event_type, event_data) + while len(self._notifications_cache) > self._show_notifications: + # dicts preserve insertion order in Python 3.7+ + self._notifications_cache.pop(next(iter(self._notifications_cache))) - finally: - if cursor is None: + finally: self.closeDB(use_cursor) + except Exception as ex: + self.log.error( + f"Notification processing failed for event_type {event_type}: {ex}" + ) + + def notify(self, event_type, event_data, cursor=None) -> None: + """Submit notification for processing in isolated thread.""" + try: + self.thread_pool.submit( + self._process_notification_safe, event_type, event_data + ) + except Exception as ex: + self.log.error(f"Failed to submit notification to thread pool: {ex}") + try: + self._process_notification_safe(event_type, event_data) + except Exception as ex2: + self.log.error(f"Notification fallback also failed: {ex2}") + def buildNotificationsCache(self, cursor): self._notifications_cache.clear() q = cursor.execute( @@ -2093,6 +2118,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "Rate mismatch.", ) + def validateMessageNets(self, message_nets: str) -> None: + try: + self.expandMessageNets(message_nets) + except Exception as e: + raise ValueError(f"Invalid message networks: {e}") + def ensureWalletCanSend( self, ci, swap_type, ensure_balance: int, estimated_fee: int, for_offer=True ) -> None: @@ -6112,6 +6143,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid.setState(BidStates.SWAP_COMPLETED) self.saveBidInSession(bid_id, bid, cursor, xmr_swap) self.commitDB() + self.notify(NT.SWAP_COMPLETED, {"bid_id": bid_id.hex()}) elif state == BidStates.XMR_SWAP_SCRIPT_TX_PREREFUND: if TxTypes.XMR_SWAP_A_LOCK_REFUND in bid.txns: refund_tx = bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND] @@ -6381,6 +6413,18 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid.setState(BidStates.SWAP_COMPLETED) self.saveBid(bid_id, bid) + try: + self.notify( + NT.SWAP_COMPLETED, + { + "bid_id": bid_id.hex(), + }, + ) + except Exception as ex: + self.log.warning( + f"Failed to send swap completion notification: {ex}" + ) + return True # Mark bid for archiving if save_bid: @@ -6673,6 +6717,17 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if not was_received: bid.setState(BidStates.SWAP_COMPLETED) + try: + self.notify( + NT.SWAP_COMPLETED, + { + "bid_id": bid_id.hex(), + }, + ) + except Exception as ex: + self.log.warning( + f"Failed to send swap completion notification: {ex}" + ) else: # Could already be processed if spend was detected in the mempool self.log.warning( diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index 20b2f90..43bddef 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -243,6 +243,7 @@ class NotificationTypes(IntEnum): OFFER_RECEIVED = auto() BID_RECEIVED = auto() BID_ACCEPTED = auto() + SWAP_COMPLETED = auto() class ConnectionRequestTypes(IntEnum): diff --git a/basicswap/static/js/modules/notification-manager.js b/basicswap/static/js/modules/notification-manager.js index f22378d..97d6d0c 100644 --- a/basicswap/static/js/modules/notification-manager.js +++ b/basicswap/static/js/modules/notification-manager.js @@ -7,6 +7,7 @@ const NotificationManager = (function() { showBidAccepted: true, showBalanceChanges: true, showOutgoingTransactions: true, + showSwapCompleted: true, notificationDuration: 20000 }; @@ -34,8 +35,166 @@ const NotificationManager = (function() { } let config = loadConfig(); + let notificationHistory = []; + const MAX_HISTORY_ITEMS = 10; - function ensureToastContainer() { + function loadNotificationHistory() { + try { + const saved = localStorage.getItem('notification_history'); + if (saved) { + notificationHistory = JSON.parse(saved); + } + } catch (e) { + console.error('Error loading notification history:', e); + notificationHistory = []; + } + } + + function saveNotificationHistory() { + try { + localStorage.setItem('notification_history', JSON.stringify(notificationHistory)); + } catch (e) { + console.error('Error saving notification history:', e); + } + } + + function addToHistory(title, type, options) { + const historyItem = { + id: Date.now(), + title: title, + type: type, + subtitle: options.subtitle || '', + coinSymbol: options.coinSymbol || '', + coinFrom: options.coinFrom || null, + coinTo: options.coinTo || null, + timestamp: new Date().toLocaleString(), + timestampMs: Date.now() + }; + + notificationHistory.unshift(historyItem); + + if (notificationHistory.length > MAX_HISTORY_ITEMS) { + notificationHistory = notificationHistory.slice(0, MAX_HISTORY_ITEMS); + } + + saveNotificationHistory(); + updateHistoryDropdown(); + } + + function updateHistoryDropdown() { + const dropdown = document.getElementById('notification-history-dropdown'); + const mobileDropdown = document.getElementById('notification-history-dropdown-mobile'); + + const emptyMessage = '
No notifications yet
'; + const emptyMessageMobile = '
No notifications yet
'; + + if (notificationHistory.length === 0) { + if (dropdown) dropdown.innerHTML = emptyMessage; + if (mobileDropdown) mobileDropdown.innerHTML = emptyMessageMobile; + return; + } + + const clearAllButton = ` +
+ +
+ `; + + let historyHTML = ''; + let mobileHistoryHTML = ''; + + notificationHistory.forEach(item => { + let coinIconHtml = ''; + if (item.coinSymbol) { + const coinIcon = getCoinIcon(item.coinSymbol); + coinIconHtml = `${item.coinSymbol}`; + } + + const typeIcon = getToastIcon(item.type); + const typeColor = getToastColor(item.type, item); + const typeIconHtml = `
${typeIcon}
`; + + let enhancedTitle = item.title; + if ((item.type === 'new_offer' || item.type === 'new_bid') && item.coinFrom && item.coinTo) { + const coinFromIcon = getCoinIcon(getCoinDisplayName(item.coinFrom)); + const coinToIcon = getCoinIcon(getCoinDisplayName(item.coinTo)); + const coinFromName = getCoinDisplayName(item.coinFrom); + const coinToName = getCoinDisplayName(item.coinTo); + + enhancedTitle = item.title + .replace(new RegExp(`(\\d+\\.\\d+)\\s+${coinFromName}`, 'g'), `${coinFromName}$1 ${coinFromName}`) + .replace(new RegExp(`(\\d+\\.\\d+)\\s+${coinToName}`, 'g'), `${coinToName}$1 ${coinToName}`); + } + + const clickAction = getNotificationClickAction(item); + const itemHTML = ` +
+
+ ${typeIconHtml} + ${coinIconHtml} +
+
${enhancedTitle}
+ ${item.subtitle ? `
${item.subtitle}
` : ''} +
${item.timestamp}
+
+
+
+ `; + + historyHTML += itemHTML; + + const mobileItemHTML = ` +
+
+ ${typeIconHtml} + ${coinIconHtml} +
+
${enhancedTitle}
+ ${item.subtitle ? `
${item.subtitle}
` : ''} +
${item.timestamp}
+
+
+
+ `; + + mobileHistoryHTML += mobileItemHTML; + }); + + historyHTML += clearAllButton; + mobileHistoryHTML += clearAllButton; + + if (dropdown) dropdown.innerHTML = historyHTML; + if (mobileDropdown) mobileDropdown.innerHTML = mobileHistoryHTML; + } + + function getNotificationClickAction(item) { + if (item.type === 'balance_change' && item.coinSymbol) { + return `window.location.href='/wallet/${item.coinSymbol.toLowerCase()}'`; + } + + if (item.type === 'new_offer') { + return `window.location.href='/offers'`; + } + + if (item.type === 'new_bid' || item.type === 'bid_accepted') { + return `window.location.href='/bids'`; + } + + if (item.title.includes('offer') || item.title.includes('Offer')) { + return `window.location.href='/offers'`; + } + + if (item.title.includes('bid') || item.title.includes('Bid') || item.title.includes('swap') || item.title.includes('Swap')) { + return `window.location.href='/bids'`; + } + + return null; +} + +function ensureToastContainer() { let container = document.getElementById('ul_updates'); if (!container) { const floating_div = document.createElement('div'); @@ -66,6 +225,9 @@ const NotificationManager = (function() { 'bid_accepted': ` `, + 'swap_completed': ` + + `, 'balance_change': ` `, @@ -81,6 +243,7 @@ const NotificationManager = (function() { 'new_offer': 'bg-blue-500', 'new_bid': 'bg-green-500', 'bid_accepted': 'bg-purple-500', + 'swap_completed': 'bg-green-600', 'balance_change': 'bg-yellow-500', 'success': 'bg-blue-500' }; @@ -96,20 +259,67 @@ const NotificationManager = (function() { return colors[type] || colors['success']; } +// Todo: Remove later and use global. + function getCoinDisplayName(coinId) { + const coinMap = { + 1: 'PART', + 2: 'BTC', + 3: 'LTC', + 4: 'DCR', + 5: 'NMC', + 6: 'XMR', + 7: 'PART (Blind)', + 8: 'PART (Anon)', + 9: 'WOW', + 11: 'PIVX', + 12: 'DASH', + 13: 'FIRO', + 14: 'NAV', + 15: 'LTC (MWEB)', + 17: 'BCH', + 18: 'DOGE' + }; + return coinMap[coinId] || `Coin ${coinId}`; + } + +// Todo: Remove later. + function formatCoinAmount(amount, coinId) { + const divisors = { + 1: 100000000, // PART - 8 decimals + 2: 100000000, // BTC - 8 decimals + 3: 100000000, // LTC - 8 decimals + 4: 100000000, // DCR - 8 decimals + 5: 100000000, // NMC - 8 decimals + 6: 1000000000000, // XMR - 12 decimals + 7: 100000000, // PART (Blind) - 8 decimals + 8: 100000000, // PART (Anon) - 8 decimals + 9: 100000000000, // WOW - 11 decimals + 11: 100000000, // PIVX - 8 decimals + 12: 100000000, // DASH - 8 decimals + 13: 100000000, // FIRO - 8 decimals + 14: 100000000, // NAV - 8 decimals + 15: 100000000, // LTC (MWEB) - 8 decimals + 17: 100000000, // BCH - 8 decimals + 18: 100000000 // DOGE - 8 decimals + }; + + const divisor = divisors[coinId] || 100000000; + const displayAmount = amount / divisor; + + return displayAmount.toFixed(8).replace(/\.?0+$/, ''); + } + const publicAPI = { initialize: function(options = {}) { Object.assign(config, options); - + loadNotificationHistory(); + updateHistoryDropdown(); this.initializeBalanceTracking(); if (window.CleanupManager) { window.CleanupManager.registerResource('notificationManager', this, (mgr) => { - - if (this.balanceTimeouts) { - Object.values(this.balanceTimeouts).forEach(timeout => clearTimeout(timeout)); - } - console.log('NotificationManager disposed'); + mgr.dispose(); }); } @@ -121,6 +331,16 @@ const NotificationManager = (function() { return this; }, + getConfig: function() { + return { ...config }; + }, + + clearAllNotifications: function() { + notificationHistory = []; + localStorage.removeItem('notification_history'); + updateHistoryDropdown(); + }, + getSettings: function() { return { ...config }; }, @@ -170,24 +390,45 @@ const NotificationManager = (function() { setTimeout(() => { this.createToast( - 'New network offer', + 'BTC1.00000000 BTC → XMR15.50000000 XMR', 'new_offer', - { offerId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', subtitle: 'Click to view offer' } + { + offerId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', + subtitle: 'New offer • Rate: 1 BTC = 15.50000000 XMR', + coinFrom: 2, + coinTo: 6 + } ); }, 3000); setTimeout(() => { this.createToast( - 'New bid received', + 'BTC0.50000000 BTC → XMR7.75000000 XMR', 'new_bid', - { bidId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', subtitle: 'Click to view bid' } + { + bidId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', + subtitle: 'New bid • Rate: 1 BTC = 15.50000000 XMR', + coinFrom: 2, + coinTo: 6 + } ); }, 3500); + + setTimeout(() => { + this.createToast( + 'Swap completed successfully', + 'swap_completed', + { + bidId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', + subtitle: 'Click to view details' + } + ); + }, 4000); + }, - - initializeBalanceTracking: function() { + this.checkAndResetStaleBalanceTracking(); fetch('/json/walletbalances') .then(response => response.json()) @@ -201,14 +442,17 @@ const NotificationManager = (function() { const storageKey = `prev_balance_${coinKey}`; const pendingStorageKey = `prev_pending_${coinKey}`; - if (!localStorage.getItem(storageKey)) { localStorage.setItem(storageKey, balance.toString()); + localStorage.setItem(`${storageKey}_timestamp`, Date.now().toString()); } if (!localStorage.getItem(pendingStorageKey)) { localStorage.setItem(pendingStorageKey, pending.toString()); + localStorage.setItem(`${pendingStorageKey}_timestamp`, Date.now().toString()); } }); + + localStorage.setItem('last_balance_fetch', Date.now().toString()); } }) .catch(error => { @@ -216,14 +460,50 @@ const NotificationManager = (function() { }); }, + checkAndResetStaleBalanceTracking: function() { + const lastFetch = localStorage.getItem('last_balance_fetch'); + const now = Date.now(); + const staleThreshold = 10 * 60 * 1000; + + if (!lastFetch || (now - parseInt(lastFetch)) > staleThreshold) { + console.log('Resetting stale balance tracking to prevent false notifications'); + this.resetBalanceTracking(); + } + }, + + resetBalanceTracking: function() { + const keys = Object.keys(localStorage); + keys.forEach(key => { + if (key.startsWith('prev_balance_') || key.startsWith('prev_pending_') || key.startsWith('last_notification_') || key.startsWith('balance_change_')) { + localStorage.removeItem(key); + } + }); + }, + + getNotificationHistory: function() { + return notificationHistory; + }, + + clearNotificationHistory: function() { + notificationHistory = []; + localStorage.removeItem('notification_history'); + updateHistoryDropdown(); + }, + + updateHistoryDropdown: function() { + updateHistoryDropdown(); + }, + createToast: function(title, type = 'success', options = {}) { + const plainTitle = title.replace(/<[^>]*>/g, ''); + addToHistory(plainTitle, type, options); + const messages = ensureToastContainer(); const message = document.createElement('li'); - const toastId = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const toastId = `toast-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; const iconColor = getToastColor(type, options); const icon = getToastIcon(type); - let coinIconHtml = ''; if (options.coinSymbol) { const coinIcon = getCoinIcon(options.coinSymbol); @@ -278,7 +558,6 @@ const NotificationManager = (function() { `; messages.appendChild(message); - setTimeout(() => { if (message.parentNode) { message.classList.add('toast-slide-out'); @@ -286,6 +565,7 @@ const NotificationManager = (function() { if (message.parentNode) { message.parentNode.removeChild(message); } + }, 300); } }, config.notificationDuration); @@ -298,16 +578,42 @@ const NotificationManager = (function() { switch (data.event) { case 'new_offer': - toastTitle = `New network offer`; + if (data.coin_from && data.coin_to && data.amount_from && data.amount_to) { + const coinFromName = getCoinDisplayName(data.coin_from); + const coinToName = getCoinDisplayName(data.coin_to); + const amountFrom = formatCoinAmount(data.amount_from, data.coin_from); + const amountTo = formatCoinAmount(data.amount_to, data.coin_to); + const coinFromIcon = getCoinIcon(coinFromName); + const coinToIcon = getCoinIcon(coinToName); + toastTitle = `${coinFromName}${amountFrom} ${coinFromName} → ${coinToName}${amountTo} ${coinToName}`; + toastOptions.subtitle = `New offer • Rate: 1 ${coinFromName} = ${(data.amount_to / data.amount_from).toFixed(8)} ${coinToName}`; + toastOptions.coinFrom = data.coin_from; + toastOptions.coinTo = data.coin_to; + } else { + toastTitle = `New network offer`; + toastOptions.subtitle = 'Click to view offer'; + } toastType = 'new_offer'; toastOptions.offerId = data.offer_id; - toastOptions.subtitle = 'Click to view offer'; shouldShowToast = config.showNewOffers; break; case 'new_bid': - toastTitle = `New bid received`; + if (data.coin_from && data.coin_to && data.bid_amount && data.bid_amount_to) { + const coinFromName = getCoinDisplayName(data.coin_from); + const coinToName = getCoinDisplayName(data.coin_to); + const bidAmountFrom = formatCoinAmount(data.bid_amount, data.coin_from); + const bidAmountTo = formatCoinAmount(data.bid_amount_to, data.coin_to); + const coinFromIcon = getCoinIcon(coinFromName); + const coinToIcon = getCoinIcon(coinToName); + toastTitle = `${coinFromName}${bidAmountFrom} ${coinFromName} → ${coinToName}${bidAmountTo} ${coinToName}`; + toastOptions.subtitle = `New bid • Rate: 1 ${coinFromName} = ${(data.bid_amount_to / data.bid_amount).toFixed(8)} ${coinToName}`; + toastOptions.coinFrom = data.coin_from; + toastOptions.coinTo = data.coin_to; + } else { + toastTitle = `New bid received`; + toastOptions.subtitle = 'Click to view bid'; + } toastOptions.bidId = data.bid_id; - toastOptions.subtitle = 'Click to view bid'; toastType = 'new_bid'; shouldShowToast = config.showNewBids; break; @@ -318,6 +624,15 @@ const NotificationManager = (function() { toastType = 'bid_accepted'; shouldShowToast = config.showBidAccepted; break; + + case 'swap_completed': + toastTitle = `Swap completed successfully`; + toastOptions.bidId = data.bid_id; + toastOptions.subtitle = 'Click to view details'; + toastType = 'swap_completed'; + shouldShowToast = config.showSwapCompleted; + break; + case 'coin_balance_updated': if (data.coin && config.showBalanceChanges) { this.handleBalanceUpdate(data); @@ -350,7 +665,6 @@ const NotificationManager = (function() { }, fetchAndShowBalanceChange: function(coinSymbol) { - fetch('/json/walletbalances') .then(response => response.json()) .then(balanceData => { @@ -371,6 +685,8 @@ const NotificationManager = (function() { coinsToCheck.forEach(coinData => { this.checkSingleCoinBalance(coinData, coinSymbol); }); + + localStorage.setItem('last_balance_fetch', Date.now().toString()); } }) .catch(error => { @@ -385,15 +701,48 @@ const NotificationManager = (function() { const coinKey = coinData.name.replace(/\s+/g, '_'); const storageKey = `prev_balance_${coinKey}`; const pendingStorageKey = `prev_pending_${coinKey}`; + const lastNotificationKey = `last_notification_${coinKey}`; + const prevBalance = parseFloat(localStorage.getItem(storageKey)) || 0; const prevPending = parseFloat(localStorage.getItem(pendingStorageKey)) || 0; - + const lastNotificationTime = parseInt(localStorage.getItem(lastNotificationKey)) || 0; const balanceIncrease = currentBalance - prevBalance; const pendingIncrease = currentPending - prevPending; const pendingDecrease = prevPending - currentPending; + + const totalChange = Math.abs(balanceIncrease) + Math.abs(pendingIncrease); + const maxReasonableChange = Math.max(currentBalance, prevBalance) * 0.5; + + if (totalChange > maxReasonableChange && totalChange > 1.0) { + console.log(`Detected potentially stale balance data for ${coinData.name}, resetting tracking`); + localStorage.setItem(storageKey, currentBalance.toString()); + localStorage.setItem(pendingStorageKey, currentPending.toString()); + return; + } + + const now = Date.now(); + const minTimeBetweenNotifications = 30000; + const balanceChangeKey = `balance_change_${coinKey}`; + const lastBalanceChange = localStorage.getItem(balanceChangeKey); + + const currentChangeSignature = `${currentBalance}_${currentPending}`; + + if (lastBalanceChange === currentChangeSignature) { + localStorage.setItem(storageKey, currentBalance.toString()); + localStorage.setItem(pendingStorageKey, currentPending.toString()); + return; + } + + if (now - lastNotificationTime < minTimeBetweenNotifications) { + localStorage.setItem(storageKey, currentBalance.toString()); + localStorage.setItem(pendingStorageKey, currentPending.toString()); + localStorage.setItem(balanceChangeKey, currentChangeSignature); + return; + } + const isPendingToConfirmed = pendingDecrease > 0.00000001 && balanceIncrease > 0.00000001; @@ -408,7 +757,9 @@ const NotificationManager = (function() { variantInfo = ` (${coinData.name.replace('Litecoin ', '')})`; } - if (balanceIncrease > 0.00000001) { + let notificationShown = false; + + if (balanceIncrease > 0.00000001 && config.showBalanceChanges) { const displayAmount = balanceIncrease.toFixed(8).replace(/\.?0+$/, ''); const subtitle = isPendingToConfirmed ? 'Funds confirmed' : 'Incoming funds confirmed'; this.createToast( @@ -419,6 +770,7 @@ const NotificationManager = (function() { subtitle: subtitle } ); + notificationShown = true; } if (balanceIncrease < -0.00000001 && config.showOutgoingTransactions) { @@ -431,6 +783,7 @@ const NotificationManager = (function() { subtitle: 'Funds sent' } ); + notificationShown = true; } if (pendingIncrease > 0.00000001) { @@ -443,6 +796,7 @@ const NotificationManager = (function() { subtitle: 'Incoming funds pending' } ); + notificationShown = true; } if (pendingIncrease < -0.00000001 && config.showOutgoingTransactions && !isPendingToConfirmed) { @@ -455,9 +809,9 @@ const NotificationManager = (function() { subtitle: 'Funds sending' } ); + notificationShown = true; } - if (pendingDecrease > 0.00000001 && !isPendingToConfirmed) { const displayAmount = pendingDecrease.toFixed(8).replace(/\.?0+$/, ''); this.createToast( @@ -468,11 +822,16 @@ const NotificationManager = (function() { subtitle: 'Pending funds confirmed' } ); + notificationShown = true; } - localStorage.setItem(storageKey, currentBalance.toString()); localStorage.setItem(pendingStorageKey, currentPending.toString()); + localStorage.setItem(balanceChangeKey, currentChangeSignature); + + if (notificationShown) { + localStorage.setItem(lastNotificationKey, now.toString()); + } }, @@ -480,6 +839,20 @@ const NotificationManager = (function() { updateConfig: function(newConfig) { Object.assign(config, newConfig); return this; + }, + + manualResetBalanceTracking: function() { + this.resetBalanceTracking(); + this.initializeBalanceTracking(); + }, + + dispose: function() { + if (this.balanceTimeouts) { + Object.values(this.balanceTimeouts).forEach(timeout => { + clearTimeout(timeout); + }); + this.balanceTimeouts = {}; + } } }; @@ -488,7 +861,9 @@ const NotificationManager = (function() { while (element.nodeName !== "BUTTON") { element = element.parentNode; } - element.parentNode.parentNode.removeChild(element.parentNode); + const toastElement = element.parentNode; + + toastElement.parentNode.removeChild(toastElement); }; return publicAPI; @@ -496,13 +871,21 @@ const NotificationManager = (function() { window.NotificationManager = NotificationManager; -document.addEventListener('DOMContentLoaded', function() { +window.resetBalanceTracking = function() { + if (window.NotificationManager && window.NotificationManager.manualResetBalanceTracking) { + window.NotificationManager.manualResetBalanceTracking(); + } +}; +window.testNotification = function() { + if (window.NotificationManager) { + window.NotificationManager.createToast('Test Notification', 'success', { subtitle: 'This is a test notification' }); + } +}; + +document.addEventListener('DOMContentLoaded', function() { if (!window.notificationManagerInitialized) { window.NotificationManager.initialize(window.notificationConfig || {}); window.notificationManagerInitialized = true; } }); - - -console.log('NotificationManager initialized'); diff --git a/basicswap/templates/header.html b/basicswap/templates/header.html index a3cbf77..2124c54 100644 --- a/basicswap/templates/header.html +++ b/basicswap/templates/header.html @@ -45,6 +45,32 @@ }; })(); + function toggleNotificationDropdown(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + const dropdown = document.getElementById('notification-history-dropdown'); + const mobileDropdown = document.getElementById('notification-history-dropdown-mobile'); + + if (window.innerWidth >= 768) { + if (dropdown) { + dropdown.classList.toggle('hidden'); + } + } else { + if (mobileDropdown) { + mobileDropdown.classList.toggle('hidden'); + } + } + } + + function clearAllNotifications() { + if (window.NotificationManager && typeof window.NotificationManager.clearAllNotifications === 'function') { + window.NotificationManager.clearAllNotifications(); + } + } + (function() { const isDarkMode = localStorage.getItem('color-theme') === 'dark' || (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); @@ -159,11 +185,10 @@
  • - + {{ new_offer_svg | safe }} - Place new Offer + Place New Offer
  • @@ -173,7 +198,7 @@
  • {{ love_svg | safe }} - Donate + Donations
  • @@ -398,7 +423,18 @@ {% endif %} - +
    + + +
    + + + @@ -767,3 +815,34 @@ + + diff --git a/basicswap/templates/settings.html b/basicswap/templates/settings.html index 27cdddf..31de22f 100644 --- a/basicswap/templates/settings.html +++ b/basicswap/templates/settings.html @@ -495,6 +495,16 @@

    Show notifications for sent funds

    +
    +
    + + +
    +

    Show notifications when swaps complete successfully

    +
    + + + @@ -553,13 +563,13 @@
    @@ -580,6 +580,28 @@ function copyToClipboardFallback(text) { document.body.removeChild(textArea); } + +function fillDonationAddress(address, type) { + const addressInput = document.querySelector('input[name^="to_"]'); + if (addressInput) { + addressInput.value = address; + addressInput.focus(); + + const typeIndicator = document.getElementById('donation-type-indicator'); + if (typeIndicator && type) { + typeIndicator.textContent = type; + } + + const withdrawTypeSelect = document.getElementById('withdraw_type'); + if (withdrawTypeSelect && type) { + if (type === 'LTC') { + withdrawTypeSelect.value = 'plain'; + } else if (type === 'MWEB') { + withdrawTypeSelect.value = 'mweb'; + } + } + } +}
    @@ -643,6 +665,54 @@ function copyToClipboardFallback(text) { {% endif %} + {% if donation_info %} + + +
    +
    +
    + {{ love_svg | safe }} +
    +
    +

    Support BasicSwap Development!

    +

    + Consider donating {{ donation_info.coin_name }} to help fund development. +

    + + {% if donation_info.mweb_address %} +
    +
    + + +
    +
    + Current type: LTC +
    +
    + {% else %} + + {% endif %} + + +
    +
    +
    + + + {% endif %} {{ w.name }} Address: diff --git a/basicswap/ui/page_settings.py b/basicswap/ui/page_settings.py index 64e1e50..5d61458 100644 --- a/basicswap/ui/page_settings.py +++ b/basicswap/ui/page_settings.py @@ -86,6 +86,11 @@ def page_settings(self, url_split, post_string): form_data, "notifications_outgoing_transactions", "false" ) ), + "notifications_swap_completed": toBool( + get_data_entry_or( + form_data, "notifications_swap_completed", "false" + ) + ), "notifications_duration": int( get_data_entry_or(form_data, "notifications_duration", "20") ), @@ -234,6 +239,9 @@ def page_settings(self, url_split, post_string): "notifications_outgoing_transactions": swap_client.settings.get( "notifications_outgoing_transactions", True ), + "notifications_swap_completed": swap_client.settings.get( + "notifications_swap_completed", True + ), "notifications_duration": swap_client.settings.get( "notifications_duration", 20 ), diff --git a/basicswap/ui/page_wallet.py b/basicswap/ui/page_wallet.py index 8efca8c..7062f6d 100644 --- a/basicswap/ui/page_wallet.py +++ b/basicswap/ui/page_wallet.py @@ -21,6 +21,15 @@ from basicswap.chainparams import ( getCoinIdFromTicker, ) +# Todo: Move at JS +DONATION_ADDRESSES = { + "XMR": "8BuQsYBNdfhfoWsvVR1unE7YuZEoTkC4hANaPm2fD6VR5VM2DzQoJhq2CHHXUN1UCWQfH3dctJgorSRxksVa5U4RNTJkcAc", + "LTC": "ltc1qevlumv48nz2afl0re9ml4tdewc56svxq3egkyt", + "LTC_MWEB": "ltcmweb1qqt9rwznnxzkghv4s5wgtwxs0m0ry6n3atp95f47slppapxljde3xyqmdlnrc8ag7y2k354jzdc4pc4ks0kr43jehr77lngdecgh6689nn5mgv5yn", + "BTC": "bc1q72j07vkn059xnmsrkk8x9up9lgvd9h9xjf8cq8", + "PART": "pw1qf59ef0zjdckldjs8smfhv4j04gsjv302w7pdpz", +} + def format_wallet_data(swap_client, ci, w): wf = { @@ -376,6 +385,21 @@ def page_wallet(self, url_split, post_string): checkAddressesOwned(swap_client, ci, wallet_data) + donation_info = None + ticker = wallet_data.get("ticker", "").upper() + + if ticker == "LTC": + donation_info = { + "address": DONATION_ADDRESSES["LTC"], + "coin_name": wallet_data.get("name", "Litecoin"), + "mweb_address": DONATION_ADDRESSES["LTC_MWEB"], + } + elif ticker in DONATION_ADDRESSES: + donation_info = { + "address": DONATION_ADDRESSES[ticker], + "coin_name": wallet_data.get("name", ticker), + } + template = server.env.get_template("wallet.html") return self.render_template( template, @@ -385,5 +409,6 @@ def page_wallet(self, url_split, post_string): "w": wallet_data, "summary": summary, "block_unknown_seeds": swap_client._restrict_unknown_seed_wallets, + "donation_info": donation_info, }, )