mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-07 13:54:49 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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": "/"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user