feat: Add PWA notification support with badge counters

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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-05 12:29:17 +01:00
parent 34ab437390
commit ecb3618da7
6 changed files with 399 additions and 3 deletions
+10
View File
@@ -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;
}
+255
View File
@@ -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);
+55
View File
@@ -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;
}
+41
View File
@@ -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))
);
});
+9 -3
View File
@@ -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": "/"
}
+29
View File
@@ -63,6 +63,19 @@
<i class="bi bi-broadcast-pin" style="font-size: 1.5rem;"></i>
<span>Manage Channels</span>
</button>
<!-- Notifications Toggle -->
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button">
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-center">
<span>Powiadomienia</span>
<span id="notificationStatus" class="badge bg-secondary">Wyłączone</span>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">
Działa gdy app w tle
</small>
</div>
</button>
<div class="list-group-item">
<div class="d-flex align-items-center gap-3 mb-2">
<i class="bi bi-calendar3" style="font-size: 1.5rem;"></i>
@@ -277,6 +290,22 @@
});
</script>
<!-- Service Worker Registration for PWA -->
<script>
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/js/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration.scope);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
});
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>