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; }