fix: store display names separately to preserve channel handle identifiers

- Add DisplayName field to ChannelInfo in the wire protocol
- Add displayNames map to Feed; SetChannelDisplayName no longer mutates
  f.channels, keeping handles stable for cache keys and management
- Public fetcher: extract title via extractChannelTitle, pass to feed
- Telegram fetcher: capture ch.Title / user.FirstName from MTProto API
- Frontend: render DisplayName as sidebar label, keep Name as identifier
This commit is contained in:
Sepehr
2026-04-18 11:27:03 -04:00
parent d2922e6afb
commit dd77610f18
5 changed files with 53 additions and 23 deletions
+26 -2
View File
@@ -81,6 +81,7 @@ type Metadata struct {
// ChannelInfo describes a single feed channel.
type ChannelInfo struct {
Name string
DisplayName string // human-readable title; empty means fall back to Name
Blocks uint16
LastMsgID uint32
ContentHash uint32 // CRC32 of serialized message data; changes on edits
@@ -104,12 +105,16 @@ func ContentHashOf(msgs []Message) uint32 {
// SerializeMetadata encodes metadata into bytes for channel 0 blocks.
// Format: marker(3) + timestamp(4) + nextFetch(4) + flags(1) + channelCount(2) + per-channel data
// Per-channel: nameLen(1) + name + blocks(2) + lastMsgID(4) + contentHash(4) + chatType(1) + flags(1)
// Per-channel: nameLen(1) + name + blocks(2) + lastMsgID(4) + contentHash(4) + chatType(1) + flags(1) + displayNameLen(1) + displayName
func SerializeMetadata(m *Metadata) []byte {
// 3 marker + 4 timestamp + 4 nextFetch + 1 flags + 2 channel count + per-channel data
size := MarkerSize + 4 + 4 + 1 + 2
for _, ch := range m.Channels {
size += 1 + len(ch.Name) + 2 + 4 + 4 + 1 + 1 // +4 for contentHash
dn := ch.DisplayName
if len(dn) > 255 {
dn = dn[:255]
}
size += 1 + len(ch.Name) + 2 + 4 + 4 + 1 + 1 + 1 + len(dn)
}
buf := make([]byte, size)
off := 0
@@ -156,6 +161,14 @@ func SerializeMetadata(m *Metadata) []byte {
}
buf[off] = chFlags
off++
dnBytes := []byte(ch.DisplayName)
if len(dnBytes) > 255 {
dnBytes = dnBytes[:255]
}
buf[off] = byte(len(dnBytes))
off++
copy(buf[off:], dnBytes)
off += len(dnBytes)
}
return buf
@@ -213,8 +226,19 @@ func ParseMetadata(data []byte) (*Metadata, error) {
chFlags := data[off]
off++
var displayName string
if off < len(data) {
dnLen := int(data[off])
off++
if off+dnLen <= len(data) {
displayName = string(data[off : off+dnLen])
off += dnLen
}
}
m.Channels = append(m.Channels, ChannelInfo{
Name: name,
DisplayName: displayName,
Blocks: blocks,
LastMsgID: lastID,
ContentHash: contentHash,
+11 -7
View File
@@ -14,6 +14,7 @@ type Feed struct {
mu sync.RWMutex
marker [protocol.MarkerSize]byte
channels []string
displayNames map[int]string
blocks map[int][][]byte
lastIDs map[int]uint32
contentHashes map[int]uint32
@@ -31,6 +32,7 @@ type Feed struct {
func NewFeed(channels []string) *Feed {
f := &Feed{
channels: channels,
displayNames: make(map[int]string),
blocks: make(map[int][][]byte),
lastIDs: make(map[int]uint32),
contentHashes: make(map[int]uint32),
@@ -135,6 +137,7 @@ func (f *Feed) rebuildMetaBlocks() {
}
meta.Channels = append(meta.Channels, protocol.ChannelInfo{
Name: name,
DisplayName: f.displayNames[chNum],
Blocks: blockCount,
LastMsgID: f.lastIDs[chNum],
ContentHash: f.contentHashes[chNum],
@@ -211,19 +214,20 @@ func (f *Feed) SetChannels(channels []string) {
f.rebuildMetaBlocks()
}
// SetChannelDisplayName updates the display name for a specific channel number (1-indexed).
// This allows replacing the raw handle (e.g. "networkti") with the channel's
// actual title (e.g. "Sarto") after it has been fetched.
// SetChannelDisplayName stores a human-readable title for a channel (1-indexed).
// It never mutates the handle in f.channels, which remains the stable identifier.
func (f *Feed) SetChannelDisplayName(channelNum int, displayName string) {
if displayName == "" {
return
}
f.mu.Lock()
defer f.mu.Unlock()
idx := channelNum - 1
if idx < 0 || idx >= len(f.channels) {
if channelNum < 1 || channelNum > len(f.channels) {
return
}
if displayName == "" || f.channels[idx] == displayName {
if f.displayNames[channelNum] == displayName {
return
}
f.channels[idx] = displayName
f.displayNames[channelNum] = displayName
f.rebuildMetaBlocks()
}
+2 -6
View File
@@ -144,9 +144,7 @@ func (pr *PublicReader) fetchAll(ctx context.Context) {
pr.feed.UpdateChannel(chNum, msgs)
pr.feed.SetChatInfo(chNum, protocol.ChatTypeChannel, false)
if title != "" {
pr.feed.SetChannelDisplayName(chNum, title)
}
pr.feed.SetChannelDisplayName(chNum, title)
fetched++
log.Printf("[public] updated %s (%s): %d messages", username, title, len(msgs))
}
@@ -178,12 +176,10 @@ func (pr *PublicReader) fetchChannel(ctx context.Context, username string) ([]pr
if err != nil {
return nil, "", err
}
title := extractChannelTitle(body)
return msgs, title, nil
return msgs, extractChannelTitle(body), nil
}
// extractChannelTitle parses the channel display name from the Telegram public page.
// It looks for <div class="tgme_channel_info_header_title"><span>Title</span></div>.
func extractChannelTitle(body []byte) string {
doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
+5 -1
View File
@@ -79,6 +79,7 @@ type resolvedPeer struct {
peer tg.InputPeerClass
chatType protocol.ChatType
canSend bool
title string
}
type cachedMessages struct {
@@ -267,8 +268,9 @@ func (tr *TelegramReader) fetchAll(ctx context.Context, api *tg.Client) {
// Update feed with messages and chat type info
tr.feed.UpdateChannel(chNum, msgs)
tr.feed.SetChatInfo(chNum, rp.chatType, rp.canSend)
tr.feed.SetChannelDisplayName(chNum, rp.title)
fetched++
log.Printf("[telegram] updated %s: %d messages (type=%d, canSend=%v)", username, len(msgs), rp.chatType, rp.canSend)
log.Printf("[telegram] updated %s (%s): %d messages (type=%d, canSend=%v)", username, rp.title, len(msgs), rp.chatType, rp.canSend)
}
log.Printf("[telegram] fetch cycle done in %s: %d fetched, %d failed, %d total", time.Since(start).Round(time.Millisecond), fetched, failed, len(tr.channels))
}
@@ -294,6 +296,7 @@ func (tr *TelegramReader) resolvePeer(ctx context.Context, api *tg.Client, usern
},
chatType: protocol.ChatTypeChannel,
canSend: canSend,
title: ch.Title,
}, nil
}
}
@@ -308,6 +311,7 @@ func (tr *TelegramReader) resolvePeer(ctx context.Context, api *tg.Client, usern
},
chatType: protocol.ChatTypePrivate,
canSend: true,
title: user.FirstName,
}, nil
}
}
+9 -7
View File
@@ -3085,7 +3085,8 @@
var needsFullRebuild = false;
for (var ci = 0; ci < channels.length; ci++) {
var ch = channels[ci], nm = ch.Name || ch.name || 'Channel ' + (ci + 1);
if (existingItems[ci].dataset.name !== nm) { needsFullRebuild = true; break }
var lbl = ch.DisplayName || ch.displayName || nm;
if (existingItems[ci].dataset.name !== nm || existingItems[ci].dataset.label !== lbl) { needsFullRebuild = true; break }
}
if (!needsFullRebuild) {
for (var ui = 0; ui < channels.length; ui++) {
@@ -3113,21 +3114,22 @@
if (title) h += '<div class="channel-section-title">' + esc(title) + '</div>';
for (var j = 0; j < items.length; j++) {
var e = items[j], num2 = e.idx + 1;
var name = e.ch.Name || e.ch.name || 'Channel ' + num2;
var handle = e.ch.Name || e.ch.name || 'Channel ' + num2;
var label = e.ch.DisplayName || e.ch.displayName || handle;
var ct2 = e.ch.ChatType || e.ch.chatType || 0;
var isPriv = e.ch.ChatType === 1 || e.ch.chatType === 1;
var isX = ct2 === 2;
var avatarName = name;
if (isX && avatarName.toLowerCase().indexOf('x/') === 0) avatarName = avatarName.substring(2);
var avatarName = label;
if (isX && handle.toLowerCase().indexOf('x/') === 0) avatarName = handle.substring(2);
if (avatarName.charAt(0) === '@') avatarName = avatarName.substring(1);
var avatarText = (avatarName || '?').charAt(0).toUpperCase();
var active = num2 === selectedChannel ? ' active' : '';
var lastID = e.ch.LastMsgID || e.ch.lastMsgID || 0;
var chNm2 = e.ch.Name || e.ch.name || '';
var badge = (previousMsgIDs[chNm2] > 0 && lastID > previousMsgIDs[chNm2] && num2 !== selectedChannel) ? '<span class="ch-badge">NEW</span>' : '';
h += '<div class="ch-item' + active + '" data-name="' + escAttr(name) + '" onclick="selectChannel(' + num2 + ')">';
h += '<div class="ch-item' + active + '" data-name="' + escAttr(handle) + '" data-label="' + escAttr(label) + '" onclick="selectChannel(' + num2 + ')">';
h += '<div class="ch-avatar">' + esc(avatarText) + '</div>';
h += '<div class="ch-info"><div class="ch-name">' + esc(name) + (isPriv ? '<span class="ch-type-tag">' + t('private') + '</span>' : (isX ? '<span class="ch-type-tag x-tag">' + t('x_label') + '</span>' : '')) + '</div>';
h += '<div class="ch-info"><div class="ch-name">' + esc(label) + (isPriv ? '<span class="ch-type-tag">' + t('private') + '</span>' : (isX ? '<span class="ch-type-tag x-tag">' + t('x_label') + '</span>' : '')) + '</div>';
h += '<div class="ch-preview">' + badge + '</div></div></div>';
}
return h;
@@ -3154,7 +3156,7 @@
currentMaxTimestamp = 0;
newMsgScrollDone = false;
openChat();
var ch = channels[num - 1]; var name = (ch && (ch.Name || ch.name)) || 'Channel ' + num;
var ch = channels[num - 1]; var name = (ch && (ch.DisplayName || ch.displayName || ch.Name || ch.name)) || 'Channel ' + num;
document.getElementById('chatName').textContent = name;
renderChannels(); updateSendPanel();
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';