From ecb3618da70a1ec770a602ab29a2d0cc85212a80 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 5 Jan 2026 12:29:17 +0100 Subject: [PATCH] feat: Add PWA notification support with badge counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Progressive Web App notification capabilities: - Service Worker foundation for PWA installability and offline support - Browser notification toggle in side menu with permission management - Local notifications triggered on new messages (channels, DMs, pending) - App Badge API integration showing total unread count on app icon - Smart notification logic (only when app hidden, only for NEW messages) - Graceful degradation for unsupported browsers Technical details: - Service Worker with network-first fetch strategy for dynamic content - Notification permission stored in localStorage (mc_notifications_enabled) - Delta-based notification tracking (prevents spam on page load) - Notification tag prevents duplicate alerts - App badge auto-clears when user returns to app Limitations (documented): - Android may freeze background JS after 5-10 minutes (OS behavior) - Full "wake device" support requires Web Push API (future enhancement) - Works best for active users who check app regularly Files modified: - app/static/js/sw.js (new) - app/templates/base.html - app/static/js/app.js - app/static/js/dm.js - app/static/manifest.json - app/static/css/style.css 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- app/static/css/style.css | 10 ++ app/static/js/app.js | 255 +++++++++++++++++++++++++++++++++++++++ app/static/js/dm.js | 55 +++++++++ app/static/js/sw.js | 41 +++++++ app/static/manifest.json | 12 +- app/templates/base.html | 29 +++++ 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 app/static/js/sw.js diff --git a/app/static/css/style.css b/app/static/css/style.css index d79ef48..e30b028 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -688,3 +688,13 @@ main { padding-bottom: env(safe-area-inset-bottom); } } + +/* ============================================================================= + PWA Notifications + ============================================================================= */ + +/* Notification toggle disabled state */ +#notificationsToggle:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index ca06d8e..eed2958 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -85,6 +85,9 @@ document.addEventListener('DOMContentLoaded', async function() { updatePendingContactsBadge(); loadStatus(); + // Update notification toggle UI + updateNotificationToggleUI(); + // Setup auto-refresh AFTER channels are loaded setupAutoRefresh(); }); @@ -110,6 +113,13 @@ document.addEventListener('visibilitychange', function() { window.dispatchEvent(new Event('resize')); document.body.offsetHeight; }, 100); + + // Clear app badge when user returns to app + if ('clearAppBadge' in navigator) { + navigator.clearAppBadge().catch((error) => { + console.error('Error clearing app badge on visibility:', error); + }); + } } }); @@ -306,6 +316,12 @@ function setupEventListeners() { } await executeSpecialCommand('floodadv'); }); + + // Notification toggle + const notificationsToggle = document.getElementById('notificationsToggle'); + if (notificationsToggle) { + notificationsToggle.addEventListener('click', handleNotificationToggle); + } } /** @@ -618,6 +634,233 @@ function setupAutoRefresh() { console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`); } +// ============================================================================ +// PWA Notifications +// ============================================================================ + +/** + * Request notification permission from user + * Stores result in localStorage + */ +async function requestNotificationPermission() { + if (!('Notification' in window)) { + showNotification('Powiadomienia nie są obsługiwane w tej przeglądarce', 'warning'); + return false; + } + + try { + const permission = await Notification.requestPermission(); + + if (permission === 'granted') { + localStorage.setItem('mc_notifications_enabled', 'true'); + updateNotificationToggleUI(); + showNotification('Powiadomienia zostały włączone', 'success'); + return true; + } else if (permission === 'denied') { + localStorage.setItem('mc_notifications_enabled', 'false'); + updateNotificationToggleUI(); + showNotification('Powiadomienia zostały zablokowane. Zmień ustawienia przeglądarki aby je włączyć.', 'warning'); + return false; + } + } catch (error) { + console.error('Error requesting notification permission:', error); + showNotification('Błąd podczas włączania powiadomień', 'danger'); + return false; + } +} + +/** + * Check current notification permission status + */ +function getNotificationPermission() { + if (!('Notification' in window)) { + return 'unsupported'; + } + return Notification.permission; +} + +/** + * Check if notifications are enabled by user + */ +function areNotificationsEnabled() { + return localStorage.getItem('mc_notifications_enabled') === 'true' && + getNotificationPermission() === 'granted'; +} + +/** + * Update notification toggle button UI + */ +function updateNotificationToggleUI() { + const toggleBtn = document.getElementById('notificationsToggle'); + const statusBadge = document.getElementById('notificationStatus'); + + if (!toggleBtn || !statusBadge) return; + + const permission = getNotificationPermission(); + + if (permission === 'unsupported') { + statusBadge.className = 'badge bg-secondary'; + statusBadge.textContent = 'Niedostępne'; + toggleBtn.disabled = true; + } else if (permission === 'denied') { + statusBadge.className = 'badge bg-danger'; + statusBadge.textContent = 'Zablokowane'; + toggleBtn.disabled = false; + } else if (permission === 'granted') { + statusBadge.className = 'badge bg-success'; + statusBadge.textContent = 'Włączone'; + toggleBtn.disabled = false; + } else { + statusBadge.className = 'badge bg-secondary'; + statusBadge.textContent = 'Wyłączone'; + toggleBtn.disabled = false; + } +} + +/** + * Handle notification toggle button click + */ +async function handleNotificationToggle() { + const permission = getNotificationPermission(); + + if (permission === 'granted') { + // Already granted - toggle off + localStorage.setItem('mc_notifications_enabled', 'false'); + updateNotificationToggleUI(); + showNotification('Powiadomienia zostały wyłączone', 'info'); + } else if (permission === 'denied') { + // Blocked - show help message + showNotification('Powiadomienia są zablokowane. Zmień ustawienia w przeglądarce: Ustawienia → Strony → mc-webui → Powiadomienia', 'warning'); + } else { + // Not yet requested - ask for permission + await requestNotificationPermission(); + } +} + +/** + * Send browser notification when new messages arrive + * @param {number} channelCount - Number of channels with new messages + * @param {number} dmCount - Number of DMs with new messages + * @param {number} pendingCount - Number of pending contacts + */ +function sendBrowserNotification(channelCount, dmCount, pendingCount) { + // Only send if enabled and app is hidden + if (!areNotificationsEnabled() || document.visibilityState !== 'hidden') { + return; + } + + let message = ''; + const parts = []; + + if (channelCount > 0) { + parts.push(`${channelCount} ${channelCount === 1 ? 'kanał' : 'kanały'}`); + } + if (dmCount > 0) { + parts.push(`${dmCount} ${dmCount === 1 ? 'wiadomość prywatna' : 'wiadomości prywatne'}`); + } + if (pendingCount > 0) { + parts.push(`${pendingCount} ${pendingCount === 1 ? 'oczekujący kontakt' : 'oczekujące kontakty'}`); + } + + if (parts.length === 0) return; + + message = `Nowe: ${parts.join(', ')}`; + + try { + const notification = new Notification('mc-webui', { + body: message, + icon: '/static/images/android-chrome-192x192.png', + badge: '/static/images/android-chrome-192x192.png', + tag: 'mc-webui-updates', // Prevents spam - replaces previous notification + requireInteraction: false, // Auto-dismiss after ~5s + silent: false + }); + + // Click handler - bring app to focus + notification.onclick = function() { + window.focus(); + notification.close(); + }; + + } catch (error) { + console.error('Error sending notification:', error); + } +} + +/** + * Track previous counts to detect NEW messages (not just unread) + */ +let previousTotalUnread = 0; +let previousDmUnread = 0; +let previousPendingCount = 0; + +/** + * Check if we should send notification based on count changes + */ +function checkAndNotify() { + // Calculate current totals + const currentTotalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); + + // Get DM unread count from badge + const dmBadge = document.querySelector('.fab-badge-dm'); + const currentDmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0; + + // Get pending contacts count from badge + const pendingBadge = document.querySelector('.fab-badge-pending'); + const currentPendingCount = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0; + + // Detect increases (new messages/contacts) + const channelIncrease = currentTotalUnread > previousTotalUnread; + const dmIncrease = currentDmUnread > previousDmUnread; + const pendingIncrease = currentPendingCount > previousPendingCount; + + // Send notification if ANY category increased + if (channelIncrease || dmIncrease || pendingIncrease) { + const channelDelta = channelIncrease ? (currentTotalUnread - previousTotalUnread) : 0; + const dmDelta = dmIncrease ? (currentDmUnread - previousDmUnread) : 0; + const pendingDelta = pendingIncrease ? (currentPendingCount - previousPendingCount) : 0; + + sendBrowserNotification(channelDelta, dmDelta, pendingDelta); + } + + // Update previous counts + previousTotalUnread = currentTotalUnread; + previousDmUnread = currentDmUnread; + previousPendingCount = currentPendingCount; +} + +/** + * Update app icon badge (Android/Desktop) + * Shows total unread count across channels + DMs + pending + */ +function updateAppBadge() { + if (!('setAppBadge' in navigator)) { + // Badge API not supported + return; + } + + // Calculate total unread + const channelUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); + + const dmBadge = document.querySelector('.fab-badge-dm'); + const dmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0; + + const pendingBadge = document.querySelector('.fab-badge-pending'); + const pendingUnread = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0; + + const totalUnread = channelUnread + dmUnread + pendingUnread; + + if (totalUnread > 0) { + navigator.setAppBadge(totalUnread).catch((error) => { + console.error('Error setting app badge:', error); + }); + } else { + navigator.clearAppBadge().catch((error) => { + console.error('Error clearing app badge:', error); + }); + } +} + /** * Update connection status indicator */ @@ -872,6 +1115,9 @@ async function checkForUpdates() { // Update UI badges updateUnreadBadges(); + // Check if we should send browser notification + checkAndNotify(); + // If current channel has updates, refresh the view const currentChannelUpdate = data.channels.find(ch => ch.index === currentChannelIdx); if (currentChannelUpdate && currentChannelUpdate.has_updates) { @@ -914,6 +1160,9 @@ function updateUnreadBadges() { // Update notification bell const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); updateNotificationBell(totalUnread); + + // Update app icon badge + updateAppBadge(); } /** @@ -1377,6 +1626,9 @@ async function checkDmUpdates() { // Update badges updateDmBadges(data.total_unread || 0); + + // Update app icon badge + updateAppBadge(); } } catch (error) { if (error.name !== 'AbortError') { @@ -1427,6 +1679,9 @@ async function updatePendingContactsBadge() { const count = data.pending?.length || 0; // Update FAB badge (orange badge on Contact Management button) updateFabBadge('.fab-contacts', 'fab-badge-pending', count); + + // Update app icon badge + updateAppBadge(); } } catch (error) { console.error('Error updating pending contacts badge:', error); diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 0ab2e90..c6f62b3 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -158,6 +158,9 @@ async function loadConversations() { if (convData.success) { dmConversations = convData.conversations || []; populateConversationSelector(); + + // Check for new DM notifications + checkDmNotifications(dmConversations); } else { console.error('Failed to load conversations:', convData.error); // Still populate selector with just contacts @@ -777,3 +780,55 @@ function showNotification(message, type = 'info') { }); toast.show(); } + +// ============================================================================ +// PWA Notifications for DM +// ============================================================================ + +/** + * Track previous DM unread for notifications + */ +let previousDmTotalUnread = 0; + +/** + * Check if we should send DM notification + */ +function checkDmNotifications(conversations) { + // Only check if notifications are enabled + // areNotificationsEnabled is defined in app.js and should be available globally + if (typeof areNotificationsEnabled === 'undefined' || !areNotificationsEnabled()) { + return; + } + + if (document.visibilityState !== 'hidden') { + return; + } + + // Calculate total DM unread + const currentDmTotalUnread = conversations.reduce((sum, conv) => sum + conv.unread_count, 0); + + // Detect increase + if (currentDmTotalUnread > previousDmTotalUnread) { + const delta = currentDmTotalUnread - previousDmTotalUnread; + + try { + const notification = new Notification('mc-webui', { + body: `Nowe wiadomości prywatne: ${delta}`, + icon: '/static/images/android-chrome-192x192.png', + badge: '/static/images/android-chrome-192x192.png', + tag: 'mc-webui-dm', + requireInteraction: false, + silent: false + }); + + notification.onclick = function() { + window.focus(); + notification.close(); + }; + } catch (error) { + console.error('Error sending DM notification:', error); + } + } + + previousDmTotalUnread = currentDmTotalUnread; +} diff --git a/app/static/js/sw.js b/app/static/js/sw.js new file mode 100644 index 0000000..dee3074 --- /dev/null +++ b/app/static/js/sw.js @@ -0,0 +1,41 @@ +const CACHE_NAME = 'mc-webui-v1'; +const ASSETS_TO_CACHE = [ + '/', + '/static/css/style.css', + '/static/js/app.js', + '/static/js/dm.js', + '/static/js/contacts.js', + '/static/js/message-utils.js', + '/static/images/android-chrome-192x192.png', + '/static/images/android-chrome-512x512.png' +]; + +// Install event - cache core assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(ASSETS_TO_CACHE)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - network first, fallback to cache (for dynamic content like messages) +self.addEventListener('fetch', (event) => { + event.respondWith( + fetch(event.request) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/app/static/manifest.json b/app/static/manifest.json index 536bb7c..098a1e0 100644 --- a/app/static/manifest.json +++ b/app/static/manifest.json @@ -1,19 +1,25 @@ { "name": "mc-webui", "short_name": "mc-webui", + "description": "Lightweight web interface for MeshCore mesh network chat", + "categories": ["communication", "utilities"], "icons": [ { "src": "/static/images/android-chrome-192x192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" }, { "src": "/static/images/android-chrome-512x512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" } ], "theme_color": "#0d6efd", "background_color": "#ffffff", - "display": "standalone" + "display": "standalone", + "start_url": "/", + "scope": "/" } diff --git a/app/templates/base.html b/app/templates/base.html index 253c31b..94142a0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,6 +63,19 @@ Manage Channels + +
@@ -277,6 +290,22 @@ }); + + + {% block extra_scripts %}{% endblock %}