diff --git a/basicswap/http_server.py b/basicswap/http_server.py index a994b4a..41bdd4a 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -178,6 +178,16 @@ class HttpHandler(BaseHTTPRequestHandler): self.server.msg_id_counter += 1 args_dict["err_messages"] = err_messages_with_ids + if self.path: + parsed = parse.urlparse(self.path) + url_split = parsed.path.split("/") + if len(url_split) > 1 and url_split[1]: + args_dict["current_page"] = url_split[1] + else: + args_dict["current_page"] = "index" + else: + args_dict["current_page"] = "index" + shutdown_token = os.urandom(8).hex() self.server.session_tokens["shutdown"] = shutdown_token args_dict["shutdown_token"] = shutdown_token @@ -410,7 +420,6 @@ class HttpHandler(BaseHTTPRequestHandler): return self.render_template( template, { - "refresh": 30, "active_swaps": [ ( s[0].hex(), diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 194b846..ebd021a 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -983,37 +983,49 @@ def js_readurl(self, url_split, post_string, is_json) -> bytes: def js_active(self, url_split, post_string, is_json) -> bytes: swap_client = self.server.swap_client swap_client.checkSystemStatus() - filters = {"sort_by": "created_at", "sort_dir": "desc"} + + filters = { + "sort_by": "created_at", + "sort_dir": "desc", + "with_available_or_active": True, + "with_extra_info": True + } + EXCLUDED_STATES = [ - "Completed", - "Expired", - "Timed-out", - "Abandoned", "Failed, refunded", "Failed, swiped", "Failed", "Error", - "received", + "Expired", + "Timed-out", + "Abandoned", + "Completed" ] + all_bids = [] processed_bid_ids = set() - try: received_bids = swap_client.listBids(filters=filters) sent_bids = swap_client.listBids(sent=True, filters=filters) + for bid in received_bids + sent_bids: try: bid_id_hex = bid[2].hex() if bid_id_hex in processed_bid_ids: continue - bid_state = strBidState(bid[5]) - tx_state_a = strTxState(bid[7]) - tx_state_b = strTxState(bid[8]) - if bid_state in EXCLUDED_STATES: - continue + offer = swap_client.getOffer(bid[3]) if not offer: continue + + bid_state = strBidState(bid[5]) + + if bid_state in EXCLUDED_STATES: + continue + + tx_state_a = strTxState(bid[7]) + tx_state_b = strTxState(bid[8]) + swap_data = { "bid_id": bid_id_hex, "offer_id": bid[3].hex(), @@ -1040,6 +1052,7 @@ def js_active(self, url_split, post_string, is_json) -> bytes: continue except Exception: return bytes(json.dumps([]), "UTF-8") + return bytes(json.dumps(all_bids), "UTF-8") diff --git a/basicswap/static/js/bids_available.js b/basicswap/static/js/bids_available.js index 7e31cca..3cb11e8 100644 --- a/basicswap/static/js/bids_available.js +++ b/basicswap/static/js/bids_available.js @@ -1,4 +1,3 @@ -// Constants and State const PAGE_SIZE = 50; const COIN_NAME_TO_SYMBOL = { 'Bitcoin': 'BTC', @@ -16,7 +15,6 @@ const COIN_NAME_TO_SYMBOL = { 'Dogecoin': 'DOGE' }; -// Global state const state = { dentities: new Map(), currentPage: 1, @@ -27,7 +25,6 @@ const state = { refreshPromise: null }; -// DOM const elements = { bidsBody: document.getElementById('bids-body'), prevPageButton: document.getElementById('prevPage'), @@ -40,125 +37,6 @@ const elements = { statusText: document.getElementById('status-text') }; -// Identity Manager -const IdentityManager = { - cache: new Map(), - pendingRequests: new Map(), - retryDelay: 2000, - maxRetries: 3, - cacheTimeout: 5 * 60 * 1000, // 5 minutes - - async getIdentityData(address) { - if (!address) { - return { address: '' }; - } - - const cachedData = this.getCachedIdentity(address); - if (cachedData) { - return { ...cachedData, address }; - } - - if (this.pendingRequests.has(address)) { - const pendingData = await this.pendingRequests.get(address); - return { ...pendingData, address }; - } - - const request = this.fetchWithRetry(address); - this.pendingRequests.set(address, request); - - try { - const data = await request; - this.cache.set(address, { - data, - timestamp: Date.now() - }); - return { ...data, address }; - } catch (error) { - console.warn(`Error fetching identity for ${address}:`, error); - return { address }; - } finally { - this.pendingRequests.delete(address); - } - }, - - getCachedIdentity(address) { - const cached = this.cache.get(address); - if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { - return cached.data; - } - if (cached) { - this.cache.delete(address); - } - return null; - }, - - async fetchWithRetry(address, attempt = 1) { - try { - const response = await fetch(`/json/identities/${address}`, { - signal: AbortSignal.timeout(5000) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return { - ...data, - address, - num_sent_bids_successful: safeParseInt(data.num_sent_bids_successful), - num_recv_bids_successful: safeParseInt(data.num_recv_bids_successful), - num_sent_bids_failed: safeParseInt(data.num_sent_bids_failed), - num_recv_bids_failed: safeParseInt(data.num_recv_bids_failed), - num_sent_bids_rejected: safeParseInt(data.num_sent_bids_rejected), - num_recv_bids_rejected: safeParseInt(data.num_recv_bids_rejected), - label: data.label || '', - note: data.note || '', - automation_override: safeParseInt(data.automation_override) - }; - } catch (error) { - if (attempt >= this.maxRetries) { - console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); - return { - address, - num_sent_bids_successful: 0, - num_recv_bids_successful: 0, - num_sent_bids_failed: 0, - num_recv_bids_failed: 0, - num_sent_bids_rejected: 0, - num_recv_bids_rejected: 0, - label: '', - note: '', - automation_override: 0 - }; - } - - await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); - return this.fetchWithRetry(address, attempt + 1); - } - }, - - clearCache() { - this.cache.clear(); - this.pendingRequests.clear(); - }, - - removeFromCache(address) { - this.cache.delete(address); - this.pendingRequests.delete(address); - }, - - cleanup() { - const now = Date.now(); - for (const [address, cached] of this.cache.entries()) { - if (now - cached.timestamp >= this.cacheTimeout) { - this.cache.delete(address); - } - } - } -}; - -// Util const formatTimeAgo = (timestamp) => { const now = Math.floor(Date.now() / 1000); const diff = now - timestamp; @@ -342,108 +220,6 @@ const createIdentityTooltip = (identity) => { `; }; -// WebSocket Manager -const WebSocketManager = { - ws: null, - processingQueue: false, - reconnectTimeout: null, - maxReconnectAttempts: 5, - reconnectAttempts: 0, - reconnectDelay: 5000, - - initialize() { - this.connect(); - this.startHealthCheck(); - }, - - connect() { - if (this.ws?.readyState === WebSocket.OPEN) return; - - try { - let wsPort; - - if (typeof getWebSocketConfig === 'function') { - const wsConfig = getWebSocketConfig(); - wsPort = wsConfig?.port || wsConfig?.fallbackPort; - } - if (!wsPort && window.config?.port) { - wsPort = window.config.port; - } - if (!wsPort) { - wsPort = window.ws_port || '11700'; - } - console.log("Using WebSocket port:", wsPort); - this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); - this.setupEventHandlers(); - } catch (error) { - console.error('WebSocket connection error:', error); - this.handleReconnect(); - } -}, - - setupEventHandlers() { - this.ws.onopen = () => { - state.wsConnected = true; - this.reconnectAttempts = 0; - updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established for Bid Requests'); - updateBidsTable({ resetPage: true, refreshData: true }); - }; - - this.ws.onmessage = () => { - if (!this.processingQueue) { - this.processingQueue = true; - setTimeout(async () => { - try { - if (!state.isRefreshing) { - await updateBidsTable({ resetPage: false, refreshData: true }); - } - } finally { - this.processingQueue = false; - } - }, 200); - } - }; - - this.ws.onclose = () => { - state.wsConnected = false; - updateConnectionStatus('disconnected'); - this.handleReconnect(); - }; - - this.ws.onerror = () => { - updateConnectionStatus('error'); - }; - }, - - startHealthCheck() { - setInterval(() => { - if (this.ws?.readyState !== WebSocket.OPEN) { - this.handleReconnect(); - } - }, 30000); - }, - - handleReconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - } - - this.reconnectAttempts++; - if (this.reconnectAttempts <= this.maxReconnectAttempts) { - const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); - this.reconnectTimeout = setTimeout(() => this.connect(), delay); - } else { - updateConnectionStatus('error'); - setTimeout(() => { - this.reconnectAttempts = 0; - this.connect(); - }, 60000); - } - } -}; - -// UI const updateConnectionStatus = (status) => { const { statusDot, statusText } = elements; if (!statusDot || !statusText) return; @@ -864,7 +640,6 @@ async function updateBidsTable(options = {}) { } } -// Event const setupEventListeners = () => { if (elements.refreshBidsButton) { elements.refreshBidsButton.addEventListener('click', async () => { @@ -904,8 +679,8 @@ if (elements.refreshBidsButton) { } }; -// Init -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { WebSocketManager.initialize(); setupEventListeners(); + await updateBidsTable({ resetPage: true, refreshData: true }); }); diff --git a/basicswap/static/js/bids_sentreceived.js b/basicswap/static/js/bids_sentreceived.js index 2ea8d3b..cdbebca 100644 --- a/basicswap/static/js/bids_sentreceived.js +++ b/basicswap/static/js/bids_sentreceived.js @@ -1,4 +1,3 @@ -// Constants and State const PAGE_SIZE = 50; const state = { currentPage: { @@ -167,262 +166,225 @@ const EventManager = { }; function cleanup() { - console.log('Starting cleanup process'); - EventManager.clearAll(); - - const exportSentButton = document.getElementById('exportSentBids'); - const exportReceivedButton = document.getElementById('exportReceivedBids'); - - if (exportSentButton) { - exportSentButton.remove(); - } - - if (exportReceivedButton) { - exportReceivedButton.remove(); - } - - if (window.TooltipManager) { - const originalCleanup = window.TooltipManager.cleanup; - window.TooltipManager.cleanup = function() { - originalCleanup.call(window.TooltipManager); - - setTimeout(() => { - forceTooltipDOMCleanup(); - - const detachedTooltips = document.querySelectorAll('[id^="tooltip-"]'); - detachedTooltips.forEach(tooltip => { - const tooltipId = tooltip.id; - const trigger = document.querySelector(`[data-tooltip-target="${tooltipId}"]`); - if (!trigger || !document.body.contains(trigger)) { - tooltip.remove(); - } - }); - }, 10); - }; -} - - WebSocketManager.cleanup(); - if (searchTimeout) { - clearTimeout(searchTimeout); - searchTimeout = null; - } - state.data = { - sent: [], - received: [] - }; - IdentityManager.clearCache(); - Object.keys(elements).forEach(key => { - elements[key] = null; - }); + //console.log('Starting comprehensive cleanup process for bids table'); - console.log('Cleanup completed'); + try { + if (searchTimeout) { + clearTimeout(searchTimeout); + searchTimeout = null; + } + + if (state.refreshPromise) { + state.isRefreshing = false; + } + + if (window.WebSocketManager) { + WebSocketManager.disconnect(); + } + + cleanupTooltips(); + forceTooltipDOMCleanup(); + + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + } + + tooltipIdsToCleanup.clear(); + + const cleanupTableBody = (tableId) => { + const tbody = document.getElementById(tableId); + if (!tbody) return; + + const rows = tbody.querySelectorAll('tr'); + rows.forEach(row => { + if (window.CleanupManager) { + CleanupManager.removeListenersByElement(row); + } else { + EventManager.removeAll(row); + } + Array.from(row.attributes).forEach(attr => { + if (attr.name.startsWith('data-')) { + row.removeAttribute(attr.name); + } + }); + }); + while (tbody.firstChild) { + tbody.removeChild(tbody.firstChild); + } + }; + + cleanupTableBody('sent-tbody'); + cleanupTableBody('received-tbody'); + + if (window.CleanupManager) { + CleanupManager.clearAll(); + } else { + EventManager.clearAll(); + } + + const clearAllAnimationFrames = () => { + const rafList = window.requestAnimationFrameList; + if (Array.isArray(rafList)) { + rafList.forEach(id => { + cancelAnimationFrame(id); + }); + window.requestAnimationFrameList = []; + } + }; + clearAllAnimationFrames(); + + state.data = { + sent: [], + received: [] + }; + + state.currentPage = { + sent: 1, + received: 1 + }; + + state.isLoading = false; + state.isRefreshing = false; + state.wsConnected = false; + state.refreshPromise = null; + + state.filters = { + state: -1, + sort_by: 'created_at', + sort_dir: 'desc', + with_expired: true, + searchQuery: '', + coin_from: 'any', + coin_to: 'any' + }; + + if (window.IdentityManager) { + IdentityManager.clearCache(); + } + + if (window.CacheManager) { + CacheManager.cleanup(true); + } + + if (window.MemoryManager) { + MemoryManager.forceCleanup(); + } + + Object.keys(elements).forEach(key => { + elements[key] = null; + }); + + console.log('Comprehensive cleanup completed'); + } catch (error) { + console.error('Error during cleanup process:', error); + + try { + if (window.EventManager) EventManager.clearAll(); + if (window.CleanupManager) CleanupManager.clearAll(); + if (window.WebSocketManager) WebSocketManager.disconnect(); + + state.data = { sent: [], received: [] }; + state.isLoading = false; + + Object.keys(elements).forEach(key => { + elements[key] = null; + }); + } catch (e) { + console.error('Failsafe cleanup also failed:', e); + } + } } -document.addEventListener('beforeunload', cleanup); -document.addEventListener('visibilitychange', () => { +window.cleanupBidsTable = cleanup; + +CleanupManager.addListener(document, 'visibilitychange', () => { if (document.hidden) { - WebSocketManager.pause(); + //console.log('Page hidden - pausing WebSocket and optimizing memory'); + + if (WebSocketManager && typeof WebSocketManager.pause === 'function') { + WebSocketManager.pause(); + } else if (WebSocketManager && typeof WebSocketManager.disconnect === 'function') { + WebSocketManager.disconnect(); + } + + if (window.TooltipManager && typeof window.TooltipManager.cleanup === 'function') { + window.TooltipManager.cleanup(); + } + + // Run memory optimization + if (window.MemoryManager) { + MemoryManager.forceCleanup(); + } } else { - WebSocketManager.resume(); + + if (WebSocketManager && typeof WebSocketManager.resume === 'function') { + WebSocketManager.resume(); + } else if (WebSocketManager && typeof WebSocketManager.connect === 'function') { + WebSocketManager.connect(); + } + + const lastUpdateTime = state.lastRefresh || 0; + const now = Date.now(); + const refreshInterval = 5 * 60 * 1000; // 5 minutes + + if (now - lastUpdateTime > refreshInterval) { + setTimeout(() => { + updateBidsTable(); + }, 500); + } } }); -// WebSocket Management -const WebSocketManager = { - ws: null, - processingQueue: false, - reconnectTimeout: null, - maxReconnectAttempts: 5, - reconnectAttempts: 0, - reconnectDelay: 5000, - healthCheckInterval: null, - isPaused: false, - lastMessageTime: Date.now(), +CleanupManager.addListener(window, 'beforeunload', () => { + cleanup(); +}); + +function cleanupRow(row) { + if (!row) return; + + const tooltipTriggers = row.querySelectorAll('[data-tooltip-target]'); + tooltipTriggers.forEach(trigger => { + if (window.TooltipManager) { + window.TooltipManager.destroy(trigger); + } + }); + + if (window.CleanupManager) { + CleanupManager.removeListenersByElement(row); + } else { + EventManager.removeAll(row); + } + + row.removeAttribute('data-offer-id'); + row.removeAttribute('data-bid-id'); + + while (row.firstChild) { + const child = row.firstChild; + row.removeChild(child); + } +} + +function optimizeMemoryUsage() { + const MAX_BIDS_IN_MEMORY = 500; - initialize() { - this.connect(); - this.startHealthCheck(); - }, + ['sent', 'received'].forEach(type => { + if (state.data[type] && state.data[type].length > MAX_BIDS_IN_MEMORY) { + console.log(`Trimming ${type} bids data from ${state.data[type].length} to ${MAX_BIDS_IN_MEMORY}`); + state.data[type] = state.data[type].slice(0, MAX_BIDS_IN_MEMORY); + } + }); - isConnected() { - return this.ws?.readyState === WebSocket.OPEN; - }, + cleanupOffscreenTooltips(); - connect() { - if (this.isConnected() || this.isPaused) return; - - if (this.ws) { - this.cleanupConnection(); + if (window.IdentityManager && typeof IdentityManager.limitCacheSize === 'function') { + IdentityManager.limitCacheSize(100); } - try { - - let wsPort; - - if (typeof getWebSocketConfig === 'function') { - const wsConfig = getWebSocketConfig(); - wsPort = wsConfig?.port || wsConfig?.fallbackPort; - } - - if (!wsPort && window.config?.port) { - wsPort = window.config.port; - } - - if (!wsPort) { - wsPort = window.ws_port || '11700'; - } - - console.log("Using WebSocket port:", wsPort); - this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); - this.setupEventHandlers(); - } catch (error) { - console.error('WebSocket connection error:', error); - this.handleReconnect(); + if (window.MemoryManager) { + MemoryManager.forceCleanup(); } -}, +} - setupEventHandlers() { - if (!this.ws) return; - - this.ws.onopen = () => { - state.wsConnected = true; - this.reconnectAttempts = 0; - this.lastMessageTime = Date.now(); - updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established for Sent Bids / Received Bids'); - updateBidsTable(); - }; - - this.ws.onmessage = () => { - this.lastMessageTime = Date.now(); - if (this.isPaused) return; - - if (!this.processingQueue) { - this.processingQueue = true; - setTimeout(async () => { - try { - if (!state.isRefreshing) { - await updateBidsTable(); - } - } finally { - this.processingQueue = false; - } - }, 200); - } - }; - - this.ws.onclose = () => { - state.wsConnected = false; - updateConnectionStatus('disconnected'); - if (!this.isPaused) { - this.handleReconnect(); - } - }; - - this.ws.onerror = () => { - updateConnectionStatus('error'); - }; - }, - - startHealthCheck() { - this.stopHealthCheck(); - - this.healthCheckInterval = setInterval(() => { - if (this.isPaused) return; - - const timeSinceLastMessage = Date.now() - this.lastMessageTime; - if (timeSinceLastMessage > 120000) { - console.log('WebSocket connection appears stale. Reconnecting...'); - this.cleanupConnection(); - this.connect(); - return; - } - - if (!this.isConnected()) { - this.handleReconnect(); - } - }, 30000); - }, - - stopHealthCheck() { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - } - }, - - handleReconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - if (this.isPaused) return; - - this.reconnectAttempts++; - if (this.reconnectAttempts <= this.maxReconnectAttempts) { - const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); - //console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); - this.reconnectTimeout = setTimeout(() => this.connect(), delay); - } else { - updateConnectionStatus('error'); - //console.log('Maximum reconnection attempts reached. Will try again in 60 seconds.'); - setTimeout(() => { - this.reconnectAttempts = 0; - this.connect(); - }, 60000); - } - }, - - cleanupConnection() { - if (this.ws) { - this.ws.onopen = null; - this.ws.onmessage = null; - this.ws.onclose = null; - this.ws.onerror = null; - if (this.ws.readyState === WebSocket.OPEN) { - try { - this.ws.close(1000, 'Cleanup'); - } catch (e) { - console.warn('Error closing WebSocket:', e); - } - } - this.ws = null; - } - }, - - pause() { - this.isPaused = true; - //console.log('WebSocket operations paused'); - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - }, - - resume() { - if (!this.isPaused) return; - this.isPaused = false; - //console.log('WebSocket operations resumed'); - this.lastMessageTime = Date.now(); - if (!this.isConnected()) { - this.reconnectAttempts = 0; - this.connect(); - } - }, - - cleanup() { - this.isPaused = true; - this.stopHealthCheck(); - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - this.cleanupConnection(); - } -}; - -// Core const safeParseInt = (value) => { const parsed = parseInt(value); return isNaN(parsed) ? 0 : parsed; @@ -528,7 +490,6 @@ function coinMatches(offerCoin, filterCoin) { return false; } -// State function hasActiveFilters() { const coinFromSelect = document.getElementById('coin_from'); const coinToSelect = document.getElementById('coin_to'); @@ -596,11 +557,58 @@ function filterAndSortData(bids) { const searchStr = state.filters.searchQuery.toLowerCase(); const matchesBidId = bid.bid_id.toLowerCase().includes(searchStr); const matchesIdentity = bid.addr_from?.toLowerCase().includes(searchStr); - const identity = IdentityManager.cache.get(bid.addr_from); - const label = identity?.data?.label || ''; + + let label = ''; + try { + if (window.IdentityManager) { + + let identity = null; + + if (IdentityManager.cache && typeof IdentityManager.cache.get === 'function') { + identity = IdentityManager.cache.get(bid.addr_from); + } + + if (identity && identity.label) { + label = identity.label; + } else if (identity && identity.data && identity.data.label) { + label = identity.data.label; + } + + if (!label && bid.identity) { + label = bid.identity.label || ''; + } + } + } catch (e) { + console.warn('Error accessing identity for search:', e); + } + const matchesLabel = label.toLowerCase().includes(searchStr); - if (!(matchesBidId || matchesIdentity || matchesLabel)) { + let matchesDisplayedLabel = false; + if (!matchesLabel && document) { + try { + const tableId = state.currentTab === 'sent' ? 'sent' : 'received'; + const cells = document.querySelectorAll(`#${tableId} a[href^="/identity/"]`); + + for (const cell of cells) { + + const href = cell.getAttribute('href'); + const cellAddress = href ? href.split('/').pop() : ''; + + if (cellAddress === bid.addr_from) { + const cellText = cell.textContent.trim().toLowerCase(); + if (cellText.includes(searchStr)) { + matchesDisplayedLabel = true; + break; + } + } + } + } catch (e) { + console.warn('Error checking displayed labels:', e); + } + } + + if (!(matchesBidId || matchesIdentity || matchesLabel || matchesDisplayedLabel)) { return false; } } @@ -615,6 +623,37 @@ function filterAndSortData(bids) { }); } +async function preloadIdentitiesForSearch(bids) { + if (!window.IdentityManager || typeof IdentityManager.getIdentityData !== 'function') { + return; + } + + try { + const addresses = new Set(); + bids.forEach(bid => { + if (bid.addr_from) { + addresses.add(bid.addr_from); + } + }); + + const BATCH_SIZE = 20; + const addressArray = Array.from(addresses); + + for (let i = 0; i < addressArray.length; i += BATCH_SIZE) { + const batch = addressArray.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(addr => IdentityManager.getIdentityData(addr))); + + if (i + BATCH_SIZE < addressArray.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + console.log(`Preloaded ${addressArray.length} identities for search`); + } catch (error) { + console.error('Error preloading identities:', error); + } +} + function updateCoinFilterImages() { const coinToSelect = document.getElementById('coin_to'); const coinFromSelect = document.getElementById('coin_from'); @@ -709,108 +748,6 @@ const updateConnectionStatus = (status) => { }); }; -// Identity -const IdentityManager = { - cache: new Map(), - pendingRequests: new Map(), - retryDelay: 2000, - maxRetries: 3, - cacheTimeout: 5 * 60 * 1000, - maxCacheSize: 500, - - async getIdentityData(address) { - if (!address) return { address: '' }; - - const cachedData = this.getCachedIdentity(address); - if (cachedData) return { ...cachedData, address }; - - if (this.pendingRequests.has(address)) { - try { - const pendingData = await this.pendingRequests.get(address); - return { ...pendingData, address }; - } catch (error) { - this.pendingRequests.delete(address); - } - } - - const request = this.fetchWithRetry(address); - this.pendingRequests.set(address, request); - - try { - const data = await request; - - this.trimCacheIfNeeded(); - - this.cache.set(address, { - data, - timestamp: Date.now() - }); - - return { ...data, address }; - } catch (error) { - console.warn(`Error fetching identity for ${address}:`, error); - return { address }; - } finally { - this.pendingRequests.delete(address); - } - }, - - getCachedIdentity(address) { - const cached = this.cache.get(address); - if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { - cached.timestamp = Date.now(); - return cached.data; - } - if (cached) { - this.cache.delete(address); - } - return null; - }, - - trimCacheIfNeeded() { - if (this.cache.size > this.maxCacheSize) { - - const entries = Array.from(this.cache.entries()); - const sortedByAge = entries.sort((a, b) => a[1].timestamp - b[1].timestamp); - - const toRemove = Math.ceil(this.maxCacheSize * 0.2); - for (let i = 0; i < toRemove && i < sortedByAge.length; i++) { - this.cache.delete(sortedByAge[i][0]); - } - console.log(`Trimmed identity cache: removed ${toRemove} oldest entries`); - } - }, - - clearCache() { - this.cache.clear(); - this.pendingRequests.clear(); - }, - - async fetchWithRetry(address, attempt = 1) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`/json/identities/${address}`, { - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - return await response.json(); - } catch (error) { - if (attempt >= this.maxRetries) { - console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); - return { address }; - } - await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); - return this.fetchWithRetry(address, attempt + 1); - } - } -}; - -// Stats const processIdentityStats = (identity) => { if (!identity) return null; @@ -910,7 +847,6 @@ const createIdentityTooltipContent = (identity) => { `; }; -// Table let tooltipIdsToCleanup = new Set(); const cleanupTooltips = () => { @@ -1097,14 +1033,14 @@ const createTableRow = async (bid) => { <!-- Status Column --> <td class="py-3 px-6"> <div class="relative flex justify-center" data-tooltip-target="tooltip-status-${uniqueId}"> - <span class="w-full lg:w-7/8 xl:w-2/3 px-2.5 py-1 inline-flex items-center justify-center rounded-full text-xs font-medium bold ${getStatusClass(bid.bid_state)}"> + <span class="w-full lg:w-7/8 xl:w-2/3 px-2.5 py-1 inline-flex items-center justify-center text-center rounded-full text-xs font-medium bold ${getStatusClass(bid.bid_state)}"> ${bid.bid_state} </span> </div> </td> <!-- Actions Column --> - <td class="py-3 pr-4 pl-3"> + <td class="py-3 pr-4"> <div class="flex justify-center"> <a href="/bid/${bid.bid_id}" class="inline-block w-20 py-1 px-2 font-medium text-center text-sm rounded-md bg-blue-500 text-white border border-blue-500 hover:bg-blue-600 transition duration-200"> @@ -1357,7 +1293,6 @@ function implementVirtualizedRows() { }); } -// Fetching let activeFetchController = null; const fetchBids = async () => { @@ -1432,21 +1367,20 @@ const fetchBids = async () => { const updateBidsTable = async () => { if (state.isLoading) { - //console.log('Already loading, skipping update'); return; } try { - //console.log('Starting updateBidsTable for tab:', state.currentTab); - //console.log('Current filters:', state.filters); - state.isLoading = true; updateLoadingState(true); const bids = await fetchBids(); - - //console.log('Fetched bids:', bids.length); - + + // Add identity preloading if we're searching + if (state.filters.searchQuery && state.filters.searchQuery.length > 0) { + await preloadIdentitiesForSearch(bids); + } + state.data[state.currentTab] = bids; state.currentPage[state.currentTab] = 1; @@ -1503,7 +1437,6 @@ const updatePaginationControls = (type) => { } }; -// Filter let searchTimeout; function handleSearch(event) { if (searchTimeout) { @@ -1708,7 +1641,6 @@ const setupRefreshButtons = () => { }); }; -// Tabs const switchTab = (tabId) => { if (state.isLoading) return; @@ -1925,15 +1857,22 @@ function setupMemoryMonitoring() { const intervalId = setInterval(() => { if (document.hidden) { console.log('Tab hidden - running memory optimization'); - IdentityManager.trimCacheIfNeeded(); - if (window.TooltipManager) { + + if (window.IdentityManager) { + if (typeof IdentityManager.limitCacheSize === 'function') { + IdentityManager.limitCacheSize(100); + } + } + + if (window.TooltipManager && typeof window.TooltipManager.cleanup === 'function') { window.TooltipManager.cleanup(); } + if (state.data.sent.length > 1000) { console.log('Trimming sent bids data'); state.data.sent = state.data.sent.slice(0, 1000); } - + if (state.data.received.length > 1000) { console.log('Trimming received bids data'); state.data.received = state.data.received.slice(0, 1000); @@ -1942,6 +1881,7 @@ function setupMemoryMonitoring() { cleanupTooltips(); } }, MEMORY_CHECK_INTERVAL); + document.addEventListener('beforeunload', () => { clearInterval(intervalId); }, { once: true }); @@ -1985,6 +1925,12 @@ function initialize() { updateBidsTable(); }, 100); + setInterval(() => { + if ((state.data.sent.length + state.data.received.length) > 1000) { + optimizeMemoryUsage(); + } + }, 5 * 60 * 1000); // Check every 5 minutes + window.cleanupBidsTable = cleanup; } diff --git a/basicswap/static/js/bids_export.js b/basicswap/static/js/bids_sentreceived_export.js similarity index 100% rename from basicswap/static/js/bids_export.js rename to basicswap/static/js/bids_sentreceived_export.js diff --git a/basicswap/static/js/coin_icons.js b/basicswap/static/js/coin_icons.js deleted file mode 100644 index f8db7e7..0000000 --- a/basicswap/static/js/coin_icons.js +++ /dev/null @@ -1,68 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - - const selectCache = {}; - - function updateSelectCache(select) { - const selectedOption = select.options[select.selectedIndex]; - const image = selectedOption.getAttribute('data-image'); - const name = selectedOption.textContent.trim(); - selectCache[select.id] = { image, name }; - } - - function setSelectData(select) { - const selectedOption = select.options[select.selectedIndex]; - const image = selectedOption.getAttribute('data-image') || ''; - const name = selectedOption.textContent.trim(); - select.style.backgroundImage = image ? `url(${image}?${new Date().getTime()})` : ''; - - const selectImage = select.nextElementSibling.querySelector('.select-image'); - if (selectImage) { - selectImage.src = image; - } - - const selectNameElement = select.nextElementSibling.querySelector('.select-name'); - if (selectNameElement) { - selectNameElement.textContent = name; - } - - updateSelectCache(select); - } - - const selectIcons = document.querySelectorAll('.custom-select .select-icon'); - const selectImages = document.querySelectorAll('.custom-select .select-image'); - const selectNames = document.querySelectorAll('.custom-select .select-name'); - - selectIcons.forEach(icon => icon.style.display = 'none'); - selectImages.forEach(image => image.style.display = 'none'); - selectNames.forEach(name => name.style.display = 'none'); - - function setupCustomSelect(select) { - const options = select.querySelectorAll('option'); - const selectIcon = select.parentElement.querySelector('.select-icon'); - const selectImage = select.parentElement.querySelector('.select-image'); - - options.forEach(option => { - const image = option.getAttribute('data-image'); - if (image) { - option.style.backgroundImage = `url(${image})`; - } - }); - - const storedValue = localStorage.getItem(select.name); - if (storedValue && select.value == '-1') { - select.value = storedValue; - } - - select.addEventListener('change', () => { - setSelectData(select); - localStorage.setItem(select.name, select.value); - }); - - setSelectData(select); - selectIcon.style.display = 'none'; - selectImage.style.display = 'none'; - } - - const customSelects = document.querySelectorAll('.custom-select select'); - customSelects.forEach(setupCustomSelect); -}); \ No newline at end of file diff --git a/basicswap/static/js/global.js b/basicswap/static/js/global.js new file mode 100644 index 0000000..5892e98 --- /dev/null +++ b/basicswap/static/js/global.js @@ -0,0 +1,199 @@ +document.addEventListener('DOMContentLoaded', function() { + const burger = document.querySelectorAll('.navbar-burger'); + const menu = document.querySelectorAll('.navbar-menu'); + + if (burger.length && menu.length) { + for (var i = 0; i < burger.length; i++) { + burger[i].addEventListener('click', function() { + for (var j = 0; j < menu.length; j++) { + menu[j].classList.toggle('hidden'); + } + }); + } + } + + const close = document.querySelectorAll('.navbar-close'); + const backdrop = document.querySelectorAll('.navbar-backdrop'); + + if (close.length) { + for (var k = 0; k < close.length; k++) { + close[k].addEventListener('click', function() { + for (var j = 0; j < menu.length; j++) { + menu[j].classList.toggle('hidden'); + } + }); + } + } + + if (backdrop.length) { + for (var l = 0; l < backdrop.length; l++) { + backdrop[l].addEventListener('click', function() { + for (var j = 0; j < menu.length; j++) { + menu[j].classList.toggle('hidden'); + } + }); + } + } + + const tooltipManager = TooltipManager.initialize(); + tooltipManager.initializeTooltips(); + setupShutdownModal(); + setupDarkMode(); + toggleImages(); +}); + +function setupShutdownModal() { + const shutdownButtons = document.querySelectorAll('.shutdown-button'); + const shutdownModal = document.getElementById('shutdownModal'); + const closeModalButton = document.getElementById('closeShutdownModal'); + const confirmShutdownButton = document.getElementById('confirmShutdown'); + const shutdownWarning = document.getElementById('shutdownWarning'); + + function updateShutdownButtons() { + const activeSwaps = parseInt(shutdownButtons[0].getAttribute('data-active-swaps') || '0'); + shutdownButtons.forEach(button => { + if (activeSwaps > 0) { + button.classList.add('shutdown-disabled'); + button.setAttribute('data-disabled', 'true'); + button.setAttribute('title', 'Caution: Swaps in progress'); + } else { + button.classList.remove('shutdown-disabled'); + button.removeAttribute('data-disabled'); + button.removeAttribute('title'); + } + }); + } + + function closeAllDropdowns() { + + const openDropdowns = document.querySelectorAll('.dropdown-menu:not(.hidden)'); + openDropdowns.forEach(dropdown => { + if (dropdown.style.display !== 'none') { + dropdown.style.display = 'none'; + } + }); + + if (window.Dropdown && window.Dropdown.instances) { + window.Dropdown.instances.forEach(instance => { + if (instance._visible) { + instance.hide(); + } + }); + } + } + + function showShutdownModal() { + closeAllDropdowns(); + + const activeSwaps = parseInt(shutdownButtons[0].getAttribute('data-active-swaps') || '0'); + if (activeSwaps > 0) { + shutdownWarning.classList.remove('hidden'); + confirmShutdownButton.textContent = 'Yes, Shut Down Anyway'; + } else { + shutdownWarning.classList.add('hidden'); + confirmShutdownButton.textContent = 'Yes, Shut Down'; + } + shutdownModal.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function hideShutdownModal() { + shutdownModal.classList.add('hidden'); + document.body.style.overflow = ''; + } + + if (shutdownButtons.length) { + shutdownButtons.forEach(button => { + button.addEventListener('click', function(e) { + e.preventDefault(); + showShutdownModal(); + }); + }); + } + + if (closeModalButton) { + closeModalButton.addEventListener('click', hideShutdownModal); + } + + if (confirmShutdownButton) { + confirmShutdownButton.addEventListener('click', function() { + const shutdownToken = document.querySelector('.shutdown-button') + .getAttribute('href').split('/').pop(); + window.location.href = '/shutdown/' + shutdownToken; + }); + } + + if (shutdownModal) { + shutdownModal.addEventListener('click', function(e) { + if (e.target === this) { + hideShutdownModal(); + } + }); + } + + if (shutdownButtons.length) { + updateShutdownButtons(); + } +} + +function setupDarkMode() { + const themeToggle = document.getElementById('theme-toggle'); + const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); + const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); + + if (themeToggleDarkIcon && themeToggleLightIcon) { + if (localStorage.getItem('color-theme') === 'dark' || + (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + themeToggleLightIcon.classList.remove('hidden'); + } else { + themeToggleDarkIcon.classList.remove('hidden'); + } + } + + function setTheme(theme) { + if (theme === 'light') { + document.documentElement.classList.remove('dark'); + localStorage.setItem('color-theme', 'light'); + } else { + document.documentElement.classList.add('dark'); + localStorage.setItem('color-theme', 'dark'); + } + } + + if (themeToggle) { + themeToggle.addEventListener('click', () => { + if (localStorage.getItem('color-theme') === 'dark') { + setTheme('light'); + } else { + setTheme('dark'); + } + + if (themeToggleDarkIcon && themeToggleLightIcon) { + themeToggleDarkIcon.classList.toggle('hidden'); + themeToggleLightIcon.classList.toggle('hidden'); + } + + toggleImages(); + }); + } +} + +function toggleImages() { + var html = document.querySelector('html'); + var darkImages = document.querySelectorAll('.dark-image'); + var lightImages = document.querySelectorAll('.light-image'); + + if (html && html.classList.contains('dark')) { + toggleImageDisplay(darkImages, 'block'); + toggleImageDisplay(lightImages, 'none'); + } else { + toggleImageDisplay(darkImages, 'none'); + toggleImageDisplay(lightImages, 'block'); + } +} + +function toggleImageDisplay(images, display) { + images.forEach(function(img) { + img.style.display = display; + }); +} diff --git a/basicswap/static/js/main.js b/basicswap/static/js/main.js deleted file mode 100644 index 38f6e4e..0000000 --- a/basicswap/static/js/main.js +++ /dev/null @@ -1,40 +0,0 @@ -// Burger menus -document.addEventListener('DOMContentLoaded', function() { - // open - const burger = document.querySelectorAll('.navbar-burger'); - const menu = document.querySelectorAll('.navbar-menu'); - - if (burger.length && menu.length) { - for (var i = 0; i < burger.length; i++) { - burger[i].addEventListener('click', function() { - for (var j = 0; j < menu.length; j++) { - menu[j].classList.toggle('hidden'); - } - }); - } - } - - // close - const close = document.querySelectorAll('.navbar-close'); - const backdrop = document.querySelectorAll('.navbar-backdrop'); - - if (close.length) { - for (var k = 0; k < close.length; k++) { - close[k].addEventListener('click', function() { - for (var j = 0; j < menu.length; j++) { - menu[j].classList.toggle('hidden'); - } - }); - } - } - - if (backdrop.length) { - for (var l = 0; l < backdrop.length; l++) { - backdrop[l].addEventListener('click', function() { - for (var j = 0; j < menu.length; j++) { - menu[j].classList.toggle('hidden'); - } - }); - } - } -}); diff --git a/basicswap/static/js/modules/api-manager.js b/basicswap/static/js/modules/api-manager.js new file mode 100644 index 0000000..2effc12 --- /dev/null +++ b/basicswap/static/js/modules/api-manager.js @@ -0,0 +1,389 @@ +const ApiManager = (function() { + + const state = { + isInitialized: false + }; + + const config = { + requestTimeout: 60000, + retryDelays: [5000, 15000, 30000], + rateLimits: { + coingecko: { + requestsPerMinute: 50, + minInterval: 1200 + }, + cryptocompare: { + requestsPerMinute: 30, + minInterval: 2000 + } + } + }; + + const rateLimiter = { + lastRequestTime: {}, + minRequestInterval: { + coingecko: 1200, + cryptocompare: 2000 + }, + requestQueue: {}, + retryDelays: [5000, 15000, 30000], + + canMakeRequest: function(apiName) { + const now = Date.now(); + const lastRequest = this.lastRequestTime[apiName] || 0; + return (now - lastRequest) >= this.minRequestInterval[apiName]; + }, + + updateLastRequestTime: function(apiName) { + this.lastRequestTime[apiName] = Date.now(); + }, + + getWaitTime: function(apiName) { + const now = Date.now(); + const lastRequest = this.lastRequestTime[apiName] || 0; + return Math.max(0, this.minRequestInterval[apiName] - (now - lastRequest)); + }, + + queueRequest: async function(apiName, requestFn, retryCount = 0) { + if (!this.requestQueue[apiName]) { + this.requestQueue[apiName] = Promise.resolve(); + } + + try { + await this.requestQueue[apiName]; + + const executeRequest = async () => { + const waitTime = this.getWaitTime(apiName); + if (waitTime > 0) { + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + + try { + this.updateLastRequestTime(apiName); + return await requestFn(); + } catch (error) { + if (error.message.includes('429') && retryCount < this.retryDelays.length) { + const delay = this.retryDelays[retryCount]; + console.log(`Rate limit hit, retrying in ${delay/1000} seconds...`); + await new Promise(resolve => setTimeout(resolve, delay)); + return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1); + } + + if ((error.message.includes('timeout') || error.name === 'NetworkError') && + retryCount < this.retryDelays.length) { + const delay = this.retryDelays[retryCount]; + console.warn(`Request failed, retrying in ${delay/1000} seconds...`, { + apiName, + retryCount, + error: error.message + }); + await new Promise(resolve => setTimeout(resolve, delay)); + return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1); + } + + throw error; + } + }; + + this.requestQueue[apiName] = executeRequest(); + return await this.requestQueue[apiName]; + + } catch (error) { + if (error.message.includes('429') || + error.message.includes('timeout') || + error.name === 'NetworkError') { + const cacheKey = `coinData_${apiName}`; + try { + const cachedData = JSON.parse(localStorage.getItem(cacheKey)); + if (cachedData && cachedData.value) { + return cachedData.value; + } + } catch (e) { + console.warn('Error accessing cached data:', e); + } + } + throw error; + } + } + }; + + const publicAPI = { + config, + rateLimiter, + + initialize: function(options = {}) { + if (state.isInitialized) { + console.warn('[ApiManager] Already initialized'); + return this; + } + + if (options.config) { + Object.assign(config, options.config); + } + + if (config.rateLimits) { + Object.keys(config.rateLimits).forEach(api => { + if (config.rateLimits[api].minInterval) { + rateLimiter.minRequestInterval[api] = config.rateLimits[api].minInterval; + } + }); + } + + if (config.retryDelays) { + rateLimiter.retryDelays = [...config.retryDelays]; + } + + if (window.CleanupManager) { + window.CleanupManager.registerResource('apiManager', this, (mgr) => mgr.dispose()); + } + + state.isInitialized = true; + console.log('ApiManager initialized'); + return this; + }, + + makeRequest: async function(url, method = 'GET', headers = {}, body = null) { + try { + const options = { + method: method, + headers: { + 'Content-Type': 'application/json', + ...headers + }, + signal: AbortSignal.timeout(config.requestTimeout) + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Request failed for ${url}:`, error); + throw error; + } + }, + + makePostRequest: async function(url, headers = {}) { + return new Promise((resolve, reject) => { + fetch('/json/readurl', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + url: url, + headers: headers + }) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (data.Error) { + reject(new Error(data.Error)); + } else { + resolve(data); + } + }) + .catch(error => { + console.error(`Request failed for ${url}:`, error); + reject(error); + }); + }); + }, + + fetchCoinPrices: async function(coins, source = "coingecko.com", ttl = 300) { + if (!Array.isArray(coins)) { + coins = [coins]; + } + + return this.makeRequest('/json/coinprices', 'POST', {}, { + coins: Array.isArray(coins) ? coins.join(',') : coins, + source: source, + ttl: ttl + }); + }, + + fetchCoinGeckoData: async function() { + return this.rateLimiter.queueRequest('coingecko', async () => { + try { + const coins = (window.config && window.config.coins) ? + window.config.coins + .filter(coin => coin.usesCoinGecko) + .map(coin => coin.name) + .join(',') : + 'bitcoin,monero,particl,bitcoincash,pivx,firo,dash,litecoin,dogecoin,decred'; + + //console.log('Fetching coin prices for:', coins); + const response = await this.fetchCoinPrices(coins); + + //console.log('Full API response:', response); + + if (!response || typeof response !== 'object') { + throw new Error('Invalid response type'); + } + + if (!response.rates || typeof response.rates !== 'object' || Object.keys(response.rates).length === 0) { + throw new Error('No valid rates found in response'); + } + + return response; + } catch (error) { + console.error('Error in fetchCoinGeckoData:', { + message: error.message, + stack: error.stack + }); + throw error; + } + }); + }, + + fetchVolumeData: async function() { + return this.rateLimiter.queueRequest('coingecko', async () => { + try { + const coins = (window.config && window.config.coins) ? + window.config.coins + .filter(coin => coin.usesCoinGecko) + .map(coin => getCoinBackendId ? getCoinBackendId(coin.name) : coin.name) + .join(',') : + 'bitcoin,monero,particl,bitcoin-cash,pivx,firo,dash,litecoin,dogecoin,decred'; + + const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coins}&vs_currencies=usd&include_24hr_vol=true&include_24hr_change=true`; + + const response = await this.makePostRequest(url, { + 'User-Agent': 'Mozilla/5.0', + 'Accept': 'application/json' + }); + + const volumeData = {}; + Object.entries(response).forEach(([coinId, data]) => { + if (data && data.usd_24h_vol) { + volumeData[coinId] = { + total_volume: data.usd_24h_vol, + price_change_percentage_24h: data.usd_24h_change || 0 + }; + } + }); + + return volumeData; + } catch (error) { + console.error("Error fetching volume data:", error); + throw error; + } + }); + }, + + fetchCryptoCompareData: function(coin) { + return this.rateLimiter.queueRequest('cryptocompare', async () => { + try { + const apiKey = window.config?.apiKeys?.cryptoCompare || ''; + const url = `https://min-api.cryptocompare.com/data/pricemultifull?fsyms=${coin}&tsyms=USD,BTC&api_key=${apiKey}`; + const headers = { + 'User-Agent': 'Mozilla/5.0', + 'Accept': 'application/json' + }; + + return await this.makePostRequest(url, headers); + } catch (error) { + console.error(`CryptoCompare request failed for ${coin}:`, error); + throw error; + } + }); + }, + + fetchHistoricalData: async function(coinSymbols, resolution = 'day') { + if (!Array.isArray(coinSymbols)) { + coinSymbols = [coinSymbols]; + } + + const results = {}; + const fetchPromises = coinSymbols.map(async coin => { + if (coin === 'WOW') { + return this.rateLimiter.queueRequest('coingecko', async () => { + const url = `https://api.coingecko.com/api/v3/coins/wownero/market_chart?vs_currency=usd&days=1`; + 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); + throw error; + } + }); + } else { + return this.rateLimiter.queueRequest('cryptocompare', async () => { + try { + const apiKey = window.config?.apiKeys?.cryptoCompare || ''; + let url; + + if (resolution === 'day') { + url = `https://min-api.cryptocompare.com/data/v2/histohour?fsym=${coin}&tsym=USD&limit=24&api_key=${apiKey}`; + } else if (resolution === 'year') { + url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=365&api_key=${apiKey}`; + } else { + url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=180&api_key=${apiKey}`; + } + + const response = await this.makePostRequest(url); + if (response.Response === "Error") { + console.error(`API Error for ${coin}:`, response.Message); + throw new Error(response.Message); + } else if (response.Data && response.Data.Data) { + results[coin] = response.Data; + } + } catch (error) { + console.error(`Error fetching CryptoCompare data for ${coin}:`, error); + throw error; + } + }); + } + }); + + await Promise.all(fetchPromises); + return results; + }, + + dispose: function() { + // Clear any pending requests or resources + rateLimiter.requestQueue = {}; + rateLimiter.lastRequestTime = {}; + state.isInitialized = false; + console.log('ApiManager disposed'); + } + }; + + return publicAPI; +})(); + +function getCoinBackendId(coinName) { + const nameMap = { + 'bitcoin-cash': 'bitcoincash', + 'bitcoin cash': 'bitcoincash', + 'firo': 'zcoin', + 'zcoin': 'zcoin', + 'bitcoincash': 'bitcoin-cash' + }; + return nameMap[coinName.toLowerCase()] || coinName.toLowerCase(); +} + +window.Api = ApiManager; +window.ApiManager = ApiManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.apiManagerInitialized) { + ApiManager.initialize(); + window.apiManagerInitialized = true; + } +}); + +//console.log('ApiManager initialized with methods:', Object.keys(ApiManager)); +console.log('ApiManager initialized'); diff --git a/basicswap/static/js/modules/cache-manager.js b/basicswap/static/js/modules/cache-manager.js new file mode 100644 index 0000000..1dec899 --- /dev/null +++ b/basicswap/static/js/modules/cache-manager.js @@ -0,0 +1,535 @@ +const CacheManager = (function() { + const defaults = window.config?.cacheConfig?.storage || { + maxSizeBytes: 10 * 1024 * 1024, + maxItems: 200, + defaultTTL: 5 * 60 * 1000 + }; + + const PRICES_CACHE_KEY = 'crypto_prices_unified'; + + const CACHE_KEY_PATTERNS = [ + 'coinData_', + 'chartData_', + 'historical_', + 'rates_', + 'prices_', + 'offers_', + 'fallback_', + 'volumeData' + ]; + + const isCacheKey = (key) => { + return CACHE_KEY_PATTERNS.some(pattern => key.startsWith(pattern)) || + key === 'coinGeckoOneLiner' || + key === PRICES_CACHE_KEY; + }; + + const isLocalStorageAvailable = () => { + try { + const testKey = '__storage_test__'; + localStorage.setItem(testKey, testKey); + localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + }; + + let storageAvailable = isLocalStorageAvailable(); + + const memoryCache = new Map(); + + if (!storageAvailable) { + console.warn('localStorage is not available. Using in-memory cache instead.'); + } + + const cacheAPI = { + getTTL: function(resourceType) { + const ttlConfig = window.config?.cacheConfig?.ttlSettings || {}; + return ttlConfig[resourceType] || window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL; + }, + + set: function(key, value, resourceTypeOrCustomTtl = null) { + try { + this.cleanup(); + + if (!value) { + console.warn('Attempted to cache null/undefined value for key:', key); + return false; + } + + let ttl; + if (typeof resourceTypeOrCustomTtl === 'string') { + ttl = this.getTTL(resourceTypeOrCustomTtl); + } else if (typeof resourceTypeOrCustomTtl === 'number') { + ttl = resourceTypeOrCustomTtl; + } else { + ttl = window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL; + } + + const item = { + value: value, + timestamp: Date.now(), + expiresAt: Date.now() + ttl + }; + + let serializedItem; + try { + serializedItem = JSON.stringify(item); + } catch (e) { + console.error('Failed to serialize cache item:', e); + return false; + } + + const itemSize = new Blob([serializedItem]).size; + if (itemSize > defaults.maxSizeBytes) { + console.warn(`Cache item exceeds maximum size (${(itemSize/1024/1024).toFixed(2)}MB)`); + return false; + } + + if (storageAvailable) { + try { + localStorage.setItem(key, serializedItem); + return true; + } catch (storageError) { + if (storageError.name === 'QuotaExceededError') { + this.cleanup(true); + try { + localStorage.setItem(key, serializedItem); + return true; + } catch (retryError) { + console.error('Storage quota exceeded even after cleanup:', retryError); + storageAvailable = false; + console.warn('Switching to in-memory cache due to quota issues'); + memoryCache.set(key, item); + return true; + } + } else { + console.error('localStorage error:', storageError); + storageAvailable = false; + console.warn('Switching to in-memory cache due to localStorage error'); + memoryCache.set(key, item); + return true; + } + } + } else { + memoryCache.set(key, item); + if (memoryCache.size > defaults.maxItems) { + const keysToDelete = Array.from(memoryCache.keys()) + .filter(k => isCacheKey(k)) + .sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp) + .slice(0, Math.floor(memoryCache.size * 0.2)); // Remove oldest 20% + + keysToDelete.forEach(k => memoryCache.delete(k)); + } + + return true; + } + } catch (error) { + console.error('Cache set error:', error); + try { + memoryCache.set(key, { + value: value, + timestamp: Date.now(), + expiresAt: Date.now() + (window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL) + }); + return true; + } catch (e) { + console.error('Memory cache set error:', e); + return false; + } + } + }, + + get: function(key) { + try { + if (storageAvailable) { + try { + const itemStr = localStorage.getItem(key); + if (itemStr) { + let item; + try { + item = JSON.parse(itemStr); + } catch (parseError) { + console.error('Failed to parse cached item:', parseError); + localStorage.removeItem(key); + return null; + } + + if (!item || typeof item.expiresAt !== 'number' || !Object.prototype.hasOwnProperty.call(item, 'value')) { + console.warn('Invalid cache item structure for key:', key); + localStorage.removeItem(key); + return null; + } + + const now = Date.now(); + if (now < item.expiresAt) { + return { + value: item.value, + remainingTime: item.expiresAt - now + }; + } + + localStorage.removeItem(key); + return null; + } + } catch (error) { + console.error("localStorage access error:", error); + storageAvailable = false; + console.warn('Switching to in-memory cache due to localStorage error'); + } + } + + if (memoryCache.has(key)) { + const item = memoryCache.get(key); + const now = Date.now(); + + if (now < item.expiresAt) { + return { + value: item.value, + remainingTime: item.expiresAt - now + }; + } else { + + memoryCache.delete(key); + } + } + + return null; + } catch (error) { + console.error("Cache retrieval error:", error); + try { + if (storageAvailable) { + localStorage.removeItem(key); + } + memoryCache.delete(key); + } catch (removeError) { + console.error("Failed to remove invalid cache entry:", removeError); + } + return null; + } + }, + + isValid: function(key) { + return this.get(key) !== null; + }, + + cleanup: function(aggressive = false) { + const now = Date.now(); + let totalSize = 0; + let itemCount = 0; + const items = []; + + if (storageAvailable) { + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!isCacheKey(key)) continue; + + try { + const itemStr = localStorage.getItem(key); + const size = new Blob([itemStr]).size; + const item = JSON.parse(itemStr); + + if (now >= item.expiresAt) { + localStorage.removeItem(key); + continue; + } + + items.push({ + key, + size, + expiresAt: item.expiresAt, + timestamp: item.timestamp + }); + + totalSize += size; + itemCount++; + } catch (error) { + console.error("Error processing cache item:", error); + localStorage.removeItem(key); + } + } + + if (aggressive || totalSize > defaults.maxSizeBytes || itemCount > defaults.maxItems) { + items.sort((a, b) => b.timestamp - a.timestamp); + + while ((totalSize > defaults.maxSizeBytes || itemCount > defaults.maxItems) && items.length > 0) { + const item = items.pop(); + try { + localStorage.removeItem(item.key); + totalSize -= item.size; + itemCount--; + } catch (error) { + console.error("Error removing cache item:", error); + } + } + } + } catch (error) { + console.error("Error during localStorage cleanup:", error); + storageAvailable = false; + console.warn('Switching to in-memory cache due to localStorage error'); + } + } + + const expiredKeys = []; + memoryCache.forEach((item, key) => { + if (now >= item.expiresAt) { + expiredKeys.push(key); + } + }); + + expiredKeys.forEach(key => memoryCache.delete(key)); + + if (aggressive && memoryCache.size > defaults.maxItems / 2) { + const keysToDelete = Array.from(memoryCache.keys()) + .filter(key => isCacheKey(key)) + .sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp) + .slice(0, Math.floor(memoryCache.size * 0.3)); // Remove oldest 30% during aggressive cleanup + + keysToDelete.forEach(key => memoryCache.delete(key)); + } + + return { + totalSize, + itemCount, + memoryCacheSize: memoryCache.size, + cleaned: items.length, + storageAvailable + }; + }, + + clear: function() { + + if (storageAvailable) { + try { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (isCacheKey(key)) { + keys.push(key); + } + } + + keys.forEach(key => { + try { + localStorage.removeItem(key); + } catch (error) { + console.error("Error clearing cache item:", error); + } + }); + } catch (error) { + console.error("Error clearing localStorage cache:", error); + storageAvailable = false; + } + } + + Array.from(memoryCache.keys()) + .filter(key => isCacheKey(key)) + .forEach(key => memoryCache.delete(key)); + + console.log("Cache cleared successfully"); + return true; + }, + + getStats: function() { + let totalSize = 0; + let itemCount = 0; + let expiredCount = 0; + const now = Date.now(); + + if (storageAvailable) { + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!isCacheKey(key)) continue; + + try { + const itemStr = localStorage.getItem(key); + const size = new Blob([itemStr]).size; + const item = JSON.parse(itemStr); + + totalSize += size; + itemCount++; + + if (now >= item.expiresAt) { + expiredCount++; + } + } catch (error) { + console.error("Error getting cache stats:", error); + } + } + } catch (error) { + console.error("Error getting localStorage stats:", error); + storageAvailable = false; + } + } + + let memoryCacheSize = 0; + let memoryCacheItems = 0; + let memoryCacheExpired = 0; + + memoryCache.forEach((item, key) => { + if (isCacheKey(key)) { + memoryCacheItems++; + if (now >= item.expiresAt) { + memoryCacheExpired++; + } + try { + memoryCacheSize += new Blob([JSON.stringify(item)]).size; + } catch (e) { + } + } + }); + + return { + totalSizeMB: (totalSize / 1024 / 1024).toFixed(2), + itemCount, + expiredCount, + utilization: ((totalSize / defaults.maxSizeBytes) * 100).toFixed(1) + '%', + memoryCacheItems, + memoryCacheExpired, + memoryCacheSizeKB: (memoryCacheSize / 1024).toFixed(2), + storageType: storageAvailable ? 'localStorage' : 'memory' + }; + }, + + checkStorage: function() { + const wasAvailable = storageAvailable; + storageAvailable = isLocalStorageAvailable(); + + if (storageAvailable && !wasAvailable && memoryCache.size > 0) { + console.log('localStorage is now available. Migrating memory cache...'); + let migratedCount = 0; + memoryCache.forEach((item, key) => { + if (isCacheKey(key)) { + try { + localStorage.setItem(key, JSON.stringify(item)); + memoryCache.delete(key); + migratedCount++; + } catch (e) { + if (e.name === 'QuotaExceededError') { + console.warn('Storage quota exceeded during migration. Keeping items in memory cache.'); + return false; + } + } + } + }); + + console.log(`Migrated ${migratedCount} items from memory cache to localStorage.`); + } + + return { + available: storageAvailable, + type: storageAvailable ? 'localStorage' : 'memory' + }; + } + }; + + const publicAPI = { + ...cacheAPI, + + setPrices: function(priceData, customTtl = null) { + return this.set(PRICES_CACHE_KEY, priceData, + customTtl || (typeof customTtl === 'undefined' ? 'prices' : null)); + }, + + getPrices: function() { + return this.get(PRICES_CACHE_KEY); + }, + + getCoinPrice: function(symbol) { + const prices = this.getPrices(); + if (!prices || !prices.value) { + return null; + } + + const normalizedSymbol = symbol.toLowerCase(); + return prices.value[normalizedSymbol] || null; + }, + + getCompatiblePrices: function(format) { + const prices = this.getPrices(); + if (!prices || !prices.value) { + return null; + } + + switch(format) { + case 'rates': + const ratesFormat = {}; + Object.entries(prices.value).forEach(([coin, data]) => { + const coinKey = coin.replace(/-/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + .toLowerCase() + .replace(' ', '-'); + + ratesFormat[coinKey] = { + usd: data.price || data.usd, + btc: data.price_btc || data.btc + }; + }); + return { + value: ratesFormat, + remainingTime: prices.remainingTime + }; + + case 'coinGecko': + const geckoFormat = {}; + Object.entries(prices.value).forEach(([coin, data]) => { + const symbol = this.getSymbolFromCoinId(coin); + if (symbol) { + geckoFormat[symbol.toLowerCase()] = { + current_price: data.price || data.usd, + price_btc: data.price_btc || data.btc, + total_volume: data.total_volume, + price_change_percentage_24h: data.price_change_percentage_24h, + displayName: symbol + }; + } + }); + return { + value: geckoFormat, + remainingTime: prices.remainingTime + }; + + default: + return prices; + } + }, + + getSymbolFromCoinId: function(coinId) { + const symbolMap = { + 'bitcoin': 'BTC', + 'litecoin': 'LTC', + 'monero': 'XMR', + 'particl': 'PART', + 'pivx': 'PIVX', + 'firo': 'FIRO', + 'zcoin': 'FIRO', + 'dash': 'DASH', + 'decred': 'DCR', + 'wownero': 'WOW', + 'bitcoin-cash': 'BCH', + 'dogecoin': 'DOGE' + }; + + return symbolMap[coinId] || null; + } + }; + + if (window.CleanupManager) { + window.CleanupManager.registerResource('cacheManager', publicAPI, (cm) => { + cm.clear(); + }); + } + + return publicAPI; +})(); + +window.CacheManager = CacheManager; + + +//console.log('CacheManager initialized with methods:', Object.keys(CacheManager)); +console.log('CacheManager initialized'); diff --git a/basicswap/static/js/modules/cleanup-manager.js b/basicswap/static/js/modules/cleanup-manager.js new file mode 100644 index 0000000..0615fca --- /dev/null +++ b/basicswap/static/js/modules/cleanup-manager.js @@ -0,0 +1,270 @@ +const CleanupManager = (function() { + + const state = { + eventListeners: [], + timeouts: [], + intervals: [], + animationFrames: [], + resources: new Map(), + debug: false + }; + + function log(message, ...args) { + if (state.debug) { + console.log(`[CleanupManager] ${message}`, ...args); + } + } + + const publicAPI = { + addListener: function(element, type, handler, options = false) { + if (!element) { + log('Warning: Attempted to add listener to null/undefined element'); + return handler; + } + + element.addEventListener(type, handler, options); + state.eventListeners.push({ element, type, handler, options }); + log(`Added ${type} listener to`, element); + return handler; + }, + + setTimeout: function(callback, delay) { + const id = window.setTimeout(callback, delay); + state.timeouts.push(id); + log(`Created timeout ${id} with ${delay}ms delay`); + return id; + }, + + setInterval: function(callback, delay) { + const id = window.setInterval(callback, delay); + state.intervals.push(id); + log(`Created interval ${id} with ${delay}ms delay`); + return id; + }, + + requestAnimationFrame: function(callback) { + const id = window.requestAnimationFrame(callback); + state.animationFrames.push(id); + log(`Requested animation frame ${id}`); + return id; + }, + + registerResource: function(type, resource, cleanupFn) { + const id = `${type}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + state.resources.set(id, { resource, cleanupFn }); + log(`Registered custom resource ${id} of type ${type}`); + return id; + }, + + unregisterResource: function(id) { + const resourceInfo = state.resources.get(id); + if (resourceInfo) { + try { + resourceInfo.cleanupFn(resourceInfo.resource); + state.resources.delete(id); + log(`Unregistered and cleaned up resource ${id}`); + return true; + } catch (error) { + console.error(`[CleanupManager] Error cleaning up resource ${id}:`, error); + return false; + } + } + log(`Resource ${id} not found`); + return false; + }, + + clearTimeout: function(id) { + const index = state.timeouts.indexOf(id); + if (index !== -1) { + window.clearTimeout(id); + state.timeouts.splice(index, 1); + log(`Cleared timeout ${id}`); + } + }, + + clearInterval: function(id) { + const index = state.intervals.indexOf(id); + if (index !== -1) { + window.clearInterval(id); + state.intervals.splice(index, 1); + log(`Cleared interval ${id}`); + } + }, + + cancelAnimationFrame: function(id) { + const index = state.animationFrames.indexOf(id); + if (index !== -1) { + window.cancelAnimationFrame(id); + state.animationFrames.splice(index, 1); + log(`Cancelled animation frame ${id}`); + } + }, + + removeListener: function(element, type, handler, options = false) { + if (!element) return; + + try { + element.removeEventListener(type, handler, options); + log(`Removed ${type} listener from`, element); + } catch (error) { + console.error(`[CleanupManager] Error removing event listener:`, error); + } + + state.eventListeners = state.eventListeners.filter( + listener => !(listener.element === element && + listener.type === type && + listener.handler === handler) + ); + }, + + removeListenersByElement: function(element) { + if (!element) return; + + const listenersToRemove = state.eventListeners.filter( + listener => listener.element === element + ); + + listenersToRemove.forEach(({ element, type, handler, options }) => { + try { + element.removeEventListener(type, handler, options); + log(`Removed ${type} listener from`, element); + } catch (error) { + console.error(`[CleanupManager] Error removing event listener:`, error); + } + }); + + state.eventListeners = state.eventListeners.filter( + listener => listener.element !== element + ); + }, + + clearAllTimeouts: function() { + state.timeouts.forEach(id => { + window.clearTimeout(id); + }); + const count = state.timeouts.length; + state.timeouts = []; + log(`Cleared all timeouts (${count})`); + }, + + clearAllIntervals: function() { + state.intervals.forEach(id => { + window.clearInterval(id); + }); + const count = state.intervals.length; + state.intervals = []; + log(`Cleared all intervals (${count})`); + }, + + clearAllAnimationFrames: function() { + state.animationFrames.forEach(id => { + window.cancelAnimationFrame(id); + }); + const count = state.animationFrames.length; + state.animationFrames = []; + log(`Cancelled all animation frames (${count})`); + }, + + clearAllResources: function() { + let successCount = 0; + let errorCount = 0; + + state.resources.forEach((resourceInfo, id) => { + try { + resourceInfo.cleanupFn(resourceInfo.resource); + successCount++; + } catch (error) { + console.error(`[CleanupManager] Error cleaning up resource ${id}:`, error); + errorCount++; + } + }); + + state.resources.clear(); + log(`Cleared all custom resources (${successCount} success, ${errorCount} errors)`); + }, + + clearAllListeners: function() { + state.eventListeners.forEach(({ element, type, handler, options }) => { + if (element) { + try { + element.removeEventListener(type, handler, options); + } catch (error) { + console.error(`[CleanupManager] Error removing event listener:`, error); + } + } + }); + const count = state.eventListeners.length; + state.eventListeners = []; + log(`Removed all event listeners (${count})`); + }, + + clearAll: function() { + const counts = { + listeners: state.eventListeners.length, + timeouts: state.timeouts.length, + intervals: state.intervals.length, + animationFrames: state.animationFrames.length, + resources: state.resources.size + }; + + this.clearAllListeners(); + this.clearAllTimeouts(); + this.clearAllIntervals(); + this.clearAllAnimationFrames(); + this.clearAllResources(); + + log(`All resources cleaned up:`, counts); + return counts; + }, + + getResourceCounts: function() { + return { + listeners: state.eventListeners.length, + timeouts: state.timeouts.length, + intervals: state.intervals.length, + animationFrames: state.animationFrames.length, + resources: state.resources.size, + total: state.eventListeners.length + + state.timeouts.length + + state.intervals.length + + state.animationFrames.length + + state.resources.size + }; + }, + + setDebugMode: function(enabled) { + state.debug = Boolean(enabled); + log(`Debug mode ${state.debug ? 'enabled' : 'disabled'}`); + return state.debug; + }, + + dispose: function() { + this.clearAll(); + log('CleanupManager disposed'); + }, + + initialize: function(options = {}) { + if (options.debug !== undefined) { + this.setDebugMode(options.debug); + } + log('CleanupManager initialized'); + return this; + } + }; + + return publicAPI; +})(); + + +window.CleanupManager = CleanupManager; + + +document.addEventListener('DOMContentLoaded', function() { + if (!window.cleanupManagerInitialized) { + CleanupManager.initialize(); + window.cleanupManagerInitialized = true; + } +}); + +//console.log('CleanupManager initialized with methods:', Object.keys(CleanupManager)); +console.log('CleanupManager initialized'); diff --git a/basicswap/static/js/modules/config-manager.js b/basicswap/static/js/modules/config-manager.js new file mode 100644 index 0000000..e77c55e --- /dev/null +++ b/basicswap/static/js/modules/config-manager.js @@ -0,0 +1,414 @@ +const ConfigManager = (function() { + const state = { + isInitialized: false + }; + + function determineWebSocketPort() { + const wsPort = + window.ws_port || + (typeof getWebSocketConfig === 'function' ? getWebSocketConfig().port : null) || + '11700'; + return wsPort; + } + + const selectedWsPort = determineWebSocketPort(); + + const defaultConfig = { + cacheDuration: 10 * 60 * 1000, + requestTimeout: 60000, + wsPort: selectedWsPort, + + cacheConfig: { + defaultTTL: 10 * 60 * 1000, + + ttlSettings: { + prices: 5 * 60 * 1000, + chart: 5 * 60 * 1000, + historical: 60 * 60 * 1000, + volume: 30 * 60 * 1000, + offers: 2 * 60 * 1000, + identity: 15 * 60 * 1000 + }, + + storage: { + maxSizeBytes: 10 * 1024 * 1024, + maxItems: 200 + }, + + fallbackTTL: 24 * 60 * 60 * 1000 + }, + + itemsPerPage: 50, + + apiEndpoints: { + cryptoCompare: 'https://min-api.cryptocompare.com/data/pricemultifull', + coinGecko: 'https://api.coingecko.com/api/v3', + cryptoCompareHistorical: 'https://min-api.cryptocompare.com/data/v2/histoday', + cryptoCompareHourly: 'https://min-api.cryptocompare.com/data/v2/histohour', + volumeEndpoint: 'https://api.coingecko.com/api/v3/simple/price' + }, + + rateLimits: { + coingecko: { + requestsPerMinute: 50, + minInterval: 1200 + }, + cryptocompare: { + requestsPerMinute: 30, + minInterval: 2000 + } + }, + + retryDelays: [5000, 15000, 30000], + + coins: [ + { symbol: 'BTC', name: 'bitcoin', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'XMR', name: 'monero', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'PART', name: 'particl', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'BCH', name: 'bitcoincash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'PIVX', name: 'pivx', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'FIRO', name: 'firo', displayName: 'Firo', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'DASH', name: 'dash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'LTC', name: 'litecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'DOGE', name: 'dogecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'DCR', name: 'decred', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, + { symbol: 'WOW', name: 'wownero', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 } + ], + + coinMappings: { + nameToSymbol: { + 'Bitcoin': 'BTC', + 'Litecoin': 'LTC', + 'Monero': 'XMR', + 'Particl': 'PART', + 'Particl Blind': 'PART', + 'Particl Anon': 'PART', + 'PIVX': 'PIVX', + 'Firo': 'FIRO', + 'Zcoin': 'FIRO', + 'Dash': 'DASH', + 'Decred': 'DCR', + 'Wownero': 'WOW', + 'Bitcoin Cash': 'BCH', + 'Dogecoin': 'DOGE' + }, + + nameToDisplayName: { + 'Bitcoin': 'Bitcoin', + 'Litecoin': 'Litecoin', + 'Monero': 'Monero', + 'Particl': 'Particl', + 'Particl Blind': 'Particl Blind', + 'Particl Anon': 'Particl Anon', + 'PIVX': 'PIVX', + 'Firo': 'Firo', + 'Zcoin': 'Firo', + 'Dash': 'Dash', + 'Decred': 'Decred', + 'Wownero': 'Wownero', + 'Bitcoin Cash': 'Bitcoin Cash', + 'Dogecoin': 'Dogecoin' + }, + + idToName: { + 1: 'particl', 2: 'bitcoin', 3: 'litecoin', 4: 'decred', + 6: 'monero', 7: 'particl blind', 8: 'particl anon', + 9: 'wownero', 11: 'pivx', 13: 'firo', 17: 'bitcoincash', + 18: 'dogecoin' + }, + + nameToCoinGecko: { + 'bitcoin': 'bitcoin', + 'monero': 'monero', + 'particl': 'particl', + 'bitcoin cash': 'bitcoin-cash', + 'bitcoincash': 'bitcoin-cash', + 'pivx': 'pivx', + 'firo': 'firo', + 'zcoin': 'firo', + 'dash': 'dash', + 'litecoin': 'litecoin', + 'dogecoin': 'dogecoin', + 'decred': 'decred', + 'wownero': 'wownero' + } + }, + + chartConfig: { + colors: { + default: { + lineColor: 'rgba(77, 132, 240, 1)', + backgroundColor: 'rgba(77, 132, 240, 0.1)' + } + }, + showVolume: false, + specialCoins: [''], + resolutions: { + year: { days: 365, interval: 'month' }, + sixMonths: { days: 180, interval: 'daily' }, + day: { days: 1, interval: 'hourly' } + }, + currentResolution: 'year' + } + }; + + const publicAPI = { + ...defaultConfig, + + initialize: function(options = {}) { + if (state.isInitialized) { + console.warn('[ConfigManager] Already initialized'); + return this; + } + + if (options) { + Object.assign(this, options); + } + + if (window.CleanupManager) { + window.CleanupManager.registerResource('configManager', this, (mgr) => mgr.dispose()); + } + + this.utils = utils; + + state.isInitialized = true; + console.log('ConfigManager initialized'); + return this; + }, + + getAPIKeys: function() { + if (typeof window.getAPIKeys === 'function') { + const apiKeys = window.getAPIKeys(); + return { + cryptoCompare: apiKeys.cryptoCompare || '', + coinGecko: apiKeys.coinGecko || '' + }; + } + + return { + cryptoCompare: '', + coinGecko: '' + }; + }, + + getCoinBackendId: function(coinName) { + if (!coinName) return null; + + const nameMap = { + 'bitcoin-cash': 'bitcoincash', + 'bitcoin cash': 'bitcoincash', + 'firo': 'firo', + 'zcoin': 'firo', + 'bitcoincash': 'bitcoin-cash' + }; + + const lowerCoinName = typeof coinName === 'string' ? coinName.toLowerCase() : ''; + return nameMap[lowerCoinName] || lowerCoinName; + }, + + coinMatches: function(offerCoin, filterCoin) { + if (!offerCoin || !filterCoin) return false; + + offerCoin = offerCoin.toLowerCase(); + filterCoin = filterCoin.toLowerCase(); + + if (offerCoin === filterCoin) return true; + + if ((offerCoin === 'firo' || offerCoin === 'zcoin') && + (filterCoin === 'firo' || filterCoin === 'zcoin')) { + return true; + } + + if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') || + (offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) { + return true; + } + + const particlVariants = ['particl', 'particl anon', 'particl blind']; + if (filterCoin === 'particl' && particlVariants.includes(offerCoin)) { + return true; + } + + if (particlVariants.includes(filterCoin)) { + return offerCoin === filterCoin; + } + + return false; + }, + + update: function(path, value) { + const parts = path.split('.'); + let current = this; + + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + + current[parts[parts.length - 1]] = value; + return this; + }, + + get: function(path, defaultValue = null) { + const parts = path.split('.'); + let current = this; + + for (let i = 0; i < parts.length; i++) { + if (current === undefined || current === null) { + return defaultValue; + } + current = current[parts[i]]; + } + + return current !== undefined ? current : defaultValue; + }, + + dispose: function() { + state.isInitialized = false; + console.log('ConfigManager disposed'); + } + }; + + const utils = { + formatNumber: function(number, decimals = 2) { + if (typeof number !== 'number' || isNaN(number)) { + console.warn('formatNumber received a non-number value:', number); + return '0'; + } + try { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(number); + } catch (e) { + return '0'; + } + }, + + formatDate: function(timestamp, resolution) { + const date = new Date(timestamp); + const options = { + day: { hour: '2-digit', minute: '2-digit', hour12: true }, + week: { month: 'short', day: 'numeric' }, + month: { year: 'numeric', month: 'short', day: 'numeric' } + }; + return date.toLocaleString('en-US', { ...options[resolution], timeZone: 'UTC' }); + }, + + debounce: function(func, delay) { + let timeoutId; + return function(...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; + }, + + formatTimeLeft: function(timestamp) { + const now = Math.floor(Date.now() / 1000); + if (timestamp <= now) return "Expired"; + return this.formatTime(timestamp); + }, + + formatTime: function(timestamp, addAgoSuffix = false) { + const now = Math.floor(Date.now() / 1000); + const diff = Math.abs(now - timestamp); + + let timeString; + if (diff < 60) { + timeString = `${diff} seconds`; + } else if (diff < 3600) { + timeString = `${Math.floor(diff / 60)} minutes`; + } else if (diff < 86400) { + timeString = `${Math.floor(diff / 3600)} hours`; + } else if (diff < 2592000) { + timeString = `${Math.floor(diff / 86400)} days`; + } else if (diff < 31536000) { + timeString = `${Math.floor(diff / 2592000)} months`; + } else { + timeString = `${Math.floor(diff / 31536000)} years`; + } + + return addAgoSuffix ? `${timeString} ago` : timeString; + }, + + escapeHtml: function(unsafe) { + if (typeof unsafe !== 'string') { + console.warn('escapeHtml received a non-string value:', unsafe); + return ''; + } + return unsafe + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }, + + formatPrice: function(coin, price) { + if (typeof price !== 'number' || isNaN(price)) { + console.warn(`Invalid price for ${coin}:`, price); + return 'N/A'; + } + if (price < 0.000001) return price.toExponential(2); + if (price < 0.001) return price.toFixed(8); + if (price < 1) return price.toFixed(4); + if (price < 10) return price.toFixed(3); + if (price < 1000) return price.toFixed(2); + if (price < 100000) return price.toFixed(1); + return price.toFixed(0); + }, + + getEmptyPriceData: function() { + return { + 'bitcoin': { usd: null, btc: null }, + 'bitcoin-cash': { usd: null, btc: null }, + 'dash': { usd: null, btc: null }, + 'dogecoin': { usd: null, btc: null }, + 'decred': { usd: null, btc: null }, + 'litecoin': { usd: null, btc: null }, + 'particl': { usd: null, btc: null }, + 'pivx': { usd: null, btc: null }, + 'monero': { usd: null, btc: null }, + 'zano': { usd: null, btc: null }, + 'wownero': { usd: null, btc: null }, + 'firo': { usd: null, btc: null } + }; + }, + + getCoinSymbol: function(fullName) { + return publicAPI.coinMappings?.nameToSymbol[fullName] || fullName; + } + }; + + return publicAPI; +})(); + +window.logger = { + log: function(message) { + console.log(`[AppLog] ${new Date().toISOString()}: ${message}`); + }, + warn: function(message) { + console.warn(`[AppWarn] ${new Date().toISOString()}: ${message}`); + }, + error: function(message) { + console.error(`[AppError] ${new Date().toISOString()}: ${message}`); + } +}; + +window.config = ConfigManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.configManagerInitialized) { + ConfigManager.initialize(); + window.configManagerInitialized = true; + } +}); + +if (typeof module !== 'undefined') { + module.exports = ConfigManager; +} + +//console.log('ConfigManager initialized with properties:', Object.keys(ConfigManager)); +console.log('ConfigManager initialized'); diff --git a/basicswap/static/js/modules/identity-manager.js b/basicswap/static/js/modules/identity-manager.js new file mode 100644 index 0000000..9ac2859 --- /dev/null +++ b/basicswap/static/js/modules/identity-manager.js @@ -0,0 +1,192 @@ +const IdentityManager = (function() { + const state = { + cache: new Map(), + pendingRequests: new Map(), + config: { + retryDelay: 2000, + maxRetries: 3, + maxCacheSize: 100, + cacheTimeout: window.config?.cacheConfig?.ttlSettings?.identity || 15 * 60 * 1000, + debug: false + } + }; + + function log(message, ...args) { + if (state.config.debug) { + console.log(`[IdentityManager] ${message}`, ...args); + } + } + + const publicAPI = { + getIdentityData: async function(address) { + if (!address) { + return null; + } + + const cachedData = this.getCachedIdentity(address); + if (cachedData) { + log(`Cache hit for ${address}`); + return cachedData; + } + + if (state.pendingRequests.has(address)) { + log(`Using pending request for ${address}`); + return state.pendingRequests.get(address); + } + + log(`Fetching identity for ${address}`); + const request = fetchWithRetry(address); + state.pendingRequests.set(address, request); + + try { + const data = await request; + this.setCachedIdentity(address, data); + return data; + } finally { + state.pendingRequests.delete(address); + } + }, + + getCachedIdentity: function(address) { + const cached = state.cache.get(address); + if (cached && (Date.now() - cached.timestamp) < state.config.cacheTimeout) { + return cached.data; + } + return null; + }, + + setCachedIdentity: function(address, data) { + if (state.cache.size >= state.config.maxCacheSize) { + const oldestEntries = [...state.cache.entries()] + .sort((a, b) => a[1].timestamp - b[1].timestamp) + .slice(0, Math.floor(state.config.maxCacheSize * 0.2)); + + oldestEntries.forEach(([key]) => { + state.cache.delete(key); + log(`Pruned cache entry for ${key}`); + }); + } + + state.cache.set(address, { + data, + timestamp: Date.now() + }); + log(`Cached identity for ${address}`); + }, + + clearCache: function() { + log(`Clearing identity cache (${state.cache.size} entries)`); + state.cache.clear(); + state.pendingRequests.clear(); + }, + + limitCacheSize: function(maxSize = state.config.maxCacheSize) { + if (state.cache.size <= maxSize) { + return 0; + } + + const entriesToRemove = [...state.cache.entries()] + .sort((a, b) => a[1].timestamp - b[1].timestamp) + .slice(0, state.cache.size - maxSize); + + entriesToRemove.forEach(([key]) => state.cache.delete(key)); + log(`Limited cache size, removed ${entriesToRemove.length} entries`); + + return entriesToRemove.length; + }, + + getCacheSize: function() { + return state.cache.size; + }, + + configure: function(options = {}) { + Object.assign(state.config, options); + log(`Configuration updated:`, state.config); + return state.config; + }, + + getStats: function() { + const now = Date.now(); + let expiredCount = 0; + let totalSize = 0; + + state.cache.forEach((value, key) => { + if (now - value.timestamp > state.config.cacheTimeout) { + expiredCount++; + } + const keySize = key.length * 2; + const dataSize = JSON.stringify(value.data).length * 2; + totalSize += keySize + dataSize; + }); + + return { + cacheEntries: state.cache.size, + pendingRequests: state.pendingRequests.size, + expiredEntries: expiredCount, + estimatedSizeKB: Math.round(totalSize / 1024), + config: { ...state.config } + }; + }, + + setDebugMode: function(enabled) { + state.config.debug = Boolean(enabled); + return `Debug mode ${state.config.debug ? 'enabled' : 'disabled'}`; + }, + + initialize: function(options = {}) { + + if (options) { + this.configure(options); + } + + if (window.CleanupManager) { + window.CleanupManager.registerResource('identityManager', this, (mgr) => mgr.dispose()); + } + + log('IdentityManager initialized'); + return this; + }, + + dispose: function() { + this.clearCache(); + log('IdentityManager disposed'); + } + }; + + async function fetchWithRetry(address, attempt = 1) { + try { + const response = await fetch(`/json/identities/${address}`, { + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (attempt >= state.config.maxRetries) { + console.error(`[IdentityManager] Error:`, error.message); + console.warn(`[IdentityManager] Failed to fetch identity for ${address} after ${attempt} attempts`); + return null; + } + + await new Promise(resolve => setTimeout(resolve, state.config.retryDelay * attempt)); + return fetchWithRetry(address, attempt + 1); + } + } + + return publicAPI; +})(); + +window.IdentityManager = IdentityManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.identityManagerInitialized) { + IdentityManager.initialize(); + window.identityManagerInitialized = true; + } +}); + +//console.log('IdentityManager initialized with methods:', Object.keys(IdentityManager)); +console.log('IdentityManager initialized'); diff --git a/basicswap/static/js/modules/memory-manager.js b/basicswap/static/js/modules/memory-manager.js new file mode 100644 index 0000000..4c3cffd --- /dev/null +++ b/basicswap/static/js/modules/memory-manager.js @@ -0,0 +1,219 @@ +const MemoryManager = (function() { + + const state = { + isMonitoringEnabled: false, + monitorInterval: null, + cleanupInterval: null + }; + + const config = { + monitorInterval: 30000, + cleanupInterval: 60000, + debug: false + }; + + function log(message, ...args) { + if (config.debug) { + console.log(`[MemoryManager] ${message}`, ...args); + } + } + + const publicAPI = { + enableMonitoring: function(interval = config.monitorInterval) { + if (state.monitorInterval) { + clearInterval(state.monitorInterval); + } + + state.isMonitoringEnabled = true; + config.monitorInterval = interval; + + this.logMemoryUsage(); + + state.monitorInterval = setInterval(() => { + this.logMemoryUsage(); + }, interval); + + console.log(`Memory monitoring enabled - reporting every ${interval/1000} seconds`); + return true; + }, + + disableMonitoring: function() { + if (state.monitorInterval) { + clearInterval(state.monitorInterval); + state.monitorInterval = null; + } + + state.isMonitoringEnabled = false; + console.log('Memory monitoring disabled'); + return true; + }, + + logMemoryUsage: function() { + const timestamp = new Date().toLocaleTimeString(); + console.log(`=== Memory Monitor [${timestamp}] ===`); + + if (window.performance && window.performance.memory) { + console.log('Memory usage:', { + usedJSHeapSize: (window.performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB', + totalJSHeapSize: (window.performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB' + }); + } + + if (navigator.deviceMemory) { + console.log('Device memory:', navigator.deviceMemory, 'GB'); + } + + const nodeCount = document.querySelectorAll('*').length; + console.log('DOM node count:', nodeCount); + + if (window.CleanupManager) { + const counts = CleanupManager.getResourceCounts(); + console.log('Managed resources:', counts); + } + + if (window.TooltipManager) { + const tooltipInstances = document.querySelectorAll('[data-tippy-root]').length; + const tooltipTriggers = document.querySelectorAll('[data-tooltip-trigger-id]').length; + console.log('Tooltip instances:', tooltipInstances, '- Tooltip triggers:', tooltipTriggers); + } + + if (window.CacheManager && window.CacheManager.getStats) { + const cacheStats = CacheManager.getStats(); + console.log('Cache stats:', cacheStats); + } + + if (window.IdentityManager && window.IdentityManager.getStats) { + const identityStats = window.IdentityManager.getStats(); + console.log('Identity cache stats:', identityStats); + } + + console.log('=============================='); + }, + + enableAutoCleanup: function(interval = config.cleanupInterval) { + if (state.cleanupInterval) { + clearInterval(state.cleanupInterval); + } + + config.cleanupInterval = interval; + + this.forceCleanup(); + + state.cleanupInterval = setInterval(() => { + this.forceCleanup(); + }, interval); + + log('Auto-cleanup enabled every', interval/1000, 'seconds'); + return true; + }, + + disableAutoCleanup: function() { + if (state.cleanupInterval) { + clearInterval(state.cleanupInterval); + state.cleanupInterval = null; + } + + console.log('Memory auto-cleanup disabled'); + return true; + }, + + forceCleanup: function() { + if (config.debug) { + console.log('Running memory cleanup...', new Date().toLocaleTimeString()); + } + + if (window.CacheManager && CacheManager.cleanup) { + CacheManager.cleanup(true); + } + + if (window.TooltipManager && TooltipManager.cleanup) { + window.TooltipManager.cleanup(); + } + + document.querySelectorAll('[data-tooltip-trigger-id]').forEach(element => { + if (window.TooltipManager && TooltipManager.destroy) { + window.TooltipManager.destroy(element); + } + }); + + if (window.chartModule && chartModule.cleanup) { + chartModule.cleanup(); + } + + if (window.gc) { + window.gc(); + } else { + const arr = new Array(1000); + for (let i = 0; i < 1000; i++) { + arr[i] = new Array(10000).join('x'); + } + } + + if (config.debug) { + console.log('Memory cleanup completed'); + } + + return true; + }, + + setDebugMode: function(enabled) { + config.debug = Boolean(enabled); + return `Debug mode ${config.debug ? 'enabled' : 'disabled'}`; + }, + + getStatus: function() { + return { + monitoring: { + enabled: Boolean(state.monitorInterval), + interval: config.monitorInterval + }, + autoCleanup: { + enabled: Boolean(state.cleanupInterval), + interval: config.cleanupInterval + }, + debug: config.debug + }; + }, + + initialize: function(options = {}) { + if (options.debug !== undefined) { + this.setDebugMode(options.debug); + } + + if (options.enableMonitoring) { + this.enableMonitoring(options.monitorInterval || config.monitorInterval); + } + + if (options.enableAutoCleanup) { + this.enableAutoCleanup(options.cleanupInterval || config.cleanupInterval); + } + + if (window.CleanupManager) { + window.CleanupManager.registerResource('memoryManager', this, (mgr) => mgr.dispose()); + } + + log('MemoryManager initialized'); + return this; + }, + + dispose: function() { + this.disableMonitoring(); + this.disableAutoCleanup(); + log('MemoryManager disposed'); + } + }; + + return publicAPI; +})(); + +window.MemoryManager = MemoryManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.memoryManagerInitialized) { + MemoryManager.initialize(); + window.memoryManagerInitialized = true; + } +}); + +//console.log('MemoryManager initialized with methods:', Object.keys(MemoryManager)); +console.log('MemoryManager initialized'); diff --git a/basicswap/static/js/modules/network-manager.js b/basicswap/static/js/modules/network-manager.js new file mode 100644 index 0000000..c5a1dc4 --- /dev/null +++ b/basicswap/static/js/modules/network-manager.js @@ -0,0 +1,280 @@ +const NetworkManager = (function() { + const state = { + isOnline: navigator.onLine, + reconnectAttempts: 0, + reconnectTimer: null, + lastNetworkError: null, + eventHandlers: {}, + connectionTestInProgress: false + }; + + const config = { + maxReconnectAttempts: 5, + reconnectDelay: 5000, + reconnectBackoff: 1.5, + connectionTestEndpoint: '/json', + connectionTestTimeout: 3000, + debug: false + }; + + function log(message, ...args) { + if (config.debug) { + console.log(`[NetworkManager] ${message}`, ...args); + } + } + + function generateHandlerId() { + return `handler_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + const publicAPI = { + initialize: function(options = {}) { + Object.assign(config, options); + + window.addEventListener('online', this.handleOnlineStatus.bind(this)); + window.addEventListener('offline', this.handleOfflineStatus.bind(this)); + + state.isOnline = navigator.onLine; + log(`Network status initialized: ${state.isOnline ? 'online' : 'offline'}`); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('networkManager', this, (mgr) => mgr.dispose()); + } + + return this; + }, + + isOnline: function() { + return state.isOnline; + }, + + getReconnectAttempts: function() { + return state.reconnectAttempts; + }, + + resetReconnectAttempts: function() { + state.reconnectAttempts = 0; + return this; + }, + + handleOnlineStatus: function() { + log('Browser reports online status'); + state.isOnline = true; + this.notifyHandlers('online'); + + if (state.reconnectTimer) { + this.scheduleReconnectRefresh(); + } + }, + + handleOfflineStatus: function() { + log('Browser reports offline status'); + state.isOnline = false; + this.notifyHandlers('offline'); + }, + + handleNetworkError: function(error) { + if (error && ( + (error.name === 'TypeError' && error.message.includes('NetworkError')) || + (error.name === 'AbortError') || + (error.message && error.message.includes('network')) || + (error.message && error.message.includes('timeout')) + )) { + log('Network error detected:', error.message); + + if (state.isOnline) { + state.isOnline = false; + state.lastNetworkError = error; + this.notifyHandlers('error', error); + } + + if (!state.reconnectTimer) { + this.scheduleReconnectRefresh(); + } + + return true; + } + return false; + }, + + scheduleReconnectRefresh: function() { + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + + const delay = config.reconnectDelay * Math.pow(config.reconnectBackoff, + Math.min(state.reconnectAttempts, 5)); + + log(`Scheduling reconnection attempt in ${delay/1000} seconds`); + + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null; + this.attemptReconnect(); + }, delay); + + return this; + }, + + attemptReconnect: function() { + if (!navigator.onLine) { + log('Browser still reports offline, delaying reconnection attempt'); + this.scheduleReconnectRefresh(); + return; + } + + if (state.connectionTestInProgress) { + log('Connection test already in progress'); + return; + } + + state.reconnectAttempts++; + state.connectionTestInProgress = true; + + log(`Attempting reconnect #${state.reconnectAttempts}`); + + this.testBackendConnection() + .then(isAvailable => { + state.connectionTestInProgress = false; + + if (isAvailable) { + log('Backend connection confirmed'); + state.isOnline = true; + state.reconnectAttempts = 0; + state.lastNetworkError = null; + this.notifyHandlers('reconnected'); + } else { + log('Backend still unavailable'); + + if (state.reconnectAttempts < config.maxReconnectAttempts) { + this.scheduleReconnectRefresh(); + } else { + log('Maximum reconnect attempts reached'); + this.notifyHandlers('maxAttemptsReached'); + } + } + }) + .catch(error => { + state.connectionTestInProgress = false; + log('Error during connection test:', error); + + if (state.reconnectAttempts < config.maxReconnectAttempts) { + this.scheduleReconnectRefresh(); + } else { + log('Maximum reconnect attempts reached'); + this.notifyHandlers('maxAttemptsReached'); + } + }); + }, + + testBackendConnection: function() { + return fetch(config.connectionTestEndpoint, { + method: 'HEAD', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + }, + timeout: config.connectionTestTimeout, + signal: AbortSignal.timeout(config.connectionTestTimeout) + }) + .then(response => { + return response.ok; + }) + .catch(error => { + log('Backend connection test failed:', error.message); + return false; + }); + }, + + manualReconnect: function() { + log('Manual reconnection requested'); + + state.isOnline = navigator.onLine; + state.reconnectAttempts = 0; + + this.notifyHandlers('manualReconnect'); + + if (state.isOnline) { + return this.attemptReconnect(); + } else { + log('Cannot attempt manual reconnect while browser reports offline'); + this.notifyHandlers('offlineWarning'); + return false; + } + }, + + addHandler: function(event, handler) { + if (!state.eventHandlers[event]) { + state.eventHandlers[event] = {}; + } + + const handlerId = generateHandlerId(); + state.eventHandlers[event][handlerId] = handler; + + return handlerId; + }, + + removeHandler: function(event, handlerId) { + if (state.eventHandlers[event] && state.eventHandlers[event][handlerId]) { + delete state.eventHandlers[event][handlerId]; + return true; + } + return false; + }, + + notifyHandlers: function(event, data) { + if (state.eventHandlers[event]) { + Object.values(state.eventHandlers[event]).forEach(handler => { + try { + handler(data); + } catch (error) { + log(`Error in ${event} handler:`, error); + } + }); + } + }, + + setDebugMode: function(enabled) { + config.debug = Boolean(enabled); + return `Debug mode ${config.debug ? 'enabled' : 'disabled'}`; + }, + + getState: function() { + return { + isOnline: state.isOnline, + reconnectAttempts: state.reconnectAttempts, + hasReconnectTimer: Boolean(state.reconnectTimer), + connectionTestInProgress: state.connectionTestInProgress + }; + }, + + dispose: function() { + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + + window.removeEventListener('online', this.handleOnlineStatus); + window.removeEventListener('offline', this.handleOfflineStatus); + + state.eventHandlers = {}; + + log('NetworkManager disposed'); + } + }; + + return publicAPI; +})(); + +window.NetworkManager = NetworkManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.networkManagerInitialized) { + NetworkManager.initialize(); + window.networkManagerInitialized = true; + } +}); + +//console.log('NetworkManager initialized with methods:', Object.keys(NetworkManager)); +console.log('NetworkManager initialized'); + diff --git a/basicswap/static/js/modules/notification-manager.js b/basicswap/static/js/modules/notification-manager.js new file mode 100644 index 0000000..7265b15 --- /dev/null +++ b/basicswap/static/js/modules/notification-manager.js @@ -0,0 +1,126 @@ +const NotificationManager = (function() { + + const config = { + showNewOffers: false, + showNewBids: true, + showBidAccepted: true + }; + + function ensureToastContainer() { + let container = document.getElementById('ul_updates'); + if (!container) { + const floating_div = document.createElement('div'); + floating_div.classList.add('floatright'); + container = document.createElement('ul'); + container.setAttribute('id', 'ul_updates'); + floating_div.appendChild(container); + document.body.appendChild(floating_div); + } + return container; + } + + const publicAPI = { + initialize: function(options = {}) { + Object.assign(config, options); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('notificationManager', this, (mgr) => { + + console.log('NotificationManager disposed'); + }); + } + + return this; + }, + + createToast: function(title, type = 'success') { + const messages = ensureToastContainer(); + const message = document.createElement('li'); + message.innerHTML = ` + <div id="hide"> + <div id="toast-${type}" class="flex items-center p-4 mb-4 w-full max-w-xs text-gray-500 + bg-white rounded-lg shadow" role="alert"> + <div class="inline-flex flex-shrink-0 justify-center items-center w-10 h-10 + bg-blue-500 rounded-lg"> + <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" height="18" width="18" + viewBox="0 0 24 24"> + <g fill="#ffffff"> + <path d="M8.5,20a1.5,1.5,0,0,1-1.061-.439L.379,12.5,2.5,10.379l6,6,13-13L23.621, + 5.5,9.561,19.561A1.5,1.5,0,0,1,8.5,20Z"></path> + </g> + </svg> + </div> + <div class="uppercase w-40 ml-3 text-sm font-semibold text-gray-900">${title}</div> + <button type="button" onclick="closeAlert(event)" class="ml-auto -mx-1.5 -my-1.5 + bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-0 focus:outline-none + focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8"> + <span class="sr-only">Close</span> + <svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" + xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 + 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 + 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" + clip-rule="evenodd"></path> + </svg> + </button> + </div> + </div> + `; + messages.appendChild(message); + }, + + handleWebSocketEvent: function(data) { + if (!data || !data.event) return; + let toastTitle; + let shouldShowToast = false; + + switch (data.event) { + case 'new_offer': + toastTitle = `New network <a class="underline" href=/offer/${data.offer_id}>offer</a>`; + shouldShowToast = config.showNewOffers; + break; + case 'new_bid': + toastTitle = `<a class="underline" href=/bid/${data.bid_id}>New bid</a> on + <a class="underline" href=/offer/${data.offer_id}>offer</a>`; + shouldShowToast = config.showNewBids; + break; + case 'bid_accepted': + toastTitle = `<a class="underline" href=/bid/${data.bid_id}>Bid</a> accepted`; + shouldShowToast = config.showBidAccepted; + break; + } + + if (toastTitle && shouldShowToast) { + this.createToast(toastTitle); + } + }, + + updateConfig: function(newConfig) { + Object.assign(config, newConfig); + return this; + } + }; + + window.closeAlert = function(event) { + let element = event.target; + while (element.nodeName !== "BUTTON") { + element = element.parentNode; + } + element.parentNode.parentNode.removeChild(element.parentNode); + }; + + return publicAPI; +})(); + +window.NotificationManager = NotificationManager; + +document.addEventListener('DOMContentLoaded', function() { + + if (!window.notificationManagerInitialized) { + window.NotificationManager.initialize(window.notificationConfig || {}); + window.notificationManagerInitialized = true; + } +}); + +//console.log('NotificationManager initialized with methods:', Object.keys(NotificationManager)); +console.log('NotificationManager initialized'); diff --git a/basicswap/static/js/modules/summary-manager.js b/basicswap/static/js/modules/summary-manager.js new file mode 100644 index 0000000..d4f42b4 --- /dev/null +++ b/basicswap/static/js/modules/summary-manager.js @@ -0,0 +1,338 @@ +const SummaryManager = (function() { + const config = { + refreshInterval: window.config?.cacheDuration || 30000, + summaryEndpoint: '/json', + retryDelay: 5000, + maxRetries: 3, + requestTimeout: 15000 + }; + + let refreshTimer = null; + let webSocket = null; + let fetchRetryCount = 0; + let lastSuccessfulData = null; + + function updateElement(elementId, value) { + const element = document.getElementById(elementId); + if (!element) return false; + + const safeValue = (value !== undefined && value !== null) + ? value + : (element.dataset.lastValue || 0); + + element.dataset.lastValue = safeValue; + + if (elementId === 'sent-bids-counter' || elementId === 'recv-bids-counter') { + const svg = element.querySelector('svg'); + element.textContent = safeValue; + if (svg) { + element.insertBefore(svg, element.firstChild); + } + } else { + element.textContent = safeValue; + } + + if (['offers-counter', 'bid-requests-counter', 'sent-bids-counter', + 'recv-bids-counter', 'swaps-counter', 'network-offers-counter', + 'watched-outputs-counter'].includes(elementId)) { + element.classList.remove('bg-blue-500', 'bg-gray-400'); + element.classList.add(safeValue > 0 ? 'bg-blue-500' : 'bg-gray-400'); + } + + if (elementId === 'swaps-counter') { + const swapContainer = document.getElementById('swapContainer'); + if (swapContainer) { + const isSwapping = safeValue > 0; + if (isSwapping) { + swapContainer.innerHTML = document.querySelector('#swap-in-progress-green-template').innerHTML || ''; + swapContainer.style.animation = 'spin 2s linear infinite'; + } else { + swapContainer.innerHTML = document.querySelector('#swap-in-progress-template').innerHTML || ''; + swapContainer.style.animation = 'none'; + } + } + } + return true; + } + + function updateUIFromData(data) { + if (!data) return; + + updateElement('network-offers-counter', data.num_network_offers); + updateElement('offers-counter', data.num_sent_active_offers); + updateElement('sent-bids-counter', data.num_sent_active_bids); + updateElement('recv-bids-counter', data.num_recv_active_bids); + updateElement('bid-requests-counter', data.num_available_bids); + updateElement('swaps-counter', data.num_swapping); + updateElement('watched-outputs-counter', data.num_watched_outputs); + + const shutdownButtons = document.querySelectorAll('.shutdown-button'); + shutdownButtons.forEach(button => { + button.setAttribute('data-active-swaps', data.num_swapping); + if (data.num_swapping > 0) { + button.classList.add('shutdown-disabled'); + button.setAttribute('data-disabled', 'true'); + button.setAttribute('title', 'Caution: Swaps in progress'); + } else { + button.classList.remove('shutdown-disabled'); + button.removeAttribute('data-disabled'); + button.removeAttribute('title'); + } + }); + } + + function cacheSummaryData(data) { + if (!data) return; + + localStorage.setItem('summary_data_cache', JSON.stringify({ + timestamp: Date.now(), + data: data + })); + } + + function getCachedSummaryData() { + let cachedData = null; + + cachedData = localStorage.getItem('summary_data_cache'); + if (!cachedData) return null; + + const parsedCache = JSON.parse(cachedData); + const maxAge = 24 * 60 * 60 * 1000; + + if (Date.now() - parsedCache.timestamp < maxAge) { + return parsedCache.data; + } + + return null; + } + + function fetchSummaryDataWithTimeout() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + + return fetch(config.summaryEndpoint, { + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }) + .then(response => { + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response.json(); + }) + .catch(error => { + clearTimeout(timeoutId); + throw error; + }); + } + + function setupWebSocket() { + if (webSocket) { + webSocket.close(); + } + + const wsPort = window.config?.wsPort || + (typeof determineWebSocketPort === 'function' ? determineWebSocketPort() : '11700'); + + const wsUrl = "ws://" + window.location.hostname + ":" + wsPort; + webSocket = new WebSocket(wsUrl); + + webSocket.onopen = () => { + publicAPI.fetchSummaryData() + .then(() => {}) + .catch(() => {}); + }; + + webSocket.onmessage = (event) => { + let data; + + try { + data = JSON.parse(event.data); + } catch (error) { + if (window.logger && window.logger.error) { + window.logger.error('WebSocket message processing error: ' + error.message); + } + return; + } + + if (data.event) { + publicAPI.fetchSummaryData() + .then(() => {}) + .catch(() => {}); + + if (window.NotificationManager && typeof window.NotificationManager.handleWebSocketEvent === 'function') { + window.NotificationManager.handleWebSocketEvent(data); + } + } + }; + + webSocket.onclose = () => { + setTimeout(setupWebSocket, 5000); + }; + } + + function ensureSwapTemplates() { + if (!document.getElementById('swap-in-progress-template')) { + const template = document.createElement('template'); + template.id = 'swap-in-progress-template'; + template.innerHTML = document.querySelector('[id^="swapContainer"]')?.innerHTML || ''; + document.body.appendChild(template); + } + + if (!document.getElementById('swap-in-progress-green-template') && + document.querySelector('[id^="swapContainer"]')?.innerHTML) { + const greenTemplate = document.createElement('template'); + greenTemplate.id = 'swap-in-progress-green-template'; + greenTemplate.innerHTML = document.querySelector('[id^="swapContainer"]')?.innerHTML || ''; + document.body.appendChild(greenTemplate); + } + } + + function startRefreshTimer() { + stopRefreshTimer(); + + publicAPI.fetchSummaryData() + .then(() => {}) + .catch(() => {}); + + refreshTimer = setInterval(() => { + publicAPI.fetchSummaryData() + .then(() => {}) + .catch(() => {}); + }, config.refreshInterval); + } + + function stopRefreshTimer() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + const publicAPI = { + initialize: function(options = {}) { + Object.assign(config, options); + + ensureSwapTemplates(); + + const cachedData = getCachedSummaryData(); + if (cachedData) { + updateUIFromData(cachedData); + } + + if (window.WebSocketManager && typeof window.WebSocketManager.initialize === 'function') { + const wsManager = window.WebSocketManager; + + if (!wsManager.isConnected()) { + wsManager.connect(); + } + + wsManager.addMessageHandler('message', (data) => { + if (data.event) { + this.fetchSummaryData() + .then(() => {}) + .catch(() => {}); + + if (window.NotificationManager && typeof window.NotificationManager.handleWebSocketEvent === 'function') { + window.NotificationManager.handleWebSocketEvent(data); + } + } + }); + } else { + setupWebSocket(); + } + + startRefreshTimer(); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('summaryManager', this, (mgr) => mgr.dispose()); + } + + return this; + }, + + fetchSummaryData: function() { + return fetchSummaryDataWithTimeout() + .then(data => { + lastSuccessfulData = data; + cacheSummaryData(data); + fetchRetryCount = 0; + + updateUIFromData(data); + + return data; + }) + .catch(error => { + if (window.logger && window.logger.error) { + window.logger.error('Summary data fetch error: ' + error.message); + } + + if (fetchRetryCount < config.maxRetries) { + fetchRetryCount++; + + if (window.logger && window.logger.warn) { + window.logger.warn(`Retrying summary data fetch (${fetchRetryCount}/${config.maxRetries}) in ${config.retryDelay/1000}s`); + } + + return new Promise(resolve => { + setTimeout(() => { + resolve(this.fetchSummaryData()); + }, config.retryDelay); + }); + } else { + const cachedData = lastSuccessfulData || getCachedSummaryData(); + + if (cachedData) { + if (window.logger && window.logger.warn) { + window.logger.warn('Using cached summary data after fetch failures'); + } + updateUIFromData(cachedData); + } + + fetchRetryCount = 0; + + throw error; + } + }); + }, + + startRefreshTimer: function() { + startRefreshTimer(); + }, + + stopRefreshTimer: function() { + stopRefreshTimer(); + }, + + dispose: function() { + stopRefreshTimer(); + + if (webSocket && webSocket.readyState === WebSocket.OPEN) { + webSocket.close(); + } + + webSocket = null; + } + }; + + return publicAPI; +})(); + +window.SummaryManager = SummaryManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.summaryManagerInitialized) { + window.SummaryManager = SummaryManager.initialize(); + window.summaryManagerInitialized = true; + } +}); + +//console.log('SummaryManager initialized with methods:', Object.keys(SummaryManager)); +console.log('SummaryManager initialized'); diff --git a/basicswap/static/js/modules/tooltips-manager.js b/basicswap/static/js/modules/tooltips-manager.js new file mode 100644 index 0000000..cb8018f --- /dev/null +++ b/basicswap/static/js/modules/tooltips-manager.js @@ -0,0 +1,588 @@ +const TooltipManager = (function() { + let instance = null; + + class TooltipManagerImpl { + constructor() { + + if (instance) { + return instance; + } + + this.activeTooltips = new WeakMap(); + this.tooltipIdCounter = 0; + this.pendingAnimationFrames = new Set(); + this.tooltipElementsMap = new Map(); + this.maxTooltips = 300; + this.cleanupThreshold = 1.3; + this.disconnectedCheckInterval = null; + + this.setupStyles(); + this.setupCleanupEvents(); + this.initializeMutationObserver(); + this.startDisconnectedElementsCheck(); + + instance = this; + } + + create(element, content, options = {}) { + if (!element) return null; + + this.destroy(element); + + if (this.tooltipElementsMap.size > this.maxTooltips * this.cleanupThreshold) { + const oldestEntries = Array.from(this.tooltipElementsMap.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp) + .slice(0, 20); + + oldestEntries.forEach(([el]) => { + this.destroy(el); + }); + } + + const originalContent = content; + + const rafId = requestAnimationFrame(() => { + this.pendingAnimationFrames.delete(rafId); + + if (!document.body.contains(element)) return; + + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + this.createTooltip(element, originalContent, options, rect); + } else { + let retryCount = 0; + const retryCreate = () => { + const newRect = element.getBoundingClientRect(); + if ((newRect.width > 0 && newRect.height > 0) || retryCount >= 3) { + if (newRect.width > 0 && newRect.height > 0) { + this.createTooltip(element, originalContent, options, newRect); + } + } else { + retryCount++; + const newRafId = requestAnimationFrame(retryCreate); + this.pendingAnimationFrames.add(newRafId); + } + }; + const initialRetryId = requestAnimationFrame(retryCreate); + this.pendingAnimationFrames.add(initialRetryId); + } + }); + + this.pendingAnimationFrames.add(rafId); + return null; + } + + createTooltip(element, content, options, rect) { + const targetId = element.getAttribute('data-tooltip-target'); + let bgClass = 'bg-gray-400'; + let arrowColor = 'rgb(156 163 175)'; + + if (targetId?.includes('tooltip-offer-') && window.jsonData) { + try { + const offerId = targetId.split('tooltip-offer-')[1]; + let actualOfferId = offerId; + + if (offerId.includes('_')) { + [actualOfferId] = offerId.split('_'); + } + + let offer = null; + if (Array.isArray(window.jsonData)) { + for (let i = 0; i < window.jsonData.length; i++) { + const o = window.jsonData[i]; + if (o && (o.unique_id === offerId || o.offer_id === actualOfferId)) { + offer = o; + break; + } + } + } + + if (offer) { + if (offer.is_revoked) { + bgClass = 'bg-red-500'; + arrowColor = 'rgb(239 68 68)'; + } else if (offer.is_own_offer) { + bgClass = 'bg-gray-300'; + arrowColor = 'rgb(209 213 219)'; + } else { + bgClass = 'bg-green-700'; + arrowColor = 'rgb(21 128 61)'; + } + } + } catch (e) { + console.warn('Error finding offer for tooltip:', e); + } + } + + const tooltipId = `tooltip-${++this.tooltipIdCounter}`; + + try { + if (typeof tippy !== 'function') { + console.error('Tippy.js is not loaded. Cannot create tooltip.'); + return null; + } + + const instance = tippy(element, { + content: content, + allowHTML: true, + placement: options.placement || 'top', + appendTo: document.body, + animation: false, + duration: 0, + delay: 0, + interactive: true, + arrow: false, + theme: '', + moveTransition: 'none', + offset: [0, 10], + onShow(instance) { + if (!document.body.contains(element)) { + return false; + } + return true; + }, + onMount(instance) { + if (instance.popper && instance.popper.firstElementChild) { + instance.popper.firstElementChild.classList.add(bgClass); + instance.popper.setAttribute('data-for-tooltip-id', tooltipId); + } + const arrow = instance.popper.querySelector('.tippy-arrow'); + if (arrow) { + arrow.style.setProperty('color', arrowColor, 'important'); + } + }, + popperOptions: { + strategy: 'fixed', + modifiers: [ + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 10 + } + }, + { + name: 'flip', + options: { + padding: 10, + fallbackPlacements: ['top', 'bottom', 'right', 'left'] + } + } + ] + } + }); + + element.setAttribute('data-tooltip-trigger-id', tooltipId); + this.activeTooltips.set(element, instance); + + this.tooltipElementsMap.set(element, { + timestamp: Date.now(), + id: tooltipId + }); + + return instance; + } catch (e) { + console.error('Error creating tooltip:', e); + return null; + } + } + + destroy(element) { + if (!element) return; + + const id = element.getAttribute('data-tooltip-trigger-id'); + if (!id) return; + + const instance = this.activeTooltips.get(element); + if (instance?.[0]) { + try { + instance[0].destroy(); + } catch (e) { + console.warn('Error destroying tooltip:', e); + + const tippyRoot = document.querySelector(`[data-for-tooltip-id="${id}"]`); + if (tippyRoot && tippyRoot.parentNode) { + tippyRoot.parentNode.removeChild(tippyRoot); + } + } + } + + this.activeTooltips.delete(element); + this.tooltipElementsMap.delete(element); + + element.removeAttribute('data-tooltip-trigger-id'); + } + + cleanup() { + this.pendingAnimationFrames.forEach(id => { + cancelAnimationFrame(id); + }); + this.pendingAnimationFrames.clear(); + + const elements = document.querySelectorAll('[data-tooltip-trigger-id]'); + const batchSize = 20; + + const processElementsBatch = (startIdx) => { + const endIdx = Math.min(startIdx + batchSize, elements.length); + + for (let i = startIdx; i < endIdx; i++) { + this.destroy(elements[i]); + } + + if (endIdx < elements.length) { + const rafId = requestAnimationFrame(() => { + this.pendingAnimationFrames.delete(rafId); + processElementsBatch(endIdx); + }); + this.pendingAnimationFrames.add(rafId); + } else { + this.cleanupOrphanedTippyElements(); + } + }; + + if (elements.length > 0) { + processElementsBatch(0); + } else { + this.cleanupOrphanedTippyElements(); + } + + this.tooltipElementsMap.clear(); + } + + cleanupOrphanedTippyElements() { + const tippyElements = document.querySelectorAll('[data-tippy-root]'); + tippyElements.forEach(element => { + if (element.parentNode) { + element.parentNode.removeChild(element); + } + }); + } + + setupStyles() { + if (document.getElementById('tooltip-styles')) return; + + document.head.insertAdjacentHTML('beforeend', ` + <style id="tooltip-styles"> + [data-tippy-root] { + position: fixed !important; + z-index: 9999 !important; + pointer-events: none !important; + } + + .tippy-box { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + border-radius: 0.5rem; + color: white; + position: relative !important; + pointer-events: auto !important; + } + + .tippy-content { + padding: 0.5rem 0.75rem !important; + } + + .tippy-box .bg-gray-400 { + background-color: rgb(156 163 175); + padding: 0.5rem 0.75rem; + } + .tippy-box:has(.bg-gray-400) .tippy-arrow { + color: rgb(156 163 175); + } + + .tippy-box .bg-red-500 { + background-color: rgb(239 68 68); + padding: 0.5rem 0.75rem; + } + .tippy-box:has(.bg-red-500) .tippy-arrow { + color: rgb(239 68 68); + } + + .tippy-box .bg-gray-300 { + background-color: rgb(209 213 219); + padding: 0.5rem 0.75rem; + } + .tippy-box:has(.bg-gray-300) .tippy-arrow { + color: rgb(209 213 219); + } + + .tippy-box .bg-green-700 { + background-color: rgb(21 128 61); + padding: 0.5rem 0.75rem; + } + .tippy-box:has(.bg-green-700) .tippy-arrow { + color: rgb(21 128 61); + } + + .tippy-box[data-placement^='top'] > .tippy-arrow::before { + border-top-color: currentColor; + } + + .tippy-box[data-placement^='bottom'] > .tippy-arrow::before { + border-bottom-color: currentColor; + } + + .tippy-box[data-placement^='left'] > .tippy-arrow::before { + border-left-color: currentColor; + } + + .tippy-box[data-placement^='right'] > .tippy-arrow::before { + border-right-color: currentColor; + } + + .tippy-box[data-placement^='top'] > .tippy-arrow { + bottom: 0; + } + + .tippy-box[data-placement^='bottom'] > .tippy-arrow { + top: 0; + } + + .tippy-box[data-placement^='left'] > .tippy-arrow { + right: 0; + } + + .tippy-box[data-placement^='right'] > .tippy-arrow { + left: 0; + } + </style> + `); + } + + setupCleanupEvents() { + this.boundCleanup = this.cleanup.bind(this); + this.handleVisibilityChange = () => { + if (document.hidden) { + this.cleanup(); + + if (window.MemoryManager) { + window.MemoryManager.forceCleanup(); + } + } + }; + + window.addEventListener('beforeunload', this.boundCleanup); + window.addEventListener('unload', this.boundCleanup); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('tooltipManager', this, (tm) => tm.dispose()); + } + + this.cleanupInterval = setInterval(() => { + this.performPeriodicCleanup(); + }, 120000); + } + + startDisconnectedElementsCheck() { + + if (this.disconnectedCheckInterval) { + clearInterval(this.disconnectedCheckInterval); + } + + this.disconnectedCheckInterval = setInterval(() => { + this.checkForDisconnectedElements(); + }, 60000); + } + + checkForDisconnectedElements() { + if (this.tooltipElementsMap.size === 0) return; + + const elementsToCheck = Array.from(this.tooltipElementsMap.keys()); + let removedCount = 0; + + elementsToCheck.forEach(element => { + + if (!document.body.contains(element)) { + this.destroy(element); + removedCount++; + } + }); + + if (removedCount > 0) { + this.cleanupOrphanedTippyElements(); + } + } + + performPeriodicCleanup() { + this.cleanupOrphanedTippyElements(); + this.checkForDisconnectedElements(); + + if (this.tooltipElementsMap.size > this.maxTooltips * this.cleanupThreshold) { + const sortedTooltips = Array.from(this.tooltipElementsMap.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp); + + const tooltipsToRemove = sortedTooltips.slice(0, sortedTooltips.length - this.maxTooltips); + tooltipsToRemove.forEach(([element]) => { + this.destroy(element); + }); + } + } + + removeCleanupEvents() { + window.removeEventListener('beforeunload', this.boundCleanup); + window.removeEventListener('unload', this.boundCleanup); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + if (this.disconnectedCheckInterval) { + clearInterval(this.disconnectedCheckInterval); + this.disconnectedCheckInterval = null; + } + } + + initializeMutationObserver() { + if (this.mutationObserver) return; + + this.mutationObserver = new MutationObserver(mutations => { + let needsCleanup = false; + + mutations.forEach(mutation => { + if (mutation.removedNodes.length) { + Array.from(mutation.removedNodes).forEach(node => { + if (node.nodeType === 1) { + + if (node.hasAttribute && node.hasAttribute('data-tooltip-trigger-id')) { + this.destroy(node); + needsCleanup = true; + } + + if (node.querySelectorAll) { + const tooltipTriggers = node.querySelectorAll('[data-tooltip-trigger-id]'); + if (tooltipTriggers.length > 0) { + tooltipTriggers.forEach(el => { + this.destroy(el); + }); + needsCleanup = true; + } + } + } + }); + } + }); + + if (needsCleanup) { + this.cleanupOrphanedTippyElements(); + } + }); + + this.mutationObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + + initializeTooltips(selector = '[data-tooltip-target]') { + document.querySelectorAll(selector).forEach(element => { + const targetId = element.getAttribute('data-tooltip-target'); + const tooltipContent = document.getElementById(targetId); + + if (tooltipContent) { + this.create(element, tooltipContent.innerHTML, { + placement: element.getAttribute('data-tooltip-placement') || 'top' + }); + } + }); + } + + dispose() { + this.cleanup(); + + this.pendingAnimationFrames.forEach(id => { + cancelAnimationFrame(id); + }); + this.pendingAnimationFrames.clear(); + + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + + this.removeCleanupEvents(); + + const styleElement = document.getElementById('tooltip-styles'); + if (styleElement && styleElement.parentNode) { + styleElement.parentNode.removeChild(styleElement); + } + + this.activeTooltips = new WeakMap(); + this.tooltipElementsMap.clear(); + + instance = null; + } + + initialize(options = {}) { + + if (options.maxTooltips) { + this.maxTooltips = options.maxTooltips; + } + + console.log('TooltipManager initialized'); + return this; + } + } + + return { + initialize: function(options = {}) { + if (!instance) { + const manager = new TooltipManagerImpl(); + manager.initialize(options); + } + return instance; + }, + + getInstance: function() { + if (!instance) { + const manager = new TooltipManagerImpl(); + } + return instance; + }, + + create: function(...args) { + const manager = this.getInstance(); + return manager.create(...args); + }, + + destroy: function(...args) { + const manager = this.getInstance(); + return manager.destroy(...args); + }, + + cleanup: function(...args) { + const manager = this.getInstance(); + return manager.cleanup(...args); + }, + + initializeTooltips: function(...args) { + const manager = this.getInstance(); + return manager.initializeTooltips(...args); + }, + + dispose: function(...args) { + const manager = this.getInstance(); + return manager.dispose(...args); + } + }; +})(); + +window.TooltipManager = TooltipManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.tooltipManagerInitialized) { + TooltipManager.initialize(); + TooltipManager.initializeTooltips(); + window.tooltipManagerInitialized = true; + } +}); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = TooltipManager; +} + +//console.log('TooltipManager initialized with methods:', Object.keys(TooltipManager)); +console.log('TooltipManager initialized'); diff --git a/basicswap/static/js/modules/wallet-manager.js b/basicswap/static/js/modules/wallet-manager.js new file mode 100644 index 0000000..ac81e0d --- /dev/null +++ b/basicswap/static/js/modules/wallet-manager.js @@ -0,0 +1,655 @@ +const WalletManager = (function() { + + const config = { + maxRetries: 5, + baseDelay: 500, + cacheExpiration: 5 * 60 * 1000, + priceUpdateInterval: 5 * 60 * 1000, + apiTimeout: 30000, + debounceDelay: 300, + cacheMinInterval: 60 * 1000, + defaultTTL: 300, + priceSource: { + primary: 'coingecko.com', + fallback: 'cryptocompare.com', + enabledSources: ['coingecko.com', 'cryptocompare.com'] + } + }; + + const stateKeys = { + lastUpdate: 'last-update-time', + previousTotal: 'previous-total-usd', + currentTotal: 'current-total-usd', + balancesVisible: 'balancesVisible' + }; + + const coinData = { + symbols: { + 'Bitcoin': 'BTC', + 'Particl': 'PART', + 'Monero': 'XMR', + 'Wownero': 'WOW', + 'Litecoin': 'LTC', + 'Dogecoin': 'DOGE', + 'Firo': 'FIRO', + 'Dash': 'DASH', + 'PIVX': 'PIVX', + 'Decred': 'DCR', + 'Bitcoin Cash': 'BCH' + }, + + coingeckoIds: { + 'BTC': 'btc', + 'PART': 'part', + 'XMR': 'xmr', + 'WOW': 'wownero', + 'LTC': 'ltc', + 'DOGE': 'doge', + 'FIRO': 'firo', + 'DASH': 'dash', + 'PIVX': 'pivx', + 'DCR': 'dcr', + 'BCH': 'bch' + }, + + shortNames: { + 'Bitcoin': 'BTC', + 'Particl': 'PART', + 'Monero': 'XMR', + 'Wownero': 'WOW', + 'Litecoin': 'LTC', + 'Litecoin MWEB': 'LTC MWEB', + 'Firo': 'FIRO', + 'Dash': 'DASH', + 'PIVX': 'PIVX', + 'Decred': 'DCR', + 'Bitcoin Cash': 'BCH', + 'Dogecoin': 'DOGE' + } + }; + + const state = { + lastFetchTime: 0, + toggleInProgress: false, + toggleDebounceTimer: null, + priceUpdateInterval: null, + lastUpdateTime: 0, + isWalletsPage: false, + initialized: false, + cacheKey: 'rates_crypto_prices' + }; + + function getShortName(fullName) { + return coinData.shortNames[fullName] || fullName; + } + + async function fetchPrices(forceUpdate = false) { + const now = Date.now(); + const timeSinceLastFetch = now - state.lastFetchTime; + + if (!forceUpdate && timeSinceLastFetch < config.cacheMinInterval) { + const cachedData = CacheManager.get(state.cacheKey); + if (cachedData) { + return cachedData.value; + } + } + + let lastError = null; + for (let attempt = 0; attempt < config.maxRetries; attempt++) { + try { + const processedData = {}; + const currentSource = config.priceSource.primary; + + const shouldIncludeWow = currentSource === 'coingecko.com'; + + const coinsToFetch = Object.values(coinData.symbols) + .filter(symbol => shouldIncludeWow || symbol !== 'WOW') + .map(symbol => coinData.coingeckoIds[symbol] || symbol.toLowerCase()) + .join(','); + + const mainResponse = await fetch("/json/coinprices", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + coins: coinsToFetch, + source: currentSource, + ttl: config.defaultTTL + }) + }); + + if (!mainResponse.ok) { + throw new Error(`HTTP error: ${mainResponse.status}`); + } + + const mainData = await mainResponse.json(); + + if (mainData && mainData.rates) { + Object.entries(mainData.rates).forEach(([coinId, price]) => { + const symbol = Object.entries(coinData.coingeckoIds).find(([sym, id]) => id.toLowerCase() === coinId.toLowerCase())?.[0]; + if (symbol) { + const coinKey = Object.keys(coinData.symbols).find(key => coinData.symbols[key] === symbol); + if (coinKey) { + processedData[coinKey.toLowerCase().replace(' ', '-')] = { + usd: price, + btc: symbol === 'BTC' ? 1 : price / (mainData.rates.btc || 1) + }; + } + } + }); + } + + if (!shouldIncludeWow && !processedData['wownero']) { + try { + const wowResponse = await fetch("/json/coinprices", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + coins: "wownero", + source: "coingecko.com", + ttl: config.defaultTTL + }) + }); + + if (wowResponse.ok) { + const wowData = await wowResponse.json(); + if (wowData && wowData.rates && wowData.rates.wownero) { + processedData['wownero'] = { + usd: wowData.rates.wownero, + btc: processedData.bitcoin ? wowData.rates.wownero / processedData.bitcoin.usd : 0 + }; + } + } + } catch (wowError) { + console.error('Error fetching WOW price:', wowError); + } + } + + CacheManager.set(state.cacheKey, processedData, config.cacheExpiration); + state.lastFetchTime = now; + return processedData; + } catch (error) { + lastError = error; + console.error(`Price fetch attempt ${attempt + 1} failed:`, error); + + if (attempt === config.maxRetries - 1 && + config.priceSource.fallback && + config.priceSource.fallback !== config.priceSource.primary) { + const temp = config.priceSource.primary; + config.priceSource.primary = config.priceSource.fallback; + config.priceSource.fallback = temp; + + console.warn(`Switching to fallback source: ${config.priceSource.primary}`); + attempt = -1; + continue; + } + + if (attempt < config.maxRetries - 1) { + const delay = Math.min(config.baseDelay * Math.pow(2, attempt), 10000); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + const cachedData = CacheManager.get(state.cacheKey); + if (cachedData) { + console.warn('Using cached data after fetch failures'); + return cachedData.value; + } + + throw lastError || new Error('Failed to fetch prices'); + } + + // UI Management functions + function storeOriginalValues() { + document.querySelectorAll('.coinname-value').forEach(el => { + const coinName = el.getAttribute('data-coinname'); + const value = el.textContent?.trim() || ''; + + if (coinName) { + const amount = value ? parseFloat(value.replace(/[^0-9.-]+/g, '')) : 0; + const coinId = coinData.symbols[coinName]; + const shortName = getShortName(coinName); + + if (coinId) { + if (coinName === 'Particl') { + const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind'); + const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon'); + const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public'; + localStorage.setItem(`particl-${balanceType}-amount`, amount.toString()); + } else if (coinName === 'Litecoin') { + const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB'); + const balanceType = isMWEB ? 'mweb' : 'public'; + localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString()); + } else { + localStorage.setItem(`${coinId.toLowerCase()}-amount`, amount.toString()); + } + + el.setAttribute('data-original-value', `${amount} ${shortName}`); + } + } + }); + + document.querySelectorAll('.usd-value').forEach(el => { + const text = el.textContent?.trim() || ''; + if (text === 'Loading...') { + el.textContent = ''; + } + }); + } + + async function updatePrices(forceUpdate = false) { + try { + const prices = await fetchPrices(forceUpdate); + let newTotal = 0; + + const currentTime = Date.now(); + localStorage.setItem(stateKeys.lastUpdate, currentTime.toString()); + state.lastUpdateTime = currentTime; + + if (prices) { + Object.entries(prices).forEach(([coinId, priceData]) => { + if (priceData?.usd) { + localStorage.setItem(`${coinId}-price`, priceData.usd.toString()); + } + }); + } + + document.querySelectorAll('.coinname-value').forEach(el => { + const coinName = el.getAttribute('data-coinname'); + const amountStr = el.getAttribute('data-original-value') || el.textContent?.trim() || ''; + + if (!coinName) return; + + let amount = 0; + if (amountStr) { + const matches = amountStr.match(/([0-9]*[.])?[0-9]+/); + if (matches && matches.length > 0) { + amount = parseFloat(matches[0]); + } + } + + const coinId = coinName.toLowerCase().replace(' ', '-'); + + if (!prices[coinId]) { + return; + } + + const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0'); + if (!price) return; + + const usdValue = (amount * price).toFixed(2); + + if (coinName === 'Particl') { + const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind'); + const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon'); + const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public'; + localStorage.setItem(`particl-${balanceType}-last-value`, usdValue); + localStorage.setItem(`particl-${balanceType}-amount`, amount.toString()); + } else if (coinName === 'Litecoin') { + const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB'); + const balanceType = isMWEB ? 'mweb' : 'public'; + localStorage.setItem(`litecoin-${balanceType}-last-value`, usdValue); + localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString()); + } else { + localStorage.setItem(`${coinId}-last-value`, usdValue); + localStorage.setItem(`${coinId}-amount`, amount.toString()); + } + + if (amount > 0) { + newTotal += parseFloat(usdValue); + } + + let usdEl = null; + + const flexContainer = el.closest('.flex'); + if (flexContainer) { + const nextFlex = flexContainer.nextElementSibling; + if (nextFlex) { + const usdInNextFlex = nextFlex.querySelector('.usd-value'); + if (usdInNextFlex) { + usdEl = usdInNextFlex; + } + } + } + + if (!usdEl) { + const parentCell = el.closest('td'); + if (parentCell) { + const usdInSameCell = parentCell.querySelector('.usd-value'); + if (usdInSameCell) { + usdEl = usdInSameCell; + } + } + } + + if (!usdEl) { + const sibling = el.nextElementSibling; + if (sibling && sibling.classList.contains('usd-value')) { + usdEl = sibling; + } + } + + if (!usdEl) { + const parentElement = el.parentElement; + if (parentElement) { + const usdElNearby = parentElement.querySelector('.usd-value'); + if (usdElNearby) { + usdEl = usdElNearby; + } + } + } + + if (usdEl) { + usdEl.textContent = `$${usdValue}`; + usdEl.setAttribute('data-original-value', usdValue); + } + }); + + document.querySelectorAll('.usd-value').forEach(el => { + if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { + const parentCell = el.closest('td'); + if (!parentCell) return; + + const coinValueEl = parentCell.querySelector('.coinname-value'); + if (!coinValueEl) return; + + const coinName = coinValueEl.getAttribute('data-coinname'); + if (!coinName) return; + + const amountStr = coinValueEl.textContent?.trim() || '0'; + const amount = parseFloat(amountStr) || 0; + + const coinId = coinName.toLowerCase().replace(' ', '-'); + if (!prices[coinId]) return; + + const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0'); + if (!price) return; + + const usdValue = (amount * price).toFixed(8); + el.textContent = `$${usdValue}`; + el.setAttribute('data-original-value', usdValue); + } + }); + + if (state.isWalletsPage) { + updateTotalValues(newTotal, prices?.bitcoin?.usd); + } + + localStorage.setItem(stateKeys.previousTotal, localStorage.getItem(stateKeys.currentTotal) || '0'); + localStorage.setItem(stateKeys.currentTotal, newTotal.toString()); + + return true; + } catch (error) { + console.error('Price update failed:', error); + return false; + } + } + + function updateTotalValues(totalUsd, btcPrice) { + const totalUsdEl = document.getElementById('total-usd-value'); + if (totalUsdEl) { + totalUsdEl.textContent = `$${totalUsd.toFixed(2)}`; + totalUsdEl.setAttribute('data-original-value', totalUsd.toString()); + localStorage.setItem('total-usd', totalUsd.toString()); + } + + if (btcPrice) { + const btcTotal = btcPrice ? totalUsd / btcPrice : 0; + const totalBtcEl = document.getElementById('total-btc-value'); + if (totalBtcEl) { + totalBtcEl.textContent = `~ ${btcTotal.toFixed(8)} BTC`; + totalBtcEl.setAttribute('data-original-value', btcTotal.toString()); + } + } + } + + async function toggleBalances() { + if (state.toggleInProgress) return; + + try { + state.toggleInProgress = true; + const balancesVisible = localStorage.getItem('balancesVisible') === 'true'; + const newVisibility = !balancesVisible; + + localStorage.setItem('balancesVisible', newVisibility.toString()); + updateVisibility(newVisibility); + + if (state.toggleDebounceTimer) { + clearTimeout(state.toggleDebounceTimer); + } + + state.toggleDebounceTimer = window.setTimeout(async () => { + state.toggleInProgress = false; + if (newVisibility) { + await updatePrices(true); + } + }, config.debounceDelay); + } catch (error) { + console.error('Failed to toggle balances:', error); + state.toggleInProgress = false; + } + } + + function updateVisibility(isVisible) { + if (isVisible) { + showBalances(); + } else { + hideBalances(); + } + + const eyeIcon = document.querySelector("#hide-usd-amount-toggle svg"); + if (eyeIcon) { + eyeIcon.innerHTML = isVisible ? + '<path d="M23.444,10.239C21.905,8.062,17.708,3,12,3S2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0C2.1,15.938,6.292,21,12,21s9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239ZM12,17a5,5,0,1,1,5-5A5,5,0,0,1,12,17Z"></path>' : + '<path d="M23.444,10.239a22.936,22.936,0,0,0-2.492-2.948l-4.021,4.021A5.026,5.026,0,0,1,17,12a5,5,0,0,1-5,5,5.026,5.026,0,0,1-.688-.069L8.055,20.188A10.286,10.286,0,0,0,12,21c5.708,0,9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239Z"></path><path d="M12,3C6.292,3,2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0a21.272,21.272,0,0,0,4.784,4.9l3.124-3.124a5,5,0,0,1,7.071-7.072L8.464,15.536l10.2-10.2A11.484,11.484,0,0,0,12,3Z"></path><path data-color="color-2" d="M1,24a1,1,0,0,1-.707-1.707l22-22a1,1,0,0,1,1.414,1.414l-22,22A1,1,0,0,1,1,24Z"></path>'; + } + } + + function showBalances() { + const usdText = document.getElementById('usd-text'); + if (usdText) { + usdText.style.display = 'inline'; + } + + document.querySelectorAll('.coinname-value').forEach(el => { + const originalValue = el.getAttribute('data-original-value'); + if (originalValue) { + el.textContent = originalValue; + } + }); + + document.querySelectorAll('.usd-value').forEach(el => { + const storedValue = el.getAttribute('data-original-value'); + if (storedValue !== null && storedValue !== undefined) { + if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { + el.textContent = `$${parseFloat(storedValue).toFixed(8)}`; + } else { + el.textContent = `$${parseFloat(storedValue).toFixed(2)}`; + } + } else { + if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { + el.textContent = '$0.00000000'; + } else { + el.textContent = '$0.00'; + } + } + }); + + if (state.isWalletsPage) { + ['total-usd-value', 'total-btc-value'].forEach(id => { + const el = document.getElementById(id); + const originalValue = el?.getAttribute('data-original-value'); + if (el && originalValue) { + if (id === 'total-usd-value') { + el.textContent = `$${parseFloat(originalValue).toFixed(2)}`; + el.classList.add('font-extrabold'); + } else { + el.textContent = `~ ${parseFloat(originalValue).toFixed(8)} BTC`; + } + } + }); + } + } + + function hideBalances() { + const usdText = document.getElementById('usd-text'); + if (usdText) { + usdText.style.display = 'none'; + } + + document.querySelectorAll('.coinname-value').forEach(el => { + el.textContent = '****'; + }); + + document.querySelectorAll('.usd-value').forEach(el => { + el.textContent = '****'; + }); + + if (state.isWalletsPage) { + ['total-usd-value', 'total-btc-value'].forEach(id => { + const el = document.getElementById(id); + if (el) { + el.textContent = '****'; + } + }); + + const totalUsdEl = document.getElementById('total-usd-value'); + if (totalUsdEl) { + totalUsdEl.classList.remove('font-extrabold'); + } + } + } + + async function loadBalanceVisibility() { + const balancesVisible = localStorage.getItem('balancesVisible') === 'true'; + updateVisibility(balancesVisible); + + if (balancesVisible) { + await updatePrices(true); + } + } + + // Public API + const publicAPI = { + initialize: async function(options) { + if (state.initialized) { + console.warn('[WalletManager] Already initialized'); + return this; + } + + if (options) { + Object.assign(config, options); + } + + state.lastUpdateTime = parseInt(localStorage.getItem(stateKeys.lastUpdate) || '0'); + state.isWalletsPage = document.querySelector('.wallet-list') !== null || + window.location.pathname.includes('/wallets'); + + document.querySelectorAll('.usd-value').forEach(el => { + const text = el.textContent?.trim() || ''; + if (text === 'Loading...') { + el.textContent = ''; + } + }); + + storeOriginalValues(); + + if (localStorage.getItem('balancesVisible') === null) { + localStorage.setItem('balancesVisible', 'true'); + } + + const hideBalancesToggle = document.getElementById('hide-usd-amount-toggle'); + if (hideBalancesToggle) { + hideBalancesToggle.addEventListener('click', toggleBalances); + } + + await loadBalanceVisibility(); + + if (state.priceUpdateInterval) { + clearInterval(state.priceUpdateInterval); + } + + state.priceUpdateInterval = setInterval(() => { + if (localStorage.getItem('balancesVisible') === 'true' && !state.toggleInProgress) { + updatePrices(false); + } + }, config.priceUpdateInterval); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('walletManager', this, (mgr) => mgr.dispose()); + } + + state.initialized = true; + console.log('WalletManager initialized'); + + return this; + }, + + updatePrices: function(forceUpdate = false) { + return updatePrices(forceUpdate); + }, + + toggleBalances: function() { + return toggleBalances(); + }, + + setPriceSource: function(primarySource, fallbackSource = null) { + if (!config.priceSource.enabledSources.includes(primarySource)) { + throw new Error(`Invalid primary source: ${primarySource}`); + } + + if (fallbackSource && !config.priceSource.enabledSources.includes(fallbackSource)) { + throw new Error(`Invalid fallback source: ${fallbackSource}`); + } + + config.priceSource.primary = primarySource; + if (fallbackSource) { + config.priceSource.fallback = fallbackSource; + } + + return this; + }, + + getConfig: function() { + return { ...config }; + }, + + getState: function() { + return { + initialized: state.initialized, + lastUpdateTime: state.lastUpdateTime, + isWalletsPage: state.isWalletsPage, + balancesVisible: localStorage.getItem('balancesVisible') === 'true' + }; + }, + + dispose: function() { + if (state.priceUpdateInterval) { + clearInterval(state.priceUpdateInterval); + state.priceUpdateInterval = null; + } + + if (state.toggleDebounceTimer) { + clearTimeout(state.toggleDebounceTimer); + state.toggleDebounceTimer = null; + } + + state.initialized = false; + console.log('WalletManager disposed'); + } + }; + + return publicAPI; +})(); + +window.WalletManager = WalletManager; + +document.addEventListener('DOMContentLoaded', function() { + if (!window.walletManagerInitialized) { + WalletManager.initialize(); + window.walletManagerInitialized = true; + } +}); + +//console.log('WalletManager initialized with methods:', Object.keys(WalletManager)); +console.log('WalletManager initialized'); diff --git a/basicswap/static/js/modules/websocket-manager.js b/basicswap/static/js/modules/websocket-manager.js new file mode 100644 index 0000000..1aae881 --- /dev/null +++ b/basicswap/static/js/modules/websocket-manager.js @@ -0,0 +1,444 @@ +const WebSocketManager = (function() { + let ws = null; + + const config = { + reconnectAttempts: 0, + maxReconnectAttempts: 5, + reconnectDelay: 5000, + debug: false + }; + + const state = { + isConnecting: false, + isIntentionallyClosed: false, + lastConnectAttempt: null, + connectTimeout: null, + lastHealthCheck: null, + healthCheckInterval: null, + isPageHidden: document.hidden, + messageHandlers: {}, + listeners: {}, + reconnectTimeout: null + }; + + function log(message, ...args) { + if (config.debug) { + console.log(`[WebSocketManager] ${message}`, ...args); + } + } + + function generateHandlerId() { + return `handler_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + function determineWebSocketPort() { + let wsPort; + + if (window.config && window.config.wsPort) { + wsPort = window.config.wsPort; + return wsPort; + } + + if (window.ws_port) { + wsPort = window.ws_port.toString(); + return wsPort; + } + + if (typeof getWebSocketConfig === 'function') { + const wsConfig = getWebSocketConfig(); + wsPort = (wsConfig.port || wsConfig.fallbackPort || '11700').toString(); + return wsPort; + } + + wsPort = '11700'; + return wsPort; + } + + const publicAPI = { + initialize: function(options = {}) { + Object.assign(config, options); + setupPageVisibilityHandler(); + this.connect(); + startHealthCheck(); + + log('WebSocketManager initialized with options:', options); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('webSocketManager', this, (mgr) => mgr.dispose()); + } + + return this; + }, + + connect: function() { + if (state.isConnecting || state.isIntentionallyClosed) { + log('Connection attempt blocked - already connecting or intentionally closed'); + return false; + } + + if (state.reconnectTimeout) { + clearTimeout(state.reconnectTimeout); + state.reconnectTimeout = null; + } + + cleanup(); + state.isConnecting = true; + state.lastConnectAttempt = Date.now(); + + try { + const wsPort = determineWebSocketPort(); + + if (!wsPort) { + state.isConnecting = false; + return false; + } + + ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); + setupEventHandlers(); + + state.connectTimeout = setTimeout(() => { + if (state.isConnecting) { + log('Connection timeout, cleaning up'); + cleanup(); + handleReconnect(); + } + }, 5000); + + return true; + } catch (error) { + log('Error during connection attempt:', error); + state.isConnecting = false; + handleReconnect(); + return false; + } + }, + + disconnect: function() { + log('Disconnecting WebSocket'); + state.isIntentionallyClosed = true; + cleanup(); + stopHealthCheck(); + }, + + isConnected: function() { + return ws && ws.readyState === WebSocket.OPEN; + }, + + sendMessage: function(message) { + if (!this.isConnected()) { + log('Cannot send message - not connected'); + return false; + } + + try { + ws.send(JSON.stringify(message)); + return true; + } catch (error) { + log('Error sending message:', error); + return false; + } + }, + + addMessageHandler: function(type, handler) { + if (!state.messageHandlers[type]) { + state.messageHandlers[type] = {}; + } + + const handlerId = generateHandlerId(); + state.messageHandlers[type][handlerId] = handler; + + return handlerId; + }, + + removeMessageHandler: function(type, handlerId) { + if (state.messageHandlers[type] && state.messageHandlers[type][handlerId]) { + delete state.messageHandlers[type][handlerId]; + } + }, + + cleanup: function() { + log('Cleaning up WebSocket resources'); + + clearTimeout(state.connectTimeout); + stopHealthCheck(); + + if (state.reconnectTimeout) { + clearTimeout(state.reconnectTimeout); + state.reconnectTimeout = null; + } + + state.isConnecting = false; + + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, 'Cleanup'); + } + + ws = null; + window.ws = null; + } + }, + + dispose: function() { + log('Disposing WebSocketManager'); + + this.disconnect(); + + if (state.listeners.visibilityChange) { + document.removeEventListener('visibilitychange', state.listeners.visibilityChange); + } + + state.messageHandlers = {}; + state.listeners = {}; + }, + + pause: function() { + log('WebSocketManager paused'); + state.isIntentionallyClosed = true; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1000, 'WebSocketManager paused'); + } + + stopHealthCheck(); + }, + + resume: function() { + log('WebSocketManager resumed'); + state.isIntentionallyClosed = false; + + if (!this.isConnected()) { + this.connect(); + } + + startHealthCheck(); + } + }; + + function setupEventHandlers() { + if (!ws) return; + + ws.onopen = () => { + state.isConnecting = false; + config.reconnectAttempts = 0; + clearTimeout(state.connectTimeout); + state.lastHealthCheck = Date.now(); + window.ws = ws; + + log('WebSocket connection established'); + + notifyHandlers('connect', { isConnected: true }); + + if (typeof updateConnectionStatus === 'function') { + updateConnectionStatus('connected'); + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + log('WebSocket message received:', message); + notifyHandlers('message', message); + } catch (error) { + log('Error processing message:', error); + if (typeof updateConnectionStatus === 'function') { + updateConnectionStatus('error'); + } + } + }; + + ws.onerror = (error) => { + log('WebSocket error:', error); + if (typeof updateConnectionStatus === 'function') { + updateConnectionStatus('error'); + } + notifyHandlers('error', error); + }; + + ws.onclose = (event) => { + log('WebSocket closed:', event); + state.isConnecting = false; + window.ws = null; + + if (typeof updateConnectionStatus === 'function') { + updateConnectionStatus('disconnected'); + } + + notifyHandlers('disconnect', { + code: event.code, + reason: event.reason + }); + + if (!state.isIntentionallyClosed) { + handleReconnect(); + } + }; + } + + function setupPageVisibilityHandler() { + const visibilityChangeHandler = () => { + if (document.hidden) { + handlePageHidden(); + } else { + handlePageVisible(); + } + }; + + document.addEventListener('visibilitychange', visibilityChangeHandler); + state.listeners.visibilityChange = visibilityChangeHandler; + } + + function handlePageHidden() { + log('Page hidden'); + state.isPageHidden = true; + stopHealthCheck(); + + if (ws && ws.readyState === WebSocket.OPEN) { + state.isIntentionallyClosed = true; + ws.close(1000, 'Page hidden'); + } + } + + function handlePageVisible() { + log('Page visible'); + state.isPageHidden = false; + state.isIntentionallyClosed = false; + + setTimeout(() => { + if (!publicAPI.isConnected()) { + publicAPI.connect(); + } + startHealthCheck(); + }, 0); + } + + function startHealthCheck() { + stopHealthCheck(); + state.healthCheckInterval = setInterval(() => { + performHealthCheck(); + }, 30000); + } + + function stopHealthCheck() { + if (state.healthCheckInterval) { + clearInterval(state.healthCheckInterval); + state.healthCheckInterval = null; + } + } + + function performHealthCheck() { + if (!publicAPI.isConnected()) { + log('Health check failed - not connected'); + handleReconnect(); + return; + } + + const now = Date.now(); + const lastCheck = state.lastHealthCheck; + + if (lastCheck && (now - lastCheck) > 60000) { + log('Health check failed - too long since last check'); + handleReconnect(); + return; + } + + state.lastHealthCheck = now; + log('Health check passed'); + } + + function handleReconnect() { + + if (state.reconnectTimeout) { + clearTimeout(state.reconnectTimeout); + state.reconnectTimeout = null; + } + + config.reconnectAttempts++; + if (config.reconnectAttempts <= config.maxReconnectAttempts) { + const delay = Math.min( + config.reconnectDelay * Math.pow(1.5, config.reconnectAttempts - 1), + 30000 + ); + + log(`Scheduling reconnect in ${delay}ms (attempt ${config.reconnectAttempts})`); + + state.reconnectTimeout = setTimeout(() => { + state.reconnectTimeout = null; + if (!state.isIntentionallyClosed) { + publicAPI.connect(); + } + }, delay); + } else { + log('Max reconnect attempts reached'); + if (typeof updateConnectionStatus === 'function') { + updateConnectionStatus('error'); + } + + state.reconnectTimeout = setTimeout(() => { + state.reconnectTimeout = null; + config.reconnectAttempts = 0; + publicAPI.connect(); + }, 60000); + } + } + + function notifyHandlers(type, data) { + if (state.messageHandlers[type]) { + Object.values(state.messageHandlers[type]).forEach(handler => { + try { + handler(data); + } catch (error) { + log(`Error in ${type} handler:`, error); + } + }); + } + } + + function cleanup() { + log('Cleaning up WebSocket resources'); + + clearTimeout(state.connectTimeout); + stopHealthCheck(); + + if (state.reconnectTimeout) { + clearTimeout(state.reconnectTimeout); + state.reconnectTimeout = null; + } + + state.isConnecting = false; + + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, 'Cleanup'); + } + + ws = null; + window.ws = null; + } + } + + return publicAPI; +})(); + +window.WebSocketManager = WebSocketManager; + +document.addEventListener('DOMContentLoaded', function() { + + if (!window.webSocketManagerInitialized) { + window.WebSocketManager.initialize(); + window.webSocketManagerInitialized = true; + } +}); + +//console.log('WebSocketManager initialized with methods:', Object.keys(WebSocketManager)); +console.log('WebSocketManager initialized'); diff --git a/basicswap/static/js/new_offer.js b/basicswap/static/js/new_offer.js index 058e8dc..c88df43 100644 --- a/basicswap/static/js/new_offer.js +++ b/basicswap/static/js/new_offer.js @@ -1,59 +1,548 @@ -window.addEventListener('DOMContentLoaded', () => { - const err_msgs = document.querySelectorAll('p.error_msg'); - for (let i = 0; i < err_msgs.length; i++) { - err_msg = err_msgs[i].innerText; - if (err_msg.indexOf('coin_to') >= 0 || err_msg.indexOf('Coin To') >= 0) { - e = document.getElementById('coin_to'); - e.classList.add('error'); - } - if (err_msg.indexOf('Coin From') >= 0) { - e = document.getElementById('coin_from'); - e.classList.add('error'); - } - if (err_msg.indexOf('Amount From') >= 0) { - e = document.getElementById('amt_from'); - e.classList.add('error'); - } - if (err_msg.indexOf('Amount To') >= 0) { - e = document.getElementById('amt_to'); - e.classList.add('error'); - } - if (err_msg.indexOf('Minimum Bid Amount') >= 0) { - e = document.getElementById('amt_bid_min'); - e.classList.add('error'); - } - if (err_msg.indexOf('Select coin you send') >= 0) { - e = document.getElementById('coin_from').parentNode; - e.classList.add('error'); - } - } +const DOM = { + get: (id) => document.getElementById(id), + getValue: (id) => { + const el = document.getElementById(id); + return el ? el.value : ''; + }, + setValue: (id, value) => { + const el = document.getElementById(id); + if (el) el.value = value; + }, + addEvent: (id, event, handler) => { + const el = document.getElementById(id); + if (el) el.addEventListener(event, handler); + }, + query: (selector) => document.querySelector(selector), + queryAll: (selector) => document.querySelectorAll(selector) +}; - // remove error class on input or select focus - const inputs = document.querySelectorAll('input.error'); - const selects = document.querySelectorAll('select.error'); - const elements = [...inputs, ...selects]; - elements.forEach((element) => { - element.addEventListener('focus', (event) => { - event.target.classList.remove('error'); +const Storage = { + get: (key) => { + try { + return JSON.parse(localStorage.getItem(key)); + } catch(e) { + console.warn(`Failed to retrieve item from storage: ${key}`, e); + return null; + } + }, + set: (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch(e) { + console.error(`Failed to save item to storage: ${key}`, e); + return false; + } + }, + setRaw: (key, value) => { + try { + localStorage.setItem(key, value); + return true; + } catch(e) { + console.error(`Failed to save raw item to storage: ${key}`, e); + return false; + } + }, + getRaw: (key) => { + try { + return localStorage.getItem(key); + } catch(e) { + console.warn(`Failed to retrieve raw item from storage: ${key}`, e); + return null; + } + } +}; + +const Ajax = { + post: (url, data, onSuccess, onError) => { + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return; + if (xhr.status === 200) { + if (onSuccess) { + try { + const response = xhr.responseText.startsWith('{') ? + JSON.parse(xhr.responseText) : xhr.responseText; + onSuccess(response); + } catch (e) { + console.error('Failed to parse response:', e); + if (onError) onError('Invalid response format'); + } + } + } else { + console.error('Request failed:', xhr.statusText); + if (onError) onError(xhr.statusText); + } + }; + xhr.open('POST', url); + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + xhr.send(data); + return xhr; + } +}; + +function handleNewOfferAddress() { + const STORAGE_KEY = 'lastUsedAddressNewOffer'; + const selectElement = DOM.query('select[name="addr_from"]'); + const form = selectElement?.closest('form'); + + if (!selectElement || !form) return; + + function loadInitialAddress() { + const savedAddress = Storage.get(STORAGE_KEY); + if (savedAddress) { + try { + selectElement.value = savedAddress.value; + } catch (e) { + selectFirstAddress(); + } + } else { + selectFirstAddress(); + } + } + + function selectFirstAddress() { + if (selectElement.options.length > 1) { + const firstOption = selectElement.options[1]; + if (firstOption) { + selectElement.value = firstOption.value; + saveAddress(firstOption.value, firstOption.text); + } + } + } + + function saveAddress(value, text) { + Storage.set(STORAGE_KEY, { value, text }); + } + + form.addEventListener('submit', () => { + saveAddress(selectElement.value, selectElement.selectedOptions[0].text); }); - }); -}); -const selects = document.querySelectorAll('select.disabled-select'); -for (const select of selects) { - if (select.disabled) { - select.classList.add('disabled-select-enabled'); - } else { - select.classList.remove('disabled-select-enabled'); - } + selectElement.addEventListener('change', (event) => { + saveAddress(event.target.value, event.target.selectedOptions[0].text); + }); + + loadInitialAddress(); } +const RateManager = { + lookupRates: () => { + const coinFrom = DOM.getValue('coin_from'); + const coinTo = DOM.getValue('coin_to'); + const ratesDisplay = DOM.get('rates_display'); -const inputs = document.querySelectorAll('input.disabled-input, input[type="checkbox"].disabled-input'); -for (const input of inputs) { - if (input.readOnly) { - input.classList.add('disabled-input-enabled'); - } else { - input.classList.remove('disabled-input-enabled'); + if (!coinFrom || !coinTo || !ratesDisplay) { + console.log('Required elements for lookup_rates not found'); + return; + } + + if (coinFrom === '-1' || coinTo === '-1') { + alert('Coins from and to must be set first.'); + return; + } + + const selectedCoin = (coinFrom === '15') ? '3' : coinFrom; + + ratesDisplay.innerHTML = '<p>Updating...</p>'; + + const priceJsonElement = DOM.query(".pricejsonhidden"); + if (priceJsonElement) { + priceJsonElement.classList.remove("hidden"); + } + + const params = 'coin_from=' + selectedCoin + '&coin_to=' + coinTo; + + Ajax.post('/json/rates', params, + (response) => { + if (ratesDisplay) { + ratesDisplay.innerHTML = typeof response === 'string' ? + response : '<pre><code>' + JSON.stringify(response, null, ' ') + '</code></pre>'; + } + }, + (error) => { + if (ratesDisplay) { + ratesDisplay.innerHTML = '<p>Error loading rates: ' + error + '</p>'; + } + } + ); + }, + + getRateInferred: (event) => { + if (event) event.preventDefault(); + + const coinFrom = DOM.getValue('coin_from'); + const coinTo = DOM.getValue('coin_to'); + const rateElement = DOM.get('rate'); + + if (!coinFrom || !coinTo || !rateElement) { + console.log('Required elements for getRateInferred not found'); + return; + } + + const params = 'coin_from=' + encodeURIComponent(coinFrom) + + '&coin_to=' + encodeURIComponent(coinTo); + + DOM.setValue('rate', 'Loading...'); + + Ajax.post('/json/rates', params, + (response) => { + if (response.coingecko && response.coingecko.rate_inferred) { + DOM.setValue('rate', response.coingecko.rate_inferred); + RateManager.setRate('rate'); + } else { + DOM.setValue('rate', 'Error: No rate available'); + console.error('Rate not available in response'); + } + }, + (error) => { + DOM.setValue('rate', 'Error: Rate lookup failed'); + console.error('Error fetching rate data:', error); + } + ); + }, + + setRate: (valueChanged) => { + const elements = { + coinFrom: DOM.get('coin_from'), + coinTo: DOM.get('coin_to'), + amtFrom: DOM.get('amt_from'), + amtTo: DOM.get('amt_to'), + rate: DOM.get('rate'), + rateLock: DOM.get('rate_lock'), + swapType: DOM.get('swap_type') + }; + + if (!elements.coinFrom || !elements.coinTo || + !elements.amtFrom || !elements.amtTo || !elements.rate) { + console.log('Required elements for setRate not found'); + return; + } + + const values = { + coinFrom: elements.coinFrom.value, + coinTo: elements.coinTo.value, + amtFrom: elements.amtFrom.value, + amtTo: elements.amtTo.value, + rate: elements.rate.value, + lockRate: elements.rate.value == '' ? false : + (elements.rateLock ? elements.rateLock.checked : false) + }; + + if (valueChanged === 'coin_from' || valueChanged === 'coin_to') { + DOM.setValue('rate', ''); + return; + } + + if (elements.swapType) { + SwapTypeManager.setSwapTypeEnabled( + values.coinFrom, + values.coinTo, + elements.swapType + ); + } + + if (values.coinFrom == '-1' || values.coinTo == '-1') { + return; + } + + let params = 'coin_from=' + values.coinFrom + '&coin_to=' + values.coinTo; + + if (valueChanged == 'rate' || + (values.lockRate && valueChanged == 'amt_from') || + (values.amtTo == '' && valueChanged == 'amt_from')) { + + if (values.rate == '' || (values.amtFrom == '' && values.amtTo == '')) { + return; + } else if (values.amtFrom == '' && values.amtTo != '') { + if (valueChanged == 'amt_from') { + return; + } + params += '&rate=' + values.rate + '&amt_to=' + values.amtTo; + } else { + params += '&rate=' + values.rate + '&amt_from=' + values.amtFrom; + } + } else if (values.lockRate && valueChanged == 'amt_to') { + if (values.amtTo == '' || values.rate == '') { + return; + } + params += '&amt_to=' + values.amtTo + '&rate=' + values.rate; + } else { + if (values.amtFrom == '' || values.amtTo == '') { + return; + } + params += '&amt_from=' + values.amtFrom + '&amt_to=' + values.amtTo; + } + + Ajax.post('/json/rate', params, + (response) => { + if (response.hasOwnProperty('rate')) { + DOM.setValue('rate', response.rate); + } else if (response.hasOwnProperty('amount_to')) { + DOM.setValue('amt_to', response.amount_to); + } else if (response.hasOwnProperty('amount_from')) { + DOM.setValue('amt_from', response.amount_from); + } + }, + (error) => { + console.error('Rate calculation failed:', error); + } + ); } +}; + +function set_rate(valueChanged) { + RateManager.setRate(valueChanged); +} + +function lookup_rates() { + RateManager.lookupRates(); +} + +function getRateInferred(event) { + RateManager.getRateInferred(event); +} + +const SwapTypeManager = { + adaptor_sig_only_coins: ['6', '9', '8', '7', '13', '18', '17'], + secret_hash_only_coins: ['11', '12'], + + setSwapTypeEnabled: (coinFrom, coinTo, swapTypeElement) => { + if (!swapTypeElement) return; + + let makeHidden = false; + coinFrom = String(coinFrom); + coinTo = String(coinTo); + + if (SwapTypeManager.adaptor_sig_only_coins.includes(coinFrom) || + SwapTypeManager.adaptor_sig_only_coins.includes(coinTo)) { + swapTypeElement.disabled = true; + swapTypeElement.value = 'xmr_swap'; + makeHidden = true; + swapTypeElement.classList.add('select-disabled'); + } else if (SwapTypeManager.secret_hash_only_coins.includes(coinFrom) || + SwapTypeManager.secret_hash_only_coins.includes(coinTo)) { + swapTypeElement.disabled = true; + swapTypeElement.value = 'seller_first'; + makeHidden = true; + swapTypeElement.classList.add('select-disabled'); + } else { + swapTypeElement.disabled = false; + swapTypeElement.classList.remove('select-disabled'); + swapTypeElement.value = 'xmr_swap'; + } + + let swapTypeHidden = DOM.get('swap_type_hidden'); + if (makeHidden) { + if (!swapTypeHidden) { + const form = DOM.get('form'); + if (form) { + swapTypeHidden = document.createElement('input'); + swapTypeHidden.setAttribute('id', 'swap_type_hidden'); + swapTypeHidden.setAttribute('type', 'hidden'); + swapTypeHidden.setAttribute('name', 'swap_type'); + form.appendChild(swapTypeHidden); + } + } + if (swapTypeHidden) { + swapTypeHidden.setAttribute('value', swapTypeElement.value); + } + } else if (swapTypeHidden) { + swapTypeHidden.parentNode.removeChild(swapTypeHidden); + } + } +}; + +function set_swap_type_enabled(coinFrom, coinTo, swapTypeElement) { + SwapTypeManager.setSwapTypeEnabled(coinFrom, coinTo, swapTypeElement); +} + +const UIEnhancer = { + handleErrorHighlighting: () => { + const errMsgs = document.querySelectorAll('p.error_msg'); + + const errorFieldMap = { + 'coin_to': ['coin_to', 'Coin To'], + 'coin_from': ['Coin From'], + 'amt_from': ['Amount From'], + 'amt_to': ['Amount To'], + 'amt_bid_min': ['Minimum Bid Amount'], + 'Select coin you send': ['coin_from', 'parentNode'] + }; + + errMsgs.forEach(errMsg => { + const text = errMsg.innerText; + + Object.entries(errorFieldMap).forEach(([field, keywords]) => { + if (keywords.some(keyword => text.includes(keyword))) { + let element = DOM.get(field); + + if (field === 'Select coin you send' && element) { + element = element.parentNode; + } + + if (element) { + element.classList.add('error'); + } + } + }); + }); + + document.querySelectorAll('input.error, select.error').forEach(element => { + element.addEventListener('focus', event => { + event.target.classList.remove('error'); + }); + }); + }, + + updateDisabledStyles: () => { + document.querySelectorAll('select.disabled-select').forEach(select => { + if (select.disabled) { + select.classList.add('disabled-select-enabled'); + } else { + select.classList.remove('disabled-select-enabled'); + } + }); + + document.querySelectorAll('input.disabled-input, input[type="checkbox"].disabled-input').forEach(input => { + if (input.readOnly) { + input.classList.add('disabled-input-enabled'); + } else { + input.classList.remove('disabled-input-enabled'); + } + }); + }, + + setupCustomSelects: () => { + const selectCache = {}; + + function updateSelectCache(select) { + if (!select || !select.options || select.selectedIndex === undefined) return; + + const selectedOption = select.options[select.selectedIndex]; + if (!selectedOption) return; + + const image = selectedOption.getAttribute('data-image'); + const name = selectedOption.textContent.trim(); + selectCache[select.id] = { image, name }; + } + + function setSelectData(select) { + if (!select || !select.options || select.selectedIndex === undefined) return; + + const selectedOption = select.options[select.selectedIndex]; + if (!selectedOption) return; + + const image = selectedOption.getAttribute('data-image') || ''; + const name = selectedOption.textContent.trim(); + + select.style.backgroundImage = image ? `url(${image}?${new Date().getTime()})` : ''; + + const selectImage = select.nextElementSibling?.querySelector('.select-image'); + if (selectImage) { + selectImage.src = image; + } + + const selectNameElement = select.nextElementSibling?.querySelector('.select-name'); + if (selectNameElement) { + selectNameElement.textContent = name; + } + + updateSelectCache(select); + } + + function setupCustomSelect(select) { + if (!select) return; + + const options = select.querySelectorAll('option'); + const selectIcon = select.parentElement?.querySelector('.select-icon'); + const selectImage = select.parentElement?.querySelector('.select-image'); + + if (!options || !selectIcon || !selectImage) return; + + options.forEach(option => { + const image = option.getAttribute('data-image'); + if (image) { + option.style.backgroundImage = `url(${image})`; + } + }); + + const storedValue = Storage.getRaw(select.name); + if (storedValue && select.value == '-1') { + select.value = storedValue; + } + + select.addEventListener('change', () => { + setSelectData(select); + Storage.setRaw(select.name, select.value); + }); + + setSelectData(select); + selectIcon.style.display = 'none'; + selectImage.style.display = 'none'; + } + + const selectIcons = document.querySelectorAll('.custom-select .select-icon'); + const selectImages = document.querySelectorAll('.custom-select .select-image'); + const selectNames = document.querySelectorAll('.custom-select .select-name'); + + selectIcons.forEach(icon => icon.style.display = 'none'); + selectImages.forEach(image => image.style.display = 'none'); + selectNames.forEach(name => name.style.display = 'none'); + + const customSelects = document.querySelectorAll('.custom-select select'); + customSelects.forEach(setupCustomSelect); + } +}; + +function initializeApp() { + handleNewOfferAddress(); + + DOM.addEvent('get_rate_inferred_button', 'click', RateManager.getRateInferred); + + const coinFrom = DOM.get('coin_from'); + const coinTo = DOM.get('coin_to'); + const swapType = DOM.get('swap_type'); + + if (coinFrom && coinTo && swapType) { + SwapTypeManager.setSwapTypeEnabled(coinFrom.value, coinTo.value, swapType); + + coinFrom.addEventListener('change', function() { + SwapTypeManager.setSwapTypeEnabled(this.value, coinTo.value, swapType); + RateManager.setRate('coin_from'); + }); + + coinTo.addEventListener('change', function() { + SwapTypeManager.setSwapTypeEnabled(coinFrom.value, this.value, swapType); + RateManager.setRate('coin_to'); + }); + } + + ['amt_from', 'amt_to', 'rate'].forEach(id => { + DOM.addEvent(id, 'change', function() { + RateManager.setRate(id); + }); + + DOM.addEvent(id, 'input', function() { + RateManager.setRate(id); + }); + }); + + DOM.addEvent('rate_lock', 'change', function() { + if (DOM.getValue('rate')) { + RateManager.setRate('rate'); + } + }); + + DOM.addEvent('lookup_rates_button', 'click', RateManager.lookupRates); + + UIEnhancer.handleErrorHighlighting(); + UIEnhancer.updateDisabledStyles(); + UIEnhancer.setupCustomSelects(); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeApp); +} else { + initializeApp(); } diff --git a/basicswap/static/js/offers.js b/basicswap/static/js/offers.js index 83b5864..1d534cf 100644 --- a/basicswap/static/js/offers.js +++ b/basicswap/static/js/offers.js @@ -1,68 +1,3 @@ -// EVENT MANAGER -const EventManager = { - listeners: new Map(), - - add(element, type, handler, options = false) { - if (!this.listeners.has(element)) { - this.listeners.set(element, new Map()); - } - - const elementListeners = this.listeners.get(element); - if (!elementListeners.has(type)) { - elementListeners.set(type, new Set()); - } - - const handlerInfo = { handler, options }; - elementListeners.get(type).add(handlerInfo); - element.addEventListener(type, handler, options); - - return handlerInfo; - }, - - remove(element, type, handler, options = false) { - const elementListeners = this.listeners.get(element); - if (!elementListeners) return; - - const typeListeners = elementListeners.get(type); - if (!typeListeners) return; - - typeListeners.forEach(info => { - if (info.handler === handler) { - element.removeEventListener(type, handler, options); - typeListeners.delete(info); - } - }); - - if (typeListeners.size === 0) { - elementListeners.delete(type); - } - if (elementListeners.size === 0) { - this.listeners.delete(element); - } - }, - - removeAll(element) { - const elementListeners = this.listeners.get(element); - if (!elementListeners) return; - - elementListeners.forEach((typeListeners, type) => { - typeListeners.forEach(info => { - element.removeEventListener(type, info.handler, info.options); - }); - }); - - this.listeners.delete(element); - }, - - clearAll() { - this.listeners.forEach((elementListeners, element) => { - this.removeAll(element); - }); - this.listeners.clear(); - } -}; - -// GLOBAL STATE VARIABLES let latestPrices = null; let lastRefreshTime = null; let currentPage = 1; @@ -72,68 +7,11 @@ let currentSortColumn = 0; let currentSortDirection = 'desc'; let filterTimeout = null; -// CONFIGURATION CONSTANTS -// TIME CONSTANTS -const CACHE_DURATION = 10 * 60 * 1000; -const wsPort = config.port || window.ws_port || '11700'; - -// APP CONSTANTS -const itemsPerPage = 50; const isSentOffers = window.offersTableConfig.isSentOffers; +const CACHE_DURATION = window.config.cacheConfig.defaultTTL; +const wsPort = window.config.wsPort; +const itemsPerPage = window.config.itemsPerPage; -const offersConfig = { - apiEndpoints: { - coinGecko: 'https://api.coingecko.com/api/v3', - cryptoCompare: 'https://min-api.cryptocompare.com/data' - }, - apiKeys: getAPIKeys() -}; - -// MAPPING OBJECTS -const coinNameToSymbol = { - 'Bitcoin': 'bitcoin', - 'Particl': 'particl', - 'Particl Blind': 'particl', - 'Particl Anon': 'particl', - 'Monero': 'monero', - 'Wownero': 'wownero', - 'Litecoin': 'litecoin', - 'Firo': 'firo', - 'Zcoin': 'firo', - 'Dash': 'dash', - 'PIVX': 'pivx', - 'Decred': 'decred', - 'Zano': 'zano', - 'Dogecoin': 'dogecoin', - 'Bitcoin Cash': 'bitcoin-cash' -}; - -const coinNameToDisplayName = { - 'Bitcoin': 'Bitcoin', - 'Litecoin': 'Litecoin', - 'Monero': 'Monero', - 'Particl': 'Particl', - 'Particl Blind': 'Particl Blind', - 'Particl Anon': 'Particl Anon', - 'PIVX': 'PIVX', - 'Firo': 'Firo', - 'Zcoin': 'Firo', - 'Dash': 'Dash', - 'Decred': 'Decred', - 'Wownero': 'Wownero', - 'Bitcoin Cash': 'Bitcoin Cash', - 'Dogecoin': 'Dogecoin', - 'Zano': 'Zano' -}; - -const coinIdToName = { - 1: 'particl', 2: 'bitcoin', 3: 'litecoin', 4: 'decred', - 6: 'monero', 7: 'particl blind', 8: 'particl anon', - 9: 'wownero', 11: 'pivx', 13: 'firo', 17: 'bitcoincash', - 18: 'dogecoin' -}; - -// DOM ELEMENT REFERENCES const offersBody = document.getElementById('offers-body'); const filterForm = document.getElementById('filterForm'); const prevPageButton = document.getElementById('prevPage'); @@ -143,582 +21,6 @@ const totalPagesSpan = document.getElementById('totalPages'); const lastRefreshTimeSpan = document.getElementById('lastRefreshTime'); const newEntriesCountSpan = document.getElementById('newEntriesCount'); - -// MANAGER OBJECTS -const WebSocketManager = { - ws: null, - messageQueue: [], - processingQueue: false, - debounceTimeout: null, - reconnectTimeout: null, - maxReconnectAttempts: 5, - reconnectAttempts: 0, - reconnectDelay: 5000, - maxQueueSize: 1000, - isIntentionallyClosed: false, - handlers: {}, - isPageHidden: document.hidden, - priceUpdatePaused: false, - lastVisibilityChange: Date.now(), - - connectionState: { - isConnecting: false, - lastConnectAttempt: null, - connectTimeout: null, - lastHealthCheck: null, - healthCheckInterval: null - }, - - initialize() { - this.setupPageVisibilityHandler(); - this.connect(); - this.startHealthCheck(); - }, - - setupPageVisibilityHandler() { - this.handlers.visibilityChange = () => { - if (document.hidden) { - this.handlePageHidden(); - } else { - this.handlePageVisible(); - } - }; - document.addEventListener('visibilitychange', this.handlers.visibilityChange); - }, - - handlePageHidden() { - this.isPageHidden = true; - this.priceUpdatePaused = true; - this.stopHealthCheck(); - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.isIntentionallyClosed = true; - this.ws.close(1000, 'Page hidden'); - } - }, - - handlePageVisible() { - this.isPageHidden = false; - this.lastVisibilityChange = Date.now(); - this.isIntentionallyClosed = false; - - setTimeout(() => { - this.priceUpdatePaused = false; - if (!this.isConnected()) { - this.connect(); - } - this.startHealthCheck(); - }, 0); - }, - - startHealthCheck() { - this.stopHealthCheck(); - this.connectionState.healthCheckInterval = setInterval(() => { - this.performHealthCheck(); - }, 30000); - }, - - stopHealthCheck() { - if (this.connectionState.healthCheckInterval) { - clearInterval(this.connectionState.healthCheckInterval); - this.connectionState.healthCheckInterval = null; - } - }, - - performHealthCheck() { - if (!this.isConnected()) { - this.handleReconnect(); - return; - } - - const now = Date.now(); - const lastCheck = this.connectionState.lastHealthCheck; - if (lastCheck && (now - lastCheck) > 60000) { - this.handleReconnect(); - return; - } - - this.connectionState.lastHealthCheck = now; - }, - - connect() { - if (this.connectionState.isConnecting || this.isIntentionallyClosed) { - return false; - } - - this.cleanup(); - this.connectionState.isConnecting = true; - this.connectionState.lastConnectAttempt = Date.now(); - - try { - let wsPort; - - if (typeof getWebSocketConfig === 'function') { - const wsConfig = getWebSocketConfig(); - wsPort = wsConfig.port || wsConfig.fallbackPort; - console.log("Using WebSocket port:", wsPort); - } else { - wsPort = config?.port || window.ws_port || '11700'; - } - - if (!wsPort) { - this.connectionState.isConnecting = false; - return false; - } - - this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); - this.setupEventHandlers(); - - this.connectionState.connectTimeout = setTimeout(() => { - if (this.connectionState.isConnecting) { - this.cleanup(); - this.handleReconnect(); - } - }, 5000); - - return true; - } catch (error) { - this.connectionState.isConnecting = false; - this.handleReconnect(); - return false; - } -}, - setupEventHandlers() { - if (!this.ws) return; - - this.handlers.open = () => { - this.connectionState.isConnecting = false; - this.reconnectAttempts = 0; - clearTimeout(this.connectionState.connectTimeout); - this.connectionState.lastHealthCheck = Date.now(); - window.ws = this.ws; - console.log('🟢 WebSocket connection established for Offers'); - updateConnectionStatus('connected'); - }; - - this.handlers.message = (event) => { - try { - const message = JSON.parse(event.data); - this.handleMessage(message); - } catch (error) { - updateConnectionStatus('error'); - } - }; - - this.handlers.error = (error) => { - updateConnectionStatus('error'); - }; - - this.handlers.close = (event) => { - this.connectionState.isConnecting = false; - window.ws = null; - updateConnectionStatus('disconnected'); - - if (!this.isIntentionallyClosed) { - this.handleReconnect(); - } - }; - - this.ws.onopen = this.handlers.open; - this.ws.onmessage = this.handlers.message; - this.ws.onerror = this.handlers.error; - this.ws.onclose = this.handlers.close; - }, - - handleMessage(message) { - if (this.messageQueue.length >= this.maxQueueSize) { - this.messageQueue.shift(); - } - - if (this.debounceTimeout) { - clearTimeout(this.debounceTimeout); - } - - this.messageQueue.push(message); - - this.debounceTimeout = setTimeout(() => { - this.processMessageQueue(); - }, 200); - }, - - async processMessageQueue() { - if (this.processingQueue || this.messageQueue.length === 0) return; - - this.processingQueue = true; - const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; - - try { - const response = await fetch(endpoint); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - - const newData = await response.json(); - const fetchedOffers = Array.isArray(newData) ? newData : Object.values(newData); - - jsonData = formatInitialData(fetchedOffers); - originalJsonData = [...jsonData]; - - requestAnimationFrame(() => { - updateOffersTable(); - updatePaginationInfo(); - }); - - this.messageQueue = []; - } catch (error) { - } finally { - this.processingQueue = false; - } - }, - - handleReconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - } - - this.reconnectAttempts++; - if (this.reconnectAttempts <= this.maxReconnectAttempts) { - const delay = Math.min( - this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), - 30000 - ); - - this.reconnectTimeout = setTimeout(() => { - if (!this.isIntentionallyClosed) { - this.connect(); - } - }, delay); - } else { - updateConnectionStatus('error'); - setTimeout(() => { - this.reconnectAttempts = 0; - this.connect(); - }, 60000); - } - }, - - cleanup() { - clearTimeout(this.debounceTimeout); - clearTimeout(this.reconnectTimeout); - clearTimeout(this.connectionState.connectTimeout); - this.stopHealthCheck(); - - this.messageQueue = []; - this.processingQueue = false; - this.connectionState.isConnecting = false; - - if (this.ws) { - this.ws.onopen = null; - this.ws.onmessage = null; - this.ws.onerror = null; - this.ws.onclose = null; - - if (this.ws.readyState === WebSocket.OPEN) { - this.ws.close(1000, 'Cleanup'); - } - - this.ws = null; - window.ws = null; - } - - if (this.handlers.visibilityChange) { - document.removeEventListener('visibilitychange', this.handlers.visibilityChange); - } - - this.handlers = {}; - }, - - disconnect() { - this.isIntentionallyClosed = true; - this.cleanup(); - this.stopHealthCheck(); - }, - - isConnected() { - return this.ws && this.ws.readyState === WebSocket.OPEN; - } -}; - -window.WebSocketManager = WebSocketManager; - -const CacheManager = { - maxItems: 100, - maxSize: 5 * 1024 * 1024, // 5MB - - set: function(key, value, customTtl = null) { - try { - this.cleanup(); - - if (!value) { - console.warn('Attempted to cache null/undefined value for key:', key); - return false; - } - - const item = { - value: value, - timestamp: Date.now(), - expiresAt: Date.now() + (customTtl || CACHE_DURATION) - }; - - try { - JSON.stringify(item); - } catch (e) { - console.error('Failed to serialize cache item:', e); - return false; - } - - const itemSize = new Blob([JSON.stringify(item)]).size; - if (itemSize > this.maxSize) { - console.warn(`Cache item exceeds maximum size (${(itemSize/1024/1024).toFixed(2)}MB)`); - return false; - } - - try { - localStorage.setItem(key, JSON.stringify(item)); - return true; - } catch (storageError) { - if (storageError.name === 'QuotaExceededError') { - this.cleanup(true); - try { - localStorage.setItem(key, JSON.stringify(item)); - return true; - } catch (retryError) { - console.error('Storage quota exceeded even after cleanup:', retryError); - return false; - } - } - throw storageError; - } - - } catch (error) { - console.error('Cache set error:', error); - return false; - } - }, - - get: function(key) { - try { - const itemStr = localStorage.getItem(key); - if (!itemStr) { - return null; - } - - let item; - try { - item = JSON.parse(itemStr); - } catch (parseError) { - console.error('Failed to parse cached item:', parseError); - localStorage.removeItem(key); - return null; - } - - if (!item || typeof item.expiresAt !== 'number' || !Object.prototype.hasOwnProperty.call(item, 'value')) { - console.warn('Invalid cache item structure for key:', key); - localStorage.removeItem(key); - return null; - } - - const now = Date.now(); - if (now < item.expiresAt) { - return { - value: item.value, - remainingTime: item.expiresAt - now - }; - } - - localStorage.removeItem(key); - return null; - - } catch (error) { - console.error("Cache retrieval error:", error); - try { - localStorage.removeItem(key); - } catch (removeError) { - console.error("Failed to remove invalid cache entry:", removeError); - } - return null; - } -}, - - cleanup: function(aggressive = false) { - const now = Date.now(); - let totalSize = 0; - let itemCount = 0; - const items = []; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key.startsWith('offers_') && !key.startsWith('prices_')) continue; - - try { - const itemStr = localStorage.getItem(key); - const size = new Blob([itemStr]).size; - const item = JSON.parse(itemStr); - - if (now >= item.expiresAt) { - localStorage.removeItem(key); - continue; - } - - items.push({ - key, - size, - expiresAt: item.expiresAt, - timestamp: item.timestamp - }); - - totalSize += size; - itemCount++; - } catch (error) { - console.error("Error processing cache item:", error); - localStorage.removeItem(key); - } - } - - if (aggressive || totalSize > this.maxSize || itemCount > this.maxItems) { - items.sort((a, b) => b.timestamp - a.timestamp); - - while ((totalSize > this.maxSize || itemCount > this.maxItems) && items.length > 0) { - const item = items.pop(); - try { - localStorage.removeItem(item.key); - totalSize -= item.size; - itemCount--; - } catch (error) { - console.error("Error removing cache item:", error); - } - } - } - - return { - totalSize, - itemCount, - cleaned: items.length - }; - }, - - clear: function() { - const keys = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key.startsWith('offers_') || key.startsWith('prices_')) { - keys.push(key); - } - } - - keys.forEach(key => { - try { - localStorage.removeItem(key); - } catch (error) { - console.error("Error clearing cache item:", error); - } - }); - }, - - getStats: function() { - let totalSize = 0; - let itemCount = 0; - let expiredCount = 0; - const now = Date.now(); - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key.startsWith('offers_') && !key.startsWith('prices_')) continue; - - try { - const itemStr = localStorage.getItem(key); - const size = new Blob([itemStr]).size; - const item = JSON.parse(itemStr); - - totalSize += size; - itemCount++; - - if (now >= item.expiresAt) { - expiredCount++; - } - } catch (error) { - console.error("Error getting cache stats:", error); - } - } - - return { - totalSizeMB: (totalSize / 1024 / 1024).toFixed(2), - itemCount, - expiredCount, - utilization: ((totalSize / this.maxSize) * 100).toFixed(1) + '%' - }; - } -}; - -window.CacheManager = CacheManager; - -// IDENTITY CACHE MANAGEMENT -const IdentityManager = { - cache: new Map(), - pendingRequests: new Map(), - retryDelay: 2000, - maxRetries: 3, - cacheTimeout: 5 * 60 * 1000, - async getIdentityData(address) { - - const cachedData = this.getCachedIdentity(address); - if (cachedData) { - return cachedData; - } - - if (this.pendingRequests.has(address)) { - return this.pendingRequests.get(address); - } - - const request = this.fetchWithRetry(address); - this.pendingRequests.set(address, request); - - try { - const data = await request; - this.cache.set(address, { - data, - timestamp: Date.now() - }); - return data; - } finally { - this.pendingRequests.delete(address); - } - }, - - getCachedIdentity(address) { - const cached = this.cache.get(address); - if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { - return cached.data; - } - return null; - }, - - async fetchWithRetry(address, attempt = 1) { - try { - const response = await fetch(`/json/identities/${address}`, { - signal: AbortSignal.timeout(5000) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - if (attempt >= this.maxRetries) { - console.error("An error occured:", error.message); - console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); - return null; - } - - await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); - return this.fetchWithRetry(address, attempt + 1); - } - }, - - clearCache() { - this.cache.clear(); - this.pendingRequests.clear(); - } -}; - window.tableRateModule = { coinNameToSymbol: { 'Bitcoin': 'BTC', @@ -753,7 +55,12 @@ window.tableRateModule = { return null; }, - setCachedValue(key, value, ttl = 900000) { + setCachedValue(key, value, resourceType = null) { + const ttl = resourceType ? + window.config.cacheConfig.ttlSettings[resourceType] || + window.config.cacheConfig.defaultTTL : + 900000; + const item = { value: value, expiry: Date.now() + ttl, @@ -762,7 +69,7 @@ window.tableRateModule = { }, setFallbackValue(coinSymbol, value) { - this.setCachedValue(`fallback_${coinSymbol}_usd`, value, 24 * 60 * 60 * 1000); + this.setCachedValue(`fallback_${coinSymbol}_usd`, value, 'fallback'); }, isNewOffer(offerId) { @@ -802,7 +109,6 @@ window.tableRateModule = { document.querySelectorAll('.coinname-value').forEach(coinNameValue => { const coinFullNameOrSymbol = coinNameValue.getAttribute('data-coinname'); if (!coinFullNameOrSymbol || coinFullNameOrSymbol === 'Unknown') { - //console.warn('Missing or unknown coin name/symbol in data-coinname attribute'); return; } coinNameValue.classList.remove('hidden'); @@ -825,19 +131,15 @@ window.tableRateModule = { }, init() { - //console.log('Initializing TableRateModule'); this.initializeTable(); } }; -// CORE SYSTEM FUNCTIONS function initializeTableRateModule() { if (typeof window.tableRateModule !== 'undefined') { tableRateModule = window.tableRateModule; - //console.log('tableRateModule loaded successfully'); return true; } else { - //console.warn('tableRateModule not found. Waiting for it to load...'); return false; } } @@ -855,8 +157,6 @@ function continueInitialization() { if (listingLabel) { listingLabel.textContent = isSentOffers ? 'Total Listings: ' : 'Network Listings: '; } - //console.log('Initialization completed'); - } function initializeTooltips() { @@ -865,36 +165,42 @@ function initializeTooltips() { } } -// DATA PROCESSING FUNCTIONS function getValidOffers() { if (!jsonData) { - //console.warn('jsonData is undefined or null'); return []; } const filteredData = filterAndSortData(); - //console.log(`getValidOffers: Found ${filteredData.length} valid offers`); return filteredData; } +function saveFilterSettings() { + const formData = new FormData(filterForm); + const filters = Object.fromEntries(formData); + + const storageKey = isSentOffers ? 'sentOffersTableSettings' : 'networkOffersTableSettings'; + + localStorage.setItem(storageKey, JSON.stringify({ + coin_to: filters.coin_to, + coin_from: filters.coin_from, + status: filters.status, + sent_from: filters.sent_from, + sortColumn: currentSortColumn, + sortDirection: currentSortDirection + })); +} + function filterAndSortData() { const formData = new FormData(filterForm); const filters = Object.fromEntries(formData); - localStorage.setItem('offersTableSettings', JSON.stringify({ - coin_to: filters.coin_to, - coin_from: filters.coin_from, - status: filters.status, - sent_from: filters.sent_from, - sortColumn: currentSortColumn, - sortDirection: currentSortDirection - })); + saveFilterSettings(); if (filters.coin_to !== 'any') { - filters.coin_to = coinIdToName[filters.coin_to] || filters.coin_to; + filters.coin_to = window.config.coinMappings.idToName[filters.coin_to] || filters.coin_to; } if (filters.coin_from !== 'any') { - filters.coin_from = coinIdToName[filters.coin_from] || filters.coin_from; + filters.coin_from = window.config.coinMappings.idToName[filters.coin_from] || filters.coin_from; } let filteredData = [...originalJsonData]; @@ -959,14 +265,14 @@ function filterAndSortData() { filteredData.sort((a, b) => { let comparison; switch(currentSortColumn) { - case 0: // Time + case 0: comparison = a.created_at - b.created_at; break; - case 5: // Rate - case 6: // Market +/- + case 5: + case 6: comparison = sortValues.get(a.offer_id) - sortValues.get(b.offer_id); break; - case 7: // Trade + case 7: comparison = a.offer_id.localeCompare(b.offer_id); break; default: @@ -989,69 +295,77 @@ async function calculateProfitLoss(fromCoin, toCoin, fromAmount, toAmount, isOwn const getPriceKey = (coin) => { const lowerCoin = coin.toLowerCase(); - if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { - return 'zcoin'; - } - if (lowerCoin === 'bitcoin cash') { - return 'bitcoin-cash'; - } - if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') { - return 'particl'; - } - return coinNameToSymbol[coin] || lowerCoin; + const symbolToName = { + 'btc': 'bitcoin', + 'xmr': 'monero', + 'part': 'particl', + 'bch': 'bitcoin-cash', + 'pivx': 'pivx', + 'firo': 'firo', + 'dash': 'dash', + 'ltc': 'litecoin', + 'doge': 'dogecoin', + 'dcr': 'decred', + 'wow': 'wownero' + }; + + if (lowerCoin === 'zcoin') return 'firo'; + if (lowerCoin === 'bitcoin cash') return 'bitcoin-cash'; + if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') return 'particl'; + + return symbolToName[lowerCoin] || lowerCoin; }; const fromSymbol = getPriceKey(fromCoin); const toSymbol = getPriceKey(toCoin); - let fromPriceUSD = latestPrices[fromSymbol]?.usd; - let toPriceUSD = latestPrices[toSymbol]?.usd; + + let fromPriceUSD = latestPrices && latestPrices[fromSymbol] ? latestPrices[fromSymbol].usd : null; + let toPriceUSD = latestPrices && latestPrices[toSymbol] ? latestPrices[toSymbol].usd : null; if (!fromPriceUSD || !toPriceUSD) { fromPriceUSD = tableRateModule.getFallbackValue(fromSymbol); toPriceUSD = tableRateModule.getFallbackValue(toSymbol); } + if (!fromPriceUSD || !toPriceUSD || isNaN(fromPriceUSD) || isNaN(toPriceUSD)) { resolve(null); return; } + const fromValueUSD = fromAmount * fromPriceUSD; const toValueUSD = toAmount * toPriceUSD; + if (isNaN(fromValueUSD) || isNaN(toValueUSD) || fromValueUSD === 0 || toValueUSD === 0) { resolve(null); return; } + let percentDiff; if (isOwnOffer) { percentDiff = ((toValueUSD / fromValueUSD) - 1) * 100; } else { percentDiff = ((fromValueUSD / toValueUSD) - 1) * 100; } + if (isNaN(percentDiff)) { resolve(null); return; } + resolve(percentDiff); }); } function getEmptyPriceData() { - return { - 'bitcoin': { usd: null, btc: null }, - 'bitcoin-cash': { usd: null, btc: null }, - 'dash': { usd: null, btc: null }, - 'dogecoin': { usd: null, btc: null }, - 'decred': { usd: null, btc: null }, - 'litecoin': { usd: null, btc: null }, - 'particl': { usd: null, btc: null }, - 'pivx': { usd: null, btc: null }, - 'monero': { usd: null, btc: null }, - 'zano': { usd: null, btc: null }, - 'wownero': { usd: null, btc: null }, - 'zcoin': { usd: null, btc: null } - }; + return window.config.utils.getEmptyPriceData(); } async function fetchLatestPrices() { + if (!NetworkManager.isOnline()) { + const cachedData = CacheManager.get('prices_coingecko'); + return cachedData?.value || getEmptyPriceData(); + } + if (WebSocketManager.isPageHidden || WebSocketManager.priceUpdatePaused) { const cachedData = CacheManager.get('prices_coingecko'); return cachedData?.value || getEmptyPriceData(); @@ -1086,48 +400,65 @@ async function fetchLatestPrices() { } try { - const existingCache = CacheManager.get(PRICES_CACHE_KEY, true); + const existingCache = CacheManager.get(PRICES_CACHE_KEY); const fallbackData = existingCache ? existingCache.value : null; - const url = `${offersConfig.apiEndpoints.coinGecko}/simple/price?ids=bitcoin,bitcoin-cash,dash,dogecoin,decred,litecoin,particl,pivx,monero,zano,wownero,zcoin&vs_currencies=USD,BTC&api_key=${offersConfig.apiKeys.coinGecko}`; - const response = await fetch('/json/readurl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - url: url, - headers: { - 'User-Agent': 'Mozilla/5.0', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.5' + const coinIds = [ + 'bitcoin', 'particl', 'monero', 'litecoin', + 'dogecoin', 'firo', 'dash', 'pivx', + 'decred', 'bitcoincash' + ]; + + let processedData = {}; + const MAX_RETRIES = 3; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const mainResponse = await Api.fetchCoinPrices(coinIds); + + if (mainResponse && mainResponse.rates) { + Object.entries(mainResponse.rates).forEach(([coinId, price]) => { + const normalizedCoinId = coinId === 'bitcoincash' ? 'bitcoin-cash' : coinId.toLowerCase(); + + processedData[normalizedCoinId] = { + usd: price, + btc: normalizedCoinId === 'bitcoin' ? 1 : price / (mainResponse.rates.bitcoin || 1) + }; + }); } - }) - }); - if (!response.ok) { - throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); - } + try { + const wowResponse = await Api.fetchCoinPrices("wownero"); - const data = await response.json(); + if (wowResponse && wowResponse.rates && wowResponse.rates.wownero) { + processedData['wownero'] = { + usd: wowResponse.rates.wownero, + btc: processedData.bitcoin ? wowResponse.rates.wownero / processedData.bitcoin.usd : 0 + }; + } + } catch (wowError) { + console.error('Error fetching WOW price:', wowError); + } - if (data.Error) { - if (fallbackData) { - return fallbackData; + latestPrices = processedData; + CacheManager.set(PRICES_CACHE_KEY, processedData, 'prices'); + + Object.entries(processedData).forEach(([coin, prices]) => { + if (prices.usd) { + tableRateModule.setFallbackValue(coin, prices.usd); + } + }); + + return processedData; + } catch (error) { + console.error(`Price fetch attempt ${attempt + 1} failed:`, error); + NetworkManager.handleNetworkError(error); + + if (attempt < MAX_RETRIES - 1) { + const delay = Math.min(500 * Math.pow(2, attempt), 5000); + await new Promise(resolve => setTimeout(resolve, delay)); + } } - throw new Error(data.Error); - } - - if (data && Object.keys(data).length > 0) { - latestPrices = data; - CacheManager.set(PRICES_CACHE_KEY, data, CACHE_DURATION); - - Object.entries(data).forEach(([coin, prices]) => { - if (prices.usd) { - tableRateModule.setFallbackValue(coin, prices.usd); - } - }); - return data; } if (fallbackData) { @@ -1146,16 +477,11 @@ async function fetchLatestPrices() { return fallbackPrices; } - return null; + return getEmptyPriceData(); } catch (error) { - const fallbackPrices = {}; - Object.keys(getEmptyPriceData()).forEach(coin => { - const fallbackValue = tableRateModule.getFallbackValue(coin); - if (fallbackValue !== null) { - fallbackPrices[coin] = { usd: fallbackValue, btc: null }; - } - }); - return Object.keys(fallbackPrices).length > 0 ? fallbackPrices : null; + console.error('Unexpected error in fetchLatestPrices:', error); + NetworkManager.handleNetworkError(error); + return getEmptyPriceData(); } finally { window.isManualRefresh = false; } @@ -1167,6 +493,10 @@ async function fetchOffers() { const refreshText = document.getElementById('refreshText'); try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + if (refreshButton) { refreshButton.disabled = true; refreshIcon.classList.add('animate-spin'); @@ -1190,12 +520,15 @@ async function fetchOffers() { originalJsonData = [...jsonData]; latestPrices = pricesData || getEmptyPriceData(); + + CacheManager.set('offers_cached', jsonData, 'offers'); await updateOffersTable(); updatePaginationInfo(); } catch (error) { console.error('[Debug] Error fetching offers:', error); + NetworkManager.handleNetworkError(error); const cachedOffers = CacheManager.get('offers_cached'); if (cachedOffers?.value) { @@ -1235,13 +568,11 @@ function formatInitialData(data) { })); } -// UI COMPONENT FUNCTIONS function updateConnectionStatus(status) { const dot = document.getElementById('status-dot'); const text = document.getElementById('status-text'); if (!dot || !text) { - //console.warn('Status indicators not found in DOM'); return; } @@ -1375,7 +706,180 @@ function updateProfitLoss(row, fromCoin, toCoin, fromAmount, toAmount, isOwnOffe profitLossElement.textContent = `${percentDiffDisplay}%`; profitLossElement.className = `profit-loss text-lg font-bold ${colorClass}`; - // Update tooltip if it exists + const tooltipId = `percentage-tooltip-${row.getAttribute('data-offer-id')}`; + const tooltipElement = document.getElementById(tooltipId); + if (tooltipElement) { + const tooltipContent = createTooltipContent(isSentOffers || isOwnOffer, fromCoin, toCoin, fromAmount, toAmount); + tooltipElement.innerHTML = ` + <div class="tooltip-content"> + ${tooltipContent} + </div> + <div class="tooltip-arrow" data-popper-arrow></div> + `; + } + }) + .catch(error => { + console.error('Error in updateProfitLoss:', error); + profitLossElement.textContent = 'N/A'; + profitLossElement.className = 'profit-loss text-lg font-bold text-gray-300'; + }); +} + +function updateClearFiltersButton() { + const clearButton = document.getElementById('clearFilters'); + if (clearButton) { + const hasFilters = hasActiveFilters(); + clearButton.classList.toggle('opacity-50', !hasFilters); + clearButton.disabled = !hasFilters; + + if (hasFilters) { + clearButton.classList.add('hover:bg-green-600', 'hover:text-white'); + clearButton.classList.remove('cursor-not-allowed'); + } else { + clearButton.classList.remove('hover:bg-green-600', 'hover:text-white'); + clearButton.classList.add('cursor-not-allowed'); + } + } +} + +function updateConnectionStatus(status) { + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + + if (!dot || !text) { + return; + } + + switch(status) { + case 'connected': + dot.className = 'w-2.5 h-2.5 rounded-full bg-green-500 mr-2'; + text.textContent = 'Connected'; + text.className = 'text-sm text-green-500'; + break; + case 'disconnected': + dot.className = 'w-2.5 h-2.5 rounded-full bg-red-500 mr-2'; + text.textContent = 'Disconnected - Reconnecting...'; + text.className = 'text-sm text-red-500'; + break; + case 'error': + dot.className = 'w-2.5 h-2.5 rounded-full bg-yellow-500 mr-2'; + text.textContent = 'Connection Error'; + text.className = 'text-sm text-yellow-500'; + break; + default: + dot.className = 'w-2.5 h-2.5 rounded-full bg-gray-500 mr-2'; + text.textContent = 'Connecting...'; + text.className = 'text-sm text-gray-500'; + } +} + +function updateRowTimes() { + requestAnimationFrame(() => { + const rows = document.querySelectorAll('[data-offer-id]'); + rows.forEach(row => { + const offerId = row.getAttribute('data-offer-id'); + const offer = jsonData.find(o => o.offer_id === offerId); + if (!offer) return; + + const newPostedTime = formatTime(offer.created_at, true); + const newExpiresIn = formatTimeLeft(offer.expire_at); + + const postedElement = row.querySelector('.text-xs:first-child'); + const expiresElement = row.querySelector('.text-xs:last-child'); + + if (postedElement && postedElement.textContent !== `Posted: ${newPostedTime}`) { + postedElement.textContent = `Posted: ${newPostedTime}`; + } + if (expiresElement && expiresElement.textContent !== `Expires in: ${newExpiresIn}`) { + expiresElement.textContent = `Expires in: ${newExpiresIn}`; + } + }); + }); +} + +function updateLastRefreshTime() { + if (lastRefreshTimeSpan) { + lastRefreshTimeSpan.textContent = lastRefreshTime ? new Date(lastRefreshTime).toLocaleTimeString() : 'Never'; + } +} + +function stopRefreshAnimation() { + const refreshButton = document.getElementById('refreshOffers'); + const refreshIcon = document.getElementById('refreshIcon'); + const refreshText = document.getElementById('refreshText'); + + if (refreshButton) { + refreshButton.disabled = false; + refreshButton.classList.remove('opacity-75', 'cursor-wait'); + } + if (refreshIcon) { + refreshIcon.classList.remove('animate-spin'); + } + if (refreshText) { + refreshText.textContent = 'Refresh'; + } +} + +function updatePaginationInfo() { + const validOffers = getValidOffers(); + const totalItems = validOffers.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + + currentPage = Math.max(1, Math.min(currentPage, totalPages)); + + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; + + const showPrev = currentPage > 1; + const showNext = currentPage < totalPages && totalItems > 0; + + prevPageButton.style.display = showPrev ? 'inline-flex' : 'none'; + nextPageButton.style.display = showNext ? 'inline-flex' : 'none'; + + if (lastRefreshTime) { + lastRefreshTimeSpan.textContent = new Date(lastRefreshTime).toLocaleTimeString(); + } + + if (newEntriesCountSpan) { + newEntriesCountSpan.textContent = totalItems; + } +} + +function updatePaginationControls(totalPages) { + prevPageButton.style.display = currentPage > 1 ? 'inline-flex' : 'none'; + nextPageButton.style.display = currentPage < totalPages ? 'inline-flex' : 'none'; + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; +} + +function updateProfitLoss(row, fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) { + const profitLossElement = row.querySelector('.profit-loss'); + if (!profitLossElement) { + return; + } + + if (!fromCoin || !toCoin) { + profitLossElement.textContent = 'N/A'; + profitLossElement.className = 'profit-loss text-lg font-bold text-gray-300'; + return; + } + + calculateProfitLoss(fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) + .then(percentDiff => { + if (percentDiff === null || isNaN(percentDiff)) { + profitLossElement.textContent = 'N/A'; + profitLossElement.className = 'profit-loss text-lg font-bold text-gray-300'; + return; + } + + const formattedPercentDiff = percentDiff.toFixed(2); + const percentDiffDisplay = formattedPercentDiff === "0.00" ? "0.00" : + (percentDiff > 0 ? `+${formattedPercentDiff}` : formattedPercentDiff); + + const colorClass = getProfitColorClass(percentDiff); + profitLossElement.textContent = `${percentDiffDisplay}%`; + profitLossElement.className = `profit-loss text-lg font-bold ${colorClass}`; + const tooltipId = `percentage-tooltip-${row.getAttribute('data-offer-id')}`; const tooltipElement = document.getElementById(tooltipId); if (tooltipElement) { @@ -1406,12 +910,14 @@ function updateCoinFilterImages() { const imagePath = selectedOption.getAttribute('data-image'); if (imagePath && select.value !== 'any') { button.style.backgroundImage = `url(${imagePath})`; - button.style.backgroundSize = 'contain'; - button.style.backgroundRepeat = 'no-repeat'; + button.style.backgroundSize = '25px 25px'; button.style.backgroundPosition = 'center'; + button.style.backgroundRepeat = 'no-repeat'; } else { button.style.backgroundImage = 'none'; } + button.style.minWidth = '25px'; + button.style.minHeight = '25px'; } updateButtonImage(coinToSelect, coinToButton); @@ -1436,23 +942,33 @@ function updateClearFiltersButton() { } function cleanupRow(row) { + if (!row) return; + const tooltipTriggers = row.querySelectorAll('[data-tooltip-trigger-id]'); tooltipTriggers.forEach(trigger => { if (window.TooltipManager) { window.TooltipManager.destroy(trigger); } }); - EventManager.removeAll(row); + + CleanupManager.removeListenersByElement(row); + + row.removeAttribute('data-offer-id'); + + while (row.firstChild) { + const child = row.firstChild; + row.removeChild(child); + } } - function cleanupTable() { - EventManager.clearAll(); - if (offersBody) { - const existingRows = offersBody.querySelectorAll('tr'); - existingRows.forEach(row => cleanupRow(row)); - offersBody.innerHTML = ''; - } + if (!offersBody) return; + + const existingRows = offersBody.querySelectorAll('tr'); + existingRows.forEach(row => cleanupRow(row)); + + offersBody.innerHTML = ''; + if (window.TooltipManager) { window.TooltipManager.cleanup(); } @@ -1645,8 +1161,8 @@ function createTableRow(offer, identity = null) { is_public: isPublic } = offer; - const coinFromSymbol = coinNameToSymbol[coinFrom] || coinFrom.toLowerCase(); - const coinToSymbol = coinNameToSymbol[coinTo] || coinTo.toLowerCase(); + const coinFromSymbol = window.config.coinMappings.nameToSymbol[coinFrom] || coinFrom.toLowerCase(); + const coinToSymbol = window.config.coinMappings.nameToSymbol[coinTo] || coinTo.toLowerCase(); const coinFromDisplay = getDisplayName(coinFrom); const coinToDisplay = getDisplayName(coinTo); const postedTime = formatTime(createdAt, true); @@ -1663,7 +1179,7 @@ function createTableRow(offer, identity = null) { ${createDetailsColumn(offer, identity)} ${createTakerAmountColumn(offer, coinTo, coinFrom)} ${createSwapColumn(offer, coinFromDisplay, coinToDisplay, coinFromSymbol, coinToSymbol)} - ${createOrderbookColumn(offer, coinFrom, coinTo)} + ${createOrderbookColumn(offer, coinFrom)} ${createRateColumn(offer, coinFrom, coinTo)} ${createPercentageColumn(offer)} ${createActionColumn(offer, isActuallyExpired)} @@ -1698,11 +1214,11 @@ function createTimeColumn(offer, postedTime, expiresIn) { const now = Math.floor(Date.now() / 1000); const timeLeft = offer.expire_at - now; - let strokeColor = '#10B981'; // Default green for > 30 min + let strokeColor = '#10B981'; if (timeLeft <= 300) { - strokeColor = '#9CA3AF'; // Grey for 5 min or less + strokeColor = '#9CA3AF'; } else if (timeLeft <= 1800) { - strokeColor = '#3B82F6'; // Blue for 5-30 min + strokeColor = '#3B82F6'; } return ` @@ -1836,22 +1352,31 @@ function createRateColumn(offer, coinFrom, coinTo) { const inverseRate = rate ? (1 / rate) : 0; const getPriceKey = (coin) => { - if (!coin) return null; const lowerCoin = coin.toLowerCase(); - if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { - return 'zcoin'; - } - if (lowerCoin === 'bitcoin cash') { - return 'bitcoin-cash'; - } - if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') { - return 'particl'; - } - return coinNameToSymbol[coin] || lowerCoin; + + const symbolToName = { + 'btc': 'bitcoin', + 'xmr': 'monero', + 'part': 'particl', + 'bch': 'bitcoin-cash', + 'pivx': 'pivx', + 'firo': 'firo', + 'dash': 'dash', + 'ltc': 'litecoin', + 'doge': 'dogecoin', + 'dcr': 'decred', + 'wow': 'wownero' + }; + + if (lowerCoin === 'zcoin') return 'firo'; + if (lowerCoin === 'bitcoin cash') return 'bitcoin-cash'; + if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') return 'particl'; + + return symbolToName[lowerCoin] || lowerCoin; }; const toSymbolKey = getPriceKey(coinTo); - let toPriceUSD = latestPrices && toSymbolKey ? latestPrices[toSymbolKey]?.usd : null; + let toPriceUSD = latestPrices && latestPrices[toSymbolKey] ? latestPrices[toSymbolKey].usd : null; if (!toPriceUSD || isNaN(toPriceUSD)) { toPriceUSD = tableRateModule.getFallbackValue(toSymbolKey); @@ -1927,7 +1452,6 @@ function createActionColumn(offer, isActuallyExpired = false) { `; } -// TOOLTIP FUNCTIONS function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, toAmount, postedTime, expiresIn, isActuallyExpired, isRevoked, identity = null) { const uniqueId = `${offer.offer_id}_${offer.created_at}`; @@ -2119,19 +1643,46 @@ function createTooltipContent(isSentOffers, coinFrom, coinTo, fromAmount, toAmou const getPriceKey = (coin) => { const lowerCoin = coin.toLowerCase(); - return lowerCoin === 'firo' || lowerCoin === 'zcoin' ? 'zcoin' : - lowerCoin === 'bitcoin cash' ? 'bitcoin-cash' : - lowerCoin === 'particl anon' || lowerCoin === 'particl blind' ? 'particl' : - coinNameToSymbol[coin] || lowerCoin; + + const symbolToName = { + 'btc': 'bitcoin', + 'xmr': 'monero', + 'part': 'particl', + 'bch': 'bitcoin-cash', + 'pivx': 'pivx', + 'firo': 'firo', + 'dash': 'dash', + 'ltc': 'litecoin', + 'doge': 'dogecoin', + 'dcr': 'decred', + 'wow': 'wownero' + }; + + if (lowerCoin === 'zcoin') return 'firo'; + if (lowerCoin === 'bitcoin cash') return 'bitcoin-cash'; + if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') return 'particl'; + + return symbolToName[lowerCoin] || lowerCoin; }; + + if (latestPrices && latestPrices['firo'] && !latestPrices['zcoin']) { + latestPrices['zcoin'] = JSON.parse(JSON.stringify(latestPrices['firo'])); + } const fromSymbol = getPriceKey(coinFrom); const toSymbol = getPriceKey(coinTo); - const fromPriceUSD = latestPrices[fromSymbol]?.usd; - const toPriceUSD = latestPrices[toSymbol]?.usd; + + let fromPriceUSD = latestPrices && latestPrices[fromSymbol] ? latestPrices[fromSymbol].usd : null; + let toPriceUSD = latestPrices && latestPrices[toSymbol] ? latestPrices[toSymbol].usd : null; + + if (!fromPriceUSD || !toPriceUSD) { + fromPriceUSD = tableRateModule.getFallbackValue(fromSymbol); + toPriceUSD = tableRateModule.getFallbackValue(toSymbol); + } if (fromPriceUSD === null || toPriceUSD === null || - fromPriceUSD === undefined || toPriceUSD === undefined) { + fromPriceUSD === undefined || toPriceUSD === undefined || + isNaN(fromPriceUSD) || isNaN(toPriceUSD)) { return `<p class="font-bold mb-1">Price Information Unavailable</p> <p>Current market prices are temporarily unavailable.</p> <p class="mt-2">You are ${isSentOffers ? 'selling' : 'buying'} ${fromAmount.toFixed(8)} ${coinFrom} @@ -2194,25 +1745,46 @@ function createCombinedRateTooltip(offer, coinFrom, coinTo, treatAsSentOffer) { const getPriceKey = (coin) => { const lowerCoin = coin.toLowerCase(); - if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { - return 'zcoin'; - } - if (lowerCoin === 'bitcoin cash') { - return 'bitcoin-cash'; - } - if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') { - return 'particl'; - } - return coinNameToSymbol[coin] || lowerCoin; + + const symbolToName = { + 'btc': 'bitcoin', + 'xmr': 'monero', + 'part': 'particl', + 'bch': 'bitcoin-cash', + 'pivx': 'pivx', + 'firo': 'firo', + 'dash': 'dash', + 'ltc': 'litecoin', + 'doge': 'dogecoin', + 'dcr': 'decred', + 'wow': 'wownero' + }; + + if (lowerCoin === 'zcoin') return 'firo'; + if (lowerCoin === 'bitcoin cash') return 'bitcoin-cash'; + if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') return 'particl'; + + return symbolToName[lowerCoin] || lowerCoin; }; + if (latestPrices && latestPrices['firo'] && !latestPrices['zcoin']) { + latestPrices['zcoin'] = JSON.parse(JSON.stringify(latestPrices['firo'])); + } + const fromSymbol = getPriceKey(coinFrom); const toSymbol = getPriceKey(coinTo); - const fromPriceUSD = latestPrices[fromSymbol]?.usd; - const toPriceUSD = latestPrices[toSymbol]?.usd; + + let fromPriceUSD = latestPrices && latestPrices[fromSymbol] ? latestPrices[fromSymbol].usd : null; + let toPriceUSD = latestPrices && latestPrices[toSymbol] ? latestPrices[toSymbol].usd : null; + + if (!fromPriceUSD || !toPriceUSD) { + fromPriceUSD = tableRateModule.getFallbackValue(fromSymbol); + toPriceUSD = tableRateModule.getFallbackValue(toSymbol); + } if (fromPriceUSD === null || toPriceUSD === null || - fromPriceUSD === undefined || toPriceUSD === undefined) { + fromPriceUSD === undefined || toPriceUSD === undefined || + isNaN(fromPriceUSD) || isNaN(toPriceUSD)) { return ` <p class="font-bold mb-1">Exchange Rate Information</p> <p>Market price data is temporarily unavailable.</p> @@ -2267,7 +1839,6 @@ function updateTooltipTargets(row, uniqueId) { }); } -// FILTER FUNCTIONS function applyFilters() { if (filterTimeout) { clearTimeout(filterTimeout); @@ -2289,7 +1860,6 @@ function applyFilters() { } function clearFilters() { - filterForm.reset(); const selectElements = filterForm.querySelectorAll('select'); @@ -2306,6 +1876,9 @@ function clearFilters() { jsonData = [...originalJsonData]; currentPage = 1; + + const storageKey = isSentOffers ? 'sentOffersTableSettings' : 'networkOffersTableSettings'; + localStorage.removeItem(storageKey); updateOffersTable(); updateCoinFilterImages(); @@ -2324,18 +1897,16 @@ function hasActiveFilters() { return hasChangedFilters; } -// UTILITY FUNCTIONS + function formatTimeLeft(timestamp) { - const now = Math.floor(Date.now() / 1000); - if (timestamp <= now) return "Expired"; - return formatTime(timestamp); + return window.config.utils.formatTimeLeft(timestamp); } function getDisplayName(coinName) { if (coinName.toLowerCase() === 'zcoin') { return 'Firo'; } - return coinNameToDisplayName[coinName] || coinName; + return window.config.coinMappings.nameToDisplayName[coinName] || coinName; } function getCoinSymbolLowercase(coin) { @@ -2343,43 +1914,16 @@ function getCoinSymbolLowercase(coin) { if (coin.toLowerCase() === 'bitcoin cash') { return 'bitcoin-cash'; } - return (coinNameToSymbol[coin] || coin).toLowerCase(); + return (window.config.coinMappings.nameToSymbol[coin] || coin).toLowerCase(); } else if (coin && typeof coin === 'object' && coin.symbol) { return coin.symbol.toLowerCase(); } else { - //console.warn('Invalid coin input:', coin); return 'unknown'; } } function coinMatches(offerCoin, filterCoin) { - if (!offerCoin || !filterCoin) return false; - - offerCoin = offerCoin.toLowerCase(); - filterCoin = filterCoin.toLowerCase(); - - if (offerCoin === filterCoin) return true; - - if ((offerCoin === 'firo' || offerCoin === 'zcoin') && - (filterCoin === 'firo' || filterCoin === 'zcoin')) { - return true; - } - - if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') || - (offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) { - return true; - } - - const particlVariants = ['particl', 'particl anon', 'particl blind']; - if (filterCoin === 'particl' && particlVariants.includes(offerCoin)) { - return true; - } - - if (particlVariants.includes(filterCoin)) { - return offerCoin === filterCoin; - } - - return false; + return window.config.coinMatches(offerCoin, filterCoin); } function getProfitColorClass(percentage) { @@ -2396,68 +1940,30 @@ function isOfferExpired(offer) { } const currentTime = Math.floor(Date.now() / 1000); const isExpired = offer.expire_at <= currentTime; - if (isExpired) { - // console.log(`Offer ${offer.offer_id} is expired. Expire time: ${offer.expire_at}, Current time: ${currentTime}`); - } return isExpired; } function formatTime(timestamp, addAgoSuffix = false) { - const now = Math.floor(Date.now() / 1000); - const diff = Math.abs(now - timestamp); - - let timeString; - if (diff < 60) { - timeString = `${diff} seconds`; - } else if (diff < 3600) { - timeString = `${Math.floor(diff / 60)} minutes`; - } else if (diff < 86400) { - timeString = `${Math.floor(diff / 3600)} hours`; - } else if (diff < 2592000) { - timeString = `${Math.floor(diff / 86400)} days`; - } else if (diff < 31536000) { - timeString = `${Math.floor(diff / 2592000)} months`; - } else { - timeString = `${Math.floor(diff / 31536000)} years`; - } - - return addAgoSuffix ? `${timeString} ago` : timeString; + return window.config.utils.formatTime(timestamp, addAgoSuffix); } function escapeHtml(unsafe) { - if (typeof unsafe !== 'string') { - //console.warn('escapeHtml received a non-string value:', unsafe); - return ''; - } - return unsafe - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return window.config.utils.escapeHtml(unsafe); } function getCoinSymbol(fullName) { - const symbolMap = { - 'Bitcoin': 'BTC', 'Litecoin': 'LTC', 'Monero': 'XMR', - 'Particl': 'PART', 'Particl Blind': 'PART', 'Particl Anon': 'PART', - 'PIVX': 'PIVX', 'Firo': 'FIRO', 'Zcoin': 'FIRO', - 'Dash': 'DASH', 'Decred': 'DCR', 'Wownero': 'WOW', - 'Bitcoin Cash': 'BCH', 'Dogecoin': 'DOGE' - }; - return symbolMap[fullName] || fullName; + return window.config.coinMappings.nameToSymbol[fullName] || fullName; } -// EVENT LISTENERS function initializeTableEvents() { const filterForm = document.getElementById('filterForm'); if (filterForm) { - EventManager.add(filterForm, 'submit', (e) => { + CleanupManager.addListener(filterForm, 'submit', (e) => { e.preventDefault(); applyFilters(); }); - EventManager.add(filterForm, 'change', () => { + CleanupManager.addListener(filterForm, 'change', () => { applyFilters(); updateClearFiltersButton(); }); @@ -2467,14 +1973,14 @@ function initializeTableEvents() { const coinFromSelect = document.getElementById('coin_from'); if (coinToSelect) { - EventManager.add(coinToSelect, 'change', () => { + CleanupManager.addListener(coinToSelect, 'change', () => { applyFilters(); updateCoinFilterImages(); }); } if (coinFromSelect) { - EventManager.add(coinFromSelect, 'change', () => { + CleanupManager.addListener(coinFromSelect, 'change', () => { applyFilters(); updateCoinFilterImages(); }); @@ -2482,116 +1988,117 @@ function initializeTableEvents() { const clearFiltersBtn = document.getElementById('clearFilters'); if (clearFiltersBtn) { - EventManager.add(clearFiltersBtn, 'click', () => { + CleanupManager.addListener(clearFiltersBtn, 'click', () => { clearFilters(); updateCoinFilterImages(); }); } -const refreshButton = document.getElementById('refreshOffers'); -if (refreshButton) { - let lastRefreshTime = 0; - const REFRESH_COOLDOWN = 6000; - let countdownInterval; + const refreshButton = document.getElementById('refreshOffers'); + if (refreshButton) { + let lastRefreshTime = 0; + const REFRESH_COOLDOWN = 6000; + let countdownInterval; - EventManager.add(refreshButton, 'click', async () => { - const now = Date.now(); - if (now - lastRefreshTime < REFRESH_COOLDOWN) { - console.log('Refresh rate limited. Please wait before refreshing again.'); - const startTime = now; - const refreshText = document.getElementById('refreshText'); + CleanupManager.addListener(refreshButton, 'click', async () => { + const now = Date.now(); + if (now - lastRefreshTime < REFRESH_COOLDOWN) { + console.log('Refresh rate limited. Please wait before refreshing again.'); + const startTime = now; + const refreshText = document.getElementById('refreshText'); - refreshButton.classList.remove('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); - refreshButton.classList.add('bg-red-600', 'border-red-500', 'cursor-not-allowed'); + refreshButton.classList.remove('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); + refreshButton.classList.add('bg-red-600', 'border-red-500', 'cursor-not-allowed'); - if (countdownInterval) clearInterval(countdownInterval); + if (countdownInterval) clearInterval(countdownInterval); - countdownInterval = setInterval(() => { - const currentTime = Date.now(); - const elapsedTime = currentTime - startTime; - const remainingTime = Math.ceil((REFRESH_COOLDOWN - elapsedTime) / 1000); + countdownInterval = setInterval(() => { + const currentTime = Date.now(); + const elapsedTime = currentTime - startTime; + const remainingTime = Math.ceil((REFRESH_COOLDOWN - elapsedTime) / 1000); - if (remainingTime <= 0) { - clearInterval(countdownInterval); - refreshText.textContent = 'Refresh'; + if (remainingTime <= 0) { + clearInterval(countdownInterval); + refreshText.textContent = 'Refresh'; - refreshButton.classList.remove('bg-red-600', 'border-red-500', 'cursor-not-allowed'); - refreshButton.classList.add('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); - } else { - refreshText.textContent = `Refresh (${remainingTime}s)`; - } - }, 100); - return; - } - - console.log('Manual refresh initiated'); - lastRefreshTime = now; - const refreshIcon = document.getElementById('refreshIcon'); - const refreshText = document.getElementById('refreshText'); - refreshButton.disabled = true; - refreshIcon.classList.add('animate-spin'); - refreshText.textContent = 'Refreshing...'; - refreshButton.classList.add('opacity-75', 'cursor-wait'); - - try { - const cachedPrices = CacheManager.get('prices_coingecko'); - const previousPrices = cachedPrices ? cachedPrices.value : null; - CacheManager.clear(); - window.isManualRefresh = true; - const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; - const response = await fetch(endpoint); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + refreshButton.classList.remove('bg-red-600', 'border-red-500', 'cursor-not-allowed'); + refreshButton.classList.add('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); + } else { + refreshText.textContent = `Refresh (${remainingTime}s)`; + } + }, 100); + return; } - const newData = await response.json(); - const processedNewData = Array.isArray(newData) ? newData : Object.values(newData); - jsonData = formatInitialData(processedNewData); - originalJsonData = [...jsonData]; - const priceData = await fetchLatestPrices(); - if (!priceData && previousPrices) { - console.log('Using previous price data after failed refresh'); - latestPrices = previousPrices; - await updateOffersTable(); - } else if (priceData) { - latestPrices = priceData; - await updateOffersTable(); - } else { - throw new Error('Unable to fetch price data'); - } - updatePaginationInfo(); + + console.log('Manual refresh initiated'); lastRefreshTime = now; - updateLastRefreshTime(); + const refreshIcon = document.getElementById('refreshIcon'); + const refreshText = document.getElementById('refreshText'); + refreshButton.disabled = true; + refreshIcon.classList.add('animate-spin'); + refreshText.textContent = 'Refreshing...'; + refreshButton.classList.add('opacity-75', 'cursor-wait'); - console.log('Manual refresh completed successfully'); + try { + const cachedPrices = CacheManager.get('prices_coingecko'); + const previousPrices = cachedPrices ? cachedPrices.value : null; + CacheManager.clear(); + window.isManualRefresh = true; + const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; + const response = await fetch(endpoint); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const newData = await response.json(); + const processedNewData = Array.isArray(newData) ? newData : Object.values(newData); + jsonData = formatInitialData(processedNewData); + originalJsonData = [...jsonData]; + const priceData = await fetchLatestPrices(); + if (!priceData && previousPrices) { + console.log('Using previous price data after failed refresh'); + latestPrices = previousPrices; + await updateOffersTable(); + } else if (priceData) { + latestPrices = priceData; + await updateOffersTable(); + } else { + throw new Error('Unable to fetch price data'); + } + updatePaginationInfo(); + lastRefreshTime = now; + updateLastRefreshTime(); - } catch (error) { - console.error('Error during manual refresh:', error); - ui.displayErrorMessage('Unable to refresh data. Previous data will be preserved.'); + console.log('Manual refresh completed successfully'); - const cachedData = CacheManager.get('prices_coingecko'); - if (cachedData?.value) { - latestPrices = cachedData.value; - await updateOffersTable(); + } catch (error) { + console.error('Error during manual refresh:', error); + NetworkManager.handleNetworkError(error); + ui.displayErrorMessage('Unable to refresh data. Previous data will be preserved.'); + + const cachedData = CacheManager.get('prices_coingecko'); + if (cachedData?.value) { + latestPrices = cachedData.value; + await updateOffersTable(); + } + } finally { + window.isManualRefresh = false; + refreshButton.disabled = false; + refreshIcon.classList.remove('animate-spin'); + refreshText.textContent = 'Refresh'; + refreshButton.classList.remove('opacity-75', 'cursor-wait'); + + refreshButton.classList.remove('bg-red-600', 'border-red-500', 'cursor-not-allowed'); + refreshButton.classList.add('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); + + if (countdownInterval) { + clearInterval(countdownInterval); + } } - } finally { - window.isManualRefresh = false; - refreshButton.disabled = false; - refreshIcon.classList.remove('animate-spin'); - refreshText.textContent = 'Refresh'; - refreshButton.classList.remove('opacity-75', 'cursor-wait'); - - refreshButton.classList.remove('bg-red-600', 'border-red-500', 'cursor-not-allowed'); - refreshButton.classList.add('bg-blue-600', 'hover:bg-green-600', 'border-blue-500', 'hover:border-green-600'); - - if (countdownInterval) { - clearInterval(countdownInterval); - } - } - }); -} + }); + } document.querySelectorAll('th[data-sortable="true"]').forEach(header => { - EventManager.add(header, 'click', () => { + CleanupManager.addListener(header, 'click', async () => { const columnIndex = parseInt(header.getAttribute('data-column-index')); handleTableSort(columnIndex, header); }); @@ -2601,7 +2108,7 @@ if (refreshButton) { const nextPageButton = document.getElementById('nextPage'); if (prevPageButton) { - EventManager.add(prevPageButton, 'click', () => { + CleanupManager.addListener(prevPageButton, 'click', () => { if (currentPage > 1) { currentPage--; updateOffersTable(); @@ -2610,7 +2117,7 @@ if (refreshButton) { } if (nextPageButton) { - EventManager.add(nextPageButton, 'click', () => { + CleanupManager.addListener(nextPageButton, 'click', () => { const totalPages = Math.ceil(jsonData.length / itemsPerPage); if (currentPage < totalPages) { currentPage++; @@ -2628,14 +2135,7 @@ function handleTableSort(columnIndex, header) { currentSortDirection = 'desc'; } - localStorage.setItem('offersTableSettings', JSON.stringify({ - coin_to: document.getElementById('coin_to').value, - coin_from: document.getElementById('coin_from').value, - status: document.getElementById('status')?.value || 'any', - sent_from: document.getElementById('sent_from').value, - sortColumn: currentSortColumn, - sortDirection: currentSortDirection - })); + saveFilterSettings(); document.querySelectorAll('th[data-sortable="true"]').forEach(th => { const columnSpan = th.querySelector('span:not(.sort-icon)'); @@ -2674,39 +2174,6 @@ function handleTableSort(columnIndex, header) { }, 100); } -// TIMER MANAGEMENT -const timerManager = { - intervals: [], - timeouts: [], - - addInterval(callback, delay) { - const intervalId = setInterval(callback, delay); - this.intervals.push(intervalId); - return intervalId; - }, - - addTimeout(callback, delay) { - const timeoutId = setTimeout(callback, delay); - this.timeouts.push(timeoutId); - return timeoutId; - }, - - clearAllIntervals() { - this.intervals.forEach(clearInterval); - this.intervals = []; - }, - - clearAllTimeouts() { - this.timeouts.forEach(clearTimeout); - this.timeouts = []; - }, - - clearAll() { - this.clearAllIntervals(); - this.clearAllTimeouts(); - } -}; - async function initializeTableAndData() { loadSavedSettings(); updateClearFiltersButton(); @@ -2719,12 +2186,15 @@ async function initializeTableAndData() { applyFilters(); } catch (error) { console.error('Error loading initial data:', error); + NetworkManager.handleNetworkError(error); ui.displayErrorMessage('Error loading data. Retrying in background...'); } } function loadSavedSettings() { - const saved = localStorage.getItem('offersTableSettings'); + const storageKey = isSentOffers ? 'sentOffersTableSettings' : 'networkOffersTableSettings'; + const saved = localStorage.getItem(storageKey); + if (saved) { const settings = JSON.parse(saved); @@ -2755,174 +2225,162 @@ function updateSortIndicators() { } document.addEventListener('DOMContentLoaded', async () => { - const tableLoadPromise = initializeTableAndData(); + if (window.NetworkManager && !window.networkManagerInitialized) { + NetworkManager.initialize({ + connectionTestEndpoint: '/json', + connectionTestTimeout: 3000, + reconnectDelay: 5000, + maxReconnectAttempts: 5 + }); + window.networkManagerInitialized = true; + } - WebSocketManager.initialize(); + NetworkManager.addHandler('offline', () => { + ui.displayErrorMessage("Network connection lost. Will automatically retry when connection is restored."); + updateConnectionStatus('disconnected'); + }); + + NetworkManager.addHandler('reconnected', () => { + ui.hideErrorMessage(); + updateConnectionStatus('connected'); + fetchOffers(); + }); + + NetworkManager.addHandler('maxAttemptsReached', () => { + ui.displayErrorMessage("Server connection lost. Please check your internet connection and try refreshing the page."); + updateConnectionStatus('error'); + }); + + const tableLoadPromise = initializeTableAndData(); + + WebSocketManager.initialize({ + debug: false + }); + + WebSocketManager.addMessageHandler('message', async (message) => { + try { + if (!NetworkManager.isOnline()) { + return; + } + + const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const newData = await response.json(); + const fetchedOffers = Array.isArray(newData) ? newData : Object.values(newData); + + jsonData = formatInitialData(fetchedOffers); + originalJsonData = [...jsonData]; + + CacheManager.set('offers_cached', jsonData, 'offers'); + + requestAnimationFrame(() => { + updateOffersTable(); + updatePaginationInfo(); + }); + } catch (error) { + console.error('[Debug] Error processing WebSocket message:', error); + NetworkManager.handleNetworkError(error); + } + }); await tableLoadPromise; - timerManager.addInterval(() => { - if (WebSocketManager.isConnected()) { - console.log('🟢 WebSocket connection established for Offers'); - } - }, 30000); - - timerManager.addInterval(() => { + CleanupManager.setInterval(() => { CacheManager.cleanup(); }, 300000); - timerManager.addInterval(updateRowTimes, 900000); + CleanupManager.setInterval(updateRowTimes, 900000); - EventManager.add(document, 'visibilitychange', () => { + if (window.MemoryManager) { + MemoryManager.enableAutoCleanup(); + } + + CleanupManager.addListener(document, 'visibilitychange', () => { if (!document.hidden) { if (!WebSocketManager.isConnected()) { WebSocketManager.connect(); } + + if (NetworkManager.isOnline()) { + fetchLatestPrices().then(priceData => { + if (priceData) { + latestPrices = priceData; + updateProfitLossDisplays(); + } + }); + } } }); - EventManager.add(window, 'beforeunload', () => { + CleanupManager.addListener(window, 'beforeunload', () => { cleanup(); }); }); async function cleanup() { - const debug = { - startTime: Date.now(), - steps: [], - errors: [], - addStep: function(step, details = null) { - const timeFromStart = Date.now() - this.startTime; - console.log(`[Cleanup ${timeFromStart}ms] ${step}`, details || ''); - this.steps.push({ step, time: timeFromStart, details }); - }, - addError: function(step, error) { - const timeFromStart = Date.now() - this.startTime; - console.error(`[Cleanup Error ${timeFromStart}ms] ${step}:`, error); - this.errors.push({ step, error, time: timeFromStart }); - }, - summarizeLogs: function() { - console.log('Cleanup Summary:'); - console.log(`Total cleanup time: ${Date.now() - this.startTime}ms`); - console.log(`Steps completed: ${this.steps.length}`); - console.log(`Errors encountered: ${this.errors.length}`); - } - }; + console.log('Starting cleanup process'); try { - debug.addStep('Starting cleanup process'); - debug.addStep('Starting tooltip cleanup'); + if (filterTimeout) { + clearTimeout(filterTimeout); + filterTimeout = null; + } + + if (window.WebSocketManager) { + WebSocketManager.disconnect(); + WebSocketManager.dispose(); + } + if (window.TooltipManager) { window.TooltipManager.cleanup(); + window.TooltipManager.dispose(); } - debug.addStep('Tooltip cleanup completed'); - debug.addStep('Clearing timers'); - const timerCount = timerManager.intervals.length + timerManager.timeouts.length; - timerManager.clearAll(); - debug.addStep('Timers cleared', `Cleaned up ${timerCount} timers`); - - debug.addStep('Starting WebSocket cleanup'); - await Promise.resolve(WebSocketManager.cleanup()).catch(error => { - debug.addError('WebSocket cleanup', error); - }); - debug.addStep('WebSocket cleanup completed'); - - debug.addStep('Clearing event listeners'); - const listenerCount = EventManager.listeners.size; - EventManager.clearAll(); - debug.addStep('Event listeners cleared', `Cleaned up ${listenerCount} listeners`); - - debug.addStep('Starting table cleanup'); - const rowCount = offersBody ? offersBody.querySelectorAll('tr').length : 0; cleanupTable(); - debug.addStep('Table cleanup completed', `Cleaned up ${rowCount} rows`); - debug.addStep('Resetting global state'); - const globals = { - currentPage: currentPage, - dataLength: jsonData.length, - originalDataLength: originalJsonData.length - }; - currentPage = 1; + CleanupManager.clearAll(); + + latestPrices = null; jsonData = []; originalJsonData = []; + lastRefreshTime = null; + + const domRefs = [ + 'offersBody', 'filterForm', 'prevPageButton', 'nextPageButton', + 'currentPageSpan', 'totalPagesSpan', 'lastRefreshTimeSpan', 'newEntriesCountSpan' + ]; + + domRefs.forEach(ref => { + if (window[ref]) window[ref] = null; + }); + + if (window.tableRateModule) { + window.tableRateModule.cache = {}; + window.tableRateModule.processedOffers.clear(); + } + + currentPage = 1; currentSortColumn = 0; currentSortDirection = 'desc'; - filterTimeout = null; - latestPrices = null; - lastRefreshTime = null; - debug.addStep('Global state reset', globals); - debug.addStep('Clearing global references'); - [ - 'WebSocketManager', - 'tableRateModule', - 'offersBody', - 'filterForm', - 'prevPageButton', - 'nextPageButton', - 'currentPageSpan', - 'totalPagesSpan', - 'lastRefreshTimeSpan', - 'newEntriesCountSpan' - ].forEach(ref => { - if (window[ref]) { - window[ref] = null; - } - }); - debug.addStep('Global references cleared'); - - debug.addStep('Cleaning up tooltip containers'); - const tooltipContainers = document.querySelectorAll('.tooltip-container'); - tooltipContainers.forEach(container => { - if (container && container.parentNode) { - container.parentNode.removeChild(container); - } - }); - debug.addStep('Tooltip containers cleaned up'); - - debug.addStep('Clearing document/window events'); - ['visibilitychange', 'beforeunload', 'scroll'].forEach(event => { - document.removeEventListener(event, null); - window.removeEventListener(event, null); - }); - debug.addStep('Document/window events cleared'); - - debug.addStep('Clearing localStorage items'); - try { - localStorage.removeItem('tableSortColumn'); - localStorage.removeItem('tableSortDirection'); - debug.addStep('localStorage items cleared'); - } catch (e) { - debug.addError('localStorage cleanup', e); + if (window.MemoryManager) { + MemoryManager.forceCleanup(); } + console.log('Offers table cleanup completed'); } catch (error) { - debug.addError('Main cleanup process', error); + console.error('Error during offers cleanup:', error); - debug.addStep('Starting failsafe cleanup'); try { - if (window.TooltipManager) { - window.TooltipManager.cleanup(); - } - WebSocketManager.cleanup(); - EventManager.clearAll(); - timerManager.clearAll(); - if (window.ws) { - window.ws.close(); - window.ws = null; - } - debug.addStep('Failsafe cleanup completed'); - } catch (criticalError) { - debug.addError('Critical failsafe cleanup', criticalError); + CleanupManager.clearAll(); + cleanupTable(); + } catch (e) { + console.error('Failsafe cleanup failed:', e); } - } finally { - debug.summarizeLogs(); } } window.cleanup = cleanup; - -//console.log('Offers Table Module fully initialized'); diff --git a/basicswap/static/js/pricechart.js b/basicswap/static/js/pricechart.js index 010ab5e..05e7147 100644 --- a/basicswap/static/js/pricechart.js +++ b/basicswap/static/js/pricechart.js @@ -1,184 +1,22 @@ -// CLEANUP -const cleanupManager = { - eventListeners: [], - timeouts: [], - intervals: [], - animationFrames: [], +const chartConfig = window.config.chartConfig; +const coins = window.config.coins; +const apiKeys = window.config.getAPIKeys(); - addListener: function(element, type, handler, options) { - if (!element) return null; - element.addEventListener(type, handler, options); - this.eventListeners.push({ element, type, handler, options }); - return handler; - }, - - setTimeout: function(callback, delay) { - const id = setTimeout(callback, delay); - this.timeouts.push(id); - return id; - }, - - setInterval: function(callback, delay) { - const id = setInterval(callback, delay); - this.intervals.push(id); - return id; - }, - - requestAnimationFrame: function(callback) { - const id = requestAnimationFrame(callback); - this.animationFrames.push(id); - return id; - }, - - clearAll: function() { - this.eventListeners.forEach(({ element, type, handler, options }) => { - if (element) { - try { - element.removeEventListener(type, handler, options); - } catch (e) { - console.warn('Error removing event listener:', e); - } - } - }); - this.eventListeners = []; - - this.timeouts.forEach(id => clearTimeout(id)); - this.timeouts = []; - - this.intervals.forEach(id => clearInterval(id)); - this.intervals = []; - - this.animationFrames.forEach(id => cancelAnimationFrame(id)); - this.animationFrames = []; - - console.log('All resources cleaned up'); - }, - - clearTimeouts: function() { - this.timeouts.forEach(id => clearTimeout(id)); - this.timeouts = []; - }, - - clearIntervals: function() { - this.intervals.forEach(id => clearInterval(id)); - this.intervals = []; - }, - - removeListenersByElement: function(element) { - if (!element) return; - - const listenersToRemove = this.eventListeners.filter( - listener => listener.element === element - ); - - listenersToRemove.forEach(({ element, type, handler, options }) => { - try { - element.removeEventListener(type, handler, options); - } catch (e) { - console.warn('Error removing event listener:', e); - } - }); - - this.eventListeners = this.eventListeners.filter( - listener => listener.element !== element - ); - } -}; - -// MEMORY -const memoryMonitor = { - isEnabled: true, - lastLogTime: 0, - logInterval: 5 * 60 * 1000, - monitorInterval: null, - - startMonitoring: function() { - console.log('Starting memory monitoring'); - if (!this.isEnabled) return; - - if (this.monitorInterval) { - clearInterval(this.monitorInterval); - } - - this.monitorInterval = setInterval(() => { - this.logMemoryUsage(); - }, this.logInterval); - - this.logMemoryUsage(); - }, - - logMemoryUsage: function() { - console.log('Logging memory usage'); - if (window.performance && window.performance.memory) { - const memory = window.performance.memory; - console.log(`Memory Usage: ${Math.round(memory.usedJSHeapSize / (1024 * 1024))}MB / ${Math.round(memory.jsHeapSizeLimit / (1024 * 1024))}MB`); - } - }, - - stopMonitoring: function() { - if (this.monitorInterval) { - clearInterval(this.monitorInterval); - this.monitorInterval = null; - } - } -}; - -// CONFIG -const config = { - apiKeys: getAPIKeys(), - coins: [ - { symbol: 'BTC', name: 'bitcoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'XMR', name: 'monero', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'PART', name: 'particl', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'BCH', name: 'bitcoin-cash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'PIVX', name: 'pivx', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'FIRO', name: 'zcoin', displayName: 'Firo', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DASH', name: 'dash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'LTC', name: 'litecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DOGE', name: 'dogecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'ETH', name: 'ethereum', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DCR', name: 'decred', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'ZANO', name: 'zano', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'WOW', name: 'wownero', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 } - ], - apiEndpoints: { - cryptoCompare: 'https://min-api.cryptocompare.com/data/pricemultifull', - coinGecko: 'https://api.coingecko.com/api/v3', - cryptoCompareHistorical: 'https://min-api.cryptocompare.com/data/v2/histoday' - }, - chartColors: { - default: { - lineColor: 'rgba(77, 132, 240, 1)', - backgroundColor: 'rgba(77, 132, 240, 0.1)' - } - }, - showVolume: false, - cacheTTL: 10 * 60 * 1000, - specialCoins: [''], - resolutions: { - year: { days: 365, interval: 'month' }, - sixMonths: { days: 180, interval: 'daily' }, - day: { days: 1, interval: 'hourly' } - }, - currentResolution: 'year', - requestTimeout: 60000, // 60 sec - retryDelays: [5000, 15000, 30000], - rateLimits: { - coingecko: { - requestsPerMinute: 50, - minInterval: 1200 // 1.2 sec - }, - cryptocompare: { - requestsPerMinute: 30, - minInterval: 2000 // 2 sec - } - } -}; - -// UTILS const utils = { - formatNumber: (number, decimals = 2) => - number.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ','), + formatNumber: (number, decimals = 2) => { + if (typeof number !== 'number' || isNaN(number)) { + return '0'; + } + + try { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(number); + } catch (e) { + return '0'; + } + }, formatDate: (timestamp, resolution) => { const date = new Date(timestamp); const options = { @@ -188,7 +26,6 @@ const utils = { }; return date.toLocaleString('en-US', { ...options[resolution], timeZone: 'UTC' }); }, - debounce: (func, delay) => { let timeoutId; return (...args) => { @@ -198,7 +35,6 @@ const utils = { } }; -// ERROR class AppError extends Error { constructor(message, type = 'AppError') { super(message); @@ -206,7 +42,6 @@ class AppError extends Error { } } -// LOG const logger = { log: (message) => console.log(`[AppLog] ${new Date().toISOString()}: ${message}`), warn: (message) => console.warn(`[AppWarn] ${new Date().toISOString()}: ${message}`), @@ -214,153 +49,192 @@ const logger = { }; const api = { - makePostRequest: (url, headers = {}) => { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/json/readurl'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.timeout = config.requestTimeout; - - xhr.ontimeout = () => { - logger.warn(`Request timed out for ${url}`); - reject(new AppError('Request timed out')); - }; - - xhr.onload = () => { - logger.log(`Response for ${url}:`, xhr.responseText); - if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - if (response.Error) { - logger.error(`API Error for ${url}:`, response.Error); - reject(new AppError(response.Error, 'APIError')); - } else { - resolve(response); - } - } catch (error) { - logger.error(`Invalid JSON response for ${url}:`, xhr.responseText); - reject(new AppError(`Invalid JSON response: ${error.message}`, 'ParseError')); + fetchVolumeDataXHR: async () => { + const cacheKey = 'volumeData'; + const cachedData = CacheManager.get(cacheKey); + + if (cachedData) { + console.log("Using cached volume data"); + return cachedData.value; + } + + try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + + const volumeData = await Api.fetchVolumeData({ + cryptoCompare: apiKeys.cryptoCompare, + coinGecko: apiKeys.coinGecko + }); + + if (Object.keys(volumeData).length > 0) { + CacheManager.set(cacheKey, volumeData, 'volume'); + return volumeData; + } + + throw new Error("No volume data found in the response"); + } catch (error) { + console.error("Error fetching volume data:", error); + + NetworkManager.handleNetworkError(error); + + try { + const existingCache = localStorage.getItem(cacheKey); + if (existingCache) { + const fallbackData = JSON.parse(existingCache).value; + if (fallbackData && Object.keys(fallbackData).length > 0) { + return fallbackData; } - } else { - logger.error(`HTTP Error for ${url}: ${xhr.status} ${xhr.statusText}`); - reject(new AppError(`HTTP Error: ${xhr.status} ${xhr.statusText}`, 'HTTPError')); } - }; - - xhr.onerror = () => { - logger.error(`Network error occurred for ${url}`); - reject(new AppError('Network error occurred', 'NetworkError')); - }; - - xhr.send(JSON.stringify({ - url: url, - headers: headers - })); - }); + } catch (e) { + console.warn("Error accessing cached volume data:", e); + } + return {}; + } }, fetchCryptoCompareDataXHR: (coin) => { - return rateLimiter.queueRequest('cryptocompare', async () => { - const url = `${config.apiEndpoints.cryptoCompare}?fsyms=${coin}&tsyms=USD,BTC&api_key=${config.apiKeys.cryptoCompare}`; - const headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - }; - try { - return await api.makePostRequest(url, headers); - } catch (error) { - logger.error(`CryptoCompare request failed for ${coin}:`, error); - const cachedData = cache.get(`coinData_${coin}`); - if (cachedData) { - logger.info(`Using cached data for ${coin}`); - return cachedData.value; - } - return { error: error.message }; + try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); } - }); + + return Api.fetchCryptoCompareData(coin, { + cryptoCompare: apiKeys.cryptoCompare + }); + } catch (error) { + logger.error(`CryptoCompare request failed for ${coin}:`, error); + + NetworkManager.handleNetworkError(error); + + const cachedData = CacheManager.get(`coinData_${coin}`); + if (cachedData) { + logger.info(`Using cached data for ${coin}`); + return cachedData.value; + } + return { error: error.message }; + } }, fetchCoinGeckoDataXHR: async () => { const cacheKey = 'coinGeckoOneLiner'; - const cachedData = cache.get(cacheKey); + const cachedData = CacheManager.get(cacheKey); if (cachedData) { - //console.log('Using cached CoinGecko data'); return cachedData.value; } - return rateLimiter.queueRequest('coingecko', async () => { + try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + + const existingCache = localStorage.getItem(cacheKey); + let fallbackData = null; + + if (existingCache) { + try { + const parsed = JSON.parse(existingCache); + fallbackData = parsed.value; + } catch (e) { + console.warn('Failed to parse existing cache:', e); + } + } + + const apiResponse = await Api.fetchCoinGeckoData({ + coinGecko: window.config.getAPIKeys().coinGecko + }); + + if (!apiResponse || !apiResponse.rates) { + if (fallbackData) { + return fallbackData; + } + throw new Error('Invalid data structure received from API'); + } + + const transformedData = {}; + window.config.coins.forEach(coin => { + const coinName = coin.name; + const coinRate = apiResponse.rates[coinName]; + if (coinRate) { + const symbol = coin.symbol.toLowerCase(); + transformedData[symbol] = { + current_price: coinRate, + price_btc: coinName === 'bitcoin' ? 1 : coinRate / (apiResponse.rates.bitcoin || 1), + total_volume: fallbackData && fallbackData[symbol] ? fallbackData[symbol].total_volume : null, + price_change_percentage_24h: fallbackData && fallbackData[symbol] ? fallbackData[symbol].price_change_percentage_24h : null, + displayName: coin.displayName || coin.symbol || coinName + }; + } + }); + + try { + if (!transformedData['wow'] && config.coins.some(c => c.symbol === 'WOW')) { + const wowResponse = await Api.fetchCoinPrices("wownero", { + coinGecko: window.config.getAPIKeys().coinGecko + }); + + if (wowResponse && wowResponse.rates && wowResponse.rates.wownero) { + transformedData['wow'] = { + current_price: wowResponse.rates.wownero, + price_btc: transformedData.btc ? wowResponse.rates.wownero / transformedData.btc.current_price : 0, + total_volume: fallbackData && fallbackData['wow'] ? fallbackData['wow'].total_volume : null, + price_change_percentage_24h: fallbackData && fallbackData['wow'] ? fallbackData['wow'].price_change_percentage_24h : null, + displayName: 'Wownero' + }; + } + } + } catch (wowError) { + console.error('Error fetching WOW price:', wowError); + } + + const missingCoins = window.config.coins.filter(coin => + !transformedData[coin.symbol.toLowerCase()] && + fallbackData && + fallbackData[coin.symbol.toLowerCase()] + ); + + missingCoins.forEach(coin => { + const symbol = coin.symbol.toLowerCase(); + if (fallbackData && fallbackData[symbol]) { + transformedData[symbol] = fallbackData[symbol]; + } + }); + + CacheManager.set(cacheKey, transformedData, 'prices'); + + if (NetworkManager.getReconnectAttempts() > 0) { + NetworkManager.resetReconnectAttempts(); + } + + return transformedData; + } catch (error) { + console.error('Error fetching coin data:', error); + + NetworkManager.handleNetworkError(error); + + const cachedData = CacheManager.get(cacheKey); + if (cachedData) { + console.log('Using cached data due to error'); + return cachedData.value; + } + try { const existingCache = localStorage.getItem(cacheKey); - let fallbackData = null; - if (existingCache) { - try { - const parsed = JSON.parse(existingCache); - fallbackData = parsed.value; - } catch (e) { - console.warn('Failed to parse existing cache:', e); + const parsed = JSON.parse(existingCache); + if (parsed.value) { + console.log('Using expired cache as last resort'); + return parsed.value; } } - - const coinIds = config.coins - .filter(coin => coin.usesCoinGecko) - .map(coin => coin.name) - .join(','); - - const url = `${config.apiEndpoints.coinGecko}/simple/price?ids=${coinIds}&vs_currencies=usd,btc&include_24hr_vol=true&include_24hr_change=true&api_key=${config.apiKeys.coinGecko}`; - - const response = await api.makePostRequest(url, { - 'User-Agent': 'Mozilla/5.0', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.5' - }); - - if (typeof response !== 'object' || response === null) { - if (fallbackData) { - //console.log('Using fallback data due to invalid response'); - return fallbackData; - } - throw new AppError('Invalid data structure received from CoinGecko'); - } - - if (response.error || response.Error) { - if (fallbackData) { - //console.log('Using fallback data due to API error'); - return fallbackData; - } - throw new AppError(response.error || response.Error); - } - - const transformedData = {}; - Object.entries(response).forEach(([id, values]) => { - const coinConfig = config.coins.find(coin => coin.name === id); - const symbol = coinConfig?.symbol.toLowerCase() || id; - transformedData[symbol] = { - current_price: values.usd, - price_btc: values.btc, - total_volume: values.usd_24h_vol, - price_change_percentage_24h: values.usd_24h_change, - displayName: coinConfig?.displayName || coinConfig?.symbol || id - }; - }); - - cache.set(cacheKey, transformedData); - return transformedData; - - } catch (error) { - console.error('Error fetching CoinGecko data:', error); - - const cachedData = cache.get(cacheKey); - if (cachedData) { - //console.log('Using expired cache data due to error'); - return cachedData.value; - } - - throw error; + } catch (e) { + console.warn('Failed to parse expired cache:', e); } - }); + + throw error; + } }, fetchHistoricalDataXHR: async (coinSymbols) => { @@ -369,78 +243,57 @@ const api = { } const results = {}; - const fetchPromises = coinSymbols.map(async coin => { - const coinConfig = config.coins.find(c => c.symbol === coin); - if (!coinConfig) return; - const cacheKey = `historical_${coin}_${config.currentResolution}`; - const cachedData = cache.get(cacheKey); - if (cachedData) { - results[coin] = cachedData.value; - return; + try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); } - if (coin === 'WOW') { - return rateLimiter.queueRequest('coingecko', async () => { - const url = `${config.apiEndpoints.coinGecko}/coins/wownero/market_chart?vs_currency=usd&days=1&api_key=${config.apiKeys.coinGecko}`; - try { - const response = await api.makePostRequest(url); - if (response && response.prices) { - results[coin] = response.prices; - cache.set(cacheKey, response.prices); - } - } catch (error) { - console.error(`Error fetching CoinGecko data for WOW:`, error); - if (cachedData) { - results[coin] = cachedData.value; - } - } - }); - } else { - return rateLimiter.queueRequest('cryptocompare', async () => { - const resolution = config.resolutions[config.currentResolution]; - let url; - if (resolution.interval === 'hourly') { - url = `https://min-api.cryptocompare.com/data/v2/histohour?fsym=${coin}&tsym=USD&limit=${resolution.days * 24}&api_key=${config.apiKeys.cryptoCompare}`; - } else { - url = `${config.apiEndpoints.cryptoCompareHistorical}?fsym=${coin}&tsym=USD&limit=${resolution.days}&api_key=${config.apiKeys.cryptoCompare}`; - } + const historicalData = await Api.fetchHistoricalData( + coinSymbols, + window.config.currentResolution, + { + cryptoCompare: window.config.getAPIKeys().cryptoCompare + } + ); - try { - const response = await api.makePostRequest(url); - if (response.Response === "Error") { - console.error(`API Error for ${coin}:`, response.Message); - if (cachedData) { - results[coin] = cachedData.value; - } - } else if (response.Data && response.Data.Data) { - results[coin] = response.Data; - cache.set(cacheKey, response.Data); - } - } catch (error) { - console.error(`Error fetching CryptoCompare data for ${coin}:`, error); - if (cachedData) { - results[coin] = cachedData.value; - } - } - }); + Object.keys(historicalData).forEach(coin => { + if (historicalData[coin]) { + results[coin] = historicalData[coin]; + + const cacheKey = `historical_${coin}_${window.config.currentResolution}`; + CacheManager.set(cacheKey, historicalData[coin], 'historical'); + } + }); + + return results; + } catch (error) { + console.error('Error fetching historical data:', error); + + NetworkManager.handleNetworkError(error); + + for (const coin of coinSymbols) { + const cacheKey = `historical_${coin}_${window.config.currentResolution}`; + const cachedData = CacheManager.get(cacheKey); + if (cachedData) { + results[coin] = cachedData.value; + } } - }); - - await Promise.all(fetchPromises); - return results; - } + + return results; + } + }, }; const rateLimiter = { lastRequestTime: {}, minRequestInterval: { - coingecko: config.rateLimits.coingecko.minInterval, - cryptocompare: config.rateLimits.cryptocompare.minInterval + coingecko: window.config.rateLimits.coingecko.minInterval, + cryptocompare: window.config.rateLimits.cryptocompare.minInterval }, requestQueue: {}, - retryDelays: config.retryDelays, - + retryDelays: window.config.retryDelays, + canMakeRequest: function(apiName) { const now = Date.now(); const lastRequest = this.lastRequestTime[apiName] || 0; @@ -485,11 +338,7 @@ const rateLimiter = { if ((error.message.includes('timeout') || error.name === 'NetworkError') && retryCount < this.retryDelays.length) { const delay = this.retryDelays[retryCount]; - logger.warn(`Request failed, retrying in ${delay/1000} seconds...`, { - apiName, - retryCount, - error: error.message - }); + logger.warn(`Request failed, retrying in ${delay/1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); return this.queueRequest(apiName, requestFn, retryCount + 1); } @@ -500,14 +349,15 @@ const rateLimiter = { this.requestQueue[apiName] = executeRequest(); return await this.requestQueue[apiName]; - } catch (error) { if (error.message.includes('429') || error.message.includes('timeout') || error.name === 'NetworkError') { - const cachedData = cache.get(`coinData_${apiName}`); + + NetworkManager.handleNetworkError(error); + + const cachedData = CacheManager.get(`coinData_${apiName}`); if (cachedData) { - //console.log('Using cached data due to request failure'); return cachedData.value; } } @@ -516,190 +366,65 @@ const rateLimiter = { } }; -// CACHE -const cache = { - maxSizeBytes: 10 * 1024 * 1024, - maxItems: 200, - cacheTTL: 5 * 60 * 1000, - - set: function(key, value, customTtl = null) { - this.cleanup(); - - const item = { - value: value, - timestamp: Date.now(), - expiresAt: Date.now() + (customTtl || this.cacheTTL) - }; - - try { - const serialized = JSON.stringify(item); - localStorage.setItem(key, serialized); - } catch (e) { - console.warn('Cache set error:', e); - this.clear(); - try { - const serialized = JSON.stringify(item); - localStorage.setItem(key, serialized); - } catch (e2) { - console.error('Failed to store in cache even after cleanup:', e2); - } - } - }, - - get: function(key) { - const itemStr = localStorage.getItem(key); - if (!itemStr) { - return null; - } - - try { - const item = JSON.parse(itemStr); - const now = Date.now(); - - if (now < item.expiresAt) { - return { - value: item.value, - remainingTime: item.expiresAt - now - }; - } else { - localStorage.removeItem(key); - } - } catch (error) { - console.error('Error parsing cache item:', error.message); - localStorage.removeItem(key); - } - - return null; - }, - - isValid: function(key) { - return this.get(key) !== null; - }, - - clear: function() { - const keysToRemove = []; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key.startsWith('coinData_') || key.startsWith('chartData_') || key === 'coinGeckoOneLiner') { - keysToRemove.push(key); - } - } - - keysToRemove.forEach(key => { - localStorage.removeItem(key); - }); - - console.log(`Cache cleared: removed ${keysToRemove.length} items`); - }, - - cleanup: function() { - let totalSize = 0; - const items = []; - const keysToRemove = []; - const now = Date.now(); - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key.startsWith('coinData_') || key.startsWith('chartData_') || key === 'coinGeckoOneLiner') { - try { - const value = localStorage.getItem(key); - const size = new Blob([value]).size; - - const item = JSON.parse(value); - - if (item.expiresAt && item.expiresAt < now) { - keysToRemove.push(key); - continue; - } - - totalSize += size; - items.push({ - key, - size, - timestamp: item.timestamp || 0, - expiresAt: item.expiresAt || 0 - }); - } catch (e) { - keysToRemove.push(key); - } - } - } - - keysToRemove.forEach(key => { - localStorage.removeItem(key); - }); - - if (totalSize > this.maxSizeBytes || items.length > this.maxItems) { - items.sort((a, b) => a.timestamp - b.timestamp); - - const itemsToRemove = Math.max( - Math.ceil(items.length * 0.2), - items.length - this.maxItems - ); - - items.slice(0, itemsToRemove).forEach(item => { - localStorage.removeItem(item.key); - }); - - console.log(`Cache cleanup: removed ${itemsToRemove} items, freed ${Math.round((totalSize - this.maxSizeBytes) / 1024)}KB`); - } - - return { - totalSize, - itemCount: items.length, - removedCount: keysToRemove.length - }; - } -}; - -// UI const ui = { -displayCoinData: (coin, data) => { + displayCoinData: (coin, data) => { let priceUSD, priceBTC, priceChange1d, volume24h; const updateUI = (isError = false) => { - const priceUsdElement = document.querySelector(`#${coin.toLowerCase()}-price-usd`); - const volumeDiv = document.querySelector(`#${coin.toLowerCase()}-volume-div`); - const volumeElement = document.querySelector(`#${coin.toLowerCase()}-volume-24h`); - const btcPriceDiv = document.querySelector(`#${coin.toLowerCase()}-btc-price-div`); - const priceBtcElement = document.querySelector(`#${coin.toLowerCase()}-price-btc`); - if (priceUsdElement) { - priceUsdElement.textContent = isError ? 'N/A' : `$ ${ui.formatPrice(coin, priceUSD)}`; + const priceUsdElement = document.querySelector(`#${coin.toLowerCase()}-price-usd`); + const volumeDiv = document.querySelector(`#${coin.toLowerCase()}-volume-div`); + const volumeElement = document.querySelector(`#${coin.toLowerCase()}-volume-24h`); + const btcPriceDiv = document.querySelector(`#${coin.toLowerCase()}-btc-price-div`); + const priceBtcElement = document.querySelector(`#${coin.toLowerCase()}-price-btc`); + + if (priceUsdElement) { + priceUsdElement.textContent = isError ? 'N/A' : `$ ${ui.formatPrice(coin, priceUSD)}`; + } + + if (volumeDiv && volumeElement) { + if (isError || volume24h === null || volume24h === undefined) { + volumeElement.textContent = 'N/A'; + } else { + volumeElement.textContent = `${utils.formatNumber(volume24h, 0)} USD`; } - if (volumeDiv && volumeElement) { - volumeElement.textContent = isError ? 'N/A' : `${utils.formatNumber(volume24h, 0)} USD`; - volumeDiv.style.display = volumeToggle.isVisible ? 'flex' : 'none'; + volumeDiv.style.display = volumeToggle.isVisible ? 'flex' : 'none'; + } + + if (btcPriceDiv && priceBtcElement) { + if (coin === 'BTC') { + btcPriceDiv.style.display = 'none'; + } else { + priceBtcElement.textContent = isError ? 'N/A' : `${priceBTC.toFixed(8)}`; + btcPriceDiv.style.display = 'flex'; } - if (btcPriceDiv && priceBtcElement) { - if (coin === 'BTC') { - btcPriceDiv.style.display = 'none'; - } else { - priceBtcElement.textContent = isError ? 'N/A' : `${priceBTC.toFixed(8)}`; - btcPriceDiv.style.display = 'flex'; - } - } - ui.updatePriceChangeContainer(coin, isError ? null : priceChange1d); + } + + ui.updatePriceChangeContainer(coin, isError ? null : priceChange1d); }; + try { - if (data.error) { - throw new Error(data.error); - } - if (!data || !data.current_price) { - throw new Error(`Invalid CoinGecko data structure for ${coin}`); - } - priceUSD = data.current_price; - priceBTC = coin === 'BTC' ? 1 : data.price_btc || (data.current_price / app.btcPriceUSD); - priceChange1d = data.price_change_percentage_24h; - volume24h = data.total_volume; - if (isNaN(priceUSD) || isNaN(priceBTC) || isNaN(volume24h)) { - throw new Error(`Invalid numeric values in data for ${coin}`); - } - updateUI(false); + if (data.error) { + throw new Error(data.error); + } + + if (!data || !data.current_price) { + throw new Error(`Invalid data structure for ${coin}`); + } + + priceUSD = data.current_price; + priceBTC = coin === 'BTC' ? 1 : data.price_btc || (data.current_price / app.btcPriceUSD); + priceChange1d = data.price_change_percentage_24h || 0; + volume24h = data.total_volume || 0; + + if (isNaN(priceUSD) || isNaN(priceBTC)) { + throw new Error(`Invalid numeric values in data for ${coin}`); + } + + updateUI(false); } catch (error) { - logger.error(`Failed to display data for ${coin}:`, error.message); - updateUI(true); // Show error state in UI -} -}, + logger.error(`Failed to display data for ${coin}:`, error.message); + updateUI(true); + } + }, showLoader: () => { const loader = document.getElementById('loader'); @@ -762,9 +487,13 @@ displayCoinData: (coin, data) => { updatePriceChangeContainer: (coin, priceChange) => { const container = document.querySelector(`#${coin.toLowerCase()}-price-change-container`); if (container) { - container.innerHTML = priceChange !== null ? - (priceChange >= 0 ? ui.positivePriceChangeHTML(priceChange) : ui.negativePriceChangeHTML(priceChange)) : - 'N/A'; + if (priceChange === null || priceChange === undefined) { + container.innerHTML = 'N/A'; + } else { + container.innerHTML = priceChange >= 0 ? + ui.positivePriceChangeHTML(priceChange) : + ui.negativePriceChangeHTML(priceChange); + } } }, @@ -775,6 +504,16 @@ displayCoinData: (coin, data) => { lastRefreshedElement.textContent = `Last Refreshed: ${formattedTime}`; } }, + + updateConnectionStatus: () => { + const statusElement = document.getElementById('connection-status'); + if (statusElement) { + const online = NetworkManager.isOnline(); + statusElement.textContent = online ? 'Connected' : 'Disconnected'; + statusElement.classList.toggle('text-green-500', online); + statusElement.classList.toggle('text-red-500', !online); + } + }, positivePriceChangeHTML: (value) => ` <div class="flex flex-wrap items-center py-px px-1 border border-green-500 rounded-full"> @@ -819,7 +558,7 @@ displayCoinData: (coin, data) => { }); }, - displayErrorMessage: (message) => { + displayErrorMessage: (message, duration = 0) => { const errorOverlay = document.getElementById('error-overlay'); const errorMessage = document.getElementById('error-message'); const chartContainer = document.querySelector('.container-to-blur'); @@ -827,6 +566,12 @@ displayCoinData: (coin, data) => { errorOverlay.classList.remove('hidden'); errorMessage.textContent = message; chartContainer.classList.add('blurred'); + + if (duration > 0) { + setTimeout(() => { + ui.hideErrorMessage(); + }, duration); + } } }, @@ -837,16 +582,42 @@ displayCoinData: (coin, data) => { errorOverlay.classList.add('hidden'); containersToBlur.forEach(container => container.classList.remove('blurred')); } + }, + + showNetworkErrorMessage: () => { + ui.displayErrorMessage( + "Network connection lost. Data shown may be outdated. We'll automatically refresh once connection is restored.", + 0 + ); + + const errorOverlay = document.getElementById('error-overlay'); + if (errorOverlay) { + const reconnectBtn = document.createElement('button'); + reconnectBtn.className = "mt-4 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"; + reconnectBtn.textContent = "Try to Reconnect"; + reconnectBtn.onclick = () => { + NetworkManager.manualReconnect(); + }; + + const buttonContainer = errorOverlay.querySelector('.button-container') || + document.createElement('div'); + buttonContainer.className = "button-container mt-4"; + buttonContainer.innerHTML = ''; + buttonContainer.appendChild(reconnectBtn); + + if (!errorOverlay.querySelector('.button-container')) { + errorOverlay.querySelector('div').appendChild(buttonContainer); + } + } } }; -// CHART const chartModule = { chart: null, currentCoin: 'BTC', loadStartTime: 0, chartRefs: new WeakMap(), - + verticalLinePlugin: { id: 'verticalLine', beforeDraw: (chart, args, options) => { @@ -879,29 +650,54 @@ const chartModule = { destroyChart: function() { if (chartModule.chart) { try { + const canvas = document.getElementById('coin-chart'); + if (canvas) { + const events = ['click', 'mousemove', 'mouseout', 'mouseover', 'mousedown', 'mouseup']; + events.forEach(eventType => { + canvas.removeEventListener(eventType, null); + }); + } + chartModule.chart.destroy(); + chartModule.chart = null; + + if (canvas) { + chartModule.chartRefs.delete(canvas); + } } catch (e) { - console.error('Error destroying chart:', e); + try { + if (chartModule.chart) { + if (chartModule.chart.destroy && typeof chartModule.chart.destroy === 'function') { + chartModule.chart.destroy(); + } + chartModule.chart = null; + } + } catch (finalError) {} } - chartModule.chart = null; } }, initChart: function() { this.destroyChart(); - + const canvas = document.getElementById('coin-chart'); if (!canvas) { - logger.error('Chart canvas element not found'); + console.error('Chart canvas element not found'); return; } - + + canvas.style.display = 'block'; + if (canvas.style.width === '1px' || canvas.style.height === '1px') { + canvas.style.width = '100%'; + canvas.style.height = '100%'; + } + const ctx = canvas.getContext('2d'); if (!ctx) { - logger.error('Failed to get chart context. Make sure the canvas element exists.'); + console.error('Failed to get chart context. Make sure the canvas element exists.'); return; } - + const gradient = ctx.createLinearGradient(0, 0, 0, 400); gradient.addColorStop(0, 'rgba(77, 132, 240, 0.2)'); gradient.addColorStop(1, 'rgba(77, 132, 240, 0)'); @@ -923,6 +719,9 @@ const chartModule = { options: { responsive: true, maintainAspectRatio: false, + animation: { + duration: 750 + }, interaction: { intersect: false, mode: 'index' @@ -945,7 +744,7 @@ const chartModule = { } }, ticks: { - source: 'data', + source: 'auto', maxTicksLimit: 12, font: { size: 12, @@ -956,14 +755,14 @@ const chartModule = { minRotation: 0, callback: function(value) { const date = new Date(value); - if (config.currentResolution === 'day') { + if (window.config.currentResolution === 'day') { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'UTC' }); - } else if (config.currentResolution === 'year') { + } else if (window.config.currentResolution === 'year') { return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric', @@ -1015,7 +814,7 @@ const chartModule = { callbacks: { title: (tooltipItems) => { const date = new Date(tooltipItems[0].parsed.x); - if (config.currentResolution === 'day') { + if (window.config.currentResolution === 'day') { return date.toLocaleString('en-US', { month: 'short', day: 'numeric', @@ -1024,7 +823,7 @@ const chartModule = { hour12: true, timeZone: 'UTC' }); - } else if (config.currentResolution === 'year') { + } else if (window.config.currentResolution === 'year') { return date.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -1058,6 +857,12 @@ const chartModule = { }); this.setChartReference(canvas, chartModule.chart); + + if (window.CleanupManager) { + window.CleanupManager.registerResource('chart', chartModule.chart, () => { + chartModule.destroyChart(); + }); + } }, prepareChartData: function(coinSymbol, data) { @@ -1066,68 +871,85 @@ const chartModule = { } try { - let preparedData; - - if (coinSymbol === 'WOW' && Array.isArray(data)) { - const endTime = new Date(data[data.length - 1][0]); - endTime.setUTCMinutes(0, 0, 0); - const endUnix = endTime.getTime(); - const startUnix = endUnix - (24 * 3600000); - const hourlyPoints = []; - - for (let hourUnix = startUnix; hourUnix <= endUnix; hourUnix += 3600000) { - const targetHour = new Date(hourUnix); - targetHour.setUTCMinutes(0, 0, 0); - - const closestPoint = data.reduce((prev, curr) => { - const prevTime = new Date(prev[0]); - const currTime = new Date(curr[0]); - const prevDiff = Math.abs(prevTime - targetHour); - const currDiff = Math.abs(currTime - targetHour); - return currDiff < prevDiff ? curr : prev; - }); - - hourlyPoints.push({ - x: targetHour, - y: closestPoint[1] - }); - } - - const lastTime = new Date(data[data.length - 1][0]); - if (lastTime.getUTCMinutes() !== 0) { - hourlyPoints.push({ - x: lastTime, - y: data[data.length - 1][1] - }); - } - - preparedData = hourlyPoints; + let rawDataPoints = []; + if (Array.isArray(data)) { + rawDataPoints = data.map(([timestamp, price]) => ({ + time: new Date(timestamp).getTime(), + close: price + })); } else if (data.Data && Array.isArray(data.Data)) { - preparedData = data.Data.map(d => ({ - x: new Date(d.time * 1000), - y: d.close + rawDataPoints = data.Data.map(d => ({ + time: d.time * 1000, + close: d.close })); } else if (data.Data && data.Data.Data && Array.isArray(data.Data.Data)) { - preparedData = data.Data.Data.map(d => ({ - x: new Date(d.time * 1000), - y: d.close - })); - } else if (Array.isArray(data)) { - preparedData = data.map(([timestamp, price]) => ({ - x: new Date(timestamp), - y: price + rawDataPoints = data.Data.Data.map(d => ({ + time: d.time * 1000, + close: d.close })); } else { - console.warn('Unknown data format for chartData:', data); return []; } - return preparedData.map(point => ({ - x: new Date(point.x).getTime(), - y: point.y - })); + + if (rawDataPoints.length === 0) { + return []; + } + + rawDataPoints.sort((a, b) => a.time - b.time); + + let preparedData = []; + + if (window.config.currentResolution === 'day') { + const endTime = new Date(rawDataPoints[rawDataPoints.length - 1].time); + endTime.setUTCMinutes(0, 0, 0); + + const endUnix = endTime.getTime(); + const startUnix = endUnix - (24 * 3600000); + + for (let hourUnix = startUnix; hourUnix <= endUnix; hourUnix += 3600000) { + let closestPoint = null; + let closestDiff = Infinity; + + for (const point of rawDataPoints) { + const diff = Math.abs(point.time - hourUnix); + if (diff < closestDiff) { + closestDiff = diff; + closestPoint = point; + } + } + + if (closestPoint) { + preparedData.push({ + x: hourUnix, + y: closestPoint.close + }); + } + } + + const lastTime = rawDataPoints[rawDataPoints.length - 1].time; + if (lastTime > endUnix) { + preparedData.push({ + x: lastTime, + y: rawDataPoints[rawDataPoints.length - 1].close + }); + } + } else { + preparedData = rawDataPoints.map(point => ({ + x: point.time, + y: point.close + })); + } + + if (preparedData.length === 0 && rawDataPoints.length > 0) { + preparedData = rawDataPoints.map(point => ({ + x: point.time, + y: point.close + })); + } + + return preparedData; } catch (error) { - console.error(`Error preparing chart data for ${coinSymbol}:`, error); return []; } }, @@ -1142,10 +964,10 @@ const chartModule = { const targetTime = new Date(twentyFourHoursAgo.getTime() + i * 60 * 60 * 1000); if (data.length > 0) { - const closestDataPoint = data.reduce((prev, curr) => - Math.abs(new Date(curr.x).getTime() - targetTime.getTime()) < - Math.abs(new Date(prev.x).getTime() - targetTime.getTime()) ? curr : prev -); + const closestDataPoint = data.reduce((prev, curr) => + Math.abs(new Date(curr.x).getTime() - targetTime.getTime()) < + Math.abs(new Date(prev.x).getTime() - targetTime.getTime()) ? curr : prev + , data[0]); hourlyData.push({ x: targetTime.getTime(), y: closestDataPoint.y @@ -1166,13 +988,17 @@ const chartModule = { chartModule.showChartLoader(); } chartModule.loadStartTime = Date.now(); - const cacheKey = `chartData_${coinSymbol}_${config.currentResolution}`; - let cachedData = !forceRefresh ? cache.get(cacheKey) : null; + const cacheKey = `chartData_${coinSymbol}_${window.config.currentResolution}`; + let cachedData = !forceRefresh ? CacheManager.get(cacheKey) : null; let data; if (cachedData && Object.keys(cachedData.value).length > 0) { data = cachedData.value; } else { try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + const allData = await api.fetchHistoricalDataXHR([coinSymbol]); data = allData[coinSymbol]; @@ -1180,8 +1006,10 @@ const chartModule = { throw new Error(`No data returned for ${coinSymbol}`); } - cache.set(cacheKey, data, config.cacheTTL); + CacheManager.set(cacheKey, data, 'chart'); } catch (error) { + NetworkManager.handleNetworkError(error); + if (error.message.includes('429') && currentChartData.length > 0) { console.warn(`Rate limit hit for ${coinSymbol}, maintaining current chart`); chartModule.hideChartLoader(); @@ -1212,10 +1040,10 @@ const chartModule = { if (coinSymbol === 'WOW') { chartModule.chart.options.scales.x.time.unit = 'hour'; } else { - const resolution = config.resolutions[config.currentResolution]; + const resolution = window.config.chartConfig.resolutions[window.config.currentResolution]; chartModule.chart.options.scales.x.time.unit = - resolution.interval === 'hourly' ? 'hour' : - config.currentResolution === 'year' ? 'month' : 'day'; + resolution && resolution.interval === 'hourly' ? 'hour' : + window.config.currentResolution === 'year' ? 'month' : 'day'; } chartModule.chart.update('active'); chartModule.currentCoin = coinSymbol; @@ -1224,8 +1052,7 @@ const chartModule = { } } catch (error) { console.error(`Error updating chart for ${coinSymbol}:`, error); - - // Keep existing chart data if possible /todo + if (!(chartModule.chart?.data?.datasets[0]?.data?.length > 0)) { if (!chartModule.chart) { chartModule.initChart(); @@ -1259,11 +1086,12 @@ const chartModule = { loader.classList.add('hidden'); chart.classList.remove('hidden'); }, + cleanup: function() { this.destroyChart(); this.currentCoin = null; this.loadStartTime = 0; - console.log('Chart module cleaned up'); + this.chartRefs = new WeakMap(); } }; @@ -1274,8 +1102,8 @@ const volumeToggle = { init: function() { const toggleButton = document.getElementById('toggle-volume'); if (toggleButton) { - if (typeof cleanupManager !== 'undefined') { - cleanupManager.addListener(toggleButton, 'click', volumeToggle.toggle); + if (typeof CleanupManager !== 'undefined') { + CleanupManager.addListener(toggleButton, 'click', volumeToggle.toggle); } else { toggleButton.addEventListener('click', volumeToggle.toggle); } @@ -1311,12 +1139,12 @@ const volumeToggle = { } }; - function updateButtonStyles(button, isActive, color) { - button.classList.toggle('text-' + color + '-500', isActive); - button.classList.toggle('text-gray-600', !isActive); - button.classList.toggle('dark:text-' + color + '-400', isActive); - button.classList.toggle('dark:text-gray-400', !isActive); - } +function updateButtonStyles(button, isActive, color) { + button.classList.toggle('text-' + color + '-500', isActive); + button.classList.toggle('text-gray-600', !isActive); + button.classList.toggle('dark:text-' + color + '-400', isActive); + button.classList.toggle('dark:text-gray-400', !isActive); +} const app = { btcPriceUSD: 0, @@ -1330,92 +1158,122 @@ const app = { disabled: 'Auto-refresh: disabled', justRefreshed: 'Just refreshed', }, - cacheTTL: 5 * 60 * 1000, // 5 min - minimumRefreshInterval: 60 * 1000, // 1 min + cacheTTL: window.config.cacheConfig.ttlSettings.prices, + minimumRefreshInterval: 60 * 1000, - init: () => { - console.log('Init'); + init: function() { window.addEventListener('load', app.onLoad); app.loadLastRefreshedTime(); app.updateAutoRefreshButton(); - //console.log('App initialized'); + + NetworkManager.addHandler('offline', () => { + ui.showNetworkErrorMessage(); + }); + + NetworkManager.addHandler('reconnected', () => { + ui.hideErrorMessage(); + app.refreshAllData(); + }); + + NetworkManager.addHandler('maxAttemptsReached', () => { + ui.displayErrorMessage( + "Server connection lost. Please check your internet connection and try refreshing the page.", + 0 + ); + }); + + return app; }, - onLoad: async () => { - //console.log('App onLoad event triggered'); + onLoad: async function() { ui.showLoader(); try { - volumeToggle.init(); - await app.updateBTCPrice(); - const chartContainer = document.getElementById('coin-chart'); - if (chartContainer) { - chartModule.initChart(); - chartModule.showChartLoader(); + volumeToggle.init(); + await app.updateBTCPrice(); + const chartContainer = document.getElementById('coin-chart'); + if (chartContainer) { + chartModule.initChart(); + chartModule.showChartLoader(); + } + + await app.loadAllCoinData(); + + if (chartModule.chart) { + window.config.currentResolution = 'day'; + await chartModule.updateChart('BTC'); + app.updateResolutionButtons('BTC'); + + const chartTitle = document.getElementById('chart-title'); + if (chartTitle) { + chartTitle.textContent = 'Price Chart (BTC)'; } + } + ui.setActiveContainer('btc-container'); - //console.log('Loading all coin data...'); - await app.loadAllCoinData(); - - if (chartModule.chart) { - config.currentResolution = 'day'; - await chartModule.updateChart('BTC'); - app.updateResolutionButtons('BTC'); - - const chartTitle = document.getElementById('chart-title'); - if (chartTitle) { - chartTitle.textContent = 'Price Chart (BTC)'; - } - } - ui.setActiveContainer('btc-container'); - - app.setupEventListeners(); - app.initializeSelectImages(); - app.initAutoRefresh(); + app.setupEventListeners(); + app.initAutoRefresh(); } catch (error) { - ui.displayErrorMessage('Failed to initialize the dashboard. Please try refreshing the page.'); + ui.displayErrorMessage('Failed to initialize the dashboard. Please try refreshing the page.'); + NetworkManager.handleNetworkError(error); } finally { - ui.hideLoader(); - if (chartModule.chart) { - chartModule.hideChartLoader(); - } - //console.log('App onLoad completed'); + ui.hideLoader(); + if (chartModule.chart) { + chartModule.hideChartLoader(); + } } -}, - loadAllCoinData: async () => { - //console.log('Loading data for all coins...'); - try { - const allCoinData = await api.fetchCoinGeckoDataXHR(); - if (allCoinData.error) { - throw new Error(allCoinData.error); - } + }, + + loadAllCoinData: async function() { + try { + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + + const allCoinData = await api.fetchCoinGeckoDataXHR(); + if (allCoinData.error) { + throw new Error(allCoinData.error); + } - for (const coin of config.coins) { - const coinData = allCoinData[coin.symbol.toLowerCase()]; - if (coinData) { - coinData.displayName = coin.displayName || coin.symbol; - ui.displayCoinData(coin.symbol, coinData); - const cacheKey = `coinData_${coin.symbol}`; - cache.set(cacheKey, coinData); - } else { - //console.warn(`No data found for ${coin.symbol}`); - } + let volumeData = {}; + try { + volumeData = await api.fetchVolumeDataXHR(); + } catch (volumeError) {} + + for (const coin of window.config.coins) { + const coinData = allCoinData[coin.symbol.toLowerCase()]; + + if (coinData) { + coinData.displayName = coin.displayName || coin.symbol; + + const backendId = getCoinBackendId ? getCoinBackendId(coin.name) : coin.name; + if (volumeData[backendId]) { + coinData.total_volume = volumeData[backendId].total_volume; + if (!coinData.price_change_percentage_24h && volumeData[backendId].price_change_percentage_24h) { + coinData.price_change_percentage_24h = volumeData[backendId].price_change_percentage_24h; } - } catch (error) { - //console.error('Error loading all coin data:', error); - ui.displayErrorMessage('Failed to load coin data. Please try refreshing the page.'); - } finally { - //console.log('All coin data loaded'); + } + + ui.displayCoinData(coin.symbol, coinData); + + const cacheKey = `coinData_${coin.symbol}`; + CacheManager.set(cacheKey, coinData); + } else { + console.warn(`No data found for ${coin.symbol}`); } - }, + } + } catch (error) { + console.error('Error loading all coin data:', error); + NetworkManager.handleNetworkError(error); + ui.displayErrorMessage('Failed to load coin data. Please try refreshing the page.'); + } + }, - loadCoinData: async (coin) => { - //console.log(`Loading data for ${coin.symbol}...`); + loadCoinData: async function(coin) { const cacheKey = `coinData_${coin.symbol}`; - let cachedData = cache.get(cacheKey); + let cachedData = CacheManager.get(cacheKey); let data; if (cachedData) { - //console.log(`Using cached data for ${coin.symbol}`); data = cachedData.value; } else { try { @@ -1428,11 +1286,10 @@ const app = { if (data.error) { throw new Error(data.error); } - //console.log(`Caching new data for ${coin.symbol}`); - cache.set(cacheKey, data); + CacheManager.set(cacheKey, data, 'prices'); cachedData = null; } catch (error) { - //console.error(`Error fetching ${coin.symbol} data:`, error.message); + NetworkManager.handleNetworkError(error); data = { error: error.message }; @@ -1442,49 +1299,51 @@ const app = { } ui.displayCoinData(coin.symbol, data); ui.updateLoadTimeAndCache(0, cachedData); - //console.log(`Data loaded for ${coin.symbol}`); }, -setupEventListeners: () => { - config.coins.forEach(coin => { - const container = document.getElementById(`${coin.symbol.toLowerCase()}-container`); - if (container) { - container.addEventListener('click', () => { - const chartTitle = document.getElementById('chart-title'); - if (chartTitle) { - chartTitle.textContent = `Price Chart (${coin.symbol})`; - } - ui.setActiveContainer(`${coin.symbol.toLowerCase()}-container`); - if (chartModule.chart) { - if (coin.symbol === 'WOW') { - config.currentResolution = 'day'; - } - chartModule.updateChart(coin.symbol); - app.updateResolutionButtons(coin.symbol); - } - }); - } + setupEventListeners: function() { + window.config.coins.forEach(coin => { + const container = document.getElementById(`${coin.symbol.toLowerCase()}-container`); + if (container) { + CleanupManager.addListener(container, 'click', () => { + const chartTitle = document.getElementById('chart-title'); + if (chartTitle) { + chartTitle.textContent = `Price Chart (${coin.symbol})`; + } + ui.setActiveContainer(`${coin.symbol.toLowerCase()}-container`); + if (chartModule.chart) { + if (coin.symbol === 'WOW') { + window.config.currentResolution = 'day'; + } + chartModule.updateChart(coin.symbol); + app.updateResolutionButtons(coin.symbol); + } + }); + } }); const refreshAllButton = document.getElementById('refresh-all'); if (refreshAllButton) { - refreshAllButton.addEventListener('click', app.refreshAllData); + CleanupManager.addListener(refreshAllButton, 'click', app.refreshAllData); } const headers = document.querySelectorAll('th'); headers.forEach((header, index) => { - header.addEventListener('click', () => app.sortTable(index, header.classList.contains('disabled'))); + CleanupManager.addListener(header, 'click', () => app.sortTable(index, header.classList.contains('disabled'))); }); const closeErrorButton = document.getElementById('close-error'); if (closeErrorButton) { - closeErrorButton.addEventListener('click', ui.hideErrorMessage); + CleanupManager.addListener(closeErrorButton, 'click', ui.hideErrorMessage); + } + + const reconnectButton = document.getElementById('network-reconnect'); + if (reconnectButton) { + CleanupManager.addListener(reconnectButton, 'click', NetworkManager.manualReconnect); } - //console.log('Event listeners set up'); }, - initAutoRefresh: () => { - //console.log('Initializing auto-refresh...'); + initAutoRefresh: function() { const toggleAutoRefreshButton = document.getElementById('toggle-auto-refresh'); if (toggleAutoRefreshButton) { toggleAutoRefreshButton.addEventListener('click', app.toggleAutoRefresh); @@ -1492,15 +1351,11 @@ setupEventListeners: () => { } if (app.isAutoRefreshEnabled) { - console.log('Auto-refresh is enabled, scheduling next refresh'); app.scheduleNextRefresh(); - } else { - console.log('Auto-refresh is disabled'); } }, - scheduleNextRefresh: () => { - //console.log('Scheduling next refresh...'); + scheduleNextRefresh: function() { if (app.autoRefreshInterval) { clearTimeout(app.autoRefreshInterval); } @@ -1516,7 +1371,6 @@ setupEventListeners: () => { earliestExpiration = Math.min(earliestExpiration, cachedItem.expiresAt); } } catch (error) { - //console.error(`Error parsing cached item ${key}:`, error); localStorage.removeItem(key); } } @@ -1526,22 +1380,30 @@ setupEventListeners: () => { if (earliestExpiration !== Infinity) { nextRefreshTime = Math.max(earliestExpiration, now + app.minimumRefreshInterval); } else { - nextRefreshTime = now + config.cacheTTL; + nextRefreshTime = now + window.config.cacheTTL; } const timeUntilRefresh = nextRefreshTime - now; - console.log(`Next refresh scheduled in ${timeUntilRefresh / 1000} seconds`); app.nextRefreshTime = nextRefreshTime; app.autoRefreshInterval = setTimeout(() => { - console.log('Auto-refresh triggered'); - app.refreshAllData(); + if (NetworkManager.isOnline()) { + app.refreshAllData(); + } else { + app.scheduleNextRefresh(); + } }, timeUntilRefresh); localStorage.setItem('nextRefreshTime', app.nextRefreshTime.toString()); app.updateNextRefreshTime(); }, - refreshAllData: async () => { + + refreshAllData: async function() { if (app.isRefreshing) { - console.log('Refresh already in progress, skipping...'); - return; + console.log('Refresh already in progress, skipping...'); + return; + } + + if (!NetworkManager.isOnline()) { + ui.displayErrorMessage("Network connection unavailable. Please check your connection."); + return; } const lastGeckoRequest = rateLimiter.lastRequestTime['coingecko'] || 0; @@ -1549,131 +1411,159 @@ setupEventListeners: () => { const waitTime = Math.max(0, rateLimiter.minRequestInterval.coingecko - timeSinceLastRequest); if (waitTime > 0) { - const seconds = Math.ceil(waitTime / 1000); - ui.displayErrorMessage(`Rate limit: Please wait ${seconds} seconds before refreshing`); + const seconds = Math.ceil(waitTime / 1000); + ui.displayErrorMessage(`Rate limit: Please wait ${seconds} seconds before refreshing`); - let remainingTime = seconds; - const countdownInterval = setInterval(() => { - remainingTime--; - if (remainingTime > 0) { - ui.displayErrorMessage(`Rate limit: Please wait ${remainingTime} seconds before refreshing`); - } else { - clearInterval(countdownInterval); - ui.hideErrorMessage(); - } - }, 1000); + let remainingTime = seconds; + const countdownInterval = setInterval(() => { + remainingTime--; + if (remainingTime > 0) { + ui.displayErrorMessage(`Rate limit: Please wait ${remainingTime} seconds before refreshing`); + } else { + clearInterval(countdownInterval); + ui.hideErrorMessage(); + } + }, 1000); - return; + return; } - //console.log('Starting refresh of all data...'); + console.log('Starting refresh of all data...'); app.isRefreshing = true; ui.showLoader(); chartModule.showChartLoader(); try { - ui.hideErrorMessage(); - cache.clear(); + ui.hideErrorMessage(); + CacheManager.clear(); - const btcUpdateSuccess = await app.updateBTCPrice(); - if (!btcUpdateSuccess) { - console.warn('BTC price update failed, continuing with cached or default value'); - } + const btcUpdateSuccess = await app.updateBTCPrice(); + if (!btcUpdateSuccess) { + console.warn('BTC price update failed, continuing with cached or default value'); + } - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 1000)); - const allCoinData = await api.fetchCoinGeckoDataXHR(); - if (allCoinData.error) { - throw new Error(`CoinGecko API Error: ${allCoinData.error}`); - } + const allCoinData = await api.fetchCoinGeckoDataXHR(); + if (allCoinData.error) { + throw new Error(`CoinGecko API Error: ${allCoinData.error}`); + } - const failedCoins = []; + let volumeData = {}; + try { + volumeData = await api.fetchVolumeDataXHR(); + } catch (volumeError) {} - for (const coin of config.coins) { - const symbol = coin.symbol.toLowerCase(); - const coinData = allCoinData[symbol]; + const failedCoins = []; - try { - if (!coinData) { - throw new Error(`No data received`); - } + for (const coin of window.config.coins) { + const symbol = coin.symbol.toLowerCase(); + const coinData = allCoinData[symbol]; - coinData.displayName = coin.displayName || coin.symbol; - ui.displayCoinData(coin.symbol, coinData); + try { + if (!coinData) { + throw new Error(`No data received`); + } - const cacheKey = `coinData_${coin.symbol}`; - cache.set(cacheKey, coinData); + coinData.displayName = coin.displayName || coin.symbol; - } catch (coinError) { - console.warn(`Failed to update ${coin.symbol}: ${coinError.message}`); - failedCoins.push(coin.symbol); + const backendId = getCoinBackendId ? getCoinBackendId(coin.name) : coin.name; + if (volumeData[backendId]) { + coinData.total_volume = volumeData[backendId].total_volume; + if (!coinData.price_change_percentage_24h && volumeData[backendId].price_change_percentage_24h) { + coinData.price_change_percentage_24h = volumeData[backendId].price_change_percentage_24h; } - } - - await new Promise(resolve => setTimeout(resolve, 1000)); - - if (chartModule.currentCoin) { + } else { try { - await chartModule.updateChart(chartModule.currentCoin, true); - } catch (chartError) { - console.error('Chart update failed:', chartError); + const cacheKey = `coinData_${coin.symbol}`; + const cachedData = CacheManager.get(cacheKey); + if (cachedData && cachedData.value && cachedData.value.total_volume) { + coinData.total_volume = cachedData.value.total_volume; + } + if (cachedData && cachedData.value && cachedData.value.price_change_percentage_24h && + !coinData.price_change_percentage_24h) { + coinData.price_change_percentage_24h = cachedData.value.price_change_percentage_24h; + } + } catch (e) { + console.warn(`Failed to retrieve cached volume data for ${coin.symbol}:`, e); } + } + + ui.displayCoinData(coin.symbol, coinData); + + const cacheKey = `coinData_${coin.symbol}`; + CacheManager.set(cacheKey, coinData, 'prices'); + + } catch (coinError) { + console.warn(`Failed to update ${coin.symbol}: ${coinError.message}`); + failedCoins.push(coin.symbol); } + } - app.lastRefreshedTime = new Date(); - localStorage.setItem('lastRefreshedTime', app.lastRefreshedTime.getTime().toString()); - ui.updateLastRefreshedTime(); + await new Promise(resolve => setTimeout(resolve, 1000)); - if (failedCoins.length > 0) { - const failureMessage = failedCoins.length === config.coins.length - ? 'Failed to update any coin data' - : `Failed to update some coins: ${failedCoins.join(', ')}`; + if (chartModule.currentCoin) { + try { + await chartModule.updateChart(chartModule.currentCoin, true); + } catch (chartError) { + console.error('Chart update failed:', chartError); + } + } - let countdown = 5; + app.lastRefreshedTime = new Date(); + localStorage.setItem('lastRefreshedTime', app.lastRefreshedTime.getTime().toString()); + ui.updateLastRefreshedTime(); + + if (failedCoins.length > 0) { + const failureMessage = failedCoins.length === window.config.coins.length + ? 'Failed to update any coin data' + : `Failed to update some coins: ${failedCoins.join(', ')}`; + + let countdown = 5; + ui.displayErrorMessage(`${failureMessage} (${countdown}s)`); + + const countdownInterval = setInterval(() => { + countdown--; + if (countdown > 0) { ui.displayErrorMessage(`${failureMessage} (${countdown}s)`); + } else { + clearInterval(countdownInterval); + ui.hideErrorMessage(); + } + }, 1000); + } - const countdownInterval = setInterval(() => { - countdown--; - if (countdown > 0) { - ui.displayErrorMessage(`${failureMessage} (${countdown}s)`); - } else { - clearInterval(countdownInterval); - ui.hideErrorMessage(); - } - }, 1000); - } - - console.log(`Refresh completed. Failed coins: ${failedCoins.length}`); + console.log(`Refresh completed. Failed coins: ${failedCoins.length}`); } catch (error) { - console.error('Critical error during refresh:', error); + console.error('Critical error during refresh:', error); + NetworkManager.handleNetworkError(error); - let countdown = 10; - ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); - - const countdownInterval = setInterval(() => { - countdown--; - if (countdown > 0) { - ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); - } else { - clearInterval(countdownInterval); - ui.hideErrorMessage(); - } - }, 1000); + let countdown = 10; + ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); + + const countdownInterval = setInterval(() => { + countdown--; + if (countdown > 0) { + ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); + } else { + clearInterval(countdownInterval); + ui.hideErrorMessage(); + } + }, 1000); } finally { - ui.hideLoader(); - chartModule.hideChartLoader(); - app.isRefreshing = false; + ui.hideLoader(); + chartModule.hideChartLoader(); + app.isRefreshing = false; - if (app.isAutoRefreshEnabled) { - app.scheduleNextRefresh(); - } + if (app.isAutoRefreshEnabled) { + app.scheduleNextRefresh(); + } } -}, + }, - updateNextRefreshTime: () => { - //console.log('Updating next refresh time display'); + updateNextRefreshTime: function() { const nextRefreshSpan = document.getElementById('next-refresh-time'); const labelElement = document.getElementById('next-refresh-label'); const valueElement = document.getElementById('next-refresh-value'); @@ -1708,8 +1598,7 @@ setupEventListeners: () => { } }, - updateAutoRefreshButton: () => { - //console.log('Updating auto-refresh button state'); + updateAutoRefreshButton: function() { const button = document.getElementById('toggle-auto-refresh'); if (button) { if (app.isAutoRefreshEnabled) { @@ -1725,8 +1614,7 @@ setupEventListeners: () => { } }, - startSpinAnimation: () => { - //console.log('Starting spin animation on auto-refresh button'); + startSpinAnimation: function() { const svg = document.querySelector('#toggle-auto-refresh svg'); if (svg) { svg.classList.add('animate-spin'); @@ -1736,16 +1624,14 @@ setupEventListeners: () => { } }, - stopSpinAnimation: () => { - //console.log('Stopping spin animation on auto-refresh button'); + stopSpinAnimation: function() { const svg = document.querySelector('#toggle-auto-refresh svg'); if (svg) { svg.classList.remove('animate-spin'); } }, - updateLastRefreshedTime: () => { - //console.log('Updating last refreshed time'); + updateLastRefreshedTime: function() { const lastRefreshedElement = document.getElementById('last-refreshed-time'); if (lastRefreshedElement && app.lastRefreshedTime) { const formattedTime = app.lastRefreshedTime.toLocaleTimeString(); @@ -1753,8 +1639,7 @@ setupEventListeners: () => { } }, - loadLastRefreshedTime: () => { - //console.log('Loading last refreshed time from storage'); + loadLastRefreshedTime: function() { const storedTime = localStorage.getItem('lastRefreshedTime'); if (storedTime) { app.lastRefreshedTime = new Date(parseInt(storedTime)); @@ -1762,199 +1647,57 @@ setupEventListeners: () => { } }, -updateBTCPrice: async () => { - //console.log('Updating BTC price...'); + updateBTCPrice: async function() { try { - const priceData = await api.fetchCoinGeckoDataXHR(); + if (!NetworkManager.isOnline()) { + throw new Error('Network is offline'); + } + + const response = await Api.fetchCoinPrices("bitcoin"); - if (priceData.error) { - console.warn('API error when fetching BTC price:', priceData.error); - return false; - } + if (response && response.rates && response.rates.bitcoin) { + app.btcPriceUSD = response.rates.bitcoin; + return true; + } - if (priceData.btc && typeof priceData.btc.current_price === 'number') { - app.btcPriceUSD = priceData.btc.current_price; - return true; - } else if (priceData.bitcoin && typeof priceData.bitcoin.usd === 'number') { - app.btcPriceUSD = priceData.bitcoin.usd; - return true; - } - - console.warn('Unexpected BTC price data structure:', priceData); - return false; + console.warn('Unexpected BTC price data structure:', response); + return false; } catch (error) { - console.error('Error fetching BTC price:', error); - return false; + console.error('Error fetching BTC price:', error); + NetworkManager.handleNetworkError(error); + return false; } -}, + }, -sortTable: (columnIndex) => { - //console.log(`Sorting column: ${columnIndex}`); - const sortableColumns = [0, 5, 6, 7]; // 0: Time, 5: Rate, 6: Market +/-, 7: Trade - if (!sortableColumns.includes(columnIndex)) { - //console.log(`Column ${columnIndex} is not sortable`); - return; - } - const table = document.querySelector('table'); - if (!table) { - //console.error("Table not found for sorting."); - return; - } - const rows = Array.from(table.querySelectorAll('tbody tr')); - console.log(`Found ${rows.length} rows to sort`); - const sortIcon = document.getElementById(`sort-icon-${columnIndex}`); - if (!sortIcon) { - //console.error("Sort icon not found."); - return; - } - const sortOrder = sortIcon.textContent === '↓' ? 1 : -1; - sortIcon.textContent = sortOrder === 1 ? '↑' : '↓'; - - const getSafeTextContent = (element) => element ? element.textContent.trim() : ''; - - rows.sort((a, b) => { - let aValue, bValue; - switch (columnIndex) { - case 1: // Time column - aValue = getSafeTextContent(a.querySelector('td:first-child .text-xs:first-child')); - bValue = getSafeTextContent(b.querySelector('td:first-child .text-xs:first-child')); - //console.log(`Comparing times: "${aValue}" vs "${bValue}"`); - - const parseTime = (timeStr) => { - const [value, unit] = timeStr.split(' '); - const numValue = parseFloat(value); - switch(unit) { - case 'seconds': return numValue; - case 'minutes': return numValue * 60; - case 'hours': return numValue * 3600; - case 'days': return numValue * 86400; - default: return 0; - } - }; - return (parseTime(bValue) - parseTime(aValue)) * sortOrder; - - case 5: // Rate - case 6: // Market +/- - aValue = getSafeTextContent(a.cells[columnIndex]); - bValue = getSafeTextContent(b.cells[columnIndex]); - //console.log(`Comparing values: "${aValue}" vs "${bValue}"`); - - aValue = parseFloat(aValue.replace(/[^\d.-]/g, '') || '0'); - bValue = parseFloat(bValue.replace(/[^\d.-]/g, '') || '0'); - return (aValue - bValue) * sortOrder; - - case 7: // Trade - const aCell = a.cells[columnIndex]; - const bCell = b.cells[columnIndex]; - //console.log('aCell:', aCell ? aCell.outerHTML : 'null'); - //console.log('bCell:', bCell ? bCell.outerHTML : 'null'); - - aValue = getSafeTextContent(aCell.querySelector('a')) || - getSafeTextContent(aCell.querySelector('button')) || - getSafeTextContent(aCell); - bValue = getSafeTextContent(bCell.querySelector('a')) || - getSafeTextContent(bCell.querySelector('button')) || - getSafeTextContent(bCell); - - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - - //console.log(`Comparing trade actions: "${aValue}" vs "${bValue}"`); - - if (aValue === bValue) return 0; - if (aValue === "swap") return -1 * sortOrder; - if (bValue === "swap") return 1 * sortOrder; - return aValue.localeCompare(bValue) * sortOrder; - - default: - aValue = getSafeTextContent(a.cells[columnIndex]); - bValue = getSafeTextContent(b.cells[columnIndex]); - //console.log(`Comparing default values: "${aValue}" vs "${bValue}"`); - return aValue.localeCompare(bValue, undefined, { - numeric: true, - sensitivity: 'base' - }) * sortOrder; - } - }); - - const tbody = table.querySelector('tbody'); - if (tbody) { - rows.forEach(row => tbody.appendChild(row)); - } else { - //console.error("Table body not found."); - } - //console.log('Sorting completed'); -}, - - initializeSelectImages: () => { - const updateSelectedImage = (selectId) => { - const select = document.getElementById(selectId); - const button = document.getElementById(`${selectId}_button`); - if (!select || !button) { - //console.error(`Elements not found for ${selectId}`); - return; - } - const selectedOption = select.options[select.selectedIndex]; - const imageURL = selectedOption?.getAttribute('data-image'); - requestAnimationFrame(() => { - if (imageURL) { - button.style.backgroundImage = `url('${imageURL}')`; - button.style.backgroundSize = '25px 25px'; - button.style.backgroundPosition = 'center'; - button.style.backgroundRepeat = 'no-repeat'; + updateResolutionButtons: function(coinSymbol) { + const resolutionButtons = document.querySelectorAll('.resolution-button'); + resolutionButtons.forEach(button => { + const resolution = button.id.split('-')[1]; + if (coinSymbol === 'WOW') { + if (resolution === 'day') { + button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); + button.classList.add('active'); + button.disabled = false; } else { - button.style.backgroundImage = 'none'; + button.classList.add('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); + button.classList.remove('active'); + button.disabled = true; } - button.style.minWidth = '25px'; - button.style.minHeight = '25px'; - }); - }; - const handleSelectChange = (event) => { - updateSelectedImage(event.target.id); - }; - ['coin_to', 'coin_from'].forEach(selectId => { - const select = document.getElementById(selectId); - if (select) { - select.addEventListener('change', handleSelectChange); - updateSelectedImage(selectId); } else { - //console.error(`Select element not found for ${selectId}`); + button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); + button.classList.toggle('active', resolution === window.config.currentResolution); + button.disabled = false; } }); }, -updateResolutionButtons: (coinSymbol) => { - const resolutionButtons = document.querySelectorAll('.resolution-button'); - resolutionButtons.forEach(button => { - const resolution = button.id.split('-')[1]; - if (coinSymbol === 'WOW') { - if (resolution === 'day') { - button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); - button.classList.add('active'); - button.disabled = false; - } else { - button.classList.add('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); - button.classList.remove('active'); - button.disabled = true; - } - } else { - button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); - button.classList.toggle('active', resolution === config.currentResolution); - button.disabled = false; - } - }); -}, - - toggleAutoRefresh: () => { - console.log('Toggling auto-refresh'); + toggleAutoRefresh: function() { app.isAutoRefreshEnabled = !app.isAutoRefreshEnabled; localStorage.setItem('autoRefreshEnabled', app.isAutoRefreshEnabled.toString()); if (app.isAutoRefreshEnabled) { - console.log('Auto-refresh enabled, scheduling next refresh'); app.scheduleNextRefresh(); } else { - console.log('Auto-refresh disabled, clearing interval'); if (app.autoRefreshInterval) { clearTimeout(app.autoRefreshInterval); app.autoRefreshInterval = null; @@ -1974,55 +1717,158 @@ resolutionButtons.forEach(button => { const currentCoin = chartModule.currentCoin; if (currentCoin !== 'WOW' || resolution === 'day') { - config.currentResolution = resolution; + window.config.currentResolution = resolution; chartModule.updateChart(currentCoin, true); app.updateResolutionButtons(currentCoin); } }); }); -// LOAD +function cleanup() { + console.log('Starting cleanup process'); + + try { + if (window.MemoryManager) { + MemoryManager.forceCleanup(); + } + + if (chartModule) { + CleanupManager.registerResource('chartModule', chartModule, (cm) => { + cm.cleanup(); + }); + } + + if (volumeToggle) { + CleanupManager.registerResource('volumeToggle', volumeToggle, (vt) => { + vt.cleanup(); + }); + } + + ['chartModule', 'volumeToggle', 'app'].forEach(ref => { + if (window[ref]) { + window[ref] = null; + } + }); + + const cleanupCounts = CleanupManager.clearAll(); + console.log('All resources cleaned up:', cleanupCounts); + + } catch (error) { + console.error('Error during cleanup:', error); + CleanupManager.clearAll(); + } +} + +window.cleanup = cleanup; + const appCleanup = { init: function() { - memoryMonitor.startMonitoring(); window.addEventListener('beforeunload', this.globalCleanup); }, globalCleanup: function() { try { + if (window.MemoryManager) { + MemoryManager.forceCleanup(); + } + if (app.autoRefreshInterval) { - clearTimeout(app.autoRefreshInterval); + CleanupManager.clearTimeout(app.autoRefreshInterval); } if (chartModule) { - chartModule.cleanup(); + CleanupManager.registerResource('chartModule', chartModule, (cm) => { + cm.cleanup(); + }); } if (volumeToggle) { - volumeToggle.cleanup(); + CleanupManager.registerResource('volumeToggle', volumeToggle, (vt) => { + vt.cleanup(); + }); } - cleanupManager.clearAll(); - memoryMonitor.stopMonitoring(); - cache.clear(); - - console.log('Global application cleanup completed'); - } catch (error) { - console.error('Error during global cleanup:', error); - } + CleanupManager.clearAll(); + CacheManager.clear(); + } catch (error) {} }, + manualCleanup: function() { this.globalCleanup(); window.location.reload(); } }; -app.init = () => { - //console.log('Init'); +document.addEventListener('DOMContentLoaded', () => { + if (window.NetworkManager && !window.networkManagerInitialized) { + NetworkManager.initialize({ + connectionTestEndpoint: '/json', + connectionTestTimeout: 3000, + reconnectDelay: 5000, + maxReconnectAttempts: 5 + }); + window.networkManagerInitialized = true; + } + + app.init(); + + if (window.MemoryManager) { + MemoryManager.enableAutoCleanup(); + } + + CleanupManager.setInterval(() => { + CacheManager.cleanup(); + }, 300000); // Every 5 minutes + + CleanupManager.setInterval(() => { + if (chartModule && chartModule.currentCoin && NetworkManager.isOnline()) { + chartModule.updateChart(chartModule.currentCoin); + } + }, 900000); // Every 15 minutes + + CleanupManager.addListener(document, 'visibilitychange', () => { + if (!document.hidden) { + console.log('Page is now visible'); + + if (NetworkManager.isOnline()) { + if (chartModule && chartModule.currentCoin) { + chartModule.updateChart(chartModule.currentCoin); + } + } else { + + NetworkManager.attemptReconnect(); + } + } + }); + + CleanupManager.addListener(window, 'beforeunload', () => { + cleanup(); + }); + + appCleanup.init(); +}); + +app.init = function() { window.addEventListener('load', app.onLoad); - appCleanup.init(); app.loadLastRefreshedTime(); app.updateAutoRefreshButton(); - memoryMonitor.startMonitoring(); - //console.log('App initialized'); + + if (window.NetworkManager) { + NetworkManager.addHandler('offline', () => { + ui.showNetworkErrorMessage(); + }); + + NetworkManager.addHandler('reconnected', () => { + ui.hideErrorMessage(); + app.refreshAllData(); + }); + + NetworkManager.addHandler('maxAttemptsReached', () => { + ui.displayErrorMessage( + "Server connection lost. Please check your internet connection and try refreshing the page.", + 0 + ); + }); + } + + return app; }; -// LOAD app.init(); diff --git a/basicswap/static/js/active.js b/basicswap/static/js/swaps_in_progress.js similarity index 78% rename from basicswap/static/js/active.js rename to basicswap/static/js/swaps_in_progress.js index 2189979..6efe3d7 100644 --- a/basicswap/static/js/active.js +++ b/basicswap/static/js/swaps_in_progress.js @@ -1,4 +1,3 @@ -// Constants and State const PAGE_SIZE = 50; const COIN_NAME_TO_SYMBOL = { 'Bitcoin': 'BTC', @@ -16,7 +15,6 @@ const COIN_NAME_TO_SYMBOL = { 'Dogecoin': 'DOGE' }; -// Global state const state = { identities: new Map(), currentPage: 1, @@ -27,7 +25,6 @@ const state = { refreshPromise: null }; -// DOM const elements = { swapsBody: document.getElementById('active-swaps-body'), prevPageButton: document.getElementById('prevPage'), @@ -40,105 +37,6 @@ const elements = { statusText: document.getElementById('status-text') }; -// Identity Manager -const IdentityManager = { - cache: new Map(), - pendingRequests: new Map(), - retryDelay: 2000, - maxRetries: 3, - cacheTimeout: 5 * 60 * 1000, // 5 minutes - - async getIdentityData(address) { - if (!address) { - return { address: '' }; - } - - const cachedData = this.getCachedIdentity(address); - if (cachedData) { - return { ...cachedData, address }; - } - - if (this.pendingRequests.has(address)) { - const pendingData = await this.pendingRequests.get(address); - return { ...pendingData, address }; - } - - const request = this.fetchWithRetry(address); - this.pendingRequests.set(address, request); - - try { - const data = await request; - this.cache.set(address, { - data, - timestamp: Date.now() - }); - return { ...data, address }; - } catch (error) { - console.warn(`Error fetching identity for ${address}:`, error); - return { address }; - } finally { - this.pendingRequests.delete(address); - } - }, - - getCachedIdentity(address) { - const cached = this.cache.get(address); - if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { - return cached.data; - } - if (cached) { - this.cache.delete(address); - } - return null; - }, - - async fetchWithRetry(address, attempt = 1) { - try { - const response = await fetch(`/json/identities/${address}`, { - signal: AbortSignal.timeout(5000) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return { - ...data, - address, - num_sent_bids_successful: safeParseInt(data.num_sent_bids_successful), - num_recv_bids_successful: safeParseInt(data.num_recv_bids_successful), - num_sent_bids_failed: safeParseInt(data.num_sent_bids_failed), - num_recv_bids_failed: safeParseInt(data.num_recv_bids_failed), - num_sent_bids_rejected: safeParseInt(data.num_sent_bids_rejected), - num_recv_bids_rejected: safeParseInt(data.num_recv_bids_rejected), - label: data.label || '', - note: data.note || '', - automation_override: safeParseInt(data.automation_override) - }; - } catch (error) { - if (attempt >= this.maxRetries) { - console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); - return { - address, - num_sent_bids_successful: 0, - num_recv_bids_successful: 0, - num_sent_bids_failed: 0, - num_recv_bids_failed: 0, - num_sent_bids_rejected: 0, - num_recv_bids_rejected: 0, - label: '', - note: '', - automation_override: 0 - }; - } - - await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); - return this.fetchWithRetry(address, attempt + 1); - } - } -}; - const safeParseInt = (value) => { const parsed = parseInt(value); return isNaN(parsed) ? 0 : parsed; @@ -200,7 +98,6 @@ const getTxStatusClass = (status) => { return 'text-blue-500'; }; -// Util const formatTimeAgo = (timestamp) => { const now = Math.floor(Date.now() / 1000); const diff = now - timestamp; @@ -211,7 +108,6 @@ const formatTimeAgo = (timestamp) => { return `${Math.floor(diff / 86400)} days ago`; }; - const formatTime = (timestamp) => { if (!timestamp) return ''; const date = new Date(timestamp * 1000); @@ -251,111 +147,6 @@ const getTimeStrokeColor = (expireTime) => { return '#10B981'; // More than 30 minutes }; -// WebSocket Manager -const WebSocketManager = { - ws: null, - processingQueue: false, - reconnectTimeout: null, - maxReconnectAttempts: 5, - reconnectAttempts: 0, - reconnectDelay: 5000, - - initialize() { - this.connect(); - this.startHealthCheck(); - }, - - connect() { - if (this.ws?.readyState === WebSocket.OPEN) return; - - try { - - let wsPort; - - if (typeof getWebSocketConfig === 'function') { - const wsConfig = getWebSocketConfig(); - wsPort = wsConfig?.port || wsConfig?.fallbackPort; - } - - if (!wsPort && window.config?.port) { - wsPort = window.config.port; - } - - if (!wsPort) { - wsPort = window.ws_port || '11700'; - } - - console.log("Using WebSocket port:", wsPort); - this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); - this.setupEventHandlers(); - } catch (error) { - console.error('WebSocket connection error:', error); - this.handleReconnect(); - } -}, - setupEventHandlers() { - this.ws.onopen = () => { - state.wsConnected = true; - this.reconnectAttempts = 0; - updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established for Swaps in Progress'); - updateSwapsTable({ resetPage: true, refreshData: true }); - }; - - this.ws.onmessage = () => { - if (!this.processingQueue) { - this.processingQueue = true; - setTimeout(async () => { - try { - if (!state.isRefreshing) { - await updateSwapsTable({ resetPage: false, refreshData: true }); - } - } finally { - this.processingQueue = false; - } - }, 200); - } - }; - - this.ws.onclose = () => { - state.wsConnected = false; - updateConnectionStatus('disconnected'); - this.handleReconnect(); - }; - - this.ws.onerror = () => { - updateConnectionStatus('error'); - }; - }, - - startHealthCheck() { - setInterval(() => { - if (this.ws?.readyState !== WebSocket.OPEN) { - this.handleReconnect(); - } - }, 30000); - }, - - handleReconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - } - - this.reconnectAttempts++; - if (this.reconnectAttempts <= this.maxReconnectAttempts) { - const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); - this.reconnectTimeout = setTimeout(() => this.connect(), delay); - } else { - updateConnectionStatus('error'); - setTimeout(() => { - this.reconnectAttempts = 0; - this.connect(); - }, 60000); - } - } -}; - -// UI const updateConnectionStatus = (status) => { const { statusDot, statusText } = elements; if (!statusDot || !statusText) return; @@ -528,7 +319,7 @@ const createSwapTableRow = async (swap) => { <td class="relative w-0 p-0 m-0"> <div class="absolute top-0 bottom-0 left-0 w-1"></div> </td> - + <!-- Time Column --> <td class="py-3 pl-1 pr-2 text-xs whitespace-nowrap"> <div class="flex items-center"> @@ -575,13 +366,14 @@ const createSwapTableRow = async (swap) => { </div> </div> </td> - - <!-- You Receive Column --> + <!-- You Send Column --> <td class="py-0"> <div class="py-3 px-4 text-left"> <div class="items-center monospace"> - <div class="text-sm font-semibold">${toAmount.toFixed(8)}</div> - <div class="text-sm text-gray-500 dark:text-gray-400">${toSymbol}</div> + <div class="pr-2"> + <div class="text-sm font-semibold">${fromAmount.toFixed(8)}</div> + <div class="text-sm text-gray-500 dark:text-gray-400">${fromSymbol}</div> + </div> </div> </div> </td> @@ -592,8 +384,8 @@ const createSwapTableRow = async (swap) => { <div class="flex items-center justify-center"> <span class="inline-flex mr-3 align-middle items-center justify-center w-18 h-20 rounded"> <img class="h-12" - src="/static/images/coins/${swap.coin_to.replace(' ', '-')}.png" - alt="${swap.coin_to}" + src="/static/images/coins/${swap.coin_from.replace(' ', '-')}.png" + alt="${swap.coin_from}" onerror="this.src='/static/images/coins/default.png'"> </span> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> @@ -601,30 +393,27 @@ const createSwapTableRow = async (swap) => { </svg> <span class="inline-flex ml-3 align-middle items-center justify-center w-18 h-20 rounded"> <img class="h-12" - src="/static/images/coins/${swap.coin_from.replace(' ', '-')}.png" - alt="${swap.coin_from}" + src="/static/images/coins/${swap.coin_to.replace(' ', '-')}.png" + alt="${swap.coin_to}" onerror="this.src='/static/images/coins/default.png'"> </span> </div> </div> </td> - <!-- You Send Column --> + <!-- You Receive Column --> <td class="py-0"> <div class="py-3 px-4 text-right"> <div class="items-center monospace"> - <div> - <div class="text-sm font-semibold">${fromAmount.toFixed(8)}</div> - <div class="text-sm text-gray-500 dark:text-gray-400">${fromSymbol}</div> - </div> + <div class="text-sm font-semibold">${toAmount.toFixed(8)}</div> + <div class="text-sm text-gray-500 dark:text-gray-400">${toSymbol}</div> </div> </div> </td> - <!-- Status Column --> <td class="py-3 px-4 text-center"> <div data-tooltip-target="tooltip-status-${uniqueId}" class="flex justify-center"> - <span class="px-2.5 py-1 text-xs font-medium rounded-full ${getStatusClass(swap.bid_state, swap.tx_state_a, swap.tx_state_b)}"> + <span class="w-full lg:w-7/8 xl:w-2/3 px-2.5 py-1 inline-flex items-center justify-center text-center rounded-full text-xs font-medium bold ${getStatusClass(swap.bid_state, swap.tx_state_a, swap.tx_state_b)}"> ${swap.bid_state} </span> </div> @@ -727,6 +516,8 @@ const createSwapTableRow = async (swap) => { async function updateSwapsTable(options = {}) { const { resetPage = false, refreshData = true } = options; + //console.log('Updating swaps table:', { resetPage, refreshData }); + if (state.refreshPromise) { await state.refreshPromise; return; @@ -752,9 +543,19 @@ async function updateSwapsTable(options = {}) { } const data = await response.json(); - state.swapsData = Array.isArray(data) ? data : []; + //console.log('Received swap data:', data); + + state.swapsData = Array.isArray(data) + ? data.filter(swap => { + const isActive = isActiveSwap(swap); + //console.log(`Swap ${swap.bid_id}: ${isActive ? 'Active' : 'Inactive'}`, swap.bid_state); + return isActive; + }) + : []; + + //console.log('Filtered active swaps:', state.swapsData); } catch (error) { - console.error('Error fetching swap data:', error); + //console.error('Error fetching swap data:', error); state.swapsData = []; } finally { state.refreshPromise = null; @@ -780,13 +581,14 @@ async function updateSwapsTable(options = {}) { const endIndex = startIndex + PAGE_SIZE; const currentPageSwaps = state.swapsData.slice(startIndex, endIndex); + //console.log('Current page swaps:', currentPageSwaps); + if (elements.swapsBody) { if (currentPageSwaps.length > 0) { const rowPromises = currentPageSwaps.map(swap => createSwapTableRow(swap)); const rows = await Promise.all(rowPromises); elements.swapsBody.innerHTML = rows.join(''); - // Initialize tooltips if (window.TooltipManager) { window.TooltipManager.cleanup(); const tooltipTriggers = document.querySelectorAll('[data-tooltip-target]'); @@ -801,6 +603,7 @@ async function updateSwapsTable(options = {}) { }); } } else { + //console.log('No active swaps found, displaying empty state'); elements.swapsBody.innerHTML = ` <tr> <td colspan="8" class="text-center py-4 text-gray-500 dark:text-white"> @@ -810,22 +613,6 @@ async function updateSwapsTable(options = {}) { } } - if (elements.paginationControls) { - elements.paginationControls.style.display = totalPages > 1 ? 'flex' : 'none'; - } - - if (elements.currentPageSpan) { - elements.currentPageSpan.textContent = state.currentPage; - } - - if (elements.prevPageButton) { - elements.prevPageButton.style.display = state.currentPage > 1 ? 'inline-flex' : 'none'; - } - - if (elements.nextPageButton) { - elements.nextPageButton.style.display = state.currentPage < totalPages ? 'inline-flex' : 'none'; - } - } catch (error) { console.error('Error updating swaps table:', error); if (elements.swapsBody) { @@ -841,7 +628,34 @@ async function updateSwapsTable(options = {}) { } } -// Event +function isActiveSwap(swap) { + const activeStates = [ + + 'InProgress', + 'Accepted', + 'Delaying', + 'Auto accept delay', + 'Request accepted', + //'Received', + + 'Script coin locked', + 'Scriptless coin locked', + 'Script coin lock released', + + 'SendingInitialTx', + 'SendingPaymentTx', + + 'Exchanged script lock tx sigs msg', + 'Exchanged script lock spend tx msg', + + 'Script tx redeemed', + 'Scriptless tx redeemed', + 'Scriptless tx recovered' + ]; + + return activeStates.includes(swap.bid_state); +} + const setupEventListeners = () => { if (elements.refreshSwapsButton) { elements.refreshSwapsButton.addEventListener('click', async (e) => { @@ -881,8 +695,11 @@ const setupEventListeners = () => { } }; -// Init -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { WebSocketManager.initialize(); setupEventListeners(); + await updateSwapsTable({ resetPage: true, refreshData: true }); + const autoRefreshInterval = setInterval(async () => { + await updateSwapsTable({ resetPage: false, refreshData: true }); + }, 10000); // 30 seconds }); diff --git a/basicswap/static/js/tooltips.js b/basicswap/static/js/tooltips.js deleted file mode 100644 index 9249c56..0000000 --- a/basicswap/static/js/tooltips.js +++ /dev/null @@ -1,387 +0,0 @@ -class TooltipManager { - constructor() { - this.activeTooltips = new WeakMap(); - this.sizeCheckIntervals = new WeakMap(); - this.tooltipIdCounter = 0; - this.setupStyles(); - this.setupCleanupEvents(); - this.initializeMutationObserver(); - } - - static initialize() { - if (!window.TooltipManager) { - window.TooltipManager = new TooltipManager(); - } - return window.TooltipManager; - } - - create(element, content, options = {}) { - if (!element) return null; - - this.destroy(element); - - const checkSize = () => { - if (!document.body.contains(element)) { - return; - } - - const rect = element.getBoundingClientRect(); - if (rect.width && rect.height) { - delete element._tooltipRetryCount; - this.createTooltip(element, content, options, rect); - } else { - const retryCount = element._tooltipRetryCount || 0; - if (retryCount < 5) { - element._tooltipRetryCount = retryCount + 1; - requestAnimationFrame(checkSize); - } else { - delete element._tooltipRetryCount; - } - } - }; - - requestAnimationFrame(checkSize); - return null; - } - - createTooltip(element, content, options, rect) { - const targetId = element.getAttribute('data-tooltip-target'); - let bgClass = 'bg-gray-400'; - let arrowColor = 'rgb(156 163 175)'; - - if (targetId?.includes('tooltip-offer-')) { - const offerId = targetId.split('tooltip-offer-')[1]; - const [actualOfferId] = offerId.split('_'); - - if (window.jsonData) { - const offer = window.jsonData.find(o => - o.unique_id === offerId || - o.offer_id === actualOfferId - ); - - if (offer) { - if (offer.is_revoked) { - bgClass = 'bg-red-500'; - arrowColor = 'rgb(239 68 68)'; - } else if (offer.is_own_offer) { - bgClass = 'bg-gray-300'; - arrowColor = 'rgb(209 213 219)'; - } else { - bgClass = 'bg-green-700'; - arrowColor = 'rgb(21 128 61)'; - } - } - } - } - - const tooltipId = `tooltip-${++this.tooltipIdCounter}`; - - const instance = tippy(element, { - content, - allowHTML: true, - placement: options.placement || 'top', - appendTo: document.body, - animation: false, - duration: 0, - delay: 0, - interactive: true, - arrow: false, - theme: '', - moveTransition: 'none', - offset: [0, 10], - onShow(instance) { - if (!document.body.contains(element)) { - return false; - } - - const rect = element.getBoundingClientRect(); - if (!rect.width || !rect.height) { - return false; - } - - return true; - }, - onMount(instance) { - if (instance.popper.firstElementChild) { - instance.popper.firstElementChild.classList.add(bgClass); - instance.popper.setAttribute('data-for-tooltip-id', tooltipId); - } - const arrow = instance.popper.querySelector('.tippy-arrow'); - if (arrow) { - arrow.style.setProperty('color', arrowColor, 'important'); - } - }, - popperOptions: { - strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary: 'viewport', - padding: 10 - } - }, - { - name: 'flip', - options: { - padding: 10, - fallbackPlacements: ['top', 'bottom', 'right', 'left'] - } - } - ] - } - }); - - element.setAttribute('data-tooltip-trigger-id', tooltipId); - this.activeTooltips.set(element, instance); - - return instance; - } - - destroy(element) { - if (!element) return; - - delete element._tooltipRetryCount; - - const id = element.getAttribute('data-tooltip-trigger-id'); - if (!id) return; - - const instance = this.activeTooltips.get(element); - if (instance?.[0]) { - try { - instance[0].destroy(); - } catch (e) { - console.warn('Error destroying tooltip:', e); - - const tippyRoot = document.querySelector(`[data-for-tooltip-id="${id}"]`); - if (tippyRoot && tippyRoot.parentNode) { - tippyRoot.parentNode.removeChild(tippyRoot); - } - } - } - - this.activeTooltips.delete(element); - element.removeAttribute('data-tooltip-trigger-id'); - } - - cleanup() { - document.querySelectorAll('[data-tooltip-trigger-id]').forEach(element => { - this.destroy(element); - }); - - document.querySelectorAll('[data-tippy-root]').forEach(element => { - if (element.parentNode) { - element.parentNode.removeChild(element); - } - }); - } - - getActiveTooltipInstances() { - const result = []; - - document.querySelectorAll('[data-tooltip-trigger-id]').forEach(element => { - const instance = this.activeTooltips.get(element); - if (instance) { - result.push([element, instance]); - } - }); - - return result; - } - - initializeMutationObserver() { - if (this.mutationObserver) return; - - this.mutationObserver = new MutationObserver(mutations => { - let needsCleanup = false; - - mutations.forEach(mutation => { - if (mutation.removedNodes.length) { - Array.from(mutation.removedNodes).forEach(node => { - if (node.nodeType === 1) { - if (node.hasAttribute && node.hasAttribute('data-tooltip-trigger-id')) { - this.destroy(node); - needsCleanup = true; - } - - if (node.querySelectorAll) { - node.querySelectorAll('[data-tooltip-trigger-id]').forEach(el => { - this.destroy(el); - needsCleanup = true; - }); - } - } - }); - } - }); - - if (needsCleanup) { - document.querySelectorAll('[data-tippy-root]').forEach(element => { - const id = element.getAttribute('data-for-tooltip-id'); - if (id && !document.querySelector(`[data-tooltip-trigger-id="${id}"]`)) { - if (element.parentNode) { - element.parentNode.removeChild(element); - } - } - }); - } - }); - - this.mutationObserver.observe(document.body, { - childList: true, - subtree: true - }); - } - - setupStyles() { - if (document.getElementById('tooltip-styles')) return; - - document.head.insertAdjacentHTML('beforeend', ` - <style id="tooltip-styles"> - [data-tippy-root] { - position: fixed !important; - z-index: 9999 !important; - pointer-events: none !important; - } - - .tippy-box { - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 500; - border-radius: 0.5rem; - color: white; - position: relative !important; - pointer-events: auto !important; - } - - .tippy-content { - padding: 0.5rem 0.75rem !important; - } - - .tippy-box .bg-gray-400 { - background-color: rgb(156 163 175); - padding: 0.5rem 0.75rem; - } - .tippy-box:has(.bg-gray-400) .tippy-arrow { - color: rgb(156 163 175); - } - - .tippy-box .bg-red-500 { - background-color: rgb(239 68 68); - padding: 0.5rem 0.75rem; - } - .tippy-box:has(.bg-red-500) .tippy-arrow { - color: rgb(239 68 68); - } - - .tippy-box .bg-gray-300 { - background-color: rgb(209 213 219); - padding: 0.5rem 0.75rem; - } - .tippy-box:has(.bg-gray-300) .tippy-arrow { - color: rgb(209 213 219); - } - - .tippy-box .bg-green-700 { - background-color: rgb(21 128 61); - padding: 0.5rem 0.75rem; - } - .tippy-box:has(.bg-green-700) .tippy-arrow { - color: rgb(21 128 61); - } - - .tippy-box[data-placement^='top'] > .tippy-arrow::before { - border-top-color: currentColor; - } - - .tippy-box[data-placement^='bottom'] > .tippy-arrow::before { - border-bottom-color: currentColor; - } - - .tippy-box[data-placement^='left'] > .tippy-arrow::before { - border-left-color: currentColor; - } - - .tippy-box[data-placement^='right'] > .tippy-arrow::before { - border-right-color: currentColor; - } - - .tippy-box[data-placement^='top'] > .tippy-arrow { - bottom: 0; - } - - .tippy-box[data-placement^='bottom'] > .tippy-arrow { - top: 0; - } - - .tippy-box[data-placement^='left'] > .tippy-arrow { - right: 0; - } - - .tippy-box[data-placement^='right'] > .tippy-arrow { - left: 0; - } - </style> - `); - } - - setupCleanupEvents() { - this.boundCleanup = this.cleanup.bind(this); - this.handleVisibilityChange = () => { - if (document.hidden) { - this.cleanup(); - } - }; - - window.addEventListener('beforeunload', this.boundCleanup); - window.addEventListener('unload', this.boundCleanup); - document.addEventListener('visibilitychange', this.handleVisibilityChange); - } - - removeCleanupEvents() { - window.removeEventListener('beforeunload', this.boundCleanup); - window.removeEventListener('unload', this.boundCleanup); - document.removeEventListener('visibilitychange', this.handleVisibilityChange); - } - - initializeTooltips(selector = '[data-tooltip-target]') { - document.querySelectorAll(selector).forEach(element => { - const targetId = element.getAttribute('data-tooltip-target'); - const tooltipContent = document.getElementById(targetId); - - if (tooltipContent) { - this.create(element, tooltipContent.innerHTML, { - placement: element.getAttribute('data-tooltip-placement') || 'top' - }); - } - }); - } - - dispose() { - this.cleanup(); - - if (this.mutationObserver) { - this.mutationObserver.disconnect(); - this.mutationObserver = null; - } - - this.removeCleanupEvents(); - - const styleElement = document.getElementById('tooltip-styles'); - if (styleElement && styleElement.parentNode) { - styleElement.parentNode.removeChild(styleElement); - } - - if (window.TooltipManager === this) { - window.TooltipManager = null; - } - } -} - -if (typeof module !== 'undefined' && module.exports) { - module.exports = TooltipManager; -} - -document.addEventListener('DOMContentLoaded', () => { - TooltipManager.initialize(); -}); diff --git a/basicswap/static/js/dropdown.js b/basicswap/static/js/ui/dropdown.js similarity index 91% rename from basicswap/static/js/dropdown.js rename to basicswap/static/js/ui/dropdown.js index 6fdf12a..788382d 100644 --- a/basicswap/static/js/dropdown.js +++ b/basicswap/static/js/ui/dropdown.js @@ -1,15 +1,17 @@ (function(window) { 'use strict'; + const dropdownInstances = []; + function positionElement(targetEl, triggerEl, placement = 'bottom', offsetDistance = 8) { targetEl.style.visibility = 'hidden'; targetEl.style.display = 'block'; - + const triggerRect = triggerEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - + let top, left; top = triggerRect.bottom + offsetDistance; @@ -58,6 +60,9 @@ this._handleScroll = this._handleScroll.bind(this); this._handleResize = this._handleResize.bind(this); this._handleOutsideClick = this._handleOutsideClick.bind(this); + + dropdownInstances.push(this); + this.init(); } @@ -66,7 +71,8 @@ this._targetEl.style.margin = '0'; this._targetEl.style.display = 'none'; this._targetEl.style.position = 'fixed'; - this._targetEl.style.zIndex = '50'; + this._targetEl.style.zIndex = '40'; + this._targetEl.classList.add('dropdown-menu'); this._setupEventListeners(); this._initialized = true; @@ -123,6 +129,12 @@ show() { if (!this._visible) { + dropdownInstances.forEach(instance => { + if (instance !== this && instance._visible) { + instance.hide(); + } + }); + this._targetEl.style.display = 'block'; this._targetEl.style.visibility = 'hidden'; @@ -133,7 +145,7 @@ this._options.placement, this._options.offset ); - + this._visible = true; this._options.onShow(); }); @@ -160,6 +172,12 @@ document.removeEventListener('click', this._handleOutsideClick); window.removeEventListener('scroll', this._handleScroll, true); window.removeEventListener('resize', this._handleResize); + + const index = dropdownInstances.indexOf(this); + if (index > -1) { + dropdownInstances.splice(index, 1); + } + this._initialized = false; } } @@ -168,7 +186,7 @@ document.querySelectorAll('[data-dropdown-toggle]').forEach(triggerEl => { const targetId = triggerEl.getAttribute('data-dropdown-toggle'); const targetEl = document.getElementById(targetId); - + if (targetEl) { const placement = triggerEl.getAttribute('data-dropdown-placement'); new Dropdown(targetEl, triggerEl, { @@ -184,6 +202,8 @@ initDropdowns(); } + Dropdown.instances = dropdownInstances; + window.Dropdown = Dropdown; window.initDropdowns = initDropdowns; diff --git a/basicswap/static/js/tabs.js b/basicswap/static/js/ui/tabs.js similarity index 99% rename from basicswap/static/js/tabs.js rename to basicswap/static/js/ui/tabs.js index a8e9c76..5ca6339 100644 --- a/basicswap/static/js/tabs.js +++ b/basicswap/static/js/ui/tabs.js @@ -36,7 +36,7 @@ show(tabId, force = false) { const tab = this.getTab(tabId); - + if ((tab !== this._activeTab) || force) { this._items.forEach(t => { if (t !== tab) { diff --git a/basicswap/static/js/wallets.js b/basicswap/static/js/wallets.js deleted file mode 100644 index 4f5f70c..0000000 --- a/basicswap/static/js/wallets.js +++ /dev/null @@ -1,654 +0,0 @@ -const Wallets = (function() { - const CONFIG = { - MAX_RETRIES: 5, - BASE_DELAY: 500, - CACHE_EXPIRATION: 5 * 60 * 1000, - PRICE_UPDATE_INTERVAL: 5 * 60 * 1000, - API_TIMEOUT: 30000, - DEBOUNCE_DELAY: 300, - CACHE_MIN_INTERVAL: 60 * 1000, - DEFAULT_TTL: 300, - PRICE_SOURCE: { - PRIMARY: 'coingecko.com', - FALLBACK: 'cryptocompare.com', - ENABLED_SOURCES: ['coingecko.com', 'cryptocompare.com'] - } - }; - - const COIN_SYMBOLS = { - 'Bitcoin': 'BTC', - 'Particl': 'PART', - 'Monero': 'XMR', - 'Wownero': 'WOW', - 'Litecoin': 'LTC', - 'Dogecoin': 'DOGE', - 'Firo': 'FIRO', - 'Dash': 'DASH', - 'PIVX': 'PIVX', - 'Decred': 'DCR', - 'Bitcoin Cash': 'BCH' - }; - - const COINGECKO_IDS = { - 'BTC': 'btc', - 'PART': 'part', - 'XMR': 'xmr', - 'WOW': 'wownero', - 'LTC': 'ltc', - 'DOGE': 'doge', - 'FIRO': 'firo', - 'DASH': 'dash', - 'PIVX': 'pivx', - 'DCR': 'dcr', - 'BCH': 'bch' - }; - - const SHORT_NAMES = { - 'Bitcoin': 'BTC', - 'Particl': 'PART', - 'Monero': 'XMR', - 'Wownero': 'WOW', - 'Litecoin': 'LTC', - 'Litecoin MWEB': 'LTC MWEB', - 'Firo': 'FIRO', - 'Dash': 'DASH', - 'PIVX': 'PIVX', - 'Decred': 'DCR', - 'Bitcoin Cash': 'BCH', - 'Dogecoin': 'DOGE' - }; - - class Cache { - constructor(expirationTime) { - this.data = null; - this.timestamp = null; - this.expirationTime = expirationTime; - } - - isValid() { - return Boolean( - this.data && - this.timestamp && - (Date.now() - this.timestamp < this.expirationTime) - ); - } - - set(data) { - this.data = data; - this.timestamp = Date.now(); - } - - get() { - if (this.isValid()) { - return this.data; - } - return null; - } - - clear() { - this.data = null; - this.timestamp = null; - } - } - - class ApiClient { - constructor() { - this.cache = new Cache(CONFIG.CACHE_EXPIRATION); - this.lastFetchTime = 0; - } - - async fetchPrices(forceUpdate = false) { - const now = Date.now(); - const timeSinceLastFetch = now - this.lastFetchTime; - - if (!forceUpdate && timeSinceLastFetch < CONFIG.CACHE_MIN_INTERVAL) { - const cachedData = this.cache.get(); - if (cachedData) { - return cachedData; - } - } - - let lastError = null; - for (let attempt = 0; attempt < CONFIG.MAX_RETRIES; attempt++) { - try { - const processedData = {}; - const currentSource = CONFIG.PRICE_SOURCE.PRIMARY; - - const shouldIncludeWow = currentSource === 'coingecko.com'; - - const coinsToFetch = Object.values(COIN_SYMBOLS) - .filter(symbol => shouldIncludeWow || symbol !== 'WOW') - .map(symbol => COINGECKO_IDS[symbol] || symbol.toLowerCase()) - .join(','); - - const mainResponse = await fetch("/json/coinprices", { - method: "POST", - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - coins: coinsToFetch, - source: currentSource, - ttl: CONFIG.DEFAULT_TTL - }) - }); - - if (!mainResponse.ok) { - throw new Error(`HTTP error: ${mainResponse.status}`); - } - - const mainData = await mainResponse.json(); - - if (mainData && mainData.rates) { - Object.entries(mainData.rates).forEach(([coinId, price]) => { - const symbol = Object.entries(COINGECKO_IDS).find(([sym, id]) => id.toLowerCase() === coinId.toLowerCase())?.[0]; - if (symbol) { - const coinKey = Object.keys(COIN_SYMBOLS).find(key => COIN_SYMBOLS[key] === symbol); - if (coinKey) { - processedData[coinKey.toLowerCase().replace(' ', '-')] = { - usd: price, - btc: symbol === 'BTC' ? 1 : price / (mainData.rates.btc || 1) - }; - } - } - }); - } - - if (!shouldIncludeWow && !processedData['wownero']) { - try { - const wowResponse = await fetch("/json/coinprices", { - method: "POST", - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - coins: "wownero", - source: "coingecko.com", - ttl: CONFIG.DEFAULT_TTL - }) - }); - - if (wowResponse.ok) { - const wowData = await wowResponse.json(); - if (wowData && wowData.rates && wowData.rates.wownero) { - processedData['wownero'] = { - usd: wowData.rates.wownero, - btc: processedData.bitcoin ? wowData.rates.wownero / processedData.bitcoin.usd : 0 - }; - } - } - } catch (wowError) { - console.error('Error fetching WOW price:', wowError); - } - } - - this.cache.set(processedData); - this.lastFetchTime = now; - return processedData; - } catch (error) { - lastError = error; - console.error(`Price fetch attempt ${attempt + 1} failed:`, error); - - if (attempt === CONFIG.MAX_RETRIES - 1 && - CONFIG.PRICE_SOURCE.FALLBACK && - CONFIG.PRICE_SOURCE.FALLBACK !== CONFIG.PRICE_SOURCE.PRIMARY) { - const temp = CONFIG.PRICE_SOURCE.PRIMARY; - CONFIG.PRICE_SOURCE.PRIMARY = CONFIG.PRICE_SOURCE.FALLBACK; - CONFIG.PRICE_SOURCE.FALLBACK = temp; - - console.warn(`Switching to fallback source: ${CONFIG.PRICE_SOURCE.PRIMARY}`); - attempt = -1; - continue; - } - - if (attempt < CONFIG.MAX_RETRIES - 1) { - const delay = Math.min(CONFIG.BASE_DELAY * Math.pow(2, attempt), 10000); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - const cachedData = this.cache.get(); - if (cachedData) { - console.warn('Using cached data after fetch failures'); - return cachedData; - } - - throw lastError || new Error('Failed to fetch prices'); - } - - setPriceSource(primarySource, fallbackSource = null) { - if (!CONFIG.PRICE_SOURCE.ENABLED_SOURCES.includes(primarySource)) { - throw new Error(`Invalid primary source: ${primarySource}`); - } - - if (fallbackSource && !CONFIG.PRICE_SOURCE.ENABLED_SOURCES.includes(fallbackSource)) { - throw new Error(`Invalid fallback source: ${fallbackSource}`); - } - - CONFIG.PRICE_SOURCE.PRIMARY = primarySource; - if (fallbackSource) { - CONFIG.PRICE_SOURCE.FALLBACK = fallbackSource; - } - } - } - - class UiManager { - constructor() { - this.api = new ApiClient(); - this.toggleInProgress = false; - this.toggleDebounceTimer = null; - this.priceUpdateInterval = null; - this.lastUpdateTime = parseInt(localStorage.getItem(STATE_KEYS.LAST_UPDATE) || '0'); - this.isWalletsPage = document.querySelector('.wallet-list') !== null || - window.location.pathname.includes('/wallets'); - } - - getShortName(fullName) { - return SHORT_NAMES[fullName] || fullName; - } - - storeOriginalValues() { - document.querySelectorAll('.coinname-value').forEach(el => { - const coinName = el.getAttribute('data-coinname'); - const value = el.textContent?.trim() || ''; - - if (coinName) { - const amount = value ? parseFloat(value.replace(/[^0-9.-]+/g, '')) : 0; - const coinId = COIN_SYMBOLS[coinName]; - const shortName = this.getShortName(coinName); - - if (coinId) { - if (coinName === 'Particl') { - const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind'); - const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon'); - const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public'; - localStorage.setItem(`particl-${balanceType}-amount`, amount.toString()); - } else if (coinName === 'Litecoin') { - const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB'); - const balanceType = isMWEB ? 'mweb' : 'public'; - localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString()); - } else { - localStorage.setItem(`${coinId.toLowerCase()}-amount`, amount.toString()); - } - - el.setAttribute('data-original-value', `${amount} ${shortName}`); - } - } - }); - - document.querySelectorAll('.usd-value').forEach(el => { - const text = el.textContent?.trim() || ''; - if (text === 'Loading...') { - el.textContent = ''; - } - }); - } - - async updatePrices(forceUpdate = false) { - try { - const prices = await this.api.fetchPrices(forceUpdate); - let newTotal = 0; - - const currentTime = Date.now(); - localStorage.setItem(STATE_KEYS.LAST_UPDATE, currentTime.toString()); - this.lastUpdateTime = currentTime; - - if (prices) { - Object.entries(prices).forEach(([coinId, priceData]) => { - if (priceData?.usd) { - localStorage.setItem(`${coinId}-price`, priceData.usd.toString()); - } - }); - } - - document.querySelectorAll('.coinname-value').forEach(el => { - const coinName = el.getAttribute('data-coinname'); - const amountStr = el.getAttribute('data-original-value') || el.textContent?.trim() || ''; - - if (!coinName) return; - - let amount = 0; - if (amountStr) { - const matches = amountStr.match(/([0-9]*[.])?[0-9]+/); - if (matches && matches.length > 0) { - amount = parseFloat(matches[0]); - } - } - - const coinId = coinName.toLowerCase().replace(' ', '-'); - - if (!prices[coinId]) { - return; - } - - const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0'); - if (!price) return; - - const usdValue = (amount * price).toFixed(2); - - if (coinName === 'Particl') { - const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind'); - const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon'); - const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public'; - localStorage.setItem(`particl-${balanceType}-last-value`, usdValue); - localStorage.setItem(`particl-${balanceType}-amount`, amount.toString()); - } else if (coinName === 'Litecoin') { - const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB'); - const balanceType = isMWEB ? 'mweb' : 'public'; - localStorage.setItem(`litecoin-${balanceType}-last-value`, usdValue); - localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString()); - } else { - localStorage.setItem(`${coinId}-last-value`, usdValue); - localStorage.setItem(`${coinId}-amount`, amount.toString()); - } - - if (amount > 0) { - newTotal += parseFloat(usdValue); - } - - let usdEl = null; - - const flexContainer = el.closest('.flex'); - if (flexContainer) { - const nextFlex = flexContainer.nextElementSibling; - if (nextFlex) { - const usdInNextFlex = nextFlex.querySelector('.usd-value'); - if (usdInNextFlex) { - usdEl = usdInNextFlex; - } - } - } - - if (!usdEl) { - const parentCell = el.closest('td'); - if (parentCell) { - const usdInSameCell = parentCell.querySelector('.usd-value'); - if (usdInSameCell) { - usdEl = usdInSameCell; - } - } - } - - if (!usdEl) { - const sibling = el.nextElementSibling; - if (sibling && sibling.classList.contains('usd-value')) { - usdEl = sibling; - } - } - - if (!usdEl) { - const parentElement = el.parentElement; - if (parentElement) { - const usdElNearby = parentElement.querySelector('.usd-value'); - if (usdElNearby) { - usdEl = usdElNearby; - } - } - } - - if (usdEl) { - usdEl.textContent = `$${usdValue}`; - usdEl.setAttribute('data-original-value', usdValue); - } - }); - - document.querySelectorAll('.usd-value').forEach(el => { - if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { - const parentCell = el.closest('td'); - if (!parentCell) return; - - const coinValueEl = parentCell.querySelector('.coinname-value'); - if (!coinValueEl) return; - - const coinName = coinValueEl.getAttribute('data-coinname'); - if (!coinName) return; - - const amountStr = coinValueEl.textContent?.trim() || '0'; - const amount = parseFloat(amountStr) || 0; - - const coinId = coinName.toLowerCase().replace(' ', '-'); - if (!prices[coinId]) return; - - const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0'); - if (!price) return; - - const usdValue = (amount * price).toFixed(8); - el.textContent = `$${usdValue}`; - el.setAttribute('data-original-value', usdValue); - } - }); - - if (this.isWalletsPage) { - this.updateTotalValues(newTotal, prices?.bitcoin?.usd); - } - - localStorage.setItem(STATE_KEYS.PREVIOUS_TOTAL, localStorage.getItem(STATE_KEYS.CURRENT_TOTAL) || '0'); - localStorage.setItem(STATE_KEYS.CURRENT_TOTAL, newTotal.toString()); - - return true; - } catch (error) { - console.error('Price update failed:', error); - return false; - } - } - - updateTotalValues(totalUsd, btcPrice) { - const totalUsdEl = document.getElementById('total-usd-value'); - if (totalUsdEl) { - totalUsdEl.textContent = `$${totalUsd.toFixed(2)}`; - totalUsdEl.setAttribute('data-original-value', totalUsd.toString()); - localStorage.setItem('total-usd', totalUsd.toString()); - } - - if (btcPrice) { - const btcTotal = btcPrice ? totalUsd / btcPrice : 0; - const totalBtcEl = document.getElementById('total-btc-value'); - if (totalBtcEl) { - totalBtcEl.textContent = `~ ${btcTotal.toFixed(8)} BTC`; - totalBtcEl.setAttribute('data-original-value', btcTotal.toString()); - } - } - } - - async toggleBalances() { - if (this.toggleInProgress) return; - - try { - this.toggleInProgress = true; - const balancesVisible = localStorage.getItem('balancesVisible') === 'true'; - const newVisibility = !balancesVisible; - - localStorage.setItem('balancesVisible', newVisibility.toString()); - this.updateVisibility(newVisibility); - - if (this.toggleDebounceTimer) { - clearTimeout(this.toggleDebounceTimer); - } - - this.toggleDebounceTimer = window.setTimeout(async () => { - this.toggleInProgress = false; - if (newVisibility) { - await this.updatePrices(true); - } - }, CONFIG.DEBOUNCE_DELAY); - } catch (error) { - console.error('Failed to toggle balances:', error); - this.toggleInProgress = false; - } - } - - updateVisibility(isVisible) { - if (isVisible) { - this.showBalances(); - } else { - this.hideBalances(); - } - - const eyeIcon = document.querySelector("#hide-usd-amount-toggle svg"); - if (eyeIcon) { - eyeIcon.innerHTML = isVisible ? - '<path d="M23.444,10.239C21.905,8.062,17.708,3,12,3S2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0C2.1,15.938,6.292,21,12,21s9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239ZM12,17a5,5,0,1,1,5-5A5,5,0,0,1,12,17Z"></path>' : - '<path d="M23.444,10.239a22.936,22.936,0,0,0-2.492-2.948l-4.021,4.021A5.026,5.026,0,0,1,17,12a5,5,0,0,1-5,5,5.026,5.026,0,0,1-.688-.069L8.055,20.188A10.286,10.286,0,0,0,12,21c5.708,0,9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239Z"></path><path d="M12,3C6.292,3,2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0a21.272,21.272,0,0,0,4.784,4.9l3.124-3.124a5,5,0,0,1,7.071-7.072L8.464,15.536l10.2-10.2A11.484,11.484,0,0,0,12,3Z"></path><path data-color="color-2" d="M1,24a1,1,0,0,1-.707-1.707l22-22a1,1,0,0,1,1.414,1.414l-22,22A1,1,0,0,1,1,24Z"></path>'; - } - } - - showBalances() { - const usdText = document.getElementById('usd-text'); - if (usdText) { - usdText.style.display = 'inline'; - } - - document.querySelectorAll('.coinname-value').forEach(el => { - const originalValue = el.getAttribute('data-original-value'); - if (originalValue) { - el.textContent = originalValue; - } - }); - - document.querySelectorAll('.usd-value').forEach(el => { - const storedValue = el.getAttribute('data-original-value'); - if (storedValue !== null && storedValue !== undefined) { - if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { - el.textContent = `$${parseFloat(storedValue).toFixed(8)}`; - } else { - el.textContent = `$${parseFloat(storedValue).toFixed(2)}`; - } - } else { - if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) { - el.textContent = '$0.00000000'; - } else { - el.textContent = '$0.00'; - } - } - }); - - if (this.isWalletsPage) { - ['total-usd-value', 'total-btc-value'].forEach(id => { - const el = document.getElementById(id); - const originalValue = el?.getAttribute('data-original-value'); - if (el && originalValue) { - if (id === 'total-usd-value') { - el.textContent = `$${parseFloat(originalValue).toFixed(2)}`; - el.classList.add('font-extrabold'); - } else { - el.textContent = `~ ${parseFloat(originalValue).toFixed(8)} BTC`; - } - } - }); - } - } - - hideBalances() { - const usdText = document.getElementById('usd-text'); - if (usdText) { - usdText.style.display = 'none'; - } - - document.querySelectorAll('.coinname-value').forEach(el => { - el.textContent = '****'; - }); - - document.querySelectorAll('.usd-value').forEach(el => { - el.textContent = '****'; - }); - - if (this.isWalletsPage) { - ['total-usd-value', 'total-btc-value'].forEach(id => { - const el = document.getElementById(id); - if (el) { - el.textContent = '****'; - } - }); - - const totalUsdEl = document.getElementById('total-usd-value'); - if (totalUsdEl) { - totalUsdEl.classList.remove('font-extrabold'); - } - } - } - - async initialize() { - document.querySelectorAll('.usd-value').forEach(el => { - const text = el.textContent?.trim() || ''; - if (text === 'Loading...') { - el.textContent = ''; - } - }); - - this.storeOriginalValues(); - - if (localStorage.getItem('balancesVisible') === null) { - localStorage.setItem('balancesVisible', 'true'); - } - - const hideBalancesToggle = document.getElementById('hide-usd-amount-toggle'); - if (hideBalancesToggle) { - hideBalancesToggle.addEventListener('click', () => this.toggleBalances()); - } - - await this.loadBalanceVisibility(); - - if (this.priceUpdateInterval) { - clearInterval(this.priceUpdateInterval); - } - - this.priceUpdateInterval = setInterval(() => { - if (localStorage.getItem('balancesVisible') === 'true' && !this.toggleInProgress) { - this.updatePrices(false); - } - }, CONFIG.PRICE_UPDATE_INTERVAL); - } - - async loadBalanceVisibility() { - const balancesVisible = localStorage.getItem('balancesVisible') === 'true'; - this.updateVisibility(balancesVisible); - - if (balancesVisible) { - await this.updatePrices(true); - } - } - - cleanup() { - if (this.priceUpdateInterval) { - clearInterval(this.priceUpdateInterval); - } - } - } - - const STATE_KEYS = { - LAST_UPDATE: 'last-update-time', - PREVIOUS_TOTAL: 'previous-total-usd', - CURRENT_TOTAL: 'current-total-usd', - BALANCES_VISIBLE: 'balancesVisible' - }; - - return { - initialize: function() { - const uiManager = new UiManager(); - - window.cryptoPricingManager = uiManager; - - window.addEventListener('beforeunload', () => { - uiManager.cleanup(); - }); - - uiManager.initialize().catch(error => { - console.error('Failed to initialize crypto pricing:', error); - }); - - return uiManager; - }, - - getUiManager: function() { - return window.cryptoPricingManager; - }, - - setPriceSource: function(primarySource, fallbackSource = null) { - const uiManager = this.getUiManager(); - if (uiManager && uiManager.api) { - uiManager.api.setPriceSource(primarySource, fallbackSource); - } - } - }; -})(); - -document.addEventListener('DOMContentLoaded', function() { - Wallets.initialize(); -}); diff --git a/basicswap/templates/active.html b/basicswap/templates/active.html index 8630183..72fbad5 100644 --- a/basicswap/templates/active.html +++ b/basicswap/templates/active.html @@ -113,6 +113,6 @@ </div> </section> -<script src="/static/js/active.js"></script> +<script src="/static/js/swaps_in_progress.js"></script> {% include 'footer.html' %} diff --git a/basicswap/templates/bid.html b/basicswap/templates/bid.html index d96d504..e916a60 100644 --- a/basicswap/templates/bid.html +++ b/basicswap/templates/bid.html @@ -557,6 +557,27 @@ </div> </div> </section> +<div id="confirmModal" class="fixed inset-0 z-50 hidden overflow-y-auto"> + <div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-out"></div> + <div class="relative z-50 min-h-screen px-4 flex items-center justify-center"> + <div class="bg-white dark:bg-gray-500 rounded-lg max-w-md w-full p-6 shadow-lg transition-opacity duration-300 ease-out"> + <div class="text-center"> + <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4" id="confirmTitle">Confirm Action</h2> + <p class="text-gray-600 dark:text-gray-200 mb-6 whitespace-pre-line" id="confirmMessage">Are you sure?</p> + <div class="flex justify-center gap-4"> + <button type="button" id="confirmYes" + class="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none"> + Confirm + </button> + <button type="button" id="confirmNo" + class="px-4 py-2.5 font-medium text-sm text-white hover:text-red border border-red-500 hover:border-red-500 hover:bg-red-600 bg-red-500 rounded-md shadow-button focus:ring-0 focus:outline-none"> + Cancel + </button> + </div> + </div> + </div> + </div> +</div> <input type="hidden" name="formid" value="{{ form_id }}"> </div> </div> @@ -564,9 +585,74 @@ </div> </form> <script> - function confirmPopup(name) { - return confirm(name + " Bid - Are you sure?"); +document.addEventListener('DOMContentLoaded', function() { + let confirmCallback = null; + let triggerElement = null; + + document.getElementById('confirmYes').addEventListener('click', function() { + if (typeof confirmCallback === 'function') { + confirmCallback(); + } + hideConfirmDialog(); + }); + + document.getElementById('confirmNo').addEventListener('click', hideConfirmDialog); + + function showConfirmDialog(title, message, callback) { + confirmCallback = callback; + document.getElementById('confirmTitle').textContent = title; + document.getElementById('confirmMessage').textContent = message; + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.classList.remove('hidden'); + } + return false; } + + function hideConfirmDialog() { + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.classList.add('hidden'); + } + confirmCallback = null; + return false; + } + + window.confirmPopup = function(action = 'Abandon') { + triggerElement = document.activeElement; + const title = `Confirm ${action} Bid`; + const message = `Are you sure you want to ${action.toLowerCase()} this bid?`; + + return showConfirmDialog(title, message, function() { + if (triggerElement) { + const form = triggerElement.form; + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = triggerElement.name; + hiddenInput.value = triggerElement.value; + form.appendChild(hiddenInput); + form.submit(); + } + }); + }; + + const overrideButtonConfirm = function(button, action) { + if (button) { + button.removeAttribute('onclick'); + button.addEventListener('click', function(e) { + e.preventDefault(); + triggerElement = this; + return confirmPopup(action); + }); + } + }; + + const abandonBidBtn = document.querySelector('button[name="abandon_bid"]'); + overrideButtonConfirm(abandonBidBtn, 'Abandon'); + + const acceptBidBtn = document.querySelector('button[name="accept_bid"]'); + overrideButtonConfirm(acceptBidBtn, 'Accept'); +}); </script> </div> {% include 'footer.html' %} diff --git a/basicswap/templates/bids.html b/basicswap/templates/bids.html index 1bf3d5d..e2ec345 100644 --- a/basicswap/templates/bids.html +++ b/basicswap/templates/bids.html @@ -363,6 +363,6 @@ </div> <script src="/static/js/bids_sentreceived.js"></script> -<script src="/static/js/bids_export.js"></script> +<script src="/static/js/bids_sentreceived_export.js"></script> {% include 'footer.html' %} diff --git a/basicswap/templates/footer.html b/basicswap/templates/footer.html index bdfd99e..0042412 100644 --- a/basicswap/templates/footer.html +++ b/basicswap/templates/footer.html @@ -43,68 +43,3 @@ </div> </div> </section> -<script> - var toggleImages = function() { - var html = document.querySelector('html'); - var darkImages = document.querySelectorAll('.dark-image'); - var lightImages = document.querySelectorAll('.light-image'); - - if (html && html.classList.contains('dark')) { - toggleImageDisplay(darkImages, 'block'); - toggleImageDisplay(lightImages, 'none'); - } else { - toggleImageDisplay(darkImages, 'none'); - toggleImageDisplay(lightImages, 'block'); - } - }; - - var toggleImageDisplay = function(images, display) { - images.forEach(function(img) { - img.style.display = display; - }); - }; - - document.addEventListener('DOMContentLoaded', function() { - var themeToggle = document.getElementById('theme-toggle'); - - if (themeToggle) { - themeToggle.addEventListener('click', function() { - toggleImages(); - }); - } - - toggleImages(); - }); -</script> -<script> - var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); - var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); - - if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - themeToggleLightIcon.classList.remove('hidden'); - } else { - themeToggleDarkIcon.classList.remove('hidden'); - } - - function setTheme(theme) { - if (theme === 'light') { - document.documentElement.classList.remove('dark'); - localStorage.setItem('color-theme', 'light'); - } else { - document.documentElement.classList.add('dark'); - localStorage.setItem('color-theme', 'dark'); - } - } - - document.getElementById('theme-toggle').addEventListener('click', () => { - if (localStorage.getItem('color-theme') === 'dark') { - setTheme('light'); - } else { - setTheme('dark'); - } - themeToggleDarkIcon.classList.toggle('hidden'); - themeToggleLightIcon.classList.toggle('hidden'); - toggleImages(); - }); - -</script> diff --git a/basicswap/templates/header.html b/basicswap/templates/header.html index 0e8b867..f57f5ab 100644 --- a/basicswap/templates/header.html +++ b/basicswap/templates/header.html @@ -9,153 +9,95 @@ swap_in_progress_green_svg, available_bids_svg, your_offers_svg, bids_received_svg, bids_sent_svg, header_arrow_down_svg, love_svg %} +<!DOCTYPE html> <html lang="en"> <head> + <!-- Meta Tags --> <meta charset="UTF-8"> {% if refresh %} <meta http-equiv="refresh" content="{{ refresh }}"> {% endif %} - - <!-- Scripts --> - <script src="/static/js/libs/chart.js"></script> - <script src="/static/js/libs/chartjs-adapter-date-fns.bundle.min.js"></script> - <script src="/static/js/main.js"></script> - <script src="/static/js/tabs.js"></script> - <script src="/static/js/dropdown.js"></script> - <script src="/static/js/libs/popper.js"></script> - <script src="/static/js/libs/tippy.js"></script> - <script src="/static/js/tooltips.js"></script> - - <!-- Styles --> - <link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet" /> + <title>(BSX) BasicSwap - v{{ version }}</title> + + <!-- Favicon --> + <link rel="icon" sizes="32x32" type="image/png" href="/static/images/favicon/favicon-32.png"> + + <!-- Stylesheets --> + <link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet"> <link type="text/css" media="all" href="/static/css/libs/tailwind.min.css" rel="stylesheet"> <link type="text/css" media="all" href="/static/css/style.css" rel="stylesheet"> - - <link rel="icon" sizes="32x32" type="image/png" href="/static/images/favicon/favicon-32.png"> - - <title>(BSX) BasicSwap - v{{ version }}</title> - - <!-- Initialize tooltips --> + <script> - document.addEventListener('DOMContentLoaded', () => { - const tooltipManager = TooltipManager.initialize(); - tooltipManager.initializeTooltips(); - }); - </script> - - <!-- Dark mode initialization --> - <script> - const isDarkMode = localStorage.getItem('color-theme') === 'dark' || - (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); - - if (!localStorage.getItem('color-theme')) { - localStorage.setItem('color-theme', 'dark'); + // API Keys Configuration + function getAPIKeys() { + return { + cryptoCompare: "{{ chart_api_key|safe }}", + coinGecko: "{{ coingecko_api_key|safe }}" + }; } - document.documentElement.classList.toggle('dark', isDarkMode); - </script> - - <!-- Shutdown modal functionality --> - <script> - document.addEventListener('DOMContentLoaded', function() { - const shutdownButtons = document.querySelectorAll('.shutdown-button'); - const shutdownModal = document.getElementById('shutdownModal'); - const closeModalButton = document.getElementById('closeShutdownModal'); - const confirmShutdownButton = document.getElementById('confirmShutdown'); - const shutdownWarning = document.getElementById('shutdownWarning'); - - function updateShutdownButtons() { - const activeSwaps = parseInt(shutdownButtons[0].getAttribute('data-active-swaps') || '0'); - shutdownButtons.forEach(button => { - if (activeSwaps > 0) { - button.classList.add('shutdown-disabled'); - button.setAttribute('data-disabled', 'true'); - button.setAttribute('title', 'Caution: Swaps in progress'); - } else { - button.classList.remove('shutdown-disabled'); - button.removeAttribute('data-disabled'); - button.removeAttribute('title'); - } - }); - } - - function showShutdownModal() { - const activeSwaps = parseInt(shutdownButtons[0].getAttribute('data-active-swaps') || '0'); - if (activeSwaps > 0) { - shutdownWarning.classList.remove('hidden'); - confirmShutdownButton.textContent = 'Yes, Shut Down Anyway'; - } else { - shutdownWarning.classList.add('hidden'); - confirmShutdownButton.textContent = 'Yes, Shut Down'; - } - shutdownModal.classList.remove('hidden'); - document.body.style.overflow = 'hidden'; - } - - function hideShutdownModal() { - shutdownModal.classList.add('hidden'); - document.body.style.overflow = ''; - } - - shutdownButtons.forEach(button => { - button.addEventListener('click', function(e) { - e.preventDefault(); - showShutdownModal(); - }); + + // WebSocket Configuration + (function() { + Object.defineProperty(window, 'ws_port', { + value: "{{ ws_port|safe }}", + writable: false, + configurable: false, + enumerable: true }); - - closeModalButton.addEventListener('click', hideShutdownModal); - - confirmShutdownButton.addEventListener('click', function() { - const shutdownToken = document.querySelector('.shutdown-button') - .getAttribute('href').split('/').pop(); - window.location.href = '/shutdown/' + shutdownToken; - }); - - shutdownModal.addEventListener('click', function(e) { - if (e.target === this) { - hideShutdownModal(); - } - }); - - updateShutdownButtons(); - }); + + window.getWebSocketConfig = window.getWebSocketConfig || function() { + return { + port: window.ws_port || '11701', + fallbackPort: '11700' + }; + }; + })(); + + // Dark Mode Initialization + (function() { + const isDarkMode = localStorage.getItem('color-theme') === 'dark' || + (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); + + if (!localStorage.getItem('color-theme')) { + localStorage.setItem('color-theme', 'dark'); + } + document.documentElement.classList.toggle('dark', isDarkMode); + })(); </script> - <script> - document.addEventListener('DOMContentLoaded', function() { - const closeButtons = document.querySelectorAll('[data-dismiss-target]'); + <!-- Third-party Libraries --> + <script src="/static/js/libs/chart.js"></script> + <script src="/static/js/libs/chartjs-adapter-date-fns.bundle.min.js"></script> + <script src="/static/js/libs/popper.js"></script> + <script src="/static/js/libs/tippy.js"></script> - closeButtons.forEach(button => { - button.addEventListener('click', function() { - const targetId = this.getAttribute('data-dismiss-target'); - const targetElement = document.querySelector(targetId); - - if (targetElement) { - targetElement.style.display = 'none'; - } - }); - }); -}); -</script> - -<script> -function getAPIKeys() { - return { - cryptoCompare: "{{ chart_api_key|safe }}", - coinGecko: "{{ coingecko_api_key|safe }}" - }; -} - -function getWebSocketConfig() { - return { - port: "{{ ws_port|safe }}", - fallbackPort: "11700" - }; -} -</script> - + <!-- UI Components --> + <script src="/static/js/ui/tabs.js"></script> + <script src="/static/js/ui/dropdown.js"></script> + + <!-- Core Application Modules --> + <script src="/static/js/modules/config-manager.js"></script> + <script src="/static/js/modules/cache-manager.js"></script> + <script src="/static/js/modules/cleanup-manager.js"></script> + + <!-- Connection & Communication Modules --> + <script src="/static/js/modules/websocket-manager.js"></script> + <script src="/static/js/modules/network-manager.js"></script> + <script src="/static/js/modules/api-manager.js"></script> + + <!-- UI & Interaction Modules --> + <script src="/static/js/modules/tooltips-manager.js"></script> + <script src="/static/js/modules/notification-manager.js"></script> + <script src="/static/js/modules/identity-manager.js"></script> + <script src="/static/js/modules/summary-manager.js"></script> + {% if current_page == 'wallets' or current_page == 'wallet' %} + <script src="/static/js/modules/wallet-manager.js"></script> + {% endif %} + <script src="/static/js/modules/memory-manager.js"></script> + + <!-- Global Script --> + <script src="/static/js/global.js"></script> </head> - <body class="dark:bg-gray-700"> <div id="shutdownModal" tabindex="-1" class="hidden fixed inset-0 z-50 overflow-y-auto overflow-x-hidden"> <div class="fixed inset-0 bg-black bg-opacity-60 transition-opacity"></div> @@ -757,194 +699,3 @@ function getWebSocketConfig() { </div> </div> </section> - - <!-- WebSocket --> - {% if ws_port %} - <script> -(function() { - window.notificationConfig = { - showNewOffers: false, - showNewBids: true, - showBidAccepted: true - }; - - function ensureToastContainer() { - let container = document.getElementById('ul_updates'); - if (!container) { - const floating_div = document.createElement('div'); - floating_div.classList.add('floatright'); - container = document.createElement('ul'); - container.setAttribute('id', 'ul_updates'); - floating_div.appendChild(container); - document.body.appendChild(floating_div); - } - return container; - } - - function createToast(title, type = 'success') { - const messages = ensureToastContainer(); - const message = document.createElement('li'); - message.innerHTML = ` - <div id="hide"> - <div id="toast-${type}" class="flex items-center p-4 mb-4 w-full max-w-xs text-gray-500 - bg-white rounded-lg shadow" role="alert"> - <div class="inline-flex flex-shrink-0 justify-center items-center w-10 h-10 - bg-blue-500 rounded-lg"> - <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" height="18" width="18" - viewBox="0 0 24 24"> - <g fill="#ffffff"> - <path d="M8.5,20a1.5,1.5,0,0,1-1.061-.439L.379,12.5,2.5,10.379l6,6,13-13L23.621, - 5.5,9.561,19.561A1.5,1.5,0,0,1,8.5,20Z"></path> - </g> - </svg> - </div> - <div class="uppercase w-40 ml-3 text-sm font-semibold text-gray-900">${title}</div> - <button type="button" onclick="closeAlert(event)" class="ml-auto -mx-1.5 -my-1.5 - bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-0 focus:outline-none - focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8"> - <span class="sr-only">Close</span> - <svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" - xmlns="http://www.w3.org/2000/svg"> - <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 - 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 - 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" - clip-rule="evenodd"></path> - </svg> - </button> - </div> - </div> - `; - messages.appendChild(message); - } - - function updateElement(elementId, value, options = {}) { - const element = document.getElementById(elementId); - if (!element) return false; - - const safeValue = (value !== undefined && value !== null) - ? value - : (element.dataset.lastValue || 0); - - element.dataset.lastValue = safeValue; - - if (elementId === 'sent-bids-counter' || elementId === 'recv-bids-counter') { - const svg = element.querySelector('svg'); - element.textContent = safeValue; - if (svg) { - element.insertBefore(svg, element.firstChild); - } - } else { - element.textContent = safeValue; - } - - if (['offers-counter', 'bid-requests-counter', 'sent-bids-counter', - 'recv-bids-counter', 'swaps-counter', 'network-offers-counter', - 'watched-outputs-counter'].includes(elementId)) { - element.classList.remove('bg-blue-500', 'bg-gray-400'); - element.classList.add(safeValue > 0 ? 'bg-blue-500' : 'bg-gray-400'); - } - - if (elementId === 'swaps-counter') { - const swapContainer = document.getElementById('swapContainer'); - if (swapContainer) { - const isSwapping = safeValue > 0; - if (isSwapping) { - swapContainer.innerHTML = `{{ swap_in_progress_green_svg | safe }}`; - swapContainer.style.animation = 'spin 2s linear infinite'; - } else { - swapContainer.innerHTML = `{{ swap_in_progress_svg | safe }}`; - swapContainer.style.animation = 'none'; - } - } - } - return true; - } - - function fetchSummaryData() { - fetch('/json') - .then(response => response.json()) - .then(data => { - updateElement('network-offers-counter', data.num_network_offers); - updateElement('offers-counter', data.num_sent_active_offers); - updateElement('sent-bids-counter', data.num_sent_active_bids); - updateElement('recv-bids-counter', data.num_recv_active_bids); - updateElement('bid-requests-counter', data.num_available_bids); - updateElement('swaps-counter', data.num_swapping); - updateElement('watched-outputs-counter', data.num_watched_outputs); - }) - .catch(error => console.error('Summary data fetch error:', error)); - } - - function initWebSocket() { - const wsUrl = "ws://" + window.location.hostname + ":{{ ws_port }}"; - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log('🟢 WebSocket connection established for Dynamic Counters'); - fetchSummaryData(); - setInterval(fetchSummaryData, 30000); // Refresh every 30 seconds - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.event) { - let toastTitle; - let shouldShowToast = false; - - switch (data.event) { - case 'new_offer': - toastTitle = `New network <a class="underline" href=/offer/${data.offer_id}>offer</a>`; - shouldShowToast = window.notificationConfig.showNewOffers; - break; - case 'new_bid': - toastTitle = `<a class="underline" href=/bid/${data.bid_id}>New bid</a> on - <a class="underline" href=/offer/${data.offer_id}>offer</a>`; - shouldShowToast = window.notificationConfig.showNewBids; - break; - case 'bid_accepted': - toastTitle = `<a class="underline" href=/bid/${data.bid_id}>Bid</a> accepted`; - shouldShowToast = window.notificationConfig.showBidAccepted; - break; - } - - if (toastTitle && shouldShowToast) { - createToast(toastTitle); - } - } - fetchSummaryData(); - } catch (error) { - console.error('WebSocket message processing error:', error); - } - }; - - ws.onerror = (error) => { - console.error('WebSocket Error:', error); - }; - - ws.onclose = (event) => { - console.log('WebSocket connection closed', event); - setTimeout(initWebSocket, 5000); - }; - } - - window.closeAlert = function(event) { - let element = event.target; - while (element.nodeName !== "BUTTON") { - element = element.parentNode; - } - element.parentNode.parentNode.removeChild(element.parentNode); - }; - - function init() { - initWebSocket(); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); - </script> - {% endif %} diff --git a/basicswap/templates/offer_confirm.html b/basicswap/templates/offer_confirm.html index c9c941c..08e2055 100644 --- a/basicswap/templates/offer_confirm.html +++ b/basicswap/templates/offer_confirm.html @@ -1,5 +1,4 @@ {% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, input_down_arrow_offer_svg, select_network_svg, select_address_svg, select_swap_type_svg, select_bid_amount_svg, select_rate_svg, step_one_svg, step_two_svg, step_three_svg, select_setup_svg, input_time_svg, select_target_svg , select_auto_strategy_svg %} -<script src="static/js/coin_icons.js"></script> <div class="container mx-auto"> <section class="p-5 mt-5"> <div class="flex flex-wrap items-center -m-2"> diff --git a/basicswap/templates/offer_new_1.html b/basicswap/templates/offer_new_1.html index 0165b0a..da9f42c 100644 --- a/basicswap/templates/offer_new_1.html +++ b/basicswap/templates/offer_new_1.html @@ -1,5 +1,4 @@ {% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, input_down_arrow_offer_svg, select_network_svg, select_address_svg, select_swap_type_svg, select_bid_amount_svg, select_rate_svg, step_one_svg, step_two_svg, step_three_svg %} -<script src="static/js/coin_icons.js"></script> <div class="container mx-auto"> <section class="p-5 mt-5"> <div class="flex flex-wrap items-center -m-2"> @@ -117,63 +116,6 @@ </div> </div> </div> -<script> -function handleNewOfferAddress() { - const selectElement = document.querySelector('select[name="addr_from"]'); - const STORAGE_KEY = 'lastUsedAddressNewOffer'; - const form = selectElement?.closest('form'); - - if (!selectElement || !form) return; - - function loadInitialAddress() { - const savedAddressJSON = localStorage.getItem(STORAGE_KEY); - if (savedAddressJSON) { - try { - const savedAddress = JSON.parse(savedAddressJSON); - selectElement.value = savedAddress.value; - } catch (e) { - selectFirstAddress(); - } - } else { - selectFirstAddress(); - } - } - - function selectFirstAddress() { - if (selectElement.options.length > 1) { - const firstOption = selectElement.options[1]; - if (firstOption) { - selectElement.value = firstOption.value; - saveAddress(firstOption.value, firstOption.text); - } - } - } - - function saveAddress(value, text) { - const addressData = { - value: value, - text: text - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(addressData)); - } - - form.addEventListener('submit', async (e) => { - saveAddress(selectElement.value, selectElement.selectedOptions[0].text); - }); - - selectElement.addEventListener('change', (event) => { - saveAddress(event.target.value, event.target.selectedOptions[0].text); - }); - - loadInitialAddress(); -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', handleNewOfferAddress); -} else { - handleNewOfferAddress(); -} -</script> <div class="py-0 border-b items-center justify-between -mx-4 mb-6 pb-3 border-gray-400 border-opacity-20"> <div class="w-full md:w-10/12"> @@ -413,225 +355,6 @@ if (document.readyState === 'loading') { </div> </div> </section> -<script> -const xhr_rates = new XMLHttpRequest(); -xhr_rates.onload = () => { - if (xhr_rates.status == 200) { - const obj = JSON.parse(xhr_rates.response); - inner_html = '<pre><code>' + JSON.stringify(obj, null, ' ') + '</code></pre>'; - document.getElementById('rates_display').innerHTML = inner_html; - } -}; - -const xhr_rate = new XMLHttpRequest(); -xhr_rate.onload = () => { - if (xhr_rate.status == 200) { - const obj = JSON.parse(xhr_rate.response); - if (obj.hasOwnProperty('rate')) { - document.getElementById('rate').value = obj['rate']; - } else if (obj.hasOwnProperty('amount_to')) { - document.getElementById('amt_to').value = obj['amount_to']; - } else if (obj.hasOwnProperty('amount_from')) { - document.getElementById('amt_from').value = obj['amount_from']; - } - } -}; - -function lookup_rates() { - const coin_from = document.getElementById('coin_from').value; - const coin_to = document.getElementById('coin_to').value; - - if (coin_from === '-1' || coin_to === '-1') { - alert('Coins from and to must be set first.'); - return; - } - - const selectedCoin = (coin_from === '15') ? '3' : coin_from; - - inner_html = '<p>Updating...</p>'; - document.getElementById('rates_display').innerHTML = inner_html; - document.querySelector(".pricejsonhidden").classList.remove("hidden"); - - const xhr_rates = new XMLHttpRequest(); - xhr_rates.onreadystatechange = function() { - if (xhr_rates.readyState === XMLHttpRequest.DONE) { - if (xhr_rates.status === 200) { - document.getElementById('rates_display').innerHTML = xhr_rates.responseText; - } else { - console.error('Error fetching data:', xhr_rates.statusText); - } - } - }; - - xhr_rates.open('POST', '/json/rates'); - xhr_rates.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr_rates.send('coin_from=' + selectedCoin + '&coin_to=' + coin_to); -} - -function getRateInferred(event) { - event.preventDefault(); - - const coin_from = document.getElementById('coin_from').value; - const coin_to = document.getElementById('coin_to').value; - const params = 'coin_from=' + encodeURIComponent(coin_from) + '&coin_to=' + encodeURIComponent(coin_to); - - const xhr_rates = new XMLHttpRequest(); - xhr_rates.onreadystatechange = function() { - if (xhr_rates.readyState === XMLHttpRequest.DONE) { - if (xhr_rates.status === 200) { - try { - const responseData = JSON.parse(xhr_rates.responseText); - if (responseData.coingecko && responseData.coingecko.rate_inferred) { - const rateInferred = responseData.coingecko.rate_inferred; - document.getElementById('rate').value = rateInferred; - set_rate('rate'); - } else { - document.getElementById('rate').value = 'Error: Rate limit'; - console.error('Rate limit reached or invalid response format'); - } - } catch (error) { - document.getElementById('rate').value = 'Error: Rate limit'; - console.error('Error parsing response:', error); - } - } else { - document.getElementById('rate').value = 'Error: Rate limit'; - console.error('Error fetching data:', xhr_rates.statusText); - } - } - }; - - xhr_rates.open('POST', '/json/rates'); - xhr_rates.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr_rates.send(params); -} - -document.getElementById('get_rate_inferred_button').addEventListener('click', getRateInferred); - -function set_swap_type_enabled(coin_from, coin_to, swap_type) { - const adaptor_sig_only_coins = [ - '6', /* XMR */ - '9', /* WOW */ - '8', /* PART_ANON */ - '7', /* PART_BLIND */ - '13', /* FIRO */ - '18', /* DOGE */ - '17' /* BCH */ - ]; - const secret_hash_only_coins = [ - '11', /* PIVX */ - '12' /* DASH */ - ]; - - let make_hidden = false; - - coin_from = String(coin_from); - coin_to = String(coin_to); - - if (adaptor_sig_only_coins.indexOf(coin_from) !== -1 || adaptor_sig_only_coins.indexOf(coin_to) !== -1) { - swap_type.disabled = true; - swap_type.value = 'xmr_swap'; - make_hidden = true; - swap_type.classList.add('select-disabled'); - } else if (secret_hash_only_coins.indexOf(coin_from) !== -1 || secret_hash_only_coins.indexOf(coin_to) !== -1) { - swap_type.disabled = true; - swap_type.value = 'seller_first'; - make_hidden = true; - swap_type.classList.add('select-disabled'); - } else { - swap_type.disabled = false; - swap_type.classList.remove('select-disabled'); - swap_type.value = 'xmr_swap'; - } - - let swap_type_hidden = document.getElementById('swap_type_hidden'); - if (make_hidden) { - if (!swap_type_hidden) { - swap_type_hidden = document.createElement('input'); - swap_type_hidden.setAttribute('id', 'swap_type_hidden'); - swap_type_hidden.setAttribute('type', 'hidden'); - swap_type_hidden.setAttribute('name', 'swap_type'); - document.getElementById('form').appendChild(swap_type_hidden); - } - swap_type_hidden.setAttribute('value', swap_type.value); - } else if (swap_type_hidden) { - swap_type_hidden.parentNode.removeChild(swap_type_hidden); - } -} - -document.addEventListener('DOMContentLoaded', function() { - const coin_from = document.getElementById('coin_from'); - const coin_to = document.getElementById('coin_to'); - - if (coin_from && coin_to) { - coin_from.addEventListener('change', function() { - const swap_type = document.getElementById('swap_type'); - set_swap_type_enabled(this.value, coin_to.value, swap_type); - }); - - coin_to.addEventListener('change', function() { - const swap_type = document.getElementById('swap_type'); - set_swap_type_enabled(coin_from.value, this.value, swap_type); - }); - } -}); - -function set_rate(value_changed) { - const coin_from = document.getElementById('coin_from').value; - const coin_to = document.getElementById('coin_to').value; - const amt_from = document.getElementById('amt_from').value; - const amt_to = document.getElementById('amt_to').value; - const rate = document.getElementById('rate').value; - const lock_rate = rate == '' ? false : document.getElementById('rate_lock').checked; - - if (value_changed === 'coin_from' || value_changed === 'coin_to') { - document.getElementById('rate').value = ''; - return; - } - - const swap_type = document.getElementById('swap_type'); - set_swap_type_enabled(coin_from, coin_to, swap_type); - - if (coin_from == '-1' || coin_to == '-1') { - return; - } - - let params = 'coin_from=' + coin_from + '&coin_to=' + coin_to; - - if (value_changed == 'rate' || (lock_rate && value_changed == 'amt_from') || (amt_to == '' && value_changed == 'amt_from')) { - if (rate == '' || (amt_from == '' && amt_to == '')) { - return; - } else if (amt_from == '' && amt_to != '') { - if (value_changed == 'amt_from') { - return; - } - params += '&rate=' + rate + '&amt_to=' + amt_to; - } else { - params += '&rate=' + rate + '&amt_from=' + amt_from; - } - } else if (lock_rate && value_changed == 'amt_to') { - if (amt_to == '' || rate == '') { - return; - } - params += '&amt_to=' + amt_to + '&rate=' + rate; - } else { - if (amt_from == '' || amt_to == '') { - return; - } - params += '&amt_from=' + amt_from + '&amt_to=' + amt_to; - } - - xhr_rate.open('POST', '/json/rate'); - xhr_rate.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr_rate.send(params); -} - -document.addEventListener("DOMContentLoaded", function() { - const coin_from = document.getElementById('coin_from').value; - const coin_to = document.getElementById('coin_to').value; - const swap_type = document.getElementById('swap_type'); - set_swap_type_enabled(coin_from, coin_to, swap_type); -}); -</script> </div> <script src="static/js/new_offer.js"></script> {% include 'footer.html' %} diff --git a/basicswap/templates/offer_new_2.html b/basicswap/templates/offer_new_2.html index 73169d3..b92a8a8 100644 --- a/basicswap/templates/offer_new_2.html +++ b/basicswap/templates/offer_new_2.html @@ -1,5 +1,4 @@ {% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, input_down_arrow_offer_svg, select_network_svg, select_address_svg, select_swap_type_svg, select_bid_amount_svg, select_rate_svg, step_one_svg, step_two_svg, step_three_svg, select_setup_svg, input_time_svg, select_target_svg , select_auto_strategy_svg %} -<script src="static/js/coin_icons.js"></script> <div class="container mx-auto"> <section class="p-5 mt-5"> <div class="flex flex-wrap items-center -m-2"> diff --git a/basicswap/templates/offers.html b/basicswap/templates/offers.html index 80b00c9..eb04efb 100644 --- a/basicswap/templates/offers.html +++ b/basicswap/templates/offers.html @@ -193,9 +193,9 @@ </div> </div> </section> - -{% endif %} <script src="/static/js/pricechart.js"></script> +{% endif %} + <section> <div class="px-6 py-0 mt-5 h-full overflow-hidden"> @@ -401,4 +401,5 @@ <input type="hidden" name="formid" value="{{ form_id }}"> <script src="/static/js/offers.js"></script> + {% include 'footer.html' %} diff --git a/basicswap/templates/unlock.html b/basicswap/templates/unlock.html index 3feeb3c..918b282 100644 --- a/basicswap/templates/unlock.html +++ b/basicswap/templates/unlock.html @@ -9,19 +9,49 @@ <link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet" /> <link type="text/css" media="all" href="/static/css/libs/tailwind.min.css" rel="stylesheet"> <link type="text/css" media="all" href="/static/css/style.css" rel="stylesheet"> - <script src="/static/js/main.js"></script> + + <!-- Third-party Libraries --> + <script src="/static/js/libs/chart.js"></script> + <script src="/static/js/libs/chartjs-adapter-date-fns.bundle.min.js"></script> + <script src="/static/js/libs/popper.js"></script> + <script src="/static/js/libs/tippy.js"></script> + + <!-- UI Components --> + <script src="/static/js/ui/tabs.js"></script> + <script src="/static/js/ui/dropdown.js"></script> + + <!-- Core Application Modules --> + <script src="/static/js/modules/config-manager.js"></script> + <script src="/static/js/modules/cache-manager.js"></script> + <script src="/static/js/modules/cleanup-manager.js"></script> + + <!-- Connection & Communication Modules --> + <script src="/static/js/modules/websocket-manager.js"></script> + <script src="/static/js/modules/network-manager.js"></script> + <script src="/static/js/modules/api-manager.js"></script> + + <!-- UI & Interaction Modules --> + <script src="/static/js/modules/tooltips-manager.js"></script> + <script src="/static/js/modules/notification-manager.js"></script> + <script src="/static/js/modules/identity-manager.js"></script> + <script src="/static/js/modules/summary-manager.js"></script> + {% if current_page == 'wallets' or current_page == 'wallet' %} + <script src="/static/js/modules/wallet-manager.js"></script> + {% endif %} + <script src="/static/js/modules/memory-manager.js"></script> + <script> - const isDarkMode = - localStorage.getItem('color-theme') === 'dark' || - (!localStorage.getItem('color-theme') && - window.matchMedia('(prefers-color-scheme: dark)').matches); - + (function() { + const isDarkMode = localStorage.getItem('color-theme') === 'dark' || + (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); + if (!localStorage.getItem('color-theme')) { - localStorage.setItem('color-theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('color-theme', 'dark'); } - document.documentElement.classList.toggle('dark', isDarkMode); + })(); </script> + <link rel=icon sizes="32x32" type="image/png" href="/static/images/favicon/favicon-32.png"> <title>(BSX) BasicSwap - v{{ version }}</title> </head> @@ -107,7 +137,6 @@ <script> document.addEventListener('DOMContentLoaded', () => { - // Password toggle functionality const passwordToggle = document.querySelector('.js-password-toggle'); if (passwordToggle) { passwordToggle.addEventListener('change', function() { @@ -126,7 +155,6 @@ }); } - // Image toggling function function toggleImages() { const html = document.querySelector('html'); const darkImages = document.querySelectorAll('.dark-image'); @@ -147,42 +175,6 @@ }); } - // Theme toggle functionality - function setTheme(theme) { - if (theme === 'light') { - document.documentElement.classList.remove('dark'); - localStorage.setItem('color-theme', 'light'); - } else { - document.documentElement.classList.add('dark'); - localStorage.setItem('color-theme', 'dark'); - } - } - - // Initialize theme - const themeToggle = document.getElementById('theme-toggle'); - const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); - const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); - - if (themeToggle && themeToggleDarkIcon && themeToggleLightIcon) { - if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - themeToggleLightIcon.classList.remove('hidden'); - } else { - themeToggleDarkIcon.classList.remove('hidden'); - } - - themeToggle.addEventListener('click', () => { - if (localStorage.getItem('color-theme') === 'dark') { - setTheme('light'); - } else { - setTheme('dark'); - } - themeToggleDarkIcon.classList.toggle('hidden'); - themeToggleLightIcon.classList.toggle('hidden'); - toggleImages(); - }); - } - - // Call toggleImages on load toggleImages(); }); </script> diff --git a/basicswap/templates/wallet.html b/basicswap/templates/wallet.html index 175edf7..c4d3860 100644 --- a/basicswap/templates/wallet.html +++ b/basicswap/templates/wallet.html @@ -1183,7 +1183,6 @@ document.addEventListener('DOMContentLoaded', function() { }); </script> -<script src="/static/js/wallets.js"></script> {% include 'footer.html' %} </body> </html> diff --git a/basicswap/templates/wallets.html b/basicswap/templates/wallets.html index 86b0a2a..aa697f1 100644 --- a/basicswap/templates/wallets.html +++ b/basicswap/templates/wallets.html @@ -1,8 +1,8 @@ {% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, circular_arrows_svg, withdraw_svg, utxo_groups_svg, create_utxo_svg, lock_svg, eye_show_svg %} -<section class="py-3 px-4"> - <div class="lg:container mx-auto">> +<section class="py-3 px-4 mt-6"> + <div class="lg:container mx-auto"> <div class="relative py-8 px-8 bg-coolGray-900 dark:bg-blue-500 rounded-md overflow-hidden"> <img class="absolute z-10 left-4 top-4" src="/static/images/elements/dots-red.svg" alt="dots-red"> <img class="absolute z-10 right-4 bottom-4" src="/static/images/elements/dots-red.svg" alt="dots-red"> @@ -189,9 +189,6 @@ </div> </section> - -<script src="/static/js/wallets.js"></script> - {% include 'footer.html' %} </body> </html> diff --git a/basicswap/ui/page_bids.py b/basicswap/ui/page_bids.py index fb1dea8..b649269 100644 --- a/basicswap/ui/page_bids.py +++ b/basicswap/ui/page_bids.py @@ -223,8 +223,6 @@ def page_bids( return self.render_template( template, { - "page_type_available": "Bids Available", - "page_type_available_description": "Bids available for you to accept.", "messages": messages, "filters": filters, "data": page_data,