diff --git a/app/routes/api.py b/app/routes/api.py index 1bbaf1b..b0f991f 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -24,6 +24,12 @@ _channels_cache = None _channels_cache_timestamp = 0 CHANNELS_CACHE_TTL = 30 # seconds +# Cache for contacts/detailed to reduce USB calls (4 calls per request!) +# Contacts change infrequently, 60s cache is safe +_contacts_detailed_cache = None +_contacts_detailed_cache_timestamp = 0 +CONTACTS_DETAILED_CACHE_TTL = 60 # seconds + def get_channels_cached(force_refresh=False): """ @@ -66,6 +72,48 @@ def invalidate_channels_cache(): logger.debug("Channels cache invalidated") +def get_contacts_detailed_cached(force_refresh=False): + """ + Get detailed contacts with caching to reduce USB/meshcli calls. + This endpoint makes 4 USB calls (one per contact type), so caching is critical. + + Args: + force_refresh: If True, bypass cache and fetch fresh data + + Returns: + Tuple of (success, contacts_dict, error_message) + """ + global _contacts_detailed_cache, _contacts_detailed_cache_timestamp + + current_time = time.time() + + # Return cached data if valid and not forcing refresh + if (not force_refresh and + _contacts_detailed_cache is not None and + (current_time - _contacts_detailed_cache_timestamp) < CONTACTS_DETAILED_CACHE_TTL): + logger.debug(f"Returning cached contacts (age: {current_time - _contacts_detailed_cache_timestamp:.1f}s)") + return True, _contacts_detailed_cache, None + + # Fetch fresh data (this makes 4 USB calls!) + logger.debug("Fetching fresh contacts from meshcli (4 USB calls)") + success, contacts, error = cli.get_contacts_with_last_seen() + + if success: + _contacts_detailed_cache = contacts + _contacts_detailed_cache_timestamp = current_time + logger.debug(f"Contacts cached ({len(contacts)} contacts)") + + return success, contacts, error + + +def invalidate_contacts_cache(): + """Invalidate contacts cache (call after contact changes)""" + global _contacts_detailed_cache, _contacts_detailed_cache_timestamp + _contacts_detailed_cache = None + _contacts_detailed_cache_timestamp = 0 + logger.debug("Contacts cache invalidated") + + @api_bp.route('/messages', methods=['GET']) def get_messages(): """ @@ -518,6 +566,10 @@ def cleanup_contacts(): 'error': message }) + # Invalidate contacts cache after deletions + if deleted_count > 0: + invalidate_contacts_cache() + return jsonify({ 'success': True, 'message': f'Cleanup completed: {deleted_count} deleted, {failed_count} failed', @@ -1104,6 +1156,9 @@ def get_messages_updates(): Check for new messages across all channels without fetching full message content. Used for intelligent refresh mechanism and unread notifications. + OPTIMIZED: Reads messages file only ONCE and computes stats for all channels. + Previously read the file N*2 times (once per channel, twice if updates). + Query parameters: last_seen (str): JSON object with last seen timestamps per channel Format: {"0": 1234567890, "1": 1234567891, ...} @@ -1143,44 +1198,52 @@ def get_messages_updates(): 'error': 'Failed to get channels' }), 500 + # OPTIMIZATION: Read ALL messages ONCE (no channel filter) + # Then compute per-channel statistics in memory + all_messages = parser.read_messages( + limit=None, # Get all messages + days=7 # Only last 7 days + ) + + # Group messages by channel and compute stats + channel_stats = {} # channel_idx -> {latest_ts, messages_after_last_seen} + for msg in all_messages: + ch_idx = msg.get('channel_idx', 0) + ts = msg.get('timestamp', 0) + + if ch_idx not in channel_stats: + channel_stats[ch_idx] = { + 'latest_timestamp': 0, + 'unread_count': 0 + } + + # Track latest timestamp per channel + if ts > channel_stats[ch_idx]['latest_timestamp']: + channel_stats[ch_idx]['latest_timestamp'] = ts + + # Count unread messages (newer than last_seen) + last_seen_ts = last_seen.get(ch_idx, 0) + if ts > last_seen_ts: + channel_stats[ch_idx]['unread_count'] += 1 + + # Build response updates = [] total_unread = 0 - # Check each channel for new messages for channel in channels: channel_idx = channel['index'] + stats = channel_stats.get(channel_idx, {'latest_timestamp': 0, 'unread_count': 0}) - # Get latest message for this channel - messages = parser.read_messages( - limit=1, - channel_idx=channel_idx, - days=7 # Only check recent messages - ) - - latest_timestamp = 0 - if messages and len(messages) > 0: - latest_timestamp = messages[0]['timestamp'] - - # Check if there are updates last_seen_ts = last_seen.get(channel_idx, 0) - has_updates = latest_timestamp > last_seen_ts - - # Count unread messages (messages newer than last_seen) - unread_count = 0 - if has_updates: - all_messages = parser.read_messages( - limit=500, - channel_idx=channel_idx, - days=7 - ) - unread_count = sum(1 for msg in all_messages if msg['timestamp'] > last_seen_ts) - total_unread += unread_count + has_updates = stats['latest_timestamp'] > last_seen_ts + unread_count = stats['unread_count'] if has_updates else 0 + total_unread += unread_count updates.append({ 'index': channel_idx, 'name': channel['name'], 'has_updates': has_updates, - 'latest_timestamp': latest_timestamp, + 'latest_timestamp': stats['latest_timestamp'], 'unread_count': unread_count }) @@ -1505,8 +1568,9 @@ def get_contacts_detailed_api(): } """ try: - # Get detailed contact info from meshcli (includes all fields) - success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen() + # Get detailed contact info from cache (reduces 4 USB calls to 0 on cache hit) + force_refresh = request.args.get('refresh', 'false').lower() == 'true' + success_detailed, contacts_detailed, error_detailed = get_contacts_detailed_cached(force_refresh) if not success_detailed: return jsonify({ @@ -1609,6 +1673,8 @@ def delete_contact_api(): success, message = cli.delete_contact(selector) if success: + # Invalidate contacts cache after deletion + invalidate_contacts_cache() return jsonify({ 'success': True, 'message': message @@ -1748,6 +1814,8 @@ def approve_pending_contact_api(): success, message = cli.approve_pending_contact(public_key) if success: + # Invalidate contacts cache after adding new contact + invalidate_contacts_cache() return jsonify({ 'success': True, 'message': message or 'Contact approved successfully' diff --git a/app/static/js/app.js b/app/static/js/app.js index 8df0a5c..3ccf097 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -245,6 +245,7 @@ async function loadContactsGeoCache() { // Initialize on page load document.addEventListener('DOMContentLoaded', async function() { console.log('mc-webui initialized'); + const initStart = performance.now(); // Force viewport recalculation on PWA navigation // This fixes the bottom bar visibility issue when navigating from other pages @@ -254,34 +255,45 @@ document.addEventListener('DOMContentLoaded', async function() { // Force reflow to ensure proper layout calculation document.body.offsetHeight; - // Load last seen timestamps from server - await loadLastSeenTimestampsFromServer(); - await loadDmLastSeenTimestampsFromServer(); - - // Restore last selected channel from localStorage + // Restore last selected channel from localStorage (sync, fast) const savedChannel = localStorage.getItem('mc_active_channel'); if (savedChannel !== null) { currentChannelIdx = parseInt(savedChannel); } - // Setup event listeners (do this early) + // Setup event listeners and emoji picker early (sync, fast) setupEventListeners(); - - // Setup emoji picker setupEmojiPicker(); - // CRITICAL: Load channels FIRST before anything else - // This ensures channels are available for checkForUpdates() + // OPTIMIZATION: Load timestamps in parallel (both are independent API calls) + console.log('[init] Loading timestamps in parallel...'); + await Promise.all([ + loadLastSeenTimestampsFromServer(), + loadDmLastSeenTimestampsFromServer() + ]); + + // Load channels (required before loading messages) + // NOTE: checkForUpdates() was removed from loadChannels() to speed up init + console.log('[init] Loading channels...'); await loadChannels(); - // Load contacts geo cache BEFORE messages (needed for Map buttons on bubbles) - await loadContactsGeoCache(); + // OPTIMIZATION: Load messages immediately, don't wait for geo cache + // Map buttons will appear once geo cache loads (non-blocking UX improvement) + console.log('[init] Loading messages (priority) and geo cache (background)...'); - // Now load other data (can run in parallel) + // Start these in parallel - messages are critical, geo cache can load async + const messagesPromise = loadMessages(); + const geoCachePromise = loadContactsGeoCache(); // Non-blocking, Map buttons update when ready + + // Also start archive list loading in parallel loadArchiveList(); - loadMessages(); - // Initial badge updates + // Wait for messages to display (this is what the user wants to see ASAP) + await messagesPromise; + + console.log(`[init] Messages loaded in ${(performance.now() - initStart).toFixed(0)}ms`); + + // Initial badge updates (fast, sync-ish) updatePendingContactsBadge(); loadStatus(); @@ -299,8 +311,18 @@ document.addEventListener('DOMContentLoaded', async function() { // Update notification toggle UI updateNotificationToggleUI(); - // Setup auto-refresh AFTER channels are loaded + // DEFERRED: Check for updates AFTER messages are displayed + // This updates the unread badges without blocking initial load + console.log('[init] Checking for updates (deferred)...'); + checkForUpdates(); // No await - runs in background + + // Wait for geo cache to complete before setting up auto-refresh + await geoCachePromise; + + // Setup auto-refresh AFTER initial load is complete setupAutoRefresh(); + + console.log(`[init] Full initialization complete in ${(performance.now() - initStart).toFixed(0)}ms`); }); // Handle page restoration from cache (PWA back/forward navigation) @@ -1555,9 +1577,8 @@ async function loadChannels() { availableChannels = data.channels; console.log('[loadChannels] Channels loaded:', availableChannels.length); populateChannelSelector(data.channels); - - // Check for unread messages after channels are loaded - await checkForUpdates(); + // NOTE: checkForUpdates() is now called separately after messages are displayed + // to avoid blocking the initial page load } else { console.error('[loadChannels] Error loading channels:', data.error || 'No channels returned'); // Fallback: ensure at least Public channel exists