diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 8403a69..e0604b8 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -56,6 +56,7 @@ const ( MediaPoll = "[POLL]" MediaContact = "[CONTACT]" MediaLocation = "[LOCATION]" + MediaReply = "[REPLY]" ) // ChatType distinguishes channel types in metadata. diff --git a/internal/server/dns.go b/internal/server/dns.go index 3083bcf..616f681 100644 --- a/internal/server/dns.go +++ b/internal/server/dns.go @@ -28,6 +28,7 @@ type DNSServer struct { reader *TelegramReader // nil when --no-telegram channelCtl channelRefresher refreshers []channelRefresher + xReader *XPublicReader queryKey [protocol.KeySize]byte responseKey [protocol.KeySize]byte listenAddr string @@ -113,6 +114,11 @@ func (s *DNSServer) AddRefresher(channelCtl channelRefresher) { } } +// SetXReader stores the XPublicReader so baseCh can be updated on channel changes. +func (s *DNSServer) SetXReader(xr *XPublicReader) { + s.xReader = xr +} + // ListenAndServe starts the DNS server on UDP, shutting down when ctx is cancelled. func (s *DNSServer) ListenAndServe(ctx context.Context) error { mux := dns.NewServeMux() @@ -523,6 +529,9 @@ func (s *DNSServer) adminAddChannel(username string) (string, error) { all, err := loadChannelsFromFile(s.channelsFile) if err == nil { s.feed.SetChannels(combineDisplayChannels(all, s.xAccounts)) + if s.xReader != nil { + s.xReader.SetBaseCh(len(all) + 1) + } if s.channelCtl != nil { s.channelCtl.UpdateChannels(all) s.channelCtl.RequestRefresh() @@ -569,6 +578,9 @@ func (s *DNSServer) adminRemoveChannel(username string) (string, error) { log.Printf("[admin] removed channel @%s", username) s.feed.SetChannels(combineDisplayChannels(remaining, s.xAccounts)) + if s.xReader != nil { + s.xReader.SetBaseCh(len(remaining) + 1) + } if s.channelCtl != nil { s.channelCtl.UpdateChannels(remaining) s.channelCtl.RequestRefresh() diff --git a/internal/server/public.go b/internal/server/public.go index b5cea13..3eab421 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -242,6 +242,10 @@ func parsePublicMessages(body []byte) ([]protocol.Message, error) { if text == "" { return } + // Detect replies by checking for the reply preview element. + if findFirstByClass(n, "tgme_widget_message_reply") != nil { + text = protocol.MediaReply + "\n" + text + } collected = append(collected, publicMessage{ id: id, timestamp: extractMessageTimestamp(n), diff --git a/internal/server/public_test.go b/internal/server/public_test.go index 1febebd..03f05ff 100644 --- a/internal/server/public_test.go +++ b/internal/server/public_test.go @@ -120,7 +120,7 @@ func TestParsePublicMessagesReplyPreviewUsesMainBody(t *testing.T) { if len(msgs) != 1 { t.Fatalf("len(msgs) = %d, want 1", len(msgs)) } - if msgs[0].Text != "this is the real new post" { - t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, "this is the real new post") + if msgs[0].Text != "[REPLY]\nthis is the real new post" { + t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, "[REPLY]\nthis is the real new post") } } diff --git a/internal/server/server.go b/internal/server/server.go index a41787b..fc480a7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -132,6 +132,7 @@ func (s *Server) Run(ctx context.Context) error { } if xReader != nil { dnsServer.AddRefresher(xReader) + dnsServer.SetXReader(xReader) } return dnsServer.ListenAndServe(ctx) } diff --git a/internal/server/telegram.go b/internal/server/telegram.go index 12cbaad..046e6dd 100644 --- a/internal/server/telegram.go +++ b/internal/server/telegram.go @@ -390,6 +390,11 @@ func (tr *TelegramReader) extractMessages(hist tg.MessagesMessagesClass, chatTyp } } + // Mark messages that are replies. + if _, hasReply := msg.GetReplyTo(); hasReply { + text = protocol.MediaReply + "\n" + text + } + msgs = append(msgs, protocol.Message{ ID: uint32(msg.ID), Timestamp: uint32(msg.Date), diff --git a/internal/server/xpublic.go b/internal/server/xpublic.go index 2d4ac49..9db4675 100644 --- a/internal/server/xpublic.go +++ b/internal/server/xpublic.go @@ -150,9 +150,26 @@ func (xr *XPublicReader) RequestRefresh() { func (xr *XPublicReader) UpdateChannels(_ []string) {} +// SetBaseCh updates the base channel number when Telegram channels are added/removed. +func (xr *XPublicReader) SetBaseCh(baseCh int) { + xr.mu.Lock() + xr.baseCh = baseCh + xr.mu.Unlock() +} + func (xr *XPublicReader) fetchAll(ctx context.Context) { + xr.mu.RLock() + baseCh := xr.baseCh + xr.mu.RUnlock() + + // Always set ChatType for all X accounts upfront, so channels show the X flag + // even if the Nitter fetch fails or the cache is still valid. + for i := range xr.accounts { + xr.feed.SetChatInfo(baseCh+i, protocol.ChatTypeX, false) + } + for i, account := range xr.accounts { - chNum := xr.baseCh + i + chNum := baseCh + i xr.mu.RLock() cached, ok := xr.cache[account] @@ -179,7 +196,6 @@ func (xr *XPublicReader) fetchAll(ctx context.Context) { xr.mu.Unlock() xr.feed.UpdateChannel(chNum, msgs) - xr.feed.SetChatInfo(chNum, protocol.ChatTypeX, false) log.Printf("[x] updated @%s: %d posts", account, len(msgs)) } } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 0c3fc61..e892f60 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -33,6 +33,24 @@ --font-size: 14px; } + :root[data-theme="light"] { + --bg: #ffffff; + --bg2: #f0f2f5; + --sidebar-bg: #f0f2f5; + --surface: #ffffff; + --surface2: #e8eaed; + --border: #d3d6da; + --accent: #3390ec; + --accent-hover: #2b7dd6; + --text: #1a1a1a; + --text-dim: #707579; + --success: #4fae4e; + --error: #e53935; + --send-color: #3390ec; + --msg-in: #ffffff; + --hover: #e8eaed; + } + * { margin: 0; padding: 0; @@ -455,6 +473,10 @@ border-bottom-left-radius: 4px } + :root[data-theme="light"] .msg { + border-color: var(--border) + } + .msg-meta { display: flex; justify-content: flex-end; @@ -475,6 +497,11 @@ margin-bottom: 6px } + .media-tag.reply-tag { + background: rgba(192, 132, 252, .15); + color: #c084fc + } + /* ===== SEND PANEL ===== */ .send-panel { display: none; @@ -621,19 +648,64 @@ .msg-copy-btn { background: none; - border: none; + border: 1px solid var(--text-dim); color: var(--text-dim); - font-size: 14px; + font-size: 11px; cursor: pointer; - padding: 0 3px; - line-height: 1; + padding: 1px 8px; + line-height: 1.4; flex-shrink: 0; - opacity: .45; - transition: opacity .15s + opacity: .6; + transition: opacity .15s, border-color .15s; + font-family: inherit; + border-radius: 4px } .msg-copy-btn:hover { - opacity: 1 + opacity: 1; + border-color: var(--accent) + } + + .msg-new-sep { + text-align: center; + padding: 6px 0; + font-size: 12px; + color: var(--accent) + } + + .msg-new-sep span { + background: rgba(51, 144, 236, .12); + padding: 3px 14px; + border-radius: 10px; + border: 1px solid rgba(51, 144, 236, .25) + } + + .theme-row { + display: flex; + gap: 8px; + margin-top: 4px + } + + .theme-btn { + flex: 1; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-dim); + font-size: 13px; + transition: all .15s + } + + .theme-btn:hover { + background: var(--hover) + } + + .theme-btn.active-theme { + border-color: var(--accent); + background: rgba(51, 144, 236, .12); + color: var(--accent); + font-weight: 600 } /* ===== SCROLL-TO-BOTTOM ===== */ @@ -1396,6 +1468,13 @@ +
+ +
+ + +
+
Version @@ -1695,6 +1774,13 @@ scanner_new_scan: 'اسکن جدید', scanner_advanced: 'تنظیمات پیشرفته', scanner_copy_all: 'کپی همه', + theme: 'پوسته', + theme_dark: 'تاریک', + theme_light: 'روشن', + rescan_prompt_title: 'بررسی ریزالورها', + rescan_prompt_msg: '{n} ریزالور سالم از اسکن قبلی موجود است. بدون بررسی مجدد ادامه دهیم؟', + rescan_prompt_skip: 'ادامه بدون اسکن', + rescan_prompt_yes: 'بررسی مجدد', }, en: { search: 'Search...', settings: 'Settings', profiles: 'Profiles', @@ -1776,6 +1862,13 @@ scanner_new_scan: 'New Scan', scanner_advanced: 'Advanced options', scanner_copy_all: 'Copy All', + theme: 'Theme', + theme_dark: 'Dark', + theme_light: 'Light', + rescan_prompt_title: 'Resolver Check', + rescan_prompt_msg: '{n} healthy resolvers from previous scan. Continue without rescanning?', + rescan_prompt_skip: 'Skip Rescan', + rescan_prompt_yes: 'Rescan', } }; var lang = localStorage.getItem('thefeed_lang') || 'fa'; @@ -1790,6 +1883,7 @@ document.getElementById('langFa').classList.toggle('active-lang', lang === 'fa'); document.getElementById('langEn').classList.toggle('active-lang', lang === 'en'); document.getElementById('sendInput').style.direction = isRtl ? 'rtl' : 'ltr'; + applyThemeButtons(); // Re-render dynamic content if (channels.length > 0) renderChannels(); } @@ -1800,10 +1894,23 @@ var serverNextFetch = 0, nextFetchInterval = null, previousMsgIDs = {}, currentMsgTexts = []; var appVersion = '', latestVersion = ''; var profiles = null, activeProfileId = '', editingProfileId = null, resolverScanHint = '', resolverScanHealthy = 0, resolverScanDone = 0, resolverScanTotal = 0; + var currentMaxMsgID = 0; + var currentMaxTimestamp = 0; + var newMsgScrollDone = false; // ===== MOBILE NAV ===== - function openChat() { if (window.innerWidth <= 768) document.getElementById('app').classList.add('chat-open') } + function openChat() { + if (window.innerWidth <= 768) { + document.getElementById('app').classList.add('chat-open'); + history.pushState({ view: 'chat' }, ''); + } + } function openSidebar() { document.getElementById('app').classList.remove('chat-open') } + window.addEventListener('popstate', function () { + if (window.innerWidth <= 768 && document.getElementById('app').classList.contains('chat-open')) { + openSidebar(); + } + }); function filterChannels() { var q = document.getElementById('channelSearch').value.toLowerCase(); document.querySelectorAll('.ch-item').forEach(function (el) { el.style.display = el.dataset.name.toLowerCase().includes(q) ? 'flex' : 'none' }); @@ -1811,6 +1918,7 @@ // ===== INIT ===== async function init() { + loadTheme(); applyLang(); await loadFontSize(); connectSSE(); @@ -1818,7 +1926,6 @@ var r = await fetch('/api/status'); var st = await r.json(); await loadProfiles(); if (!st.configured) { openProfiles(); return } - cleanupOldLocalStorageKeys(); checkAndShowSavedResolversPrompt(st); telegramLoggedIn = !!st.telegramLoggedIn; serverNextFetch = st.nextFetch || 0; @@ -1890,8 +1997,57 @@ } function previewFontSize(v) { document.documentElement.style.setProperty('--font-size', v + 'px'); document.getElementById('fontSizeVal').textContent = v } + // ===== THEME ===== + function loadTheme() { + var t = localStorage.getItem('thefeed_theme') || 'dark'; + document.documentElement.setAttribute('data-theme', t); + } + function setTheme(t) { + localStorage.setItem('thefeed_theme', t); + document.documentElement.setAttribute('data-theme', t); + applyThemeButtons(); + } + function applyThemeButtons() { + var cur = localStorage.getItem('thefeed_theme') || 'dark'; + var d = document.getElementById('themeDark'); + var l = document.getElementById('themeLight'); + if (d) d.classList.toggle('active-theme', cur === 'dark'); + if (l) l.classList.toggle('active-theme', cur === 'light'); + } + + // ===== LAST SEEN MESSAGES ===== + function channelName(num) { + var ch = channels[num - 1]; + return (ch && (ch.Name || ch.name)) || ''; + } + function getLastSeenTimestamp(name) { + if (!name) return 0; + try { return parseInt(localStorage.getItem('thefeed_seen_ts_' + name)) || 0 } catch (e) { return 0 } + } + function setLastSeenTimestamp(name, ts) { + if (!name) return; + try { localStorage.setItem('thefeed_seen_ts_' + name, ts) } catch (e) { } + } + + // ===== RESCAN PROMPT ===== + function showRescanPrompt(count) { + return new Promise(function (resolve) { + var msg = t('rescan_prompt_msg').replace('{n}', count); + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + document.getElementById('rescanPromptSkip').onclick = function () { document.body.removeChild(overlay); resolve(true) }; + document.getElementById('rescanPromptYes').onclick = function () { document.body.removeChild(overlay); resolve(false) }; + }); + } + // ===== SETTINGS ===== - function openSettings() { renderLatestVersion(); document.getElementById('settingsModal').classList.add('active') } + function openSettings() { + renderLatestVersion(); + applyThemeButtons(); + document.getElementById('settingsModal').classList.add('active'); + } function closeSettings() { document.getElementById('settingsModal').classList.remove('active') } async function saveSettings() { var fs = parseInt(document.getElementById('fontSizeSlider').value) || 14; @@ -2085,8 +2241,17 @@ async function activateProfile(id) { if (id === activeProfileId) { closeProfiles(); return } + // Check if we should skip resolver check + var skipCheck = false; try { - var r = await fetch('/api/profiles/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) }); + var stRes = await fetch('/api/status'); + var st = await stRes.json(); + if (st.lastScan && st.lastScan.count > 0) { + skipCheck = await showRescanPrompt(st.lastScan.count); + } + } catch (e) { } + try { + var r = await fetch('/api/profiles/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, skipCheck: skipCheck }) }); if (!r.ok) return; activeProfileId = id; selectedChannel = 0; channels = []; document.getElementById('progressPanel').innerHTML = ''; document.getElementById('messages').innerHTML = '

