implement support for replies and reactions app (#411)

* implement support for replies and reactions app

* Allow numeric reaction port packets

* allow reaction packets through mai channel filter
This commit is contained in:
l5y
2025-11-06 20:58:35 +01:00
committed by GitHub
parent 71c0f8b21e
commit 8bb98f65d6
13 changed files with 675 additions and 19 deletions
+1
View File
@@ -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",
)
+79 -5
View File
@@ -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
+4 -1
View File
@@ -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);
@@ -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;
+46
View File
@@ -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"
@@ -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])
@@ -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__)
+7 -2
View File
@@ -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(
@@ -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,
'<span class="chat-entry-reply">[ESC(in reply to) SHORT(BEEF|CLIENT|Parent Node)]</span>'
);
const body = buildMessageBody({
message: { text: 'Hello', emoji: ' 🔥 ' },
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(body, 'ESC(Hello) EMOJI(🔥)');
});
+27 -7
View File
@@ -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<Object>} */
let allNeighbors = [];
/** @type {Map<string, Object>} */
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,
+319
View File
@@ -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<Object>} messages Message collection.
* @returns {Map<string, Object>} 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<string>} 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<string, Object>} 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<string, Object>,
* nodesById: Map<string, Object>,
* 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 `<span class="chat-entry-reply">[${label} ${shortHtml}]</span>`;
}
/**
* 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(' ');
}
+10
View File
@@ -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;
}
+47 -1
View File
@@ -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")