diff --git a/internal/client/cache.go b/internal/client/cache.go index c0968e0..d5a7eee 100644 --- a/internal/client/cache.go +++ b/internal/client/cache.go @@ -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() diff --git a/internal/client/fetcher.go b/internal/client/fetcher.go index 764a000..4e1b301 100644 --- a/internal/client/fetcher.go +++ b/internal/client/fetcher.go @@ -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 diff --git a/internal/protocol/dns.go b/internal/protocol/dns.go index 3c96274..025b3a3 100644 --- a/internal/protocol/dns.go +++ b/internal/protocol/dns.go @@ -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 diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 093f7dc..42c6be6 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -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) { diff --git a/internal/server/feed.go b/internal/server/feed.go index 7bb4602..ee9ce1a 100644 --- a/internal/server/feed.go +++ b/internal/server/feed.go @@ -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() +} diff --git a/internal/server/public.go b/internal/server/public.go index d8901b8..413bcc9 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -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 { diff --git a/internal/server/telegram.go b/internal/server/telegram.go index ed1d476..48b793d 100644 --- a/internal/server/telegram.go +++ b/internal/server/telegram.go @@ -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 } } diff --git a/internal/server/xpublic.go b/internal/server/xpublic.go index 82469d0..6d2bccf 100644 --- a/internal/server/xpublic.go +++ b/internal/server/xpublic.go @@ -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. diff --git a/internal/server/xpublic_test.go b/internal/server/xpublic_test.go index f70c901..b55e132 100644 --- a/internal/server/xpublic_test.go +++ b/internal/server/xpublic_test.go @@ -24,7 +24,7 @@ func TestParseXRSSMessages(t *testing.T) { `) - 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) { `) - 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) { `) - 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) { `) - 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) { `) - 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) { `) - 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) { `) - msgs, err := parseXRSSMessages(body, "RezaVaisi") + msgs, _, err := parseXRSSMessages(body, "RezaVaisi") if err != nil { t.Fatalf("parseXRSSMessages: %v", err) } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 2a07cce..1fff97c 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -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 @@