diff --git a/app/routes/api.py b/app/routes/api.py index 8006afc..9f767ee 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -643,3 +643,103 @@ def get_channel_qr(index): 'success': False, 'error': str(e) }), 500 + + +@api_bp.route('/messages/updates', methods=['GET']) +def get_messages_updates(): + """ + Check for new messages across all channels without fetching full message content. + Used for intelligent refresh mechanism and unread notifications. + + Query parameters: + last_seen (str): JSON object with last seen timestamps per channel + Format: {"0": 1234567890, "1": 1234567891, ...} + + Returns: + JSON with update information per channel: + { + "success": true, + "channels": [ + { + "index": 0, + "name": "Public", + "has_updates": true, + "latest_timestamp": 1234567900, + "unread_count": 5 + }, + ... + ], + "total_unread": 10 + } + """ + try: + # Parse last_seen timestamps from query param + last_seen_str = request.args.get('last_seen', '{}') + try: + last_seen = json.loads(last_seen_str) + # Convert keys to integers and values to floats + last_seen = {int(k): float(v) for k, v in last_seen.items()} + except (json.JSONDecodeError, ValueError): + last_seen = {} + + # Get list of channels + success_ch, channels = cli.get_channels() + if not success_ch: + return jsonify({ + 'success': False, + 'error': 'Failed to get channels' + }), 500 + + updates = [] + total_unread = 0 + + # Check each channel for new messages + for channel in channels: + channel_idx = channel['index'] + + # 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 + + updates.append({ + 'index': channel_idx, + 'name': channel['name'], + 'has_updates': has_updates, + 'latest_timestamp': latest_timestamp, + 'unread_count': unread_count + }) + + return jsonify({ + 'success': True, + 'channels': updates, + 'total_unread': total_unread + }), 200 + + except Exception as e: + logger.error(f"Error checking message updates: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/static/css/style.css b/app/static/css/style.css index 33bd99d..4b3b34b 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 3a61cd8..3313e96 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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); } diff --git a/app/templates/base.html b/app/templates/base.html index 9ec9adf..8bc4cac 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -36,6 +36,9 @@ +
+ +