web: render chat incrementally to reduce page load time (#813)

This commit is contained in:
l5y
2026-06-23 12:18:00 +02:00
committed by GitHub
parent 843dc85776
commit 4107527eca
9 changed files with 1059 additions and 110 deletions
+61
View File
@@ -924,3 +924,64 @@ during shutdown rather than executed. `Thread#kill` runs Ruby `ensure`
blocks, so SQLite handles opened inside crawl/announce tasks (guarded by
`ensure db&.close`) still close cleanly. Net effect: CTRL+C on a running
instance reaps `potato-mesh-fed-N` workers in seconds, not minutes.
---
## Bugfix: Chat-log incremental render & per-node hydration storm
The dashboard rebuilt the **entire** chat log from HTML strings on every refresh
tick (`element.innerHTML = …` per entry — ~77% of a refresh's main-thread time in
the deployed profile) and the message-node hydrator backfilled each unknown
sender with a separate `GET /api/nodes/:id` (hundreds of round trips, many `404`
for RF-only nodes, on every cold load). The render now memoises each entry's DOM
node and reuses it while its rendered HTML is unchanged, so an idle tick parses
nothing; the hydrator resolves senders from the already-loaded bulk node map and
renders an `!id` placeholder on a miss, issuing zero per-node requests.
Frontend-only (vanilla JS, existing stack); no API/DB/ingestor change, so the
apex (I) and privacy (II) invariants are untouched.
### CR-A1 — Idle re-render materialises no entries; content preserved; no per-node fetch
```bash
( cd web && node --test public/assets/js/app/__tests__/main-chat-render-incremental.test.js )
```
**Expected:** pass. After the initial render fills the entry cache, calling
`rerenderChatLog` again with unchanged state materialises **0** entries
(`getChatRenderStats().materialized` stays `0` — the brief's "idle page renders
~0 entries per cycle" gate) and the rendered chat still contains every message.
A refresh whose sender is absent from the bulk `/api/nodes` payload issues **no**
`GET /api/nodes/!…` request (the hydration storm is gone).
### CR-A2 — Entry-node cache memoises, namespaces, prunes, and releases tabs
```bash
( cd web && node --test public/assets/js/app/main/__tests__/chat-entry-cache.test.js \
public/assets/js/app/main/__tests__/chat-entry-keys.test.js )
```
**Expected:** pass. `createChatEntryCache` reuses a node while its HTML is
unchanged, rebuilds it when the HTML changes (e.g. a renamed sender), keeps a
distinct node per tab namespace for the same key (a message renders in both the
Log and its channel tab), prunes entries that aged out of a tab's window, and
releases caches for tabs no longer present. The stable per-entry keys cover
messages (by `id`, with a timestamp/sender/text fallback) and every log-entry
type (including encrypted).
### CR-A3 — Hydration is map-only by default; per-node fetch is opt-in
```bash
( cd web && node --test public/assets/js/app/__tests__/message-node-hydrator.test.js )
```
**Expected:** pass. With no `fetchNodeById` injected (the dashboard default) the
hydrator binds senders from `nodesById` and emits a protocol-stamped `!id`
placeholder on a miss, performing **zero** network lookups. `applyNodeFallback`
remains mandatory; `fetchNodeById` is now optional, and supplying it re-enables
the bounded per-node backfill (worker-pool + negative cache) for a deliberate,
opt-in batched refresh path.
### CR-R1 — Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )
```
**Expected:** all green. The public `createMessageChatEntry` /
`createAnnouncementEntry` test surface is unchanged (now thin wrappers over the
pure parts builders), so **A4c** (chat name resolution honours protocol) and the
chat-entry / progressive-load suites (**PL-A1**, **PL-A2**) stay green. No
POST/GET contract change, so the Ruby and Python suites are unaffected.
@@ -0,0 +1,176 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Regression guards for the chat-log render + node-hydration fixes.
*
* FIX 1 — incremental chat render: an idle re-render (no new/changed
* messages) must not rebuild every entry from an HTML string.
* FIX 2 — node hydration: the chat hydrator must resolve senders from the
* already-loaded bulk node map and never issue per-node
* ``GET /api/nodes/:id`` requests.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
/** Config that disables auto-refresh so the test controls render timing. */
const CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 0,
refreshIntervalSeconds: 0,
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
tileFilters: { light: '', dark: '' },
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
/**
* Build a fetch double that records every call and answers from a prefix map.
*
* @param {Object<string, *>} responsesByEndpoint URL-substring → JSON body (or
* a thunk returning one).
* @returns {{ fetch: Function, calls: Array<{ url: string, options: Object }> }}
*/
function buildStubFetch(responsesByEndpoint = {}) {
const calls = [];
function stubFetch(url, options = {}) {
calls.push({ url, options });
for (const [prefix, body] of Object.entries(responsesByEndpoint)) {
if (url.includes(prefix)) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(typeof body === 'function' ? body() : body),
});
}
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) });
}
return { fetch: stubFetch, calls };
}
/**
* Spin up the dashboard with a registered ``#chat`` container and a stubbed
* fetch, let the initial refresh settle, then run the test body.
*
* @param {Object<string, *>} stubResponses Response map for the stub fetch.
* @param {function({ testUtils: Object, calls: Array, env: Object }): Promise<void>} fn Test body.
* @returns {Promise<void>}
*/
async function withChatApp(stubResponses, fn) {
const env = createDomEnvironment({ includeBody: true });
env.registerElement('chat', env.createElement('div', 'chat'));
const originalFetch = globalThis.fetch;
const { fetch: stubFetch, calls } = buildStubFetch(stubResponses);
globalThis.fetch = stubFetch;
try {
const { _testUtils } = initializeApp(CONFIG);
// The initial refresh() is async (fetch + hydrate + render); let it settle.
await new Promise(r => setTimeout(r, 60));
await fn({ testUtils: _testUtils, calls, env });
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
}
const NOW = Math.floor(Date.now() / 1000);
/**
* Build N plaintext messages on the primary channel from a known sender.
*
* @param {number} count Number of messages.
* @returns {Array<Object>} Message payloads.
*/
function makeMessages(count) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
channel: 0,
from_id: '!00000001',
to_id: '^all',
text: `hello ${i + 1}`,
rx_time: NOW - i,
protocol: 'meshtastic',
}));
}
const KNOWN_NODES = [
{
node_id: '!00000001',
short_name: 'S1',
long_name: 'Sender One',
role: 'CLIENT',
last_heard: NOW,
// ``first_heard`` within the window produces a "New node" announcement in the
// Log tab, exercising the announcement (parts) render path too.
first_heard: NOW,
protocol: 'meshtastic',
},
];
test('FIX 1: an idle chat re-render materialises no entries', async () => {
await withChatApp({ '/api/messages': makeMessages(5), '/api/nodes': KNOWN_NODES }, async ({ testUtils }) => {
const initial = testUtils.getChatRenderStats().materialized;
assert.ok(initial > 0, `initial render should materialise entries (saw ${initial})`);
// Re-render with the exact same state — nothing new arrived.
testUtils.resetChatRenderStats();
testUtils.rerenderChatLog();
const afterIdle = testUtils.getChatRenderStats().materialized;
assert.equal(
afterIdle,
0,
`an idle re-render must reuse already-built entries, but materialised ${afterIdle}`,
);
});
});
test('FIX 1: an idle re-render preserves rendered chat content', async () => {
await withChatApp({ '/api/messages': makeMessages(3), '/api/nodes': KNOWN_NODES }, async ({ testUtils, env }) => {
testUtils.rerenderChatLog();
const chat = env.document.getElementById('chat');
const html = chat ? chat.innerHTML : '';
for (const text of ['hello 1', 'hello 2', 'hello 3']) {
assert.ok(html.includes(text), `chat should still contain "${text}" after re-render`);
}
});
});
test('FIX 2: chat hydration issues no per-node /api/nodes/:id requests', async () => {
// The sender is absent from the bulk /api/nodes payload (an RF-only node).
const messages = [
{ id: 1, channel: 0, from_id: '!ffff0000', to_id: '^all', text: 'rf only', rx_time: NOW, protocol: 'meshtastic' },
];
await withChatApp({ '/api/messages': messages, '/api/nodes': [] }, async ({ calls }) => {
const perNode = calls
.map(c => c.url)
.filter(url => /\/api\/nodes\/(!|%21)/.test(url));
assert.deepEqual(
perNode,
[],
`hydration must not fetch nodes one at a time, but requested: ${perNode.join(', ')}`,
);
});
});
@@ -293,15 +293,65 @@ test('hydrate falls back to the default cap for invalid concurrency values', asy
}
});
test('factory rejects missing fetch and fallback dependencies', () => {
assert.throws(
() => createMessageNodeHydrator({ applyNodeFallback: () => {} }),
TypeError,
);
test('factory requires applyNodeFallback but allows an absent fetcher', () => {
// applyNodeFallback is mandatory.
assert.throws(
() => createMessageNodeHydrator({ fetchNodeById: async () => null }),
TypeError,
);
// fetchNodeById is optional — omitting it selects map-only mode rather than
// throwing (issue: node hydration).
assert.doesNotThrow(() => createMessageNodeHydrator({ applyNodeFallback: () => {} }));
});
test('map-only hydrate binds cached nodes and performs no lookups', async () => {
const node = { node_id: '!abc', short_name: 'Node' };
const nodesById = new Map([[node.node_id, node]]);
// No fetchNodeById supplied → map-only mode.
const hydrator = createMessageNodeHydrator({ applyNodeFallback: () => {} });
const result = await hydrator.hydrate([{ node_id: '!abc', text: 'hi' }], nodesById);
assert.strictEqual(result[0].node, node);
});
test('map-only hydrate renders an id placeholder for misses, no network', async () => {
let fallbackCalls = 0;
const hydrator = createMessageNodeHydrator({
applyNodeFallback: node => {
fallbackCalls += 1;
if (!node.short_name) {
node.short_name = 'Fallback';
}
},
});
const nodesById = new Map();
const result = await hydrator.hydrate(
[{ from_id: '!missing', text: 'hi', protocol: 'meshcore' }],
nodesById,
);
assert.equal(fallbackCalls, 1);
assert.equal(result[0].node.node_id, '!missing');
// Protocol is stamped onto the placeholder so the badge palette matches.
assert.equal(result[0].node.protocol, 'meshcore');
// The placeholder is not written back into the bulk map.
assert.equal(nodesById.has('!missing'), false);
});
test('map-only placeholder omits protocol when the message has none', async () => {
let observedProtocol = 'sentinel';
const hydrator = createMessageNodeHydrator({
applyNodeFallback: node => {
observedProtocol = node.protocol;
},
});
const result = await hydrator.hydrate([{ from_id: '!unknown', text: 'hi' }], new Map());
assert.equal(observedProtocol, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(result[0].node, 'protocol'), false);
});
test('hydrate skips non-object entries and senderless messages', async () => {
+210 -80
View File
@@ -86,6 +86,8 @@ import { initializeMobileMenu } from './mobile-menu.js';
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
import { createChatEntryCache } from './main/chat-entry-cache.js';
import { chatMessageEntryKey, chatLogEntryKey } from './main/chat-entry-keys.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
import { buildMessageIndex } from './message-replies.js';
@@ -161,7 +163,6 @@ import {
} from './main/constants.js';
import {
fetchNeighbors,
fetchNodeById,
fetchNodes,
fetchPositions,
fetchTelemetry,
@@ -301,8 +302,13 @@ export function initializeApp(config) {
let nodesById = new Map();
let messagesById = new Map();
let nodesByNum = new Map();
// No ``fetchNodeById`` is supplied, so the hydrator resolves senders purely
// from the already-loaded bulk node map (``nodesById``) and renders an ``!id``
// placeholder on a miss — it never issues per-node ``GET /api/nodes/:id``
// requests, which used to storm the server with hundreds of round trips (many
// 404s for RF-only nodes) on every cold load (issue: node hydration). A
// deliberate, batched refresh path can opt back in by injecting a fetcher.
const messageNodeHydrator = createMessageNodeHydrator({
fetchNodeById,
applyNodeFallback: applyNodeNameFallback,
logger: console,
});
@@ -326,6 +332,13 @@ export function initializeApp(config) {
// values without re-declaring them here.
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
// Memoising cache of chat-log entry DOM nodes. Incremental rendering reuses
// already-built entries across refresh ticks so only new/changed entries are
// parsed from HTML, keeping idle re-renders free of per-entry work (issue:
// chat-log render). Exposed via ``_testUtils.getChatRenderStats`` so unit
// tests and the manual verification hook can confirm idle ticks materialise
// no entries.
const chatEntryCache = createChatEntryCache({ documentRef: document });
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
@@ -2212,12 +2225,13 @@ export function initializeApp(config) {
}
/**
* Build a chat log entry describing a node join event.
* Build the parts (class name + HTML) for a node-join chat entry.
*
* @param {Object} n Node payload.
* @returns {HTMLElement} Chat log element.
* @param {Object} node Node payload.
* @param {?number} [timestampOverride=null] Optional timestamp override.
* @returns {{ className: string, html: string }|null} Entry parts or null.
*/
function createNodeChatEntry(node, timestampOverride = null) {
function buildNodeChatEntryParts(node, timestampOverride = null) {
if (!node || typeof node !== 'object') return null;
const nodeIdRaw = pickFirstProperty([node], ['node_id', 'nodeId']);
const fallbackId = nodeIdRaw || 'Unknown node';
@@ -2232,7 +2246,7 @@ export function initializeApp(config) {
const tsSeconds = timestampOverride != null
? timestampOverride
: resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: tsSeconds,
shortName: shortNameDisplay,
longName: longNameDisplay,
@@ -2306,9 +2320,16 @@ export function initializeApp(config) {
return `<span class="chat-entry-copy">${escapeHtml(safeBase)}${safeSuffix}</span>`;
}
function createNodeInfoChatEntry(entry, context) {
/**
* Build the parts for a "node info updated" chat entry.
*
* @param {Object} entry Structured chat-log entry.
* @param {Object} context Display context from {@link buildDisplayContext}.
* @returns {{ className: string, html: string }} Entry parts.
*/
function buildNodeInfoChatEntryParts(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
@@ -2320,10 +2341,17 @@ export function initializeApp(config) {
});
}
function createTelemetryChatEntry(entry, context) {
/**
* Build the parts for a telemetry-broadcast chat entry.
*
* @param {Object} entry Structured chat-log entry.
* @param {Object} context Display context from {@link buildDisplayContext}.
* @returns {{ className: string, html: string }} Entry parts.
*/
function buildTelemetryChatEntryParts(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatTelemetryHighlights(entry?.telemetry));
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
@@ -2335,10 +2363,17 @@ export function initializeApp(config) {
});
}
function createPositionChatEntry(entry, context) {
/**
* Build the parts for a position-broadcast chat entry.
*
* @param {Object} entry Structured chat-log entry.
* @param {Object} context Display context from {@link buildDisplayContext}.
* @returns {{ className: string, html: string }} Entry parts.
*/
function buildPositionChatEntryParts(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatPositionHighlights(entry?.position));
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
@@ -2350,7 +2385,14 @@ export function initializeApp(config) {
});
}
function createNeighborChatEntry(entry, context) {
/**
* Build the parts for a neighbour-broadcast chat entry.
*
* @param {Object} entry Structured chat-log entry.
* @param {Object} context Display context from {@link buildDisplayContext}.
* @returns {{ className: string, html: string }} Entry parts.
*/
function buildNeighborChatEntryParts(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const neighborId = entry?.neighborId ?? pickFirstProperty([entry?.neighbor], ['neighbor_id', 'neighborId']);
let neighborLabel = null;
@@ -2364,7 +2406,7 @@ export function initializeApp(config) {
}
}
const detail = neighborLabel ? `: ${escapeHtml(String(neighborLabel))}` : '';
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
@@ -2376,32 +2418,43 @@ export function initializeApp(config) {
});
}
function createChatLogEntry(entry) {
/**
* Compute the class name and HTML for a mixed-feed (Log tab) chat entry,
* dispatching on the entry type, without touching the DOM. Returns ``null``
* for entries that should not render. Used by the memoising render path.
*
* @param {Object} entry Structured chat-log entry.
* @returns {{ className: string, html: string }|null} Entry parts or null.
*/
function buildChatLogEntryParts(entry) {
if (!entry || typeof entry !== 'object') return null;
if (entry.type === CHAT_LOG_ENTRY_TYPES.NODE_NEW) {
return createNodeChatEntry(entry.node ?? resolveNodeForLogEntry(entry) ?? null, entry?.ts ?? null);
return buildNodeChatEntryParts(entry.node ?? resolveNodeForLogEntry(entry) ?? null, entry?.ts ?? null);
}
const context = buildDisplayContext(entry);
switch (entry.type) {
case CHAT_LOG_ENTRY_TYPES.NODE_INFO:
return createNodeInfoChatEntry(entry, context);
return buildNodeInfoChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.TELEMETRY:
return createTelemetryChatEntry(entry, context);
return buildTelemetryChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.POSITION:
return createPositionChatEntry(entry, context);
return buildPositionChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
return createNeighborChatEntry(entry, context);
return buildNeighborChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.TRACE:
return createTraceChatEntry(entry, context);
return buildTraceChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
return entry?.message ? createMessageChatEntry(entry.message) : null;
return entry?.message ? buildMessageChatEntryParts(entry.message) : null;
default:
return null;
}
}
/**
* Create a consistently formatted chat log entry for node-centric events.
* Compute the class name and HTML string for a node-centric announcement
* entry, without touching the DOM. The render path consumes this pure form so
* the HTML parse can be memoised per entry (issue: chat-log render); the
* {@link createAnnouncementEntry} wrapper materialises it into a node.
*
* @param {{
* timestampSeconds: ?number,
@@ -2413,9 +2466,9 @@ export function initializeApp(config) {
* messageHtml: string,
* protocol: ?string
* }} params Rendering parameters.
* @returns {HTMLElement} Chat log element.
* @returns {{ className: string, html: string }} Entry class name and HTML.
*/
function createAnnouncementEntry({
function buildAnnouncementParts({
timestampSeconds,
shortName,
longName,
@@ -2425,7 +2478,6 @@ export function initializeApp(config) {
messageHtml,
protocol: protocolHint = null
}) {
const div = document.createElement('div');
const tsDate = timestampSeconds != null ? new Date(timestampSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const metadata = extractChatMessageMetadata(metadataSource || nodeData || {});
@@ -2439,9 +2491,22 @@ export function initializeApp(config) {
const announcementProtocol =
protocolHint ?? pickFirstProperty([nodeData, metadataSource], ['protocol']);
const announcementIconPrefix = protocolIconPrefixHtml(announcementProtocol);
div.className = 'chat-entry-node';
div.innerHTML = `${prefix}${presetTag} ${announcementIconPrefix}${shortHtml} ${messageHtml}`;
return div;
return {
className: 'chat-entry-node',
html: `${prefix}${presetTag} ${announcementIconPrefix}${shortHtml} ${messageHtml}`
};
}
/**
* Materialise a node-centric announcement entry as a DOM element. Thin wrapper
* over {@link buildAnnouncementParts} retained for the test-utility surface;
* the render path uses the parts form directly so it can memoise the parse.
*
* @param {Object} params Rendering parameters (see {@link buildAnnouncementParts}).
* @returns {HTMLElement} Chat log element.
*/
function createAnnouncementEntry(params) {
return materializeEntryNode(buildAnnouncementParts(params));
}
/**
@@ -2466,7 +2531,15 @@ export function initializeApp(config) {
return labels;
}
function createTraceChatEntry(entry, context) {
/**
* Build the parts for a traceroute chat entry, or null when the trace path is
* too short to render.
*
* @param {Object} entry Structured chat-log entry carrying ``tracePath``.
* @param {Object} context Display context from {@link buildDisplayContext}.
* @returns {{ className: string, html: string }|null} Entry parts or null.
*/
function buildTraceChatEntryParts(entry, context) {
if (!entry || !Array.isArray(entry.tracePath) || entry.tracePath.length < 2) {
return null;
}
@@ -2475,7 +2548,7 @@ export function initializeApp(config) {
const labels = formatTracePathLabels(entry.tracePath);
const labelText = labels.length ? labels.join(', ') : 'Traceroute';
const labelSuffix = `: ${escapeHtml(labelText)}`;
return createAnnouncementEntry({
return buildAnnouncementParts({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: context.longName || context.nodeId || labels[0] || 'Traceroute',
@@ -2706,12 +2779,15 @@ export function initializeApp(config) {
}
/**
* Build a chat log entry for a text message.
* Compute the class name and HTML for a text-message chat entry, without
* touching the DOM. Returns ``null`` for encrypted placeholder blobs that
* should not render. The render path consumes this pure form so the HTML
* parse can be memoised per message (issue: chat-log render).
*
* @param {Object} m Message payload.
* @returns {HTMLElement} Chat log element.
* @returns {{ className: string, html: string }|null} Entry parts or null.
*/
function createMessageChatEntry(m) {
function buildMessageChatEntryParts(m) {
let plainText = '';
if (m?.text != null) {
plainText = String(m.text).trim();
@@ -2720,7 +2796,6 @@ export function initializeApp(config) {
return null;
}
const div = document.createElement('div');
const tsSeconds = resolveTimestampSeconds(
m.rx_time ?? m.rxTime,
m.rx_iso ?? m.rxIso
@@ -2766,9 +2841,23 @@ export function initializeApp(config) {
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
div.className = 'chat-entry-msg';
div.innerHTML = `${prefix}${presetTag} ${nodeProtocolPrefix}${short} ${text}`;
return div;
return {
className: 'chat-entry-msg',
html: `${prefix}${presetTag} ${nodeProtocolPrefix}${short} ${text}`
};
}
/**
* Materialise a text-message chat entry as a DOM element. Thin wrapper over
* {@link buildMessageChatEntryParts} retained for the test-utility surface;
* the render path uses the parts form directly so it can memoise the parse.
*
* @param {Object} m Message payload.
* @returns {HTMLElement|null} Chat log element, or null for hidden blobs.
*/
function createMessageChatEntry(m) {
const parts = buildMessageChatEntryParts(m);
return parts ? materializeEntryNode(parts) : null;
}
/**
@@ -2908,36 +2997,46 @@ export function initializeApp(config) {
);
const logContent = buildChatFragment({
namespace: 'log',
entries: filteredLogEntries,
renderEntry: createChatLogEntry,
renderParts: buildChatLogEntryParts,
keyOf: chatLogEntryKey,
emptyLabel: 'No recent mesh activity.'
});
const channelTabs = filteredChannels.map(channel => ({
id: channel.id || `channel-${channel.index}`,
label: `${channel.label} (${channel.messageCount})`,
iconSrc: isMeshtasticProtocol(channel.protocol)
? MESHTASTIC_ICON_SRC
: isMeshcoreProtocol(channel.protocol)
? MESHCORE_ICON_SRC
: null,
// Channel tabs are the chat proper: render the entire window (issue #796)
// rather than only the newest CHAT_LIMIT. The entry set is already bounded
// by the seven-day window, so there is no count cap to apply here.
content: buildChatFragment({
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderEntry: entry => createMessageChatEntry(entry.item),
emptyLabel: 'No messages on this channel.',
limit: Infinity
}),
index: channel.index,
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
}));
const channelTabs = filteredChannels.map(channel => {
const tabId = channel.id || `channel-${channel.index}`;
return {
id: tabId,
label: `${channel.label} (${channel.messageCount})`,
iconSrc: isMeshtasticProtocol(channel.protocol)
? MESHTASTIC_ICON_SRC
: isMeshcoreProtocol(channel.protocol)
? MESHCORE_ICON_SRC
: null,
// Channel tabs are the chat proper: render the entire window (issue #796)
// rather than only the newest CHAT_LIMIT. The entry set is already bounded
// by the seven-day window, so there is no count cap to apply here.
content: buildChatFragment({
namespace: tabId,
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderParts: entry => buildMessageChatEntryParts(entry.item),
keyOf: entry => chatMessageEntryKey(entry.item),
emptyLabel: 'No messages on this channel.',
limit: Infinity
}),
index: channel.index,
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
};
});
const tabs = [
{ id: 'log', label: 'Log', content: logContent },
...channelTabs
];
// Release entry-node caches for tabs no longer present (e.g. a channel that
// dropped out of the window) so cached DOM nodes are not retained forever.
chatEntryCache.retainNamespaces(new Set(tabs.map(tab => tab.id)));
const previousActive = chatEl.dataset?.activeTab || null;
const defaultActive =
@@ -2956,30 +3055,44 @@ export function initializeApp(config) {
}
/**
* Construct a document fragment for chat entries, inserting date dividers
* and optional empty-state labels.
* Build a div element from precomputed entry parts (class name + HTML). This
* is the single place a chat entry is parsed from an HTML string; both the
* node-returning ``createEntry`` test wrappers and {@link chatEntryCache}
* funnel through it.
*
* @param {{ className: string, html: string }} parts Entry class name and HTML.
* @returns {HTMLElement} Entry element.
*/
function materializeEntryNode({ className, html }) {
const div = document.createElement('div');
div.className = className;
div.innerHTML = html;
return div;
}
/**
* Construct a document fragment for chat entries, inserting date dividers and
* an optional empty-state label. Entry nodes are sourced from
* {@link chatEntryCache}, so an entry whose rendered HTML is unchanged since
* the previous refresh is reused rather than re-parsed (issue: chat-log
* render). Entries that aged out of this tab's window are pruned from the
* cache afterwards.
*
* @param {{
* entries: Array<{ ts: number, item: Object }>,
* renderEntry: Function,
* namespace: string,
* entries: Array<{ ts: number, item?: Object }>,
* renderParts: Function,
* keyOf: Function,
* emptyLabel?: string,
* limit?: number
* }} params Fragment construction parameters. ``limit`` caps how many of the
* newest entries are rendered; pass ``Infinity`` to render them all (the Log
* firehose defaults to {@link CHAT_LIMIT}, chat channel tabs opt out).
* }} params Fragment construction parameters. ``namespace`` scopes the entry
* cache to a single tab; ``limit`` caps how many of the newest entries are
* rendered (pass ``Infinity`` to render them all the Log firehose defaults
* to {@link CHAT_LIMIT}, chat channel tabs opt out).
* @returns {DocumentFragment} Populated fragment.
*/
function buildChatFragment({ entries = [], renderEntry, emptyLabel, limit = CHAT_LIMIT }) {
function buildChatFragment({ namespace, entries = [], renderParts, keyOf, emptyLabel, limit = CHAT_LIMIT }) {
const fragment = document.createDocumentFragment();
if (!entries || entries.length === 0) {
if (emptyLabel) {
const empty = document.createElement('p');
empty.className = 'chat-empty';
empty.textContent = emptyLabel;
fragment.appendChild(empty);
}
return fragment;
}
const getDivider = createDateDividerFactory();
const limitedEntries = Number.isFinite(limit)
? entries.slice(Math.max(entries.length - limit, 0))
@@ -2989,18 +3102,21 @@ export function initializeApp(config) {
if (!entry || typeof entry.ts !== 'number') {
continue;
}
if (typeof renderEntry !== 'function') {
if (typeof renderParts !== 'function' || typeof keyOf !== 'function') {
continue;
}
const node = renderEntry(entry);
if (!node) {
const parts = renderParts(entry);
if (!parts) {
continue;
}
const node = chatEntryCache.materialize(namespace, keyOf(entry), parts.className, parts.html);
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
fragment.appendChild(node);
renderedEntries += 1;
}
// Drop cached nodes for entries no longer present in this tab's window.
chatEntryCache.prune(namespace);
if (renderedEntries === 0 && emptyLabel) {
const empty = document.createElement('p');
empty.className = 'chat-empty';
@@ -4339,6 +4455,20 @@ export function initializeApp(config) {
},
/** Trigger a manual refresh cycle (test use only). */
refresh,
/** Re-render the chat log from current state (test/verification hook). */
rerenderChatLog,
/**
* Chat-render instrumentation: how many entries have been materialised
* into DOM nodes so far. Idle re-renders should not increase this once
* incremental rendering is in place (issue: chat-log render).
*
* @returns {{ materialized: number }} Cumulative materialisation count.
*/
getChatRenderStats: () => chatEntryCache.stats(),
/** Reset the chat-render materialisation counter (test use only). */
resetChatRenderStats: () => {
chatEntryCache.resetStats();
},
/** Number of plaintext chat messages currently loaded (test use only). */
getLoadedMessageCount: () => allMessages.length,
/** Project an original lat/lon + pixel offset into a display LatLng. */
@@ -0,0 +1,154 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createChatEntryCache } from '../chat-entry-cache.js';
/**
* Build a minimal document double whose ``createElement`` returns a settable
* node and counts how many nodes were created (i.e. how many parses occurred).
*
* @returns {{ createElement: Function, created: () => number }} Fake document.
*/
function makeDoc() {
let created = 0;
return {
createElement() {
created += 1;
return { className: '', innerHTML: '' };
},
created: () => created,
};
}
test('constructor rejects a document without createElement', () => {
assert.throws(() => createChatEntryCache({ documentRef: {} }), TypeError);
assert.throws(() => createChatEntryCache({ documentRef: null }), TypeError);
});
test('materialize builds a node on first sight and applies class + html', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
const node = cache.materialize('log', 'k1', 'chat-entry-msg', '<b>hi</b>');
assert.equal(node.className, 'chat-entry-msg');
assert.equal(node.innerHTML, '<b>hi</b>');
assert.equal(doc.created(), 1);
assert.deepEqual(cache.stats(), { materialized: 1 });
});
test('materialize reuses the cached node when the html is unchanged', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
const first = cache.materialize('log', 'k1', 'c', '<b>hi</b>');
const second = cache.materialize('log', 'k1', 'c', '<b>hi</b>');
assert.strictEqual(second, first, 'unchanged entry should reuse the same node');
assert.equal(doc.created(), 1, 'no second parse for an unchanged entry');
assert.equal(cache.stats().materialized, 1);
});
test('materialize rebuilds when the html changes (e.g. a renamed sender)', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
const first = cache.materialize('log', 'k1', 'c', 'old');
const second = cache.materialize('log', 'k1', 'c', 'new');
assert.notStrictEqual(second, first);
assert.equal(second.innerHTML, 'new');
assert.equal(doc.created(), 2);
assert.equal(cache.stats().materialized, 2);
});
test('namespaces keep independent nodes for the same key', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
const logNode = cache.materialize('log', 'msg:1', 'c', 'h');
const chanNode = cache.materialize('channel-0', 'msg:1', 'c', 'h');
assert.notStrictEqual(logNode, chanNode, 'same message in two tabs gets two nodes');
assert.equal(cache.size('log'), 1);
assert.equal(cache.size('channel-0'), 1);
assert.equal(cache.size(), 2);
});
test('prune drops entries not seen during the cycle and keeps seen ones', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
// Cycle 1: both a and b are present; prune closes the cycle.
cache.materialize('log', 'a', 'c', 'A');
cache.materialize('log', 'b', 'c', 'B');
cache.prune('log');
assert.equal(cache.size('log'), 2);
// Cycle 2: only a is present.
cache.materialize('log', 'a', 'c', 'A');
cache.prune('log');
assert.equal(cache.size('log'), 1, 'aged-out entry b should be pruned');
// 'a' is reused, not rebuilt (still one materialisation total for it).
assert.equal(cache.stats().materialized, 2);
});
test('prune on an unknown namespace is a no-op', () => {
const cache = createChatEntryCache({ documentRef: makeDoc() });
assert.doesNotThrow(() => cache.prune('never-seen'));
});
test('a second prune with no intervening materialize clears the namespace', () => {
const cache = createChatEntryCache({ documentRef: makeDoc() });
cache.materialize('log', 'a', 'c', 'A');
cache.prune('log'); // drops the per-cycle seen set, keeps 'a'
assert.equal(cache.size('log'), 1);
cache.prune('log'); // no entries seen this cycle → empties the tab cache
assert.equal(cache.size('log'), 0);
});
test('retainNamespaces forgets caches and seen sets outside the active set', () => {
const cache = createChatEntryCache({ documentRef: makeDoc() });
cache.materialize('log', 'a', 'c', 'A');
cache.materialize('channel-0', 'b', 'c', 'B');
cache.materialize('channel-9', 'c', 'c', 'C');
assert.equal(cache.size(), 3);
cache.retainNamespaces(new Set(['log', 'channel-0']));
assert.equal(cache.size('channel-9'), 0, 'removed channel cache released');
assert.equal(cache.size('log'), 1);
assert.equal(cache.size('channel-0'), 1);
});
test('retainNamespaces accepts a plain iterable, not only a Set', () => {
const cache = createChatEntryCache({ documentRef: makeDoc() });
cache.materialize('log', 'a', 'c', 'A');
cache.materialize('channel-0', 'b', 'c', 'B');
cache.retainNamespaces(['log']);
assert.equal(cache.size('log'), 1);
assert.equal(cache.size('channel-0'), 0);
});
test('resetStats clears the counter without evicting cached nodes', () => {
const doc = makeDoc();
const cache = createChatEntryCache({ documentRef: doc });
const node = cache.materialize('log', 'a', 'c', 'A');
cache.resetStats();
assert.equal(cache.stats().materialized, 0);
// Still cached: re-materialising the same html returns the same node.
assert.strictEqual(cache.materialize('log', 'a', 'c', 'A'), node);
assert.equal(cache.stats().materialized, 0);
});
test('size of an unknown namespace is zero', () => {
const cache = createChatEntryCache({ documentRef: makeDoc() });
assert.equal(cache.size('nope'), 0);
});
@@ -0,0 +1,83 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { CHAT_LOG_ENTRY_TYPES } from '../../chat-log-tabs.js';
import { chatMessageEntryKey, chatLogEntryKey } from '../chat-entry-keys.js';
test('chatMessageEntryKey prefers the message id', () => {
assert.equal(chatMessageEntryKey({ id: 42 }), 'msg:42');
assert.equal(chatMessageEntryKey({ message_id: 'a1' }), 'msg:a1');
assert.equal(chatMessageEntryKey({ messageId: 'b2' }), 'msg:b2');
});
test('chatMessageEntryKey falls back to a composite when id is absent or blank', () => {
assert.equal(
chatMessageEntryKey({ rx_time: 100, from_id: '!x', text: 'hi' }),
'msg:100:!x:hi',
);
// An empty-string id is treated as absent.
assert.equal(
chatMessageEntryKey({ id: '', rxTime: 7, fromId: '!y', text: 'yo' }),
'msg:7:!y:yo',
);
// Missing composite fields collapse to empty segments.
assert.equal(chatMessageEntryKey({}), 'msg:::');
});
test('chatMessageEntryKey tolerates non-object input', () => {
assert.equal(chatMessageEntryKey(null), 'msg:');
assert.equal(chatMessageEntryKey('nope'), 'msg:');
});
test('chatLogEntryKey reuses the message key for encrypted entries', () => {
const entry = { type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message: { id: 9 } };
assert.equal(chatLogEntryKey(entry), 'enc:msg:9');
});
test('chatLogEntryKey builds a type/node/ts/neighbor key for announcements', () => {
assert.equal(
chatLogEntryKey({ type: CHAT_LOG_ENTRY_TYPES.NODE_NEW, nodeId: '!abc', ts: 123 }),
'log:node-new:!abc:123:',
);
// Neighbor id read directly off the entry.
assert.equal(
chatLogEntryKey({ type: 'neighbor', nodeId: '!a', ts: 1, neighborId: '!b' }),
'log:neighbor:!a:1:!b',
);
// Neighbor id read from the nested neighbor payload.
assert.equal(
chatLogEntryKey({ type: 'neighbor', nodeId: '!a', ts: 1, neighbor: { neighbor_id: '!c' } }),
'log:neighbor:!a:1:!c',
);
});
test('chatLogEntryKey defaults missing fields to empty segments', () => {
assert.equal(chatLogEntryKey({}), 'log::::');
});
test('chatLogEntryKey tolerates non-object input', () => {
assert.equal(chatLogEntryKey(null), 'log:');
assert.equal(chatLogEntryKey(7), 'log:');
});
test('chatLogEntryKey without a message on an encrypted entry uses the generic key', () => {
// No ``message`` → falls through to the generic announcement key path.
const entry = { type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, nodeId: '!z', ts: 5 };
assert.equal(chatLogEntryKey(entry), `log:${CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED}:!z:5:`);
});
@@ -0,0 +1,201 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Memoising cache for chat-log entry DOM nodes.
*
* The chat log re-renders on every refresh tick. Turning each entry's HTML
* string into a DOM node (``element.innerHTML = ``) forces a synchronous parse
* and is the dominant cost of a refresh (issue: chat-log render). This cache
* keeps the built node and only re-parses when the entry's rendered HTML
* actually changes a new message, a renamed sender, a fresh telemetry value.
* An idle re-render (nothing new) therefore performs zero parses and simply
* re-appends the cached nodes.
*
* Entries are **namespaced per tab** because the same message renders in more
* than one tab (the mixed Log feed and its channel tab) and a DOM node can live
* in only one parent at a time, so each tab keeps its own node for a given key.
*
* @module main/chat-entry-cache
*/
/**
* Create a chat-entry node cache.
*
* @param {{ documentRef?: Document }} [options] Optional document override; the
* ambient ``document`` is used when omitted. Primarily for unit tests.
* @returns {{
* materialize: (namespace: string, key: string, className: string, html: string) => Object,
* prune: (namespace: string) => void,
* retainNamespaces: (activeNamespaces: Iterable<string>) => void,
* stats: () => { materialized: number },
* resetStats: () => void,
* size: (namespace?: string) => number
* }} Cache API.
*/
export function createChatEntryCache({ documentRef } = {}) {
const doc = documentRef ?? (typeof document !== 'undefined' ? document : null);
if (!doc || typeof doc.createElement !== 'function') {
throw new TypeError('createChatEntryCache requires a document with createElement');
}
/** @type {Map<string, Map<string, { html: string, node: Object }>>} */
const namespaces = new Map();
/** @type {Map<string, Set<string>>} keys touched in the current build cycle. */
const seen = new Map();
let materialized = 0;
/**
* Resolve (creating if needed) the entry map for a namespace.
*
* @param {string} namespace Tab identifier.
* @returns {Map<string, { html: string, node: Object }>} Entry map.
*/
function nsMap(namespace) {
let map = namespaces.get(namespace);
if (!map) {
map = new Map();
namespaces.set(namespace, map);
}
return map;
}
/**
* Resolve (creating if needed) the seen-key set for a namespace.
*
* @param {string} namespace Tab identifier.
* @returns {Set<string>} Set of keys seen this cycle.
*/
function seenSet(namespace) {
let set = seen.get(namespace);
if (!set) {
set = new Set();
seen.set(namespace, set);
}
return set;
}
/**
* Return the cached node for ``(namespace, key)`` when its HTML is unchanged;
* otherwise build, cache, and return a fresh node. Records the key as seen so
* a later {@link prune} keeps it.
*
* @param {string} namespace Tab identifier.
* @param {string} key Stable per-entry identity.
* @param {string} className CSS class applied to the entry element.
* @param {string} html Rendered entry HTML (also the cache-validity signature).
* @returns {Object} The cached or freshly-built entry node.
*/
function materialize(namespace, key, className, html) {
const cache = nsMap(namespace);
seenSet(namespace).add(key);
const existing = cache.get(key);
if (existing && existing.html === html) {
return existing.node;
}
const node = doc.createElement('div');
node.className = className;
node.innerHTML = html;
cache.set(key, { html, node });
materialized += 1;
return node;
}
/**
* Drop cached entries in ``namespace`` that were not seen during the current
* build cycle (messages that aged out of the window), then clear the cycle's
* seen set so the next build starts fresh.
*
* @param {string} namespace Tab identifier.
* @returns {void}
*/
function prune(namespace) {
const cache = namespaces.get(namespace);
const set = seen.get(namespace);
if (!cache) {
return;
}
if (!set) {
cache.clear();
return;
}
for (const key of [...cache.keys()]) {
if (!set.has(key)) {
cache.delete(key);
}
}
seen.delete(namespace);
}
/**
* Forget every namespace not present in ``activeNamespaces`` so caches for
* removed tabs (e.g. a channel that dropped out of the window) are released.
*
* @param {Iterable<string>} activeNamespaces Namespaces to keep.
* @returns {void}
*/
function retainNamespaces(activeNamespaces) {
const keep = activeNamespaces instanceof Set ? activeNamespaces : new Set(activeNamespaces);
for (const namespace of [...namespaces.keys()]) {
if (!keep.has(namespace)) {
namespaces.delete(namespace);
}
}
for (const namespace of [...seen.keys()]) {
if (!keep.has(namespace)) {
seen.delete(namespace);
}
}
}
/**
* Cumulative count of entries materialised (parsed) since the last reset.
*
* @returns {{ materialized: number }} Render statistics.
*/
function stats() {
return { materialized };
}
/**
* Reset the materialisation counter (does not evict cached nodes).
*
* @returns {void}
*/
function resetStats() {
materialized = 0;
}
/**
* Number of cached entries, for one namespace or across all of them.
*
* @param {string} [namespace] Tab identifier; omit for the grand total.
* @returns {number} Cached entry count.
*/
function size(namespace) {
if (namespace === undefined) {
let total = 0;
for (const map of namespaces.values()) {
total += map.size;
}
return total;
}
const map = namespaces.get(namespace);
return map ? map.size : 0;
}
return { materialize, prune, retainNamespaces, stats, resetStats, size };
}
@@ -0,0 +1,66 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Stable cache keys for chat-log entries.
*
* The incremental chat renderer ({@link module:main/chat-entry-cache}) keys
* each entry's memoised DOM node by a string that uniquely and stably
* identifies the underlying record across refresh ticks. These pure helpers
* derive those keys.
*
* @module main/chat-entry-keys
*/
import { CHAT_LOG_ENTRY_TYPES } from '../chat-log-tabs.js';
/**
* Derive a stable cache key for a chat message. Prefers the message ``id`` (the
* server's primary key); falls back to a timestamp/sender/text composite for
* id-less rows so distinct messages still get distinct keys.
*
* @param {?Object} message Message payload.
* @returns {string} Stable per-message cache key.
*/
export function chatMessageEntryKey(message) {
if (!message || typeof message !== 'object') return 'msg:';
const id = message.id ?? message.message_id ?? message.messageId;
if (id != null && id !== '') return `msg:${id}`;
const ts = message.rx_time ?? message.rxTime ?? '';
const from = message.from_id ?? message.fromId ?? '';
const text = message.text ?? '';
return `msg:${ts}:${from}:${text}`;
}
/**
* Derive a stable cache key for a mixed-feed (Log tab) chat entry from its type
* and identifying fields. Encrypted messages reuse the message key so they stay
* stable across refreshes just like the channel-tab copy.
*
* @param {?Object} entry Structured chat-log entry.
* @returns {string} Stable per-entry cache key.
*/
export function chatLogEntryKey(entry) {
if (!entry || typeof entry !== 'object') return 'log:';
if (entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED && entry.message) {
return `enc:${chatMessageEntryKey(entry.message)}`;
}
const type = entry.type ?? '';
const nodeId = entry.nodeId ?? '';
const ts = entry.ts ?? '';
const neighborId = entry.neighborId ?? entry.neighbor?.neighbor_id ?? '';
return `log:${type}:${nodeId}:${ts}:${neighborId}`;
}
@@ -26,26 +26,30 @@ export const MESSAGE_HYDRATION_CONCURRENCY = 4;
* Build a hydrator capable of attaching node metadata to chat messages.
*
* @param {{
* fetchNodeById: (nodeId: string) => Promise<object|null>,
* fetchNodeById?: ((nodeId: string) => Promise<object|null>)|null,
* applyNodeFallback: (node: object) => void,
* logger?: { warn?: (message?: any, ...optionalParams: any[]) => void },
* concurrency?: number
* }} options Factory configuration. ``concurrency`` overrides the default
* worker-pool size and is primarily intended for unit tests; callers
* should leave it unset in production.
* }} options Factory configuration. ``fetchNodeById`` is **optional**: when
* omitted (the dashboard default) the hydrator runs in *map-only* mode,
* resolving senders purely from the supplied ``nodesById`` map and emitting an
* ``!id`` placeholder on a miss with zero network traffic. Supplying a fetcher
* re-enables the bounded per-node backfill (the opt-in batched path).
* ``concurrency`` overrides the default worker-pool size and is primarily
* intended for unit tests; callers should leave it unset in production.
* @returns {{
* hydrate: (messages: Array<object>|null|undefined, nodesById: Map<string, object>) => Promise<Array<object>>
* }} Hydrator API.
*/
export function createMessageNodeHydrator({
fetchNodeById,
fetchNodeById = null,
applyNodeFallback,
logger = console,
concurrency = MESSAGE_HYDRATION_CONCURRENCY,
}) {
if (typeof fetchNodeById !== 'function') {
throw new TypeError('fetchNodeById must be a function');
}
// ``fetchNodeById`` is optional — its presence switches the hydrator between
// map-only mode (no network) and the per-node backfill mode.
const networkEnabled = typeof fetchNodeById === 'function';
if (typeof applyNodeFallback !== 'function') {
throw new TypeError('applyNodeFallback must be a function');
}
@@ -132,15 +136,40 @@ export function createMessageNodeHydrator({
return promise;
}
/**
* Attach an ``!id`` placeholder node to a message whose sender could not be
* resolved from the bulk map (and, in network mode, was not found via lookup).
* The message's protocol is copied onto the placeholder so the fallback label
* and badge palette match the channel the sender appeared in; without this
* hint ``applyNodeFallback`` would default to the neutral ``Unknown`` label,
* even for MeshCore chats whose messages explicitly carry
* ``protocol: "meshcore"``.
*
* @param {object} message Message whose ``node`` is being assigned.
* @param {string} targetId Canonical sender identifier.
* @returns {void}
*/
function assignPlaceholder(message, targetId) {
const placeholder = { node_id: targetId };
const messageProtocol = message && message.protocol;
if (messageProtocol != null) {
placeholder.protocol = messageProtocol;
}
applyNodeFallback(placeholder);
message.node = placeholder;
}
/**
* Attach node information to the provided message collection.
*
* Messages whose sender is already in ``nodesById`` are bound synchronously
* and incur no network traffic. Misses are pushed onto a shared queue and
* drained by a fixed worker pool so the number of in-flight
* ``/api/nodes/:id`` requests never exceeds {@link workerCap}. This caps
* the cold-load thundering-herd that would otherwise issue one request per
* unique sender in parallel.
* and incur no network traffic. In **map-only** mode (no ``fetchNodeById``)
* every remaining miss becomes an ``!id`` placeholder with no requests at all.
* When a fetcher is supplied, misses are instead pushed onto a shared queue
* and drained by a fixed worker pool so the number of in-flight
* ``/api/nodes/:id`` requests never exceeds {@link workerCap} capping the
* cold-load thundering-herd that would otherwise issue one request per unique
* sender in parallel.
*
* @param {Array<object>|null|undefined} messages Message payloads from the API.
* @param {Map<string, object>} nodesById Lookup table of known nodes.
@@ -180,6 +209,16 @@ export function createMessageNodeHydrator({
return messages;
}
// Map-only mode: with no fetcher there is nothing to look up, so every miss
// resolves to an ``!id`` placeholder synchronously — no worker pool, no
// network requests.
if (!networkEnabled) {
for (const entry of queue) {
assignPlaceholder(entry.message, entry.targetId);
}
return messages;
}
// Workers share a monotonically advancing index instead of mutating the
// queue with ``shift()`` — ``Array#shift`` is O(n) and would turn a
// large hydration burst into O(n²). Single-threaded JS makes the
@@ -195,18 +234,7 @@ export function createMessageNodeHydrator({
if (node) {
entry.message.node = node;
} else {
// Copy the message's protocol stamp onto the placeholder so the
// fallback label and badge palette match the channel the sender
// appeared in. Without this hint applyNodeFallback would default
// to the neutral ``Unknown`` label, even for MeshCore chats whose
// messages explicitly carry ``protocol: "meshcore"``.
const placeholder = { node_id: entry.targetId };
const messageProtocol = entry.message && entry.message.protocol;
if (messageProtocol != null) {
placeholder.protocol = messageProtocol;
}
applyNodeFallback(placeholder);
entry.message.node = placeholder;
assignPlaceholder(entry.message, entry.targetId);
}
}
});