Merge pull request #20 from sepehr-alipour/feat/channel-display-name

feat: channel display names & mobile UX improvements
This commit is contained in:
Sarto
2026-04-22 16:15:45 +03:30
committed by GitHub
11 changed files with 504 additions and 40 deletions
+53 -2
View File
@@ -54,8 +54,10 @@ type Cache struct {
}
type cachedChannel struct {
Messages []protocol.Message `json:"messages"`
FetchedAt int64 `json:"fetched_at"`
Name string `json:"name,omitempty"`
Messages []protocol.Message `json:"messages"`
FetchedAt int64 `json:"fetched_at"`
DisplayName string `json:"display_name,omitempty"`
}
type cachedMeta struct {
@@ -159,6 +161,55 @@ func (c *Cache) PutMetadata(meta *protocol.Metadata) error {
return os.WriteFile(filepath.Join(c.dir, "metadata.json"), data, 0600)
}
// GetAllTitles reads the display name from every ch_*.json cache file and returns
// a map of original channel name → display name. Files without a stored name or
// display name are skipped silently.
func (c *Cache) GetAllTitles() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
entries, err := os.ReadDir(c.dir)
if err != nil {
return nil
}
titles := make(map[string]string)
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "ch_") || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(c.dir, e.Name()))
if err != nil {
continue
}
var cc cachedChannel
if json.Unmarshal(data, &cc) != nil || cc.Name == "" || cc.DisplayName == "" {
continue
}
titles[cc.Name] = cc.DisplayName
}
return titles
}
// PutTitle persists a display name for a channel into its cache file.
// If the file already exists it is updated in-place so that stored messages are preserved.
func (c *Cache) PutTitle(channelName, title string) error {
c.mu.Lock()
defer c.mu.Unlock()
path := c.channelPath(channelName)
var cc cachedChannel
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &cc)
}
cc.Name = channelName
cc.DisplayName = title
data, err := json.Marshal(cc)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// Cleanup removes channel cache files (ch_*.json) not modified in 7 days.
func (c *Cache) Cleanup() error {
c.mu.Lock()
+57
View File
@@ -682,6 +682,63 @@ func (f *Fetcher) FetchLatestVersion(ctx context.Context) (string, error) {
return protocol.DecodeVersionData(data)
}
// FetchTitles fetches and decodes the channel display name map from TitlesChannel.
// Returns an empty map (not an error) when the server does not support TitlesChannel.
// Block 0 carries a uint16 total-block count prefix; remaining blocks are fetched in
// parallel so the overall fetch is bounded by the slowest single block, not the sum.
func (f *Fetcher) FetchTitles(ctx context.Context) (map[string]string, error) {
fetchCtx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
block0, err := f.FetchBlock(fetchCtx, protocol.TitlesChannel, 0)
if err != nil || len(block0) < 2 {
return map[string]string{}, nil
}
totalBlocks := int(binary.BigEndian.Uint16(block0))
payload0 := block0[2:]
if totalBlocks <= 1 {
titles, _ := protocol.DecodeTitlesData(payload0)
if titles == nil {
titles = map[string]string{}
}
return titles, nil
}
// Fetch remaining blocks in parallel.
type blockResult struct {
data []byte
err error
}
results := make([]blockResult, totalBlocks)
results[0] = blockResult{data: payload0}
var wg sync.WaitGroup
for blk := 1; blk < totalBlocks; blk++ {
wg.Add(1)
go func(blk int) {
defer wg.Done()
data, fetchErr := f.FetchBlock(fetchCtx, protocol.TitlesChannel, uint16(blk))
results[blk] = blockResult{data: data, err: fetchErr}
}(blk)
}
wg.Wait()
var allData []byte
for _, r := range results {
if r.err != nil {
return map[string]string{}, nil
}
allData = append(allData, r.data...)
}
titles, _ := protocol.DecodeTitlesData(allData)
if titles == nil {
titles = map[string]string{}
}
return titles, nil
}
// ErrContentHashMismatch is returned when the fetched messages do not match
// the expected content hash from metadata. This typically means the server
// regenerated its blocks between the metadata fetch and the block fetch
+3
View File
@@ -32,6 +32,9 @@ const (
// VersionChannel serves latest release version with random suffix.
VersionChannel uint16 = 0xFFFA
// TitlesChannel serves per-channel human-readable display names.
TitlesChannel uint16 = 0xFFF9
// MaxUpstreamBlockPayload keeps uploaded query chunks comfortably below DNS
// name limits across typical domains and resolver paths.
MaxUpstreamBlockPayload = 8
+75 -1
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
@@ -109,7 +110,7 @@ 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
size += 1 + len(ch.Name) + 2 + 4 + 4 + 1 + 1
}
buf := make([]byte, size)
off := 0
@@ -385,6 +386,79 @@ func CompressMessages(data []byte) []byte {
return append([]byte{compressionDeflate}, compressed...)
}
// EncodeTitlesData encodes a name→title map into bytes for TitlesChannel blocks.
// Format: count(2) + [nameLen(1)+name+titleLen(1)+title]*count
func EncodeTitlesData(titles map[string]string) []byte {
size := 2
for name, title := range titles {
n := name
if len(n) > 255 {
n = n[:255]
}
t := title
if len([]byte(t)) > 255 {
t = string([]byte(t)[:255])
}
size += 1 + len(n) + 1 + len([]byte(t))
}
buf := make([]byte, size)
binary.BigEndian.PutUint16(buf, uint16(len(titles)))
off := 2
for name, title := range titles {
nb := []byte(name)
if len(nb) > 255 {
nb = nb[:255]
}
tb := []byte(title)
if len(tb) > 255 {
tb = tb[:255]
}
buf[off] = byte(len(nb))
off++
copy(buf[off:], nb)
off += len(nb)
buf[off] = byte(len(tb))
off++
copy(buf[off:], tb)
off += len(tb)
}
return buf
}
// DecodeTitlesData decodes a name→title map from bytes produced by EncodeTitlesData.
func DecodeTitlesData(data []byte) (map[string]string, error) {
if len(data) < 2 {
return nil, fmt.Errorf("titles data too short: %d bytes", len(data))
}
count := int(binary.BigEndian.Uint16(data))
titles := make(map[string]string, count)
off := 2
for i := 0; i < count; i++ {
if off >= len(data) {
return nil, fmt.Errorf("truncated titles data at entry %d", i)
}
nameLen := int(data[off])
off++
if off+nameLen > len(data) {
return nil, fmt.Errorf("truncated title name at entry %d", i)
}
name := string(data[off : off+nameLen])
off += nameLen
if off >= len(data) {
return nil, fmt.Errorf("truncated titles data at title %d", i)
}
titleLen := int(data[off])
off++
if off+titleLen > len(data) {
return nil, fmt.Errorf("truncated title value at entry %d", i)
}
title := string(data[off : off+titleLen])
off += titleLen
titles[name] = title
}
return titles, nil
}
// DecompressMessages decompresses data produced by CompressMessages.
// Reads the 1-byte header to determine the compression type.
func DecompressMessages(data []byte) ([]byte, error) {
+57
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
@@ -21,6 +22,7 @@ type Feed struct {
canSend map[int]bool
metaBlocks [][]byte // metadata for all channels
versionBlocks [][]byte // channel for latest server-known release version
titlesBlocks [][]byte // channel for per-channel display names
updated time.Time
telegramLoggedIn bool
nextFetch uint32
@@ -31,6 +33,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),
@@ -40,6 +43,7 @@ func NewFeed(channels []string) *Feed {
f.rotateMarker()
f.rebuildMetaBlocks()
f.rebuildVersionBlocks()
f.rebuildTitlesBlocks()
return f
}
@@ -81,6 +85,9 @@ func (f *Feed) GetBlock(channel, block int) ([]byte, error) {
if channel == int(protocol.VersionChannel) {
return f.getVersionBlock(block)
}
if channel == int(protocol.TitlesChannel) {
return f.getTitlesBlock(block)
}
ch, ok := f.blocks[channel]
if !ok {
@@ -146,6 +153,38 @@ func (f *Feed) rebuildMetaBlocks() {
f.metaBlocks = protocol.SplitIntoBlocks(protocol.SerializeMetadata(&meta))
}
func (f *Feed) getTitlesBlock(block int) ([]byte, error) {
blocks := f.titlesBlocks
if len(blocks) == 0 {
f.rebuildTitlesBlocks()
blocks = f.titlesBlocks
}
if block < 0 || block >= len(blocks) {
return nil, fmt.Errorf("titles block %d out of range (%d blocks)", block, len(blocks))
}
return blocks[block], nil
}
// rebuildTitlesBlocks re-serializes the display name map and splits it into blocks.
// Block 0 is prefixed with a uint16 total-block count so the client can fetch all
// remaining blocks in parallel after reading the first one.
// Must be called with f.mu held.
func (f *Feed) rebuildTitlesBlocks() {
titles := make(map[string]string, len(f.channels))
for i, name := range f.channels {
chNum := i + 1
if dn := f.displayNames[chNum]; dn != "" {
titles[name] = dn
}
}
blocks := protocol.SplitIntoBlocks(protocol.EncodeTitlesData(titles))
if len(blocks) > 0 {
prefix := []byte{byte(len(blocks) >> 8), byte(len(blocks))}
blocks[0] = append(prefix, blocks[0]...)
}
f.titlesBlocks = blocks
}
func (f *Feed) rebuildVersionBlocks() {
block, err := protocol.EncodeVersionData(f.latestVersion)
if err != nil {
@@ -210,3 +249,21 @@ func (f *Feed) SetChannels(channels []string) {
f.channels = channels
f.rebuildMetaBlocks()
}
// 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()
if channelNum < 1 || channelNum > len(f.channels) {
return
}
if f.displayNames[channelNum] == displayName {
return
}
f.displayNames[channelNum] = displayName
f.rebuildTitlesBlocks()
}
+26 -8
View File
@@ -123,7 +123,7 @@ func (pr *PublicReader) fetchAll(ctx context.Context) {
continue
}
msgs, err := pr.fetchChannel(ctx, username)
msgs, title, err := pr.fetchChannel(ctx, username)
if err != nil {
log.Printf("[public] fetch %s: %v", username, err)
failed++
@@ -144,34 +144,52 @@ func (pr *PublicReader) fetchAll(ctx context.Context) {
pr.feed.UpdateChannel(chNum, msgs)
pr.feed.SetChatInfo(chNum, protocol.ChatTypeChannel, false)
pr.feed.SetChannelDisplayName(chNum, title)
fetched++
log.Printf("[public] updated %s: %d messages", username, len(msgs))
log.Printf("[public] updated %s (%s): %d messages", username, title, len(msgs))
}
log.Printf("[public] fetch cycle done in %s: %d fetched, %d failed, %d total", time.Since(start).Round(time.Millisecond), fetched, failed, len(pr.channels))
}
func (pr *PublicReader) fetchChannel(ctx context.Context, username string) ([]protocol.Message, error) {
func (pr *PublicReader) fetchChannel(ctx context.Context, username string) ([]protocol.Message, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pr.baseURL+"/"+url.PathEscape(username), nil)
if err != nil {
return nil, err
return nil, "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; thefeed/1.0; +https://github.com/sartoopjj/thefeed)")
resp, err := pr.client.Do(req)
if err != nil {
return nil, err
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
return nil, "", fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, "", err
}
return parsePublicMessages(body)
msgs, err := parsePublicMessages(body)
if err != nil {
return nil, "", err
}
return msgs, extractChannelTitle(body), nil
}
// extractChannelTitle parses the channel display name from the Telegram public page.
func extractChannelTitle(body []byte) string {
doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
return ""
}
titleNode := findFirstByClass(doc, "tgme_channel_info_header_title")
if titleNode == nil {
return ""
}
return strings.TrimSpace(extractInnerText(titleNode))
}
type publicMessage struct {
+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
}
}
+17 -10
View File
@@ -184,13 +184,17 @@ func (xr *XPublicReader) fetchAll(ctx context.Context) {
continue
}
msgs, err := xr.fetchAccount(ctx, account)
msgs, title, err := xr.fetchAccount(ctx, account)
if err != nil {
log.Printf("[x] fetch @%s: all instances failed: %v", account, err)
failed++
continue
}
if title != "" {
xr.feed.SetChannelDisplayName(chNum, title)
}
if ok && len(cached.msgs) > 0 {
msgs = mergeMessages(cached.msgs, msgs)
}
@@ -209,7 +213,7 @@ func (xr *XPublicReader) fetchAll(ctx context.Context) {
log.Printf("[x] fetch cycle done in %s: %d fetched, %d failed, %d total", time.Since(start).Round(time.Millisecond), fetched, failed, len(xr.accounts))
}
func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]protocol.Message, error) {
func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]protocol.Message, string, error) {
var lastErr error
for _, instance := range xr.instances {
u := strings.TrimSuffix(instance, "/") + "/" + url.PathEscape(username) + "/rss"
@@ -243,7 +247,7 @@ func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]p
continue
}
msgs, err := parseXRSSMessages(body, username)
msgs, title, err := parseXRSSMessages(body, username)
if err != nil {
log.Printf("[x] @%s: instance %s: parse error: %v", username, instance, err)
lastErr = fmt.Errorf("%s: %w", instance, err)
@@ -262,16 +266,17 @@ func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]p
lastErr = fmt.Errorf("%s: all %d messages were garbled", instance, len(msgs))
continue
}
return cleaned, nil
return cleaned, title, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no Nitter instances configured")
}
return nil, lastErr
return nil, "", lastErr
}
type xRSS struct {
Channel struct {
Title string `xml:"title"`
Items []xRSSItem `xml:"item"`
} `xml:"channel"`
}
@@ -285,16 +290,18 @@ type xRSSItem struct {
PubDate string `xml:"pubDate"`
}
func parseXRSSMessages(body []byte, feedUser string) ([]protocol.Message, error) {
func parseXRSSMessages(body []byte, feedUser string) ([]protocol.Message, string, error) {
body = sanitizeUTF8(body)
var feed xRSS
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("parse rss: %w", err)
return nil, "", fmt.Errorf("parse rss: %w", err)
}
if len(feed.Channel.Items) == 0 {
return nil, fmt.Errorf("empty rss feed")
return nil, "", fmt.Errorf("empty rss feed")
}
title := strings.TrimSpace(feed.Channel.Title)
feedUserLower := strings.ToLower(strings.TrimPrefix(feedUser, "@"))
msgs := make([]protocol.Message, 0, len(feed.Channel.Items))
for _, item := range feed.Channel.Items {
@@ -325,9 +332,9 @@ func parseXRSSMessages(body []byte, feedUser string) ([]protocol.Message, error)
msgs = append(msgs, protocol.Message{ID: id, Timestamp: ts, Text: text})
}
if len(msgs) == 0 {
return nil, fmt.Errorf("no parseable posts")
return nil, "", fmt.Errorf("no parseable posts")
}
return msgs, nil
return msgs, title, nil
}
// extractLinkUsername extracts the username from a Nitter/X status URL.
+7 -7
View File
@@ -24,7 +24,7 @@ func TestParseXRSSMessages(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "test")
msgs, _, err := parseXRSSMessages(body, "test")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -51,7 +51,7 @@ func TestParseXRSSMessages_MediaOnlyFallback(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "test")
msgs, _, err := parseXRSSMessages(body, "test")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -75,7 +75,7 @@ func TestParseXRSSMessages_AlternateIDFormat(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "test")
msgs, _, err := parseXRSSMessages(body, "test")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -161,7 +161,7 @@ func TestParseXRSSMessages_Retweet(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "myaccount")
msgs, _, err := parseXRSSMessages(body, "myaccount")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -186,7 +186,7 @@ func TestParseXRSSMessages_RetweetByFormat(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "myaccount")
msgs, _, err := parseXRSSMessages(body, "myaccount")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -211,7 +211,7 @@ func TestParseXRSSMessages_QuoteTweet(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "account")
msgs, _, err := parseXRSSMessages(body, "account")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
@@ -241,7 +241,7 @@ func TestParseXRSSMessages_PureRetweet(t *testing.T) {
</item>
</channel></rss>`)
msgs, err := parseXRSSMessages(body, "RezaVaisi")
msgs, _, err := parseXRSSMessages(body, "RezaVaisi")
if err != nil {
t.Fatalf("parseXRSSMessages: %v", err)
}
+88 -9
View File
@@ -329,6 +329,19 @@
text-overflow: ellipsis
}
.ch-sub {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: .7
}
.ch-item.active .ch-sub {
color: rgba(255,255,255,.75)
}
.ch-badge {
background: #fff;
color: var(--accent);
@@ -1453,6 +1466,42 @@
}
/* ===== MOBILE ===== */
.header-kebab {
position: relative;
display: none
}
.header-kebab-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,.2);
min-width: 150px;
z-index: 100;
display: none;
flex-direction: column;
overflow: hidden
}
.header-kebab-menu.open { display: flex }
.header-kebab-menu button {
width: 100%;
padding: 11px 16px;
background: none;
border: none;
color: var(--text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left
}
.header-kebab-menu button:hover { background: var(--hover) }
@media(max-width:768px) {
.app {
position: relative;
@@ -1494,6 +1543,10 @@
display: flex
}
.header-search-btn, .header-export-btn { display: none }
.header-kebab { display: flex }
.msg {
max-width: 90%
}
@@ -1569,17 +1622,25 @@
<button class="back-btn" onclick="openSidebar()">&#8592;</button>
<div class="chat-header-info">
<div class="chat-header-name" id="chatName">thefeed</div>
<div class="chat-header-sub" id="chatSub"></div>
</div>
<div class="chat-header-actions">
<span class="next-fetch-label" id="nextFetchTimer"></span><span class="next-fetch-info" id="nextFetchInfoBtn"
style="display:none" data-i18n-title="next_fetch_info" title="" onclick="showToast(t('next_fetch_info'))"
tabindex="0"></span>
<button class="icon-btn" onclick="toggleMsgSearch()" title="Search" data-i18n-title="search_messages"
<button class="icon-btn header-search-btn" onclick="toggleMsgSearch()" title="Search" data-i18n-title="search_messages"
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="search_messages">Search</button>
<button class="icon-btn" onclick="openExportModal()" title="Export" data-i18n-title="export_messages"
<button class="icon-btn header-export-btn" onclick="openExportModal()" title="Export" data-i18n-title="export_messages"
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="export_messages">Export</button>
<button class="icon-btn" id="refreshBtn" onclick="doRefreshUI()" title="Refresh"
style="width:40px;height:40px;font-size:20px">&#8635;</button>
<div class="header-kebab" id="headerKebab">
<button class="icon-btn" onclick="toggleKebabMenu(event)" title="More" style="width:40px;height:40px;font-size:20px">&#8942;</button>
<div class="header-kebab-menu" id="headerKebabMenu">
<button onclick="toggleMsgSearch();closeKebabMenu()" data-i18n="search_messages">Search</button>
<button onclick="openExportModal();closeKebabMenu()" data-i18n="export_messages">Export</button>
</div>
</div>
</div>
</div>
<div class="msg-search-bar" id="msgSearchBar">
@@ -3085,7 +3146,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 +3175,24 @@
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>';
var chSubText = !isX ? (handle.charAt(0) === '@' ? handle : '@' + handle) : '';
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>';
if (chSubText) h += '<div class="ch-sub">' + esc(chSubText) + '</div>';
h += '<div class="ch-preview">' + badge + '</div></div></div>';
}
return h;
@@ -3154,8 +3219,12 @@
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;
var chHandle = (ch && (ch.Name || ch.name)) || '';
var isXCh = ch && (ch.ChatType || ch.chatType) === 2;
var subHandle = (!isXCh && chHandle) ? (chHandle.charAt(0) === '@' ? chHandle : '@' + chHandle) : '';
document.getElementById('chatSub').textContent = subHandle;
renderChannels(); updateSendPanel();
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';
document.getElementById('scrollDownBtn').classList.remove('visible');
@@ -3991,6 +4060,16 @@
.replace(/[\u064B-\u065F\u0610-\u061A\u0670]/g, '') // strip tashkil/diacritics
.replace(/[\u200C\u200D\u200E\u200F]/g, '') // strip ZWNJ, ZWJ, directional marks
}
function toggleKebabMenu(e) {
e.stopPropagation();
document.getElementById('headerKebabMenu').classList.toggle('open');
}
function closeKebabMenu() {
var m = document.getElementById('headerKebabMenu');
if (m) m.classList.remove('open');
}
document.addEventListener('click', closeKebabMenu);
function toggleMsgSearch() {
var bar = document.getElementById('msgSearchBar');
if (bar.classList.contains('active')) { closeMsgSearch(); return }
+116 -2
View File
@@ -130,6 +130,12 @@ type Server struct {
stopRefresh chan struct{}
scanner *client.ResolverScanner
// titlesMu guards the background title-fetch state.
// Only one goroutine fetches titles at a time; errors impose a 5-minute backoff.
titlesMu sync.Mutex
titlesLoading bool
titlesBackoffUntil time.Time
}
// New creates a new web server.
@@ -999,8 +1005,19 @@ func (s *Server) refreshMetadataOnly() {
return
}
channels := meta.Channels
if cache != nil {
if cached := cache.GetAllTitles(); len(cached) > 0 {
for i := range channels {
if t := cached[channels[i].Name]; t != "" {
channels[i].DisplayName = t
}
}
}
}
s.mu.Lock()
s.channels = meta.Channels
s.channels = channels
s.telegramLoggedIn = meta.TelegramLoggedIn
s.nextFetch = meta.NextFetch
s.metaFetchedAt = time.Now()
@@ -1011,6 +1028,82 @@ func (s *Server) refreshMetadataOnly() {
}
s.broadcast("event: update\ndata: \"channels\"\n\n")
needsFetch := false
for _, ch := range channels {
if ch.DisplayName == "" {
needsFetch = true
break
}
}
if needsFetch {
go s.ensureTitlesFetched(basectx)
}
}
// ensureTitlesFetched fetches channel display names from TitlesChannel in the background.
// At most one fetch runs at a time; errors impose a 5-minute backoff so that an
// outdated server does not cause endless retries.
func (s *Server) ensureTitlesFetched(ctx context.Context) {
s.titlesMu.Lock()
if s.titlesLoading || time.Now().Before(s.titlesBackoffUntil) {
s.titlesMu.Unlock()
return
}
s.titlesLoading = true
s.titlesMu.Unlock()
defer func() {
s.titlesMu.Lock()
s.titlesLoading = false
s.titlesMu.Unlock()
}()
s.mu.RLock()
fetcher := s.fetcher
cache := s.cache
s.mu.RUnlock()
if fetcher == nil {
return
}
titles, err := fetcher.FetchTitles(ctx)
if err != nil && ctx.Err() == nil {
s.titlesMu.Lock()
s.titlesBackoffUntil = time.Now().Add(5 * time.Minute)
s.titlesMu.Unlock()
return
}
if len(titles) == 0 {
// Server doesn't support TitlesChannel or has no titles yet; back off.
s.titlesMu.Lock()
s.titlesBackoffUntil = time.Now().Add(5 * time.Minute)
s.titlesMu.Unlock()
return
}
if cache != nil {
for name, title := range titles {
_ = cache.PutTitle(name, title)
}
}
s.mu.Lock()
channels := s.channels
updated := false
for i := range channels {
if t, ok := titles[channels[i].Name]; ok && t != "" && channels[i].DisplayName != t {
channels[i].DisplayName = t
updated = true
}
}
s.channels = channels
s.mu.Unlock()
if updated {
s.broadcast("event: update\ndata: \"channels\"\n\n")
}
}
func (s *Server) refreshChannel(channelNum int) {
@@ -1088,8 +1181,19 @@ func (s *Server) refreshChannel(channelNum int) {
}
return
}
channels := meta.Channels
if cache != nil {
if cached := cache.GetAllTitles(); len(cached) > 0 {
for i := range channels {
if t := cached[channels[i].Name]; t != "" {
channels[i].DisplayName = t
}
}
}
}
meta.Channels = channels
s.mu.Lock()
s.channels = meta.Channels
s.channels = channels
s.telegramLoggedIn = meta.TelegramLoggedIn
s.nextFetch = meta.NextFetch
s.metaFetchedAt = time.Now()
@@ -1098,6 +1202,16 @@ func (s *Server) refreshChannel(channelNum int) {
_ = cache.PutMetadata(meta)
}
s.broadcast("event: update\ndata: \"channels\"\n\n")
needsFetch := false
for _, ch := range channels {
if ch.DisplayName == "" {
needsFetch = true
break
}
}
if needsFetch {
go s.ensureTitlesFetched(basectx)
}
}
channels := meta.Channels