diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 29e56b5..1ff4327 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -186,6 +186,53 @@ def validOfferStateToReceiveBid(offer_state): return False +def checkAndNotifyBalanceChange( + swap_client, coin_type, ci, cc, new_height, trigger_source="block" +): + if not swap_client.ws_server: + return + + try: + blockchain_info = ci.getBlockchainInfo() + verification_progress = blockchain_info.get("verificationprogress", 1.0) + if verification_progress < 0.99: + return + except Exception: + return + + try: + current_balance = ci.getSpendableBalance() + current_total_balance = swap_client.getTotalBalance(coin_type) + cached_balance = cc.get("cached_balance", None) + cached_total_balance = cc.get("cached_total_balance", None) + + current_unconfirmed = current_total_balance - current_balance + cached_unconfirmed = cc.get("cached_unconfirmed", None) + + if ( + cached_balance is None + or current_balance != cached_balance + or cached_total_balance is None + or current_total_balance != cached_total_balance + or cached_unconfirmed is None + or current_unconfirmed != cached_unconfirmed + ): + cc["cached_balance"] = current_balance + cc["cached_total_balance"] = current_total_balance + cc["cached_unconfirmed"] = current_unconfirmed + balance_event = { + "event": "coin_balance_updated", + "coin": ci.ticker(), + "height": new_height, + "trigger": trigger_source, + } + swap_client.ws_server.send_message_to_all(json.dumps(balance_event)) + except Exception: + cc["cached_balance"] = None + cc["cached_total_balance"] = None + cc["cached_unconfirmed"] = None + + def threadPollXMRChainState(swap_client, coin_type): ci = swap_client.ci(coin_type) cc = swap_client.coin_clients[coin_type] @@ -198,6 +245,11 @@ def threadPollXMRChainState(swap_client, coin_type): ) with swap_client.mxDB: cc["chain_height"] = new_height + + checkAndNotifyBalanceChange( + swap_client, coin_type, ci, cc, new_height, "block" + ) + except Exception as e: swap_client.log.warning( f"threadPollXMRChainState {ci.ticker()}, error: {e}" @@ -219,6 +271,11 @@ def threadPollWOWChainState(swap_client, coin_type): ) with swap_client.mxDB: cc["chain_height"] = new_height + + checkAndNotifyBalanceChange( + swap_client, coin_type, ci, cc, new_height, "block" + ) + except Exception as e: swap_client.log.warning( f"threadPollWOWChainState {ci.ticker()}, error: {e}" @@ -231,6 +288,12 @@ def threadPollWOWChainState(swap_client, coin_type): def threadPollChainState(swap_client, coin_type): ci = swap_client.ci(coin_type) cc = swap_client.coin_clients[coin_type] + + if coin_type == Coins.PART and swap_client._zmq_queue_enabled: + poll_delay_range = (40, 60) + else: + poll_delay_range = (20, 30) + while not swap_client.chainstate_delay_event.is_set(): try: chain_state = ci.getBlockchainInfo() @@ -244,11 +307,14 @@ def threadPollChainState(swap_client, coin_type): cc["chain_best_block"] = chain_state["bestblockhash"] if "mediantime" in chain_state: cc["chain_median_time"] = chain_state["mediantime"] + + checkAndNotifyBalanceChange( + swap_client, coin_type, ci, cc, new_height, "block" + ) + except Exception as e: swap_client.log.warning(f"threadPollChainState {ci.ticker()}, error: {e}") - swap_client.chainstate_delay_event.wait( - random.randrange(20, 30) - ) # Random to stagger updates + swap_client.chainstate_delay_event.wait(random.randrange(*poll_delay_range)) class WatchedOutput: # Watch for spends @@ -498,6 +564,9 @@ class BasicSwap(BaseApp, UIApp): ) self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "smsg") + if Coins.PART in chainparams: + self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "hashwtx") + self.with_coins_override = extra_opts.get("with_coins", set()) self.without_coins_override = extra_opts.get("without_coins", set()) self._force_db_upgrade = extra_opts.get("force_db_upgrade", False) @@ -5410,6 +5479,39 @@ class BasicSwap(BaseApp, UIApp): # bid saved in checkBidState + def getTotalBalance(self, coin_type) -> int: + try: + ci = self.ci(coin_type) + if hasattr(ci, "rpc_wallet"): + if coin_type in (Coins.XMR, Coins.WOW): + balance_info = ci.rpc_wallet("get_balance") + return balance_info["balance"] + elif coin_type == Coins.PART: + balances = ci.rpc_wallet("getbalances") + return ci.make_int( + balances["mine"]["trusted"] + + balances["mine"]["untrusted_pending"] + ) + else: + try: + balances = ci.rpc_wallet("getbalances") + return ci.make_int( + balances["mine"]["trusted"] + + balances["mine"]["untrusted_pending"] + ) + except Exception: + wallet_info = ci.rpc_wallet("getwalletinfo") + total = wallet_info.get("balance", 0) + if "unconfirmed_balance" in wallet_info: + total += wallet_info["unconfirmed_balance"] + if "immature_balance" in wallet_info: + total += wallet_info["immature_balance"] + return ci.make_int(total) + else: + return ci.getSpendableBalance() + except Exception: + return ci.getSpendableBalance() + def getAddressBalance(self, coin_type, address: str) -> int: if self.coin_clients[coin_type]["chain_lookups"] == "explorer": explorers = self.coin_clients[coin_type]["explorers"] @@ -10399,6 +10501,29 @@ class BasicSwap(BaseApp, UIApp): self.processMsg(msg) + def processZmqHashwtx(self) -> None: + self.zmqSubscriber.recv() + + try: + if Coins.PART not in self.coin_clients: + return + + ci = self.ci(Coins.PART) + cc = self.coin_clients[Coins.PART] + + current_height = cc.get("chain_height", 0) + + import time + + time.sleep(0.1) + + checkAndNotifyBalanceChange(self, Coins.PART, ci, cc, current_height, "zmq") + + except Exception as e: + self.log.warning(f"Error processing PART wallet transaction: {e}") + if self.debug: + self.log.error(traceback.format_exc()) + def expireBidsAndOffers(self, now) -> None: bids_to_expire = set() offers_to_expire = set() @@ -10530,6 +10655,8 @@ class BasicSwap(BaseApp, UIApp): message = self.zmqSubscriber.recv(flags=zmq.NOBLOCK) if message == b"smsg": self.processZmqSmsg() + elif message == b"hashwtx": + self.processZmqHashwtx() except zmq.Again as e: # noqa: F841 pass except Exception as e: @@ -10831,6 +10958,76 @@ class BasicSwap(BaseApp, UIApp): settings_copy["enabled_chart_coins"] = new_value settings_changed = True + if "notifications_new_offers" in data: + new_value = data["notifications_new_offers"] + ensure( + isinstance(new_value, bool), + "New notifications_new_offers value not boolean", + ) + if settings_copy.get("notifications_new_offers", False) != new_value: + settings_copy["notifications_new_offers"] = new_value + settings_changed = True + + if "notifications_new_bids" in data: + new_value = data["notifications_new_bids"] + ensure( + isinstance(new_value, bool), + "New notifications_new_bids value not boolean", + ) + if settings_copy.get("notifications_new_bids", True) != new_value: + settings_copy["notifications_new_bids"] = new_value + settings_changed = True + + if "notifications_bid_accepted" in data: + new_value = data["notifications_bid_accepted"] + ensure( + isinstance(new_value, bool), + "New notifications_bid_accepted value not boolean", + ) + if settings_copy.get("notifications_bid_accepted", True) != new_value: + settings_copy["notifications_bid_accepted"] = new_value + settings_changed = True + + if "notifications_balance_changes" in data: + new_value = data["notifications_balance_changes"] + ensure( + isinstance(new_value, bool), + "New notifications_balance_changes value not boolean", + ) + if ( + settings_copy.get("notifications_balance_changes", True) + != new_value + ): + settings_copy["notifications_balance_changes"] = new_value + settings_changed = True + + if "notifications_outgoing_transactions" in data: + new_value = data["notifications_outgoing_transactions"] + ensure( + isinstance(new_value, bool), + "New notifications_outgoing_transactions value not boolean", + ) + if ( + settings_copy.get("notifications_outgoing_transactions", True) + != new_value + ): + settings_copy["notifications_outgoing_transactions"] = new_value + settings_changed = True + + if "notifications_duration" in data: + new_value = data["notifications_duration"] + ensure( + isinstance(new_value, int), + "New notifications_duration value not integer", + ) + ensure( + 5 <= new_value <= 60, + "notifications_duration must be between 5 and 60 seconds", + ) + if settings_copy.get("notifications_duration", 20) != new_value: + settings_copy["notifications_duration"] = new_value + settings_changed = True + if settings_changed: settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) settings_path_new = settings_path + ".new" diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py index 0ef3919..e0f7bc6 100755 --- a/basicswap/bin/prepare.py +++ b/basicswap/bin/prepare.py @@ -1382,6 +1382,11 @@ def prepareDataDir(coin, settings, chain, particl_mnemonic, extra_opts={}): fp.write( "zmqpubsmsg=tcp://{}:{}\n".format(COINS_RPCBIND_IP, settings["zmqport"]) ) + fp.write( + "zmqpubhashwtx=tcp://{}:{}\n".format( + COINS_RPCBIND_IP, settings["zmqport"] + ) + ) fp.write("spentindex=1\n") fp.write("txindex=1\n") fp.write("staking=0\n") diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py index 819cd53..649d3e5 100755 --- a/basicswap/bin/run.py +++ b/basicswap/bin/run.py @@ -56,6 +56,42 @@ def signal_handler(sig, frame): swap_client.stopRunning() +def checkPARTZmqConfigBeforeStart(part_settings, swap_settings): + try: + datadir = part_settings.get("datadir") + if not datadir: + return + + config_path = os.path.join(datadir, "particl.conf") + if not os.path.exists(config_path): + return + + with open(config_path, "r") as f: + config_content = f.read() + + zmq_host = swap_settings.get("zmqhost", "tcp://127.0.0.1") + zmq_port = swap_settings.get("zmqport", 14792) + expected_line = f"zmqpubhashwtx={zmq_host}:{zmq_port}" + + if "zmqpubhashwtx=" not in config_content: + with open(config_path, "a") as f: + f.write(f"{expected_line}\n") + elif expected_line not in config_content: + lines = config_content.split("\n") + updated_lines = [] + for line in lines: + if line.startswith("zmqpubhashwtx="): + updated_lines.append(expected_line) + else: + updated_lines.append(line) + + with open(config_path, "w") as f: + f.write("\n".join(updated_lines)) + + except Exception as e: + logger.debug(f"Error checking PART ZMQ config: {e}") + + def startDaemon(node_dir, bin_dir, daemon_bin, opts=[], extra_config={}): daemon_bin = os.path.expanduser(os.path.join(bin_dir, daemon_bin)) datadir_path = os.path.expanduser(node_dir) @@ -548,6 +584,9 @@ def runClient( continue # /decred if v["manage_daemon"] is True: + if c == "particl" and swap_client._zmq_queue_enabled: + checkPARTZmqConfigBeforeStart(v, swap_client.settings) + swap_client.log.info(f"Starting {display_name} daemon") filename: str = getCoreBinName(coin_id, v, c + "d") diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 61f6fec..d8e3273 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -123,6 +123,145 @@ def js_coins(self, url_split, post_string, is_json) -> bytes: return bytes(json.dumps(coins), "UTF-8") +def js_walletbalances(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + + try: + + swap_client.updateWalletsInfo() + wallets = swap_client.getCachedWalletsInfo() + coins_with_balances = [] + + for k, v in swap_client.coin_clients.items(): + if k not in chainparams: + continue + if v["connection_type"] == "rpc": + + balance = "0.0" + if k in wallets: + w = wallets[k] + if "balance" in w and "error" not in w and "no_data" not in w: + raw_balance = w["balance"] + if isinstance(raw_balance, float): + balance = f"{raw_balance:.8f}".rstrip("0").rstrip(".") + elif isinstance(raw_balance, int): + balance = str(raw_balance) + else: + balance = raw_balance + + pending = "0.0" + if k in wallets: + w = wallets[k] + if "error" not in w and "no_data" not in w: + ci = swap_client.ci(k) + pending_amount = 0 + if "unconfirmed" in w and float(w["unconfirmed"]) > 0.0: + pending_amount += ci.make_int(w["unconfirmed"]) + if "immature" in w and float(w["immature"]) > 0.0: + pending_amount += ci.make_int(w["immature"]) + if pending_amount > 0: + pending = ci.format_amount(pending_amount) + + coin_entry = { + "id": int(k), + "name": getCoinName(k), + "balance": balance, + "pending": pending, + "ticker": chainparams[k]["ticker"], + } + + coins_with_balances.append(coin_entry) + + if k == Coins.PART: + variants = [ + { + "coin": Coins.PART_ANON, + "balance_field": "anon_balance", + "pending_field": "anon_pending", + }, + { + "coin": Coins.PART_BLIND, + "balance_field": "blind_balance", + "pending_field": "blind_unconfirmed", + }, + ] + + for variant_info in variants: + variant_balance = "0.0" + variant_pending = "0.0" + + if k in wallets: + w = wallets[k] + if "error" not in w and "no_data" not in w: + if variant_info["balance_field"] in w: + raw_balance = w[variant_info["balance_field"]] + if isinstance(raw_balance, float): + variant_balance = f"{raw_balance:.8f}".rstrip( + "0" + ).rstrip(".") + elif isinstance(raw_balance, int): + variant_balance = str(raw_balance) + else: + variant_balance = raw_balance + + if ( + variant_info["pending_field"] in w + and float(w[variant_info["pending_field"]]) > 0.0 + ): + variant_pending = str( + w[variant_info["pending_field"]] + ) + + variant_entry = { + "id": int(variant_info["coin"]), + "name": getCoinName(variant_info["coin"]), + "balance": variant_balance, + "pending": variant_pending, + "ticker": chainparams[Coins.PART]["ticker"], + } + + coins_with_balances.append(variant_entry) + + elif k == Coins.LTC: + variant_balance = "0.0" + variant_pending = "0.0" + + if k in wallets: + w = wallets[k] + if "error" not in w and "no_data" not in w: + if "mweb_balance" in w: + variant_balance = w["mweb_balance"] + + pending_amount = 0 + if ( + "mweb_unconfirmed" in w + and float(w["mweb_unconfirmed"]) > 0.0 + ): + pending_amount += float(w["mweb_unconfirmed"]) + if "mweb_immature" in w and float(w["mweb_immature"]) > 0.0: + pending_amount += float(w["mweb_immature"]) + if pending_amount > 0: + variant_pending = f"{pending_amount:.8f}".rstrip( + "0" + ).rstrip(".") + + variant_entry = { + "id": int(Coins.LTC_MWEB), + "name": getCoinName(Coins.LTC_MWEB), + "balance": variant_balance, + "pending": variant_pending, + "ticker": chainparams[Coins.LTC]["ticker"], + } + + coins_with_balances.append(variant_entry) + + return bytes(json.dumps(coins_with_balances), "UTF-8") + + except Exception as e: + error_data = {"error": str(e)} + return bytes(json.dumps(error_data), "UTF-8") + + def js_wallets(self, url_split, post_string, is_json): swap_client = self.server.swap_client swap_client.checkSystemStatus() @@ -1214,6 +1353,7 @@ def js_messageroutes(self, url_split, post_string, is_json) -> bytes: endpoints = { "coins": js_coins, + "walletbalances": js_walletbalances, "wallets": js_wallets, "offers": js_offers, "sentoffers": js_sentoffers, diff --git a/basicswap/static/css/style.css b/basicswap/static/css/style.css index 7c098c0..f9519dd 100644 --- a/basicswap/static/css/style.css +++ b/basicswap/static/css/style.css @@ -14,6 +14,62 @@ z-index: 9999; } +/* Toast Notification Animations */ +.toast-slide-in { + animation: slideInRight 0.3s ease-out; +} + +.toast-slide-out { + animation: slideOutRight 0.3s ease-in forwards; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +/* Toast Container Styles */ +#ul_updates { + list-style: none; + padding: 0; + margin: 0; + max-width: 400px; +} + +#ul_updates li { + margin-bottom: 0.5rem; +} + +/* Toast Hover Effects */ +#ul_updates .bg-white:hover { + box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-1px); + transition: all 0.2s ease-in-out; +} + +.dark #ul_updates .dark\:bg-gray-800:hover { + box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); + transform: translateY(-1px); + transition: all 0.2s ease-in-out; +} + /* Table Styles */ .padded_row td { padding-top: 1.5em; diff --git a/basicswap/static/js/amm_tables.js b/basicswap/static/js/amm_tables.js index ec2881b..0e5ab1f 100644 --- a/basicswap/static/js/amm_tables.js +++ b/basicswap/static/js/amm_tables.js @@ -23,13 +23,7 @@ const AmmTablesManager = (function() { } function debugLog(message, data) { - // if (isDebugEnabled()) { - // if (data) { - // console.log(`[AmmTables] ${message}`, data); - // } else { - // console.log(`[AmmTables] ${message}`); - // } - // } + } function initializeTabs() { @@ -309,7 +303,10 @@ const AmmTablesManager = (function() { `; }); - offersBody.innerHTML = tableHtml; + + if (offersBody.innerHTML.trim() !== tableHtml.trim()) { + offersBody.innerHTML = tableHtml; + } } function renderBidsTable(stateData) { @@ -441,7 +438,10 @@ const AmmTablesManager = (function() { `; }); - bidsBody.innerHTML = tableHtml; + + if (bidsBody.innerHTML.trim() !== tableHtml.trim()) { + bidsBody.innerHTML = tableHtml; + } } function formatDuration(seconds) { @@ -724,6 +724,429 @@ const AmmTablesManager = (function() { } } + function shouldDropdownOptionsShowBalance(select) { + const isMakerDropdown = select.id.includes('coin-from'); + const isTakerDropdown = select.id.includes('coin-to'); + + + const addModal = document.getElementById('add-amm-modal'); + const editModal = document.getElementById('edit-amm-modal'); + const addModalVisible = addModal && !addModal.classList.contains('hidden'); + const editModalVisible = editModal && !editModal.classList.contains('hidden'); + + let isBidModal = false; + if (addModalVisible) { + const dataType = addModal.getAttribute('data-amm-type'); + if (dataType) { + isBidModal = dataType === 'bid'; + } else { + + const modalTitle = document.getElementById('add-modal-title'); + isBidModal = modalTitle && modalTitle.textContent && modalTitle.textContent.includes('Bid'); + } + } else if (editModalVisible) { + const dataType = editModal.getAttribute('data-amm-type'); + if (dataType) { + isBidModal = dataType === 'bid'; + } else { + + const modalTitle = document.getElementById('edit-modal-title'); + isBidModal = modalTitle && modalTitle.textContent && modalTitle.textContent.includes('Bid'); + } + } + + + const result = isBidModal ? isTakerDropdown : isMakerDropdown; + + console.log(`[DEBUG] shouldDropdownOptionsShowBalance: ${select.id}, isBidModal=${isBidModal}, isMaker=${isMakerDropdown}, isTaker=${isTakerDropdown}, result=${result}`); + + return result; + } + + function refreshDropdownOptions() { + const dropdownIds = ['add-amm-coin-from', 'add-amm-coin-to', 'edit-amm-coin-from', 'edit-amm-coin-to']; + + dropdownIds.forEach(dropdownId => { + const select = document.getElementById(dropdownId); + if (!select || select.style.display !== 'none') return; + + const wrapper = select.parentNode.querySelector('.relative'); + if (!wrapper) return; + + + const dropdown = wrapper.querySelector('[role="listbox"]'); + if (!dropdown) return; + + + const options = dropdown.querySelectorAll('[data-value]'); + options.forEach(optionElement => { + const coinValue = optionElement.getAttribute('data-value'); + const originalOption = Array.from(select.options).find(opt => opt.value === coinValue); + if (!originalOption) return; + + + const textContainer = optionElement.querySelector('div.flex.flex-col, div.flex.items-center'); + if (!textContainer) return; + + + textContainer.innerHTML = ''; + + const shouldShowBalance = shouldDropdownOptionsShowBalance(select); + const fullText = originalOption.textContent.trim(); + const balance = originalOption.getAttribute('data-balance') || '0.00000000'; + + console.log(`[DEBUG] refreshDropdownOptions: ${select.id}, option=${coinValue}, shouldShowBalance=${shouldShowBalance}, balance=${balance}`); + + if (shouldShowBalance) { + + textContainer.className = 'flex flex-col'; + + const coinName = fullText.includes(' - Balance: ') ? fullText.split(' - Balance: ')[0] : fullText; + + const coinNameSpan = document.createElement('span'); + coinNameSpan.textContent = coinName; + coinNameSpan.className = 'text-gray-900 dark:text-white'; + + const balanceSpan = document.createElement('span'); + balanceSpan.textContent = `Balance: ${balance}`; + balanceSpan.className = 'text-gray-500 dark:text-gray-400 text-xs'; + + textContainer.appendChild(coinNameSpan); + textContainer.appendChild(balanceSpan); + } else { + + textContainer.className = 'flex items-center'; + + const coinNameSpan = document.createElement('span'); + const coinName = fullText.includes(' - Balance: ') ? fullText.split(' - Balance: ')[0] : fullText; + coinNameSpan.textContent = coinName; + coinNameSpan.className = 'text-gray-900 dark:text-white'; + + textContainer.appendChild(coinNameSpan); + } + }); + }); + } + + + function refreshDropdownBalances() { + const dropdownIds = ['add-amm-coin-from', 'add-amm-coin-to', 'edit-amm-coin-from', 'edit-amm-coin-to']; + + dropdownIds.forEach(dropdownId => { + const select = document.getElementById(dropdownId); + if (!select || select.style.display !== 'none') return; + + const wrapper = select.parentNode.querySelector('.relative'); + if (!wrapper) return; + + + const dropdownItems = wrapper.querySelectorAll('[data-value]'); + dropdownItems.forEach(item => { + const value = item.getAttribute('data-value'); + const option = select.querySelector(`option[value="${value}"]`); + if (option) { + const balance = option.getAttribute('data-balance') || '0.00000000'; + const pendingBalance = option.getAttribute('data-pending-balance') || ''; + + const balanceDiv = item.querySelector('.text-xs'); + if (balanceDiv) { + balanceDiv.textContent = `Balance: ${balance}`; + + + let pendingDiv = item.querySelector('.text-green-500'); + if (pendingBalance && parseFloat(pendingBalance) > 0) { + if (!pendingDiv) { + + pendingDiv = document.createElement('div'); + pendingDiv.className = 'text-green-500 text-xs'; + balanceDiv.parentNode.appendChild(pendingDiv); + } + pendingDiv.textContent = `+${pendingBalance} pending`; + } else if (pendingDiv) { + + pendingDiv.remove(); + } + } + } + }); + + const selectedOption = select.options[select.selectedIndex]; + if (selectedOption) { + const textContainer = wrapper.querySelector('button .flex-grow'); + const balanceDiv = textContainer ? textContainer.querySelector('.text-xs') : null; + if (balanceDiv) { + const balance = selectedOption.getAttribute('data-balance') || '0.00000000'; + const pendingBalance = selectedOption.getAttribute('data-pending-balance') || ''; + + balanceDiv.textContent = `Balance: ${balance}`; + + + let pendingDiv = textContainer.querySelector('.text-green-500'); + if (pendingBalance && parseFloat(pendingBalance) > 0) { + if (!pendingDiv) { + + pendingDiv = document.createElement('div'); + pendingDiv.className = 'text-green-500 text-xs'; + textContainer.appendChild(pendingDiv); + } + pendingDiv.textContent = `+${pendingBalance} pending`; + } else if (pendingDiv) { + + pendingDiv.remove(); + } + } + } + }); + } + + function refreshOfferDropdownBalanceDisplay() { + refreshDropdownBalances(); + } + + function refreshBidDropdownBalanceDisplay() { + refreshDropdownBalances(); + } + + function refreshDropdownBalanceDisplay(modalType = null) { + if (modalType === 'offer') { + refreshOfferDropdownBalanceDisplay(); + } else if (modalType === 'bid') { + refreshBidDropdownBalanceDisplay(); + } else { + + const addModal = document.getElementById('add-amm-modal'); + const editModal = document.getElementById('edit-amm-modal'); + const addModalVisible = addModal && !addModal.classList.contains('hidden'); + const editModalVisible = editModal && !editModal.classList.contains('hidden'); + + let detectedType = null; + if (addModalVisible) { + detectedType = addModal.getAttribute('data-amm-type'); + } else if (editModalVisible) { + detectedType = editModal.getAttribute('data-amm-type'); + } + + if (detectedType === 'offer') { + refreshOfferDropdownBalanceDisplay(); + } else if (detectedType === 'bid') { + refreshBidDropdownBalanceDisplay(); + } + } + } + + function updateDropdownsForModalType(modalPrefix) { + const coinFromSelect = document.getElementById(`${modalPrefix}-amm-coin-from`); + const coinToSelect = document.getElementById(`${modalPrefix}-amm-coin-to`); + + if (!coinFromSelect || !coinToSelect) return; + + + const balanceData = {}; + + + Array.from(coinFromSelect.options).forEach(option => { + const balance = option.getAttribute('data-balance'); + if (balance) { + balanceData[option.value] = balance; + } + }); + + + Array.from(coinToSelect.options).forEach(option => { + const balance = option.getAttribute('data-balance'); + if (balance) { + balanceData[option.value] = balance; + } + }); + + + updateDropdownOptions(coinFromSelect, balanceData); + updateDropdownOptions(coinToSelect, balanceData); + } + + function updateDropdownOptions(select, balanceData, pendingData = {}) { + Array.from(select.options).forEach(option => { + const coinName = option.value; + const balance = balanceData[coinName] || '0.00000000'; + const pending = pendingData[coinName] || '0.0'; + + + option.setAttribute('data-balance', balance); + option.setAttribute('data-pending-balance', pending); + + + option.textContent = coinName; + }); + } + + function createSimpleDropdown(select, showBalance = false) { + if (!select) return; + + + const existingWrapper = select.parentNode.querySelector('.relative'); + if (existingWrapper) { + existingWrapper.remove(); + select.style.display = ''; + } + + select.style.display = 'none'; + + const wrapper = document.createElement('div'); + wrapper.className = 'relative'; + + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'flex items-center justify-between w-full p-2.5 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:text-white'; + button.style.minHeight = '60px'; + + + const displayContent = document.createElement('div'); + displayContent.className = 'flex items-center'; + + const icon = document.createElement('img'); + icon.className = 'w-5 h-5 mr-2'; + icon.alt = ''; + + const textContainer = document.createElement('div'); + textContainer.className = 'flex-grow text-left'; + + const arrow = document.createElement('div'); + arrow.innerHTML = ``; + + displayContent.appendChild(icon); + displayContent.appendChild(textContainer); + button.appendChild(displayContent); + button.appendChild(arrow); + + + const dropdown = document.createElement('div'); + dropdown.className = 'absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg hidden dark:bg-gray-700 dark:border-gray-600 max-h-60 overflow-y-auto'; + + + Array.from(select.options).forEach(option => { + const item = document.createElement('div'); + item.className = 'flex items-center p-2 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer'; + item.setAttribute('data-value', option.value); + + const itemIcon = document.createElement('img'); + itemIcon.className = 'w-5 h-5 mr-2'; + itemIcon.src = `/static/images/coins/${getImageFilename(option.value)}`; + itemIcon.alt = ''; + + const itemText = document.createElement('div'); + const coinName = option.textContent.trim(); + const balance = option.getAttribute('data-balance') || '0.00000000'; + const pendingBalance = option.getAttribute('data-pending-balance') || ''; + + if (showBalance) { + itemText.className = 'flex flex-col'; + + let html = ` +
${coinName}
+
Balance: ${balance}
+ `; + + + if (pendingBalance && parseFloat(pendingBalance) > 0) { + html += `
+${pendingBalance} pending
`; + } + + itemText.innerHTML = html; + } else { + itemText.className = 'text-gray-900 dark:text-white'; + itemText.textContent = coinName; + } + + item.appendChild(itemIcon); + item.appendChild(itemText); + + + item.addEventListener('click', function() { + select.value = this.getAttribute('data-value'); + + + const selectedOption = select.options[select.selectedIndex]; + const selectedCoinName = selectedOption.textContent.trim(); + const selectedBalance = selectedOption.getAttribute('data-balance') || '0.00000000'; + const selectedPendingBalance = selectedOption.getAttribute('data-pending-balance') || ''; + + icon.src = itemIcon.src; + + if (showBalance) { + let html = ` +
${selectedCoinName}
+
Balance: ${selectedBalance}
+ `; + + + if (selectedPendingBalance && parseFloat(selectedPendingBalance) > 0) { + html += `
+${selectedPendingBalance} pending
`; + } + + textContainer.innerHTML = html; + textContainer.className = 'flex-grow text-left flex flex-col justify-center'; + } else { + textContainer.textContent = selectedCoinName; + textContainer.className = 'flex-grow text-left'; + } + + dropdown.classList.add('hidden'); + + + const event = new Event('change', { bubbles: true }); + select.dispatchEvent(event); + }); + + dropdown.appendChild(item); + }); + + + const selectedOption = select.options[select.selectedIndex]; + if (selectedOption) { + const selectedCoinName = selectedOption.textContent.trim(); + const selectedBalance = selectedOption.getAttribute('data-balance') || '0.00000000'; + const selectedPendingBalance = selectedOption.getAttribute('data-pending-balance') || ''; + + icon.src = `/static/images/coins/${getImageFilename(selectedOption.value)}`; + + if (showBalance) { + let html = ` +
${selectedCoinName}
+
Balance: ${selectedBalance}
+ `; + + + if (selectedPendingBalance && parseFloat(selectedPendingBalance) > 0) { + html += `
+${selectedPendingBalance} pending
`; + } + + textContainer.innerHTML = html; + textContainer.className = 'flex-grow text-left flex flex-col justify-center'; + } else { + textContainer.textContent = selectedCoinName; + textContainer.className = 'flex-grow text-left'; + } + } + + + button.addEventListener('click', function() { + dropdown.classList.toggle('hidden'); + }); + + + document.addEventListener('click', function(e) { + if (!wrapper.contains(e.target)) { + dropdown.classList.add('hidden'); + } + }); + + wrapper.appendChild(button); + wrapper.appendChild(dropdown); + select.parentNode.insertBefore(wrapper, select); + + } + function setupButtonHandlers() { const addOfferButton = document.getElementById('add-new-offer-btn'); if (addOfferButton) { @@ -844,6 +1267,40 @@ const AmmTablesManager = (function() { modalTitle.textContent = `Add New ${type.charAt(0).toUpperCase() + type.slice(1)}`; } + + const modal = document.getElementById('add-amm-modal'); + if (modal) { + modal.classList.remove('hidden'); + + modal.setAttribute('data-amm-type', type); + } + + + setTimeout(() => { + + updateDropdownsForModalType('add'); + + initializeCustomSelects(type); + + + refreshDropdownBalanceDisplay(type); + + + if (typeof fetchBalanceData === 'function') { + fetchBalanceData() + .then(balanceData => { + if (type === 'offer' && typeof updateOfferDropdownBalances === 'function') { + updateOfferDropdownBalances(balanceData); + } else if (type === 'bid' && typeof updateBidDropdownBalances === 'function') { + updateBidDropdownBalances(balanceData); + } + }) + .catch(error => { + console.error('Error updating dropdown balances:', error); + }); + } + }, 50); + document.getElementById('add-amm-type').value = type; document.getElementById('add-amm-name').value = 'Unnamed Offer'; @@ -940,11 +1397,6 @@ const AmmTablesManager = (function() { if (type === 'offer') { setupBiddingControls('add'); } - - const modal = document.getElementById('add-amm-modal'); - if (modal) { - modal.classList.remove('hidden'); - } } function closeAddModal() { @@ -1269,6 +1721,40 @@ const AmmTablesManager = (function() { modalTitle.textContent = `Edit ${type.charAt(0).toUpperCase() + type.slice(1)}`; } + + const modal = document.getElementById('edit-amm-modal'); + if (modal) { + modal.classList.remove('hidden'); + + modal.setAttribute('data-amm-type', type); + } + + + setTimeout(() => { + + updateDropdownsForModalType('edit'); + + initializeCustomSelects(type); + + + refreshDropdownBalanceDisplay(type); + + + if (typeof fetchBalanceData === 'function') { + fetchBalanceData() + .then(balanceData => { + if (type === 'offer' && typeof updateOfferDropdownBalances === 'function') { + updateOfferDropdownBalances(balanceData); + } else if (type === 'bid' && typeof updateBidDropdownBalances === 'function') { + updateBidDropdownBalances(balanceData); + } + }) + .catch(error => { + console.error('Error updating dropdown balances:', error); + }); + } + }, 50); + document.getElementById('edit-amm-type').value = type; document.getElementById('edit-amm-id').value = id || ''; document.getElementById('edit-amm-original-name').value = name; @@ -1282,8 +1768,12 @@ const AmmTablesManager = (function() { coinFromSelect.value = item.coin_from || ''; coinToSelect.value = item.coin_to || ''; - coinFromSelect.dispatchEvent(new Event('change', { bubbles: true })); - coinToSelect.dispatchEvent(new Event('change', { bubbles: true })); + if (coinFromSelect) { + coinFromSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + if (coinToSelect) { + coinToSelect.dispatchEvent(new Event('change', { bubbles: true })); + } document.getElementById('edit-amm-amount').value = item.amount || ''; @@ -1370,11 +1860,6 @@ const AmmTablesManager = (function() { setupBiddingControls('edit'); populateBiddingControls('edit', item); } - - const modal = document.getElementById('edit-amm-modal'); - if (modal) { - modal.classList.remove('hidden'); - } } catch (error) { alert(`Error processing the configuration: ${error.message}`); debugLog('Error opening edit modal:', error); @@ -1808,7 +2293,7 @@ const AmmTablesManager = (function() { } } - function initializeCustomSelects() { + function initializeCustomSelects(modalType = null) { const coinSelects = [ document.getElementById('add-amm-coin-from'), document.getElementById('add-amm-coin-to'), @@ -1821,116 +2306,16 @@ const AmmTablesManager = (function() { document.getElementById('edit-offer-swap-type') ]; - function createCoinDropdown(select) { - if (!select) return; - const wrapper = document.createElement('div'); - wrapper.className = 'relative'; - - const display = document.createElement('div'); - display.className = 'flex items-center w-full p-2.5 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white cursor-pointer'; - - const icon = document.createElement('img'); - icon.className = 'w-5 h-5 mr-2'; - icon.alt = ''; - - const text = document.createElement('span'); - text.className = 'flex-grow'; - - const arrow = document.createElement('span'); - arrow.className = 'ml-2'; - arrow.innerHTML = ` - - - - `; - - display.appendChild(icon); - display.appendChild(text); - display.appendChild(arrow); - - const dropdown = document.createElement('div'); - dropdown.className = 'absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg hidden dark:bg-gray-700 dark:border-gray-600 max-h-60 overflow-y-auto'; - - Array.from(select.options).forEach(option => { - const item = document.createElement('div'); - item.className = 'flex items-center p-2 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer text-gray-900 dark:text-white'; - item.setAttribute('data-value', option.value); - item.setAttribute('data-symbol', option.getAttribute('data-symbol') || ''); - - const optionIcon = document.createElement('img'); - optionIcon.className = 'w-5 h-5 mr-2'; - optionIcon.src = `/static/images/coins/${getImageFilename(option.value)}`; - optionIcon.alt = ''; - - const optionText = document.createElement('span'); - optionText.textContent = option.textContent.trim(); - - item.appendChild(optionIcon); - item.appendChild(optionText); - - item.addEventListener('click', function() { - select.value = this.getAttribute('data-value'); - - text.textContent = optionText.textContent; - icon.src = optionIcon.src; - - dropdown.classList.add('hidden'); - - const event = new Event('change', { bubbles: true }); - select.dispatchEvent(event); - - if (select.id === 'add-amm-coin-from' || select.id === 'add-amm-coin-to') { - const coinFrom = document.getElementById('add-amm-coin-from'); - const coinTo = document.getElementById('add-amm-coin-to'); - const swapType = document.getElementById('add-offer-swap-type'); - - if (coinFrom && coinTo && swapType) { - updateSwapTypeOptions(coinFrom.value, coinTo.value, swapType); - } - } else if (select.id === 'edit-amm-coin-from' || select.id === 'edit-amm-coin-to') { - const coinFrom = document.getElementById('edit-amm-coin-from'); - const coinTo = document.getElementById('edit-amm-coin-to'); - const swapType = document.getElementById('edit-offer-swap-type'); - - if (coinFrom && coinTo && swapType) { - updateSwapTypeOptions(coinFrom.value, coinTo.value, swapType); - } - } - }); - - dropdown.appendChild(item); - }); - - const selectedOption = select.options[select.selectedIndex]; - text.textContent = selectedOption.textContent.trim(); - icon.src = `/static/images/coins/${getImageFilename(selectedOption.value)}`; - - display.addEventListener('click', function(e) { - e.stopPropagation(); - dropdown.classList.toggle('hidden'); - }); - - document.addEventListener('click', function() { - dropdown.classList.add('hidden'); - }); - - wrapper.appendChild(display); - wrapper.appendChild(dropdown); - select.parentNode.insertBefore(wrapper, select); - - select.style.display = 'none'; - - select.addEventListener('change', function() { - const selectedOption = this.options[this.selectedIndex]; - text.textContent = selectedOption.textContent.trim(); - icon.src = `/static/images/coins/${getImageFilename(selectedOption.value)}`; - }); - } function createSwapTypeDropdown(select) { if (!select) return; + + if (select.style.display === 'none' && select.parentNode.querySelector('.relative')) { + return; // Custom dropdown already exists + } + const wrapper = document.createElement('div'); wrapper.className = 'relative'; @@ -1980,7 +2365,9 @@ const AmmTablesManager = (function() { }); const selectedOption = select.options[select.selectedIndex]; - text.textContent = selectedOption.getAttribute('data-desc') || selectedOption.textContent.trim(); + if (selectedOption) { + text.textContent = selectedOption.getAttribute('data-desc') || selectedOption.textContent.trim(); + } display.addEventListener('click', function(e) { if (select.disabled) return; @@ -2000,7 +2387,9 @@ const AmmTablesManager = (function() { select.addEventListener('change', function() { const selectedOption = this.options[this.selectedIndex]; - text.textContent = selectedOption.getAttribute('data-desc') || selectedOption.textContent.trim(); + if (selectedOption) { + text.textContent = selectedOption.getAttribute('data-desc') || selectedOption.textContent.trim(); + } }); const observer = new MutationObserver(function(mutations) { @@ -2022,7 +2411,18 @@ const AmmTablesManager = (function() { } } - coinSelects.forEach(select => createCoinDropdown(select)); + coinSelects.forEach(select => { + if (!select) return; + + let showBalance = false; + if (modalType === 'offer' && select.id.includes('coin-from')) { + showBalance = true; // OFFER: maker shows balance + } else if (modalType === 'bid' && select.id.includes('coin-to')) { + showBalance = true; // BID: taker shows balance + } + + createSimpleDropdown(select, showBalance); + }); swapTypeSelects.forEach(select => createSwapTypeDropdown(select)); } @@ -2301,19 +2701,27 @@ const AmmTablesManager = (function() { if (refreshButton) { refreshButton.addEventListener('click', async function() { + + if (refreshButton.disabled) return; + const icon = refreshButton.querySelector('svg'); + refreshButton.disabled = true; + if (icon) { icon.classList.add('animate-spin'); } - await initializePrices(); - updateTables(); - - setTimeout(() => { - if (icon) { - icon.classList.remove('animate-spin'); - } - }, 1000); + try { + await initializePrices(); + updateTables(); + } finally { + setTimeout(() => { + if (icon) { + icon.classList.remove('animate-spin'); + } + refreshButton.disabled = false; + }, 500); // Reduced from 1000ms to 500ms + } }); } @@ -2326,7 +2734,11 @@ const AmmTablesManager = (function() { return { updateTables, startRefreshTimer, - stopRefreshTimer + stopRefreshTimer, + refreshDropdownBalanceDisplay, + refreshOfferDropdownBalanceDisplay, + refreshBidDropdownBalanceDisplay, + refreshDropdownOptions }; } diff --git a/basicswap/static/js/modules/api-manager.js b/basicswap/static/js/modules/api-manager.js index bac3f22..40c938b 100644 --- a/basicswap/static/js/modules/api-manager.js +++ b/basicswap/static/js/modules/api-manager.js @@ -367,16 +367,45 @@ const ApiManager = (function() { const results = {}; const fetchPromises = coinSymbols.map(async coin => { - if (coin === 'WOW') { + let useCoinGecko = false; + let coingeckoId = null; + + if (window.CoinManager) { + const coinConfig = window.CoinManager.getCoinByAnyIdentifier(coin); + if (coinConfig) { + useCoinGecko = !coinConfig.usesCryptoCompare || coin === 'PART'; + coingeckoId = coinConfig.coingeckoId; + } + } else { + const coinGeckoCoins = { + 'WOW': 'wownero', + 'PART': 'particl', + 'BTC': 'bitcoin' + }; + if (coinGeckoCoins[coin]) { + useCoinGecko = true; + coingeckoId = coinGeckoCoins[coin]; + } + } + + if (useCoinGecko && coingeckoId) { return this.rateLimiter.queueRequest('coingecko', async () => { - const url = `https://api.coingecko.com/api/v3/coins/wownero/market_chart?vs_currency=usd&days=1`; + let days; + if (resolution === 'day') { + days = 1; + } else if (resolution === 'year') { + days = 365; + } else { + days = 180; + } + const url = `https://api.coingecko.com/api/v3/coins/${coingeckoId}/market_chart?vs_currency=usd&days=${days}`; try { const response = await this.makePostRequest(url); if (response && response.prices) { results[coin] = response.prices; } } catch (error) { - console.error(`Error fetching CoinGecko data for WOW:`, error); + console.error(`Error fetching CoinGecko data for ${coin}:`, error); throw error; } }); diff --git a/basicswap/static/js/modules/balance-updates.js b/basicswap/static/js/modules/balance-updates.js new file mode 100644 index 0000000..92f1735 --- /dev/null +++ b/basicswap/static/js/modules/balance-updates.js @@ -0,0 +1,216 @@ +const BalanceUpdatesManager = (function() { + 'use strict'; + + const config = { + balanceUpdateDelay: 2000, + swapEventDelay: 5000, + periodicRefreshInterval: 120000, + walletPeriodicRefreshInterval: 60000, + }; + + const state = { + handlers: new Map(), + timeouts: new Map(), + intervals: new Map(), + initialized: false + }; + + function fetchBalanceData() { + return fetch('/json/walletbalances', { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`Server error: ${response.status} ${response.statusText}`); + } + return response.json(); + }) + .then(balanceData => { + if (balanceData.error) { + throw new Error(balanceData.error); + } + + if (!Array.isArray(balanceData)) { + throw new Error('Invalid response format'); + } + + return balanceData; + }); + } + + function clearTimeoutByKey(key) { + if (state.timeouts.has(key)) { + clearTimeout(state.timeouts.get(key)); + state.timeouts.delete(key); + } + } + + function setTimeoutByKey(key, callback, delay) { + clearTimeoutByKey(key); + const timeoutId = setTimeout(callback, delay); + state.timeouts.set(key, timeoutId); + } + + function clearIntervalByKey(key) { + if (state.intervals.has(key)) { + clearInterval(state.intervals.get(key)); + state.intervals.delete(key); + } + } + + function setIntervalByKey(key, callback, interval) { + clearIntervalByKey(key); + const intervalId = setInterval(callback, interval); + state.intervals.set(key, intervalId); + } + + function handleBalanceUpdate(contextKey, updateCallback, errorContext) { + clearTimeoutByKey(`${contextKey}_balance_update`); + setTimeoutByKey(`${contextKey}_balance_update`, () => { + fetchBalanceData() + .then(balanceData => { + updateCallback(balanceData); + }) + .catch(error => { + console.error(`Error updating ${errorContext} balances via WebSocket:`, error); + }); + }, config.balanceUpdateDelay); + } + + function handleSwapEvent(contextKey, updateCallback, errorContext) { + clearTimeoutByKey(`${contextKey}_swap_event`); + setTimeoutByKey(`${contextKey}_swap_event`, () => { + fetchBalanceData() + .then(balanceData => { + updateCallback(balanceData); + }) + .catch(error => { + console.error(`Error updating ${errorContext} balances via swap event:`, error); + }); + }, config.swapEventDelay); + } + + function setupWebSocketHandler(contextKey, balanceUpdateCallback, swapEventCallback, errorContext) { + const handlerId = window.WebSocketManager.addMessageHandler('message', (data) => { + if (data && data.event) { + if (data.event === 'coin_balance_updated') { + handleBalanceUpdate(contextKey, balanceUpdateCallback, errorContext); + } + + if (swapEventCallback) { + const swapEvents = ['new_bid', 'bid_accepted', 'swap_completed']; + if (swapEvents.includes(data.event)) { + handleSwapEvent(contextKey, swapEventCallback, errorContext); + } + } + } + }); + + state.handlers.set(contextKey, handlerId); + return handlerId; + } + + function setupPeriodicRefresh(contextKey, updateCallback, errorContext, interval) { + const refreshInterval = interval || config.periodicRefreshInterval; + + setIntervalByKey(`${contextKey}_periodic`, () => { + fetchBalanceData() + .then(balanceData => { + updateCallback(balanceData); + }) + .catch(error => { + console.error(`Error in periodic ${errorContext} balance refresh:`, error); + }); + }, refreshInterval); + } + + function cleanup(contextKey) { + if (state.handlers.has(contextKey)) { + const handlerId = state.handlers.get(contextKey); + if (window.WebSocketManager && typeof window.WebSocketManager.removeMessageHandler === 'function') { + window.WebSocketManager.removeMessageHandler('message', handlerId); + } + state.handlers.delete(contextKey); + } + + clearTimeoutByKey(`${contextKey}_balance_update`); + clearTimeoutByKey(`${contextKey}_swap_event`); + + clearIntervalByKey(`${contextKey}_periodic`); + } + + function cleanupAll() { + state.handlers.forEach((handlerId) => { + if (window.WebSocketManager && typeof window.WebSocketManager.removeMessageHandler === 'function') { + window.WebSocketManager.removeMessageHandler('message', handlerId); + } + }); + state.handlers.clear(); + + state.timeouts.forEach(timeoutId => clearTimeout(timeoutId)); + state.timeouts.clear(); + + state.intervals.forEach(intervalId => clearInterval(intervalId)); + state.intervals.clear(); + + state.initialized = false; + } + + return { + initialize: function() { + if (state.initialized) { + return this; + } + + if (window.CleanupManager) { + window.CleanupManager.registerResource('balanceUpdatesManager', this, (mgr) => mgr.dispose()); + } + + window.addEventListener('beforeunload', cleanupAll); + + state.initialized = true; + console.log('BalanceUpdatesManager initialized'); + return this; + }, + + setup: function(options) { + const { + contextKey, + balanceUpdateCallback, + swapEventCallback, + errorContext, + enablePeriodicRefresh = false, + periodicInterval + } = options; + + if (!contextKey || !balanceUpdateCallback || !errorContext) { + throw new Error('Missing required options: contextKey, balanceUpdateCallback, errorContext'); + } + + setupWebSocketHandler(contextKey, balanceUpdateCallback, swapEventCallback, errorContext); + + if (enablePeriodicRefresh) { + setupPeriodicRefresh(contextKey, balanceUpdateCallback, errorContext, periodicInterval); + } + + return this; + }, + + fetchBalanceData: fetchBalanceData, + + cleanup: cleanup, + + dispose: cleanupAll, + + isInitialized: function() { + return state.initialized; + } + }; +})(); + +if (typeof window !== 'undefined') { + window.BalanceUpdatesManager = BalanceUpdatesManager; +} diff --git a/basicswap/static/js/modules/notification-manager.js b/basicswap/static/js/modules/notification-manager.js index 7265b15..f22378d 100644 --- a/basicswap/static/js/modules/notification-manager.js +++ b/basicswap/static/js/modules/notification-manager.js @@ -1,11 +1,40 @@ const NotificationManager = (function() { - const config = { + + const defaultConfig = { showNewOffers: false, showNewBids: true, - showBidAccepted: true + showBidAccepted: true, + showBalanceChanges: true, + showOutgoingTransactions: true, + notificationDuration: 20000 }; + + function loadConfig() { + const saved = localStorage.getItem('notification_settings'); + if (saved) { + try { + return { ...defaultConfig, ...JSON.parse(saved) }; + } catch (e) { + console.error('Error loading notification settings:', e); + } + } + return { ...defaultConfig }; + } + + + function saveConfig(newConfig) { + try { + localStorage.setItem('notification_settings', JSON.stringify(newConfig)); + Object.assign(config, newConfig); + } catch (e) { + console.error('Error saving notification settings:', e); + } + } + + let config = loadConfig(); + function ensureToastContainer() { let container = document.getElementById('ul_updates'); if (!container) { @@ -19,13 +48,67 @@ const NotificationManager = (function() { return container; } + function getCoinIcon(coinSymbol) { + if (window.CoinManager && typeof window.CoinManager.getCoinIcon === 'function') { + return window.CoinManager.getCoinIcon(coinSymbol); + } + return 'default.png'; + } + + function getToastIcon(type) { + const icons = { + 'new_offer': ` + + `, + 'new_bid': ` + + `, + 'bid_accepted': ` + + `, + 'balance_change': ` + + `, + 'success': ` + + ` + }; + return icons[type] || icons['success']; + } + + function getToastColor(type, options = {}) { + const colors = { + 'new_offer': 'bg-blue-500', + 'new_bid': 'bg-green-500', + 'bid_accepted': 'bg-purple-500', + 'balance_change': 'bg-yellow-500', + 'success': 'bg-blue-500' + }; + + if (type === 'balance_change' && options.subtitle) { + if (options.subtitle.includes('sent') || options.subtitle.includes('sending')) { + return 'bg-red-500'; + } else { + return 'bg-green-500'; + } + } + + return colors[type] || colors['success']; + } + const publicAPI = { initialize: function(options = {}) { Object.assign(config, options); + + 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'); }); } @@ -33,33 +116,160 @@ const NotificationManager = (function() { return this; }, - createToast: function(title, type = 'success') { + updateSettings: function(newSettings) { + saveConfig(newSettings); + return this; + }, + + getSettings: function() { + return { ...config }; + }, + + testToasts: function() { + if (!this.createToast) return; + + setTimeout(() => { + this.createToast( + '+0.05000000 PART', + 'balance_change', + { coinSymbol: 'PART', subtitle: 'Incoming funds pending' } + ); + }, 500); + + setTimeout(() => { + this.createToast( + '+0.00123456 XMR', + 'balance_change', + { coinSymbol: 'XMR', subtitle: 'Incoming funds confirmed' } + ); + }, 1000); + + setTimeout(() => { + this.createToast( + '-29.86277595 PART', + 'balance_change', + { coinSymbol: 'PART', subtitle: 'Funds sent' } + ); + }, 1500); + + setTimeout(() => { + this.createToast( + '-0.05000000 PART (Anon)', + 'balance_change', + { coinSymbol: 'PART', subtitle: 'Funds sending' } + ); + }, 2000); + + setTimeout(() => { + this.createToast( + '+1.23456789 PART (Anon)', + 'balance_change', + { coinSymbol: 'PART', subtitle: 'Incoming funds confirmed' } + ); + }, 2500); + + setTimeout(() => { + this.createToast( + 'New network offer', + 'new_offer', + { offerId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', subtitle: 'Click to view offer' } + ); + }, 3000); + + setTimeout(() => { + this.createToast( + 'New bid received', + 'new_bid', + { bidId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', subtitle: 'Click to view bid' } + ); + }, 3500); + }, + + + + initializeBalanceTracking: function() { + + fetch('/json/walletbalances') + .then(response => response.json()) + .then(balanceData => { + if (Array.isArray(balanceData)) { + balanceData.forEach(coin => { + const balance = parseFloat(coin.balance) || 0; + const pending = parseFloat(coin.pending) || 0; + + const coinKey = coin.name.replace(/\s+/g, '_'); + const storageKey = `prev_balance_${coinKey}`; + const pendingStorageKey = `prev_pending_${coinKey}`; + + + if (!localStorage.getItem(storageKey)) { + localStorage.setItem(storageKey, balance.toString()); + } + if (!localStorage.getItem(pendingStorageKey)) { + localStorage.setItem(pendingStorageKey, pending.toString()); + } + }); + } + }) + .catch(error => { + console.error('Error initializing balance tracking:', error); + }); + }, + + createToast: function(title, type = 'success', options = {}) { const messages = ensureToastContainer(); const message = document.createElement('li'); + const toastId = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const iconColor = getToastColor(type, options); + const icon = getToastIcon(type); + + + let coinIconHtml = ''; + if (options.coinSymbol) { + const coinIcon = getCoinIcon(options.coinSymbol); + coinIconHtml = `${options.coinSymbol}`; + } + + + let clickAction = ''; + let cursorStyle = 'cursor-default'; + + if (options.offerId) { + clickAction = `onclick="window.location.href='/offer/${options.offerId}'"`; + cursorStyle = 'cursor-pointer'; + } else if (options.bidId) { + clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`; + cursorStyle = 'cursor-pointer'; + } else if (options.coinSymbol) { + clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol}'"`; + cursorStyle = 'cursor-pointer'; + } + message.innerHTML = ` -
- @@ -190,5 +189,426 @@ {% include 'footer.html' %} + + + diff --git a/basicswap/ui/page_amm.py b/basicswap/ui/page_amm.py index 4ff4be4..1730d94 100644 --- a/basicswap/ui/page_amm.py +++ b/basicswap/ui/page_amm.py @@ -14,7 +14,7 @@ import traceback import sys from urllib import parse from urllib.request import Request, urlopen -from .util import listAvailableCoins +from .util import listAvailableCoinsWithBalances DEFAULT_AMM_CONFIG_FILE = "createoffers.json" DEFAULT_AMM_STATE_FILE = "createoffers_state.json" @@ -1286,7 +1286,7 @@ def page_amm(self, _, post_string): except Exception as e: err_messages.append(f"Failed to read state file: {str(e)}") - coins = listAvailableCoins(swap_client) + coins = listAvailableCoinsWithBalances(swap_client) template = server.env.get_template("amm.html") return self.render_template( diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index 2b77b2b..a27e839 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -16,6 +16,7 @@ from .util import ( have_data_entry, inputAmount, listAvailableCoins, + listAvailableCoinsWithBalances, PAGE_LIMIT, setCoinFilter, set_pagination_filters, @@ -526,7 +527,7 @@ def page_newoffer(self, url_split, post_string): if swap_client.debug_ui: messages.append("Debug mode active.") - coins_from, coins_to = listAvailableCoins(swap_client, split_from=True) + coins_from, coins_to = listAvailableCoinsWithBalances(swap_client, split_from=True) addrs_from_raw = swap_client.listSMSGAddresses("offer_send_from") addrs_to_raw = swap_client.listSMSGAddresses("offer_send_to") diff --git a/basicswap/ui/page_settings.py b/basicswap/ui/page_settings.py index de49e75..64e1e50 100644 --- a/basicswap/ui/page_settings.py +++ b/basicswap/ui/page_settings.py @@ -60,6 +60,38 @@ def page_settings(self, url_split, post_string): ), } swap_client.editGeneralSettings(data) + elif have_data_entry(form_data, "apply_notifications"): + active_tab = "notifications" + data = { + "notifications_new_offers": toBool( + get_data_entry_or( + form_data, "notifications_new_offers", "false" + ) + ), + "notifications_new_bids": toBool( + get_data_entry_or(form_data, "notifications_new_bids", "false") + ), + "notifications_bid_accepted": toBool( + get_data_entry_or( + form_data, "notifications_bid_accepted", "false" + ) + ), + "notifications_balance_changes": toBool( + get_data_entry_or( + form_data, "notifications_balance_changes", "false" + ) + ), + "notifications_outgoing_transactions": toBool( + get_data_entry_or( + form_data, "notifications_outgoing_transactions", "false" + ) + ), + "notifications_duration": int( + get_data_entry_or(form_data, "notifications_duration", "20") + ), + } + swap_client.editGeneralSettings(data) + messages.append("Notification settings applied.") elif have_data_entry(form_data, "apply_tor"): active_tab = "tor" # TODO: Detect if running in docker @@ -186,6 +218,27 @@ def page_settings(self, url_split, post_string): "enabled_chart_coins": swap_client.settings.get("enabled_chart_coins", ""), } + notification_settings = { + "notifications_new_offers": swap_client.settings.get( + "notifications_new_offers", False + ), + "notifications_new_bids": swap_client.settings.get( + "notifications_new_bids", True + ), + "notifications_bid_accepted": swap_client.settings.get( + "notifications_bid_accepted", True + ), + "notifications_balance_changes": swap_client.settings.get( + "notifications_balance_changes", True + ), + "notifications_outgoing_transactions": swap_client.settings.get( + "notifications_outgoing_transactions", True + ), + "notifications_duration": swap_client.settings.get( + "notifications_duration", 20 + ), + } + tor_control_password = ( "" if swap_client.tor_control_password is None @@ -209,6 +262,7 @@ def page_settings(self, url_split, post_string): "chains": chains_formatted, "general_settings": general_settings, "chart_settings": chart_settings, + "notification_settings": notification_settings, "tor_settings": tor_settings, "active_tab": active_tab, }, diff --git a/basicswap/ui/util.py b/basicswap/ui/util.py index 13f8172..beac32f 100644 --- a/basicswap/ui/util.py +++ b/basicswap/ui/util.py @@ -659,6 +659,76 @@ def listAvailableCoins(swap_client, with_variants=True, split_from=False): return coins +def listAvailableCoinsWithBalances(swap_client, with_variants=True, split_from=False): + swap_client.updateWalletsInfo() + wallets = swap_client.getCachedWalletsInfo() + + coins_from = [] + coins = [] + + for k, v in swap_client.coin_clients.items(): + if k not in chainparams: + continue + if v["connection_type"] == "rpc": + + balance = "0.0" + if k in wallets: + w = wallets[k] + if "balance" in w and "error" not in w and "no_data" not in w: + balance = w["balance"] + + coin_entry = (int(k), getCoinName(k), balance) + coins.append(coin_entry) + if split_from: + coins_from.append(coin_entry) + + if with_variants and k == Coins.PART: + + for variant in (Coins.PART_ANON, Coins.PART_BLIND): + variant_balance = "0.0" + if k in wallets: + w = wallets[k] + if "error" not in w and "no_data" not in w: + if variant == Coins.PART_ANON and "anon_balance" in w: + variant_balance = w["anon_balance"] + elif variant == Coins.PART_BLIND and "blind_balance" in w: + variant_balance = w["blind_balance"] + + variant_entry = ( + int(variant), + getCoinName(variant), + variant_balance, + ) + coins.append(variant_entry) + if split_from: + coins_from.append(variant_entry) + + if with_variants and k == Coins.LTC: + + for variant in (Coins.LTC_MWEB,): + variant_balance = "0.0" + if k in wallets: + w = wallets[k] + if ( + "error" not in w + and "no_data" not in w + and "mweb_balance" in w + ): + variant_balance = w["mweb_balance"] + + variant_entry = ( + int(variant), + getCoinName(variant), + variant_balance, + ) + + pass + + if split_from: + return coins_from, coins + return coins + + def checkAddressesOwned(swap_client, ci, wallet_info): if "stealth_address" in wallet_info: