diff --git a/app/database.py b/app/database.py index 6a4d653..6cbf30e 100644 --- a/app/database.py +++ b/app/database.py @@ -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 # ================================================================ diff --git a/app/read_status.py b/app/read_status.py index af81ed3..54ad759 100644 --- a/app/read_status.py +++ b/app/read_status.py @@ -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. diff --git a/app/routes/api.py b/app/routes/api.py index d7746df..8c4d629 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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//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 # ============================================================ diff --git a/app/schema.sql b/app/schema.sql index 769f7c8..e7e1636 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -164,6 +164,7 @@ CREATE TABLE IF NOT EXISTS read_status ( key TEXT PRIMARY KEY, -- 'chan_0', 'dm_', 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')) ); diff --git a/app/static/js/app.js b/app/static/js/app.js index 3518c47..2d9413d 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 ? ` ${escapeHtml(scope.name)}` : ''}
+