mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 11:50:24 +00:00
Part D of #2128: logged-in users propose a hashtag channel, an admin approves, rejects or later revokes it, and the ingestor decrypts approved channels without a restart. It is the first consumer of a generic proposal table, and answers the request in #2092. Stacked on #2138 (part C). Review the commits after that branch; GitHub shows C's commits here until it is merged. ## The situation - An instance decrypts only the hashtag channels listed in `hashChannels`. Users who want a city or club channel shown have to ask the operator, who edits `config.json` and restarts the ingestor. - #2092 asked for a suggest-and-approve flow; a downstream fork (dborup/CoreScope#99) runs one with anonymous suggestions and a file queue. ## What this PR adds **Name rules (`internal/channel`).** `ValidateHashtagName`, mirrored in the browser: - at most 31 UTF-8 bytes including `#` (firmware `ChannelDetails.name[32]`), case preserved, `Public` refused in any case; - control, bidi, separator and format characters refused except ZWJ, plus invisible fillers (`Other_Default_Ignorable_Code_Point`, U+2800, non-ASCII spaces). Go and JS give the same verdict for every code point. **Store.** Schema v4, one generic `proposals` table (kind, subject, status pending/approved/rejected/revoked, proposer, reviewer, note). One row per (kind, subject); limits are checked in the same transaction as the change. **Server** (only with `userManagement.channelProposals.enabled`) - `POST /api/proposals`, `GET /api/account/proposals`, `GET /api/admin/proposals`, `POST /api/admin/proposals/{id}/approve|reject|revoke`. - 400 for an invalid name. 409 for a duplicate, a rejected name, or a name already in `hashChannels`/`channelKeys` ("this channel is already decrypted on this instance"). 429 over a limit (`maxPending` 100, `maxApproved` 128, `perUserPerDay` 5). - `GET /api/channels` gains `approvedChannels`, so approved channels are listed before they have traffic. With the feature off the response is byte-identical. **Ingestor.** Opens `users.db` with `mode=ro` every 60 seconds, re-validates the names, and swaps its key map through an `atomic.Pointer`. Configured channels always win, and a failed read keeps the last good set. The ingestor never writes `users.db`; AGENTS.md now says so. **Frontend.** "Propose for everyone" in the add-channel dialog, an "approved" marker in the channel list, "My proposals" on the account page, and an admin "Proposals" tab with a warning on approve. The packet detail pane now escapes the channel name: until now only the operator chose those names. Spec: [`docs/specs/2026-10-07-channel-proposals-design.md`](https://github.com/efiten/CoreScope/blob/feat/channel-proposals/docs/specs/2026-10-07-channel-proposals-design.md). ## Performance Every key is tried on each GRP_TXT that no key opens (`decodeGrpTxt`). Benchmark of that worst case: | Keys | Time per packet | |---|---| | 320 configured | 204 to 216 µs | | 320 configured + 128 approved | 272 to 320 µs | That is about +33%, linear in the key count and capped by `maxApproved`. With the feature off the decoder gets the identical map and no ticker runs. ## Verification - `internal/channel`, `internal/users` (with `-race`), `cmd/server` and `cmd/ingestor` pass locally, 53 new Go tests. The ingestor's `TestWriteStatsAtomic_SymlinkAtDestIsReplaced` needs symlink rights on Windows and fails on `master` too. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 21 of 21 steps locally (propose, approve, the channel appears, revoke removes it). - On our staging and production instance since 7 October 2026. ## Not in this PR - Private (PSK) channels: their keys stay in the browser (#725). - Anonymous proposals. - Names that need ZWNJ (some Persian and Urdu spellings) and subdivision flags are refused, because ZWNJ and tag characters are format characters. Allowing them later only widens the rule, so no approved name gets stranded. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2433 lines
117 KiB
JavaScript
2433 lines
117 KiB
JavaScript
/* === 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 = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||
return true;
|
||
}
|
||
|
||
async function lookupNode(name) {
|
||
var cached = nodeCache[name];
|
||
if (cached !== undefined) {
|
||
if (cached && cached.fetchedAt && (Date.now() - cached.fetchedAt < _nodeCacheTTL)) return cached.data;
|
||
if (cached && !cached.fetchedAt) return cached; // legacy null entries
|
||
}
|
||
try {
|
||
const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages });
|
||
// Try exact match first, then case-insensitive, then contains
|
||
const nodes = data.nodes || [];
|
||
const match = nodes.find(n => n.name === name)
|
||
|| nodes.find(n => n.name && n.name.toLowerCase() === name.toLowerCase())
|
||
|| nodes.find(n => n.name && n.name.toLowerCase().includes(name.toLowerCase()))
|
||
|| nodes[0] || null;
|
||
nodeCache[name] = { data: match, fetchedAt: Date.now() };
|
||
return match;
|
||
} catch { nodeCache[name] = null; return null; }
|
||
}
|
||
|
||
async function showNodeTooltip(e, name) {
|
||
const node = await lookupNode(name);
|
||
let existing = document.getElementById('chNodeTooltip');
|
||
if (existing) existing.remove();
|
||
if (!node) return;
|
||
|
||
const tip = document.createElement('div');
|
||
tip.id = 'chNodeTooltip';
|
||
tip.className = 'ch-node-tooltip';
|
||
tip.setAttribute('role', 'tooltip');
|
||
const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion');
|
||
const role = (ROLE_EMOJI[roleKey] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||
const lastActivity = node.last_heard || node.last_seen;
|
||
const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown';
|
||
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||
<div class="ch-tooltip-role">${role}</div>
|
||
<div class="ch-tooltip-meta">Last seen: ${lastSeen}</div>
|
||
<div class="ch-tooltip-key mono">${(node.public_key || '').slice(0, 16)}…</div>`;
|
||
document.body.appendChild(tip);
|
||
var trigger = e.target.closest('[data-node]') || e.target;
|
||
trigger.setAttribute('aria-describedby', 'chNodeTooltip');
|
||
const rect = trigger.getBoundingClientRect();
|
||
tip.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
|
||
tip.style.top = (rect.bottom + 4) + 'px';
|
||
}
|
||
|
||
function hideNodeTooltip() {
|
||
var trigger = document.querySelector('[aria-describedby="chNodeTooltip"]');
|
||
if (trigger) trigger.removeAttribute('aria-describedby');
|
||
const tip = document.getElementById('chNodeTooltip');
|
||
if (tip) tip.remove();
|
||
}
|
||
|
||
let _focusTrapCleanup = null;
|
||
let _nodePanelTrigger = null;
|
||
|
||
function trapFocus(container) {
|
||
function handler(e) {
|
||
if (e.key === 'Escape') { closeNodeDetail(); return; }
|
||
if (e.key !== 'Tab') return;
|
||
const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||
if (!focusable.length) return;
|
||
const first = focusable[0], last = focusable[focusable.length - 1];
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||
} else {
|
||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||
}
|
||
}
|
||
container.addEventListener('keydown', handler);
|
||
return function () { container.removeEventListener('keydown', handler); };
|
||
}
|
||
|
||
async function showNodeDetail(name) {
|
||
_nodePanelTrigger = document.activeElement;
|
||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||
var _capturedHash = selectedHash;
|
||
const node = await lookupNode(name);
|
||
selectedNode = name;
|
||
var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels';
|
||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||
|
||
let panel = document.getElementById('chNodePanel');
|
||
if (!panel) {
|
||
panel = document.createElement('div');
|
||
panel.id = 'chNodePanel';
|
||
panel.className = 'ch-node-panel';
|
||
document.querySelector('.ch-main').appendChild(panel);
|
||
}
|
||
panel.classList.add('open');
|
||
|
||
if (!node) {
|
||
panel.innerHTML = `<div class="ch-node-panel-header">
|
||
<strong>${escapeHtml(name)}</strong>
|
||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
|
||
</div>
|
||
<div class="ch-node-panel-body">
|
||
<div class="ch-node-field" style="color:var(--text-muted)">No node record found — this sender has only been seen in channel messages, not via adverts.</div>
|
||
</div>`;
|
||
_focusTrapCleanup = trapFocus(panel);
|
||
panel.querySelector('.ch-node-close')?.focus();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail });
|
||
const n = detail.node;
|
||
const adverts = detail.recentAdverts || [];
|
||
const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion');
|
||
const role = (ROLE_EMOJI[roleKey] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||
const lastActivity = n.last_heard || n.last_seen;
|
||
const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown';
|
||
|
||
panel.innerHTML = `<div class="ch-node-panel-header">
|
||
<strong>${escapeHtml(n.name || 'Unknown')}</strong>
|
||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
|
||
</div>
|
||
<div class="ch-node-panel-body">
|
||
<div class="ch-node-field"><span class="ch-node-label">Role</span> ${role}</div>
|
||
<div class="ch-node-field"><span class="ch-node-label">Last Seen</span> ${lastSeen}</div>
|
||
<div class="ch-node-field"><span class="ch-node-label">Adverts</span> ${n.advert_count || 0}</div>
|
||
${n.lat && n.lon ? `<div class="ch-node-field"><span class="ch-node-label">Location</span> ${Number(n.lat).toFixed(4)}, ${Number(n.lon).toFixed(4)}</div>` : ''}
|
||
<div class="ch-node-field mono" style="font-size:11px;word-break:break-all"><span class="ch-node-label">Key</span> ${n.public_key}</div>
|
||
${adverts.length ? `<div class="ch-node-adverts"><span class="ch-node-label">Recent Adverts</span>
|
||
${adverts.slice(0, 5).map(a => `<div class="ch-node-advert">${timeAgo(a.timestamp)} · SNR ${a.snr != null ? a.snr + 'dB' : '?'}</div>`).join('')}
|
||
</div>` : ''}
|
||
<a href="#/nodes/${n.public_key}" class="ch-node-link">View full node detail →</a>
|
||
</div>`;
|
||
_focusTrapCleanup = trapFocus(panel);
|
||
panel.querySelector('.ch-node-close')?.focus();
|
||
} catch (e) {
|
||
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
|
||
_focusTrapCleanup = trapFocus(panel);
|
||
panel.querySelector('.ch-node-close')?.focus();
|
||
}
|
||
}
|
||
|
||
function closeNodeDetail() {
|
||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||
const panel = document.getElementById('chNodePanel');
|
||
if (panel) panel.classList.remove('open');
|
||
selectedNode = null;
|
||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||
history.replaceState(null, '', _chRestoreUrl);
|
||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||
_nodePanelTrigger.focus();
|
||
_nodePanelTrigger = null;
|
||
}
|
||
}
|
||
|
||
// WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds
|
||
// Channel badge colors (white text on colored background)
|
||
const CHANNEL_COLORS = [
|
||
'#1d4ed8', '#b91c1c', '#15803d', '#b45309', '#7e22ce',
|
||
'#0e7490', '#a16207', '#0f766e', '#be185d', '#1e40af',
|
||
];
|
||
// Sender name colors — must be readable on --card-bg (light: ~#fff, dark: ~#1e293b)
|
||
// Using CSS vars via inline style would be ideal, but these are reasonable middle-ground
|
||
// Light mode bg ~white: need dark enough. Dark mode bg ~#1e293b: need light enough.
|
||
// Solution: use medium-bright saturated colors that work on both.
|
||
const SENDER_COLORS_LIGHT = [
|
||
'#16a34a', '#2563eb', '#db2777', '#ca8a04', '#7c3aed',
|
||
'#0d9488', '#ea580c', '#c026d3', '#0284c7', '#dc2626',
|
||
'#059669', '#4f46e5', '#e11d48', '#d97706', '#9333ea',
|
||
];
|
||
const SENDER_COLORS_DARK = [
|
||
'#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa',
|
||
'#2dd4bf', '#fb923c', '#e879f9', '#38bdf8', '#f87171',
|
||
'#34d399', '#818cf8', '#fb7185', '#fbbf24', '#c084fc',
|
||
];
|
||
|
||
function hashCode(str) {
|
||
let h = 0;
|
||
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||
return Math.abs(h);
|
||
}
|
||
function formatHashHex(hash) {
|
||
return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash;
|
||
}
|
||
function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; }
|
||
// attrSel makes a value safe inside a quoted CSS attribute selector:
|
||
// channel names may contain " and \ (approved hashtag channels).
|
||
function attrSel(v) {
|
||
return (window.CSS && CSS.escape) ? CSS.escape(String(v)) : String(v).replace(/["\\]/g, '\\$&');
|
||
}
|
||
function getSenderColor(name) {
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
const palette = isDark ? SENDER_COLORS_DARK : SENDER_COLORS_LIGHT;
|
||
return palette[hashCode(String(name)) % palette.length];
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// #1851: region scope from the packet's transport code (scope_name).
|
||
// null = no transport code, so no chip; '' = a transport code the ingestor
|
||
// could not match to a single region (no key matched, or several did with no
|
||
// operator-configured winner); otherwise the matched region name. Chip
|
||
// classes are the Scope Audit's (scope-audit.css), so a scope looks the same
|
||
// everywhere.
|
||
function messageScopeChipHtml(scopeName) {
|
||
if (scopeName == null) return '';
|
||
if (scopeName === '') {
|
||
return '<span class="sa-chip sa-chip-unmatched ch-msg-scope" title="Sent with a region scope that could not be matched to a single region on this instance">unknown scope</span>';
|
||
}
|
||
return '<span class="sa-chip sa-chip-declared ch-msg-scope" title="Region scope this message was sent with">' + escapeHtml(scopeName) + '</span>';
|
||
}
|
||
|
||
function truncate(str, len) {
|
||
if (!str) return '';
|
||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||
}
|
||
|
||
function formatSecondsAgo(sec) {
|
||
if (sec < 0) sec = 0;
|
||
if (sec < 60) return sec + 's ago';
|
||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||
return Math.floor(sec / 86400) + 'd ago';
|
||
}
|
||
|
||
function highlightMentions(text) {
|
||
if (!text) return '';
|
||
return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) {
|
||
const safeId = btoa(encodeURIComponent(name));
|
||
return '<span class="ch-mention ch-sender-link" tabindex="0" role="button" data-node="' + safeId + '">@' + name + '</span>';
|
||
});
|
||
}
|
||
|
||
let regionChangeHandler = null;
|
||
|
||
// --- Client-side channel decryption (#725 M2) ---
|
||
|
||
// Check if input is a valid hex string (32 hex chars = 16 bytes)
|
||
function isHexKey(val) {
|
||
return /^[0-9a-fA-F]{32}$/.test(val);
|
||
}
|
||
|
||
// Show status message in the add-channel form (#759)
|
||
var statusTimer = null;
|
||
function showAddStatus(msg, type) {
|
||
var el = document.getElementById('chAddStatus');
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.className = 'ch-add-status ch-add-status--' + (type || 'info');
|
||
el.style.display = '';
|
||
clearTimeout(statusTimer);
|
||
if (type !== 'loading') {
|
||
statusTimer = setTimeout(function () { el.style.display = 'none'; }, 5000);
|
||
}
|
||
}
|
||
|
||
// #1087 Bug 3: single canonical persistence helper. Both the Generate
|
||
// path and the PSK Add path route writes through this function so the
|
||
// localStorage write happens synchronously inside the submit handler —
|
||
// not as a side effect of subsequent UI events.
|
||
//
|
||
// The previous code spread storeKey() calls across multiple branches,
|
||
// and the persistence path could be skipped entirely if the modal was
|
||
// closed before mergeUserChannels() ran. Hence the original symptom:
|
||
// a freshly-added channel disappeared on refresh, then "reappeared"
|
||
// when ANOTHER channel was added (because the second add wrote the
|
||
// entire current state, including #1).
|
||
//
|
||
// Returns true iff the key was successfully stored AND a re-read
|
||
// confirms it landed in localStorage. Returns false on quota / other
|
||
// storage failure so callers can surface an error.
|
||
function persistAddedChannel(channelName, keyHex, label) {
|
||
if (!channelName || !keyHex) return false;
|
||
try {
|
||
ChannelDecrypt.storeKey(channelName, keyHex, label);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
// Verify the write by re-reading. localStorage can silently drop
|
||
// writes under quota pressure, and we want callers to know.
|
||
try {
|
||
var keys = (typeof ChannelDecrypt.getStoredKeys === 'function')
|
||
? ChannelDecrypt.getStoredKeys()
|
||
: JSON.parse(localStorage.getItem('corescope_channel_keys') || '{}');
|
||
if (!keys || keys[channelName] !== keyHex) return false;
|
||
// Polish MINOR-3: also verify the label round-tripped when one was supplied.
|
||
// Labels live in a separate storage bucket and could fail independently
|
||
// of the key write — caller deserves to know if the friendly name didn't land.
|
||
var trimmed = (typeof label === 'string') ? label.trim() : '';
|
||
if (trimmed) {
|
||
var stored = (typeof ChannelDecrypt.getLabel === 'function')
|
||
? ChannelDecrypt.getLabel(channelName)
|
||
: ((typeof ChannelDecrypt.getLabels === 'function')
|
||
? (ChannelDecrypt.getLabels()[channelName] || '')
|
||
: '');
|
||
if (stored !== trimmed) return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Add a user channel by name (#channelname) or hex key.
|
||
// `label` (#1020) is an optional friendly name shown in the sidebar instead
|
||
// of "psk:<hex8>" — stored alongside the key in localStorage.
|
||
async function addUserChannel(val, label) {
|
||
var displayName = val.startsWith('#') ? val : (isHexKey(val) ? val.substring(0, 8) + '…' : '#' + val);
|
||
showAddStatus('Decrypting ' + displayName + ' messages…', 'loading');
|
||
var channelName, keyHex;
|
||
try {
|
||
if (val.startsWith('#')) {
|
||
channelName = val;
|
||
var keyBytes = await ChannelDecrypt.deriveKey(channelName);
|
||
keyHex = ChannelDecrypt.bytesToHex(keyBytes);
|
||
} else if (isHexKey(val)) {
|
||
keyHex = val.toLowerCase();
|
||
channelName = 'psk:' + keyHex.substring(0, 8);
|
||
} else {
|
||
// Try with # prefix if user forgot
|
||
channelName = '#' + val;
|
||
var keyBytes2 = await ChannelDecrypt.deriveKey(channelName);
|
||
keyHex = ChannelDecrypt.bytesToHex(keyBytes2);
|
||
}
|
||
|
||
// #1020/#1087: persist optional user-supplied label alongside the key
|
||
// through the canonical helper (verified read-back).
|
||
if (!persistAddedChannel(channelName, keyHex, label)) {
|
||
showAddStatus('Failed to save channel — browser storage may be full', 'error');
|
||
return;
|
||
}
|
||
|
||
// Compute channel hash byte to find matching encrypted channels
|
||
var keyBytes3 = ChannelDecrypt.hexToBytes(keyHex);
|
||
var hashByte = await ChannelDecrypt.computeChannelHash(keyBytes3);
|
||
|
||
// Add to sidebar or merge with existing encrypted channel
|
||
mergeUserChannels();
|
||
renderChannelList();
|
||
|
||
// Auto-select and start decrypting
|
||
var targetHash = 'user:' + channelName;
|
||
// Check if there's an existing encrypted channel with this hash byte
|
||
var existingEncrypted = channels.find(function (ch) {
|
||
return ch.encrypted && String(ch.hash) === String(hashByte);
|
||
});
|
||
if (existingEncrypted) {
|
||
targetHash = existingEncrypted.hash;
|
||
}
|
||
var selectResult = await selectChannel(targetHash, { userKey: keyHex, channelHashByte: hashByte, channelName: channelName });
|
||
|
||
// #1020: derive count from selectChannel's reported result, not from a
|
||
// DOM scrape that can race with rendering.
|
||
var msgCount = (selectResult && typeof selectResult.messageCount === 'number')
|
||
? selectResult.messageCount
|
||
: (Array.isArray(messages) ? messages.length : 0);
|
||
var displayLabel = (typeof label === 'string' && label.trim()) ? label.trim() :
|
||
(channelName.startsWith('psk:') ? 'Custom channel (' + channelName.substring(4) + ')' : channelName);
|
||
if (selectResult && selectResult.wrongKey) {
|
||
showAddStatus('Key does not match any packets for ' + displayLabel, 'error');
|
||
} else if (msgCount > 0) {
|
||
showAddStatus('Added ' + displayLabel + ' — ' + msgCount + ' messages decrypted', 'success');
|
||
} else {
|
||
showAddStatus('Added ' + displayLabel + ' — no messages found yet', 'warn');
|
||
}
|
||
} catch (err) {
|
||
showAddStatus('Failed to decrypt', 'error');
|
||
}
|
||
}
|
||
|
||
// Merge user-stored keys into the channel list.
|
||
// If a stored key matches a server-known channel, mark that channel as
|
||
// userAdded so the close button appears // EMOJI-OK: prior glyph reference — otherwise the user has no way to
|
||
// remove a key they added but that the server already knows about.
|
||
//
|
||
// `prevChannels` is the list a refresh is replacing. A PSK row is never in
|
||
// the server snapshot, so this is where its unread and activity survive.
|
||
function mergeUserChannels(prevChannels) {
|
||
var prevByHash = new Map();
|
||
if (Array.isArray(prevChannels)) {
|
||
for (var k = 0; k < prevChannels.length; k++) {
|
||
if (prevChannels[k] && prevChannels[k].hash) prevByHash.set(prevChannels[k].hash, prevChannels[k]);
|
||
}
|
||
}
|
||
var keys = ChannelDecrypt.getStoredKeys();
|
||
var labels = (typeof ChannelDecrypt.getLabels === 'function') ? ChannelDecrypt.getLabels() : {};
|
||
var names = Object.keys(keys);
|
||
for (var i = 0; i < names.length; i++) {
|
||
var name = names[i];
|
||
var label = labels[name] || '';
|
||
var matched = false;
|
||
for (var j = 0; j < channels.length; j++) {
|
||
var ch = channels[j];
|
||
if (ch.name === name || ch.hash === name || ch.hash === ('user:' + name)) {
|
||
ch.userAdded = true;
|
||
if (label) ch.userLabel = label;
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!matched) {
|
||
var row = {
|
||
hash: 'user:' + name,
|
||
name: name,
|
||
userLabel: label,
|
||
messageCount: 0,
|
||
lastActivityMs: 0,
|
||
lastSender: '',
|
||
lastMessage: 'Encrypted — click to decrypt',
|
||
encrypted: true,
|
||
userAdded: true
|
||
};
|
||
var prev = prevByHash.get(row.hash);
|
||
if (prev) {
|
||
carryClientChannelState(row, prev);
|
||
row.userLabel = label; // storage wins over the label the row had
|
||
}
|
||
channels.push(row);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fetch and decrypt GRP_TXT packets client-side (M5: delta fetch + cache)
|
||
async function fetchAndDecryptChannel(keyHex, channelHashByte, channelName, opts) {
|
||
opts = opts || {};
|
||
var keyBytes = ChannelDecrypt.hexToBytes(keyHex);
|
||
|
||
// M5: Check cache first — serve cached messages immediately
|
||
var cacheKey = channelName || String(channelHashByte);
|
||
var cached = ChannelDecrypt.getCache(cacheKey);
|
||
var cachedMsgs = cached ? cached.messages : [];
|
||
var lastTs = cached ? cached.lastTimestamp : '';
|
||
var cachedCount = cached ? (cached.count || 0) : 0;
|
||
|
||
// If we have cached messages and caller wants instant render, return them first
|
||
if (cachedMsgs.length > 0 && !opts.forceFullDecrypt) {
|
||
// Signal caller to render cache immediately, then do delta fetch
|
||
if (opts.onCacheHit) opts.onCacheHit(cachedMsgs);
|
||
}
|
||
|
||
// Fetch packets from API — get all payload_type=5 (GRP_TXT/CHAN)
|
||
var rp = RegionFilter.getRegionParam();
|
||
var qs = (rp ? '®ion=' + encodeURIComponent(rp) : '');
|
||
var data;
|
||
try {
|
||
data = await api('/packets?limit=1000&payloadType=5' + qs, { ttl: 10000 });
|
||
} catch (e) {
|
||
return { messages: cachedMsgs, error: 'Failed to fetch packets: ' + e.message, fromCache: cachedMsgs.length > 0 };
|
||
}
|
||
|
||
var packets = data.packets || [];
|
||
// Filter for GRP_TXT (encrypted) packets matching our channel hash byte
|
||
var candidates = [];
|
||
for (var i = 0; i < packets.length; i++) {
|
||
var p = packets[i];
|
||
var dj;
|
||
try { dj = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : p.decoded_json; }
|
||
catch (e) { continue; }
|
||
if (!dj) continue;
|
||
|
||
if (dj.type === 'CHAN' && dj.channel === channelName) {
|
||
candidates.push({ type: 'already_decrypted', decoded: dj, packet: p });
|
||
} else if (dj.type === 'GRP_TXT' && dj.encryptedData && dj.mac) {
|
||
if (dj.channelHash === channelHashByte) {
|
||
candidates.push({ type: 'encrypted', decoded: dj, packet: p });
|
||
}
|
||
}
|
||
}
|
||
|
||
// M5: Cache invalidation — if total candidate count changed, re-decrypt everything
|
||
var totalCandidates = candidates.length;
|
||
// #1851: a cache written before messages carried scope_name (or
|
||
// path_hash_size) would keep those messages without it on the delta path,
|
||
// so decrypt them again.
|
||
var cacheLacksFields = cachedMsgs.some(function (m) { return !('scope_name' in m) || !('path_hash_size' in m); });
|
||
var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt || cacheLacksFields;
|
||
|
||
// M5: Delta fetch — only decrypt packets newer than lastTs
|
||
if (!needFullDecrypt && cachedMsgs.length > 0 && lastTs) {
|
||
// Filter candidates to only those newer than cached lastTimestamp
|
||
var newCandidates = candidates.filter(function (c) {
|
||
var ts = c.packet.first_seen || c.packet.timestamp || '';
|
||
return ts > lastTs;
|
||
});
|
||
|
||
if (newCandidates.length === 0) {
|
||
// Nothing new — return cache as-is
|
||
return { messages: cachedMsgs, fromCache: true };
|
||
}
|
||
|
||
// Decrypt only new candidates
|
||
var newDecrypted = await decryptCandidates(keyBytes, newCandidates);
|
||
if (newDecrypted.wrongKey) {
|
||
return { messages: cachedMsgs, wrongKey: true };
|
||
}
|
||
|
||
// Merge: cached + new, deduplicate by packetHash, sort chronologically
|
||
var merged = deduplicateAndMerge(cachedMsgs, newDecrypted.messages);
|
||
var newLastTs = merged.length ? merged[merged.length - 1].timestamp : lastTs;
|
||
ChannelDecrypt.setCache(cacheKey, merged, newLastTs, totalCandidates);
|
||
return { messages: merged, deltaCount: newDecrypted.messages.length };
|
||
}
|
||
|
||
if (candidates.length === 0) {
|
||
return { messages: cachedMsgs, empty: true };
|
||
}
|
||
|
||
// Full decrypt
|
||
var result = await decryptCandidates(keyBytes, candidates);
|
||
if (result.wrongKey) {
|
||
return { messages: result.messages, wrongKey: true };
|
||
}
|
||
|
||
var decrypted = result.messages;
|
||
// Sort chronologically (oldest first)
|
||
decrypted.sort(function (a, b) {
|
||
var ta = a.timestamp || '';
|
||
var tb = b.timestamp || '';
|
||
return ta.localeCompare(tb);
|
||
});
|
||
|
||
// M5: Cache results
|
||
var newLastTimestamp = decrypted.length ? decrypted[decrypted.length - 1].timestamp : '';
|
||
ChannelDecrypt.setCache(cacheKey, decrypted, newLastTimestamp, totalCandidates);
|
||
|
||
return { messages: decrypted };
|
||
}
|
||
|
||
/** Decrypt an array of candidate packets. Returns { messages, wrongKey }. */
|
||
async function decryptCandidates(keyBytes, candidates) {
|
||
// Sort newest first for progressive rendering
|
||
candidates.sort(function (a, b) {
|
||
var ta = a.packet.first_seen || a.packet.timestamp || '';
|
||
var tb = b.packet.first_seen || b.packet.timestamp || '';
|
||
return tb.localeCompare(ta);
|
||
});
|
||
|
||
var decrypted = [];
|
||
var macFailCount = 0;
|
||
var macCheckCount = 0;
|
||
|
||
for (var j = 0; j < candidates.length; j++) {
|
||
var c = candidates[j];
|
||
|
||
if (c.type === 'already_decrypted') {
|
||
var d = c.decoded;
|
||
var sender = d.sender || 'Unknown';
|
||
var text = d.text || '';
|
||
var ci = text.indexOf(': ');
|
||
if (ci > 0 && ci < 50 && text.substring(0, ci) === sender) {
|
||
text = text.substring(ci + 2);
|
||
}
|
||
decrypted.push({
|
||
sender: sender, text: text,
|
||
timestamp: c.packet.first_seen || c.packet.timestamp,
|
||
sender_timestamp: d.sender_timestamp || null,
|
||
packetHash: c.packet.hash, packetId: c.packet.id,
|
||
hops: d.path_len || 0, snr: c.packet.snr || null,
|
||
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
|
||
scope_name: c.packet.scope_name ?? null,
|
||
path_hash_size: pathHashSize(c.packet.raw_hex),
|
||
repeats: 1
|
||
});
|
||
continue;
|
||
}
|
||
|
||
macCheckCount++;
|
||
var result = await ChannelDecrypt.decryptPacket(keyBytes, c.decoded.mac, c.decoded.encryptedData);
|
||
if (result) {
|
||
macFailCount = 0;
|
||
decrypted.push({
|
||
sender: result.sender, text: result.message,
|
||
timestamp: c.packet.first_seen || c.packet.timestamp,
|
||
sender_timestamp: result.timestamp || null,
|
||
packetHash: c.packet.hash, packetId: c.packet.id,
|
||
hops: 0, snr: c.packet.snr || null,
|
||
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
|
||
scope_name: c.packet.scope_name ?? null,
|
||
path_hash_size: pathHashSize(c.packet.raw_hex),
|
||
repeats: 1
|
||
});
|
||
} else {
|
||
macFailCount++;
|
||
if (macCheckCount >= 10 && macFailCount >= macCheckCount) {
|
||
return { messages: decrypted, wrongKey: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
return { messages: decrypted, wrongKey: false };
|
||
}
|
||
|
||
/** Merge cached and new messages, deduplicate by packetHash, sort chronologically. */
|
||
function deduplicateAndMerge(cached, newMsgs) {
|
||
var seen = {};
|
||
var merged = [];
|
||
// Add cached first
|
||
for (var i = 0; i < cached.length; i++) {
|
||
var key = cached[i].packetHash || ('idx:' + i);
|
||
if (!seen[key]) { seen[key] = true; merged.push(cached[i]); }
|
||
}
|
||
// Add new
|
||
for (var j = 0; j < newMsgs.length; j++) {
|
||
var key2 = newMsgs[j].packetHash || ('new:' + j);
|
||
if (!seen[key2]) { seen[key2] = true; merged.push(newMsgs[j]); }
|
||
}
|
||
merged.sort(function (a, b) {
|
||
var ta = a.timestamp || '';
|
||
var tb = b.timestamp || '';
|
||
return ta.localeCompare(tb);
|
||
});
|
||
return merged;
|
||
}
|
||
|
||
function init(app, routeParam) {
|
||
var _initUrlParams = getHashParams();
|
||
var _pendingNode = _initUrlParams.get('node');
|
||
|
||
app.innerHTML = `<div class="ch-layout">
|
||
<div class="ch-sidebar" aria-label="Channel list">
|
||
<div class="ch-sidebar-header">
|
||
<div class="ch-sidebar-title"><span class="ch-icon"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chats"/></svg></span> Channels</div>
|
||
<div id="chRegionFilter" class="region-filter-container ch-header-region"></div>
|
||
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
|
||
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add</button>
|
||
</div>
|
||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||
<div class="ch-loading">Loading channels…</div>
|
||
</div>
|
||
<div class="ch-sidebar-resize" aria-hidden="true"></div>
|
||
</div>
|
||
<!-- #1034 PR1: Add Channel modal -->
|
||
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
|
||
<div class="modal ch-modal" role="document">
|
||
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
|
||
<h3 id="chModalTitle">Add Channel</h3>
|
||
<div class="ch-modal-callout" role="note">
|
||
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
|
||
</div>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
|
||
<h4 id="chSecGenTitle" class="ch-modal-section-title">Generate PSK Channel</h4>
|
||
<p class="ch-modal-section-hint">Create a new private channel with a random key. Share the QR code with others to add it.</p>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chGenerateName" class="ch-modal-input" placeholder="Channel name (e.g. My Crew)" aria-label="Channel name" spellcheck="false">
|
||
<button type="button" id="chGenerateBtn" class="btn-primary">Generate & Show QR</button>
|
||
</div>
|
||
<div id="qr-output" class="ch-qr-output" aria-live="polite"></div>
|
||
</section>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecPskTitle">
|
||
<h4 id="chSecPskTitle" class="ch-modal-section-title">Add Private Channel (PSK)</h4>
|
||
<p class="ch-modal-section-hint">Paste a 32-character hex key someone shared with you, or scan their QR code.</p>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chPskKey" class="ch-modal-input ch-modal-input--mono"
|
||
placeholder="32-char hex key (0-9, a-f)"
|
||
pattern="[0-9a-fA-F]{32}"
|
||
maxlength="32"
|
||
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
|
||
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-camera"/></svg> Scan QR</button>
|
||
</div>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
|
||
<button type="button" id="chPskAddBtn" class="btn-primary">Add</button>
|
||
</div>
|
||
<div id="chPskError" class="ch-modal-error" style="display:none" role="alert"></div>
|
||
</section>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecTagTitle">
|
||
<h4 id="chSecTagTitle" class="ch-modal-section-title">Monitor Hashtag Channel</h4>
|
||
<p class="ch-modal-section-hint">Decrypt traffic on a public hashtag channel by deriving the key from its name.</p>
|
||
<div class="ch-modal-row ch-hashtag-row">
|
||
<span class="ch-hashtag-prefix" aria-hidden="true">#</span>
|
||
<input type="text" id="chHashtagName" class="ch-modal-input"
|
||
placeholder="meshcore"
|
||
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
|
||
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
|
||
<button type="button" id="chHashtagProposeBtn" class="ch-modal-btn-secondary" hidden
|
||
title="Ask an admin of this instance to decrypt this channel for every visitor">Propose for everyone</button>
|
||
</div>
|
||
<div id="chHashtagProposeMsg" class="ch-propose-msg" role="status" aria-live="polite"></div>
|
||
<div class="ch-modal-warn"><span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
|
||
</section>
|
||
|
||
<div class="ch-modal-footer">
|
||
<span class=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg></span> Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use the close button to remove individual channels.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- #1087 Bug 4: dedicated Share modal — separate from the Add
|
||
Channel modal above. Add = INPUT (paste/scan/generate). Share
|
||
= OUTPUT (display existing key as QR + URL + copyable text).
|
||
Reusing the Add modal for Share confused intent and let the
|
||
QR section bleed into the Add submit flow. -->
|
||
<div id="chShareModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chShareModalTitle" hidden>
|
||
<div class="modal ch-modal ch-share-modal" role="document">
|
||
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
|
||
<h3 id="chShareModalTitle" class="ch-share-modal-title">Share Channel</h3>
|
||
<div class="ch-share-modal-body">
|
||
<div id="chShareQr" class="ch-share-qr" aria-live="polite"></div>
|
||
<div class="ch-share-field-group">
|
||
<label class="ch-share-label" for="chShareKey">Hex Key</label>
|
||
<div class="ch-share-row">
|
||
<input type="text" id="chShareKey" data-share-field="key" class="ch-modal-input ch-modal-input--mono" readonly aria-label="Channel hex key">
|
||
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy</button>
|
||
</div>
|
||
</div>
|
||
<div class="ch-modal-warn" role="note">
|
||
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||
<div class="ch-main-header" id="chHeader">
|
||
<button type="button" class="ch-back" data-action="ch-back"
|
||
aria-label="Back to channel list" title="Back">‹</button>
|
||
<span class="ch-header-text">Select a channel</span>
|
||
</div>
|
||
<div class="ch-messages" id="chMessages">
|
||
<div class="ch-empty">Choose a channel from the sidebar to view messages</div>
|
||
</div>
|
||
<span id="chAriaLive" class="sr-only" aria-live="polite"></span>
|
||
<button class="ch-scroll-btn hidden" id="chScrollBtn">↓ New messages</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||
|
||
// #1409: Do NOT force-enable encrypted-channel visibility on init. The
|
||
// operator-facing toggle (read at the includeEncrypted gate in
|
||
// loadChannels) drives whether the API returns the 246+ encrypted
|
||
// placeholders. Default is OFF (hidden); a future user-facing toggle
|
||
// writes the flag explicitly.
|
||
|
||
regionChangeHandler = RegionFilter.onChange(function () {
|
||
loadChannels(true).then(async function () {
|
||
if (!selectedHash) return;
|
||
await refreshMessages({ regionSwitch: true, forceNoCache: true });
|
||
});
|
||
});
|
||
|
||
// #1034 PR1: Add Channel modal wiring (replaces inline form)
|
||
var modalEl = document.getElementById('chAddChannelModal');
|
||
// Channel proposals: the "Propose for everyone" control (channel-proposals.js).
|
||
var proposeUI = window.CSProposals ? window.CSProposals.bindPropose(document) : null;
|
||
function openAddModal() {
|
||
if (!modalEl) return;
|
||
modalEl.classList.remove('hidden');
|
||
modalEl.removeAttribute('hidden');
|
||
if (proposeUI) proposeUI.sync();
|
||
var first = document.getElementById('chGenerateName');
|
||
if (first) try { first.focus(); } catch (e) { /* noop */ }
|
||
}
|
||
function closeAddModal() {
|
||
if (!modalEl) return;
|
||
modalEl.classList.add('hidden');
|
||
modalEl.setAttribute('hidden', '');
|
||
var err = document.getElementById('chPskError');
|
||
if (err) { err.style.display = 'none'; err.textContent = ''; }
|
||
}
|
||
var addBtn = document.getElementById('chAddChannelBtn');
|
||
if (addBtn) addBtn.addEventListener('click', openAddModal);
|
||
if (modalEl) {
|
||
modalEl.addEventListener('click', function (e) {
|
||
// Close on overlay backdrop click or any [data-action=ch-modal-close]
|
||
var closeEl = e.target.closest('[data-action="ch-modal-close"]');
|
||
if (closeEl || e.target === modalEl) {
|
||
e.preventDefault();
|
||
closeAddModal();
|
||
}
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && !modalEl.classList.contains('hidden')) {
|
||
closeAddModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
// #1087 Bug 4: dedicated Share modal wiring.
|
||
// Polish follow-up: focus trap on open + restore focus on close (a11y).
|
||
var shareModalEl = document.getElementById('chShareModal');
|
||
var _shareModalTrigger = null;
|
||
var _shareModalKeyHandler = null;
|
||
// QR capacity bound: qrcode(0,'M') auto-detects smallest version, but
|
||
// very long display labels can overflow. URL = scheme(~30) + 32-char
|
||
// secret + encoded(name). Cap encoded label budget to keep total URL
|
||
// comfortably under the version-10 ECC-M payload (~213 bytes).
|
||
var SHARE_LABEL_MAX = 64;
|
||
function _truncateForQr(name) {
|
||
if (!name) return '';
|
||
var s = String(name);
|
||
// Encode first, then trim — encoded length is what QR sees.
|
||
var enc = encodeURIComponent(s);
|
||
if (enc.length <= SHARE_LABEL_MAX) return s;
|
||
// Walk back until encoded fits; preserves UTF-8 boundaries via
|
||
// encodeURIComponent re-check on each shrink.
|
||
while (s.length > 0 && encodeURIComponent(s).length > SHARE_LABEL_MAX) {
|
||
s = s.slice(0, -1);
|
||
}
|
||
return s;
|
||
}
|
||
function _trapShareModalFocus() {
|
||
if (!shareModalEl) return;
|
||
var focusable = shareModalEl.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||
if (!focusable.length) return;
|
||
var first = focusable[0], last = focusable[focusable.length - 1];
|
||
_shareModalKeyHandler = function (e) {
|
||
if (e.key !== 'Tab') return;
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||
} else {
|
||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||
}
|
||
};
|
||
shareModalEl.addEventListener('keydown', _shareModalKeyHandler);
|
||
}
|
||
// Open the share modal in NORMAL (key present) mode. For the
|
||
// "key not found" path, callers use openShareModalError() — both
|
||
// routes use this same modal so users never see a native alert().
|
||
function openShareModal(displayName, channelName, keyHex) {
|
||
if (!shareModalEl) return;
|
||
_shareModalTrigger = document.activeElement;
|
||
var safeName = _truncateForQr(displayName);
|
||
var title = document.getElementById('chShareModalTitle');
|
||
if (title) title.textContent = 'Share: ' + safeName;
|
||
var qrHolder = document.getElementById('chShareQr');
|
||
var keyField = document.getElementById('chShareKey');
|
||
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
|
||
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = false;
|
||
if (keyField) keyField.value = keyHex;
|
||
if (qrHolder) {
|
||
qrHolder.innerHTML = '';
|
||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||
// #1087 Bug 2: pass the user-facing displayName, NOT the
|
||
// internal `psk:<hex8>` channelName lookup key.
|
||
// #1101: qrOnly=true — render JUST the QR image. The Share
|
||
// modal has its own dedicated hex key field + Copy button
|
||
// BELOW the QR; an inline URL line + Copy Key button inside
|
||
// the QR box was redundant and visually overlapping.
|
||
window.ChannelQR.generate(safeName, keyHex, qrHolder, { qrOnly: true });
|
||
}
|
||
}
|
||
shareModalEl.classList.remove('hidden');
|
||
shareModalEl.removeAttribute('hidden');
|
||
_trapShareModalFocus();
|
||
var closeBtn = document.getElementById('chShareModalClose');
|
||
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
|
||
}
|
||
// Polish: replace native alert() for missing-key share with the
|
||
// dedicated modal in error mode (no QR/fields, just the message).
|
||
function openShareModalError(displayName, message) {
|
||
if (!shareModalEl) return;
|
||
_shareModalTrigger = document.activeElement;
|
||
var title = document.getElementById('chShareModalTitle');
|
||
if (title) title.textContent = 'Share: ' + displayName;
|
||
var qrHolder = document.getElementById('chShareQr');
|
||
if (qrHolder) {
|
||
qrHolder.innerHTML = '';
|
||
var msg = document.createElement('div');
|
||
msg.className = 'ch-share-error';
|
||
msg.setAttribute('role', 'alert');
|
||
msg.textContent = message;
|
||
qrHolder.appendChild(msg);
|
||
}
|
||
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
|
||
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = true;
|
||
shareModalEl.classList.remove('hidden');
|
||
shareModalEl.removeAttribute('hidden');
|
||
_trapShareModalFocus();
|
||
var closeBtn = document.getElementById('chShareModalClose');
|
||
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
|
||
}
|
||
function closeShareModal() {
|
||
if (!shareModalEl) return;
|
||
shareModalEl.classList.add('hidden');
|
||
shareModalEl.setAttribute('hidden', '');
|
||
if (_shareModalKeyHandler) {
|
||
shareModalEl.removeEventListener('keydown', _shareModalKeyHandler);
|
||
_shareModalKeyHandler = null;
|
||
}
|
||
// Restore focus to the trigger that opened the modal (a11y).
|
||
if (_shareModalTrigger && typeof _shareModalTrigger.focus === 'function') {
|
||
try { _shareModalTrigger.focus(); } catch (e) { /* noop */ }
|
||
}
|
||
_shareModalTrigger = null;
|
||
}
|
||
if (shareModalEl) {
|
||
shareModalEl.addEventListener('click', function (e) {
|
||
var copyBtn = e.target.closest && e.target.closest('[data-share-copy]');
|
||
if (copyBtn) {
|
||
e.preventDefault();
|
||
// #1101: only the hex key is copyable from the share modal;
|
||
// the URL field was removed, so the data-share-copy attribute
|
||
// is informational only — the source is always #chShareKey.
|
||
var src = document.getElementById('chShareKey');
|
||
if (src) {
|
||
try { src.select(); } catch (e2) {}
|
||
var doneCopy = function () {
|
||
var orig = copyBtn.textContent;
|
||
copyBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Copied';
|
||
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(src.value).then(doneCopy, doneCopy);
|
||
} else {
|
||
try { document.execCommand('copy'); } catch (e2) {}
|
||
doneCopy();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
var closeEl = e.target.closest('[data-action="ch-share-modal-close"]');
|
||
if (closeEl || e.target === shareModalEl) {
|
||
e.preventDefault();
|
||
closeShareModal();
|
||
}
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && !shareModalEl.classList.contains('hidden')) {
|
||
closeShareModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Section 1: Generate PSK
|
||
var genBtn = document.getElementById('chGenerateBtn');
|
||
if (genBtn) genBtn.addEventListener('click', async function () {
|
||
var nameEl = document.getElementById('chGenerateName');
|
||
var label = nameEl ? (nameEl.value || '').trim() : '';
|
||
// 16 random bytes -> 32-char hex
|
||
var bytes = crypto.getRandomValues(new Uint8Array(16));
|
||
var keyHex = ChannelDecrypt.bytesToHex(bytes);
|
||
var channelName = 'psk:' + keyHex.substring(0, 8);
|
||
// #1087 Bug 3: persist via canonical helper synchronously.
|
||
if (!persistAddedChannel(channelName, keyHex, label)) {
|
||
showAddStatus('Failed to save channel — storage full', 'error');
|
||
return;
|
||
}
|
||
var qrOut = document.getElementById('qr-output');
|
||
if (qrOut) {
|
||
qrOut.innerHTML = '';
|
||
// Render QR + URL + Copy Key inline.
|
||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||
// #1087 Bug 2: pass the user label (not psk:<hex8>).
|
||
window.ChannelQR.generate(label || channelName, keyHex, qrOut);
|
||
} else {
|
||
qrOut.textContent = 'Key generated: ' + keyHex;
|
||
}
|
||
}
|
||
mergeUserChannels();
|
||
renderChannelList();
|
||
showAddStatus('Generated channel ' + (label || channelName), 'success');
|
||
});
|
||
|
||
// Section 2: Add PSK
|
||
var pskBtn = document.getElementById('chPskAddBtn');
|
||
if (pskBtn) pskBtn.addEventListener('click', async function () {
|
||
var keyEl = document.getElementById('chPskKey');
|
||
var nameEl = document.getElementById('chPskName');
|
||
var errEl = document.getElementById('chPskError');
|
||
var raw = keyEl ? (keyEl.value || '').trim() : '';
|
||
var label = nameEl ? (nameEl.value || '').trim() : '';
|
||
if (!isHexKey(raw)) {
|
||
if (errEl) { errEl.textContent = 'Key must be 32 hex characters (0–9, a–f).'; errEl.style.display = ''; }
|
||
return;
|
||
}
|
||
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
|
||
closeAddModal();
|
||
if (keyEl) keyEl.value = '';
|
||
if (nameEl) nameEl.value = '';
|
||
await addUserChannel(raw.toLowerCase(), label);
|
||
});
|
||
|
||
// Section 2 (cont.): Scan QR — populates #chPskKey + #chPskName
|
||
// from a scanned meshcore://channel/add?... URL. Wiring added in
|
||
// PR #1034/PR3 against window.ChannelQR (public/channel-qr.js).
|
||
var scanBtn = document.getElementById('scan-qr-btn');
|
||
if (scanBtn) scanBtn.addEventListener('click', async function () {
|
||
var errEl = document.getElementById('chPskError');
|
||
if (!window.ChannelQR || typeof window.ChannelQR.scan !== 'function') {
|
||
if (errEl) {
|
||
errEl.textContent = 'QR scanning is unavailable in this browser.';
|
||
errEl.style.display = '';
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
var result = await window.ChannelQR.scan();
|
||
if (!result) return; // user cancelled
|
||
var keyEl = document.getElementById('chPskKey');
|
||
var nameEl = document.getElementById('chPskName');
|
||
if (keyEl && result.secret) keyEl.value = result.secret;
|
||
if (nameEl && result.name) nameEl.value = result.name;
|
||
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
|
||
} catch (err) {
|
||
if (errEl) {
|
||
errEl.textContent = 'Scan failed: ' + (err && err.message ? err.message : 'unknown error');
|
||
errEl.style.display = '';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Section 3: Monitor Hashtag
|
||
var tagBtn = document.getElementById('chHashtagBtn');
|
||
if (tagBtn) tagBtn.addEventListener('click', async function () {
|
||
var tagEl = document.getElementById('chHashtagName');
|
||
var raw = tagEl ? (tagEl.value || '').trim() : '';
|
||
if (!raw) return;
|
||
// Strip a leading '#' if the user typed one — the prefix is implicit.
|
||
if (raw.charAt(0) === '#') raw = raw.substring(1);
|
||
if (!raw) return;
|
||
closeAddModal();
|
||
if (tagEl) tagEl.value = '';
|
||
await addUserChannel('#' + raw, '');
|
||
});
|
||
|
||
loadObserverRegions();
|
||
loadChannels().then(async function () {
|
||
// Also load user-added encrypted channels into the sidebar.
|
||
// mergeUserChannels() mutates `channels` (marks userAdded, appends
|
||
// PSK-only entries) AFTER loadChannels() already rendered — so we
|
||
// MUST re-render here, otherwise the My Channels section never
|
||
// appears on first load when the route has no specific channel
|
||
// hash (regression caught by test-channel-issue-1111-e2e.js, case 2).
|
||
mergeUserChannels();
|
||
renderChannelList();
|
||
if (routeParam) await selectChannel(routeParam);
|
||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||
});
|
||
|
||
// #1454 — customizer flips the "show encrypted channels" toggle, which
|
||
// writes localStorage and fires this event. Re-fetch the list live so
|
||
// the operator sees the change without a page reload.
|
||
window.addEventListener('mc-channels-show-encrypted-changed', function () {
|
||
loadChannels(true);
|
||
});
|
||
|
||
// #89: Sidebar resize handle
|
||
(function () {
|
||
var sidebar = app.querySelector('.ch-sidebar');
|
||
var handle = app.querySelector('.ch-sidebar-resize');
|
||
var saved = localStorage.getItem('channels-sidebar-width');
|
||
if (saved) { var w = parseInt(saved, 10); if (w >= 180 && w <= 600) { sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; } }
|
||
var dragging = false, startX, startW;
|
||
handle.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = sidebar.getBoundingClientRect().width; e.preventDefault(); });
|
||
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = Math.max(180, Math.min(600, startW + e.clientX - startX)); sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; });
|
||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; localStorage.setItem('channels-sidebar-width', parseInt(sidebar.style.width, 10)); });
|
||
})();
|
||
|
||
// #90: Theme change observer — re-render messages on theme toggle
|
||
var _themeObserver = new MutationObserver(function (muts) {
|
||
for (var i = 0; i < muts.length; i++) {
|
||
if (muts[i].attributeName === 'data-theme') { if (selectedHash) renderMessages(); break; }
|
||
}
|
||
});
|
||
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||
|
||
// Event delegation for data-action buttons
|
||
app.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
var action = btn.dataset.action;
|
||
if (action === 'ch-close-node') closeNodeDetail();
|
||
if (action === 'ch-back') {
|
||
// Mobile slide-back: return to the channel list view.
|
||
selectedHash = null;
|
||
messages = [];
|
||
history.replaceState(null, '', '#/channels');
|
||
document.querySelector('.ch-layout')?.classList.remove('ch-detail-open');
|
||
var headerT = document.querySelector('#chHeader .ch-header-text');
|
||
if (headerT) headerT.textContent = 'Select a channel';
|
||
var msgEl = document.getElementById('chMessages');
|
||
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
renderChannelList();
|
||
}
|
||
});
|
||
|
||
// Event delegation for channel selection (touch-friendly)
|
||
var chListEl = document.getElementById('chList');
|
||
// Keyboard accessibility for the role="button" remove/share spans
|
||
// (Enter/Space). Single .closest() call with a combined selector.
|
||
chListEl.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
||
var rb = e.target.closest && e.target.closest('[data-remove-channel],[data-share-channel]');
|
||
if (!rb) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// Re-dispatch as a click so the existing click handler runs.
|
||
rb.click();
|
||
});
|
||
chListEl.addEventListener('click', (e) => {
|
||
// #1087 Bug 2 + Bug 4: Share/reshare opens a DEDICATED share modal
|
||
// (not the Add Channel modal) and resolves the user's display
|
||
// label via ChannelDecrypt.getLabel — never the raw `psk:<hex8>`
|
||
// lookup key.
|
||
const shareBtn = e.target.closest('[data-share-channel]');
|
||
if (shareBtn) {
|
||
e.stopPropagation();
|
||
var shareHash = shareBtn.getAttribute('data-share-channel');
|
||
if (!shareHash) return;
|
||
var sCh = channels.find(function (c) { return c.hash === shareHash; });
|
||
var channelName = shareHash.startsWith('user:')
|
||
? shareHash.substring(5)
|
||
: (sCh && sCh.name) || shareHash;
|
||
var keys = ChannelDecrypt.getStoredKeys();
|
||
var keyHex = keys[channelName];
|
||
// Resolve display label: explicit user label > channel.userLabel
|
||
// > strip the psk: prefix > raw channelName.
|
||
var labels = (typeof ChannelDecrypt.getLabels === 'function')
|
||
? ChannelDecrypt.getLabels() : {};
|
||
var labelFromStore = (typeof ChannelDecrypt.getLabel === 'function')
|
||
? ChannelDecrypt.getLabel(channelName)
|
||
: (labels[channelName] || '');
|
||
var displayName = labelFromStore
|
||
|| (sCh && sCh.userLabel)
|
||
|| (channelName.indexOf('psk:') === 0
|
||
? 'Private Channel'
|
||
: channelName);
|
||
if (!keyHex) {
|
||
openShareModalError(displayName, 'No stored key found for "' + displayName + '" — cannot share.');
|
||
return;
|
||
}
|
||
if (typeof openShareModal === 'function') {
|
||
openShareModal(displayName, channelName, keyHex);
|
||
}
|
||
return;
|
||
}
|
||
// M4: Remove channel button
|
||
const removeBtn = e.target.closest('[data-remove-channel]');
|
||
if (removeBtn) {
|
||
e.stopPropagation();
|
||
var channelHash = removeBtn.getAttribute('data-remove-channel');
|
||
if (!channelHash) return;
|
||
// The localStorage key is the channel name. For user:-prefixed entries
|
||
// strip the prefix; for server-known channels look up the channel
|
||
// object so we use its display name (the hash itself isn't the key).
|
||
var ch = channels.find(function (c) { return c.hash === channelHash; });
|
||
var chName = channelHash.startsWith('user:')
|
||
? channelHash.substring(5)
|
||
: (ch && ch.name) || channelHash;
|
||
if (!confirm('Remove channel "' + chName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return;
|
||
ChannelDecrypt.removeKey(chName);
|
||
if (channelHash.startsWith('user:')) {
|
||
// Pure user-added channel — drop from the list entirely.
|
||
channels = channels.filter(function (c) { return c.hash !== channelHash; });
|
||
if (selectedHash === channelHash) {
|
||
selectedHash = null;
|
||
messages = [];
|
||
history.replaceState(null, '', '#/channels');
|
||
var msgEl2 = document.getElementById('chMessages');
|
||
if (msgEl2) msgEl2.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
var header2 = document.getElementById('chHeader');
|
||
if (header2) header2.querySelector('.ch-header-text').textContent = 'Select a channel';
|
||
}
|
||
} else if (ch) {
|
||
// Server-known channel: keep the row, just unmark as user-added so
|
||
// the close button disappears until they re-add a key. // EMOJI-OK: prior glyph reference
|
||
ch.userAdded = false;
|
||
// If this was the selected channel, clear decrypted messages since
|
||
// the key is gone — they can't be re-decrypted without re-adding it.
|
||
if (selectedHash === channelHash) {
|
||
messages = [];
|
||
var msgEl2 = document.getElementById('chMessages');
|
||
if (msgEl2) msgEl2.innerHTML = '<div class="ch-empty">Key removed — add a key to decrypt messages</div>';
|
||
}
|
||
}
|
||
renderChannelList();
|
||
return;
|
||
}
|
||
// Color clear button — remove color without opening picker (#681)
|
||
const clearBtn = e.target.closest('.ch-color-clear');
|
||
if (clearBtn && window.ChannelColors) {
|
||
e.stopPropagation();
|
||
var clearCh = clearBtn.getAttribute('data-channel');
|
||
if (clearCh) { window.ChannelColors.remove(clearCh); renderChannelList(); }
|
||
return;
|
||
}
|
||
// Color dot click — open picker, don't select channel
|
||
const dot = e.target.closest('.ch-color-dot');
|
||
if (dot && window.ChannelColorPicker) {
|
||
e.stopPropagation();
|
||
var ch = dot.getAttribute('data-channel');
|
||
if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY);
|
||
return;
|
||
}
|
||
const item = e.target.closest('.ch-item[data-hash], .ch-row[data-hash]');
|
||
if (item) selectChannel(item.dataset.hash);
|
||
});
|
||
|
||
const msgEl = document.getElementById('chMessages');
|
||
msgEl.addEventListener('scroll', () => {
|
||
const atBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||
autoScroll = atBottom;
|
||
document.getElementById('chScrollBtn').classList.toggle('hidden', atBottom);
|
||
});
|
||
document.getElementById('chScrollBtn').addEventListener('click', scrollToBottom);
|
||
|
||
// Event delegation for node clicks and hovers (click + touchend for mobile reliability)
|
||
function handleNodeTap(e) {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
e.preventDefault();
|
||
const name = decodeURIComponent(atob(el.dataset.node));
|
||
showNodeDetail(name);
|
||
} else if (selectedNode && !e.target.closest('.ch-node-panel')) {
|
||
closeNodeDetail();
|
||
}
|
||
}
|
||
// Keyboard support for data-node elements (Bug #82)
|
||
msgEl.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
e.preventDefault();
|
||
const name = decodeURIComponent(atob(el.dataset.node));
|
||
showNodeDetail(name);
|
||
}
|
||
}
|
||
});
|
||
|
||
msgEl.addEventListener('click', handleNodeTap);
|
||
// touchend fires more reliably on mobile for non-button elements
|
||
let touchMoved = false;
|
||
msgEl.addEventListener('touchstart', () => { touchMoved = false; }, { passive: true });
|
||
msgEl.addEventListener('touchmove', () => { touchMoved = true; }, { passive: true });
|
||
msgEl.addEventListener('touchend', (e) => {
|
||
if (touchMoved) return;
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
e.preventDefault();
|
||
const name = decodeURIComponent(atob(el.dataset.node));
|
||
showNodeDetail(name);
|
||
} else if (selectedNode && !e.target.closest('.ch-node-panel')) {
|
||
closeNodeDetail();
|
||
}
|
||
});
|
||
let hoverTimeout = null;
|
||
msgEl.addEventListener('mouseover', (e) => {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
clearTimeout(hoverTimeout);
|
||
const name = decodeURIComponent(atob(el.dataset.node));
|
||
showNodeTooltip(e, name);
|
||
}
|
||
});
|
||
msgEl.addEventListener('mouseout', (e) => {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||
}
|
||
});
|
||
// #86: Show tooltip on focus for keyboard users
|
||
msgEl.addEventListener('focusin', (e) => {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
clearTimeout(hoverTimeout);
|
||
const name = decodeURIComponent(atob(el.dataset.node));
|
||
showNodeTooltip(e, name);
|
||
}
|
||
});
|
||
msgEl.addEventListener('focusout', (e) => {
|
||
const el = e.target.closest('[data-node]');
|
||
if (el) {
|
||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||
}
|
||
});
|
||
|
||
function processWSBatch(msgs, selectedRegions) {
|
||
var dominated = msgs.filter(function (m) {
|
||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||
});
|
||
if (!dominated.length) return;
|
||
|
||
var channelListDirty = false;
|
||
var messagesDirty = false;
|
||
var seenHashes = new Set();
|
||
|
||
for (var i = 0; i < dominated.length; i++) {
|
||
var m = dominated[i];
|
||
if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataById, observerIataByName)) continue;
|
||
var payload = m.data?.decoded?.payload;
|
||
if (!payload) continue;
|
||
|
||
// #1468: drop CHAN messages with no decoded channel name instead of
|
||
// synthesizing a literal "unknown" row that renders as a fake channel
|
||
// in the sidebar. Server-side (#1373/#1377) already filters these from
|
||
// /api/channels; the live WebSocket router was the remaining offender.
|
||
if (!payload.channel) continue;
|
||
|
||
var channelName = payload.channel;
|
||
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
|
||
// also stamps payload.channelKey ("user:<name>") so we route the
|
||
// message to the correct sidebar row and to the open chat view.
|
||
// Falls back to channelName for server-known CHAN packets.
|
||
var channelKey = payload.channelKey || channelName;
|
||
var rawText = payload.text || '';
|
||
var sender = payload.sender || null;
|
||
var displayText = rawText;
|
||
|
||
// Parse "sender: message" format
|
||
if (rawText && !sender) {
|
||
var colonIdx = rawText.indexOf(': ');
|
||
if (colonIdx > 0 && colonIdx < 50) {
|
||
sender = rawText.slice(0, colonIdx);
|
||
displayText = rawText.slice(colonIdx + 2);
|
||
}
|
||
} else if (rawText && sender) {
|
||
var colonIdx2 = rawText.indexOf(': ');
|
||
if (colonIdx2 > 0 && colonIdx2 < 50) {
|
||
displayText = rawText.slice(colonIdx2 + 2);
|
||
}
|
||
}
|
||
if (!sender) sender = 'Unknown';
|
||
|
||
var ts = new Date().toISOString();
|
||
var pktHash = m.data?.hash || m.data?.packet?.hash || null;
|
||
var pktId = m.data?.id || null;
|
||
var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null;
|
||
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
|
||
// ?? not ||: '' (transport-scoped, region unmatched) must survive.
|
||
var scopeName = m.data?.scope_name ?? m.data?.packet?.scope_name ?? null;
|
||
var hashSize = pathHashSize(m.data?.raw_hex ?? m.data?.packet?.raw_hex);
|
||
|
||
// Update channel list entry — only once per unique packet hash
|
||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey);
|
||
if (pktHash) seenHashes.add(pktHash + ':' + channelKey);
|
||
|
||
var ch = channels.find(function (c) { return c.hash === channelKey; });
|
||
if (ch) {
|
||
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
|
||
ch.lastActivityMs = Date.now();
|
||
ch.lastSender = sender;
|
||
ch.lastMessage = truncate(displayText, 100);
|
||
channelListDirty = true;
|
||
} else if (isFirstObservation) {
|
||
// New channel we haven't seen
|
||
channels.push({
|
||
hash: channelKey,
|
||
name: channelName,
|
||
messageCount: 1,
|
||
lastActivityMs: Date.now(),
|
||
lastSender: sender,
|
||
lastMessage: truncate(displayText, 100),
|
||
});
|
||
channelListDirty = true;
|
||
}
|
||
|
||
// If this message is for the selected channel, append to messages
|
||
if (selectedHash && channelKey === selectedHash) {
|
||
// Deduplicate by packet hash — same message seen by multiple observers
|
||
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
|
||
if (existing) {
|
||
existing.repeats = (existing.repeats || 1) + 1;
|
||
if (observer && existing.observers && existing.observers.indexOf(observer) === -1) {
|
||
existing.observers.push(observer);
|
||
}
|
||
// #1498 round-1 finding #2: a WS-arriving observer update on a
|
||
// REST-loaded message must be stamped so the next REST tick
|
||
// doesn't stomp it. Without this, the new observer disappears.
|
||
existing._fromWS = true;
|
||
existing._wsAt = Date.now();
|
||
} else {
|
||
messages.push({
|
||
sender: sender,
|
||
text: displayText,
|
||
timestamp: ts,
|
||
sender_timestamp: payload.sender_timestamp || null,
|
||
packetId: pktId,
|
||
packetHash: pktHash,
|
||
repeats: 1,
|
||
observers: observer ? [observer] : [],
|
||
hops: payload.path_len || 0,
|
||
snr: snr,
|
||
scope_name: scopeName,
|
||
path_hash_size: hashSize,
|
||
// #1498: mark as WS-pushed so a later REST replacement
|
||
// (selectChannel / refreshMessages) can merge instead of
|
||
// stomp. Without this flag the REST response wipes any
|
||
// live messages that landed during the in-flight fetch.
|
||
_fromWS: true,
|
||
_wsAt: Date.now(),
|
||
});
|
||
}
|
||
messagesDirty = true;
|
||
}
|
||
}
|
||
|
||
if (channelListDirty) {
|
||
channels.sort(function (a, b) { return (b.lastActivityMs || 0) - (a.lastActivityMs || 0); });
|
||
renderChannelList();
|
||
}
|
||
if (messagesDirty) {
|
||
renderMessages();
|
||
// Update header count
|
||
var ch2 = channels.find(function (c) { return c.hash === selectedHash; });
|
||
var header = document.getElementById('chHeader');
|
||
if (header && ch2) {
|
||
header.querySelector('.ch-header-text').textContent = (ch2.name || 'Channel ' + selectedHash) + ' — ' + messages.length + ' messages';
|
||
}
|
||
var msgEl = document.getElementById('chMessages');
|
||
if (msgEl && autoScroll) scrollToBottom();
|
||
else {
|
||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||
var liveEl = document.getElementById('chAriaLive');
|
||
if (liveEl) liveEl.textContent = 'New message received';
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleWSBatch(msgs) {
|
||
var selectedRegions = getSelectedRegionsSnapshot();
|
||
processWSBatch(msgs, selectedRegions);
|
||
}
|
||
|
||
// Pre-pass: rewrite encrypted GRP_TXT live packets into decrypted form
|
||
// when a stored PSK key matches their channel hash byte (#1029 — live
|
||
// PSK decrypt). Without this, users viewing a PSK-decrypted channel
|
||
// had to refresh the page to see new messages.
|
||
async function decryptLivePSKBatch(msgs) {
|
||
if (typeof ChannelDecrypt === 'undefined' ||
|
||
typeof ChannelDecrypt.tryDecryptLive !== 'function') {
|
||
return;
|
||
}
|
||
// Quick scan: do any messages look like encrypted GRP_TXT?
|
||
var anyEncrypted = false;
|
||
for (var i = 0; i < msgs.length; i++) {
|
||
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
|
||
if (p && p.type === 'GRP_TXT' && p.encryptedData && p.mac) { anyEncrypted = true; break; }
|
||
}
|
||
if (!anyEncrypted) return;
|
||
var keyMap;
|
||
try { keyMap = await ChannelDecrypt.buildKeyMap(); } catch (e) { return; }
|
||
if (!keyMap || keyMap.size === 0) return;
|
||
for (var j = 0; j < msgs.length; j++) {
|
||
var m = msgs[j];
|
||
var payload = m && m.data && m.data.decoded && m.data.decoded.payload;
|
||
if (!payload || payload.type !== 'GRP_TXT' || !payload.encryptedData || !payload.mac) continue;
|
||
var dec;
|
||
try { dec = await ChannelDecrypt.tryDecryptLive(payload, keyMap); } catch (e) { dec = null; }
|
||
if (!dec) continue;
|
||
// Rewrite payload into a CHAN-like shape so processWSBatch picks it
|
||
// up as a real message instead of an encrypted blob. Keep the original
|
||
// hash byte for any downstream consumer that wants it.
|
||
payload.channel = dec.channelName;
|
||
// For user-added PSK channels the sidebar entry & selectedHash use a
|
||
// "user:<name>" key (see addUserChannel). Stamp the canonical key on
|
||
// the payload so processWSBatch routes the live message to the
|
||
// correct sidebar row and to the open chat view instead of dropping
|
||
// it / creating a duplicate plain entry. Falls back to the raw name
|
||
// for non-user channels (server-known CHAN paths still work).
|
||
var userKey = 'user:' + dec.channelName;
|
||
var hasUserCh = false;
|
||
for (var ck = 0; ck < channels.length; ck++) {
|
||
if (channels[ck].hash === userKey) { hasUserCh = true; break; }
|
||
}
|
||
payload.channelKey = hasUserCh ? userKey : dec.channelName;
|
||
payload.sender = dec.sender;
|
||
payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text;
|
||
payload.decryptedLocally = true;
|
||
if (m.data.decoded.header) {
|
||
// Leave payloadTypeName as GRP_TXT — processWSBatch already
|
||
// accepts both 'message' and GRP_TXT-typed packet messages.
|
||
}
|
||
}
|
||
}
|
||
|
||
wsHandler = debouncedOnWS(function (msgs) {
|
||
var selectedRegions = getSelectedRegionsSnapshot();
|
||
var prior = selectedHash;
|
||
decryptLivePSKBatch(msgs).then(function () {
|
||
// Bump unread for live-decrypted channels the user is NOT viewing.
|
||
// Done here (not inside processWSBatch) so the count reflects ONLY
|
||
// newly-decrypted live packets, not historical-fetch path.
|
||
var bumped = false;
|
||
for (var i = 0; i < msgs.length; i++) {
|
||
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
|
||
if (!p || !p.decryptedLocally) continue;
|
||
// Use the canonical sidebar key stamped by decryptLivePSKBatch so
|
||
// the comparison against `prior` (= selectedHash) actually matches
|
||
// for user-added (user:*-prefixed) channels.
|
||
var chKey = p.channelKey || p.channel;
|
||
if (!chKey || chKey === prior) continue;
|
||
var ch = channels.find(function (c) { return c.hash === chKey || c.name === chKey || c.hash === ('user:' + chKey); });
|
||
if (ch) {
|
||
ch.unread = (ch.unread || 0) + 1;
|
||
bumped = true;
|
||
}
|
||
}
|
||
processWSBatch(msgs, selectedRegions);
|
||
if (bumped) renderChannelList();
|
||
});
|
||
});
|
||
window._channelsHandleWSBatchForTest = handleWSBatch;
|
||
window._channelsProcessWSBatchForTest = processWSBatch;
|
||
|
||
// #1367: Re-render the channel list when the viewport crosses the
|
||
// mobile/desktop boundary so the layout swaps between flat .ch-row
|
||
// and sectioned .ch-item without a navigation.
|
||
var _chMobileMQ = null;
|
||
try { _chMobileMQ = window.matchMedia('(max-width: 767px)'); } catch (e) { /* noop */ }
|
||
if (_chMobileMQ && typeof _chMobileMQ.addEventListener === 'function') {
|
||
_chMobileMQ.addEventListener('change', function () { renderChannelList(); });
|
||
}
|
||
|
||
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
|
||
timeAgoTimer = setInterval(function () {
|
||
var now = Date.now();
|
||
for (var i = 0; i < channels.length; i++) {
|
||
var ch = channels[i];
|
||
if (!ch.lastActivityMs) continue;
|
||
var text = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
|
||
var el = document.querySelector('.ch-item-time[data-channel-hash="' + attrSel(ch.hash) + '"]');
|
||
if (el) el.textContent = text;
|
||
// #1367: mobile rows live in a flat list; update those too.
|
||
var rowEl = document.querySelector('.ch-row[data-hash="' + attrSel(ch.hash) + '"] .ch-row-time');
|
||
if (rowEl) rowEl.textContent = text;
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
var timeAgoTimer = null;
|
||
|
||
function destroy() {
|
||
if (wsHandler) offWS(wsHandler);
|
||
wsHandler = null;
|
||
if (timeAgoTimer) clearInterval(timeAgoTimer);
|
||
timeAgoTimer = null;
|
||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||
regionChangeHandler = null;
|
||
channels = [];
|
||
messages = [];
|
||
selectedHash = null;
|
||
selectedNode = null;
|
||
hideNodeTooltip();
|
||
const panel = document.getElementById('chNodePanel');
|
||
if (panel) panel.remove();
|
||
}
|
||
|
||
async function loadChannels(silent) {
|
||
try {
|
||
const rp = RegionFilter.getRegionParam();
|
||
var showEnc = localStorage.getItem('channels-show-encrypted') === 'true';
|
||
var params = [];
|
||
if (rp) params.push('region=' + encodeURIComponent(rp));
|
||
if (showEnc) params.push('includeEncrypted=true');
|
||
const qs = params.length ? '?' + params.join('&') : '';
|
||
const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels });
|
||
const fresh = (data.channels || []).map(ch => {
|
||
ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0;
|
||
return ch;
|
||
});
|
||
// #2095 — carry client-only state across the replacement, then re-derive
|
||
// the user's PSK rows from storage. Both must happen BEFORE
|
||
// reconcileSelectionAfterChannelRefresh(), which evicts the selection
|
||
// when it cannot find selectedHash: a user:* hash is never in the server
|
||
// snapshot, so without this a refresh closed the open conversation.
|
||
const prevChannels = channels;
|
||
channels = mergeClientChannelState(fresh, prevChannels)
|
||
.sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||
// Approved hashtag channels (proposals): listed also before they have traffic.
|
||
if (window.CSProposals) channels = window.CSProposals.mergeApproved(channels, data.approvedChannels);
|
||
if (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt) mergeUserChannels(prevChannels);
|
||
renderChannelList();
|
||
reconcileSelectionAfterChannelRefresh();
|
||
} catch (e) {
|
||
if (!silent) {
|
||
const el = document.getElementById('chList');
|
||
if (el) el.innerHTML = `<div class="ch-empty">Failed to load channels</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// #1041: single source of truth for the user-facing placeholder shown
|
||
// when a PSK channel has no user-supplied label. Hoisted so the helper
|
||
// and any future call sites stay in sync (i18n / branding-friendly).
|
||
const PRIVATE_CHANNEL_LABEL = 'Private Channel';
|
||
|
||
// Display name for a channel — handles PSK channels where the raw
|
||
// "psk:<hex8>" key prefix shouldn't be shown to users. Falls back to
|
||
// userLabel, then a friendly placeholder, then a caller-supplied
|
||
// fallback, then `Channel <hash>`.
|
||
//
|
||
// `fallback` lets row rendering preserve its existing "Unknown" /
|
||
// "Channel <hash>" semantics for encrypted-but-not-user-added channels
|
||
// without duplicating the psk:* check.
|
||
function channelDisplayName(ch, fallback) {
|
||
if (!ch) return '';
|
||
const name = ch.name || '';
|
||
if (ch.userLabel) return ch.userLabel;
|
||
if (name.indexOf('psk:') === 0) return PRIVATE_CHANNEL_LABEL;
|
||
if (name) return name;
|
||
if (fallback) return fallback;
|
||
return 'Channel ' + (typeof formatHashHex === 'function' ? formatHashHex(ch.hash) : ch.hash);
|
||
}
|
||
|
||
// #1034 PR1: render a single channel row (used by all sidebar sections).
|
||
function renderChannelRow(ch) {
|
||
const isEncrypted = ch.encrypted === true;
|
||
const isUserAdded = ch.userAdded === true;
|
||
// #1041: route through channelDisplayName so the psk:* → "Private
|
||
// Channel" rule lives in one place. Pass an `encryptedFallback` so
|
||
// rows for non-user-added encrypted channels keep showing "Unknown"
|
||
// (their existing behavior) when there's no name at all.
|
||
const encryptedFallback = isEncrypted ? 'Unknown' : '';
|
||
const name = channelDisplayName(ch, encryptedFallback);
|
||
const color = isEncrypted && !isUserAdded ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
|
||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||
// Preview: show last sender+message when we have one. Otherwise show
|
||
// nothing rather than "0 messages" — the count is misleading for
|
||
// user-added (PSK) channels where messageCount only reflects
|
||
// server-known activity, not actually-decrypted messages.
|
||
let preview;
|
||
if (ch.lastSender && ch.lastMessage) {
|
||
preview = `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`;
|
||
} else if (isEncrypted && !isUserAdded) {
|
||
preview = `0x${formatHashHex(ch.hash)}`;
|
||
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
|
||
preview = `${ch.messageCount} messages`;
|
||
} else {
|
||
preview = '';
|
||
}
|
||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||
const encClass = isUserAdded
|
||
? ' ch-user-added'
|
||
: (isEncrypted ? ' ch-encrypted' : '');
|
||
const badgeIcon = isUserAdded ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock-open"/></svg>' : (isEncrypted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>' : null);
|
||
const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
|
||
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
|
||
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
|
||
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
|
||
// #1033: must NOT be a <button> — outer .ch-item is itself a <button>;
|
||
// nested <button> is invalid HTML5 and the parser orphans everything
|
||
// after it. Use <span role="button">; keydown handler on #chList
|
||
// (Enter/Space) keeps it keyboard-accessible.
|
||
// Icon button factory — used for the per-row remove/share controls.
|
||
// Both share the .ch-icon-btn base class (touch target, opacity); a
|
||
// modifier class (.ch-remove-btn / .ch-share-btn) supplies size + color.
|
||
function iconBtn(modClass, dataAttr, hash, name, glyph, title, ariaVerb, extraAttrs) {
|
||
return ' <span class="ch-icon-btn ' + modClass + '" role="button" tabindex="0"'
|
||
+ ' ' + dataAttr + '="' + escapeHtml(hash) + '"'
|
||
+ (extraAttrs || '')
|
||
+ ' title="' + title + '"'
|
||
+ ' aria-label="' + ariaVerb + ' ' + escapeHtml(name) + '">' + glyph + '</span>';
|
||
}
|
||
const removeBtn = isUserAdded
|
||
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg>',
|
||
'Remove channel and clear saved key', 'Remove', '')
|
||
: '';
|
||
const shareBtn = isUserAdded
|
||
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-share-network"/></svg> Share',
|
||
'Share channel key (QR + URL)', 'Share', ' aria-haspopup="dialog"')
|
||
: '';
|
||
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg></span>' : '';
|
||
const approvedBadge = ch.approved ? ' <span class="ch-approved-badge" title="Approved by an admin of this instance: decrypted for every visitor">approved</span>' : '';
|
||
const unreadBadge = (ch.unread && ch.unread > 0)
|
||
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
|
||
: '';
|
||
|
||
return `<button class="ch-item${sel}${encClass}" data-hash="${escapeHtml(ch.hash)}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
|
||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
|
||
<div class="ch-item-body">
|
||
<div class="ch-item-top">
|
||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${approvedBadge}${unreadBadge}
|
||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></span>' : ''}
|
||
<span class="ch-item-time" data-channel-hash="${escapeHtml(ch.hash)}">${time}</span>${shareBtn}${removeBtn}
|
||
</div>
|
||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||
</div>
|
||
</button>`;
|
||
}
|
||
|
||
// #1367: mobile chat-app row renderer. Full-width 80px rows with a
|
||
// hash-colored avatar, bold name, ellipsized last-message preview,
|
||
// and right-aligned relative timestamp. No inline action chips.
|
||
function isMobileChannels() {
|
||
try { return window.matchMedia('(max-width: 767px)').matches; } catch (e) { return false; }
|
||
}
|
||
|
||
function avatarTextForChannel(ch) {
|
||
const name = ch && ch.name ? String(ch.name) : '';
|
||
if (name.charAt(0) === '#') return name.slice(0, 3); // "#wa"
|
||
if (ch && ch.encrypted && !ch.userAdded) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>';
|
||
if (ch && ch.userAdded) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg>';
|
||
// Fallback: 2-char uppercase abbreviation.
|
||
return name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() ||
|
||
String(ch && ch.hash || '?').slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
function renderChannelRowMobile(ch) {
|
||
const isEncrypted = ch.encrypted === true;
|
||
const isUserAdded = ch.userAdded === true;
|
||
const encryptedFallback = isEncrypted ? 'Unknown' : '';
|
||
const name = channelDisplayName(ch, encryptedFallback);
|
||
const color = (isEncrypted && !isUserAdded)
|
||
? 'var(--text-muted, #6b7280)'
|
||
: getChannelColor(ch.hash);
|
||
const time = ch.lastActivityMs
|
||
? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000))
|
||
: '';
|
||
let preview = '';
|
||
if (ch.lastSender && ch.lastMessage) {
|
||
preview = ch.lastSender + ': ' + ch.lastMessage;
|
||
} else if (isEncrypted && !isUserAdded) {
|
||
preview = '0x' + formatHashHex(ch.hash);
|
||
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
|
||
preview = ch.messageCount + ' messages';
|
||
}
|
||
const abbr = avatarTextForChannel(ch);
|
||
// abbr may be a Phosphor sprite (HTML) or plain text — detect & emit raw vs escaped.
|
||
const isHtmlAbbr = typeof abbr === 'string' && abbr.startsWith('<svg');
|
||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||
return '<button type="button" class="ch-row' + sel + '" data-hash="' + escapeHtml(ch.hash) +
|
||
'" role="option" aria-selected="' + (selectedHash === ch.hash ? 'true' : 'false') +
|
||
'" aria-label="' + escapeHtml(name) + '">' +
|
||
'<div class="ch-avatar ch-row-avatar" style="background:' + color +
|
||
'" aria-hidden="true">' + (isHtmlAbbr ? abbr : escapeHtml(abbr)) + '</div>' +
|
||
'<div class="ch-row-body">' +
|
||
'<div class="ch-row-line1">' +
|
||
'<span class="ch-row-name">' + escapeHtml(name) + '</span>' +
|
||
(ch.approved ? '<span class="ch-approved-badge" title="Approved by an admin of this instance: decrypted for every visitor">approved</span>' : '') +
|
||
'<span class="ch-row-time">' + escapeHtml(time) + '</span>' +
|
||
'</div>' +
|
||
'<div class="ch-row-preview">' + escapeHtml(preview) + '</div>' +
|
||
'</div>' +
|
||
'</button>';
|
||
}
|
||
|
||
// #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 '' +
|
||
'<div class="ch-section ch-section-catalogue" data-section="catalogue">' +
|
||
'<button type="button" class="ch-section-header ch-section-toggle" id="chCatalogueToggle" aria-expanded="' + (collapsed ? 'false' : 'true') + '" aria-controls="chCatalogueBody">' +
|
||
'<span class="ch-section-caret" aria-hidden="true">' + (collapsed ? '▸' : '▾') + '</span>' +
|
||
' Known channels (catalogue)' + (count ? ' (' + count + ')' : '') +
|
||
'</button>' +
|
||
'<div class="ch-section-body" id="chCatalogueBody"' + (collapsed ? ' hidden' : '') + '>' +
|
||
bodyInner +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
// 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 '<div class="ch-section-empty">Loading catalogue…</div>';
|
||
}
|
||
if (__knownChannelsError) {
|
||
return '<div class="ch-section-empty">Catalogue unavailable</div>';
|
||
}
|
||
if (__knownChannels.length === 0) {
|
||
return '<div class="ch-section-empty">No catalogue entries</div>';
|
||
}
|
||
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 '' +
|
||
'<div class="ch-channel ch-channel-catalogue" data-known-channel="' + safeName + '">' +
|
||
'<div class="ch-channel-info">' +
|
||
'<div class="ch-channel-name">' + safeName + '</div>' +
|
||
'<div class="ch-channel-sub">' + region + (desc ? ' · ' + desc : '') + '</div>' +
|
||
'</div>' +
|
||
'<button type="button" class="ch-known-add-btn" data-action="ch-known-add" data-channel="' + safeName + '" title="Add to my channels">+ Add</button>' +
|
||
'</div>';
|
||
}
|
||
// 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;
|
||
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
|
||
|
||
// #1367: mobile gets a flat chat-app list (no sections, no inline actions).
|
||
if (isMobileChannels()) {
|
||
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
|
||
const sorted = channels.slice().sort(sortByActivity);
|
||
el.innerHTML = sorted.map(renderChannelRowMobile).join('');
|
||
return;
|
||
}
|
||
|
||
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
|
||
const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0);
|
||
|
||
const mine = channels.filter(c => c.userAdded === true).sort(sortByActivity);
|
||
const network = channels.filter(c => c.userAdded !== true && c.encrypted !== true).sort(sortByActivity);
|
||
const encrypted = channels.filter(c => c.userAdded !== true && c.encrypted === true).sort(sortByCount);
|
||
|
||
// Encrypted section collapsed by default; user toggle persisted in localStorage.
|
||
const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
|
||
|
||
const sections = [];
|
||
if (mine.length > 0) {
|
||
sections.push(
|
||
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
|
||
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-monitor"/></svg> (this browser)</span></div>
|
||
${mine.map(renderChannelRow).join('')}
|
||
</div>`
|
||
);
|
||
}
|
||
sections.push(
|
||
`<div class="ch-section ch-section-network" data-section="network">
|
||
<div class="ch-section-header">Network</div>
|
||
${network.length ? network.map(renderChannelRow).join('') : '<div class="ch-section-empty">No public channels reported by the server.</div>'}
|
||
</div>`
|
||
);
|
||
sections.push(
|
||
`<div class="ch-section ch-section-encrypted" data-section="encrypted" data-encrypted-collapsed="${collapsed ? 'true' : 'false'}">
|
||
<button type="button" class="ch-section-header ch-section-toggle" id="chEncryptedToggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="chEncryptedBody">
|
||
<span class="ch-section-caret" aria-hidden="true">${collapsed ? '▸' : '▾'}</span>
|
||
Encrypted (${encrypted.length})
|
||
</button>
|
||
<div class="ch-section-body" id="chEncryptedBody"${collapsed ? ' hidden' : ''}>
|
||
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '<div class="ch-section-empty">No unkeyed encrypted channels seen.</div>'}
|
||
</div>
|
||
</div>`
|
||
);
|
||
|
||
// #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');
|
||
if (toggle) {
|
||
toggle.addEventListener('click', function () {
|
||
const wasCollapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
|
||
const next = wasCollapsed ? 'false' : 'true';
|
||
try { localStorage.setItem('ch-encrypted-collapsed', next); } catch (e) { /* quota */ }
|
||
renderChannelList();
|
||
});
|
||
}
|
||
}
|
||
|
||
async function selectChannel(hash, decryptOpts) {
|
||
const rp = RegionFilter.getRegionParam() || '';
|
||
const request = beginMessageRequest(hash, rp);
|
||
// #1498: clear messages BEFORE flipping selectedHash so any WS-pushed
|
||
// messages from the previously-viewed channel can't survive into the
|
||
// new channel's view via mergeWsAppendedIntoRest(). Messages don't
|
||
// carry channel context, so the merge can't distinguish them itself.
|
||
messages = [];
|
||
selectedHash = hash;
|
||
// Clear unread badge on the channel we're about to view (#1029).
|
||
var __selCh = channels.find(function (c) { return c.hash === hash; });
|
||
if (__selCh && __selCh.unread) { __selCh.unread = 0; }
|
||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||
// #1367: mobile slide-in — flip the layout into detail mode so CSS
|
||
// can swap the visible pane. Desktop is a no-op (rule matches mobile).
|
||
document.querySelector('.ch-layout')?.classList.add('ch-detail-open');
|
||
renderChannelList();
|
||
const ch = channels.find(c => c.hash === hash);
|
||
// #1041: never show raw "psk:<hex>" prefixes in the header — use the
|
||
// user-supplied label or "Private Channel".
|
||
const name = ch ? channelDisplayName(ch) : `Channel ${formatHashHex(hash)}`;
|
||
const header = document.getElementById('chHeader');
|
||
header.querySelector('.ch-header-text').textContent = `${name} — ${ch?.messageCount || 0} messages`;
|
||
|
||
const msgEl = document.getElementById('chMessages');
|
||
|
||
// Shared helper: fetch, decrypt, and render messages for a channel key (M5: cache-first)
|
||
async function decryptAndRender(keyHex, channelHashByte, channelName) {
|
||
msgEl.innerHTML = '<div class="ch-loading">Decrypting messages…</div>';
|
||
var result = await fetchAndDecryptChannel(keyHex, channelHashByte, channelName, {
|
||
onCacheHit: function (cachedMsgs) {
|
||
// M5: Render cached messages immediately while delta fetch runs.
|
||
// #1498 round-1 finding #4: this site is a REST replacement
|
||
// path too — it must merge any WS-pushed messages instead of
|
||
// stomping them, same as the other two sites below.
|
||
messages = mergeWsAppendedIntoRest(messages, cachedMsgs || []);
|
||
if (messages.length > 0) {
|
||
header.querySelector('.ch-header-text').textContent = name + ' — ' + messages.length + ' messages (cached)';
|
||
renderMessages();
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
});
|
||
if (isStaleMessageRequest(request)) return { stale: true };
|
||
if (result.wrongKey) {
|
||
msgEl.innerHTML = '<div class="ch-empty ch-wrong-key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Key does not match — no messages could be decrypted</div>';
|
||
return { wrongKey: true, messageCount: 0 };
|
||
}
|
||
if (result.error) {
|
||
msgEl.innerHTML = '<div class="ch-empty">' + escapeHtml(result.error) + '</div>';
|
||
return { error: result.error, messageCount: 0 };
|
||
}
|
||
// #1498: merge WS-pushed messages that landed during the decrypt fetch.
|
||
messages = mergeWsAppendedIntoRest(messages, result.messages || []);
|
||
if (messages.length === 0) {
|
||
msgEl.innerHTML = '<div class="ch-empty">No encrypted messages found for this channel</div>';
|
||
} else {
|
||
header.querySelector('.ch-header-text').textContent = `${name} — ${messages.length} messages (decrypted)`;
|
||
renderMessages();
|
||
scrollToBottom();
|
||
}
|
||
return { messageCount: messages.length };
|
||
}
|
||
|
||
// Client-side decryption path (#725 M2)
|
||
if (decryptOpts && decryptOpts.userKey) {
|
||
return await decryptAndRender(decryptOpts.userKey, decryptOpts.channelHashByte, decryptOpts.channelName);
|
||
}
|
||
|
||
// Check if this is a user-added channel that needs decryption
|
||
var storedKeys = typeof ChannelDecrypt !== 'undefined' ? ChannelDecrypt.getStoredKeys() : {};
|
||
if (hash.startsWith('user:')) {
|
||
var chName = hash.substring(5);
|
||
if (storedKeys[chName]) {
|
||
var keyHex = storedKeys[chName];
|
||
var keyBytes = ChannelDecrypt.hexToBytes(keyHex);
|
||
var hashByte = await ChannelDecrypt.computeChannelHash(keyBytes);
|
||
await decryptAndRender(keyHex, hashByte, chName);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Also check if an encrypted channel hash matches a stored key
|
||
if (ch && ch.encrypted) {
|
||
for (var kn in storedKeys) {
|
||
var kh = storedKeys[kn];
|
||
var kb = ChannelDecrypt.hexToBytes(kh);
|
||
var hb = await ChannelDecrypt.computeChannelHash(kb);
|
||
if (String(hb) === String(hash) || String(ch.hash) === String(hb)) {
|
||
await decryptAndRender(kh, hb, kn);
|
||
return;
|
||
}
|
||
}
|
||
// #781: No matching key found — show lock message instead of fetching gibberish
|
||
msgEl.innerHTML = '<div class="ch-empty"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> This channel is encrypted and no decryption key is configured</div>';
|
||
return;
|
||
}
|
||
|
||
// #811: Deep link to a `#`-named channel that's not in the loaded list.
|
||
// If a stored key matches, decrypt. Otherwise we must distinguish an
|
||
// encrypted-no-key channel (show lock) from an unencrypted channel that
|
||
// simply isn't in the toggle-off list (#825 — must fall through to REST).
|
||
if (hash.charAt(0) === '#') {
|
||
if (storedKeys[hash]) {
|
||
var keyHex2 = storedKeys[hash];
|
||
var keyBytes2 = ChannelDecrypt.hexToBytes(keyHex2);
|
||
var hashByte2 = await ChannelDecrypt.computeChannelHash(keyBytes2);
|
||
await decryptAndRender(keyHex2, hashByte2, hash);
|
||
return;
|
||
}
|
||
// #825: confirm encrypted-ness via an encrypted-included channel list
|
||
// before assuming a lock state. Conservative on error — fall through.
|
||
// Show a loading affordance so cold deep links don't display stale content
|
||
// for the duration of the metadata RTT (cached 15s thereafter).
|
||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||
try {
|
||
var rpInc = RegionFilter.getRegionParam();
|
||
var paramsInc = ['includeEncrypted=true'];
|
||
if (rpInc) paramsInc.push('region=' + encodeURIComponent(rpInc));
|
||
var allCh = await api('/channels?' + paramsInc.join('&'), { ttl: CLIENT_TTL.channels });
|
||
if (isStaleMessageRequest(request)) return;
|
||
var foundCh = (allCh.channels || []).find(function (c) { return c.hash === hash; });
|
||
if (foundCh && foundCh.encrypted === true) {
|
||
msgEl.innerHTML = '<div class="ch-empty"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> This channel is encrypted and no decryption key is configured</div>';
|
||
return;
|
||
}
|
||
// Unencrypted (or unknown) — fall through to the REST fetch below.
|
||
} catch (e) {
|
||
// ignore — fall through to REST fetch
|
||
}
|
||
}
|
||
|
||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||
|
||
try {
|
||
const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : '';
|
||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages });
|
||
if (isStaleMessageRequest(request)) return;
|
||
// #1498: merge so any WS-pushed messages that arrived while the
|
||
// REST fetch was in flight aren't stomped.
|
||
messages = mergeWsAppendedIntoRest(messages, data.messages || []);
|
||
if (messages.length === 0 && rp) {
|
||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||
} else {
|
||
renderMessages();
|
||
scrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
if (isStaleMessageRequest(request)) return;
|
||
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function refreshMessages(opts) {
|
||
if (!selectedHash) return;
|
||
// Skip refresh for encrypted channels — no messages to fetch
|
||
var selCh = channels.find(function (c) { return c.hash === selectedHash; });
|
||
if (selCh && selCh.encrypted) return;
|
||
opts = opts || {};
|
||
const msgEl = document.getElementById('chMessages');
|
||
if (!msgEl) return;
|
||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||
try {
|
||
const requestHash = selectedHash;
|
||
const rp = RegionFilter.getRegionParam() || '';
|
||
const request = beginMessageRequest(requestHash, rp);
|
||
const regionQs = (rp ? '®ion=' + encodeURIComponent(rp) : '');
|
||
const data = await api(`/channels/${encodeURIComponent(requestHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache });
|
||
if (isStaleMessageRequest(request)) return;
|
||
const newMsgs = data.messages || [];
|
||
if (opts.regionSwitch && rp && newMsgs.length === 0) {
|
||
messages = [];
|
||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||
return;
|
||
}
|
||
// #92: Use message ID/hash for change detection instead of count + timestamp.
|
||
// #1498 round-1 finding #5: REST returns oldest→newest, and the
|
||
// merge appends survivors at the END (newest position), so the
|
||
// last element of `messages` is the newest item — same convention
|
||
// for REST and for the merged array. _getLastId remains correct.
|
||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
|
||
var prevLen = messages.length;
|
||
// #1498: merge WS-pushed messages so a refresh that races a live
|
||
// packet doesn't wipe it.
|
||
messages = mergeWsAppendedIntoRest(messages, newMsgs);
|
||
renderMessages();
|
||
if (wasAtBottom) scrollToBottom();
|
||
else {
|
||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||
var liveEl = document.getElementById('chAriaLive');
|
||
if (liveEl) liveEl.textContent = Math.max(1, newMsgs.length - prevLen) + ' new messages';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function renderMessages() {
|
||
const msgEl = document.getElementById('chMessages');
|
||
if (!msgEl) return;
|
||
if (messages.length === 0) { msgEl.innerHTML = '<div class="ch-empty">No messages in this channel yet</div>'; return; }
|
||
|
||
msgEl.innerHTML = messages.map(msg => {
|
||
const sender = msg.sender || 'Unknown';
|
||
const senderColor = getSenderColor(sender);
|
||
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
|
||
|
||
let rawBody = msg.text || '';
|
||
// Detect a leading @TARGET reply prefix and split it out so we can
|
||
// style it in the sender color (#1367 detail-view spec).
|
||
let replyTarget = '';
|
||
const replyMatch = rawBody.match(/^@([A-Za-z0-9_\-]{1,32})\s+/);
|
||
if (replyMatch) {
|
||
replyTarget = replyMatch[1];
|
||
rawBody = rawBody.slice(replyMatch[0].length);
|
||
}
|
||
let displayText = highlightMentions(rawBody);
|
||
if (replyTarget) {
|
||
displayText = '<span class="ch-reply-target" style="color:' + senderColor + '">@' +
|
||
escapeHtml(replyTarget) + '</span> ' + displayText;
|
||
}
|
||
|
||
const tsDate = msg.timestamp ? new Date(msg.timestamp) : null;
|
||
const time = tsDate ? tsDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||
const date = tsDate ? tsDate.toLocaleDateString() : '';
|
||
|
||
const meta = [];
|
||
meta.push(date + ' ' + time);
|
||
if (msg.repeats > 1) meta.push(`${msg.repeats}× heard`);
|
||
if (msg.observers?.length > 1) meta.push(`${msg.observers.length} observers`);
|
||
if (msg.hops > 0) meta.push(`${msg.hops} hops`);
|
||
if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`);
|
||
// Cast first: 0, missing or non-numeric all mean the packet encodes no size.
|
||
const hs = Number(msg.path_hash_size) || 0;
|
||
if (hs) meta.push(`<span class="ch-msg-hash-size" title="Path hash size the sender used">${hs}-byte${hs !== 1 ? 's' : ''}</span>`);
|
||
const scopeChip = messageScopeChipHtml(msg.scope_name);
|
||
if (scopeChip) meta.push(scopeChip);
|
||
|
||
const safeId = btoa(encodeURIComponent(sender));
|
||
// #1367: emit BOTH the new chat-app class names (.ch-message /
|
||
// .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg*
|
||
// names so existing tests/themes don't regress.
|
||
return `<div class="ch-msg ch-message">
|
||
<div class="ch-avatar ch-tappable" style="background:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${senderLetter}</div>
|
||
<div class="ch-msg-content ch-message-content">
|
||
<div class="ch-msg-sender ch-message-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||
<div class="ch-msg-bubble ch-message-bubble">${displayText}</div>
|
||
<div class="ch-msg-meta ch-message-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
const msgEl = document.getElementById('chMessages');
|
||
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
|
||
}
|
||
|
||
window._channelsSetStateForTest = function (state) {
|
||
if (!state) return;
|
||
if (Array.isArray(state.channels)) channels = state.channels;
|
||
if (Array.isArray(state.messages)) messages = state.messages;
|
||
if (Object.prototype.hasOwnProperty.call(state, 'selectedHash')) selectedHash = state.selectedHash;
|
||
};
|
||
window._channelsSetObserverRegionsForTest = function (byId, byName) {
|
||
observerIataById = byId || {};
|
||
observerIataByName = byName || {};
|
||
};
|
||
window._channelsSelectChannelForTest = selectChannel;
|
||
window._channelsRefreshMessagesForTest = refreshMessages;
|
||
window._channelsMergeWsAppendedIntoRestForTest = mergeWsAppendedIntoRest;
|
||
window._channelsMergeClientChannelStateForTest = mergeClientChannelState;
|
||
window._channelsLoadChannelsForTest = loadChannels;
|
||
window._channelsBeginMessageRequestForTest = beginMessageRequest;
|
||
window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest;
|
||
window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh;
|
||
window._channelsGetStateForTest = function () {
|
||
return { channels: channels, messages: messages, selectedHash: selectedHash };
|
||
};
|
||
window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion;
|
||
window._channelsRenderChannelRowForTest = renderChannelRow;
|
||
window._channelsRenderChannelRowMobileForTest = renderChannelRowMobile;
|
||
window._channelsAttrSelForTest = attrSel;
|
||
registerPage('channels', { init, destroy });
|
||
})();
|