' + t('switching') + '

'; await loadProfiles(); closeProfiles(); @@ -2214,8 +2379,19 @@ var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4 } }; var action = editingProfileId ? 'update' : 'create'; var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0; + // Check if we should skip resolver check (existing healthy resolvers) + var skipCheck = false; + if (editingProfileId && editingProfileId === activeProfileId) { + try { + var stRes = await fetch('/api/status'); + var st = await stRes.json(); + if (st.lastScan && st.lastScan.count > 0) { + skipCheck = await showRescanPrompt(st.lastScan.count); + } + } catch (e) { } + } try { - var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, profile: profile }) }); + var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, profile: profile, skipCheck: skipCheck }) }); if (!r.ok) { errEl.textContent = await r.text(); errEl.style.display = 'block'; return } await loadProfiles(); var savedEditId = editingProfileId; @@ -2298,7 +2474,8 @@ var num = ui + 1; existingItems[ui].classList.toggle('active', num === selectedChannel); var lastID = channels[ui].LastMsgID || channels[ui].lastMsgID || 0; - var showBadge = previousMsgIDs[num] > 0 && lastID > previousMsgIDs[num] && num !== selectedChannel; + var chNm = channels[ui].Name || channels[ui].name || ''; + var showBadge = previousMsgIDs[chNm] > 0 && lastID > previousMsgIDs[chNm] && num !== selectedChannel; var previewEl = existingItems[ui].querySelector('.ch-preview'); if (previewEl) previewEl.innerHTML = showBadge ? 'NEW' : ''; } @@ -2328,7 +2505,8 @@ var avatarText = (avatarName || '?').charAt(0).toUpperCase(); var active = num2 === selectedChannel ? ' active' : ''; var lastID = e.ch.LastMsgID || e.ch.lastMsgID || 0; - var badge = (previousMsgIDs[num2] > 0 && lastID > previousMsgIDs[num2] && num2 !== selectedChannel) ? 'NEW' : ''; + var chNm2 = e.ch.Name || e.ch.name || ''; + var badge = (previousMsgIDs[chNm2] > 0 && lastID > previousMsgIDs[chNm2] && num2 !== selectedChannel) ? 'NEW' : ''; h += '
'; h += '
' + esc(avatarText) + '
'; h += '
' + esc(name) + (isPriv ? '' + t('private') + '' : (isX ? '' + t('x_label') + '' : '')) + '
'; @@ -2342,14 +2520,21 @@ function _updateRefreshBadge() { var hasNew = false; for (var k = 0; k < channels.length; k++) { - var num3 = k + 1, lid = channels[k].LastMsgID || channels[k].lastMsgID || 0; - if (previousMsgIDs[num3] > 0 && lid > previousMsgIDs[num3] && num3 !== selectedChannel) { hasNew = true; break } + var num3 = k + 1, chNm3 = channels[k].Name || channels[k].name || '', lid = channels[k].LastMsgID || channels[k].lastMsgID || 0; + if (previousMsgIDs[chNm3] > 0 && lid > previousMsgIDs[chNm3] && num3 !== selectedChannel) { hasNew = true; break } } document.getElementById('refreshBtn').classList.toggle('refresh-has-new', hasNew); } async function selectChannel(num) { + // Save lastSeen for previous channel + if (selectedChannel > 0 && currentMaxTimestamp > 0) { + setLastSeenTimestamp(channelName(selectedChannel), currentMaxTimestamp); + } selectedChannel = num; + currentMaxMsgID = 0; + currentMaxTimestamp = 0; + newMsgScrollDone = false; openChat(); var ch = channels[num - 1]; var name = (ch && (ch.Name || ch.name)) || 'Channel ' + num; document.getElementById('chatName').textContent = name; @@ -2383,19 +2568,6 @@ else panel.classList.remove('visible'); } - // TODO: Remove cleanupOldLocalStorageKeys() once all clients have migrated. - // This purges thefeed_msgs_* keys written by the old HTML-side message cache. - function cleanupOldLocalStorageKeys() { - try { - var toDelete = []; - for (var i = 0; i < localStorage.length; i++) { - var k = localStorage.key(i); - if (k && k.startsWith('thefeed_msgs_')) toDelete.push(k); - } - for (var j = 0; j < toDelete.length; j++)localStorage.removeItem(toDelete[j]); - } catch (e) { } - } - // ===== MESSAGES ===== async function loadMessages(chNum) { try { @@ -2404,7 +2576,7 @@ renderMessages(data.messages || [], data.gaps || []); // Remove fetch progress bar for this channel var fetchBar = document.getElementById('prog-fetch-ch-' + chNum); if (fetchBar) fetchBar.remove(); - if (channels[chNum - 1]) { previousMsgIDs[chNum] = channels[chNum - 1].LastMsgID || channels[chNum - 1].lastMsgID || 0; renderChannels() } + if (channels[chNum - 1]) { var cn = channels[chNum - 1].Name || channels[chNum - 1].name || ''; previousMsgIDs[cn] = channels[chNum - 1].LastMsgID || channels[chNum - 1].lastMsgID || 0; renderChannels() } } catch (e) { } } @@ -2422,12 +2594,25 @@ currentMsgTexts = []; var dateLocale = lang === 'fa' ? 'fa-IR' : 'en-US'; var dateOpts = lang === 'fa' ? { year: 'numeric', month: 'long', day: 'numeric', calendar: 'persian' } : { year: 'numeric', month: 'long', day: 'numeric' }; + var lastSeenTs = getLastSeenTimestamp(channelName(selectedChannel)); + var isFirstVisit = lastSeenTs === 0; + var newMsgSepInserted = false; + var maxMsgID = 0; + var maxTimestamp = 0; for (var i = 0; i < msgs.length; i++) { var msg = msgs[i]; var id = msg.ID || msg.id; + var msgTs = msg.Timestamp || msg.timestamp || 0; + if (id > maxMsgID) maxMsgID = id; + if (msgTs > maxTimestamp) maxTimestamp = msgTs; if (gapBefore[id]) { html += '
' + t('missed_messages').replace('{n}', gapBefore[id]) + '
'; } + // New messages separator (timestamp-based for X/Telegram compatibility) + if (!isFirstVisit && lastSeenTs > 0 && msgTs > lastSeenTs && !newMsgSepInserted) { + html += '
' + t('new_messages') + '
'; + newMsgSepInserted = true; + } var ts = new Date((msg.Timestamp || msg.timestamp) * 1000); var dateStr = ts.toLocaleDateString(dateLocale, dateOpts); if (dateStr !== lastDate) { html += '
' + dateStr + '
'; lastDate = dateStr } @@ -2435,17 +2620,39 @@ var text = msg.Text || msg.text || ''; currentMsgTexts.push(text); var mediaHtml = '', textHtml = esc(text); - var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[POLL]', '[CONTACT]', '[LOCATION]']; + var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[POLL]', '[CONTACT]', '[LOCATION]', '[REPLY]']; for (var m = 0; m < mediaTypes.length; m++) { if (text.indexOf(mediaTypes[m]) === 0) { - mediaHtml = '
' + mediaTypes[m] + '
'; + var tagCls = mediaTypes[m] === '[REPLY]' ? 'media-tag reply-tag' : 'media-tag'; + mediaHtml = '
' + mediaTypes[m] + '
'; textHtml = esc(text.substring(mediaTypes[m].length).replace(/^\n/, '')); break } } - html += '
' + mediaHtml + textHtml + '
#' + id + '' + timeStr + '
'; + html += '
' + mediaHtml + textHtml + '
#' + id + '' + timeStr + '
'; } el.innerHTML = html; - if (isFirstRender || wasAtBottom) { el.scrollTop = el.scrollHeight; document.getElementById('scrollDownBtn').classList.remove('visible'); } + currentMaxMsgID = maxMsgID; + currentMaxTimestamp = maxTimestamp; + // On first visit, store max timestamp (no separator shown) + var chName = channelName(selectedChannel); + if (isFirstVisit && maxTimestamp > 0) { + setLastSeenTimestamp(chName, maxTimestamp); + } + // Update lastSeen when user has scrolled to the bottom (messages are "seen") + if (wasAtBottom && maxTimestamp > 0 && !isFirstVisit) { + setLastSeenTimestamp(chName, maxTimestamp); + } + // Scroll to new messages separator only on first render for this channel + if (newMsgSepInserted && !newMsgScrollDone) { + newMsgScrollDone = true; + setTimeout(function () { + var sep = document.getElementById('newMsgSep'); + if (sep) sep.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); + } else if (isFirstRender || wasAtBottom) { + el.scrollTop = el.scrollHeight; + document.getElementById('scrollDownBtn').classList.remove('visible'); + } } // ===== LOG ===== @@ -3003,6 +3210,46 @@ // Handle thefeed:// URI hash import (function () { var h = location.hash; if (h && h.startsWith('#thefeed://')) { document.getElementById('importUriInput').value = decodeURIComponent(h.substring(1)); openProfiles(); doImportUri() } })(); + // ===== AUTO-SCROLL DURING TEXT SELECTION ===== + (function () { + var scrollSpeed = 0, scrollFrame = null, messagesEl = null; + function startAutoScroll() { + if (scrollFrame) return; + function step() { + if (scrollSpeed === 0 || !messagesEl) { scrollFrame = null; return } + messagesEl.scrollTop += scrollSpeed; + scrollFrame = requestAnimationFrame(step); + } + scrollFrame = requestAnimationFrame(step); + } + function stopAutoScroll() { + scrollSpeed = 0; + if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null } + } + document.addEventListener('DOMContentLoaded', function () { + messagesEl = document.getElementById('messages'); + if (!messagesEl) return; + var edgeZone = 40; + function handleMove(clientY) { + var sel = window.getSelection(); + if (!sel || sel.isCollapsed) return; + var rect = messagesEl.getBoundingClientRect(); + if (clientY < rect.top + edgeZone) { + scrollSpeed = -Math.max(2, (edgeZone - (clientY - rect.top)) / 3); + startAutoScroll(); + } else if (clientY > rect.bottom - edgeZone) { + scrollSpeed = Math.max(2, (edgeZone - (rect.bottom - clientY)) / 3); + startAutoScroll(); + } else { stopAutoScroll() } + } + messagesEl.addEventListener('touchmove', function (e) { if (e.touches[0]) handleMove(e.touches[0].clientY) }); + messagesEl.addEventListener('touchend', stopAutoScroll); + messagesEl.addEventListener('touchcancel', stopAutoScroll); + messagesEl.addEventListener('mousemove', function (e) { if (e.buttons === 1) handleMove(e.clientY) }); + document.addEventListener('mouseup', stopAutoScroll); + }); + })(); + init(); diff --git a/internal/web/web.go b/internal/web/web.go index 9717af0..218a64f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -769,6 +769,24 @@ func (s *Server) startCheckerThenRefresh() { }) } +// skipCheckerUseSaved uses saved resolvers from the last scan and starts +// periodic health checks without an initial scan pass. +func (s *Server) skipCheckerUseSaved() { + s.mu.RLock() + checker := s.checker + ctx := s.fetcherCtx + fetcher := s.fetcher + s.mu.RUnlock() + if checker == nil || fetcher == nil { + return + } + if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 { + fetcher.SetActiveResolvers(ls.Resolvers) + } + checker.StartPeriodic(ctx) + go s.refreshMetadataOnly() +} + // nextFetchDeadline returns the Time when the server will next fetch from Telegram. // Returns zero value if nextFetch is not set or has already passed. func (s *Server) nextFetchDeadline() time.Time { @@ -939,13 +957,13 @@ func (s *Server) refreshChannel(channelNum int) { s.refreshMu.Unlock() }() - // Use the cached in-memory metadata if it is fresh enough (< metaCacheTTL, default 3 min). + // Use the cached in-memory metadata if it is fresh enough (< metaCacheTTL, default 30 sec). // This avoids a redundant metadata DNS fetch for every channel refresh. // If the metadata is stale (or was never fetched), fetch it from DNS now. s.mu.RLock() ttl := s.metaCacheTTL if ttl <= 0 { - ttl = 2 * time.Minute + ttl = 30 * time.Second } // Cap TTL at the time remaining until the server's next Telegram fetch. // If nextFetch is sooner than our TTL the cached metadata may already be stale. @@ -1218,9 +1236,10 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { case http.MethodPost: var req struct { - Action string `json:"action"` // "create", "update", "delete", "reorder" - Profile Profile `json:"profile"` - Order []string `json:"order"` // for reorder + Action string `json:"action"` // "create", "update", "delete", "reorder" + Profile Profile `json:"profile"` + Order []string `json:"order"` // for reorder + SkipCheck bool `json:"skipCheck"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) @@ -1306,6 +1325,8 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { s.mu.Unlock() if err := s.initFetcher(); err != nil { log.Printf("[web] re-init fetcher after profile change: %v", err) + } else if req.SkipCheck { + s.skipCheckerUseSaved() } else { s.startCheckerThenRefresh() } @@ -1328,7 +1349,8 @@ func (s *Server) handleProfileSwitch(w http.ResponseWriter, r *http.Request) { return } var req struct { - ID string `json:"id"` + ID string `json:"id"` + SkipCheck bool `json:"skipCheck"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) @@ -1373,7 +1395,11 @@ func (s *Server) handleProfileSwitch(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500) return } - s.startCheckerThenRefresh() + if req.SkipCheck { + s.skipCheckerUseSaved() + } else { + s.startCheckerThenRefresh() + } writeJSON(w, map[string]any{"ok": true}) } diff --git a/test/e2e/web_e2e_test.go b/test/e2e/web_e2e_test.go index c07e517..e86c3c8 100644 --- a/test/e2e/web_e2e_test.go +++ b/test/e2e/web_e2e_test.go @@ -460,3 +460,80 @@ func TestE2E_WebAPI_GlobalAuth(t *testing.T) { t.Errorf("wrong password: expected 401, got %d", resp.StatusCode) } } + +// TestE2E_NewMsgSeparator_TimestampBased verifies the index.html uses +// timestamp-based (not ID-based) comparison for the "new messages" separator. +// This is critical because X/Twitter post IDs are CRC32 hashes that don't +// increase monotonically, so ID-based comparison would place the separator +// in wrong positions. +func TestE2E_NewMsgSeparator_TimestampBased(t *testing.T) { + base, _ := startWebServer(t) + + resp := getJSON(t, base+"/") + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + html := string(body) + + // The separator must compare against timestamp, not message ID. + // Look for the timestamp-based lastSeen check in the new-msg separator logic. + checks := []struct { + name string + needle string + wantHas bool + }{ + {"uses timestamp for lastSeen storage", "thefeed_seen_ts_", true}, + {"compares msgTs > lastSeenTs", "msgTs > lastSeenTs", true}, + {"tracks maxTimestamp", "maxTimestamp", true}, + {"no old ID-based seen key", "thefeed_seen_'", false}, + {"no old id > lastSeen comparison", "id > lastSeen", false}, + } + for _, c := range checks { + has := strings.Contains(html, c.needle) + if has != c.wantHas { + if c.wantHas { + t.Errorf("%s: expected %q in index.html but not found", c.name, c.needle) + } else { + t.Errorf("%s: found %q in index.html but should have been removed", c.name, c.needle) + } + } + } + + // Also verify that first-visit logic stores timestamp, not ID + if !strings.Contains(html, "setLastSeenTimestamp") { + t.Error("expected setLastSeenTimestamp function in index.html") + } + if !strings.Contains(html, "getLastSeenTimestamp") { + t.Error("expected getLastSeenTimestamp function in index.html") + } + + // Verify wasAtBottom updates lastSeen on re-renders (prevents stale separator) + if !strings.Contains(html, "wasAtBottom && maxTimestamp > 0") { + t.Error("expected wasAtBottom to update lastSeen timestamp on re-render") + } +} + +// TestE2E_MessagesHaveTimestamps verifies that the messages API response +// includes Timestamp fields needed for the new-messages separator. +func TestE2E_MessagesHaveTimestamps(t *testing.T) { + base, _ := startWebServer(t) + + resp := getJSON(t, base+"/api/messages/1") + defer resp.Body.Close() + + var result struct { + Messages []struct { + ID uint32 `json:"ID"` + Timestamp uint32 `json:"Timestamp"` + Text string `json:"Text"` + } `json:"messages"` + } + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("decode messages: %v", err) + } + + // With no messages configured, the array should be empty or nil — but the + // response format must still be valid JSON with a "messages" key. + // This verifies the API structure supports the timestamp-based separator. + t.Logf("messages response contains %d messages", len(result.Messages)) +}