feat(notifications): Channel mute toggle and mark-all-as-read bell button

Add ability to mute notifications for individual channels via Manage
Channels modal (bell/bell-slash toggle button). Muted channels are
excluded from unread badge counts, browser notifications, and app icon
badge. Bell icon click now marks all channels as read in bulk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-02-23 22:00:40 +01:00
parent ad478a8d47
commit 7a4f4d3161
4 changed files with 259 additions and 17 deletions

View File

@@ -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

View File

@@ -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/<int:index>/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
# ============================================================

View File

@@ -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 = `
<div>
<strong>${escapeHtml(channel.name)}</strong>
@@ -2272,6 +2331,11 @@ function displayChannelsList(channels) {
<small class="text-muted font-monospace">${channel.key}</small>
</div>
<div class="btn-group btn-group-sm">
<button class="btn ${isMuted ? 'btn-secondary' : 'btn-outline-secondary'}"
onclick="toggleChannelMute(${channel.index})"
title="${isMuted ? 'Unmute notifications' : 'Mute notifications'}">
<i class="bi ${isMuted ? 'bi-bell-slash' : 'bi-bell'}"></i>
</button>
<button class="btn btn-outline-primary" onclick="shareChannel(${channel.index})" title="Share">
<i class="bi bi-share"></i>
</button>
@@ -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
*/

View File

@@ -38,7 +38,7 @@
{% endif %}
</span>
<div class="d-flex align-items-center gap-2">
<div id="notificationBell" class="btn btn-outline-light btn-sm position-relative" style="cursor: default;" title="Unread messages">
<div id="notificationBell" class="btn btn-outline-light btn-sm position-relative" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
<i class="bi bi-bell"></i>
</div>
<select id="channelSelector" class="form-select form-select-sm" style="width: auto; min-width: 100px;" title="Select channel">