diff --git a/.gitignore b/.gitignore index 258a212..b0fb512 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ session.json .env todo +todo.md tmp diff --git a/android/app/src/main/java/com/thefeed/android/MainActivity.kt b/android/app/src/main/java/com/thefeed/android/MainActivity.kt index 76254ba..a4820e9 100644 --- a/android/app/src/main/java/com/thefeed/android/MainActivity.kt +++ b/android/app/src/main/java/com/thefeed/android/MainActivity.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.net.Uri import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebSettings @@ -103,6 +104,19 @@ class MainActivity : ComponentActivity() { private fun configureWebView() { webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val url = request?.url ?: return false + // External links (anything not our local server) open in the system browser + if (url.host != "127.0.0.1") { + startActivity(Intent(Intent.ACTION_VIEW, url)) + return true + } + return false + } + override fun onPageFinished(view: WebView?, url: String?) { if (url != null && url.startsWith("http://127.0.0.1")) { setStatus("") diff --git a/android/app/src/main/java/com/thefeed/android/ThefeedService.kt b/android/app/src/main/java/com/thefeed/android/ThefeedService.kt index dad33ae..5379a5b 100644 --- a/android/app/src/main/java/com/thefeed/android/ThefeedService.kt +++ b/android/app/src/main/java/com/thefeed/android/ThefeedService.kt @@ -27,6 +27,10 @@ class ThefeedService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } // If the process died, restart it if (process == null || !isProcessAlive()) { startClientProcessAsync() @@ -45,6 +49,8 @@ class ThefeedService : Service() { process = null savePort(-1) super.onDestroy() + // Kill the entire app process so the activity doesn't remain open + android.os.Process.killProcess(android.os.Process.myPid()) } override fun onBind(intent: Intent?): IBinder? = null @@ -160,12 +166,21 @@ class ThefeedService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val stopIntent = Intent(this, ThefeedService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = PendingIntent.getService( + this, 1, stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("thefeed") .setContentText(message) .setSmallIcon(android.R.drawable.stat_notify_sync) .setOngoing(true) .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Exit", stopPendingIntent) .setSilent(true) .build() } @@ -184,5 +199,6 @@ class ThefeedService : Service() { const val NOTIFICATION_ID = 1201 const val PREFS_NAME = "thefeed_runtime" const val PREF_PORT = "port" + const val ACTION_STOP = "com.thefeed.android.STOP" } } diff --git a/internal/client/resolver.go b/internal/client/resolver.go index 880b124..2cfe731 100644 --- a/internal/client/resolver.go +++ b/internal/client/resolver.go @@ -17,10 +17,13 @@ import ( // updates the active (healthy) resolver pool. It replaces the old file/CIDR // scanner — no file I/O; just a plain DNS probe on channel 0. type ResolverChecker struct { - fetcher *Fetcher - timeout time.Duration - logFunc LogFunc - started atomic.Bool // guards against double-start + fetcher *Fetcher + timeout time.Duration + logFunc LogFunc + started atomic.Bool // guards against double-start + scanMu sync.Mutex // protects scanCancel + scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock) + scanCancel context.CancelFunc // cancels the currently running CheckNow } // NewResolverChecker creates a health checker for the resolvers in fetcher. @@ -110,12 +113,46 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func( }() } +// CancelCurrentScan cancels any in-progress CheckNow call, causing it to +// return early without updating the resolver list. +func (rc *ResolverChecker) CancelCurrentScan() { + rc.scanMu.Lock() + if rc.scanCancel != nil { + rc.scanCancel() + rc.scanCancel = nil + } + rc.scanMu.Unlock() +} + // CheckNow runs a single resolver health-check pass immediately. -// ctx is used to abort in-flight probes early (e.g. when a profile is switched). -func (rc *ResolverChecker) CheckNow(ctx context.Context) { +// If a scan is already in progress the call is a no-op (returns false). +// Returns true if the scan ran to completion. +// Use CancelCurrentScan to abort a running scan from outside. +func (rc *ResolverChecker) CheckNow(ctx context.Context) bool { + // Non-blocking: if another scan is running, skip. + if !rc.scanRunMu.TryLock() { + return false + } + defer rc.scanRunMu.Unlock() + + if ctx.Err() != nil { + return false + } + + scanCtx, cancel := context.WithCancel(ctx) + rc.scanMu.Lock() + rc.scanCancel = cancel + rc.scanMu.Unlock() + defer func() { + cancel() + rc.scanMu.Lock() + rc.scanCancel = nil + rc.scanMu.Unlock() + }() + resolvers := rc.fetcher.AllResolvers() if len(resolvers) == 0 { - return + return true } total := len(resolvers) @@ -129,7 +166,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) { for _, r := range resolvers { // Stop launching new probes if context was cancelled. - if ctx.Err() != nil { + if scanCtx.Err() != nil { break } wg.Add(1) @@ -138,7 +175,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) { sem <- struct{}{} defer func() { <-sem }() - ok := rc.checkOne(ctx, r) + ok := rc.checkOne(scanCtx, r) mu.Lock() if ok { healthy = append(healthy, r) @@ -153,18 +190,19 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) { } wg.Wait() - if ctx.Err() != nil { - return // context cancelled — don't update resolver list + if scanCtx.Err() != nil { + return false // context cancelled — don't update resolver list } rc.fetcher.SetActiveResolvers(healthy) if len(healthy) == 0 { rc.log("Resolver check done: 0/%d healthy", len(resolvers)) rc.log("RESOLVER_SCAN done 0/%d", total) - return + return true } rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers)) rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total) + return true } // checkOne probes a single resolver by sending a metadata channel query @@ -197,12 +235,32 @@ func (rc *ResolverChecker) checkOne(ctx context.Context, resolver string) bool { m.RecursionDesired = true m.SetEdns0(4096, false) + type exResult struct { + resp *dns.Msg + latency time.Duration + err error + } + ch := make(chan exResult, 1) start := time.Now() - resp, _, err := c.ExchangeContext(probeCtx, m, resolver) - latency := time.Since(start) - if err != nil || resp == nil { + go func() { + r, _, e := c.ExchangeContext(probeCtx, m, resolver) + ch <- exResult{r, time.Since(start), e} + }() + + var resp *dns.Msg + var latency time.Duration + select { + case <-ctx.Done(): + cancel() // ensure probeCtx resources freed rc.fetcher.RecordFailure(resolver) return false + case res := <-ch: + resp = res.resp + latency = res.latency + if res.err != nil || resp == nil { + rc.fetcher.RecordFailure(resolver) + return false + } } // Require a decodable TXT record — same check as a real fetch. diff --git a/internal/client/resolver_test.go b/internal/client/resolver_test.go new file mode 100644 index 0000000..92026ea --- /dev/null +++ b/internal/client/resolver_test.go @@ -0,0 +1,174 @@ +package client + +import ( + "context" + "net" + "sync" + "testing" + "time" +) + +func newCheckerWithFetcher(t *testing.T) (*ResolverChecker, *Fetcher) { + t.Helper() + // Use localhost non-listening ports so no real DNS traffic leaves the machine. + f := newTestFetcher(t, []string{"127.0.0.1:19753", "127.0.0.1:19754"}) + rc := NewResolverChecker(f, 200*time.Millisecond) + return rc, f +} + +func TestResolverChecker_DefaultTimeout(t *testing.T) { + f := newTestFetcher(t, []string{"127.0.0.1:19753"}) + rc := NewResolverChecker(f, 0) + if rc.timeout != 15*time.Second { + t.Errorf("default timeout = %v, want 15s", rc.timeout) + } +} + +func TestResolverChecker_CheckNow_SkipsWhenRunning(t *testing.T) { + rc, _ := newCheckerWithFetcher(t) + + // Manually lock to simulate a running scan. + rc.scanRunMu.Lock() + + // CheckNow should return false immediately (TryLock fails). + result := rc.CheckNow(context.Background()) + if result { + t.Error("CheckNow should return false when another scan is running") + } + + rc.scanRunMu.Unlock() +} + +func TestResolverChecker_CheckNow_CancelledContext(t *testing.T) { + rc, _ := newCheckerWithFetcher(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled + + result := rc.CheckNow(ctx) + if result { + t.Error("CheckNow should return false with cancelled context") + } +} + +func TestResolverChecker_CheckNow_NoResolvers(t *testing.T) { + f := newTestFetcher(t, nil) // no resolvers + rc := NewResolverChecker(f, 200*time.Millisecond) + + result := rc.CheckNow(context.Background()) + if !result { + t.Error("CheckNow should return true when there are no resolvers") + } +} + +// udpBlackhole opens a UDP listener that reads and discards all packets. +// Returns the address and a cleanup function. +func udpBlackhole(t *testing.T) string { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen udp: %v", err) + } + t.Cleanup(func() { conn.Close() }) + go func() { + buf := make([]byte, 4096) + for { + _, _, err := conn.ReadFrom(buf) + if err != nil { + return + } + // discard — never respond + } + }() + return conn.LocalAddr().String() +} + +func TestResolverChecker_CancelCurrentScan(t *testing.T) { + // UDP blackhole: listener accepts packets but never responds, + // so probes block until their context is cancelled. + addr := udpBlackhole(t) + f := newTestFetcher(t, []string{addr}) + rc := NewResolverChecker(f, 30*time.Second) + + var scanDone sync.WaitGroup + scanDone.Add(1) + go func() { + defer scanDone.Done() + rc.CheckNow(context.Background()) + }() + + // Give the goroutine time to start. + time.Sleep(50 * time.Millisecond) + + // Cancel should make the running scan return. + rc.CancelCurrentScan() + scanDone.Wait() // should not hang +} + +func TestResolverChecker_ConcurrentCheckNow_OnlyOneRuns(t *testing.T) { + rc, _ := newCheckerWithFetcher(t) + + // Fire 5 concurrent CheckNow calls. + var wg sync.WaitGroup + results := make([]bool, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = rc.CheckNow(context.Background()) + }(i) + } + wg.Wait() + + // At most 1 should have actually run (returned true or false depending on + // the mock — but only 1 should have entered the scan body). + trueCount := 0 + for _, r := range results { + if r { + trueCount++ + } + } + // Most should be false (skipped), at most 1 true. + if trueCount > 1 { + t.Errorf("expected at most 1 CheckNow to run, got %d", trueCount) + } +} + +func TestResolverChecker_StartAndNotify_OnlyOnce(t *testing.T) { + rc, _ := newCheckerWithFetcher(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // StartAndNotify should only allow one start — the second call is a no-op. + rc.StartAndNotify(ctx, nil) + + // started flag should be true. + if !rc.started.Load() { + t.Error("started flag should be true after StartAndNotify") + } + + // Second call should not panic and should be a no-op. + rc.StartAndNotify(ctx, nil) + cancel() +} + +func TestResolverChecker_SetLogFunc(t *testing.T) { + rc, _ := newCheckerWithFetcher(t) + + var logged []string + var mu sync.Mutex + rc.SetLogFunc(func(msg string) { + mu.Lock() + logged = append(logged, msg) + mu.Unlock() + }) + + rc.log("test %d", 42) + + mu.Lock() + defer mu.Unlock() + if len(logged) != 1 || logged[0] != "test 42" { + t.Errorf("logged = %v, want [test 42]", logged) + } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 7be20d5..3f277d0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -29,7 +29,8 @@ input,textarea,select{font-family:inherit} .profile-btn:hover{background:var(--hover)} .profile-btn-avatar{width:24px;height:24px;border-radius:50%;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0} .profile-btn-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.profile-btn-arrow{font-size:10px;color:var(--text-dim);flex-shrink:0} +.profile-btn-arrow{font-size:10px;color:var(--text-dim);flex-shrink:0;display:flex;align-items:center;gap:2px} +.profile-btn-arrow .plus{font-size:16px;font-weight:700;color:var(--accent)} .sidebar-search{padding:7px 12px;border:none;border-radius:18px;background:var(--surface);color:var(--text);font-size:13px;outline:none;width:100%} .sidebar-search::placeholder{color:var(--text-dim)} .icon-btn{width:36px;height:36px;border:none;background:none;color:var(--text-dim);font-size:17px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0} @@ -38,7 +39,7 @@ input,textarea,select{font-family:inherit} /* ===== CHANNEL LIST ===== */ .channel-list{flex:1;overflow-y:auto} .channel-section-title{padding:8px 14px 4px;font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px} -.ch-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;transition:background .1s;gap:10px} +.ch-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;gap:10px;contain:content} .ch-item:hover{background:var(--hover)} .ch-item.active{background:var(--accent)} .ch-item.active .ch-name,.ch-item.active .ch-preview{color:#fff} @@ -75,6 +76,8 @@ input,textarea,select{font-family:inherit} .messages{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:4px;direction:ltr} .msg-date-sep{text-align:center;padding:8px 0;font-size:12px;color:var(--text-dim)} .msg-date-sep span{background:rgba(0,0,0,.3);padding:3px 10px;border-radius:10px} +.msg-gap-sep{text-align:center;padding:6px 0;font-size:11px;color:var(--error)} +.msg-gap-sep span{background:rgba(229,57,53,.12);padding:3px 10px;border-radius:10px;border:1px dashed rgba(229,57,53,.3)} .msg{max-width:min(82%,580px);padding:7px 10px 4px;border-radius:12px;line-height:1.7;word-break:break-word;white-space:pre-wrap;font-size:inherit;background:var(--msg-in);border:1px solid rgba(255,255,255,.07);align-self:flex-start;border-bottom-left-radius:4px} .msg-meta{display:flex;justify-content:flex-end;gap:6px;font-size:10px;color:var(--text-dim);margin-top:2px;direction:ltr} .media-tag{display:block;padding:2px 6px;border-radius:4px;background:rgba(51,144,236,.15);color:var(--accent);font-size:11px;margin-bottom:6px} @@ -96,7 +99,10 @@ input,textarea,select{font-family:inherit} .progress-bar{width:100%;height:3px;background:var(--border);border-radius:2px;overflow:hidden} .progress-fill{height:100%;background:var(--accent);transition:width .2s} @keyframes prog-pulse{0%,100%{opacity:1}50%{opacity:.4}} -.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:11px;cursor:pointer;padding:0 2px;opacity:0;transition:opacity .15s;line-height:1;flex-shrink:0}.msg:hover .msg-copy-btn{opacity:1} +@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} +@keyframes badge-pulse{0%,100%{box-shadow:0 0 0 0 rgba(51,144,236,.4)}50%{box-shadow:0 0 0 4px rgba(51,144,236,0)}} +.refresh-has-new{animation:badge-pulse 2s ease-in-out infinite;color:var(--accent)!important} +.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 3px;line-height:1;flex-shrink:0;opacity:.45;transition:opacity .15s}.msg-copy-btn:hover{opacity:1} /* ===== LOG ===== */ .log-toggle{display:flex;align-items:center;justify-content:space-between;padding:3px 14px;background:var(--bg2);cursor:pointer;user-select:none;font-size:10px;color:var(--text-dim);letter-spacing:.5px;border-top:1px solid var(--border)} @@ -214,7 +220,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px} @@ -240,7 +246,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
- +
@@ -289,6 +295,10 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px} Version -
+
+ Clear Cache + +
'; @@ -738,6 +765,7 @@ async function saveProfile(){ var r=await fetch('/api/profiles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:action,profile:profile})}); if(!r.ok){errEl.textContent=await r.text();errEl.style.display='block';return} await loadProfiles(); + var savedEditId=editingProfileId; closeProfileEditor(); if(wasFirst){ closeProfiles(); @@ -746,8 +774,11 @@ async function saveProfile(){ showInitProgress(); }else{ renderProfilesModal(); - // If we updated the active profile, reload data - if(editingProfileId===activeProfileId||!editingProfileId){ + // Only rescan if we updated the currently active profile + if(savedEditId&&savedEditId===activeProfileId){ + showToast(t('rescan_started')); + document.getElementById('progressPanel').innerHTML=''; + showInitProgress(); await loadChannels(); if(selectedChannel>0)await loadMessages(selectedChannel); } @@ -755,6 +786,16 @@ async function saveProfile(){ }catch(e){errEl.textContent=e.message;errEl.style.display='block'} } +// ===== RESCAN ===== +async function doRescanFromProfiles(){ + closeProfiles(); + showToast(t('rescan_started')); + document.getElementById('progressPanel').innerHTML=''; + showInitProgress(); + try{await fetch('/api/rescan',{method:'POST'})}catch(e){} + setTimeout(function(){loadChannels().then(function(){if(selectedChannel>0)loadMessages(selectedChannel)})},3000); +} + async function deleteEditingProfile(){ if(!editingProfileId)return; if(!confirm(t('delete')+'?'))return; @@ -790,25 +831,52 @@ async function loadChannels(){ }catch(e){} } +var _renderChannelsTimer=null; function renderChannels(){ + // Debounce: avoid rapid sequential DOM rebuilds that cause hover flicker + if(_renderChannelsTimer)clearTimeout(_renderChannelsTimer); + _renderChannelsTimer=setTimeout(_renderChannelsNow,50); +} +function _renderChannelsNow(){ + _renderChannelsTimer=null; var el=document.getElementById('channelList'); if(!channels||!channels.length){ var _hint=resolverScanHint||(t('no_channels_hint')+' '+t('no_channels_hint2')); el.innerHTML='
'+t('no_channels')+'
'+_hint+'
';return } + // Fast-path: if channel count matches existing items, just update classes/badges + var existingItems=el.querySelectorAll('.ch-item'); + if(existingItems.length===channels.length){ + var needsFullRebuild=false; + for(var ci=0;ci0&&lastID>previousMsgIDs[num]&&num!==selectedChannel; + var previewEl=existingItems[ui].querySelector('.ch-preview'); + if(previewEl)previewEl.innerHTML=showBadge?'NEW':''; + } + _updateRefreshBadge();return; + } + } var pubs=[],privs=[]; for(var i=0;i'; for(var j=0;j0&&lastID>previousMsgIDs[num]&&num!==selectedChannel)?'NEW':''; - h+='
'; + var badge=(previousMsgIDs[num2]>0&&lastID>previousMsgIDs[num2]&&num2!==selectedChannel)?'NEW':''; + h+='
'; h+='
'+esc(name.charAt(0).toUpperCase())+'
'; h+='
'+esc(name)+(isPriv?''+t('private')+'':'')+'
'; h+='
'+badge+'
'; @@ -816,6 +884,15 @@ function renderChannels(){ return h; } el.innerHTML=section('',pubs)+section(t('private'),privs); + _updateRefreshBadge(); +} +function _updateRefreshBadge(){ + var hasNew=false; + for(var k=0;k0&&lid>previousMsgIDs[num3]&&num3!==selectedChannel){hasNew=true;break} + } + document.getElementById('refreshBtn').classList.toggle('refresh-has-new',hasNew); } async function selectChannel(num){ @@ -825,10 +902,25 @@ async function selectChannel(num){ document.getElementById('chatName').textContent=name; renderChannels();updateSendPanel(); document.getElementById('messages').innerHTML='

'+t('loading')+'

'; + // Show immediate feedback progress bar + showChannelFetchProgress(num,name); await loadMessages(num); await doRefresh(true); } +function showChannelFetchProgress(num,name){ + var panel=document.getElementById('progressPanel'); + var id='prog-fetch-ch-'+num; + var existing=document.getElementById(id); + if(existing)return; + var item=document.createElement('div');item.id=id;item.className='progress-item'; + item.dataset.lastUpdate=Date.now(); + item.innerHTML='
'+t('fetching_channel')+' '+(name||num)+'
'; + panel.insertBefore(item,panel.firstChild); + // Auto-remove after messages load or after timeout + setTimeout(function(){var el=document.getElementById(id);if(el)el.remove()},15000); +} + function updateSendPanel(){ var panel=document.getElementById('sendPanel'); var ch=channels[selectedChannel-1]; @@ -837,13 +929,46 @@ function updateSendPanel(){ else panel.classList.remove('visible'); } +// ===== PERSISTENT MESSAGE STORE ===== +function msgStoreKey(chNum){return 'thefeed_msgs_'+activeProfileId+'_'+chNum} +function loadStoredMessages(chNum){ + try{var raw=localStorage.getItem(msgStoreKey(chNum));return raw?JSON.parse(raw):[]}catch(e){return[]} +} +function saveStoredMessages(chNum,msgs){ + try{ + // Keep max 200 messages, sorted by timestamp + msgs.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)}); + if(msgs.length>200)msgs=msgs.slice(msgs.length-200); + localStorage.setItem(msgStoreKey(chNum),JSON.stringify(msgs)); + }catch(e){} +} +function mergeMessages(stored,fresh){ + var byId={};var merged=[]; + for(var i=0;i200)merged=merged.slice(merged.length-200); + return merged; +} + // ===== MESSAGES ===== async function loadMessages(chNum){ - if(chNum===selectedChannel){var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum]);} + if(chNum===selectedChannel){ + // Show persisted messages immediately + var stored=loadStoredMessages(chNum); + if(stored.length)renderMessages(stored); + else{var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum])} + } try{ var r=await fetch('/api/messages/'+chNum);if(chNum!==selectedChannel)return; var msgs=await r.json();if(chNum!==selectedChannel)return; - renderMessages(msgs); + // Merge with stored messages + var stored2=loadStoredMessages(chNum); + var merged=mergeMessages(stored2,msgs||[]); + saveStoredMessages(chNum,merged); + renderMessages(merged); + // Remove fetch progress bar for this channel + var fetchBar=document.getElementById('prog-fetch-ch-'+chNum);if(fetchBar)fetchBar.remove(); var _cache=loadCache()||{channels:channels,messages:{}};if(!_cache.messages)_cache.messages={};_cache.messages[''+chNum]=msgs;_cache.ts=Date.now();saveCache(_cache); if(channels[chNum-1]){previousMsgIDs[chNum]=channels[chNum-1].LastMsgID||channels[chNum-1].lastMsgID||0;renderChannels()} }catch(e){} @@ -852,17 +977,35 @@ async function loadMessages(chNum){ function renderMessages(msgs){ var el=document.getElementById('messages'); if(!msgs||!msgs.length){el.innerHTML='

'+t('no_messages')+'

'+t('no_messages_hint')+'

';return} + // Check if user is near the bottom before re-render (within 150px) + var wasAtBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150; + var isFirstRender=el.querySelector('.empty-state')!==null||el.querySelector('.msg')===null; msgs.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)}); - var html='',lastDate=''; + var html='',lastDate='',prevId=0; + 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'}; for(var i=0;i'+dateStr+'
';lastDate=dateStr} - var timeStr=ts.toLocaleTimeString(dateLocale,{hour:'2-digit',minute:'2-digit'}); var id=msg.ID||msg.id; + // Gap detection: only show if IDs are close enough to be meaningful + // Telegram IDs can have natural gaps; only flag when we have stored history + // and the gap is between messages we've seen before vs new ones + if(prevId>0&&id>prevId+1){ + var gap=id-prevId-1; + // Only show gap separator for small-ish gaps (likely real missed messages) + // and only when we have more than just a few messages (initial fetch won't trigger) + if(gap<=500&&msgs.length>=10){ + html+='
'+t('missed_messages').replace('{n}',gap)+'
'; + } + } + prevId=id; + var ts=new Date((msg.Timestamp||msg.timestamp)*1000); + var dateStr=ts.toLocaleDateString(dateLocale,dateOpts); + if(dateStr!==lastDate){html+='
'+dateStr+'
';lastDate=dateStr} + var timeStr=ts.toLocaleTimeString(dateLocale,{hour:'2-digit',minute:'2-digit'}); 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]']; for(var m=0;m'+mediaHtml+textHtml+'
#'+id+''+timeStr+'
'; + html+='
'+mediaHtml+textHtml+'
#'+id+''+timeStr+'
'; } - el.innerHTML=html;el.scrollTop=el.scrollHeight; + el.innerHTML=html; + if(isFirstRender||wasAtBottom)el.scrollTop=el.scrollHeight; } // ===== LOG ===== @@ -910,6 +1054,7 @@ function updateServerFetchDisplay(line){ item.dataset.total=total; item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+total+'s'; item.querySelector('.progress-fill').style.width='0%'; + item.dataset.lastUpdate=Date.now(); return; } if(!item)return; @@ -920,6 +1065,7 @@ function updateServerFetchDisplay(line){ var pct=Math.round(((total2-remaining)/total2)*100); item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+remaining+'s'; item.querySelector('.progress-fill').style.width=pct+'%'; + item.dataset.lastUpdate=Date.now(); return; } // SERVER_FETCH_WAIT done @@ -930,6 +1076,19 @@ function updateServerFetchDisplay(line){ } } +function ensureResolverScanItem(){ + var item=document.getElementById('prog-resolvers'); + if(!item){ + var panel=document.getElementById('progressPanel'); + // Remove the generic init loading bar when resolver scan takes over + var initItem=document.getElementById('prog-init');if(initItem)initItem.remove(); + item=document.createElement('div');item.id='prog-resolvers';item.className='progress-item'; + item.innerHTML='
'; + panel.insertBefore(item,panel.firstChild); + } + return item; +} + function updateResolverScanDisplay(line){ var panel=document.getElementById('progressPanel'); var item=document.getElementById('prog-resolvers'); @@ -938,19 +1097,18 @@ function updateResolverScanDisplay(line){ if(startMatch){ var total=parseInt(startMatch[1]); resolverScanDone=0;resolverScanHealthy=0;resolverScanTotal=total; - if(!item){ - item=document.createElement('div');item.id='prog-resolvers';item.className='progress-item'; - item.innerHTML='
'; - panel.insertBefore(item,panel.firstChild); - } + item=ensureResolverScanItem(); item.dataset.total=total; item.querySelector('.progress-label').textContent=t('scanning_resolvers')+' 0/'+total; item.querySelector('.progress-fill').style.width='0%'; + item.dataset.lastUpdate=Date.now(); resolverScanHint=t('scanning_resolvers')+'... '; var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=resolverScanHint; return; } - if(!item)return; + // If the item was removed (e.g. SSE reconnect cleared the panel) but scan + // is still in progress, re-create it so progress updates keep showing. + if(!item)item=ensureResolverScanItem(); // RESOLVER_SCAN progress D/T healthy=H var progMatch=line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)(?: healthy=(\d+))?/); if(progMatch){ @@ -962,6 +1120,7 @@ function updateResolverScanDisplay(line){ var label=t('scanning_resolvers')+' '+done+'/'+tot+' \u2713'+resolverScanHealthy; item.querySelector('.progress-label').textContent=label; item.querySelector('.progress-fill').style.width=pct+'%'; + item.dataset.lastUpdate=Date.now(); resolverScanHint=t('scanning_resolvers')+' ('+done+'/'+tot+', \u2713'+resolverScanHealthy+')'+ ' '; var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=resolverScanHint; return; @@ -974,6 +1133,8 @@ function updateResolverScanDisplay(line){ item.querySelector('.progress-label').textContent=t('scanning_resolvers')+': '+healthy+'/'+total2+' active'; item.querySelector('.progress-fill').style.width='100%'; resolverScanHint=''; + // Remove init loading bar if it's still around + var initItem=document.getElementById('prog-init');if(initItem)initItem.remove(); var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=t('no_channels_hint')+' '+t('no_channels_hint2'); setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},2000); // Scan is done — load channels in case the SSE 'update' event was dropped. @@ -1000,9 +1161,21 @@ function updateProgressDisplay(line){ } item.querySelector('.progress-label').textContent=label; item.querySelector('.progress-fill').style.width=percent+'%'; + // Mark last-update time so stale items can be cleaned + item.dataset.lastUpdate=Date.now(); if(percent>=100)setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},800); } +// Periodically remove stale progress items that stopped updating (missed 'done' event) +setInterval(function(){ + var now=Date.now(); + var items=document.getElementById('progressPanel').querySelectorAll('.progress-item'); + for(var i=0;i0&&now-lu>30000)items[i].remove(); + } +},10000); + function toggleLog(){ logVisible=!logVisible; var p=document.getElementById('logPanel');var ic=document.getElementById('logToggleIcon'); @@ -1025,7 +1198,7 @@ function jumpToLog(){ function showInitProgress(){ document.getElementById('progressPanel').innerHTML=''; var p=document.getElementById('progressPanel'); - p.innerHTML='
'+t('loading')+'
'; + p.innerHTML='
'+t('loading')+'
'; if(window.innerWidth<=768){openChat();openLog();} } function startAutoRefresh(){if(autoRefreshTimer)return;autoRefreshTimer=setInterval(function(){if(selectedChannel>0)doRefresh(true)},600000)} @@ -1035,11 +1208,21 @@ function updateNextFetchDisplay(){ var info=document.getElementById('nextFetchInfoBtn'); if(!serverNextFetch){el.textContent='';if(info)info.style.display='none';return} if(info){info.style.display='';info.title=t('next_fetch_info')} + var autoRefreshed=false; function tick(){var now=Math.floor(Date.now()/1000),d=serverNextFetch-now; - if(d<=0){el.textContent='';return} + if(d<=0){el.textContent=''; + if(!autoRefreshed){autoRefreshed=true;setTimeout(function(){doAutoRefreshAfterCountdown()},3000)} + return} var m=Math.floor(d/60),s=d%60;el.textContent=m+':'+(s<10?'0':'')+s} tick();nextFetchInterval=setInterval(tick,1000); } +async function doRefreshUI(){ + var btn=document.getElementById('refreshBtn'); + btn.style.animation='spin .8s linear'; + showToast(t('refreshing')); + await doRefresh(false); + setTimeout(function(){btn.style.animation=''},800); +} async function doRefresh(quiet){ try{ var url='/api/refresh'; @@ -1050,6 +1233,26 @@ async function doRefresh(quiet){ }catch(e){} } +async function doAutoRefreshAfterCountdown(){ + // Auto-triggered when server fetch countdown reaches 0 + // Clear metadata cache so we get fresh data + try{localStorage.removeItem(cacheKey())}catch(e){} + try{ + // Reload channels (this also fetches /api/status and updates the timer) + await loadChannels(); + // If we have a selected channel, reload its messages too + if(selectedChannel>0)await loadMessages(selectedChannel); + // If the timer still didn't show (server may not have refreshed yet), retry after a delay + if(!serverNextFetch||serverNextFetch<=Math.floor(Date.now()/1000)){ + setTimeout(async function(){ + try{var sr=await fetch('/api/status');var st=await sr.json(); + if(st.nextFetch&&st.nextFetch>Math.floor(Date.now()/1000)){serverNextFetch=st.nextFetch;updateNextFetchDisplay()} + }catch(e){} + },15000); + } + }catch(e){} +} + // ===== SEND ===== async function sendMessage(){ var input=document.getElementById('sendInput');var text=input.value.trim(); @@ -1064,7 +1267,7 @@ async function sendMessage(){ // ===== COPY MSG ===== function copyMsg(idx){ var text=currentMsgTexts[idx];if(text===undefined)return; - navigator.clipboard.writeText(text).then(function(){showToast(t('copied'))}).catch(function(){}); + navigator.clipboard.writeText(text).then(function(){showToast(t('msg_copied'))}).catch(function(){}); } // ===== TOAST ===== diff --git a/internal/web/web.go b/internal/web/web.go index a93da11..8893046 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -126,6 +126,20 @@ func New(dataDir string, port int, password string) (*Server, error) { if err := s.initFetcher(); err != nil { log.Printf("Warning: could not initialize fetcher: %v", err) } + } else { + // config.json missing — try to bootstrap from the active profile + if pl, plErr := s.loadProfiles(); plErr == nil && pl.Active != "" { + for _, p := range pl.Profiles { + if p.ID == pl.Active { + _ = s.saveConfig(&p.Config) + s.config = &p.Config + if err := s.initFetcher(); err != nil { + log.Printf("Warning: could not initialize fetcher from profile: %v", err) + } + break + } + } + } } return s, nil @@ -143,12 +157,14 @@ func (s *Server) Run() error { mux.HandleFunc("/api/channels", s.handleChannels) mux.HandleFunc("/api/messages/", s.handleMessages) mux.HandleFunc("/api/refresh", s.handleRefresh) + mux.HandleFunc("/api/rescan", s.handleRescan) mux.HandleFunc("/api/send", s.handleSend) mux.HandleFunc("/api/admin", s.handleAdmin) mux.HandleFunc("/api/events", s.handleSSE) mux.HandleFunc("/api/profiles", s.handleProfiles) mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch) mux.HandleFunc("/api/settings", s.handleSettings) + mux.HandleFunc("/api/cache/clear", s.handleClearCache) mux.HandleFunc("/", s.handleIndex) addr := fmt.Sprintf("127.0.0.1:%d", s.port) @@ -314,6 +330,27 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } +func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + s.mu.RLock() + checker := s.checker + baseCtx := s.fetcherCtx + s.mu.RUnlock() + if checker == nil || baseCtx == nil { + http.Error(w, "not configured", 400) + return + } + go func() { + if checker.CheckNow(baseCtx) { + s.refreshMetadataOnly() + } + }() + writeJSON(w, map[string]any{"ok": true}) +} + func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) @@ -512,6 +549,7 @@ func (s *Server) initFetcher() error { defer s.mu.Unlock() // Cancel goroutines from the previous fetcher configuration. + // This also cancels any in-progress manual rescan (via the context chain). if s.fetcherCancel != nil { s.fetcherCancel() } @@ -1002,6 +1040,8 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { pl = &ProfileList{} } + needsReinit := false + switch req.Action { case "create": req.Profile.ID = generateID() @@ -1011,12 +1051,16 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { pl.Profiles = append(pl.Profiles, req.Profile) if len(pl.Profiles) == 1 { pl.Active = req.Profile.ID + needsReinit = true } case "update": for i, p := range pl.Profiles { if p.ID == req.Profile.ID { pl.Profiles[i] = req.Profile + if p.ID == pl.Active { + needsReinit = true + } break } } @@ -1029,6 +1073,7 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { pl.Active = "" if len(pl.Profiles) > 0 { pl.Active = pl.Profiles[0].ID + needsReinit = true } } break @@ -1060,8 +1105,8 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { return } - // If active profile changed, update config.json and re-init the fetcher. - if pl.Active != "" { + // Only re-init the fetcher when the active profile's config was modified. + if needsReinit && pl.Active != "" { for _, p := range pl.Profiles { if p.ID == pl.Active { _ = s.saveConfig(&p.Config) @@ -1189,3 +1234,27 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", 405) } } + +// handleClearCache deletes all files in the cache directory. +func (s *Server) handleClearCache(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + cacheDir := filepath.Join(s.dataDir, "cache") + entries, err := os.ReadDir(cacheDir) + if err != nil { + writeJSON(w, map[string]any{"ok": true, "deleted": 0}) + return + } + deleted := 0 + for _, e := range entries { + if !e.IsDir() { + if os.Remove(filepath.Join(cacheDir, e.Name())) == nil { + deleted++ + } + } + } + s.addLog(fmt.Sprintf("Cache cleared: %d files deleted", deleted)) + writeJSON(w, map[string]any{"ok": true, "deleted": deleted}) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 3606c48..2e0c4c5 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -1319,3 +1319,164 @@ func TestE2E_WebAPI_SendTooLong(t *testing.T) { t.Errorf("send too long: expected 400, got %d", resp.StatusCode) } } + +// --- Cache Clear API Tests --- + +func TestE2E_WebAPI_ClearCache_Empty(t *testing.T) { + base, _ := startWebServer(t) + + resp := postJSON(t, base+"/api/cache/clear", "") + m := decodeJSON(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if m["ok"] != true { + t.Errorf("expected ok=true, got %v", m["ok"]) + } + if m["deleted"] != float64(0) { + t.Errorf("deleted = %v, want 0", m["deleted"]) + } +} + +func TestE2E_WebAPI_ClearCache_WithFiles(t *testing.T) { + domain := "cache.example.com" + passphrase := "cache-test-key" + channels := []string{"cached"} + msgs := map[int][]protocol.Message{ + 1: {{ID: 1, Timestamp: 1700000000, Text: "Cached msg"}}, + } + + resolver, cancelDNS := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancelDNS() + + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port, "") + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + // Configure and trigger channel fetch to populate cache. + cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`, + domain, passphrase, resolver) + http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON)) + time.Sleep(2 * time.Second) // wait for resolver scan + metadata + + http.Post(base+"/api/refresh?channel=1", "application/json", nil) + time.Sleep(1500 * time.Millisecond) + + // Verify cache files exist. + cacheDir := dataDir + "/cache" + entries, _ := os.ReadDir(cacheDir) + if len(entries) == 0 { + t.Fatal("expected cache files to exist after fetch") + } + + // Clear cache. + resp := postJSON(t, base+"/api/cache/clear", "") + m := decodeJSON(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("clear cache: expected 200, got %d", resp.StatusCode) + } + if m["ok"] != true { + t.Errorf("expected ok=true, got %v", m["ok"]) + } + deleted, _ := m["deleted"].(float64) + if deleted == 0 { + t.Error("expected deleted > 0 after clearing populated cache") + } + + // Verify cache dir is empty. + entries2, _ := os.ReadDir(cacheDir) + if len(entries2) != 0 { + t.Errorf("expected 0 files after clear, got %d", len(entries2)) + } +} + +func TestE2E_WebAPI_ClearCache_MethodNotAllowed(t *testing.T) { + base, _ := startWebServer(t) + + resp, err := http.Get(base + "/api/cache/clear") + if err != nil { + t.Fatalf("GET /api/cache/clear: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 405 { + t.Errorf("expected 405, got %d", resp.StatusCode) + } +} + +// --- Rescan API Tests --- + +func TestE2E_WebAPI_Rescan_NotConfigured(t *testing.T) { + base, _ := startWebServer(t) + + resp := postJSON(t, base+"/api/rescan", "") + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Errorf("rescan without config: expected 400, got %d", resp.StatusCode) + } +} + +func TestE2E_WebAPI_Rescan_MethodNotAllowed(t *testing.T) { + base, _ := startWebServer(t) + + resp, err := http.Get(base + "/api/rescan") + if err != nil { + t.Fatalf("GET /api/rescan: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 405 { + t.Errorf("expected 405, got %d", resp.StatusCode) + } +} + +func TestE2E_WebAPI_Rescan_Configured(t *testing.T) { + domain := "rescan.example.com" + passphrase := "rescan-test-key" + channels := []string{"rescanned"} + msgs := map[int][]protocol.Message{ + 1: {{ID: 1, Timestamp: 1700000000, Text: "test"}}, + } + + resolver, cancelDNS := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancelDNS() + + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port, "") + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`, + domain, passphrase, resolver) + http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON)) + time.Sleep(2 * time.Second) + + // Call rescan — should succeed. + resp := postJSON(t, base+"/api/rescan", "") + m := decodeJSON(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("rescan: expected 200, got %d", resp.StatusCode) + } + if m["ok"] != true { + t.Errorf("expected ok=true, got %v", m["ok"]) + } + + // Rapid double-call should also succeed (second is a no-op via TryLock). + resp2 := postJSON(t, base+"/api/rescan", "") + m2 := decodeJSON(t, resp2) + if resp2.StatusCode != 200 { + t.Fatalf("rescan double: expected 200, got %d", resp2.StatusCode) + } + if m2["ok"] != true { + t.Errorf("double rescan: expected ok=true, got %v", m2["ok"]) + } +}