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:
MarekWo
2026-04-29 22:48:33 +02:00
parent a335f521e4
commit a2d3111e1c
5 changed files with 215 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@@ -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'))
);

View File

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