feat: Implement intelligent refresh and multi-channel notifications

Replaces the blind 60-second refresh with a smart polling system that only updates the UI when new messages actually arrive.

Key improvements:
- Lightweight update checks every 10 seconds (vs full refresh every 60s)
- Chat view refreshes only when new messages appear on active channel
- Notification bell with global unread count across all channels
- Per-channel unread badges in channel selector (e.g., "Malopolska (3)")
- Last-seen timestamp tracking per channel with localStorage persistence
- Bell ring animation when new messages arrive

Backend changes:
- New /api/messages/updates endpoint for efficient update polling
- Returns per-channel update status and unread counts

Frontend changes:
- Smart auto-refresh mechanism with conditional UI updates
- Unread message tracking system with localStorage
- Notification bell UI component with badge
- Channel selector badges for unread messages
- CSS animations for bell ring effect

This dramatically reduces network traffic and server load while providing better UX through instant notifications about activity on other channels.

🤖 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
2025-12-23 18:12:53 +01:00
parent 3faf39c3dc
commit 76134b133a
5 changed files with 429 additions and 8 deletions
+48
View File
@@ -253,3 +253,51 @@ main {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Notification Bell Badge */
.notification-badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #dc3545;
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 0.65rem;
font-weight: bold;
line-height: 1;
min-width: 18px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
}
/* Bell Ring Animation */
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-8deg); }
30% { transform: rotate(14deg); }
40% { transform: rotate(-4deg); }
50% { transform: rotate(10deg); }
60% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
.bell-ring {
animation: bell-ring 1s ease-in-out;
transform-origin: 50% 4px;
}
/* Notification Bell Container */
#notificationBell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
#notificationBell:hover .notification-badge {
transform: scale(1.1);
transition: transform 0.2s ease;
}
+159 -8
View File
@@ -9,11 +9,16 @@ let isUserScrolling = false;
let currentArchiveDate = null; // Current selected archive date (null = live)
let currentChannelIdx = 0; // Current active channel (0 = Public)
let availableChannels = []; // List of channels from API
let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
let unreadCounts = {}; // Track unread message counts per channel
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('mc-webui initialized');
// Load last seen timestamps from localStorage
loadLastSeenTimestamps();
// Load channels list
loadChannels();
@@ -69,8 +74,9 @@ function setupEventListeners() {
});
// Manual refresh button
document.getElementById('refreshBtn').addEventListener('click', function() {
loadMessages();
document.getElementById('refreshBtn').addEventListener('click', async function() {
await loadMessages();
await checkForUpdates();
});
// Date selector (archive selection)
@@ -260,6 +266,12 @@ function displayMessages(messages) {
}
lastMessageCount = messages.length;
// Mark current channel as read (update last seen timestamp to latest message)
if (messages.length > 0 && !currentArchiveDate) {
const latestTimestamp = Math.max(...messages.map(m => m.timestamp));
markChannelAsRead(currentChannelIdx, latestTimestamp);
}
}
/**
@@ -425,16 +437,23 @@ async function cleanupContacts() {
}
/**
* Setup auto-refresh
* Setup intelligent auto-refresh
* Checks for updates regularly but only refreshes UI when new messages arrive
*/
function setupAutoRefresh() {
const interval = window.MC_CONFIG?.refreshInterval || 60000;
// Check every 10 seconds for new messages (lightweight check)
const checkInterval = 10000;
autoRefreshInterval = setInterval(() => {
loadMessages();
}, interval);
autoRefreshInterval = setInterval(async () => {
// Don't check for updates when viewing archives
if (currentArchiveDate) {
return;
}
console.log(`Auto-refresh enabled: every ${interval / 1000}s`);
await checkForUpdates();
}, checkInterval);
console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`);
}
/**
@@ -587,6 +606,135 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* Load last seen timestamps from localStorage
*/
function loadLastSeenTimestamps() {
try {
const saved = localStorage.getItem('mc_last_seen_timestamps');
if (saved) {
lastSeenTimestamps = JSON.parse(saved);
console.log('Loaded last seen timestamps:', lastSeenTimestamps);
}
} catch (error) {
console.error('Error loading last seen timestamps:', error);
lastSeenTimestamps = {};
}
}
/**
* Save last seen timestamps to localStorage
*/
function saveLastSeenTimestamps() {
try {
localStorage.setItem('mc_last_seen_timestamps', JSON.stringify(lastSeenTimestamps));
} catch (error) {
console.error('Error saving last seen timestamps:', error);
}
}
/**
* Update last seen timestamp for current channel
*/
function markChannelAsRead(channelIdx, timestamp) {
lastSeenTimestamps[channelIdx] = timestamp;
unreadCounts[channelIdx] = 0;
saveLastSeenTimestamps();
updateUnreadBadges();
}
/**
* Check for new messages across all channels
*/
async function checkForUpdates() {
try {
// Build query with last seen timestamps
const lastSeenParam = encodeURIComponent(JSON.stringify(lastSeenTimestamps));
const response = await fetch(`/api/messages/updates?last_seen=${lastSeenParam}`);
const data = await response.json();
if (data.success) {
// Update unread counts
data.channels.forEach(channel => {
unreadCounts[channel.index] = channel.unread_count;
});
// Update UI badges
updateUnreadBadges();
// If current channel has updates, refresh the view
const currentChannelUpdate = data.channels.find(ch => ch.index === currentChannelIdx);
if (currentChannelUpdate && currentChannelUpdate.has_updates) {
console.log(`New messages detected on channel ${currentChannelIdx}, refreshing...`);
await loadMessages();
}
}
} catch (error) {
console.error('Error checking for updates:', error);
}
}
/**
* Update unread badges on channel selector and notification bell
*/
function updateUnreadBadges() {
// Update channel selector options
const selector = document.getElementById('channelSelector');
if (selector) {
Array.from(selector.options).forEach(option => {
const channelIdx = parseInt(option.value);
const unreadCount = unreadCounts[channelIdx] || 0;
// Get base channel name (remove existing badge if any)
let channelName = option.textContent.replace(/\s*\(\d+\)$/, '');
// Add badge if there are unread messages and it's not the current channel
if (unreadCount > 0 && channelIdx !== currentChannelIdx) {
option.textContent = `${channelName} (${unreadCount})`;
} else {
option.textContent = channelName;
}
});
}
// Update notification bell
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
updateNotificationBell(totalUnread);
}
/**
* Update notification bell icon with unread count
*/
function updateNotificationBell(count) {
const bellContainer = document.getElementById('notificationBell');
if (!bellContainer) return;
const bellIcon = bellContainer.querySelector('i');
let badge = bellContainer.querySelector('.notification-badge');
if (count > 0) {
// Show badge
if (!badge) {
badge = document.createElement('span');
badge.className = 'notification-badge';
bellContainer.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline-block';
// Animate bell icon
if (bellIcon) {
bellIcon.classList.add('bell-ring');
setTimeout(() => bellIcon.classList.remove('bell-ring'), 1000);
}
} else {
// Hide badge
if (badge) {
badge.style.display = 'none';
}
}
}
/**
* Setup emoji picker
*/
@@ -657,6 +805,9 @@ 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();
} else {
console.error('[loadChannels] Error loading channels:', data.error);
}