mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-01 19:12:36 +02:00
feat(channels): sort sidebar by latest activity, with favorite tier
Channels in the sidebar and mobile dropdown now sort by most recent message first, with favorited channels pinned above non-favorites. Reordering is push-driven via the existing new_message socket event: the affected item is moved to the top of its tier in the DOM, no full re-render. Favorites are toggled via a star icon in Manage Channels and persisted in read_status.is_favorite for cross-device sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,12 @@ class Database:
|
||||
conn.execute("ALTER TABLE echoes ADD COLUMN hash_size INTEGER NOT NULL DEFAULT 1")
|
||||
logger.info("Migration: added echoes.hash_size column")
|
||||
|
||||
# Add is_favorite column to read_status (channel favorites)
|
||||
rs_columns = {r[1] for r in conn.execute("PRAGMA table_info(read_status)").fetchall()}
|
||||
if 'is_favorite' not in rs_columns:
|
||||
conn.execute("ALTER TABLE read_status ADD COLUMN is_favorite INTEGER DEFAULT 0")
|
||||
logger.info("Migration: added read_status.is_favorite column")
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
"""Yield a connection with auto-commit/rollback."""
|
||||
@@ -1130,6 +1136,26 @@ class Database:
|
||||
).fetchall()
|
||||
return [int(r['key'][5:]) for r in rows]
|
||||
|
||||
def set_channel_favorite(self, channel_idx: int, favorite: bool) -> None:
|
||||
key = f"chan_{channel_idx}"
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO read_status (key, is_favorite)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
is_favorite = excluded.is_favorite,
|
||||
updated_at = datetime('now')""",
|
||||
(key, 1 if favorite else 0)
|
||||
)
|
||||
|
||||
def get_favorite_channels(self) -> List[int]:
|
||||
"""Get list of favorite channel indices."""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key FROM read_status WHERE is_favorite = 1 AND key LIKE 'chan_%'"
|
||||
).fetchall()
|
||||
return [int(r['key'][5:]) for r in rows]
|
||||
|
||||
# ================================================================
|
||||
# Full-Text Search
|
||||
# ================================================================
|
||||
|
||||
@@ -30,6 +30,7 @@ def load_read_status():
|
||||
channels = {}
|
||||
dm = {}
|
||||
muted_channels = []
|
||||
favorite_channels = []
|
||||
|
||||
for key, row in rows.items():
|
||||
if key.startswith('chan_'):
|
||||
@@ -40,6 +41,11 @@ def load_read_status():
|
||||
muted_channels.append(int(chan_idx))
|
||||
except ValueError:
|
||||
pass
|
||||
if row.get('is_favorite'):
|
||||
try:
|
||||
favorite_channels.append(int(chan_idx))
|
||||
except ValueError:
|
||||
pass
|
||||
elif key.startswith('dm_'):
|
||||
conv_id = key[3:] # "dm_name_User1" -> "name_User1"
|
||||
dm[conv_id] = row['last_seen_ts']
|
||||
@@ -48,11 +54,12 @@ def load_read_status():
|
||||
'channels': channels,
|
||||
'dm': dm,
|
||||
'muted_channels': muted_channels,
|
||||
'favorite_channels': favorite_channels,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading read status: {e}")
|
||||
return {'channels': {}, 'dm': {}, 'muted_channels': []}
|
||||
return {'channels': {}, 'dm': {}, 'muted_channels': [], 'favorite_channels': []}
|
||||
|
||||
|
||||
def save_read_status(status):
|
||||
@@ -126,6 +133,28 @@ def set_channel_muted(channel_idx, muted):
|
||||
return False
|
||||
|
||||
|
||||
def get_favorite_channels():
|
||||
"""Get list of favorite channel indices."""
|
||||
try:
|
||||
db = _get_db()
|
||||
return db.get_favorite_channels()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting favorite channels: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def set_channel_favorite(channel_idx, favorite):
|
||||
"""Set favorite state for a channel."""
|
||||
try:
|
||||
db = _get_db()
|
||||
db.set_channel_favorite(int(channel_idx), favorite)
|
||||
logger.info(f"Channel {channel_idx} {'favorited' if favorite else 'unfavorited'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting favorite for channel {channel_idx}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def mark_all_channels_read(channel_timestamps):
|
||||
"""Mark all channels as read in bulk.
|
||||
|
||||
|
||||
@@ -2006,6 +2006,7 @@ def get_messages_updates():
|
||||
# Get muted channels to exclude from total
|
||||
from app import read_status as rs
|
||||
muted_channels = set(rs.get_muted_channels())
|
||||
favorite_channels = rs.get_favorite_channels()
|
||||
|
||||
# Build response
|
||||
updates = []
|
||||
@@ -2042,7 +2043,8 @@ def get_messages_updates():
|
||||
'success': True,
|
||||
'channels': updates,
|
||||
'total_unread': total_unread,
|
||||
'muted_channels': list(muted_channels)
|
||||
'muted_channels': list(muted_channels),
|
||||
'favorite_channels': favorite_channels
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
@@ -4230,7 +4232,8 @@ def get_read_status_api():
|
||||
'success': True,
|
||||
'channels': status['channels'],
|
||||
'dm': status['dm'],
|
||||
'muted_channels': status.get('muted_channels', [])
|
||||
'muted_channels': status.get('muted_channels', []),
|
||||
'favorite_channels': status.get('favorite_channels', [])
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
@@ -4240,7 +4243,8 @@ def get_read_status_api():
|
||||
'error': str(e),
|
||||
'channels': {},
|
||||
'dm': {},
|
||||
'muted_channels': []
|
||||
'muted_channels': [],
|
||||
'favorite_channels': []
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -4666,6 +4670,43 @@ def set_channel_muted_api(index):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/channels/favorites', methods=['GET'])
|
||||
def get_favorite_channels_api():
|
||||
"""Get list of favorite channel indices."""
|
||||
try:
|
||||
from app import read_status
|
||||
favorites = read_status.get_favorite_channels()
|
||||
return jsonify({'success': True, 'favorite_channels': favorites}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting favorite channels: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/channels/<int:index>/favorite', methods=['POST'])
|
||||
def set_channel_favorite_api(index):
|
||||
"""Set favorite state for a channel."""
|
||||
try:
|
||||
from app import read_status
|
||||
|
||||
data = request.get_json()
|
||||
if data is None or 'favorite' not in data:
|
||||
return jsonify({'success': False, 'error': 'Missing favorite field'}), 400
|
||||
|
||||
success = read_status.set_channel_favorite(index, data['favorite'])
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Channel {index} {"favorited" if data["favorite"] else "unfavorited"}'
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Failed to save'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting channel favorite: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Console History API
|
||||
# ============================================================
|
||||
|
||||
@@ -164,6 +164,7 @@ CREATE TABLE IF NOT EXISTS read_status (
|
||||
key TEXT PRIMARY KEY, -- 'chan_0', 'dm_<pubkey>', etc.
|
||||
last_seen_ts INTEGER DEFAULT 0, -- unix timestamp
|
||||
is_muted INTEGER DEFAULT 0, -- 1 = muted (channels only)
|
||||
is_favorite INTEGER DEFAULT 0, -- 1 = favorite (channels only)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
|
||||
let unreadCounts = {}; // Track unread message counts per channel
|
||||
let channelLastMessages = {}; // channel_idx -> {preview, timestamp}
|
||||
let mutedChannels = new Set(); // Channel indices with muted notifications
|
||||
let favoriteChannels = new Set(); // Channel indices marked as favorites (always shown above non-favorites)
|
||||
|
||||
// DM state (for badge updates on main page)
|
||||
let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation
|
||||
@@ -430,6 +431,8 @@ function connectChatSocket() {
|
||||
timestamp: data.timestamp
|
||||
};
|
||||
}
|
||||
// Reorder: move this channel to the top of its tier in sidebar + dropdown
|
||||
moveChannelToTopOfTier(data.channel_idx);
|
||||
// Update unread count for this channel
|
||||
if (data.channel_idx !== currentChannelIdx) {
|
||||
unreadCounts[data.channel_idx] = (unreadCounts[data.channel_idx] || 0) + 1;
|
||||
@@ -3796,7 +3799,11 @@ async function loadLastSeenTimestampsFromServer() {
|
||||
if (data.muted_channels) {
|
||||
mutedChannels = new Set(data.muted_channels);
|
||||
}
|
||||
console.log('Loaded channel read status from server:', lastSeenTimestamps, 'muted:', [...mutedChannels]);
|
||||
// Load favorite channels
|
||||
if (data.favorite_channels) {
|
||||
favoriteChannels = new Set(data.favorite_channels);
|
||||
}
|
||||
console.log('Loaded channel read status from server:', lastSeenTimestamps, 'muted:', [...mutedChannels], 'favorites:', [...favoriteChannels]);
|
||||
} else {
|
||||
console.warn('Failed to load read status from server, using empty state');
|
||||
lastSeenTimestamps = {};
|
||||
@@ -3935,10 +3942,19 @@ async function checkForUpdates() {
|
||||
if (data.muted_channels) {
|
||||
mutedChannels = new Set(data.muted_channels);
|
||||
}
|
||||
// Sync favorite channels from server
|
||||
if (data.favorite_channels) {
|
||||
favoriteChannels = new Set(data.favorite_channels);
|
||||
}
|
||||
|
||||
// Update UI badges
|
||||
updateUnreadBadges();
|
||||
|
||||
// Re-render channel lists now that we have last-message timestamps
|
||||
// (initial paint runs before checkForUpdates returns, so the first
|
||||
// sort would otherwise be all-zeros).
|
||||
populateChannelSelector(availableChannels);
|
||||
|
||||
// Check if we should send browser notification
|
||||
checkAndNotify();
|
||||
}
|
||||
@@ -4147,6 +4163,23 @@ function ensurePublicChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a channel array by (favorite-first, latest-message-desc, original-index).
|
||||
* Returns a new array; does not mutate the input. Channels with no recorded
|
||||
* last message fall to the bottom of their tier in original order (stable sort).
|
||||
*/
|
||||
function sortedChannelsByFavoriteAndActivity(channels) {
|
||||
return channels
|
||||
.map((ch, i) => ({
|
||||
ch,
|
||||
i,
|
||||
fav: favoriteChannels.has(ch.index) ? 0 : 1,
|
||||
ts: (channelLastMessages[ch.index] && channelLastMessages[ch.index].timestamp) || 0
|
||||
}))
|
||||
.sort((a, b) => (a.fav - b.fav) || (b.ts - a.ts) || (a.i - b.i))
|
||||
.map(x => x.ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate channel selector data (for both mobile dropdown and wide-screen sidebar)
|
||||
*/
|
||||
@@ -4164,8 +4197,8 @@ function populateChannelSelector(channels) {
|
||||
localStorage.setItem('mc_active_channel', '0');
|
||||
}
|
||||
|
||||
// Save data for the mobile dropdown
|
||||
window._channelDropdownItems = channels;
|
||||
// Save data for the mobile dropdown (sorted by favorite + latest activity)
|
||||
window._channelDropdownItems = sortedChannelsByFavoriteAndActivity(channels);
|
||||
|
||||
// Pre-render dropdown contents (still hidden) and update input display
|
||||
renderChannelDropdownItems('');
|
||||
@@ -4215,6 +4248,9 @@ function renderChannelDropdownItems(query) {
|
||||
if (mutedChannels.has(channel.index)) {
|
||||
item.classList.add('muted');
|
||||
}
|
||||
if (favoriteChannels.has(channel.index)) {
|
||||
item.classList.add('is-favorite');
|
||||
}
|
||||
|
||||
// Top row: name + time + unread badge
|
||||
const topRow = document.createElement('div');
|
||||
@@ -4336,6 +4372,7 @@ function displayChannelsList(channels) {
|
||||
const isPublic = channel.index === 0;
|
||||
|
||||
const isMuted = mutedChannels.has(channel.index);
|
||||
const isFavorite = favoriteChannels.has(channel.index);
|
||||
const scope = (window.channelScopes || {})[String(channel.index)];
|
||||
const hasScope = !!scope;
|
||||
const scopeTitle = hasScope
|
||||
@@ -4347,6 +4384,11 @@ function displayChannelsList(channels) {
|
||||
${hasScope ? `<span class="badge bg-info text-dark ms-2" style="font-size:0.7em;"><i class="bi bi-pin-map"></i> ${escapeHtml(scope.name)}</span>` : ''}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn ${isFavorite ? 'btn-warning' : 'btn-outline-warning'}"
|
||||
onclick="toggleChannelFavorite(${channel.index})"
|
||||
title="${isFavorite ? 'Unfavorite channel' : 'Favorite channel'}">
|
||||
<i class="bi ${isFavorite ? 'bi-star-fill' : 'bi-star'}"></i>
|
||||
</button>
|
||||
<button class="btn ${isMuted ? 'btn-secondary' : 'btn-outline-secondary'}"
|
||||
onclick="toggleChannelMute(${channel.index})"
|
||||
title="${isMuted ? 'Unmute notifications' : 'Mute notifications'}">
|
||||
@@ -4380,9 +4422,10 @@ function populateChannelSidebar() {
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
const channels = availableChannels.length > 0
|
||||
const baseChannels = availableChannels.length > 0
|
||||
? availableChannels
|
||||
: [{index: 0, name: 'Public', key: ''}];
|
||||
const channels = sortedChannelsByFavoriteAndActivity(baseChannels);
|
||||
|
||||
channels.forEach(channel => {
|
||||
if (!channel || typeof channel.index === 'undefined' || !channel.name) return;
|
||||
@@ -4397,6 +4440,9 @@ function populateChannelSidebar() {
|
||||
if (mutedChannels.has(channel.index)) {
|
||||
item.classList.add('muted');
|
||||
}
|
||||
if (favoriteChannels.has(channel.index)) {
|
||||
item.classList.add('is-favorite');
|
||||
}
|
||||
|
||||
// Top row: name + time + unread badge
|
||||
const topRow = document.createElement('div');
|
||||
@@ -4460,6 +4506,39 @@ function updateChannelSidebarActive() {
|
||||
updateChannelInputDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a channel's <li>/<div> to the top of its tier (favorite or non-favorite)
|
||||
* in both the sidebar and the mobile dropdown. Pure DOM move — does not rebuild
|
||||
* the list, so the active highlight, scroll, and event listeners survive.
|
||||
*/
|
||||
function moveChannelToTopOfTier(channelIdx) {
|
||||
const isFav = favoriteChannels.has(channelIdx);
|
||||
const idxStr = String(channelIdx);
|
||||
|
||||
const moveIn = (containerId, childClass) => {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const item = container.querySelector(`.${childClass}[data-channel-idx="${idxStr}"]`);
|
||||
if (!item) return; // channel not currently rendered (e.g. dropdown filtered)
|
||||
|
||||
if (isFav) {
|
||||
if (item !== container.firstElementChild) container.prepend(item);
|
||||
return;
|
||||
}
|
||||
// Non-favorite: insert before the first non-favorite sibling.
|
||||
const firstNonFav = container.querySelector(`:scope > .${childClass}:not(.is-favorite)`);
|
||||
if (!firstNonFav) {
|
||||
// No non-favorite tier yet — append after the favorites block.
|
||||
container.appendChild(item);
|
||||
} else if (firstNonFav !== item) {
|
||||
container.insertBefore(item, firstNonFav);
|
||||
}
|
||||
};
|
||||
|
||||
moveIn('channelSidebarList', 'channel-sidebar-item');
|
||||
moveIn('channelSelectorDropdown', 'channel-selector-item');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unread badges on channel sidebar
|
||||
*/
|
||||
@@ -4559,6 +4638,37 @@ async function toggleChannelMute(index) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite state for a channel
|
||||
*/
|
||||
async function toggleChannelFavorite(index) {
|
||||
const newFavorite = !favoriteChannels.has(index);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channels/${index}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ favorite: newFavorite })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (newFavorite) {
|
||||
favoriteChannels.add(index);
|
||||
} else {
|
||||
favoriteChannels.delete(index);
|
||||
}
|
||||
// Refresh modal (star icon) and rebuild sidebar/dropdown to apply new tier
|
||||
loadChannelsList();
|
||||
populateChannelSelector(availableChannels);
|
||||
} else {
|
||||
showNotification('Failed to update favorite state', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Failed to update favorite state', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete channel
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user