From 4f59ec827004b4998c760c12f62a5c9690507fe2 Mon Sep 17 00:00:00 2001 From: Sarto Date: Mon, 4 May 2026 13:14:27 +0330 Subject: [PATCH] fix: improve channel selection hints and profile switching logic --- internal/web/static/index.html | 31 +++++++++++++++----- internal/web/web.go | 53 ++++++++++++++++++++-------------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 495f5b8..730c7a0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3022,7 +3022,7 @@ ok: 'باشه', cancel_media_msg: 'دانلود این رسانه لغو شود؟', dismiss: 'بستن', write_message: 'پیام بنویسید...', configure_server: 'برای شروع یک سرور راه‌اندازی کنید', - set_up: 'راه‌اندازی', switching: 'در حال تغییر پروفایل...', + set_up: 'راه‌اندازی', switching: 'در حال تغییر پروفایل...', select_channel_hint: 'یک کانال را برای دیدن پیام‌ها انتخاب کنید', font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان', next_fetch_info: 'زمان باقی‌مانده تا دریافت بعدی محتوا توسط سرور', no_profiles: 'هنوز پروفایلی وجود ندارد', add_profile: '+ پروفایل جدید', @@ -3232,7 +3232,7 @@ ok: 'OK', cancel_media_msg: 'Cancel this download?', dismiss: 'Dismiss', write_message: 'Write a message...', configure_server: 'Configure a server to start reading', - set_up: 'Set Up', switching: 'Switching profile...', + set_up: 'Set Up', switching: 'Switching profile...', select_channel_hint: 'Pick a channel to view its messages', font_size: 'Font Size', debug_mode: 'Debug mode', language: 'Language', next_fetch_info: 'Time until the server next fetches fresh channel content', no_profiles: 'No profiles yet', add_profile: '+ Add Profile', @@ -3549,7 +3549,11 @@ // Land on the channel list; don't auto-open the first channel. // Users on mobile may be tapping while we load and the auto-open // races with their touch. - if (!channels || channels.length === 0) { showInitProgress(); await doRefresh(); } + if (!channels || channels.length === 0) { + showInitProgress(); await doRefresh(); + } else { + document.getElementById('messages').innerHTML = '

' + (t('select_channel_hint') || '') + '

'; + } startAutoRefresh(); } catch (e) { } } @@ -4758,14 +4762,20 @@ 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 = []; + refreshingChannels = {}; + progressSilencedUntil = Date.now() + 5000; + // Reset the chat header so it doesn't keep showing the previous + // profile's channel name/handle while the user picks a new one. + document.getElementById('chatName').textContent = 'thefeed'; + document.getElementById('chatSub').textContent = ''; document.getElementById('progressPanel').innerHTML = ''; - document.getElementById('messages').innerHTML = '

' + t('switching') + '

'; + document.getElementById('messages').innerHTML = '

' + (t('select_channel_hint') || t('switching')) + '

'; + // On desktop the chat panel is always visible; collapse it back to + // the sidebar so the empty state doesn't sit next to a stale header. + openSidebar(); await loadProfiles(); closeProfiles(); - // selectChannel races with in-flight touch on mobile, skip there. - var preLoadIsOnSidebar = !chatIsOpen; await loadChannels(); - if (channels.length === 0) { showInitProgress(); await doRefresh(); return; } - if (preLoadIsOnSidebar && !chatIsOpen && !mobileQuery.matches) await selectChannel(1); + if (channels.length === 0) { showInitProgress(); await doRefresh(); } } catch (e) { } } @@ -6904,7 +6914,12 @@ } } + var progressSilencedUntil = 0; function updateProgressDisplay(line) { + // Silence briefly across a profile switch — log events from the + // previous profile's in-flight fetch keep arriving until the old + // fetcher's context cancellation lands. + if (Date.now() < progressSilencedUntil) return; var match = line.match(/Channel\s+(\d+)/); if (!match) return; var channelNum = parseInt(match[1]); var fracMatch = line.match(/\((\d+)\/(\d+)\)/); diff --git a/internal/web/web.go b/internal/web/web.go index 9804b72..829acfe 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -2279,12 +2279,13 @@ func addToBank(pl *ProfileList, resolvers []string) int { } // persistResolverScores saves the current fetcher stats to profiles.json. +// Not serialised by profilesMu — initFetcher holds s.mu while calling this, +// and grabbing profilesMu here would risk AB-BA with handlers that take +// profilesMu first. The score map-merge is benign under last-writer-wins. func (s *Server) persistResolverScores(stats map[string][3]int64) { if len(stats) == 0 { return } - s.profilesMu.Lock() - defer s.profilesMu.Unlock() pl, err := s.loadProfiles() if err != nil || pl == nil { return @@ -2363,9 +2364,9 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { return } s.profilesMu.Lock() - defer s.profilesMu.Unlock() pl, err := s.loadProfilesExisting() if err != nil { + s.profilesMu.Unlock() http.Error(w, fmt.Sprintf("load: %v", err), 500) return } @@ -2444,34 +2445,44 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { } default: + s.profilesMu.Unlock() http.Error(w, "unknown action", 400) return } - if err := s.saveProfiles(pl); err != nil { - http.Error(w, fmt.Sprintf("save profiles: %v", err), 500) - return - } - - // Only re-init the fetcher when the active profile's config was modified. + saveErr := s.saveProfiles(pl) + var activeConfig *Config if needsReinit && pl.Active != "" { for _, p := range pl.Profiles { if p.ID == pl.Active { - _ = s.saveConfig(&p.Config) - s.mu.Lock() - s.config = &p.Config - 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() - } + cfg := p.Config + activeConfig = &cfg break } } } + s.profilesMu.Unlock() + + if saveErr != nil { + http.Error(w, fmt.Sprintf("save profiles: %v", saveErr), 500) + return + } + + // initFetcher takes s.mu — call it OUTSIDE profilesMu so handlers + // that need both don't AB-BA against it. + if activeConfig != nil { + _ = s.saveConfig(activeConfig) + s.mu.Lock() + s.config = activeConfig + 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() + } + } writeJSON(w, map[string]any{"ok": true, "profiles": pl}) @@ -2976,8 +2987,6 @@ func (s *Server) persistLastScanToProfiles(resolvers []string) { // a populated saved list was applied; false routes the caller to the // last_scan.json / full-scan fallback chain. func (s *Server) applySelectedList() bool { - s.profilesMu.Lock() - defer s.profilesMu.Unlock() pl, err := s.loadProfiles() if err != nil || pl == nil { return false