diff --git a/app/routes/api.py b/app/routes/api.py index 588f9bc..ee6daa2 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1902,6 +1902,19 @@ def get_channel_qr(index): }), 500 +_MENTION_RE = re.compile(r'@\[([^\]]+)\]') + + +def _make_preview(content, max_len=60): + """Strip @[name] mention syntax and truncate content for channel list preview.""" + if not content: + return '' + stripped = _MENTION_RE.sub(r'\1', content) + if len(stripped) > max_len: + return stripped[:max_len] + '…' + return stripped + + @api_bp.route('/messages/updates', methods=['GET']) def get_messages_updates(): """ @@ -1973,12 +1986,16 @@ def get_messages_updates(): if ch_idx not in channel_stats: channel_stats[ch_idx] = { 'latest_timestamp': 0, - 'unread_count': 0 + 'unread_count': 0, + 'last_message_content': '', + 'last_message_sender': '' } - # Track latest timestamp per channel + # Track latest timestamp per channel (and capture its content/sender) if ts > channel_stats[ch_idx]['latest_timestamp']: channel_stats[ch_idx]['latest_timestamp'] = ts + channel_stats[ch_idx]['last_message_content'] = msg.get('content', '') + channel_stats[ch_idx]['last_message_sender'] = msg.get('sender', '') # Count unread messages (newer than last_seen) last_seen_ts = last_seen.get(ch_idx, 0) @@ -1995,7 +2012,12 @@ def get_messages_updates(): for channel in channels: channel_idx = channel['index'] - stats = channel_stats.get(channel_idx, {'latest_timestamp': 0, 'unread_count': 0}) + stats = channel_stats.get(channel_idx, { + 'latest_timestamp': 0, + 'unread_count': 0, + 'last_message_content': '', + 'last_message_sender': '' + }) last_seen_ts = last_seen.get(channel_idx, 0) has_updates = stats['latest_timestamp'] > last_seen_ts @@ -2010,7 +2032,9 @@ def get_messages_updates(): 'name': channel['name'], 'has_updates': has_updates, 'latest_timestamp': stats['latest_timestamp'], - 'unread_count': unread_count + 'unread_count': unread_count, + 'last_message_preview': _make_preview(stats.get('last_message_content', '')), + 'last_message_time': stats['latest_timestamp'] }) return jsonify({ diff --git a/app/static/css/style.css b/app/static/css/style.css index ec45288..a44ba87 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -52,17 +52,49 @@ main { } .channel-sidebar-item { - padding: 0.5rem 0.75rem; + padding: 0.45rem 0.75rem; cursor: pointer; display: flex; - align-items: center; - gap: 0.5rem; + flex-direction: column; + align-items: stretch; + gap: 0; border-bottom: 1px solid var(--border-light); font-size: 0.85rem; transition: background-color 0.15s; color: var(--text-primary); } +/* Top row: name + time + unread badge */ +.channel-item-top { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; +} + +.channel-last-time { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.channel-item-preview { + font-size: 0.72rem; + color: var(--text-muted); + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: 1.3; + margin-top: 0.1rem; + word-break: break-word; +} + +.channel-selector-item .channel-item-preview { + -webkit-line-clamp: 1; +} + .channel-sidebar-item:hover { background-color: var(--bg-hover); } @@ -124,11 +156,12 @@ main { } .channel-selector-item { - padding: 0.5rem 0.75rem; + padding: 0.45rem 0.75rem; cursor: pointer; display: flex; - align-items: center; - gap: 0.5rem; + flex-direction: column; + align-items: stretch; + gap: 0; border-bottom: 1px solid var(--border-light); font-size: 0.88rem; font-weight: 400; diff --git a/app/static/js/app.js b/app/static/js/app.js index 833cd07..a2000aa 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -10,6 +10,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 channelLastMessages = {}; // channel_idx -> {preview, timestamp} let mutedChannels = new Set(); // Channel indices with muted notifications // DM state (for badge updates on main page) @@ -422,12 +423,21 @@ function connectChatSocket() { if (data.type === 'dm' && blockedContactNames.has(data.sender)) return; if (data.type === 'channel') { + // Update last-message preview/time for this channel (for sidebar/dropdown) + if (typeof data.content === 'string' && typeof data.timestamp === 'number') { + channelLastMessages[data.channel_idx] = { + preview: makeChannelPreview(data.content), + timestamp: data.timestamp + }; + } // Update unread count for this channel if (data.channel_idx !== currentChannelIdx) { unreadCounts[data.channel_idx] = (unreadCounts[data.channel_idx] || 0) + 1; updateUnreadBadges(); checkAndNotify(); } else if (!currentArchiveDate) { + // Refresh sidebar preview even for the active channel + updateChannelSidebarBadges(); // Skip own messages — already appended optimistically on send if (data.is_own) return; // Current channel and live view — append message directly (no full reload) @@ -3300,6 +3310,32 @@ function escapeHtml(text) { return div.innerHTML; } +/** + * Format a Unix timestamp for the channel list: HH:MM if today, DD.MM otherwise. + */ +function formatChannelTime(unixTimestamp) { + if (!unixTimestamp) return ''; + const d = new Date(unixTimestamp * 1000); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + if (isToday) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + } + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + return `${day}.${month}`; +} + +/** + * Mirror of backend _make_preview: strip @[name] mention syntax, truncate to 60 chars with ellipsis. + */ +function makeChannelPreview(text, maxLen = 60) { + if (!text) return ''; + const stripped = text.replace(/@\[([^\]]+)\]/g, '$1'); + if (stripped.length > maxLen) return stripped.slice(0, maxLen) + '…'; + return stripped; +} + // ============================================================================= // Avatar Generation Functions // ============================================================================= @@ -3514,9 +3550,15 @@ async function checkForUpdates() { const data = await response.json(); if (data.success && data.channels) { - // Update unread counts + // Update unread counts and last-message preview/time data.channels.forEach(channel => { unreadCounts[channel.index] = channel.unread_count; + if (channel.last_message_preview !== undefined) { + channelLastMessages[channel.index] = { + preview: channel.last_message_preview, + timestamp: channel.last_message_time + }; + } }); // Sync muted channels from server @@ -3804,17 +3846,39 @@ function renderChannelDropdownItems(query) { item.classList.add('muted'); } + // Top row: name + time + unread badge + const topRow = document.createElement('div'); + topRow.className = 'channel-item-top'; + const nameSpan = document.createElement('span'); nameSpan.className = 'channel-name'; nameSpan.textContent = channel.name; - item.appendChild(nameSpan); + topRow.appendChild(nameSpan); + + const lastMsg = channelLastMessages[channel.index]; + if (lastMsg && lastMsg.timestamp) { + const timeSpan = document.createElement('span'); + timeSpan.className = 'channel-last-time'; + timeSpan.textContent = formatChannelTime(lastMsg.timestamp); + topRow.appendChild(timeSpan); + } const unread = unreadCounts[channel.index] || 0; if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) { const badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; badge.textContent = unread; - item.appendChild(badge); + topRow.appendChild(badge); + } + + item.appendChild(topRow); + + // Preview row (CSS clamps to 1 line for .channel-selector-item) + if (lastMsg && lastMsg.preview) { + const preview = document.createElement('div'); + preview.className = 'channel-item-preview'; + preview.textContent = lastMsg.preview; + item.appendChild(preview); } item.addEventListener('click', () => { @@ -3949,18 +4013,39 @@ function populateChannelSidebar() { item.classList.add('muted'); } + // Top row: name + time + unread badge + const topRow = document.createElement('div'); + topRow.className = 'channel-item-top'; + const nameSpan = document.createElement('span'); nameSpan.className = 'channel-name'; nameSpan.textContent = channel.name; - item.appendChild(nameSpan); + topRow.appendChild(nameSpan); + + const lastMsg = channelLastMessages[channel.index]; + if (lastMsg && lastMsg.timestamp) { + const timeSpan = document.createElement('span'); + timeSpan.className = 'channel-last-time'; + timeSpan.textContent = formatChannelTime(lastMsg.timestamp); + topRow.appendChild(timeSpan); + } - // Unread badge const unread = unreadCounts[channel.index] || 0; if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) { const badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; badge.textContent = unread; - item.appendChild(badge); + topRow.appendChild(badge); + } + + item.appendChild(topRow); + + // Preview row (only if non-empty preview exists) + if (lastMsg && lastMsg.preview) { + const preview = document.createElement('div'); + preview.className = 'channel-item-preview'; + preview.textContent = lastMsg.preview; + item.appendChild(preview); } item.addEventListener('click', () => { @@ -4001,22 +4086,53 @@ function updateChannelSidebarBadges() { const idx = parseInt(item.dataset.channelIdx); const unread = unreadCounts[idx] || 0; const isMuted = mutedChannels.has(idx); + const lastMsg = channelLastMessages[idx]; // Update muted state item.classList.toggle('muted', isMuted); + const topRow = item.querySelector('.channel-item-top'); + if (!topRow) return; + + // Update or remove time label (insert before badge if present, else append) + let timeEl = topRow.querySelector('.channel-last-time'); + if (lastMsg && lastMsg.timestamp) { + if (!timeEl) { + timeEl = document.createElement('span'); + timeEl.className = 'channel-last-time'; + const badgeEl = topRow.querySelector('.sidebar-unread-badge'); + topRow.insertBefore(timeEl, badgeEl); // insertBefore(x, null) == appendChild + } + timeEl.textContent = formatChannelTime(lastMsg.timestamp); + } else if (timeEl) { + timeEl.remove(); + } + // Update or remove badge - let badge = item.querySelector('.sidebar-unread-badge'); + let badge = topRow.querySelector('.sidebar-unread-badge'); if (unread > 0 && idx !== currentChannelIdx && !isMuted) { if (!badge) { badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; - item.appendChild(badge); + topRow.appendChild(badge); } badge.textContent = unread; } else if (badge) { badge.remove(); } + + // Update or remove preview row + let preview = item.querySelector('.channel-item-preview'); + if (lastMsg && lastMsg.preview) { + if (!preview) { + preview = document.createElement('div'); + preview.className = 'channel-item-preview'; + item.appendChild(preview); + } + preview.textContent = lastMsg.preview; + } else if (preview) { + preview.remove(); + } }); // Re-render mobile dropdown if currently visible (badges/muted state)