diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index 1a7fba9..0896bef 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -184,3 +184,68 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () = const lastEntry = model.logEntries[model.logEntries.length - 1]; assert.equal(lastEntry.neighborId, neighborId); }); + +test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => { + const regularMessages = fixtureMessages().filter(message => !message.encrypted); + const encryptedOnly = [ + { id: 'log-only', encrypted: true, rx_time: NOW - 3, channel: 7 } + ]; + const model = buildChatTabModel({ + nodes: [], + messages: regularMessages, + logOnlyMessages: encryptedOnly, + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED); + assert.equal(encryptedEntries.length, 1); + assert.equal(encryptedEntries[0]?.message?.id, 'log-only'); + + const channelMessageIds = model.channels.reduce((acc, channel) => { + if (!channel || !Array.isArray(channel.entries)) { + return acc; + } + for (const entry of channel.entries) { + if (entry && entry.message && entry.message.id) { + acc.push(entry.message.id); + } + } + return acc; + }, []); + assert.ok(!channelMessageIds.includes('log-only')); +}); + +test('buildChatTabModel de-duplicates encrypted messages across feeds', () => { + const duplicateMessage = { id: 'dup', encrypted: true, rx_time: NOW - 4 }; + const model = buildChatTabModel({ + nodes: [], + messages: [duplicateMessage], + logOnlyMessages: [duplicateMessage], + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED); + assert.equal(encryptedEntries.length, 1); + assert.equal(encryptedEntries[0]?.message?.id, 'dup'); +}); + +test('buildChatTabModel ignores plaintext log-only entries', () => { + const logOnlyMessages = [ + { id: 'plain', encrypted: false, rx_time: NOW - 5 }, + { id: 'enc', encrypted: true, rx_time: NOW - 4 } + ]; + + const model = buildChatTabModel({ + nodes: [], + messages: [], + logOnlyMessages, + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED); + assert.equal(encryptedEntries.length, 1); + assert.equal(encryptedEntries[0]?.message?.id, 'enc'); +}); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 40940c7..8d205c3 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -54,6 +54,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({ * positions?: Array, * neighbors?: Array, * messages?: Array, + * logOnlyMessages?: Array, * nowSeconds: number, * windowSeconds: number, * maxChannelIndex?: number, @@ -70,6 +71,7 @@ export function buildChatTabModel({ positions = [], neighbors = [], messages = [], + logOnlyMessages = [], nowSeconds, windowSeconds, maxChannelIndex = MAX_CHANNEL_INDEX, @@ -128,13 +130,20 @@ export function buildChatTabModel({ logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: neighborEntry, nodeId, nodeNum, neighborId }); } + const encryptedLogEntries = []; + const encryptedLogKeys = new Set(); + for (const message of messages || []) { if (!message) continue; const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso); if (ts == null || ts < cutoff) continue; if (message.encrypted) { - logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message }); + const key = buildEncryptedMessageKey(message); + if (!encryptedLogKeys.has(key)) { + encryptedLogKeys.add(key); + encryptedLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message }); + } continue; } @@ -178,6 +187,23 @@ export function buildChatTabModel({ bucket.entries.push({ ts, message }); } + const extraLogMessages = Array.isArray(logOnlyMessages) ? logOnlyMessages : []; + for (const message of extraLogMessages) { + if (!message || !message.encrypted) continue; + const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso); + if (ts == null || ts < cutoff) continue; + const key = buildEncryptedMessageKey(message); + if (encryptedLogKeys.has(key)) { + continue; + } + encryptedLogKeys.add(key); + encryptedLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message }); + } + + if (encryptedLogEntries.length > 0) { + logEntries.push(...encryptedLogEntries); + } + logEntries.sort((a, b) => a.ts - b.ts); let hasPrimaryBucket = false; @@ -213,6 +239,57 @@ export function buildChatTabModel({ return { logEntries, channels }; } +/** + * Build a stable key for encrypted message de-duplication when merging feeds. + * + * @param {?Object} message Chat message payload. + * @returns {string} Stable deduplication key. + */ +function buildEncryptedMessageKey(message) { + if (!message || typeof message !== 'object') { + return 'encrypted:unknown'; + } + const rawId = pickFirstPropertyValue(message, ['id', 'packet_id', 'packetId']); + if (rawId != null && rawId !== '') { + const id = String(rawId).trim(); + if (id) { + return `encrypted:id:${id}`; + } + } + const rx = pickFirstPropertyValue(message, ['rx_time', 'rxTime', 'rx_iso', 'rxIso']); + const fromId = pickFirstPropertyValue(message, ['from_id', 'fromId']); + const toId = pickFirstPropertyValue(message, ['to_id', 'toId']); + const replyId = pickFirstPropertyValue(message, ['reply_id', 'replyId']); + return `encrypted:fallback:${String(rx ?? '')}|${String(fromId ?? '')}|${String(toId ?? '')}|${String(replyId ?? '')}`; +} + +/** + * Retrieve the first present property value from the provided source object. + * + * @param {?Object} source Candidate data source. + * @param {Array} keys Preferred property order. + * @returns {*|null} First matching property value, otherwise null. + */ +function pickFirstPropertyValue(source, keys) { + if (!source || typeof source !== 'object' || !Array.isArray(keys)) { + return null; + } + for (const key of keys) { + if (typeof key !== 'string' || key.length === 0) { + continue; + } + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + const value = source[key]; + if (value == null || value === '') { + continue; + } + return value; + } + return null; +} + /** * Extract a canonical node identifier from a payload when available. * diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 458e44a..d9db049 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -133,6 +133,8 @@ export function initializeApp(config) { /** @type {Array} */ let allMessages = []; /** @type {Array} */ + let allEncryptedMessages = []; + /** @type {Array} */ let allTelemetryEntries = []; /** @type {Array} */ let allPositionEntries = []; @@ -2752,6 +2754,7 @@ let messagesById = new Map(); * @param {{ * nodes?: Array, * messages?: Array, + * encryptedMessages?: Array, * telemetryEntries?: Array, * positionEntries?: Array, * neighborEntries?: Array, @@ -2762,13 +2765,18 @@ let messagesById = new Map(); function renderChatLog({ nodes = [], messages = [], + encryptedMessages = [], telemetryEntries = [], positionEntries = [], neighborEntries = [], filterQuery = '' }) { if (!CHAT_ENABLED || !chatEl) return; - messagesById = buildMessageIndex(messages); + const combinedMessages = Array.isArray(messages) ? [...messages] : []; + if (Array.isArray(encryptedMessages) && encryptedMessages.length > 0) { + combinedMessages.push(...encryptedMessages); + } + messagesById = buildMessageIndex(combinedMessages); const nowSeconds = Math.floor(Date.now() / 1000); const { logEntries, channels } = buildChatTabModel({ nodes, @@ -2776,6 +2784,7 @@ let messagesById = new Map(); positions: positionEntries, neighbors: neighborEntries, messages, + logOnlyMessages: encryptedMessages, nowSeconds, windowSeconds: CHAT_RECENT_WINDOW_SECONDS, maxChannelIndex: MAX_CHANNEL_INDEX, @@ -3047,12 +3056,18 @@ let messagesById = new Map(); * Fetch recent messages from the JSON API. * * @param {number} [limit=NODE_LIMIT] Maximum number of rows. + * @param {{ encrypted?: boolean }} [options] Optional retrieval flags. * @returns {Promise>} Parsed message payloads. */ - async function fetchMessages(limit = MESSAGE_LIMIT) { + async function fetchMessages(limit = MESSAGE_LIMIT, options = {}) { if (!CHAT_ENABLED) return []; const safeLimit = normaliseMessageLimit(limit); - const r = await fetch(`/api/messages?limit=${safeLimit}`, { cache: 'no-store' }); + const params = new URLSearchParams({ limit: String(safeLimit) }); + if (options && options.encrypted) { + params.set('encrypted', 'true'); + } + const query = params.toString(); + const r = await fetch(`/api/messages?${query}`, { cache: 'no-store' }); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); } @@ -3640,6 +3655,7 @@ let messagesById = new Map(); renderChatLog({ nodes: allNodes, messages: allMessages, + encryptedMessages: allEncryptedMessages, telemetryEntries: allTelemetryEntries, positionEntries: allPositionEntries, neighborEntries: allNeighbors, @@ -3691,12 +3707,17 @@ let messagesById = new Map(); console.warn('position refresh failed; continuing without updates', err); return []; }); - const [nodes, positions, neighborTuples, messages, telemetryEntries] = await Promise.all([ + const encryptedMessagesPromise = fetchMessages(MESSAGE_LIMIT, { encrypted: true }).catch(err => { + console.warn('encrypted message refresh failed; continuing without encrypted entries', err); + return []; + }); + const [nodes, positions, neighborTuples, messages, telemetryEntries, encryptedMessages] = await Promise.all([ fetchNodes(), positionsPromise, neighborPromise, fetchMessages(MESSAGE_LIMIT), telemetryPromise, + encryptedMessagesPromise ]); nodes.forEach(applyNodeNameFallback); mergePositionsIntoNodes(nodes, positions); @@ -3704,8 +3725,12 @@ let messagesById = new Map(); mergeTelemetryIntoNodes(nodes, telemetryEntries); allNodes = nodes; rebuildNodeIndex(allNodes); - const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById); + const [chatMessages, encryptedChatMessages] = await Promise.all([ + messageNodeHydrator.hydrate(messages, nodesById), + messageNodeHydrator.hydrate(encryptedMessages, nodesById) + ]); allMessages = Array.isArray(chatMessages) ? chatMessages : []; + allEncryptedMessages = Array.isArray(encryptedChatMessages) ? encryptedChatMessages : []; allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : []; allPositionEntries = Array.isArray(positions) ? positions : []; allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];