diff --git a/web/public/assets/js/app/__tests__/chat-format.test.js b/web/public/assets/js/app/__tests__/chat-format.test.js index b6a41a7..17d1649 100644 --- a/web/public/assets/js/app/__tests__/chat-format.test.js +++ b/web/public/assets/js/app/__tests__/chat-format.test.js @@ -19,6 +19,7 @@ import { extractChatMessageMetadata, formatChatMessagePrefix, formatChatChannelTag, + formatChatPresetTag, formatNodeAnnouncementPrefix, __test__ } from '../chat-format.js'; @@ -28,7 +29,13 @@ const { normalizeString, normalizeFrequency, normalizeFrequencySlot, - FREQUENCY_PLACEHOLDER + FREQUENCY_PLACEHOLDER, + resolveModemPresetCandidate, + normalizePresetString, + abbreviatePreset, + derivePresetInitials, + normalizePresetSlot, + PRESET_PLACEHOLDER } = __test__; test('extractChatMessageMetadata prefers explicit region_frequency and channel_name', () => { @@ -39,21 +46,32 @@ test('extractChatMessageMetadata prefers explicit region_frequency and channel_n channelName: 'Ignored' }; const result = extractChatMessageMetadata(payload); - assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel' }); + assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel', presetCode: null }); }); test('extractChatMessageMetadata falls back to LoRa metadata', () => { const payload = { lora_freq: 915, - channelName: 'SpecChannel' + channelName: 'SpecChannel', + modem_preset: 'MediumFast' }; const result = extractChatMessageMetadata(payload); - assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel' }); + assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel', presetCode: 'MF' }); }); test('extractChatMessageMetadata returns null metadata for invalid input', () => { - assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null }); - assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null }); + assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null, presetCode: null }); + assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null, presetCode: null }); +}); + +test('extractChatMessageMetadata inspects nested node payloads for modem presets', () => { + const payload = { + node: { + modem_preset: 'ShortTurbo' + } + }; + const result = extractChatMessageMetadata(payload); + assert.equal(result.presetCode, 'ST'); }); test('firstNonNull returns the first non-null candidate', () => { @@ -107,6 +125,11 @@ test('formatChatChannelTag wraps channel names after the short name slot', () => ); }); +test('formatChatPresetTag renders preset hints with placeholders', () => { + assert.equal(formatChatPresetTag({ presetCode: 'MF' }), '[MF]'); + assert.equal(formatChatPresetTag({ presetCode: null }), `[${PRESET_PLACEHOLDER}]`); +}); + test('formatNodeAnnouncementPrefix includes optional frequency bracket', () => { assert.equal( formatNodeAnnouncementPrefix({ timestamp: '12:34:56', frequency: '868' }), @@ -124,3 +147,32 @@ test('normalizeFrequencySlot returns placeholder when frequency is missing', () assert.equal(normalizeFrequencySlot(undefined), FREQUENCY_PLACEHOLDER); assert.equal(normalizeFrequencySlot('915'), '915'); }); + +test('resolveModemPresetCandidate walks nested payloads', () => { + const nested = { node: { modemPreset: 'LongFast' } }; + assert.equal(resolveModemPresetCandidate(nested), 'LongFast'); +}); + +test('normalizePresetString trims strings and ignores empties', () => { + assert.equal(normalizePresetString(' MediumSlow '), 'MediumSlow'); + assert.equal(normalizePresetString(' '), null); + assert.equal(normalizePresetString(null), null); +}); + +test('abbreviatePreset maps known presets to codes', () => { + assert.equal(abbreviatePreset('VeryLongSlow'), 'VL'); + assert.equal(abbreviatePreset('customPreset'), 'CP'); + assert.equal(abbreviatePreset('X'), 'X?'); +}); + +test('derivePresetInitials falls back to segmented tokens', () => { + assert.equal(derivePresetInitials('Long Moderate'), 'LM'); + assert.equal(derivePresetInitials('ShortTurbo'), 'ST'); + assert.equal(derivePresetInitials('Z'), 'Z?'); +}); + +test('normalizePresetSlot enforces placeholders and uppercase output', () => { + assert.equal(normalizePresetSlot('mf'), 'MF'); + assert.equal(normalizePresetSlot(''), PRESET_PLACEHOLDER); + assert.equal(normalizePresetSlot(null), PRESET_PLACEHOLDER); +}); diff --git a/web/public/assets/js/app/chat-format.js b/web/public/assets/js/app/chat-format.js index 9a0be0d..8bd25ef 100644 --- a/web/public/assets/js/app/chat-format.js +++ b/web/public/assets/js/app/chat-format.js @@ -18,12 +18,12 @@ * 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 }} + * @returns {{ frequency: string|null, channelName: string|null, presetCode: string|null }} * Normalized metadata values. */ export function extractChatMessageMetadata(message) { if (!message || typeof message !== 'object') { - return { frequency: null, channelName: null }; + return { frequency: null, channelName: null, presetCode: null }; } const frequency = normalizeFrequency( @@ -40,7 +40,10 @@ export function extractChatMessageMetadata(message) { firstNonNull(message.channel_name, message.channelName) ); - return { frequency, channelName }; + const modemPreset = normalizePresetString(resolveModemPresetCandidate(message)); + const presetCode = modemPreset ? abbreviatePreset(modemPreset) : null; + + return { frequency, channelName, presetCode }; } /** @@ -92,6 +95,17 @@ export function formatNodeAnnouncementPrefix({ timestamp, frequency }) { return `[${ts}][${freq}]`; } +/** + * Render the preset hint bracket inserted between the prefix and short name. + * + * @param {{ presetCode: string|null }} params Normalized preset abbreviation. + * @returns {string} HTML-ready bracket slot. + */ +export function formatChatPresetTag({ presetCode }) { + const slot = normalizePresetSlot(presetCode); + return `[${slot}]`; +} + /** * Produce a consistently formatted frequency slot for chat prefixes. * @@ -119,6 +133,28 @@ function normalizeFrequencySlot(value) { */ const FREQUENCY_PLACEHOLDER = '   '; +/** + * HTML placeholder for missing preset abbreviations. + * @type {string} + */ +const PRESET_PLACEHOLDER = '  '; + +/** + * Canonical preset abbreviations keyed by a normalized preset token. + * @type {Record} + */ +const PRESET_ABBREVIATIONS = { + verylongslow: 'VL', + longslow: 'LS', + longmoderate: 'LM', + longfast: 'LF', + mediumslow: 'MS', + mediumfast: 'MF', + shortslow: 'SS', + shortfast: 'SF', + shortturbo: 'ST', +}; + /** * Return the first value in ``candidates`` that is not ``null`` or ``undefined``. * @@ -182,6 +218,126 @@ function normalizeFrequency(value) { return null; } +/** + * Resolve a modem preset candidate from the provided source object. + * + * @param {*} source Source payload potentially containing modem metadata. + * @param {Set} [visited] Visited references to avoid recursion loops. + * @returns {*|null} Raw modem preset candidate. + */ +function resolveModemPresetCandidate(source, visited = new Set()) { + if (!source || typeof source !== 'object') { + return null; + } + if (visited.has(source)) { + return null; + } + visited.add(source); + + const candidate = firstNonNull( + source.modemPreset, + source.modem_preset, + source.modempreset, + source.ModemPreset + ); + if (candidate != null) { + return candidate; + } + + if (source.node && typeof source.node === 'object') { + const nested = resolveModemPresetCandidate(source.node, visited); + if (nested != null) { + return nested; + } + } + + return null; +} + +/** + * Convert arbitrary preset input to a trimmed string. + * + * @param {*} value Raw preset candidate. + * @returns {string|null} Clean preset string. + */ +function normalizePresetString(value) { + if (value == null) return null; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === 'number') { + return String(value); + } + return null; +} + +/** + * Produce a two-character abbreviation for a modem preset. + * + * @param {string} preset Normalized preset string. + * @returns {string|null} Uppercase abbreviation or ``null``. + */ +function abbreviatePreset(preset) { + if (!preset) { + return null; + } + const token = preset.replace(/[^A-Za-z]/g, '').toLowerCase(); + if (token && PRESET_ABBREVIATIONS[token]) { + return PRESET_ABBREVIATIONS[token]; + } + return derivePresetInitials(preset); +} + +/** + * Generate fallback initials for unmapped presets. + * + * @param {string} preset Raw preset string. + * @returns {string|null} Derived initials. + */ +function derivePresetInitials(preset) { + if (!preset) { + return null; + } + const spaced = preset.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + const tokens = spaced + .split(/[\s_-]+/) + .map(part => part.replace(/[^A-Za-z]/g, '')) + .filter(Boolean); + if (tokens.length === 0) { + return null; + } + if (tokens.length === 1) { + const upper = tokens[0].toUpperCase(); + if (upper.length >= 2) { + return upper.slice(0, 2); + } + if (upper.length === 1) { + return `${upper}?`; + } + return null; + } + const initials = tokens.map(part => part[0].toUpperCase()); + if (initials.length >= 2) { + return `${initials[0]}${initials[1]}`; + } + return null; +} + +/** + * Normalise the preset slot contents for the bracket display. + * + * @param {*} value Raw preset code. + * @returns {string} HTML-ready preset slot. + */ +function normalizePresetSlot(value) { + if (value == null) { + return PRESET_PLACEHOLDER; + } + const trimmed = String(value).trim().toUpperCase(); + return trimmed.length > 0 ? trimmed.slice(0, 2) : PRESET_PLACEHOLDER; +} + export const __test__ = { firstNonNull, normalizeString, @@ -190,5 +346,11 @@ export const __test__ = { formatNodeAnnouncementPrefix, normalizeFrequencySlot, FREQUENCY_PLACEHOLDER, - formatChatChannelTag + formatChatChannelTag, + resolveModemPresetCandidate, + normalizePresetString, + abbreviatePreset, + derivePresetInitials, + normalizePresetSlot, + PRESET_PLACEHOLDER }; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 8f6319f..dcf0951 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -34,7 +34,8 @@ import { createMessageNodeHydrator } from './message-node-hydrator.js'; import { extractChatMessageMetadata, formatChatMessagePrefix, - formatNodeAnnouncementPrefix + formatNodeAnnouncementPrefix, + formatChatPresetTag } from './chat-format.js'; import { initializeInstanceSelector } from './instance-selector.js'; import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js'; @@ -2333,10 +2334,11 @@ let messagesById = new Map(); timestamp: escapeHtml(ts), frequency: metadata.frequency ? escapeHtml(metadata.frequency) : '' }); + const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode }); const longNameDisplay = longName != null ? String(longName) : ''; const shortHtml = renderShortHtml(shortName, role, longNameDisplay, nodeData || metadataSource || {}); div.className = 'chat-entry-node'; - div.innerHTML = `${prefix} ${shortHtml} ${messageHtml}`; + div.innerHTML = `${prefix}${presetTag} ${shortHtml} ${messageHtml}`; return div; } @@ -2619,8 +2621,9 @@ let messagesById = new Map(); timestamp: escapeHtml(ts), frequency: metadata.frequency ? escapeHtml(metadata.frequency) : '' }); + const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode }); div.className = 'chat-entry-msg'; - div.innerHTML = `${prefix} ${short} ${text}`; + div.innerHTML = `${prefix}${presetTag} ${short} ${text}`; return div; }