diff --git a/cmd/server/config.go b/cmd/server/config.go index 3a93dee6..670d6081 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -154,6 +154,13 @@ type Config struct { // Customizer controls operator-side knobs for the in-app customizer modal // (theme/branding/etc.). See CustomizerConfig and issue #1508. Customizer *CustomizerConfig `json:"customizer,omitempty"` + + // Known-channels catalogue integration (issue #1323). + // URL of a JSON catalogue file (channels-by-country shape) fetched + // periodically and exposed via /api/known-channels. Empty disables. + KnownChannelsURL string `json:"knownChannelsUrl,omitempty"` + // Refresh interval in milliseconds. 0/missing => default 24h. + KnownChannelsRefreshMs int64 `json:"knownChannelsRefreshMs,omitempty"` } // CustomizerConfig holds operator-side knobs for the in-app customizer modal. diff --git a/cmd/server/known_channels_cache.go b/cmd/server/known_channels_cache.go new file mode 100644 index 00000000..3056fe14 --- /dev/null +++ b/cmd/server/known_channels_cache.go @@ -0,0 +1,224 @@ +package main + +// Known-channels catalogue cache (issue #1323). +// +// Fetches a community-maintained catalogue of hashtag channels (default: +// https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json) +// every N hours into an in-memory snapshot. Never blocks startup; never +// blocks UI on the fetch; fail-soft to last-known. No DB, no disk cache. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "time" +) + +// DefaultKnownChannelsURL is the suggested upstream catalogue, pinned to a +// specific commit SHA so a hostile or compromised future commit on the +// community repo cannot be silently fetched by deployments that opt in. +// Operators should periodically bump this pin (see config.example.json). +// NOTE: this constant is only used by tests and as documentation — the +// feature is OPT-IN: an empty cfg.KnownChannelsURL leaves the cache +// disabled (no background fetch, /api/known-channels serves empty). +const DefaultKnownChannelsURL = "https://raw.githubusercontent.com/marcelverdult/meshcore-channels/072bc25b6fc983aa2aa7e9d399a97a5f4899ea71/channels-by-country.json" + +// DefaultKnownChannelsRefresh is the default refresh interval (24h). +const DefaultKnownChannelsRefresh = 24 * time.Hour + +// maxKnownChannelsBytes caps the upstream response size we are willing to +// parse (the catalogue is ~80 KB today; 4 MB ceiling is plenty of headroom +// and bounds memory if upstream ever ships a malicious oversize payload). +const maxKnownChannelsBytes = 4 * 1024 * 1024 + +// KnownChannelEntry is one catalogue entry, region-stamped. +type KnownChannelEntry struct { + Channel string `json:"channel"` // e.g. "#antwerpen" (# prefix preserved) + Description string `json:"description,omitempty"` + Key string `json:"key,omitempty"` // optional PSK (base64) — present for some entries + Region string `json:"region"` // ISO 3166-1 alpha-2 lowercase + RegionName string `json:"regionName,omitempty"` +} + +// KnownChannelsSnapshot is the immutable parsed catalogue surfaced over /api. +type KnownChannelsSnapshot struct { + GeneratedAt string `json:"generatedAt,omitempty"` // upstream generation timestamp + License string `json:"license,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + Source string `json:"source"` + Entries []KnownChannelEntry `json:"entries"` +} + +// upstreamPayload mirrors the channels-by-country.json shape. +type upstreamPayload struct { + GeneratedAt string `json:"generated_at"` + License string `json:"license"` + Countries map[string][]upstreamCountryChannel `json:"countries"` + CountryNames map[string]string `json:"countryNames,omitempty"` // optional extension +} + +type upstreamCountryChannel struct { + Channel string `json:"channel"` + Description string `json:"description"` + Key string `json:"key,omitempty"` +} + +// parseKnownChannelsJSON parses the upstream JSON into a snapshot. +// Tolerant: missing/empty countries are skipped silently; entries with +// empty channel strings are dropped. +func parseKnownChannelsJSON(raw []byte, source string, now time.Time) (*KnownChannelsSnapshot, error) { + if len(raw) == 0 { + return nil, errors.New("empty payload") + } + var p upstreamPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("decode catalogue: %w", err) + } + out := &KnownChannelsSnapshot{ + GeneratedAt: p.GeneratedAt, + License: p.License, + FetchedAt: now, + Source: source, + Entries: make([]KnownChannelEntry, 0, 256), + } + for code, list := range p.Countries { + if len(list) == 0 { + continue + } + region := strings.ToLower(strings.TrimSpace(code)) + name := p.CountryNames[code] + for _, c := range list { + ch := strings.TrimSpace(c.Channel) + if ch == "" { + continue + } + out.Entries = append(out.Entries, KnownChannelEntry{ + Channel: ch, + Description: c.Description, + Key: c.Key, + Region: region, + RegionName: name, + }) + } + } + return out, nil +} + +// filterSnapshotByRegion returns a copy filtered to the given region +// (case-insensitive). Empty/whitespace region returns the original snapshot +// (entry slice shared — callers must not mutate). Unknown region returns +// a snapshot with an empty (but non-nil) Entries slice so JSON marshals as `[]`. +func filterSnapshotByRegion(snap *KnownChannelsSnapshot, region string) *KnownChannelsSnapshot { + if snap == nil { + return nil + } + region = strings.ToLower(strings.TrimSpace(region)) + if region == "" { + return snap + } + out := &KnownChannelsSnapshot{ + GeneratedAt: snap.GeneratedAt, + License: snap.License, + FetchedAt: snap.FetchedAt, + Source: snap.Source, + Entries: []KnownChannelEntry{}, + } + for _, e := range snap.Entries { + if e.Region == region { + out.Entries = append(out.Entries, e) + } + } + return out +} + +// knownChannelsCache holds the atomic snapshot pointer + config. +type knownChannelsCache struct { + ptr atomic.Pointer[KnownChannelsSnapshot] + url string + refresh time.Duration + client *http.Client + + fetchCount atomic.Int64 // # successful upstream fetches + failCount atomic.Int64 // # failed fetches (fail-soft) +} + +func newKnownChannelsCache(url string, refresh time.Duration) *knownChannelsCache { + if refresh <= 0 { + refresh = DefaultKnownChannelsRefresh + } + return &knownChannelsCache{ + url: url, + refresh: refresh, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// load returns the current snapshot or nil if never populated. +func (c *knownChannelsCache) load() *KnownChannelsSnapshot { + return c.ptr.Load() +} + +// fetchOnce performs a single upstream fetch. Updates ptr on success; +// leaves last-known snapshot in place on failure (fail-soft). +func (c *knownChannelsCache) fetchOnce(ctx context.Context) error { + if c.url == "" { + return errors.New("known channels url not configured") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if err != nil { + c.failCount.Add(1) + return err + } + req.Header.Set("User-Agent", "CoreScope-KnownChannels/1.0 (+https://github.com/Kpa-clawbot/CoreScope)") + resp, err := c.client.Do(req) + if err != nil { + c.failCount.Add(1) + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + c.failCount.Add(1) + return fmt.Errorf("upstream status %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxKnownChannelsBytes)) + if err != nil { + c.failCount.Add(1) + return err + } + snap, err := parseKnownChannelsJSON(body, c.url, time.Now()) + if err != nil { + c.failCount.Add(1) + return err + } + c.ptr.Store(snap) + c.fetchCount.Add(1) + return nil +} + +// run kicks off the background fetch loop in a new goroutine. Does an +// initial fetch (fail-soft) and then ticks every refresh interval until +// ctx is cancelled. Never blocks the caller — startup proceeds immediately +// even if the upstream is slow or unreachable. +func (c *knownChannelsCache) run(ctx context.Context) { + if c.url == "" { + return + } + go func() { + _ = c.fetchOnce(ctx) // initial fetch, fail-soft + t := time.NewTicker(c.refresh) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + _ = c.fetchOnce(ctx) + } + } + }() +} diff --git a/cmd/server/known_channels_cache_test.go b/cmd/server/known_channels_cache_test.go new file mode 100644 index 00000000..2b06d566 --- /dev/null +++ b/cmd/server/known_channels_cache_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// Canned fixture mirroring the upstream channels-by-country.json shape +// (https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json +// pinned 2026-05-24). Two countries: one with entries, one empty (to test +// the "skip empty countries" branch). +const knownChannelsFixture = `{ + "generated_at": "2026-05-24T22:29:02Z", + "license": "CC0-1.0", + "countries": { + "be": [ + {"channel": "#antwerpen", "description": "antwerpen"}, + {"channel": "#bemesh", "description": "bemesh"} + ], + "us": [ + {"channel": "#bayarea", "description": "Bay Area"} + ], + "ad": [] + } +}` + +// (a) Cache parses a canned JSON fixture into a snapshot. +func TestKnownChannelsParseFixture(t *testing.T) { + snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Unix(1700000000, 0)) + if err != nil { + t.Fatalf("parseKnownChannelsJSON: %v", err) + } + if snap == nil { + t.Fatal("snapshot is nil") + } + if snap.GeneratedAt != "2026-05-24T22:29:02Z" { + t.Errorf("GeneratedAt = %q, want 2026-05-24T22:29:02Z", snap.GeneratedAt) + } + if snap.License != "CC0-1.0" { + t.Errorf("License = %q, want CC0-1.0", snap.License) + } + if snap.Source != "fixture://test" { + t.Errorf("Source = %q, want fixture://test", snap.Source) + } + if got, want := len(snap.Entries), 3; got != want { + t.Fatalf("len(Entries) = %d, want %d (empty country ad must be skipped)", got, want) + } + // Spot-check one entry's region stamping. + var foundAntwerpen bool + for _, e := range snap.Entries { + if e.Channel == "#antwerpen" { + foundAntwerpen = true + if e.Region != "be" { + t.Errorf("antwerpen Region = %q, want be", e.Region) + } + } + } + if !foundAntwerpen { + t.Fatal("antwerpen entry missing from snapshot") + } +} + +// (b) The route returns 200 + filtered list. +func TestKnownChannelsRouteRegionFilter(t *testing.T) { + snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Now()) + if err != nil { + t.Fatalf("parse: %v", err) + } + srv := &Server{ + knownChannels: &knownChannelsCache{}, + } + srv.knownChannels.ptr.Store(snap) + + r := mux.NewRouter() + r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET") + + req := httptest.NewRequest(http.MethodGet, "/api/known-channels?region=be", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var resp KnownChannelsSnapshot + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String()) + } + if got := len(resp.Entries); got != 2 { + t.Fatalf("filtered entries = %d, want 2 (be has 2); got body=%s", got, w.Body.String()) + } + for _, e := range resp.Entries { + if e.Region != "be" { + t.Errorf("entry %q has region %q, want be", e.Channel, e.Region) + } + if !strings.HasPrefix(e.Channel, "#") { + t.Errorf("entry channel %q missing # prefix", e.Channel) + } + } +} + +// (c) Cache survives upstream 500 (fail-soft): a prior good snapshot must +// remain available after a failed refresh. +func TestKnownChannelsFailSoftOn500(t *testing.T) { + // First server: returns the fixture (success). + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(knownChannelsFixture)) + })) + defer good.Close() + + c := newKnownChannelsCache(good.URL, time.Hour) + if err := c.fetchOnce(context.Background()); err != nil { + t.Fatalf("initial fetchOnce: %v", err) + } + first := c.load() + if first == nil || len(first.Entries) == 0 { + t.Fatal("first snapshot must be populated") + } + + // Second server: always 500. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer bad.Close() + + // Re-point the cache to the failing upstream and fetch. + c.url = bad.URL + err := c.fetchOnce(context.Background()) + if err == nil { + t.Fatal("expected fetchOnce to return error on 500") + } + after := c.load() + if after == nil { + t.Fatal("snapshot wiped after failed fetch — must be fail-soft") + } + if len(after.Entries) != len(first.Entries) { + t.Errorf("snapshot entry count changed after failed fetch: was %d, now %d", len(first.Entries), len(after.Entries)) + } + if c.failCount.Load() < 1 { + t.Errorf("failCount = %d, want >=1", c.failCount.Load()) + } +} + +// (d) Malformed JSON returns an error AND increments failCount via +// fetchOnce (the parse path lives inside fetchOnce so the metric is +// the cache-level signal operators see, not just the parser's return). +func TestKnownChannelsParseError(t *testing.T) { + // parser-level: garbage in, error out. + if _, err := parseKnownChannelsJSON([]byte("{not json"), "fixture://bad", time.Now()); err == nil { + t.Fatal("parseKnownChannelsJSON: expected error on malformed JSON") + } + // cache-level: a 200 with malformed body must bump failCount and + // leave any prior snapshot in place. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{not json")) + })) + defer bad.Close() + c := newKnownChannelsCache(bad.URL, time.Hour) + before := c.failCount.Load() + if err := c.fetchOnce(context.Background()); err == nil { + t.Fatal("fetchOnce: expected parse error to surface") + } + if c.failCount.Load() <= before { + t.Errorf("failCount did not increment: before=%d after=%d", before, c.failCount.Load()) + } + if c.fetchCount.Load() != 0 { + t.Errorf("fetchCount = %d, want 0 (parse failed)", c.fetchCount.Load()) + } +} + +// (e) The handler tolerates a nil cache (the startup-window fail-soft +// guarantee): server still serves 200 + an empty entries snapshot +// rather than 500. Mirrors the production code path where the route +// is registered before — or independently of — knownChannels being +// instantiated (the OPT-IN gating leaves it nil entirely when disabled). +func TestKnownChannelsHandlerNilCache(t *testing.T) { + srv := &Server{} // knownChannels intentionally nil + r := mux.NewRouter() + r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET") + req := httptest.NewRequest(http.MethodGet, "/api/known-channels", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (nil cache must fail-soft); body=%s", w.Code, w.Body.String()) + } + var resp KnownChannelsSnapshot + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String()) + } + if resp.Entries == nil { + t.Fatal("Entries is nil, want non-nil empty slice (JSON [] not null)") + } + if len(resp.Entries) != 0 { + t.Errorf("Entries len = %d, want 0", len(resp.Entries)) + } + if cc := w.Header().Get("Cache-Control"); cc == "" { + t.Errorf("Cache-Control header missing on nil-cache response") + } +} + +// (f) An empty region query param ("?region=") must pass through as if +// no filter was supplied — i.e. the full snapshot is returned, NOT an +// empty list. Guards against an off-by-one in the trim+filter path. +func TestKnownChannelsRegionEmptyPassthrough(t *testing.T) { + snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Now()) + if err != nil { + t.Fatalf("parse: %v", err) + } + srv := &Server{knownChannels: &knownChannelsCache{}} + srv.knownChannels.ptr.Store(snap) + r := mux.NewRouter() + r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET") + req := httptest.NewRequest(http.MethodGet, "/api/known-channels?region=", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var resp KnownChannelsSnapshot + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String()) + } + if got, want := len(resp.Entries), len(snap.Entries); got != want { + t.Fatalf("empty region must return unfiltered snapshot: got %d entries, want %d", got, want) + } + if cc := w.Header().Get("Cache-Control"); cc == "" { + t.Errorf("Cache-Control header missing on populated response") + } +} diff --git a/cmd/server/known_channels_route.go b/cmd/server/known_channels_route.go new file mode 100644 index 00000000..8627f06f --- /dev/null +++ b/cmd/server/known_channels_route.go @@ -0,0 +1,38 @@ +package main + +import ( + "net/http" + "time" +) + +// handleKnownChannels — GET /api/known-channels?region=XX +// +// Returns the cached community catalogue of hashtag channels (issue #1323), +// optionally filtered to one region (ISO 3166-1 alpha-2, case-insensitive). +// Empty/missing cache returns 200 with an empty Entries list so the UI +// degrades gracefully (fail-soft). Never blocks on the upstream fetch: +// the response is served straight off an atomic snapshot pointer. +func (s *Server) handleKnownChannels(w http.ResponseWriter, r *http.Request) { + region := r.URL.Query().Get("region") + var snap *KnownChannelsSnapshot + if s.knownChannels != nil { + snap = s.knownChannels.load() + } + if snap == nil { + // Empty cache — return a well-formed empty snapshot. Short + // max-age so a slow first fetch (or disabled feature) doesn't + // freeze the UI for the whole page lifetime. + w.Header().Set("Cache-Control", "public, max-age=30") + writeJSON(w, &KnownChannelsSnapshot{ + FetchedAt: time.Time{}, + Source: "", + Entries: []KnownChannelEntry{}, + }) + return + } + // Catalogue refreshes every 24h upstream; 5 min browser cache is + // well under that and avoids hammering the endpoint when the UI + // re-renders the sidebar. + w.Header().Set("Cache-Control", "public, max-age=300") + writeJSON(w, filterSnapshotByRegion(snap, region)) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index fa7f905f..7491157c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -367,6 +367,26 @@ func main() { defer close(stopNeighborGraphCache) log.Printf("[neighbor-graph-cache] background recompute enabled (interval=%s)", ngInterval) + // Known-channels catalogue cache (issue #1323). OPT-IN: an empty + // cfg.KnownChannelsURL leaves srv.knownChannels nil and starts no + // background fetch. The /api/known-channels endpoint then serves an + // empty snapshot. Operators who want the community catalogue must + // set knownChannelsUrl explicitly in config.json (see + // config.example.json for the pinned-SHA recommendation). + if cfg.KnownChannelsURL != "" { + kcRefresh := DefaultKnownChannelsRefresh + if cfg.KnownChannelsRefreshMs > 0 { + kcRefresh = time.Duration(cfg.KnownChannelsRefreshMs) * time.Millisecond + } + srv.knownChannels = newKnownChannelsCache(cfg.KnownChannelsURL, kcRefresh) + kcCtx, stopKnownChannels := context.WithCancel(context.Background()) + srv.knownChannels.run(kcCtx) + defer stopKnownChannels() + log.Printf("[known-channels] background fetch enabled (url=%s, refresh=%s)", cfg.KnownChannelsURL, kcRefresh) + } else { + log.Printf("[known-channels] disabled (knownChannelsUrl unset in config)") + } + // Steady-state repeater-enrichment recomputer (issue #1262). // Prewarms the bulk caches feeding handleNodes so the very first // /api/nodes?limit=2000 from live.js's SPA bootstrap hits a diff --git a/cmd/server/routes.go b/cmd/server/routes.go index b9680d20..91416e90 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -89,6 +89,10 @@ type Server struct { // package globals) so multiple instances don't share observable // state. Initialised lazily on first use; see node_reach.go. reach reachState + + // Known-channels catalogue cache (issue #1323). Nil until configured; + // when nil the /api/known-channels endpoint returns an empty snapshot. + knownChannels *knownChannelsCache } // PerfStats tracks request performance. @@ -269,6 +273,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET") r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET") r.HandleFunc("/api/channels", s.handleChannels).Methods("GET") + r.HandleFunc("/api/known-channels", s.handleKnownChannels).Methods("GET") r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET") r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET") r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET") diff --git a/config.example.json b/config.example.json index 3a7e1cae..0606c1fe 100644 --- a/config.example.json +++ b/config.example.json @@ -330,6 +330,9 @@ "ttlSeconds": 30, "_comment": "TTL for the default-shape /api/observers response cache (#1481 P0-3). Default 30s. Lower = fresher data, more SQL pressure on the 1.9M-row observations table. TTL-boundary refills are collapsed via singleflight so concurrent requests cause exactly one SQL fill. Issue #1483." }, + "knownChannelsUrl": "", + "knownChannelsRefreshMs": 86400000, + "_comment_knownChannels": "Issue #1323. OPT-IN community-maintained hashtag-channels catalogue. Empty string (default) = DISABLED: no background HTTP fetch is started and GET /api/known-channels returns an empty snapshot. To enable, set knownChannelsUrl to a pinned-SHA URL such as \"https://raw.githubusercontent.com/marcelverdult/meshcore-channels/072bc25b6fc983aa2aa7e9d399a97a5f4899ea71/channels-by-country.json\". The URL should point at the channels-by-country.json shape: {generated_at, license, countries:{cc:[{channel,description,key?}]}}. Always pin to a specific commit SHA (not the moving 'main' branch) so a hostile or compromised future upstream commit cannot be silently fetched by your deployment; periodically bump the pin after reviewing upstream diffs. Fetched in the background every knownChannelsRefreshMs (default 86400000 = 24h). Stored in-memory only — no DB, no disk cache. Surfaced over GET /api/known-channels?region=XX. Cache is fail-soft: a failed refresh keeps the last-known snapshot in place. No credentials are sent.", "batteryThresholds": { "lowMv": 3300, "criticalMv": 3000, diff --git a/public/channels.js b/public/channels.js index b08b401d..b4688932 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1808,6 +1808,155 @@ } // #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N). + // #1323: Known-channels (catalogue) section — community-maintained + // hashtag-channels list fetched from /api/known-channels. Lazy: the + // first render shows a "Loading…" placeholder and kicks off the fetch; + // when it completes the section is re-rendered with the entries. The + // section is collapsed by default (persisted via localStorage). Click + // "+ Add" on a row to invoke the existing addUserChannel() path, which + // saves the key + decrypts. + var __knownChannels = null; // null = not fetched; [] = fetched empty + var __knownChannelsLoading = false; // single-flight guard + var __knownChannelsError = null; + var __knownChannelsErrorAt = 0; // ms timestamp of last error (for backoff) + var KNOWN_CHANNELS_ERROR_BACKOFF_MS = 60000; + function loadKnownChannels() { + if (__knownChannelsLoading) return; + // If we already have a snapshot (success or empty), don't refetch. + if (__knownChannels !== null && !__knownChannelsError) return; + // Sticky-error backoff: once an error has been recorded, wait + // KNOWN_CHANNELS_ERROR_BACKOFF_MS before allowing another attempt. + // This lets transient upstream failures recover without forcing a + // full page reload. + if (__knownChannelsError) { + var now = Date.now(); + if (now - __knownChannelsErrorAt < KNOWN_CHANNELS_ERROR_BACKOFF_MS) return; + // Clear and retry. + __knownChannelsError = null; + __knownChannels = null; + } + __knownChannelsLoading = true; + // Note: region filter intentionally NOT applied — catalogue is small + // and the user may want to browse other regions even if they're + // viewing one. Future work: per-section region selector. + var url = '/api/known-channels'; + fetch(url).then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); }) + .then(function (snap) { + __knownChannels = (snap && Array.isArray(snap.entries)) ? snap.entries : []; + __knownChannelsError = null; + __knownChannelsErrorAt = 0; + __knownChannelsLoading = false; + renderChannelList(); + }) + .catch(function (err) { + __knownChannelsError = String(err && err.message || err); + __knownChannelsErrorAt = Date.now(); + __knownChannels = []; + __knownChannelsLoading = false; + renderChannelList(); + }); + } + function renderKnownChannelsSection() { + var collapsed = localStorage.getItem('ch-known-collapsed') !== 'false'; + var count = (__knownChannels && Array.isArray(__knownChannels)) ? __knownChannels.length : 0; + // Lazy-render: if collapsed, emit an empty body — the rows are only + // built when the user expands (toggle handler populates in place). + // Avoids burning DOM for a 1000+ entry catalogue that's never seen. + var bodyInner = ''; + if (!collapsed) { + bodyInner = renderKnownChannelsBodyInner(); + } + return '' + + '
' + + '' + + '
' + + bodyInner + + '
' + + '
'; + } + // Renders the inner HTML for the catalogue body (rows or placeholder). + // Kicks off the fetch on first access. Pure HTML string — no DOM writes. + function renderKnownChannelsBodyInner() { + if (__knownChannels === null) { + setTimeout(loadKnownChannels, 0); + return '
Loading catalogue…
'; + } + if (__knownChannelsError) { + return '
Catalogue unavailable
'; + } + if (__knownChannels.length === 0) { + return '
No catalogue entries
'; + } + return __knownChannels.map(renderKnownChannelRow).join(''); + } + function renderKnownChannelRow(entry) { + // entry: {channel, description, region, regionName, key?} + var chName = String(entry.channel || '').toLowerCase(); + var safeName = chName.replace(/[<>&"]/g, function (c) { return ({'<':'<','>':'>','&':'&','"':'"'})[c]; }); + var desc = String(entry.description || '').replace(/[<>&"]/g, function (c) { return ({'<':'<','>':'>','&':'&','"':'"'})[c]; }); + var region = String(entry.region || '').toUpperCase(); + return '' + + '
' + + '
' + + '
' + safeName + '
' + + '
' + region + (desc ? ' · ' + desc : '') + '
' + + '
' + + '' + + '
'; + } + // Delegated click handler for catalogue "+ Add" buttons + toggle. + // Bound per-#chList-element (marker stored on the DOM node itself) so a + // future #chList recreation re-binds automatically instead of leaving a + // dead listener pinned to a destroyed node. + function bindKnownChannelsHandlers() { + var list = document.getElementById('chList'); + if (!list) return; + if (list.dataset.knownChannelsBound === '1') return; + list.addEventListener('click', function (e) { + var t = e.target; + if (!t) return; + if (t.id === 'chCatalogueToggle' || (t.closest && t.closest('#chCatalogueToggle'))) { + // Toggle in place: flip the body's hidden attr and caret, no + // full renderChannelList() rebuild. On first expand, populate + // the body lazily so collapsed catalogues never pay DOM cost. + var wasCollapsed = localStorage.getItem('ch-known-collapsed') !== 'false'; + var nowCollapsed = !wasCollapsed; + try { localStorage.setItem('ch-known-collapsed', nowCollapsed ? 'true' : 'false'); } catch (er) {} + var body = document.getElementById('chCatalogueBody'); + var btn = document.getElementById('chCatalogueToggle'); + if (body) { + if (nowCollapsed) { + body.setAttribute('hidden', ''); + } else { + body.removeAttribute('hidden'); + // Populate if empty (lazy first-expand). + if (!body.firstChild || body.innerHTML === '') { + body.innerHTML = renderKnownChannelsBodyInner(); + } + } + } + if (btn) { + btn.setAttribute('aria-expanded', nowCollapsed ? 'false' : 'true'); + var caret = btn.querySelector('.ch-section-caret'); + if (caret) caret.textContent = nowCollapsed ? '▸' : '▾'; + } + return; + } + var addBtn = (t.dataset && t.dataset.action === 'ch-known-add') ? t : (t.closest && t.closest('[data-action="ch-known-add"]')); + if (addBtn) { + e.preventDefault(); + var name = addBtn.getAttribute('data-channel'); + if (name && typeof addUserChannel === 'function') { + addUserChannel(name, ''); + } + } + }); + list.dataset.knownChannelsBound = '1'; + } + function renderChannelList() { const el = document.getElementById('chList'); if (!el) return; @@ -1857,7 +2006,13 @@ ` ); + + // #1323: Known channels (catalogue) section — community-maintained + // hashtag list, fetched once per page-load from /api/known-channels + // and rendered with a one-click "Add to my channels" button. + sections.push(renderKnownChannelsSection()); el.innerHTML = sections.join(''); + bindKnownChannelsHandlers(); // Toggle expand/collapse for the Encrypted section. const toggle = document.getElementById('chEncryptedToggle');