mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-12 03:42:57 +02:00
web: fix chat (#800)
* chore: add guardrails * docs: fix meshcore badge * web: life chat message cap at 1000 in frontend * web: honor protocol in chat * web: deprioritize test channels * fix ci
This commit is contained in:
@@ -23,8 +23,10 @@ module PotatoMesh
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param include_encrypted [Boolean] when true, include encrypted payloads in the response.
|
||||
# @param since [Integer] unix timestamp threshold; messages with rx_time older than this are excluded.
|
||||
# @param before [Integer, nil] inclusive upper-bound rx_time cursor used for
|
||||
# backward pagination (issue #796); messages newer than this are excluded.
|
||||
# @return [Array<Hash>] compacted message rows safe for API responses.
|
||||
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, protocol: nil)
|
||||
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, before: nil, protocol: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
now = Time.now.to_i
|
||||
# Default the chat feed to the same seven-day window the dashboard uses
|
||||
@@ -42,6 +44,19 @@ module PotatoMesh
|
||||
where_clauses << "m.rx_time >= ?"
|
||||
params << since_threshold
|
||||
|
||||
# Upper-bound cursor for backward pagination (issue #796). When set,
|
||||
# +before+ is an *inclusive* ceiling on +rx_time+: the client walks it
|
||||
# backward (newest -> oldest), passing the oldest +rx_time+ of each page
|
||||
# as the next cursor and de-duplicating by +id+ client-side, so rows that
|
||||
# share the boundary second are never skipped. Because the cursor only
|
||||
# ever *narrows* the result set, the seven-day floor above still bounds
|
||||
# the window — callers cannot use +before+ to reach further back.
|
||||
before_cursor = coerce_positive_or_nil(before)
|
||||
if before_cursor
|
||||
where_clauses << "m.rx_time <= ?"
|
||||
params << before_cursor
|
||||
end
|
||||
|
||||
unless include_encrypted
|
||||
where_clauses << "COALESCE(TRIM(m.encrypted), '') = ''"
|
||||
end
|
||||
|
||||
@@ -168,11 +168,16 @@ module PotatoMesh
|
||||
include_encrypted = coerce_boolean(params["encrypted"]) || false
|
||||
since = coerce_integer(params["since"])
|
||||
since = 0 if since.nil? || since.negative?
|
||||
# Upper-bound cursor for backward pagination (issue #796). A request
|
||||
# carrying +before+ is a history page, so it bypasses the shared
|
||||
# response cache (which only memoises the default newest-page feed).
|
||||
before = coerce_integer(params["before"])
|
||||
before = nil if before && before <= 0
|
||||
protocol = sanitize_protocol(params["protocol"])
|
||||
enc_key = include_encrypted ? "1" : "0"
|
||||
|
||||
if since > 0
|
||||
json_body = query_messages(limit, include_encrypted: include_encrypted, since: since, protocol: protocol).to_json
|
||||
if since > 0 || before
|
||||
json_body = query_messages(limit, include_encrypted: include_encrypted, since: since, before: before, protocol: protocol).to_json
|
||||
etag Digest::MD5.hexdigest(json_body), kind: :weak
|
||||
api_cache_control
|
||||
json_body
|
||||
|
||||
@@ -39,8 +39,8 @@ function makeNode(overrides = {}) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('renderChatEntryContent: MeshCore channel leading @[Name] becomes reply prefix', () => {
|
||||
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice' });
|
||||
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob' });
|
||||
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice', protocol: 'meshcore' });
|
||||
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob', protocol: 'meshcore' });
|
||||
const nodesById = new Map([
|
||||
[alice.node_id, alice],
|
||||
[bob.node_id, bob],
|
||||
@@ -71,7 +71,7 @@ test('renderChatEntryContent: MeshCore channel leading @[Name] becomes reply pre
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: MeshCore channel leading @[Name] handles name whitespace', () => {
|
||||
const timo = makeNode({ node_id: '!6aee769f', short_name: 'TI', long_name: '\u{1F4FA} Timo +' });
|
||||
const timo = makeNode({ node_id: '!6aee769f', short_name: 'TI', long_name: '\u{1F4FA} Timo +', protocol: 'meshcore' });
|
||||
const nodesById = new Map([[timo.node_id, timo]]);
|
||||
const message = {
|
||||
text: 'Bob: @[ Timo +] vielleicht hat jemand einen tip',
|
||||
@@ -95,8 +95,8 @@ test('renderChatEntryContent: MeshCore channel leading @[Name] handles name whit
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: MeshCore multi-mention body does NOT emit reply prefix', () => {
|
||||
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice' });
|
||||
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob' });
|
||||
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice', protocol: 'meshcore' });
|
||||
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob', protocol: 'meshcore' });
|
||||
const nodesById = new Map([[alice.node_id, alice], [bob.node_id, bob]]);
|
||||
const message = {
|
||||
text: 'X: @[Alice] and @[Bob] both',
|
||||
@@ -119,12 +119,60 @@ test('renderChatEntryContent: MeshCore multi-mention body does NOT emit reply pr
|
||||
assert.ok(html.includes('SHORT(BO|CLIENT|Bob)'));
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: leading mention with unresolved node still surfaces a reply prefix using the raw name (#727)', () => {
|
||||
test('renderChatEntryContent: MeshCore reply does not quote a same-named Meshtastic node (protocol collision)', () => {
|
||||
// A Meshtastic and a MeshCore node share the long name "Timo". The
|
||||
// Meshtastic node is inserted first, so the protocol-blind lookup returns it.
|
||||
// A MeshCore message quoting @[Timo] must badge the MeshCore node, never the
|
||||
// Meshtastic one.
|
||||
const meshtastic = makeNode({ node_id: '!10000001', short_name: 'MTMT', long_name: 'Timo', role: 'ROUTER', protocol: 'meshtastic' });
|
||||
const meshcore = makeNode({ node_id: '!20000002', short_name: 'MCMC', long_name: 'Timo', role: 'CLIENT', protocol: 'meshcore' });
|
||||
const nodesById = new Map([
|
||||
[meshtastic.node_id, meshtastic],
|
||||
[meshcore.node_id, meshcore],
|
||||
]);
|
||||
const message = { text: 'X: @[Timo] thanks!', protocol: 'meshcore', to_id: '^all' };
|
||||
|
||||
const { html } = renderChatEntryContent({
|
||||
message,
|
||||
nodesById,
|
||||
messagesById: new Map(),
|
||||
renderShortHtml,
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
});
|
||||
|
||||
assert.ok(html.includes('chat-entry-reply'), 'leading mention becomes a reply prefix');
|
||||
assert.ok(html.includes('SHORT(MCMC|CLIENT|Timo)'), 'reply target must be the MeshCore node');
|
||||
assert.ok(!html.includes('MTMT'), 'reply must NOT quote the same-named Meshtastic node');
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: MeshCore mention synthesises a node when only a same-named Meshtastic node exists', () => {
|
||||
// Only a Meshtastic "Timo" is in the registry. A MeshCore message must NOT
|
||||
// quote it; instead a synthetic MeshCore-stamped badge carrying the name is
|
||||
// rendered (issue: don't quote meshtastic nodes in a meshcore message).
|
||||
const meshtastic = makeNode({ node_id: '!10000001', short_name: 'MTMT', long_name: 'Timo', role: 'ROUTER', protocol: 'meshtastic' });
|
||||
const nodesById = new Map([[meshtastic.node_id, meshtastic]]);
|
||||
const message = { text: 'X: hi @[Timo] and @[Timo]', protocol: 'meshcore', to_id: '^all' };
|
||||
|
||||
const { html } = renderChatEntryContent({
|
||||
message,
|
||||
nodesById,
|
||||
messagesById: new Map(),
|
||||
renderShortHtml,
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
});
|
||||
|
||||
assert.ok(html.includes('SHORT(Timo|-|Timo)'), 'mention renders a synthetic node badge carrying the name');
|
||||
assert.ok(!html.includes('MTMT'), 'mention must NOT resolve to the same-named Meshtastic node');
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: leading mention with unresolved node surfaces a reply prefix using a synthetic node badge (#727)', () => {
|
||||
// Production deployments cap ``/api/nodes`` at 1000 entries, so the global
|
||||
// registry can be missing nodes that recent messages reference. In that
|
||||
// case the leading-mention-as-reply detection must still emit a reply
|
||||
// prefix using the bare mention name, otherwise the body would render as
|
||||
// ``@[Name] body...`` and look like an unresolved mention link.
|
||||
// case the leading-mention-as-reply detection still emits a reply prefix, now
|
||||
// backed by a protocol-stamped synthetic node badge (never a bare
|
||||
// ``@[Name] body...`` leak, and never a same-named node from another protocol).
|
||||
const nodesById = new Map();
|
||||
const message = {
|
||||
text: 'X: @[DA6ML/p] ja, klingt sehr gut',
|
||||
@@ -143,15 +191,16 @@ test('renderChatEntryContent: leading mention with unresolved node still surface
|
||||
|
||||
assert.ok(html.includes('chat-entry-reply'), 'should include a reply prefix even without a node match');
|
||||
assert.ok(html.includes('ESC(in reply to)'), 'reply prefix label is escaped');
|
||||
assert.ok(html.includes('ESC(DA6ML/p)'), 'mention name is shown verbatim (escaped)');
|
||||
assert.ok(html.includes('SHORT(DA6ML/p|-|DA6ML/p)'), 'mention renders as a synthetic node badge carrying the name');
|
||||
assert.ok(html.includes('ESC(ja, klingt sehr gut)'), 'remaining text rendered after the prefix');
|
||||
// The bare ``@[Name]`` form must NOT survive into the body.
|
||||
assert.ok(!html.includes('@[ESC('), 'unresolved mention should not leak into the body');
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: inline (non-leading) mentions still render as escaped literals when unresolved', () => {
|
||||
// Mentions that are NOT at the start are left as escaped literals — the
|
||||
// reply-prefix fallback only applies to leading-mention-as-reply.
|
||||
test('renderChatEntryContent: inline (non-leading) unresolved mentions render as synthetic node badges', () => {
|
||||
// Mentions that are NOT at the start no longer fall back to an escaped
|
||||
// ``@[Name]`` literal; they render a protocol-stamped synthetic node badge so
|
||||
// the mention is honored without borrowing a node from another protocol.
|
||||
const nodesById = new Map();
|
||||
const message = {
|
||||
text: 'X: hello @[Unknown] there',
|
||||
@@ -169,7 +218,8 @@ test('renderChatEntryContent: inline (non-leading) mentions still render as esca
|
||||
});
|
||||
|
||||
assert.ok(!html.includes('chat-entry-reply'), 'mid-text mention must not become reply prefix');
|
||||
assert.ok(html.includes('@[ESC(Unknown)]'), 'unresolved inline mention falls back to escaped literal');
|
||||
assert.ok(html.includes('SHORT(Unknown|-|Unknown)'), 'unresolved inline mention renders a synthetic node badge');
|
||||
assert.ok(!html.includes('@[ESC(Unknown)]'), 'bare escaped literal must not survive');
|
||||
});
|
||||
|
||||
test('renderChatEntryContent: MeshCore DM leading mention also becomes reply prefix', () => {
|
||||
@@ -287,7 +337,7 @@ test('renderChatEntryContent: encrypted message without notice formatter returns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('renderChatEntryContent: returns meshcoreSenderNode when prefix resolves against registry', () => {
|
||||
const sender = makeNode({ node_id: '!11111111', short_name: 'SN', long_name: 'Sender' });
|
||||
const sender = makeNode({ node_id: '!11111111', short_name: 'SN', long_name: 'Sender', protocol: 'meshcore' });
|
||||
const nodesById = new Map([[sender.node_id, sender]]);
|
||||
const message = {
|
||||
text: 'Sender: hello everyone',
|
||||
|
||||
@@ -20,6 +20,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
CHAT_LOG_ENTRY_TYPES,
|
||||
buildChatTabModel,
|
||||
isTestChannelLabel,
|
||||
MAX_CHANNEL_INDEX,
|
||||
normaliseChannelIndex,
|
||||
normaliseChannelName,
|
||||
@@ -27,6 +28,42 @@ import {
|
||||
} from '../chat-log-tabs.js';
|
||||
|
||||
const NOW = 1_000_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isTestChannelLabel — word-boundary ping/test/bot detection (SPEC F2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('isTestChannelLabel: matches standalone keywords case-insensitively', () => {
|
||||
for (const label of ['test', 'TEST', 'Ping', 'bot', '#test', '#ping', '#bot']) {
|
||||
assert.equal(isTestChannelLabel(label), true, `${label} should be a test channel`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isTestChannelLabel: matches a keyword as one word among others', () => {
|
||||
for (const label of ['test channel', 'my bot', 'ping pong', 'daily-test', 'bot 2', 'EU ping']) {
|
||||
assert.equal(isTestChannelLabel(label), true, `${label} should be a test channel`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isTestChannelLabel: does NOT match keywords embedded in larger words', () => {
|
||||
// The false positives the word-boundary rule exists to avoid (SPEC F2).
|
||||
for (const label of ['Camping', 'Robotics', 'RobotWars', 'Contest', 'Botswana', 'Testing', 'testbed', 'MyBot', 'test2', 'pingu']) {
|
||||
assert.equal(isTestChannelLabel(label), false, `${label} should NOT be a test channel`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isTestChannelLabel: real default/custom channel names are not test channels', () => {
|
||||
for (const label of ['Public', 'MediumFast', 'LongFast', '0', '#BerlinMesh', 'MeshTown']) {
|
||||
assert.equal(isTestChannelLabel(label), false, `${label} should NOT be a test channel`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isTestChannelLabel: non-string input returns false', () => {
|
||||
assert.equal(isTestChannelLabel(null), false);
|
||||
assert.equal(isTestChannelLabel(undefined), false);
|
||||
assert.equal(isTestChannelLabel(7), false);
|
||||
assert.equal(isTestChannelLabel(''), false);
|
||||
});
|
||||
const WINDOW = 60 * 60; // one hour
|
||||
|
||||
function fixtureNodes() {
|
||||
@@ -92,8 +129,10 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
|
||||
);
|
||||
|
||||
assert.equal(model.channels.length, 6);
|
||||
// Primary channels (index 0) come first, secondary channels (index > 0) come last.
|
||||
// Within each tier, ties on messageCount are broken alphabetically by label.
|
||||
// Default/primary channels (index 0) lead, then custom channels (index > 0);
|
||||
// these fixtures contain no test channels, so the third (test) tier is
|
||||
// exercised by the dedicated three-tier ordering tests below. Within each
|
||||
// tier, ties on messageCount are broken alphabetically by label.
|
||||
assert.deepEqual(model.channels.map(channel => channel.label), [
|
||||
'EnvDefault',
|
||||
'Fallback',
|
||||
@@ -142,6 +181,65 @@ test('buildChatTabModel skips channel buckets when there are no messages', () =>
|
||||
assert.equal(model.channels.length, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Three-tier channel ordering: default -> custom -> test (SPEC F1/F3/F4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('buildChatTabModel sinks test channels below custom channels even with more activity (F1)', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
// Custom channel, low activity (1 message).
|
||||
{ id: 'c1', rx_time: NOW - 5, channel: 1, channel_name: 'BerlinMesh' },
|
||||
// Test channel, HIGH activity (3 messages) — must still sort last.
|
||||
{ id: 't1', rx_time: NOW - 4, channel: 2, channel_name: 'test' },
|
||||
{ id: 't2', rx_time: NOW - 3, channel: 2, channel_name: 'test' },
|
||||
{ id: 't3', rx_time: NOW - 2, channel: 2, channel_name: 'test' },
|
||||
// Default/primary channel, low activity (1 message) — must lead.
|
||||
{ id: 'p1', rx_time: NOW - 6, channel: 0, channel_name: 'MediumFast' },
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW,
|
||||
primaryChannelFallbackLabel: '',
|
||||
});
|
||||
assert.deepEqual(model.channels.map(channel => channel.label), ['MediumFast', 'BerlinMesh', 'test']);
|
||||
// Presentation-only (F4): the demoted test channel keeps all its messages.
|
||||
assert.equal(findChannelByLabel(model, 'test').messageCount, 3);
|
||||
});
|
||||
|
||||
test('buildChatTabModel never demotes an index-0 channel even if its name matches a keyword (F3)', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
{ id: 'cust', rx_time: NOW - 5, channel: 1, channel_name: 'BerlinMesh' },
|
||||
{ id: 'prim', rx_time: NOW - 4, channel: 0, channel_name: 'test' }, // primary literally named "test"
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW,
|
||||
primaryChannelFallbackLabel: '',
|
||||
});
|
||||
// The index-0 "test" channel still leads; it is NOT sunk to the test tier.
|
||||
assert.deepEqual(model.channels.map(channel => channel.label), ['test', 'BerlinMesh']);
|
||||
assert.equal(model.channels[0].index, 0);
|
||||
});
|
||||
|
||||
test('buildChatTabModel orders channels within the test tier by activity then label (F1)', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
{ id: 'a1', rx_time: NOW - 5, channel: 1, channel_name: 'AlphaMesh' }, // custom (tier 1)
|
||||
{ id: 'pb1', rx_time: NOW - 4, channel: 2, channel_name: 'ping-bot' }, // test, 1 message
|
||||
{ id: 'tt1', rx_time: NOW - 3, channel: 3, channel_name: 'test' }, // test, 2 messages
|
||||
{ id: 'tt2', rx_time: NOW - 2, channel: 3, channel_name: 'test' },
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW,
|
||||
primaryChannelFallbackLabel: '',
|
||||
});
|
||||
// Custom first; then test channels, busier ('test', 2) before quieter ('ping-bot', 1).
|
||||
assert.deepEqual(model.channels.map(channel => channel.label), ['AlphaMesh', 'test', 'ping-bot']);
|
||||
});
|
||||
|
||||
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit } from '../incremental-helpers.js';
|
||||
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit, trimToWindow } from '../incremental-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// maxRecordTimestamp
|
||||
@@ -210,3 +210,49 @@ test('trimToLimit handles records with missing timestamp fields', () => {
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].id, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// trimToWindow (issue #796)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('trimToWindow drops records older than the floor and keeps the boundary', () => {
|
||||
const records = [
|
||||
{ id: 1, rx_time: 90 },
|
||||
{ id: 2, rx_time: 100 }, // exactly at the floor → kept
|
||||
{ id: 3, rx_time: 150 },
|
||||
];
|
||||
const result = trimToWindow(records, 100);
|
||||
assert.deepEqual(result.map(r => r.id), [2, 3]);
|
||||
});
|
||||
|
||||
test('trimToWindow retains records with a missing or non-numeric timestamp', () => {
|
||||
const records = [
|
||||
{ id: 1 },
|
||||
{ id: 2, rx_time: 'nope' },
|
||||
{ id: 3, rx_time: 50 },
|
||||
{ id: 4, rx_time: 500 },
|
||||
];
|
||||
const result = trimToWindow(records, 100);
|
||||
assert.deepEqual(result.map(r => r.id), [1, 2, 4]);
|
||||
});
|
||||
|
||||
test('trimToWindow uses a custom timestamp field', () => {
|
||||
const records = [
|
||||
{ id: 1, last_heard: 10 },
|
||||
{ id: 2, last_heard: 200 },
|
||||
];
|
||||
const result = trimToWindow(records, 100, 'last_heard');
|
||||
assert.deepEqual(result.map(r => r.id), [2]);
|
||||
});
|
||||
|
||||
test('trimToWindow returns the input unchanged for an unusable floor', () => {
|
||||
const records = [{ id: 1, rx_time: 10 }];
|
||||
assert.equal(trimToWindow(records, 0), records);
|
||||
assert.equal(trimToWindow(records, Number.NaN), records);
|
||||
assert.equal(trimToWindow(records, -5), records);
|
||||
});
|
||||
|
||||
test('trimToWindow returns input for non-array values', () => {
|
||||
assert.equal(trimToWindow(null, 100), null);
|
||||
assert.equal(trimToWindow(undefined, 100), undefined);
|
||||
});
|
||||
|
||||
@@ -238,3 +238,59 @@ test('since parameter uses a 1-second overlap to avoid missing rows', async () =
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('first load pages the chat window backward with a before cursor (issue #796)', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Page 1 is a *full* page (1000 rows), so the pager must request another page.
|
||||
const page1 = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: 5000 - i, rx_time: now - 60 - i, from_id: '!aabb', text: `m${i}`,
|
||||
}));
|
||||
const oldestPage1 = page1[page1.length - 1].rx_time; // inclusive cursor for page 2
|
||||
// Page 2 re-returns the boundary row (must be de-duplicated) plus older rows,
|
||||
// then is short — ending the walk.
|
||||
const page2 = [
|
||||
{ id: 4001, rx_time: oldestPage1, from_id: '!aabb', text: 'boundary' },
|
||||
...Array.from({ length: 200 }, (_, i) => ({
|
||||
id: 4000 - i, rx_time: oldestPage1 - 1 - i, from_id: '!aabb', text: `o${i}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
let body = [];
|
||||
if (url.includes('/api/messages') && !url.includes('encrypted=true')) {
|
||||
body = url.includes('before=') ? page2 : page1;
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||
};
|
||||
try {
|
||||
initializeApp(BASE_CONFIG);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const plaintextMsgCalls = calls.filter(
|
||||
c => c.url.includes('/api/messages') && !c.url.includes('encrypted=true'),
|
||||
);
|
||||
// A full first page must be followed by a backward page; the cursor is the
|
||||
// oldest rx_time of page 1 (issue #796).
|
||||
assert.ok(
|
||||
plaintextMsgCalls.length >= 2,
|
||||
`expected backward pagination, saw ${plaintextMsgCalls.length} message call(s)`,
|
||||
);
|
||||
assert.ok(!plaintextMsgCalls[0].url.includes('before='), 'first page must not carry a cursor');
|
||||
assert.ok(
|
||||
plaintextMsgCalls.some(c => c.url.includes(`before=${oldestPage1}`)),
|
||||
`expected a backward page with before=${oldestPage1}`,
|
||||
);
|
||||
// The initial load must not use the incremental `since` cursor.
|
||||
assert.ok(
|
||||
plaintextMsgCalls.every(c => !c.url.includes('since=')),
|
||||
'first load should paginate with before, never since',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -302,13 +302,16 @@ test('createMessageChatEntry: meshcore message with @[Name] mention resolved to
|
||||
});
|
||||
});
|
||||
|
||||
test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — fallback', () => {
|
||||
test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — synthetic badge', () => {
|
||||
withApp((t) => {
|
||||
t.rebuildNodeIndex([]);
|
||||
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('EchoBot: Pong! @[Ghost]', { rx_time: 3000 }));
|
||||
const html = innerHtml(div);
|
||||
// @[Ghost] mention with no matching node renders as escaped plain text
|
||||
assert.ok(html.includes('@[Ghost]'), 'unresolved mention should render as escaped @[Name] text');
|
||||
// @[Ghost] mention with no matching node renders a synthetic protocol-stamped
|
||||
// node badge carrying the name, never a bare ``@[Name]`` literal.
|
||||
assert.ok(html.includes('Ghost'), 'unresolved mention should render a synthetic badge carrying the name');
|
||||
assert.ok(html.includes('Pong!'), 'body text should still render');
|
||||
assert.ok(!html.includes('@[Ghost]'), 'bare @[Name] literal must not survive');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseMeshcoreSenderPrefix,
|
||||
findNodeByLongName,
|
||||
buildSyntheticChatNode,
|
||||
extractLeadingMentionAsReply,
|
||||
} from '../meshcore-chat-helpers.js';
|
||||
|
||||
@@ -217,6 +218,69 @@ test('findNodeByLongName: whitespace-only input returns null', () => {
|
||||
assert.equal(findNodeByLongName(' ', map), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findNodeByLongName — protocol-aware resolution (no cross-protocol quoting)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('findNodeByLongName: honors protocol and never returns a different-protocol node', () => {
|
||||
// A Meshtastic and a MeshCore node share the long name "Timo". The
|
||||
// Meshtastic node is inserted first, so the protocol-blind scan returns it.
|
||||
const meshtastic = { node_id: '!10000001', long_name: 'Timo', protocol: 'meshtastic' };
|
||||
const meshcore = { node_id: '!20000002', long_name: 'Timo', protocol: 'meshcore' };
|
||||
const map = new Map([
|
||||
['!10000001', meshtastic],
|
||||
['!20000002', meshcore],
|
||||
]);
|
||||
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), meshcore);
|
||||
assert.equal(findNodeByLongName('Timo', map, 'meshtastic'), meshtastic);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: returns null when only a different-protocol node matches', () => {
|
||||
const meshtastic = { node_id: '!10000001', long_name: 'Timo', protocol: 'meshtastic' };
|
||||
const map = new Map([['!10000001', meshtastic]]);
|
||||
// A MeshCore message must not borrow the Meshtastic node, even as a last resort.
|
||||
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: an unstamped node never matches a MeshCore request', () => {
|
||||
// Absent protocol normalises to the Meshtastic default, so it is excluded
|
||||
// from MeshCore resolution but eligible for Meshtastic resolution.
|
||||
const node = { node_id: '!10000001', long_name: 'Timo' };
|
||||
const map = new Map([['!10000001', node]]);
|
||||
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), null);
|
||||
assert.equal(findNodeByLongName('Timo', map, 'meshtastic'), node);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: protocol filter also applies to the emoji-prefix fallback pass', () => {
|
||||
// Same emoji-stripping fallback name, one node per protocol; the MeshCore
|
||||
// request must resolve the MeshCore node despite the Meshtastic one matching
|
||||
// the same stripped name first.
|
||||
const meshtastic = { node_id: '!10000001', long_name: '\u{1F4FA} Timo +', protocol: 'meshtastic' };
|
||||
const meshcore = { node_id: '!20000002', long_name: '\u{1F4FA} Timo +', protocol: 'meshcore' };
|
||||
const map = new Map([
|
||||
['!10000001', meshtastic],
|
||||
['!20000002', meshcore],
|
||||
]);
|
||||
assert.equal(findNodeByLongName('Timo +', map, 'meshcore'), meshcore);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSyntheticChatNode — protocol-stamped stand-in for unmatched names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('buildSyntheticChatNode: carries the name as short/long name and stamps the protocol', () => {
|
||||
assert.deepEqual(buildSyntheticChatNode('Bob', 'meshcore'), {
|
||||
short_name: 'Bob',
|
||||
long_name: 'Bob',
|
||||
protocol: 'meshcore',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildSyntheticChatNode: omits the protocol key when none is supplied', () => {
|
||||
assert.deepEqual(buildSyntheticChatNode('Bob', null), { short_name: 'Bob', long_name: 'Bob' });
|
||||
assert.deepEqual(buildSyntheticChatNode('Bob'), { short_name: 'Bob', long_name: 'Bob' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractLeadingMentionAsReply — MeshCore leading-mention detection (#727)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { buildMessageBody, resolveReplyPrefix } from './message-replies.js';
|
||||
import {
|
||||
buildSyntheticChatNode,
|
||||
extractLeadingMentionAsReply,
|
||||
findNodeByLongName,
|
||||
parseMeshcoreSenderPrefix,
|
||||
@@ -85,8 +86,10 @@ function formatReplyPrefixHtml(label, badgeHtml, escapeHtml) {
|
||||
* 2. MeshCore ``"SenderName: body"`` prefix parsing for channel messages.
|
||||
* 3. MeshCore leading-``@[Name]`` detection, surfacing it as an ``[in reply
|
||||
* to BADGE]`` prefix when no structured reply is already present.
|
||||
* 4. Mention rendering for MeshCore messages, mapping ``@[Name]`` to either
|
||||
* a badge (when the named node is known) or an escaped literal fallback.
|
||||
* 4. Mention rendering for MeshCore messages, mapping ``@[Name]`` to a badge.
|
||||
* Name resolution is restricted to nodes of the message's own protocol so
|
||||
* a MeshCore message never quotes a same-named Meshtastic node; when no
|
||||
* same-protocol node matches, a synthetic protocol-stamped node is badged.
|
||||
* 5. ``buildMessageBody()`` invocation, which handles URL linkification,
|
||||
* emoji rendering, and reaction detection.
|
||||
* 6. Encrypted-message notices when available from the caller.
|
||||
@@ -145,7 +148,12 @@ export function renderChatEntryContent({
|
||||
if (isMeshcoreChannelMsg && typeof message.text === 'string') {
|
||||
parsedMeshcorePrefix = parseMeshcoreSenderPrefix(message.text);
|
||||
if (parsedMeshcorePrefix && !message.node) {
|
||||
meshcoreSenderNode = findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById);
|
||||
// Resolve the sender only among same-protocol nodes; when none matches,
|
||||
// synthesise a protocol-stamped stand-in rather than borrowing a
|
||||
// same-named node from another protocol (issue: honor protocol in chat).
|
||||
meshcoreSenderNode =
|
||||
findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById, protocol) ??
|
||||
buildSyntheticChatNode(parsedMeshcorePrefix.senderName, protocol);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,20 +197,15 @@ export function renderChatEntryContent({
|
||||
if (!replyPrefix && isMeshcore && effectiveBodyText) {
|
||||
const leading = extractLeadingMentionAsReply(effectiveBodyText);
|
||||
if (leading) {
|
||||
const replyNode = findNodeByLongName(leading.mentionName, nodesById);
|
||||
let badgeHtml = '';
|
||||
if (replyNode) {
|
||||
badgeHtml = renderNodeBadge(renderShortHtml, replyNode);
|
||||
}
|
||||
// Graceful degradation: when the registry doesn't contain the
|
||||
// mention target (common on large deployments where ``/api/nodes``
|
||||
// caps at 1000 entries by recency), still surface the leading
|
||||
// mention as a reply prefix using the raw name. Without this
|
||||
// fallback the body would render as bare ``@[Name] body...`` which
|
||||
// looks like an unresolved mention link to the user.
|
||||
if (typeof badgeHtml !== 'string' || badgeHtml.length === 0) {
|
||||
badgeHtml = `<span class="short-name">${escapeHtml(leading.mentionName)}</span>`;
|
||||
}
|
||||
// Resolve the quoted node among same-protocol nodes only. When none
|
||||
// matches — whether because the registry lacks it (``/api/nodes`` caps by
|
||||
// recency) or only a different-protocol node shares the name — synthesise
|
||||
// a protocol-stamped stand-in so the reply badge is always rendered with
|
||||
// the correct protocol and never quotes a node from another protocol.
|
||||
const replyNode =
|
||||
findNodeByLongName(leading.mentionName, nodesById, protocol) ??
|
||||
buildSyntheticChatNode(leading.mentionName, protocol);
|
||||
const badgeHtml = renderNodeBadge(renderShortHtml, replyNode);
|
||||
meshcoreReplyPrefix = formatReplyPrefixHtml('in reply to', badgeHtml, escapeHtml);
|
||||
effectiveBodyText = leading.remainingText ?? '';
|
||||
}
|
||||
@@ -213,11 +216,13 @@ export function renderChatEntryContent({
|
||||
// ------------------------------------------------------------------
|
||||
const renderMentionHtml = isMeshcore
|
||||
? (mentionedName) => {
|
||||
const mentionNode = findNodeByLongName(mentionedName, nodesById);
|
||||
if (mentionNode) {
|
||||
return renderNodeBadge(renderShortHtml, mentionNode);
|
||||
}
|
||||
return `@[${escapeHtml(mentionedName)}]`;
|
||||
// Same-protocol resolution with a protocol-stamped synthetic fallback,
|
||||
// so an unresolved mention renders a MeshCore badge instead of either a
|
||||
// bare ``@[Name]`` literal or a same-named Meshtastic node.
|
||||
const mentionNode =
|
||||
findNodeByLongName(mentionedName, nodesById, protocol) ??
|
||||
buildSyntheticChatNode(mentionedName, protocol);
|
||||
return renderNodeBadge(renderShortHtml, mentionNode);
|
||||
}
|
||||
: null;
|
||||
|
||||
|
||||
@@ -22,6 +22,50 @@ import { extractModemMetadata } from './node-modem-metadata.js';
|
||||
*/
|
||||
export const MAX_CHANNEL_INDEX = 255;
|
||||
|
||||
/**
|
||||
* Matches a throwaway "test" channel by the presence of the standalone word
|
||||
* ``ping``, ``test``, or ``bot`` (case-insensitive). The ``\b`` word boundaries
|
||||
* are deliberate: they keep legitimate channels whose names merely *contain*
|
||||
* those letters — "Camping", "Robotics", "Contest", "Botswana" — out of the test
|
||||
* tier, trading the odd concatenated form ("MyBot", "test2") for zero false
|
||||
* positives (SPEC F2).
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const TEST_CHANNEL_PATTERN = /\b(?:ping|test|bot)\b/i;
|
||||
|
||||
/**
|
||||
* Decide whether a channel label denotes a deprioritized "test" channel.
|
||||
*
|
||||
* Used by {@link buildChatTabModel} to sink ``#test`` / ``#ping`` / ``#bot``
|
||||
* style channels below the community's real channels (SPEC F1/F2). Matching is
|
||||
* on the resolved display label the operator sees, case-insensitive and bounded
|
||||
* to whole words so substrings never trigger a false positive.
|
||||
*
|
||||
* @param {string} label Resolved channel display label.
|
||||
* @returns {boolean} ``true`` when the label contains a standalone test keyword.
|
||||
*/
|
||||
export function isTestChannelLabel(label) {
|
||||
if (typeof label !== 'string') return false;
|
||||
return TEST_CHANNEL_PATTERN.test(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a channel bucket into its display-ordering tier (SPEC F1/F3).
|
||||
* Lower tiers sort first:
|
||||
*
|
||||
* 0 — default/primary channel (index 0). Always leads and is **never** demoted
|
||||
* to the test tier, even if its label matches a keyword (SPEC F3).
|
||||
* 2 — test channel: a non-primary channel whose label names ping/test/bot.
|
||||
* 1 — any other custom (non-primary, non-test) channel.
|
||||
*
|
||||
* @param {{ index: number, label: string }} channel Channel bucket.
|
||||
* @returns {number} Ordering tier (0, 1, or 2).
|
||||
*/
|
||||
function channelPriorityTier(channel) {
|
||||
if (channel.index === 0) return 0;
|
||||
return isTestChannelLabel(channel.label) ? 2 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discrete event types that can appear in the chat activity log.
|
||||
*
|
||||
@@ -311,14 +355,16 @@ export function buildChatTabModel({
|
||||
channel.entries.sort((a, b) => a.ts - b.ts);
|
||||
channel.messageCount = channel.entries.length;
|
||||
}
|
||||
// Sort channels into two tiers:
|
||||
// 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
|
||||
// ordered by activity desc so the most-active protocol leads within the tier.
|
||||
// 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
|
||||
// Within each tier, ties on messageCount are broken alphabetically by label.
|
||||
// Sort channels into three priority tiers (SPEC F1):
|
||||
// 0. Default/primary channels (index 0 — LongFast, MediumFast, Public, …),
|
||||
// never demoted even if the name matches a test keyword (SPEC F3).
|
||||
// 1. Custom channels (index > 0) that are not test channels.
|
||||
// 2. Test channels (index > 0 whose label names ping/test/bot) — sunk last.
|
||||
// Within each tier the prior ordering is preserved unchanged: activity
|
||||
// (7-day message count) descending, then label alphabetical.
|
||||
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
|
||||
const aTier = a.index === 0 ? 0 : 1;
|
||||
const bTier = b.index === 0 ? 0 : 1;
|
||||
const aTier = channelPriorityTier(a);
|
||||
const bTier = channelPriorityTier(b);
|
||||
if (aTier !== bTier) return aTier - bTier;
|
||||
return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
@@ -106,3 +106,29 @@ export function trimToLimit(records, limit, tsField = 'rx_time') {
|
||||
const sorted = records.slice().sort((a, b) => (b[tsField] || 0) - (a[tsField] || 0));
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop records older than a timestamp floor, keeping the retained set aligned
|
||||
* with a rolling window rather than a fixed row count.
|
||||
*
|
||||
* The chat feed pages the whole seven-day window (issue #796), so bounding the
|
||||
* accumulated set by *count* would silently discard older-but-in-window
|
||||
* messages on the next incremental merge. Bounding by the window floor instead
|
||||
* keeps exactly what the renderer can display while still preventing unbounded
|
||||
* growth over a long-running tab. Records whose timestamp is missing or
|
||||
* non-numeric are retained so data is never lost to a malformed field.
|
||||
*
|
||||
* @param {Array<Object>} records Merged record array.
|
||||
* @param {number} floorSeconds Minimum retained timestamp (unix seconds).
|
||||
* @param {string} [tsField] Timestamp field name used for comparison.
|
||||
* @returns {Array<Object>} Filtered array (same reference when nothing is
|
||||
* dropped or the floor is unusable).
|
||||
*/
|
||||
export function trimToWindow(records, floorSeconds, tsField = 'rx_time') {
|
||||
if (!Array.isArray(records)) return records;
|
||||
if (!Number.isFinite(floorSeconds) || floorSeconds <= 0) return records;
|
||||
return records.filter(record => {
|
||||
const ts = Number(record && record[tsField]);
|
||||
return !Number.isFinite(ts) || ts >= floorSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ import {
|
||||
aggregateTelemetrySnapshots,
|
||||
} from './snapshot-aggregator.js';
|
||||
import { normalizeNodeCollection } from './node-snapshot-normalizer.js';
|
||||
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit } from './incremental-helpers.js';
|
||||
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit, trimToWindow } from './incremental-helpers.js';
|
||||
import { buildTraceSegments } from './trace-paths.js';
|
||||
import {
|
||||
getRoleColor,
|
||||
@@ -169,6 +169,7 @@ import {
|
||||
filterRecentTraces,
|
||||
resolveSnapshotLimit,
|
||||
fetchMessages as fetchMessagesImpl,
|
||||
fetchAllMessages as fetchAllMessagesImpl,
|
||||
} from './main/data-fetchers.js';
|
||||
import {
|
||||
compareNumber,
|
||||
@@ -2918,10 +2919,14 @@ export function initializeApp(config) {
|
||||
: 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.'
|
||||
emptyLabel: 'No messages on this channel.',
|
||||
limit: Infinity
|
||||
}),
|
||||
index: channel.index,
|
||||
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
|
||||
@@ -2955,11 +2960,14 @@ export function initializeApp(config) {
|
||||
* @param {{
|
||||
* entries: Array<{ ts: number, item: Object }>,
|
||||
* renderEntry: Function,
|
||||
* emptyLabel?: string
|
||||
* }} params Fragment construction parameters.
|
||||
* 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).
|
||||
* @returns {DocumentFragment} Populated fragment.
|
||||
*/
|
||||
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
|
||||
function buildChatFragment({ entries = [], renderEntry, emptyLabel, limit = CHAT_LIMIT }) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (!entries || entries.length === 0) {
|
||||
if (emptyLabel) {
|
||||
@@ -2971,7 +2979,9 @@ export function initializeApp(config) {
|
||||
return fragment;
|
||||
}
|
||||
const getDivider = createDateDividerFactory();
|
||||
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
|
||||
const limitedEntries = Number.isFinite(limit)
|
||||
? entries.slice(Math.max(entries.length - limit, 0))
|
||||
: entries;
|
||||
let renderedEntries = 0;
|
||||
for (const entry of limitedEntries) {
|
||||
if (!entry || typeof entry.ts !== 'number') {
|
||||
@@ -3019,6 +3029,24 @@ export function initializeApp(config) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closure-bound bridge to ``fetchAllMessagesImpl``. Pages the entire chat
|
||||
* window (issue #796) so the initial load surfaces every in-window message
|
||||
* instead of just the newest {@link MESSAGE_LIMIT}. Like {@link fetchMessages}
|
||||
* it injects the dashboard's ``CHAT_ENABLED`` flag and limit normaliser so the
|
||||
* underlying pager stays pure.
|
||||
*
|
||||
* @param {{ encrypted?: boolean }} [options] Optional retrieval flags.
|
||||
* @returns {Promise<Array<Object>>} Every message in the visibility window.
|
||||
*/
|
||||
function fetchAllMessages(options = {}) {
|
||||
return fetchAllMessagesImpl(MESSAGE_LIMIT, {
|
||||
...options,
|
||||
chatEnabled: CHAT_ENABLED,
|
||||
normaliseMessageLimit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute distance from the configured map center.
|
||||
*
|
||||
@@ -3972,7 +4000,12 @@ export function initializeApp(config) {
|
||||
positionsPromise,
|
||||
neighborPromise,
|
||||
tracesPromise,
|
||||
fetchMessages(MESSAGE_LIMIT, { since: msgSince }),
|
||||
// First load pages the whole window so chat is complete (issue #796);
|
||||
// incremental refreshes only need the slice newer than the high-water
|
||||
// mark, which always fits in a single page.
|
||||
useSince
|
||||
? fetchMessages(MESSAGE_LIMIT, { since: msgSince })
|
||||
: fetchAllMessages({}),
|
||||
telemetryPromise,
|
||||
encryptedMessagesPromise
|
||||
]);
|
||||
@@ -4011,9 +4044,15 @@ export function initializeApp(config) {
|
||||
const traceEntries = useSince
|
||||
? trimToLimit(mergeById(allTraces, incomingTraces, 'id'), TRACE_LIMIT)
|
||||
: incomingTraces;
|
||||
// Plaintext chat is shown for the full seven-day window (issue #796), so
|
||||
// bound the retained set by that window rather than a row count — a count
|
||||
// cap would silently drop older-but-in-window messages on the next merge.
|
||||
const messageWindowFloor = Math.floor(Date.now() / 1000) - CHAT_RECENT_WINDOW_SECONDS;
|
||||
const messages = useSince
|
||||
? trimToLimit(mergeById(allMessages, incomingMessages, 'id'), MESSAGE_LIMIT)
|
||||
? trimToWindow(mergeById(allMessages, incomingMessages, 'id'), messageWindowFloor)
|
||||
: incomingMessages;
|
||||
// Encrypted blobs only feed the mixed Log tab (itself capped), so a count
|
||||
// cap is the right memory bound for them.
|
||||
const encryptedMessages = useSince
|
||||
? trimToLimit(mergeById(allEncryptedMessages, incomingEncryptedMessages, 'id'), MESSAGE_LIMIT)
|
||||
: incomingEncryptedMessages;
|
||||
|
||||
@@ -18,6 +18,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
fetchAllMessages,
|
||||
fetchMessages,
|
||||
fetchNeighbors,
|
||||
fetchNodeById,
|
||||
@@ -344,3 +345,107 @@ test('fetchMessages propagates HTTP errors', async () => {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchMessages forwards a positive before cursor and omits a non-positive one', async () => {
|
||||
const stub = withFetchStub({ ok: true, body: [] });
|
||||
try {
|
||||
await fetchMessages(10, { before: 1234 });
|
||||
assert.ok(stub.calls[0].url.includes('before=1234'));
|
||||
await fetchMessages(10, { before: 0 });
|
||||
assert.ok(!stub.calls[1].url.includes('before='));
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchAllMessages (issue #796 backward pagination)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('fetchAllMessages pages backward until a short page and de-duplicates by id', async () => {
|
||||
// limit=2; the inclusive cursor re-returns the boundary row, which must be
|
||||
// de-duplicated rather than counted twice.
|
||||
const stub = withFetchStub((url) => {
|
||||
if (url.includes('before=40')) {
|
||||
return { ok: true, body: [{ id: 4, rx_time: 40 }, { id: 3, rx_time: 30 }] };
|
||||
}
|
||||
if (url.includes('before=30')) {
|
||||
return { ok: true, body: [{ id: 3, rx_time: 30 }] }; // short page → stop
|
||||
}
|
||||
return { ok: true, body: [{ id: 5, rx_time: 50 }, { id: 4, rx_time: 40 }] };
|
||||
});
|
||||
try {
|
||||
const all = await fetchAllMessages(2, {});
|
||||
assert.deepEqual(all.map(m => m.id), [5, 4, 3]);
|
||||
assert.equal(stub.calls.length, 3);
|
||||
assert.ok(stub.calls[1].url.includes('before=40'));
|
||||
assert.ok(stub.calls[2].url.includes('before=30'));
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchAllMessages stops when the server ignores the cursor (no progress)', async () => {
|
||||
// The stub returns the same full page regardless of the cursor; without the
|
||||
// no-progress guard this would loop forever.
|
||||
const stub = withFetchStub({ ok: true, body: [{ id: 5, rx_time: 50 }, { id: 4, rx_time: 40 }] });
|
||||
try {
|
||||
const all = await fetchAllMessages(2, {});
|
||||
assert.deepEqual(all.map(m => m.id), [5, 4]);
|
||||
assert.equal(stub.calls.length, 2); // page 1 + one no-progress page, then stop
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchAllMessages returns [] and makes one call for an empty window', async () => {
|
||||
const stub = withFetchStub({ ok: true, body: [] });
|
||||
try {
|
||||
const all = await fetchAllMessages(2, {});
|
||||
assert.deepEqual(all, []);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchAllMessages stops when no row carries a usable timestamp cursor', async () => {
|
||||
// A full page whose rows lack rx_time cannot advance the cursor; the loop must
|
||||
// still terminate (and keep the rows it found).
|
||||
const stub = withFetchStub({ ok: true, body: [{ id: 7 }, { id: 8 }] });
|
||||
try {
|
||||
const all = await fetchAllMessages(2, {});
|
||||
assert.deepEqual(all.map(m => m.id), [7, 8]);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchAllMessages skips rows without an id and forwards retrieval flags', async () => {
|
||||
const stub = withFetchStub({ ok: true, body: [{ rx_time: 40 }] }); // no id → skipped, short page
|
||||
try {
|
||||
const all = await fetchAllMessages(2, { encrypted: true });
|
||||
assert.deepEqual(all, []);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
assert.ok(stub.calls[0].url.includes('encrypted=true'));
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchAllMessages honours the maxPages backstop against a runaway feed', async () => {
|
||||
// Every page is full and strictly older, so only maxPages bounds the walk.
|
||||
let n = 0;
|
||||
const stub = withFetchStub(() => {
|
||||
n += 1;
|
||||
return { ok: true, body: [{ id: 100 - n, rx_time: 100 - n }] };
|
||||
});
|
||||
try {
|
||||
const all = await fetchAllMessages(1, { maxPages: 3 });
|
||||
assert.equal(all.length, 3);
|
||||
assert.equal(stub.calls.length, 3);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -102,13 +102,15 @@ export async function fetchNodeById(nodeId) {
|
||||
* Fetch recent messages from the JSON API.
|
||||
*
|
||||
* @param {number} limit Maximum number of rows.
|
||||
* @param {{ encrypted?: boolean, since?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options
|
||||
* @param {{ encrypted?: boolean, since?: number, before?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options
|
||||
* Retrieval flags and dependency hooks. When ``chatEnabled`` is false the
|
||||
* function short-circuits to an empty array without contacting the API.
|
||||
* ``before`` is an inclusive upper-bound ``rx_time`` cursor used for backward
|
||||
* pagination (issue #796).
|
||||
* @returns {Promise<Array<Object>>} Parsed message payloads.
|
||||
*/
|
||||
export async function fetchMessages(limit, options = {}) {
|
||||
const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0 } = options;
|
||||
const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0, before = 0 } = options;
|
||||
if (!chatEnabled) return [];
|
||||
const safeLimit = typeof normaliseMessageLimit === 'function'
|
||||
? normaliseMessageLimit(limit)
|
||||
@@ -120,12 +122,65 @@ export async function fetchMessages(limit, options = {}) {
|
||||
if (since > 0) {
|
||||
params.set('since', String(since));
|
||||
}
|
||||
if (before > 0) {
|
||||
params.set('before', String(before));
|
||||
}
|
||||
const query = params.toString();
|
||||
const r = await fetch(`/api/messages?${query}`, { cache: 'default' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch *every* message in the server's visibility window by paging backward.
|
||||
*
|
||||
* The API clamps each response to {@link MESSAGE_LIMIT} rows, so a single
|
||||
* request can only ever surface the newest page. This helper walks the feed
|
||||
* from newest to oldest: it pulls a page, then re-requests everything at or
|
||||
* before the oldest ``rx_time`` it has seen (an inclusive cursor), de-duplicating
|
||||
* by ``id``. It stops when a short page signals the window is exhausted, when a
|
||||
* page yields no new rows (the server ignored the cursor, or every row was a
|
||||
* boundary duplicate), or when ``maxPages`` is hit as a runaway backstop.
|
||||
*
|
||||
* Used for the initial chat load (issue #796) so the landing page and ``/chat``
|
||||
* subpage show the full window instead of only the newest page.
|
||||
*
|
||||
* @param {number} limit Page size (rows requested per API call).
|
||||
* @param {{ encrypted?: boolean, chatEnabled?: boolean, normaliseMessageLimit?: Function, maxPages?: number }} [options]
|
||||
* Retrieval flags, dependency hooks, and an optional page-count backstop.
|
||||
* @returns {Promise<Array<Object>>} All de-duplicated messages in the window.
|
||||
*/
|
||||
export async function fetchAllMessages(limit, options = {}) {
|
||||
const { maxPages = 200, ...fetchOptions } = options;
|
||||
const all = [];
|
||||
const seen = new Set();
|
||||
let before = 0;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop -- pages are inherently sequential (cursor depends on the prior page).
|
||||
const batch = await fetchMessages(limit, { ...fetchOptions, before });
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
let added = 0;
|
||||
let oldest = 0;
|
||||
for (const message of batch) {
|
||||
const id = message && message.id;
|
||||
if (id != null && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
all.push(message);
|
||||
added += 1;
|
||||
}
|
||||
const ts = Number(message && message.rx_time);
|
||||
if (Number.isFinite(ts) && (oldest === 0 || ts < oldest)) {
|
||||
oldest = ts;
|
||||
}
|
||||
}
|
||||
// A short page means the window is exhausted; no new rows (or no usable
|
||||
// cursor) means we cannot make further progress without looping forever.
|
||||
if (batch.length < limit || added === 0 || oldest === 0) break;
|
||||
before = oldest;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch neighbour information from the JSON API.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isMeshcoreProtocol } from './protocol-helpers.js';
|
||||
|
||||
/**
|
||||
* Parse the ``"SenderName: body"`` prefix that MeshCore embeds in channel
|
||||
* message text. MeshCore channel messages do not carry a sender node ID, so
|
||||
@@ -37,6 +39,24 @@ export function parseMeshcoreSenderPrefix(text) {
|
||||
return { senderName, bodyText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a candidate node belongs to the same protocol as the message that
|
||||
* referenced it. Node and message protocols are reduced to the two canonical
|
||||
* values (anything not explicitly MeshCore — including absent/unknown — is the
|
||||
* Meshtastic default), so a MeshCore message never matches a Meshtastic (or
|
||||
* unstamped) node and vice-versa. A ``null`` requested protocol disables the
|
||||
* filter, preserving the original protocol-agnostic behaviour for callers that
|
||||
* have no protocol context.
|
||||
*
|
||||
* @param {Object} node Candidate node record.
|
||||
* @param {string|null|undefined} protocol Protocol of the referencing message.
|
||||
* @returns {boolean} Whether the node may be matched for this protocol.
|
||||
*/
|
||||
function nodeMatchesProtocol(node, protocol) {
|
||||
if (protocol == null) return true;
|
||||
return isMeshcoreProtocol(protocol) === isMeshcoreProtocol(node && node.protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a node in the provided ``nodesById`` Map by its long name.
|
||||
*
|
||||
@@ -47,6 +67,13 @@ export function parseMeshcoreSenderPrefix(text) {
|
||||
* Both the snake_case (``long_name``) and camelCase (``longName``) property
|
||||
* variants are checked to accommodate different serialisation paths.
|
||||
*
|
||||
* When ``protocol`` is supplied, only nodes of that protocol are eligible.
|
||||
* Long names collide across protocols (a MeshCore and a Meshtastic node can
|
||||
* both be called "Timo"), so without this filter the scan would return whichever
|
||||
* node happens to come first in insertion order — letting a MeshCore message
|
||||
* quote a Meshtastic node. Filtering by the referencing message's protocol is
|
||||
* what keeps chat resolution protocol-correct.
|
||||
*
|
||||
* This is an O(n) scan over all nodes. For the typical node counts seen in
|
||||
* practice (hundreds) this is negligible; a long-name index is not maintained
|
||||
* in the client-side Map because insertions and lookups occur at different
|
||||
@@ -54,9 +81,12 @@ export function parseMeshcoreSenderPrefix(text) {
|
||||
*
|
||||
* @param {string} longName Long name to search for.
|
||||
* @param {Map<string, object>} nodesById Loaded node registry keyed by node ID.
|
||||
* @returns {object|null} The first matching node, or ``null`` when not found.
|
||||
* @param {string|null} [protocol] Protocol the matched node must belong to;
|
||||
* ``null``/omitted matches any protocol (legacy behaviour).
|
||||
* @returns {object|null} The first matching node of the requested protocol, or
|
||||
* ``null`` when not found.
|
||||
*/
|
||||
export function findNodeByLongName(longName, nodesById) {
|
||||
export function findNodeByLongName(longName, nodesById, protocol = null) {
|
||||
if (!longName || typeof longName !== 'string') return null;
|
||||
if (!(nodesById instanceof Map)) return null;
|
||||
const trimmed = longName.trim();
|
||||
@@ -69,6 +99,7 @@ export function findNodeByLongName(longName, nodesById) {
|
||||
|
||||
// First pass: exact match on trimmed candidate long names.
|
||||
for (const node of nodesById.values()) {
|
||||
if (!nodeMatchesProtocol(node, protocol)) continue;
|
||||
const raw = node.long_name ?? node.longName;
|
||||
if (typeof raw !== 'string') continue;
|
||||
if (raw.trim() === trimmed) return node;
|
||||
@@ -80,6 +111,7 @@ export function findNodeByLongName(longName, nodesById) {
|
||||
// prefix the node carries in the registry — e.g. @[Timo +] matching
|
||||
// a node whose long_name is "📺 Timo +".
|
||||
for (const node of nodesById.values()) {
|
||||
if (!nodeMatchesProtocol(node, protocol)) continue;
|
||||
const raw = node.long_name ?? node.longName;
|
||||
if (typeof raw !== 'string') continue;
|
||||
const stripped = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
@@ -89,6 +121,32 @@ export function findNodeByLongName(longName, nodesById) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic stand-in node for a chat name reference that no
|
||||
* same-protocol registry node matched.
|
||||
*
|
||||
* Carrying the referencing message's protocol keeps the rendered badge's colour
|
||||
* palette and protocol icon correct and — crucially — guarantees a MeshCore
|
||||
* message renders a MeshCore-stamped badge instead of borrowing a colliding
|
||||
* node from another protocol. This mirrors the protocol-stamped placeholder
|
||||
* the message hydrator builds for unknown senders
|
||||
* (``message-node-hydrator``), but is keyed on the visible name rather than a
|
||||
* node id because mentions/quotes reference nodes by name.
|
||||
*
|
||||
* The visible name is used as both the short and long name so the badge stays
|
||||
* legible (a bare ``long_name`` would render as a ``?`` placeholder).
|
||||
*
|
||||
* @param {string} name Visible name parsed from the message (mention/sender).
|
||||
* @param {string|null|undefined} protocol Protocol of the referencing message.
|
||||
* @returns {{short_name: string, long_name: string, protocol?: string}}
|
||||
* Synthetic node ready for badge rendering.
|
||||
*/
|
||||
export function buildSyntheticChatNode(name, protocol) {
|
||||
const node = { short_name: name, long_name: name };
|
||||
if (protocol != null) node.protocol = protocol;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a leading ``@[Name]`` mention from text if it looks like a reply.
|
||||
*
|
||||
|
||||
@@ -6533,6 +6533,81 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(scoped_since.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
# Regression for issue #796: more than MAX_QUERY_LIMIT messages inside the
|
||||
# seven-day window must all remain reachable. Before the fix the feed had
|
||||
# no upper-bound cursor, so paging stalled at the newest 1000 rows and every
|
||||
# older in-window message was invisible.
|
||||
it "exposes every in-window message through backward pagination (issue #796)" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
|
||||
cap = PotatoMesh::App::Queries::MAX_QUERY_LIMIT
|
||||
total = cap + 500
|
||||
# Seed more than one page of messages, all comfortably inside the
|
||||
# seven-day window, each with a distinct rx_time so the keyset cursor is
|
||||
# unambiguous.
|
||||
with_db do |db|
|
||||
db.transaction do
|
||||
total.times do |i|
|
||||
rx = now - 60 - i * 30
|
||||
db.execute(
|
||||
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[1000 + i, rx, Time.at(rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "msg #{i}"],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Walk the feed the way the dashboard client does: pull a page, then ask
|
||||
# for everything at-or-before the oldest row already seen. Without an
|
||||
# upper-bound cursor the server cannot return anything past the newest
|
||||
# `cap` rows, so the loop stalls and never reaches the older messages.
|
||||
seen = {}
|
||||
cursor = nil
|
||||
pages = 0
|
||||
loop do
|
||||
url = "/api/messages?limit=#{cap}"
|
||||
url += "&before=#{cursor}" if cursor
|
||||
get url
|
||||
expect(last_response).to be_ok
|
||||
rows = JSON.parse(last_response.body)
|
||||
# The per-request cap is unchanged: a single response never exceeds it.
|
||||
expect(rows.size).to be <= cap
|
||||
added = rows.reject { |row| seen.key?(row["id"]) }
|
||||
added.each { |row| seen[row["id"]] = true }
|
||||
pages += 1
|
||||
break if rows.size < cap # window exhausted
|
||||
break if added.empty? # no progress (unfixed server ignores `before`)
|
||||
break if pages >= 10 # hard safety bound against an infinite loop
|
||||
cursor = rows.map { |row| row["rx_time"] }.min
|
||||
end
|
||||
|
||||
expect(seen.size).to eq(total)
|
||||
end
|
||||
|
||||
it "treats a non-positive before cursor as absent (issue #796)" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES(?,?,?,?,?,?,?)",
|
||||
[42, now - 30, Time.at(now - 30).utc.iso8601, "!a", "!b", 0, "hi"],
|
||||
)
|
||||
end
|
||||
|
||||
# before=0 and before=-5 must be ignored (not used as a ceiling), so the
|
||||
# row still comes back rather than being filtered out by a bogus cursor.
|
||||
get "/api/messages?before=0"
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
|
||||
|
||||
get "/api/messages?before=-5"
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
|
||||
end
|
||||
|
||||
it "clamps an explicit since older than the seven-day floor up to the floor" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
@@ -677,6 +677,30 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
expect(scoped_ids).to include(101)
|
||||
expect(scoped_ids).to include(102)
|
||||
end
|
||||
|
||||
it "applies an inclusive before cursor and ignores a non-positive one (issue #796)" do
|
||||
with_db do |db|
|
||||
[10, 20, 30].each do |offset|
|
||||
rx = now - offset
|
||||
db.execute(
|
||||
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
|
||||
[200 + offset, rx, Time.at(rx).utc.iso8601, "!aabbccdd", "!ffffffff", 0, "m#{offset}"],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Inclusive ceiling: the row exactly at the cursor stays; newer rows drop.
|
||||
# This is what lets the client page backward by feeding the oldest rx_time
|
||||
# of each page as the next cursor without skipping boundary-second rows.
|
||||
paged = queries.query_messages(10, before: now - 20).map { |r| r["id"] }
|
||||
expect(paged).to include(220, 230)
|
||||
expect(paged).not_to include(210) # newer than the cursor
|
||||
expect(paged).not_to include(1) # base row at `now` is newer than the cursor
|
||||
|
||||
# A non-positive cursor is treated as "no cursor" — the default window.
|
||||
unbounded = queries.query_messages(10, before: 0).map { |r| r["id"] }
|
||||
expect(unbounded).to include(1, 210, 220, 230)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#query_telemetry" do
|
||||
|
||||
Reference in New Issue
Block a user