/* === CoreScope — channels.js === */ 'use strict'; (function () { let channels = []; let selectedHash = null; let messages = []; let wsHandler = null; // #1498: messages appended via the live WebSocket are stamped with // _fromWS so a subsequent REST replacement (selectChannel / // refreshMessages) can merge them in instead of stomping them. // mergeWsAppendedIntoRest() preserves any WS-pushed messages whose // packetHash is not already present in the REST response. // // Takes currentMsgs explicitly (rather than reading the module-global // `messages`) so the helper is unit-testable in isolation. // // Eviction policy (round-1 review finding #1): // - REST contains the same packetHash → REST version wins, survivor // dropped (_fromWS flag effectively cleared because REST entry // has no stamp). // - Survivor with packetHash NOT in REST → preserved as survivor. // - Survivor older than MAX_WS_SURVIVOR_MS → dropped regardless // (defensive cap so a WS message REST never returns can't survive // forever in the array). // - Survivor with null/undefined packetHash (finding #3): preserved // too (no hash → no way to dedup against REST → REST can't // possibly include it). The max-age cap evicts it eventually. // - Ordering: REST first, survivors appended at the end. Caller's // ordering convention is oldest→newest, and survivors arrived // AFTER the REST snapshot, so end-of-array is the correct slot. var MAX_WS_SURVIVOR_MS = 5 * 60 * 1000; // 5 minutes function mergeWsAppendedIntoRest(currentMsgs, restMsgs) { if (!Array.isArray(restMsgs)) return []; if (!Array.isArray(currentMsgs) || currentMsgs.length === 0) return restMsgs.slice(); var restHashes = new Set(); for (var i = 0; i < restMsgs.length; i++) { var h = restMsgs[i] && restMsgs[i].packetHash; if (h) restHashes.add(h); } var now = Date.now(); var survivors = []; for (var j = 0; j < currentMsgs.length; j++) { var m = currentMsgs[j]; if (!m || !m._fromWS) continue; // Drop survivors past max age (defensive eviction). if (m._wsAt && (now - m._wsAt) > MAX_WS_SURVIVOR_MS) continue; // If packetHash present and REST contains it, REST wins (drop). if (m.packetHash && restHashes.has(m.packetHash)) continue; // Hash absent OR not in REST → preserve. survivors.push(m); } // Always return a fresh array — never alias restMsgs — so callers // can mutate freely without leaking changes back to the input. return survivors.length ? restMsgs.concat(survivors) : restMsgs.slice(); } // #2095 — loadChannels() replaces `channels` with the server snapshot, and // the snapshot knows nothing about state that only ever lived in this tab: // unread counts, and activity the WS handler applied while the request was // in flight. mergeWsAppendedIntoRest above does the same job for `messages` // (#1498); this is its counterpart for `channels`. // // Deliberately enriches ONLY rows the snapshot already contains. Carrying a // missing row over would resurrect channels the region filter just excluded, // which is a worse bug than the one being fixed. A channel genuinely dropped // by a race re-appears on its next packet. // // Returns a fresh array of fresh objects; never aliases or mutates an input. function mergeClientChannelState(freshChannels, prevChannels) { if (!Array.isArray(freshChannels)) return []; if (!Array.isArray(prevChannels) || prevChannels.length === 0) { return freshChannels.map(function (c) { return Object.assign({}, c); }); } var prevByHash = new Map(); for (var i = 0; i < prevChannels.length; i++) { var p = prevChannels[i]; if (p && p.hash) prevByHash.set(p.hash, p); } return freshChannels.map(function (c) { var out = Object.assign({}, c); var prev = out.hash ? prevByHash.get(out.hash) : null; return prev ? carryClientChannelState(out, prev) : out; }); } // Copies the client-only fields of `prev` onto `out` and returns `out`. // Shared by mergeClientChannelState (server-known rows) and // mergeUserChannels (PSK rows, which no server snapshot contains). function carryClientChannelState(out, prev) { // Unread is counted in this tab and exists nowhere else. Carried when // the property is present, including an explicit 0: dropping that would // leave the row with undefined, which is a different thing from "read". if (Object.prototype.hasOwnProperty.call(prev, 'unread')) out.unread = prev.unread; // The user's own marks, re-derived from storage by mergeUserChannels() // straight after this, but carried here so a row is never briefly wrong. if (prev.userAdded) out.userAdded = true; if (prev.userLabel) out.userLabel = prev.userLabel; // A WS batch that landed while the request was in flight is newer than // the snapshot. Keep the whole set together: a sender without its // message reads as a different message. if ((prev.lastActivityMs || 0) > (out.lastActivityMs || 0)) { out.lastActivityMs = prev.lastActivityMs; out.lastSender = prev.lastSender; out.lastMessage = prev.lastMessage; if ((prev.messageCount || 0) > (out.messageCount || 0)) out.messageCount = prev.messageCount; } return out; } let autoScroll = true; let nodeCache = {}; let selectedNode = null; let observerIataById = {}; let observerIataByName = {}; let messageRequestId = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes function getSelectedRegionsSnapshot() { var rp = RegionFilter.getRegionParam(); return rp ? rp.split(',').filter(Boolean) : null; } function normalizeObserverNameKey(name) { if (!name) return ''; return String(name).trim().toLowerCase(); } function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegionsById, observerRegionsByName) { if (!selectedRegions || !selectedRegions.length) return true; if (observerRegionsById && observerRegionsById.byId) { observerRegionsByName = observerRegionsById.byName || {}; observerRegionsById = observerRegionsById.byId || {}; } observerRegionsById = observerRegionsById || {}; observerRegionsByName = observerRegionsByName || {}; var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null; var observerRegion = observerId ? observerRegionsById[observerId] : null; if (!observerRegion) { var observerName = msg?.data?.packet?.observer_name || msg?.data?.observer_name || msg?.data?.observer || null; var observerNameKey = normalizeObserverNameKey(observerName); if (observerName) observerRegion = observerRegionsByName[observerName]; if (!observerRegion && observerNameKey) observerRegion = observerRegionsByName[observerNameKey]; } if (!observerRegion) return false; return selectedRegions.indexOf(observerRegion) !== -1; } async function loadObserverRegions() { try { var data = await api('/observers', { ttl: CLIENT_TTL.observers }); var list = data && data.observers ? data.observers : []; var byId = {}; var byName = {}; for (var i = 0; i < list.length; i++) { var o = list[i]; var id = o.id || o.observer_id; var name = o.name || o.observer_name; if (!o.iata) continue; if (id) byId[id] = o.iata; if (name) { byName[name] = o.iata; var key = normalizeObserverNameKey(name); if (key) byName[key] = o.iata; } } observerIataById = byId; observerIataByName = byName; } catch {} } function beginMessageRequest(hash, regionParam) { return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; } function isStaleMessageRequest(req) { if (!req) return true; var currentRegion = RegionFilter.getRegionParam() || ''; if (req.id !== messageRequestId) return true; if (selectedHash !== req.hash) return true; if (currentRegion !== req.regionParam) return true; return false; } function reconcileSelectionAfterChannelRefresh() { if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false; selectedHash = null; messages = []; history.replaceState(null, '', '#/channels'); renderChannelList(); const header = document.getElementById('chHeader'); if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel'; const msgEl = document.getElementById('chMessages'); if (msgEl) msgEl.innerHTML = '