mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-02 19:42:38 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user