diff --git a/data/mesh_ingestor/daemon.py b/data/mesh_ingestor/daemon.py index 25f6a2f..01f469c 100644 --- a/data/mesh_ingestor/daemon.py +++ b/data/mesh_ingestor/daemon.py @@ -34,6 +34,7 @@ _RECEIVE_TOPICS = ( "meshtastic.receive.NODEINFO_APP", "meshtastic.receive.NEIGHBORINFO_APP", "meshtastic.receive.TEXT_MESSAGE_APP", + "meshtastic.receive.REACTION_APP", "meshtastic.receive.TELEMETRY_APP", ) diff --git a/data/mesh_ingestor/handlers.py b/data/mesh_ingestor/handlers.py index d65d636..2e7cc75 100644 --- a/data/mesh_ingestor/handlers.py +++ b/data/mesh_ingestor/handlers.py @@ -17,7 +17,10 @@ from __future__ import annotations import base64 +import contextlib +import importlib import json +import sys import time from collections.abc import Mapping @@ -1051,15 +1054,83 @@ def store_packet_dict(packet: Mapping) -> None: store_neighborinfo_packet(packet, decoded) return - text = _first(decoded, "payload.text", "text", default=None) + text = _first(decoded, "payload.text", "text", "data.text", default=None) encrypted = _first(decoded, "payload.encrypted", "encrypted", default=None) if encrypted is None: encrypted = _first(packet, "encrypted", default=None) - if not text and not encrypted: + reply_id_raw = _first( + decoded, + "payload.replyId", + "payload.reply_id", + "data.replyId", + "data.reply_id", + "replyId", + "reply_id", + default=None, + ) + reply_id = _coerce_int(reply_id_raw) + emoji_raw = _first( + decoded, + "payload.emoji", + "data.emoji", + "emoji", + default=None, + ) + emoji = None + if emoji_raw is not None: + try: + emoji_text = str(emoji_raw) + except Exception: + emoji_text = None + else: + emoji_text = emoji_text.strip() + if emoji_text: + emoji = emoji_text + + encrypted_flag = _is_encrypted_flag(encrypted) + if not any([text, encrypted_flag, emoji is not None, reply_id is not None]): return - if portnum and portnum not in {"1", "TEXT_MESSAGE_APP"}: - return + allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"} + allowed_port_ints = {1} + + reaction_port_candidates: set[int] = set() + for module_name in ( + "meshtastic.portnums_pb2", + "meshtastic.protobuf.portnums_pb2", + ): + module = sys.modules.get(module_name) + if module is None: + with contextlib.suppress(ModuleNotFoundError): + module = importlib.import_module(module_name) + if module is None: + continue + portnum_enum = getattr(module, "PortNum", None) + value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None + if callable(value_lookup): + with contextlib.suppress(Exception): + candidate = _coerce_int(value_lookup("REACTION_APP")) + if candidate is not None: + reaction_port_candidates.add(candidate) + constant_value = getattr(module, "REACTION_APP", None) + candidate = _coerce_int(constant_value) + if candidate is not None: + reaction_port_candidates.add(candidate) + + for candidate in reaction_port_candidates: + allowed_port_ints.add(candidate) + allowed_port_values.add(str(candidate)) + + is_reaction_packet = portnum == "REACTION_APP" or ( + reply_id is not None and emoji is not None + ) + if is_reaction_packet and portnum_int is not None: + allowed_port_ints.add(portnum_int) + allowed_port_values.add(str(portnum_int)) + + if portnum and portnum not in allowed_port_values: + if portnum_int not in allowed_port_ints: + return channel = _first(decoded, "channel", default=None) if channel is None: @@ -1096,7 +1167,8 @@ def store_packet_dict(packet: Mapping) -> None: to_id_normalized = str(to_id).strip() if to_id is not None else "" if ( - channel == 0 + not is_reaction_packet + and channel == 0 and not encrypted_flag and to_id_normalized and to_id_normalized.lower() != "^all" @@ -1124,6 +1196,8 @@ def store_packet_dict(packet: Mapping) -> None: "snr": float(snr) if snr is not None else None, "rssi": int(rssi) if rssi is not None else None, "hop_limit": int(hop) if hop is not None else None, + "reply_id": reply_id, + "emoji": emoji, } channel_name_value = None diff --git a/data/messages.sql b/data/messages.sql index 6aaecc9..2946459 100644 --- a/data/messages.sql +++ b/data/messages.sql @@ -27,7 +27,9 @@ CREATE TABLE IF NOT EXISTS messages ( hop_limit INTEGER, lora_freq INTEGER, modem_preset TEXT, - channel_name TEXT + channel_name TEXT, + reply_id INTEGER, + emoji TEXT ); CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time); @@ -35,3 +37,4 @@ CREATE INDEX IF NOT EXISTS idx_messages_from_id ON messages(from_id); CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id); CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel); CREATE INDEX IF NOT EXISTS idx_messages_portnum ON messages(portnum); +CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id); diff --git a/data/migrations/20250310_add_message_reply_and_emoji_columns.sql b/data/migrations/20250310_add_message_reply_and_emoji_columns.sql new file mode 100644 index 0000000..969e9e2 --- /dev/null +++ b/data/migrations/20250310_add_message_reply_and_emoji_columns.sql @@ -0,0 +1,20 @@ +-- 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. +-- +-- Extend the messages table to capture reply relationships and emoji reactions. +BEGIN; +ALTER TABLE messages ADD COLUMN reply_id INTEGER; +ALTER TABLE messages ADD COLUMN emoji TEXT; +CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id); +COMMIT; diff --git a/tests/test_mesh.py b/tests/test_mesh.py index d7e3b55..39d08e4 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -602,11 +602,53 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch): assert payload["hop_limit"] == 3 assert payload["snr"] == pytest.approx(1.25) assert payload["rssi"] == -70 + assert payload["reply_id"] is None + assert payload["emoji"] is None assert payload["lora_freq"] == 868 assert payload["modem_preset"] == "MediumFast" assert priority == mesh._MESSAGE_POST_PRIORITY +def test_store_packet_dict_posts_reaction_message(mesh_module, monkeypatch): + mesh = mesh_module + captured = [] + monkeypatch.setattr( + mesh, + "_queue_post_json", + lambda path, payload, *, priority: captured.append((path, payload, priority)), + ) + + packet = { + "id": 999, + "rxTime": 1_700_100_000, + "fromId": "!reply", + "toId": "!root", + "decoded": { + "portnum": "REACTION_APP", + "data": { + "reply_id": "123", + "emoji": " 👍 ", + }, + }, + } + + mesh.store_packet_dict(packet) + + assert captured, "Expected POST to be triggered for reaction message" + path, payload, priority = captured[0] + assert path == "/api/messages" + assert payload["id"] == 999 + assert payload["from_id"] == "!reply" + assert payload["to_id"] == "!root" + assert payload["portnum"] == "REACTION_APP" + assert payload["text"] is None + assert payload["reply_id"] == 123 + assert payload["emoji"] == "👍" + assert payload["rx_time"] == 1_700_100_000 + assert payload["rx_iso"] == mesh._iso(1_700_100_000) + assert priority == mesh._MESSAGE_POST_PRIORITY + + def test_store_packet_dict_posts_position(mesh_module, monkeypatch): mesh = mesh_module captured = [] @@ -1564,6 +1606,8 @@ def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys assert payload["channel"] == 5 assert payload["text"] == "hi" assert payload["encrypted"] is None + assert payload["reply_id"] is None + assert payload["emoji"] is None assert priority == mesh._MESSAGE_POST_PRIORITY log_output = capsys.readouterr().out @@ -1601,6 +1645,8 @@ def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch): assert payload["text"] is None assert payload["from_id"] == 2988082812 assert payload["to_id"] == "!receiver" + assert payload["reply_id"] is None + assert payload["emoji"] is None assert "channel_name" not in payload assert payload["lora_freq"] == 868 assert payload["modem_preset"] == "MediumFast" diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index 508f9b1..336e979 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -1262,6 +1262,8 @@ module PotatoMesh lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"]) modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"]) channel_name = string_or_nil(message["channel_name"] || message["channelName"]) + reply_id = coerce_integer(message["reply_id"] || message["replyId"]) + emoji = string_or_nil(message["emoji"]) row = [ msg_id, @@ -1279,11 +1281,13 @@ module PotatoMesh lora_freq, modem_preset, channel_name, + reply_id, + emoji, ] with_busy_retry do existing = db.get_first_row( - "SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name FROM messages WHERE id = ?", + "SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name, reply_id, emoji FROM messages WHERE id = ?", [msg_id], ) if existing @@ -1334,6 +1338,19 @@ module PotatoMesh updates["channel_name"] = channel_name if should_update end + unless reply_id.nil? + existing_reply = existing.is_a?(Hash) ? existing["reply_id"] : existing[6] + updates["reply_id"] = reply_id if existing_reply != reply_id + end + + if emoji + existing_emoji = existing.is_a?(Hash) ? existing["emoji"] : existing[7] + existing_emoji_str = existing_emoji&.to_s + should_update = existing_emoji_str.nil? || existing_emoji_str.strip.empty? + should_update ||= existing_emoji != emoji + updates["emoji"] = emoji if should_update + end + unless updates.empty? assignments = updates.keys.map { |column| "#{column} = ?" }.join(", ") db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id]) @@ -1343,8 +1360,8 @@ module PotatoMesh begin db.execute <<~SQL, row - INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name,reply_id,emoji) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) SQL rescue SQLite3::ConstraintException fallback_updates = {} @@ -1354,6 +1371,8 @@ module PotatoMesh fallback_updates["lora_freq"] = lora_freq unless lora_freq.nil? fallback_updates["modem_preset"] = modem_preset if modem_preset fallback_updates["channel_name"] = channel_name if channel_name + fallback_updates["reply_id"] = reply_id unless reply_id.nil? + fallback_updates["emoji"] = emoji if emoji unless fallback_updates.empty? assignments = fallback_updates.keys.map { |column| "#{column} = ?" }.join(", ") db.execute("UPDATE messages SET #{assignments} WHERE id = ?", fallback_updates.values + [msg_id]) diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 7589fd2..c79e2fb 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -138,6 +138,24 @@ module PotatoMesh db.execute("ALTER TABLE messages ADD COLUMN channel_name TEXT") end + unless message_columns.include?("reply_id") + db.execute("ALTER TABLE messages ADD COLUMN reply_id INTEGER") + message_columns << "reply_id" + end + + unless message_columns.include?("emoji") + db.execute("ALTER TABLE messages ADD COLUMN emoji TEXT") + message_columns << "emoji" + end + + reply_index_exists = + db.get_first_value( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'", + ).to_i > 0 + unless reply_index_exists + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id)") + end + tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten if tables.empty? sql_file = File.expand_path("../../../../data/instances.sql", __dir__) diff --git a/web/lib/potato_mesh/application/queries.rb b/web/lib/potato_mesh/application/queries.rb index c0adb22..3ddb094 100644 --- a/web/lib/potato_mesh/application/queries.rb +++ b/web/lib/potato_mesh/application/queries.rb @@ -216,7 +216,9 @@ module PotatoMesh db = open_database(readonly: true) db.results_as_hash = true params = [] - where_clauses = ["(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '')"] + where_clauses = [ + "(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '' OR m.reply_id IS NOT NULL OR COALESCE(TRIM(m.emoji), '') != '')", + ] now = Time.now.to_i min_rx_time = now - PotatoMesh::Config.week_seconds where_clauses << "m.rx_time >= ?" @@ -232,7 +234,8 @@ module PotatoMesh sql = <<~SQL SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel, m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit, - m.lora_freq, m.modem_preset, m.channel_name, m.snr + m.lora_freq, m.modem_preset, m.channel_name, m.snr, + m.reply_id, m.emoji FROM messages m SQL sql += " WHERE #{where_clauses.join(" AND ")}\n" @@ -244,6 +247,8 @@ module PotatoMesh rows = db.execute(sql, params) rows.each do |r| r.delete_if { |key, _| key.is_a?(Integer) } + r["reply_id"] = coerce_integer(r["reply_id"]) if r.key?("reply_id") + r["emoji"] = string_or_nil(r["emoji"]) if r.key?("emoji") if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?) raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first debug_log( diff --git a/web/public/assets/js/app/__tests__/message-replies.test.js b/web/public/assets/js/app/__tests__/message-replies.test.js new file mode 100644 index 0000000..4ef8197 --- /dev/null +++ b/web/public/assets/js/app/__tests__/message-replies.test.js @@ -0,0 +1,75 @@ +/* + * 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. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildMessageBody, + buildMessageIndex, + normaliseMessageId, + resolveReplyPrefix +} from '../message-replies.js'; + +test('normaliseMessageId coerces numeric identifiers', () => { + assert.equal(normaliseMessageId(42), '42'); + assert.equal(normaliseMessageId(' 0042 '), '42'); + assert.equal(normaliseMessageId('alpha'), 'alpha'); + assert.equal(normaliseMessageId(null), null); +}); + +test('buildMessageIndex normalises identifiers and ignores duplicates', () => { + const messages = [ + { id: '001', text: 'first' }, + { packet_id: 1, text: 'second' }, + { id: '2', text: 'third' } + ]; + const index = buildMessageIndex(messages); + assert.equal(index.size, 2); + assert.equal(index.get('1'), messages[0]); + assert.equal(index.get('2'), messages[2]); +}); + +test('resolveReplyPrefix renders reply badge and buildMessageBody joins emoji', () => { + const parent = { + id: 99, + node: { short_name: 'BEEF', long_name: 'Parent Node', role: 'CLIENT' }, + text: 'parent message' + }; + const reaction = { id: 100, reply_id: 99, emoji: '🔥' }; + const index = buildMessageIndex([parent, reaction]); + + const prefix = resolveReplyPrefix({ + message: reaction, + messagesById: index, + nodesById: new Map(), + renderShortHtml: (short, role, longName) => `SHORT(${short}|${role}|${longName})`, + escapeHtml: value => `ESC(${value})` + }); + + assert.equal( + prefix, + '[ESC(in reply to) SHORT(BEEF|CLIENT|Parent Node)]' + ); + + const body = buildMessageBody({ + message: { text: 'Hello', emoji: ' 🔥 ' }, + escapeHtml: value => `ESC(${value})`, + renderEmojiHtml: value => `EMOJI(${value})` + }); + + assert.equal(body, 'ESC(Hello) EMOJI(🔥)'); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 79f0ede..e388c51 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -40,6 +40,7 @@ import { initializeInstanceSelector } from './instance-selector.js'; import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js'; import { renderChatTabs } from './chat-tabs.js'; import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js'; +import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js'; /** * Entry point for the interactive dashboard. Wires up event listeners, @@ -124,7 +125,8 @@ export function initializeApp(config) { /** @type {Array} */ let allNeighbors = []; /** @type {Map} */ - let nodesById = new Map(); +let nodesById = new Map(); +let messagesById = new Map(); let nodesByNum = new Map(); const messageNodeHydrator = createMessageNodeHydrator({ fetchNodeById, @@ -2576,18 +2578,35 @@ export function initializeApp(config) { const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null; const ts = tsDate ? formatTime(tsDate) : '--:--:--'; const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node); - let messageCopy = m?.text || ''; - let copyIsHtml = false; + const replyPrefix = resolveReplyPrefix({ + message: m, + messagesById, + nodesById, + renderShortHtml, + escapeHtml + }); + + let messageBodyHtml = ''; if (m && m.encrypted) { const notice = formatEncryptedMessageNotice(m); if (notice && typeof notice === 'object') { - messageCopy = notice.content ?? ''; - copyIsHtml = Boolean(notice.isHtml); + const content = notice.content ?? ''; + messageBodyHtml = notice.isHtml ? content : escapeHtml(content); } else { - messageCopy = ''; + messageBodyHtml = ''; } + } else { + messageBodyHtml = buildMessageBody({ + message: m || {}, + escapeHtml, + renderEmojiHtml + }); } - const text = copyIsHtml ? messageCopy : escapeHtml(messageCopy); + + const combinedSegments = []; + if (replyPrefix) combinedSegments.push(replyPrefix); + if (messageBodyHtml) combinedSegments.push(messageBodyHtml); + const text = combinedSegments.length > 0 ? combinedSegments.join(' ') : ''; const metadata = extractChatMessageMetadata(m); const prefix = formatChatMessagePrefix({ timestamp: escapeHtml(ts), @@ -2613,6 +2632,7 @@ export function initializeApp(config) { neighborEntries = [] }) { if (!CHAT_ENABLED || !chatEl) return; + messagesById = buildMessageIndex(messages); const nowSeconds = Math.floor(Date.now() / 1000); const { logEntries, channels } = buildChatTabModel({ nodes, diff --git a/web/public/assets/js/app/message-replies.js b/web/public/assets/js/app/message-replies.js new file mode 100644 index 0000000..2ff686b --- /dev/null +++ b/web/public/assets/js/app/message-replies.js @@ -0,0 +1,319 @@ +/* + * 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. + */ + +/** + * Convert a value into a trimmed string or return ``null`` for blank inputs. + * + * @param {*} value Arbitrary input value. + * @returns {?string} Trimmed string when present, otherwise ``null``. + */ +function toTrimmedString(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); + } + const str = String(value).trim(); + return str.length > 0 ? str : null; +} + +/** + * Normalise a message identifier to a stable string key. + * + * @param {*} value Identifier candidate. + * @returns {?string} Canonical identifier. + */ +export function normaliseMessageId(value) { + const str = toTrimmedString(value); + if (!str) return null; + if (/^-?\d+$/.test(str)) { + const parsed = Number.parseInt(str, 10); + if (Number.isFinite(parsed)) { + return String(parsed); + } + } + return str; +} + +/** + * Build a map of message identifiers to their payload objects. + * + * Duplicate identifiers retain the first occurrence encountered, mirroring the + * ingestion pipeline that treats message IDs as unique keys. + * + * @param {?Array} messages Message collection. + * @returns {Map} Identifier lookup. + */ +export function buildMessageIndex(messages) { + const index = new Map(); + if (!Array.isArray(messages)) { + return index; + } + for (const message of messages) { + if (!message || typeof message !== 'object') { + continue; + } + const idValue = message.id ?? message.packet_id ?? message.packetId; + const key = normaliseMessageId(idValue); + if (!key || index.has(key)) { + continue; + } + index.set(key, message); + } + return index; +} + +/** + * Return the list of identifier candidates associated with ``message``. + * + * @param {?Object} message Message payload. + * @returns {Array} Identifier candidates. + */ +function candidateMessageIdentifiers(message) { + if (!message || typeof message !== 'object') { + return []; + } + const candidates = [ + message.node_id ?? message.nodeId, + message.from_id ?? message.fromId, + ]; + const unique = []; + for (const candidate of candidates) { + const trimmed = toTrimmedString(candidate); + if (!trimmed || unique.includes(trimmed)) { + continue; + } + unique.push(trimmed); + } + return unique; +} + +/** + * Resolve the node metadata associated with ``message``. + * + * @param {?Object} message Message payload. + * @param {?Map} nodesById Node lookup table. + * @returns {?Object} Node object when available. + */ +function deriveMessageNode(message, nodesById) { + if (message && typeof message === 'object' && message.node && typeof message.node === 'object') { + return message.node; + } + if (!(nodesById instanceof Map)) { + return null; + } + for (const identifier of candidateMessageIdentifiers(message)) { + if (nodesById.has(identifier)) { + return nodesById.get(identifier); + } + } + return null; +} + +/** + * Generate a short name fallback derived from a node identifier. + * + * @param {string} identifier Node identifier string. + * @returns {?string} Short name fallback. + */ +function fallbackShortFromIdentifier(identifier) { + const trimmed = toTrimmedString(identifier); + if (!trimmed) return null; + const core = trimmed.replace(/^!+/, ''); + if (core.length >= 4) { + return core.slice(-4).toUpperCase(); + } + if (trimmed.length >= 4) { + return trimmed.slice(-4).toUpperCase(); + } + return trimmed.toUpperCase(); +} + +/** + * Determine the preferred short name for a reply badge. + * + * @param {?Object} message Message payload. + * @param {?Object} node Node metadata. + * @returns {?string} Short name candidate. + */ +function deriveShortCandidate(message, node) { + const candidates = [ + node?.short_name, + node?.shortName, + message?.node?.short_name, + message?.node?.shortName, + ]; + for (const candidate of candidates) { + const trimmed = toTrimmedString(candidate); + if (trimmed) return trimmed; + } + for (const identifier of candidateMessageIdentifiers(message)) { + const fallback = fallbackShortFromIdentifier(identifier); + if (fallback) return fallback; + } + return null; +} + +/** + * Determine the preferred long name for a reply badge tooltip. + * + * @param {?Object} message Message payload. + * @param {?Object} node Node metadata. + * @returns {?string} Long name candidate. + */ +function deriveLongCandidate(message, node) { + const candidates = [ + node?.long_name, + node?.longName, + message?.node?.long_name, + message?.node?.longName, + ]; + for (const candidate of candidates) { + const trimmed = toTrimmedString(candidate); + if (trimmed) return trimmed; + } + return null; +} + +/** + * Determine the preferred role for the reply badge. + * + * @param {?Object} message Message payload. + * @param {?Object} node Node metadata. + * @returns {?string} Role candidate. + */ +function deriveRoleCandidate(message, node) { + const candidates = [ + node?.role, + message?.node?.role, + ]; + for (const candidate of candidates) { + const trimmed = toTrimmedString(candidate); + if (trimmed) return trimmed; + } + return null; +} + +/** + * Render the reply prefix for a message when the parent is known. + * + * @param {{ + * message: Object, + * messagesById: Map, + * nodesById: Map, + * renderShortHtml: Function, + * escapeHtml: Function + * }} params Rendering dependencies. + * @returns {string} HTML snippet or empty string when unavailable. + */ +export function resolveReplyPrefix({ + message, + messagesById, + nodesById, + renderShortHtml, + escapeHtml +}) { + if (!message || typeof message !== 'object') { + return ''; + } + const hasLookup = messagesById instanceof Map; + if (!hasLookup) { + return ''; + } + const replyKey = normaliseMessageId(message.reply_id ?? message.replyId); + if (!replyKey || !messagesById.has(replyKey)) { + return ''; + } + if (typeof renderShortHtml !== 'function' || typeof escapeHtml !== 'function') { + return ''; + } + + const parent = messagesById.get(replyKey); + const node = deriveMessageNode(parent, nodesById); + const shortName = deriveShortCandidate(parent, node); + const longName = deriveLongCandidate(parent, node); + const role = deriveRoleCandidate(parent, node); + const badgeSource = node || (parent && typeof parent === 'object' ? parent.node : null) || null; + const shortHtml = renderShortHtml(shortName, role, longName, badgeSource); + if (typeof shortHtml !== 'string' || shortHtml.length === 0) { + return ''; + } + const label = escapeHtml('in reply to'); + return `[${label} ${shortHtml}]`; +} + +/** + * Normalise an emoji candidate into a trimmed string. + * + * @param {*} value Emoji candidate. + * @returns {?string} Emoji string when valid. + */ +function normaliseEmojiValue(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); + } + const str = String(value).trim(); + return str.length > 0 ? str : null; +} + +/** + * Build the rendered message body containing text and optional emoji. + * + * @param {{ + * message: Object, + * escapeHtml: Function, + * renderEmojiHtml: Function + * }} params Rendering dependencies. + * @returns {string} HTML snippet describing the message body. + */ +export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) { + if (typeof escapeHtml !== 'function') { + throw new TypeError('escapeHtml must be a function'); + } + if (typeof renderEmojiHtml !== 'function') { + throw new TypeError('renderEmojiHtml must be a function'); + } + if (!message || typeof message !== 'object') { + return ''; + } + + const segments = []; + if (message.text != null) { + const textString = String(message.text); + if (textString.length > 0) { + segments.push(escapeHtml(textString)); + } + } + const emoji = normaliseEmojiValue(message.emoji); + if (emoji) { + segments.push(renderEmojiHtml(emoji)); + } + + if (segments.length === 0) { + return ''; + } + return segments.join(' '); +} diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index aded5c5..cf8c4c4 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -533,6 +533,12 @@ th { font-style: normal; } +.chat-entry-reply { + color: var(--muted); + font-style: italic; + margin-right: 4px; +} + .chat-entry-msg { font-family: ui-monospace, Menlo, Consolas, monospace; } @@ -1257,6 +1263,10 @@ body.dark .chat-entry-msg { color: #bbb; } +body.dark .chat-entry-reply { + color: #999; +} + body.dark .short-name { color: #555; } diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 6f44366..2768798 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -2481,7 +2481,8 @@ RSpec.describe "Potato Mesh Sinatra app" do rows = db.execute(<<~SQL) SELECT id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit, - lora_freq, modem_preset, channel_name + lora_freq, modem_preset, channel_name, + reply_id, emoji FROM messages ORDER BY id SQL @@ -2503,10 +2504,53 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(row["lora_freq"]).to eq(expected["lora_freq"]) expect(row["modem_preset"]).to eq(expected["modem_preset"]) expect(row["channel_name"]).to eq(expected["channel_name"]) + expect(row["reply_id"]).to eq(expected["reply_id"]) + expect(row["emoji"]).to eq(expected["emoji"]) end end end + it "persists reply metadata and emoji reactions" do + parent_payload = { + "id" => 42, + "rx_time" => reference_time.to_i - 10, + "from_id" => "!parent", + "channel" => 0, + "portnum" => "TEXT_MESSAGE_APP", + "text" => "source message", + } + + reaction_payload = { + "id" => 108, + "rx_time" => reference_time.to_i, + "from_id" => "!reactor", + "channel" => 0, + "portnum" => "REACTION_APP", + "reply_id" => parent_payload["id"], + "emoji" => " 🔥 ", + } + + post "/api/messages", parent_payload.to_json, auth_headers + expect(last_response).to be_ok + post "/api/messages", reaction_payload.to_json, auth_headers + expect(last_response).to be_ok + + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row("SELECT reply_id, emoji FROM messages WHERE id = ?", [reaction_payload["id"]]) + expect(row["reply_id"]).to eq(parent_payload["id"]) + expect(row["emoji"]).to eq("🔥") + end + + get "/api/messages" + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + reaction_row = body.find { |entry| entry["id"] == reaction_payload["id"] } + expect(reaction_row).not_to be_nil + expect(reaction_row["reply_id"]).to eq(parent_payload["id"]) + expect(reaction_row["emoji"]).to eq("🔥") + end + it "creates hidden nodes for unknown message senders" do payload = { "id" => 9_999, @@ -3570,6 +3614,8 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(actual_row["lora_freq"]).to eq(expected["lora_freq"]) expect(actual_row["modem_preset"]).to eq(expected["modem_preset"]) expect(actual_row["channel_name"]).to eq(expected["channel_name"]) + expect(actual_row["reply_id"]).to eq(expected["reply_id"]) + expect(actual_row["emoji"]).to eq(expected["emoji"]) expect(actual_row["rx_time"]).to eq(expected["rx_time"]) expect(actual_row["rx_iso"]).to eq(expected["rx_iso"]) expect(actual_row).not_to have_key("node")