mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 07:44:34 +03:00
Merge pull request #20 from sepehr-alipour/feat/channel-display-name
feat: channel display names & mobile UX improvements
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()">←</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">↻</button>
|
||||
<div class="header-kebab" id="headerKebab">
|
||||
<button class="icon-btn" onclick="toggleKebabMenu(event)" title="More" style="width:40px;height:40px;font-size:20px">⋮</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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user