feat: show last message time and preview in channel list

Each channel item in the desktop sidebar and the mobile dropdown now
surfaces the timestamp of the last message (HH:MM today / DD.MM older)
and a truncated plain-text preview (up to 60 chars, mentions stripped).
Sidebar clamps preview to 2 lines, dropdown to 1 line. Empty channels
render as a single-line name, unchanged.

- api.py: /api/messages/updates returns last_message_preview +
  last_message_time; new _make_preview helper strips @[name] syntax
  and truncates with ellipsis.
- app.js: new channelLastMessages state populated by the poll loop and
  by the new_message socket event; populateChannelSidebar,
  renderChannelDropdownItems, and updateChannelSidebarBadges build and
  maintain the two-row layout (.channel-item-top + .channel-item-preview).
- style.css: sidebar and dropdown items switch to column flex; new
  .channel-item-top, .channel-last-time, .channel-item-preview rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-23 07:43:02 +02:00
parent 57a0ca018d
commit daf9c5c0db
3 changed files with 191 additions and 18 deletions

View File

@@ -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({

View File

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

View File

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