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
+104 -9
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
*/