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