From cff89a8c88609a7dc6c59bfa5fae5b74026cd2ff Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Tue, 14 Oct 2025 20:56:42 +0200
Subject: [PATCH] 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
---
.../js/app/__tests__/chat-format.test.js | 126 ++++++++++++
web/public/assets/js/app/chat-format.js | 194 ++++++++++++++++++
web/public/assets/js/app/main.js | 23 ++-
3 files changed, 341 insertions(+), 2 deletions(-)
create mode 100644 web/public/assets/js/app/__tests__/chat-format.test.js
create mode 100644 web/public/assets/js/app/chat-format.js
diff --git a/web/public/assets/js/app/__tests__/chat-format.test.js b/web/public/assets/js/app/__tests__/chat-format.test.js
new file mode 100644
index 0000000..b6a41a7
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/chat-format.test.js
@@ -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');
+});
diff --git a/web/public/assets/js/app/chat-format.js b/web/public/assets/js/app/chat-format.js
new file mode 100644
index 0000000..9a0be0d
--- /dev/null
+++ b/web/public/assets/js/app/chat-format.js
@@ -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
+};
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index a1512b9..7f4138f 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -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} New node: ${longName}`;
+ const metadata = extractChatMessageMetadata(n);
+ const prefix = formatNodeAnnouncementPrefix({
+ timestamp: escapeHtml(ts),
+ frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
+ });
+ div.innerHTML = `${prefix} ${short} New node: ${longName}`;
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;
}