diff --git a/app/read_status.py b/app/read_status.py index 756995b..0565581 100644 --- a/app/read_status.py +++ b/app/read_status.py @@ -24,8 +24,9 @@ READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json' def _get_default_status(): """Get default read status structure""" return { - 'channels': {}, # {"0": timestamp, "1": timestamp, ...} - 'dm': {} # {"name_User1": timestamp, "pk_abc123": timestamp, ...} + 'channels': {}, # {"0": timestamp, "1": timestamp, ...} + 'dm': {}, # {"name_User1": timestamp, "pk_abc123": timestamp, ...} + 'muted_channels': [] # [2, 5, 7] - channel indices with muted notifications } @@ -50,11 +51,13 @@ def load_read_status(): logger.warning("Invalid read status structure, resetting") return _get_default_status() - # Ensure both keys exist + # Ensure all keys exist if 'channels' not in status: status['channels'] = {} if 'dm' not in status: status['dm'] = {} + if 'muted_channels' not in status: + status['muted_channels'] = [] logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations") return status @@ -196,3 +199,78 @@ def get_dm_last_seen(conversation_id): except Exception as e: logger.error(f"Error getting last seen for DM {conversation_id}: {e}") return 0 + + +def get_muted_channels(): + """ + Get list of muted channel indices. + + Returns: + list: List of muted channel indices (integers) + """ + try: + status = load_read_status() + return status.get('muted_channels', []) + except Exception as e: + logger.error(f"Error getting muted channels: {e}") + return [] + + +def set_channel_muted(channel_idx, muted): + """ + Set mute state for a channel. + + Args: + channel_idx (int): Channel index + muted (bool): True to mute, False to unmute + + Returns: + bool: True if successful + """ + try: + status = load_read_status() + muted_list = status.get('muted_channels', []) + channel_idx = int(channel_idx) + + if muted and channel_idx not in muted_list: + muted_list.append(channel_idx) + elif not muted and channel_idx in muted_list: + muted_list.remove(channel_idx) + + status['muted_channels'] = muted_list + success = save_read_status(status) + + if success: + logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}") + return success + + except Exception as e: + logger.error(f"Error setting mute for channel {channel_idx}: {e}") + return False + + +def mark_all_channels_read(channel_timestamps): + """ + Mark all channels as read in bulk. + + Args: + channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...} + + Returns: + bool: True if successful + """ + try: + status = load_read_status() + + for channel_key, timestamp in channel_timestamps.items(): + status['channels'][str(channel_key)] = int(timestamp) + + success = save_read_status(status) + + if success: + logger.info(f"Marked {len(channel_timestamps)} channels as read") + return success + + except Exception as e: + logger.error(f"Error marking all channels as read: {e}") + return False diff --git a/app/routes/api.py b/app/routes/api.py index 88981b7..dc8f780 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1541,6 +1541,10 @@ def get_messages_updates(): if ts > last_seen_ts: channel_stats[ch_idx]['unread_count'] += 1 + # Get muted channels to exclude from total + from app import read_status as rs + muted_channels = set(rs.get_muted_channels()) + # Build response updates = [] total_unread = 0 @@ -1552,7 +1556,10 @@ def get_messages_updates(): last_seen_ts = last_seen.get(channel_idx, 0) has_updates = stats['latest_timestamp'] > last_seen_ts unread_count = stats['unread_count'] if has_updates else 0 - total_unread += unread_count + + # Only count unmuted channels toward total + if channel_idx not in muted_channels: + total_unread += unread_count updates.append({ 'index': channel_idx, @@ -1565,7 +1572,8 @@ def get_messages_updates(): return jsonify({ 'success': True, 'channels': updates, - 'total_unread': total_unread + 'total_unread': total_unread, + 'muted_channels': list(muted_channels) }), 200 except Exception as e: @@ -2569,7 +2577,8 @@ def get_read_status_api(): return jsonify({ 'success': True, 'channels': status['channels'], - 'dm': status['dm'] + 'dm': status['dm'], + 'muted_channels': status.get('muted_channels', []) }), 200 except Exception as e: @@ -2578,7 +2587,8 @@ def get_read_status_api(): 'success': False, 'error': str(e), 'channels': {}, - 'dm': {} + 'dm': {}, + 'muted_channels': [] }), 500 @@ -2945,6 +2955,65 @@ def mark_read_api(): }), 500 +@api_bp.route('/read_status/mark_all_read', methods=['POST']) +def mark_all_read_api(): + """Mark all channels as read in bulk.""" + try: + from app import read_status + + data = request.get_json() + if not data or 'channels' not in data: + return jsonify({'success': False, 'error': 'Missing channels timestamps'}), 400 + + success = read_status.mark_all_channels_read(data['channels']) + + if success: + return jsonify({'success': True, 'message': 'All channels marked as read'}), 200 + else: + return jsonify({'success': False, 'error': 'Failed to save'}), 500 + + except Exception as e: + logger.error(f"Error marking all as read: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/channels/muted', methods=['GET']) +def get_muted_channels_api(): + """Get list of muted channel indices.""" + try: + from app import read_status + muted = read_status.get_muted_channels() + return jsonify({'success': True, 'muted_channels': muted}), 200 + except Exception as e: + logger.error(f"Error getting muted channels: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/channels//mute', methods=['POST']) +def set_channel_muted_api(index): + """Set mute state for a channel.""" + try: + from app import read_status + + data = request.get_json() + if data is None or 'muted' not in data: + return jsonify({'success': False, 'error': 'Missing muted field'}), 400 + + success = read_status.set_channel_muted(index, data['muted']) + + if success: + return jsonify({ + 'success': True, + 'message': f'Channel {index} {"muted" if data["muted"] else "unmuted"}' + }), 200 + else: + return jsonify({'success': False, 'error': 'Failed to save'}), 500 + + except Exception as e: + logger.error(f"Error setting channel mute: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + # ============================================================ # Console History API # ============================================================ diff --git a/app/static/js/app.js b/app/static/js/app.js index 96964f2..a64e3eb 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -11,6 +11,7 @@ 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 +let mutedChannels = new Set(); // Channel indices with muted notifications // DM state (for badge updates on main page) let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation @@ -1354,8 +1355,13 @@ 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); + // Calculate current totals (exclude muted channels) + let currentTotalUnread = 0; + for (const [idx, count] of Object.entries(unreadCounts)) { + if (!mutedChannels.has(parseInt(idx))) { + currentTotalUnread += count; + } + } // Get DM unread count from badge const dmBadge = document.querySelector('.fab-badge-dm'); @@ -1395,8 +1401,13 @@ function updateAppBadge() { return; } - // Calculate total unread - const channelUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); + // Calculate total unread (exclude muted channels) + let channelUnread = 0; + for (const [idx, count] of Object.entries(unreadCounts)) { + if (!mutedChannels.has(parseInt(idx))) { + channelUnread += count; + } + } const dmBadge = document.querySelector('.fab-badge-dm'); const dmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0; @@ -1865,7 +1876,11 @@ async function loadLastSeenTimestampsFromServer() { for (const [key, value] of Object.entries(data.channels)) { lastSeenTimestamps[parseInt(key)] = value; } - console.log('Loaded channel read status from server:', lastSeenTimestamps); + // Load muted channels + if (data.muted_channels) { + mutedChannels = new Set(data.muted_channels); + } + console.log('Loaded channel read status from server:', lastSeenTimestamps, 'muted:', [...mutedChannels]); } else { console.warn('Failed to load read status from server, using empty state'); lastSeenTimestamps = {}; @@ -1913,6 +1928,39 @@ async function markChannelAsRead(channelIdx, timestamp) { updateUnreadBadges(); } +/** + * Mark all channels as read (bell icon click) + */ +async function markAllChannelsRead() { + // Collect latest timestamps - use current time for channels with unread + const now = Math.floor(Date.now() / 1000); + const timestamps = {}; + + for (const [idx, count] of Object.entries(unreadCounts)) { + if (count > 0) { + timestamps[idx] = now; + lastSeenTimestamps[parseInt(idx)] = now; + unreadCounts[idx] = 0; + } + } + + if (Object.keys(timestamps).length === 0) return; + + // Update UI immediately + updateUnreadBadges(); + + // Save to server + try { + await fetch('/api/read_status/mark_all_read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channels: timestamps }) + }); + } catch (error) { + console.error('Error marking all as read:', error); + } +} + /** * Check for new messages across all channels */ @@ -1949,6 +1997,11 @@ async function checkForUpdates() { unreadCounts[channel.index] = channel.unread_count; }); + // Sync muted channels from server + if (data.muted_channels) { + mutedChannels = new Set(data.muted_channels); + } + // Update UI badges updateUnreadBadges(); @@ -1985,8 +2038,8 @@ function updateUnreadBadges() { // 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) { + // Add badge if there are unread messages, not current channel, and not muted + if (unreadCount > 0 && channelIdx !== currentChannelIdx && !mutedChannels.has(channelIdx)) { option.textContent = `${channelName} (${unreadCount})`; } else { option.textContent = channelName; @@ -1994,8 +2047,13 @@ function updateUnreadBadges() { }); } - // Update notification bell - const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); + // Update notification bell (exclude muted channels) + let totalUnread = 0; + for (const [idx, count] of Object.entries(unreadCounts)) { + if (!mutedChannels.has(parseInt(idx))) { + totalUnread += count; + } + } updateNotificationBell(totalUnread); // Update app icon badge @@ -2265,6 +2323,7 @@ function displayChannelsList(channels) { const isPublic = channel.index === 0; + const isMuted = mutedChannels.has(channel.index); item.innerHTML = `
${escapeHtml(channel.name)} @@ -2272,6 +2331,11 @@ function displayChannelsList(channels) { ${channel.key}
+ @@ -2287,6 +2351,37 @@ function displayChannelsList(channels) { }); } +/** + * Toggle mute state for a channel + */ +async function toggleChannelMute(index) { + const newMuted = !mutedChannels.has(index); + + try { + const response = await fetch(`/api/channels/${index}/mute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ muted: newMuted }) + }); + const data = await response.json(); + + if (data.success) { + if (newMuted) { + mutedChannels.add(index); + } else { + mutedChannels.delete(index); + } + // Refresh modal list and badges + loadChannelsList(); + updateUnreadBadges(); + } else { + showNotification('Failed to update mute state', 'danger'); + } + } catch (error) { + showNotification('Failed to update mute state', 'danger'); + } +} + /** * Delete channel */ diff --git a/app/templates/base.html b/app/templates/base.html index 3e666e3..87d274b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -38,7 +38,7 @@ {% endif %}
-
+