Display message frequency and channel in chat log (#339)

* Display message frequency and channel in chat log

* Ensure chat prefixes display consistent metadata brackets

* Ensure chat prefixes show non-breaking frequency placeholder

* Adjust chat channel tag placement
This commit is contained in:
l5y
2025-10-14 20:56:42 +02:00
committed by GitHub
parent 26c1366412
commit cff89a8c88
3 changed files with 341 additions and 2 deletions
@@ -0,0 +1,126 @@
/*
* 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 {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatNodeAnnouncementPrefix,
__test__
} from '../chat-format.js';
const {
firstNonNull,
normalizeString,
normalizeFrequency,
normalizeFrequencySlot,
FREQUENCY_PLACEHOLDER
} = __test__;
test('extractChatMessageMetadata prefers explicit region_frequency and channel_name', () => {
const payload = {
region_frequency: 868,
channel_name: ' Test Channel ',
lora_freq: 915,
channelName: 'Ignored'
};
const result = extractChatMessageMetadata(payload);
assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel' });
});
test('extractChatMessageMetadata falls back to LoRa metadata', () => {
const payload = {
lora_freq: 915,
channelName: 'SpecChannel'
};
const result = extractChatMessageMetadata(payload);
assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel' });
});
test('extractChatMessageMetadata returns null metadata for invalid input', () => {
assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null });
assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null });
});
test('firstNonNull returns the first non-null candidate', () => {
assert.equal(firstNonNull(null, undefined, '', 'value'), '');
assert.equal(firstNonNull(undefined, null), null);
});
test('normalizeString trims strings and rejects empties', () => {
assert.equal(normalizeString(' Spec '), 'Spec');
assert.equal(normalizeString(' '), null);
assert.equal(normalizeString(123), '123');
assert.equal(normalizeString(Number.POSITIVE_INFINITY), null);
});
test('normalizeFrequency handles numeric and string inputs', () => {
assert.equal(normalizeFrequency(915), '915');
assert.equal(normalizeFrequency(868.125), '868.125');
assert.equal(normalizeFrequency(' 868MHz '), '868');
assert.equal(normalizeFrequency('n/a'), 'n/a');
assert.equal(normalizeFrequency(-5), null);
assert.equal(normalizeFrequency(null), null);
});
test('formatChatMessagePrefix preserves bracket placeholders', () => {
assert.equal(
formatChatMessagePrefix({ timestamp: '11:46:48', frequency: '868' }),
'[11:46:48][868]'
);
assert.equal(
formatChatMessagePrefix({ timestamp: '16:19:19', frequency: null }),
`[16:19:19][${FREQUENCY_PLACEHOLDER}]`
);
assert.equal(
formatChatMessagePrefix({ timestamp: '09:00:00', frequency: '' }),
`[09:00:00][${FREQUENCY_PLACEHOLDER}]`
);
});
test('formatChatChannelTag wraps channel names after the short name slot', () => {
assert.equal(
formatChatChannelTag({ channelName: 'TEST' }),
'[TEST]'
);
assert.equal(
formatChatChannelTag({ channelName: '' }),
'[]'
);
assert.equal(
formatChatChannelTag({ channelName: null }),
'[]'
);
});
test('formatNodeAnnouncementPrefix includes optional frequency bracket', () => {
assert.equal(
formatNodeAnnouncementPrefix({ timestamp: '12:34:56', frequency: '868' }),
'[12:34:56][868]'
);
assert.equal(
formatNodeAnnouncementPrefix({ timestamp: '01:02:03', frequency: null }),
`[01:02:03][${FREQUENCY_PLACEHOLDER}]`
);
});
test('normalizeFrequencySlot returns placeholder when frequency is missing', () => {
assert.equal(normalizeFrequencySlot(null), FREQUENCY_PLACEHOLDER);
assert.equal(normalizeFrequencySlot(''), FREQUENCY_PLACEHOLDER);
assert.equal(normalizeFrequencySlot(undefined), FREQUENCY_PLACEHOLDER);
assert.equal(normalizeFrequencySlot('915'), '915');
});
+194
View File
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
/**
* Extract channel metadata from a message payload for chat display.
*
* @param {Object} message Raw message payload from the API.
* @returns {{ frequency: string|null, channelName: string|null }}
* Normalized metadata values.
*/
export function extractChatMessageMetadata(message) {
if (!message || typeof message !== 'object') {
return { frequency: null, channelName: null };
}
const frequency = normalizeFrequency(
firstNonNull(
message.region_frequency,
message.regionFrequency,
message.lora_freq,
message.loraFreq,
message.frequency
)
);
const channelName = normalizeString(
firstNonNull(message.channel_name, message.channelName)
);
return { frequency, channelName };
}
/**
* Produce the formatted prefix for a chat message entry.
*
* Timestamp and frequency will each be wrapped in square brackets. Missing
* metadata values result in empty brackets (with the frequency replaced by the
* configured placeholder) to preserve the positional layout expected by
* operators.
*
* @param {{
* timestamp: string,
* frequency: string|null
* }} params Normalised and escaped display strings.
* @returns {string} Prefix string suitable for HTML insertion.
*/
export function formatChatMessagePrefix({ timestamp, frequency }) {
const ts = typeof timestamp === 'string' ? timestamp : '';
const freq = normalizeFrequencySlot(frequency);
return `[${ts}][${freq}]`;
}
/**
* Render the channel tag that follows the short name in a chat message entry.
*
* Empty channel names remain blank within the brackets, mirroring the original
* UI behaviour that reserves the slot without introducing placeholder text.
*
* @param {{ channelName: string|null }} params Normalised and escaped display strings.
* @returns {string} Channel tag suitable for HTML insertion.
*/
export function formatChatChannelTag({ channelName }) {
const channel = typeof channelName === 'string' ? channelName : channelName == null ? '' : String(channelName);
return `[${channel}]`;
}
/**
* Create the formatted prefix for node announcements in the chat log.
*
* Both the timestamp and the optional frequency will be wrapped in brackets,
* mirroring the chat message display while omitting the channel indicator.
*
* @param {{ timestamp: string, frequency: string|null }} params Display strings.
* @returns {string} Prefix string suitable for HTML insertion.
*/
export function formatNodeAnnouncementPrefix({ timestamp, frequency }) {
const ts = typeof timestamp === 'string' ? timestamp : '';
const freq = normalizeFrequencySlot(frequency);
return `[${ts}][${freq}]`;
}
/**
* Produce a consistently formatted frequency slot for chat prefixes.
*
* A missing or empty frequency is rendered as three HTML non-breaking spaces to
* ensure the UI maintains its expected alignment while clearly indicating the
* absence of data.
*
* @param {*} value Frequency value that has already been escaped for HTML.
* @returns {string} Frequency slot suitable for prefix rendering.
*/
function normalizeFrequencySlot(value) {
if (value == null) {
return FREQUENCY_PLACEHOLDER;
}
if (typeof value === 'string') {
return value.length > 0 ? value : FREQUENCY_PLACEHOLDER;
}
const strValue = String(value);
return strValue.length > 0 ? strValue : FREQUENCY_PLACEHOLDER;
}
/**
* HTML entity sequence inserted when a frequency is unavailable.
* @type {string}
*/
const FREQUENCY_PLACEHOLDER = '   ';
/**
* Return the first value in ``candidates`` that is not ``null`` or ``undefined``.
*
* @param {...*} candidates Candidate values.
* @returns {*} First present value or ``null`` when missing.
*/
function firstNonNull(...candidates) {
for (const value of candidates) {
if (value !== null && value !== undefined) {
return value;
}
}
return null;
}
/**
* Normalise potential channel name values to trimmed strings.
*
* @param {*} value Raw value.
* @returns {string|null} Sanitised channel name.
*/
function normalizeString(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return String(value);
}
return null;
}
/**
* Convert various frequency representations into clean strings.
*
* @param {*} value Raw frequency value.
* @returns {string|null} Frequency in MHz as a string, when available.
*/
function normalizeFrequency(value) {
if (value == null) return null;
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const numericMatch = trimmed.match(/\d+(?:\.\d+)?/);
if (numericMatch) {
const parsed = Number(numericMatch[0]);
if (Number.isFinite(parsed) && parsed > 0) {
return Number.isInteger(parsed) ? String(Math.trunc(parsed)) : String(parsed);
}
}
return trimmed;
}
return null;
}
export const __test__ = {
firstNonNull,
normalizeString,
normalizeFrequency,
formatChatMessagePrefix,
formatNodeAnnouncementPrefix,
normalizeFrequencySlot,
FREQUENCY_PLACEHOLDER,
formatChatChannelTag
};
+21 -2
View File
@@ -19,6 +19,12 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatNodeAnnouncementPrefix
} from './chat-format.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -2069,7 +2075,12 @@ export function initializeApp(config) {
div.className = 'chat-entry-node';
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
const longName = escapeHtml(n.long_name || '');
div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
const metadata = extractChatMessageMetadata(n);
const prefix = formatNodeAnnouncementPrefix({
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
div.innerHTML = `${prefix} ${short} <em>New node: ${longName}</em>`;
return div;
}
@@ -2084,8 +2095,16 @@ export function initializeApp(config) {
const ts = formatTime(new Date(m.rx_time * 1000));
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
const text = escapeHtml(m.text || '');
const metadata = extractChatMessageMetadata(m);
const prefix = formatChatMessagePrefix({
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const channelTag = formatChatChannelTag({
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
});
div.className = 'chat-entry-msg';
div.innerHTML = `[${ts}] ${short} ${text}`;
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
return div;
}