Skip to content

Commit 6a9093b

Browse files
thatSFguyclaude
andcommitted
v0.21.0 — render multi-line messages and aggregate tap-back reactions
Two fixes to inbound message display: 1. Newlines were collapsed into a single run-on line. The message body div now uses `white-space: pre-wrap` (via a `.message-text` class) so sender-authored line breaks render, while long words still wrap. 2. Reactions (FIELD_REACTION 0x40, SPEC §5.9.8) arrived as empty bubbles. A reaction is a standalone empty-body LXMF whose fields[0x40] is {0x00: <raw 32-byte target message_id>, 0x01: <emoji>}. Per §5.9.8 we now aggregate it onto its target message (dedup by reactor+emoji) and MUST NOT render the carrying LXMF as its own bubble. Aggregated reactions show as small `👍 2` chips under the bubble, matching reticulum-mobile-app. To match reactions to their target, both inbound and outbound messages now store the canonical LXMF message_id (SPEC §5.5/§5.7.1): raw wire bytes for un-stamped 4-element payloads, canonical re-pack for stamped 5-element ones. packMessage returns {payload, messageId}; unpackMessage exposes messageId. Reactor attribution is the carrying LXMF's source identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c8c3ba commit 6a9093b

5 files changed

Lines changed: 149 additions & 10 deletions

File tree

css/style.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,11 @@ a { color: var(--accent); }
726726
line-height: 1.5;
727727
word-wrap: break-word;
728728
}
729+
/* Preserve sender-authored line breaks; still wrap long words. */
730+
.message-text {
731+
white-space: pre-wrap;
732+
word-break: break-word;
733+
}
729734
.message.incoming {
730735
background: var(--bubble-in-bg);
731736
color: var(--bubble-in-text);
@@ -771,6 +776,31 @@ a { color: var(--accent); }
771776
}
772777
.message.incoming .rx-meta { color: var(--text-muted); }
773778

779+
/* Tap-back reactions (SPEC §5.9.8) — small chips under the bubble. */
780+
.reactions {
781+
display: flex;
782+
flex-wrap: wrap;
783+
gap: 4px;
784+
margin-top: 5px;
785+
}
786+
.reaction-chip {
787+
display: inline-flex;
788+
align-items: center;
789+
gap: 3px;
790+
font-size: 12px;
791+
line-height: 1;
792+
padding: 3px 7px;
793+
border-radius: 11px;
794+
background: var(--bg);
795+
border: 0.5px solid var(--border);
796+
color: var(--text);
797+
}
798+
.message.outgoing .reaction-chip {
799+
background: rgba(0, 0, 0, 0.18);
800+
border-color: rgba(255, 255, 255, 0.25);
801+
color: rgba(255, 255, 255, 0.95);
802+
}
803+
774804
/* Compose */
775805

776806
.compose {

js/app.js

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,16 @@ async function dispatchIncomingMessage(msg, rxInfo) {
11461146

11471147
log('info', ` LXMF payload: elements=${msg.payloadElementCount} raw_msgpack=${msg.msgpackData.length}B stripped=${msg.msgpackForHash.length}B destHashInBody=${toHex(msg.destHash).substring(0, 16)}...`);
11481148

1149+
// Tap-back reaction (FIELD_REACTION 0x40, SPEC §5.9.8). A reaction is a
1150+
// standalone empty-body LXMF — it MUST be aggregated onto its target
1151+
// message, not rendered as its own (empty) bubble. Handle it before the
1152+
// content dedupe (reactions carry empty content and would collide).
1153+
const reaction = parseReaction(msg.fields);
1154+
if (reaction) {
1155+
await handleIncomingReaction(reaction, sourceHashHex);
1156+
return;
1157+
}
1158+
11491159
// Session-level dedupe. On a duplicate, increment the count on
11501160
// the already-saved row and re-render so the user sees "×2", "×3",
11511161
// etc. but no new bubble appears.
@@ -1223,6 +1233,7 @@ async function dispatchIncomingMessage(msg, rxInfo) {
12231233
direction: 'incoming',
12241234
content: msg.content,
12251235
title: msg.title,
1236+
messageId: msg.messageId ? toHex(msg.messageId) : undefined, // for matching inbound reactions
12261237
timestamp: senderTs != null ? senderTs : Date.now(),
12271238
senderTimeMissing: senderTs == null,
12281239
rssi: rxInfo.rssi,
@@ -2097,7 +2108,7 @@ async function sendMessage() {
20972108
await renderMessages(activeContactHash);
20982109
log('info', `Sending ${descriptor.kind} "${descriptor.name}"${content ? ' + caption' : ''} to "${contact.displayName}"…`);
20992110
try {
2100-
const delivered = await sendLxmfOverLink(contact, content, '', fields);
2111+
const delivered = await sendLxmfOverLink(contact, content, '', fields, id);
21012112
await updateMessage(id, { state: delivered ? MSG_STATE_DELIVERED : MSG_STATE_SENT });
21022113
} catch (e) {
21032114
await updateMessage(id, { state: MSG_STATE_FAILED, lastError: e.message });
@@ -2134,7 +2145,7 @@ async function sendMessage() {
21342145
// Pack LXMF message. LXMF's source_hash field is the sender's
21352146
// LXMF delivery *destination* hash, not the identity hash —
21362147
// receivers key their contact table on destination hashes.
2137-
const lxmfPayload = await packMessage(
2148+
const { payload: lxmfPayload, messageId: outMessageId } = await packMessage(
21382149
myIdentity, contact.destHash, myDestHash,
21392150
'', content, {}
21402151
);
@@ -2217,7 +2228,7 @@ async function sendMessage() {
22172228
await renderMessages(activeContactHash);
22182229
log('info', `Message too large for one packet (${packet.length}B) — sending over a Link…`);
22192230
try {
2220-
const delivered = await sendLxmfOverLink(contact, content, '', new Map());
2231+
const delivered = await sendLxmfOverLink(contact, content, '', new Map(), id);
22212232
await updateMessage(id, { state: delivered ? MSG_STATE_DELIVERED : MSG_STATE_SENT });
22222233
} catch (e) {
22232234
await updateMessage(id, { state: MSG_STATE_FAILED, lastError: e.message });
@@ -2245,6 +2256,7 @@ async function sendMessage() {
22452256
state: radioOn ? MSG_STATE_SENDING : MSG_STATE_PENDING,
22462257
packetHash: packetHashHex,
22472258
rawPacket: Array.from(packet),
2259+
messageId: toHex(outMessageId), // canonical id, for matching inbound reactions
22482260
attempts: 0,
22492261
nextRetryAt: 0,
22502262
};
@@ -2277,6 +2289,61 @@ function getField(fields, key) {
22772289
return fields instanceof Map ? fields.get(key) : (fields[key] ?? fields[String(key)]);
22782290
}
22792291

2292+
// Parse a FIELD_REACTION (0x40) tap-back, SPEC §5.9.8. The field value is
2293+
// an int-keyed dict {0x00: <raw 32-byte target message_id>, 0x01: <UTF-8
2294+
// emoji>}. The reaction carries NO reactor identity on the wire —
2295+
// attribution is the carrying LXMF's own source (resolved at the call
2296+
// site). Tolerates bytes/str carriers and Map/object inner maps. Returns
2297+
// {reactionToHex, emoji} or null if this isn't a (well-formed) reaction.
2298+
function parseReaction(fields) {
2299+
const reactionMap = getField(fields, 0x40);
2300+
if (reactionMap == null || typeof reactionMap !== 'object') return null;
2301+
const reactionToHex = reactionHashHex(getField(reactionMap, 0x00)); // REACTION_TO
2302+
const emoji = reactionString(getField(reactionMap, 0x01)); // REACTION_CONTENT
2303+
if (!reactionToHex || !emoji) return null;
2304+
return { reactionToHex, emoji };
2305+
}
2306+
2307+
// A raw 32-byte message_id as hex; also tolerate an already-hex string in
2308+
// case an encoder shipped it hex-encoded (SPEC §5.9.8 bytes/str tolerance).
2309+
function reactionHashHex(v) {
2310+
if (v instanceof Uint8Array) return v.length ? toHex(v) : null;
2311+
if (typeof v === 'string' && v.length) return v.toLowerCase();
2312+
return null;
2313+
}
2314+
2315+
// Reaction content as a string — msgpack may surface it as str or bin.
2316+
function reactionString(v) {
2317+
if (typeof v === 'string') return v || null;
2318+
if (v instanceof Uint8Array) {
2319+
try { return new TextDecoder().decode(v) || null; } catch (_) { return null; }
2320+
}
2321+
return null;
2322+
}
2323+
2324+
// Aggregate an inbound reaction onto its target message, dedup by
2325+
// (reactor, emoji), and re-render. SPEC §5.9.8: receivers MUST aggregate
2326+
// and MUST NOT render the reaction-carrying LXMF as a separate bubble.
2327+
// reactorHex is the carrying LXMF's source (its lxmf.delivery dest hash).
2328+
async function handleIncomingReaction(reaction, reactorHex) {
2329+
const all = await getAllMessages();
2330+
const target = all.find(m => m.messageId === reaction.reactionToHex);
2331+
if (!target) {
2332+
log('info', ` Reaction "${reaction.emoji}" from ${reactorHex.substring(0, 12)}... targets unknown message ${reaction.reactionToHex.substring(0, 16)}... — ignored`);
2333+
return;
2334+
}
2335+
const reactions = target.reactions || {};
2336+
const senders = reactions[reaction.emoji] || [];
2337+
if (senders.includes(reactorHex)) {
2338+
log('info', ` Duplicate reaction "${reaction.emoji}" from ${reactorHex.substring(0, 12)}... — ignored`);
2339+
return;
2340+
}
2341+
reactions[reaction.emoji] = [...senders, reactorHex];
2342+
await updateMessage(target.id, { reactions });
2343+
log('ok', ` Reaction "${reaction.emoji}" from ${reactorHex.substring(0, 12)}... on message #${target.id}`);
2344+
if (activeContactHash === target.contactHash) await renderMessages(activeContactHash);
2345+
}
2346+
22802347
function blobToDataUrl(blob) {
22812348
return new Promise((resolve, reject) => {
22822349
const fr = new FileReader();
@@ -2311,10 +2378,16 @@ async function resizeImage(file, maxDim = 512, quality = 0.5) {
23112378
// Send a packed LXMF (with optional attachment fields) to a contact over a
23122379
// Link. Returns true if confirmed delivered (Resource proof), false if sent
23132380
// as a single packet (delivery proof, if any, arrives async). Throws on error.
2314-
async function sendLxmfOverLink(contact, content, title, fields) {
2381+
async function sendLxmfOverLink(contact, content, title, fields, rowId = null) {
23152382
if (!radioOn) throw new Error('Radio is off');
23162383
const link = await openLinkToContact(contact);
2317-
const packed = await packMessage(myIdentity, contact.destHash, myDestHash, title || '', content || '', fields || new Map());
2384+
const { payload: packed, messageId } = await packMessage(myIdentity, contact.destHash, myDestHash, title || '', content || '', fields || new Map());
2385+
// Persist the canonical message_id so an inbound reaction/reply that
2386+
// targets this message can be matched back to the row (§5.9.8/9).
2387+
if (rowId != null) {
2388+
try { await updateMessage(rowId, { messageId: toHex(messageId) }); }
2389+
catch (_) { /* id is best-effort; never block the send on it */ }
2390+
}
23182391
const destBytes = contact.destHash instanceof Uint8Array ? contact.destHash : new Uint8Array(contact.destHash);
23192392
const container = concatBytes([destBytes, packed]); // link form keeps the dest hash
23202393
const encrypted = await link.encrypt(container);
@@ -3140,8 +3213,9 @@ async function renderMessages(contactHash) {
31403213
const time = ts != null ? formatMessageTime(ts) : '(no time)';
31413214
const stateIcon = renderOutgoingStateIcon(msg);
31423215
const rxMeta = renderIncomingRxMeta(msg);
3143-
const body = msg.content ? `<div>${escapeHtml(msg.content)}</div>` : '';
3144-
div.innerHTML = `${renderAttachment(msg.attachment)}${body}<div class="meta">${time}${stateIcon}${rxMeta}</div>`;
3216+
const body = msg.content ? `<div class="message-text">${escapeHtml(msg.content)}</div>` : '';
3217+
const reactions = renderReactions(msg.reactions);
3218+
div.innerHTML = `${renderAttachment(msg.attachment)}${body}${reactions}<div class="meta">${time}${stateIcon}${rxMeta}</div>`;
31453219
list.appendChild(div);
31463220
}
31473221
list.scrollTop = list.scrollHeight;
@@ -3162,6 +3236,20 @@ function renderAttachment(att) {
31623236
return `<a class="att-file" href="${att.dataUrl}" download="${escapeHtml(att.name)}">📎 ${escapeHtml(att.name)}${kb}</a>`;
31633237
}
31643238

3239+
// Render aggregated tap-back reactions (SPEC §5.9.8) as small `👍 2` chips
3240+
// below the bubble. `reactions` is {emoji: [reactorHash, ...]}; an empty or
3241+
// missing map renders nothing.
3242+
function renderReactions(reactions) {
3243+
if (!reactions) return '';
3244+
const entries = Object.entries(reactions).filter(([, s]) => s && s.length);
3245+
if (!entries.length) return '';
3246+
const chips = entries.map(([emoji, senders]) => {
3247+
const count = senders.length > 1 ? ` ${senders.length}` : '';
3248+
return `<span class="reaction-chip">${escapeHtml(emoji)}${count}</span>`;
3249+
}).join('');
3250+
return `<div class="reactions">${chips}</div>`;
3251+
}
3252+
31653253
// Radio metadata for incoming messages: hops, RSSI, SNR, and dupe count.
31663254
// Returns an HTML fragment for the meta line. Outgoing rows and legacy
31673255
// rows (saved before these fields were added) return empty.

js/lxmf.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ export async function unpackMessage(data, destHash) {
8484
const hashedOriginal = concatBytes([destHash, sourceHash, msgpackData]);
8585
const hashOriginal = await sha256(hashedOriginal);
8686

87+
// Canonical LXMF message_id (SPEC §5.5 / §5.7.1): the value reactions
88+
// (§5.9.8) and replies (§5.9.9) reference. Distinct from the §5.6
89+
// signature hashes above — it is computed over a path-dependent
90+
// msgpack_payload:
91+
// - un-stamped (4-element array): the payload bytes EXACTLY AS
92+
// RECEIVED (msgpackData). Re-encoding would diverge from every
93+
// spec-compliant peer the moment the sender's msgpack encoder
94+
// drifts from ours (non-minimal ints, str-vs-bin, float width).
95+
// - stamped (5-element array): element [4] dropped, first four
96+
// re-packed canonically (== strippedMsgpack here).
97+
const msgpackForId = payload.length === 4 ? msgpackData : strippedMsgpack;
98+
const messageId = await sha256(concatBytes([destHash, sourceHash, msgpackForId]));
99+
87100
return {
88101
sourceHash,
89102
signature,
@@ -105,6 +118,8 @@ export async function unpackMessage(data, destHash) {
105118
// Fallback view for the "no stamp stripping" variant.
106119
hashedPartOriginal: hashedOriginal,
107120
messageHashOriginal: hashOriginal,
121+
// Canonical LXMF message_id (raw 32 bytes) — reaction/reply target id.
122+
messageId,
108123
payloadElementCount: payload.length,
109124
};
110125
}
@@ -221,7 +236,13 @@ export async function packMessage(sourceIdentity, destHash, sourceHash, title, c
221236

222237
// On-wire format (destination stripped for opportunistic single-packet):
223238
// source_hash(16) + signature(64) + msgpack(payload)
224-
return concatBytes([sourceHash, signature, msgpackData]);
239+
// messageHash is the canonical LXMF message_id (SPEC §5.5) for this
240+
// 4-element (un-stamped) message — returned so the sender can store it
241+
// and later match inbound reactions/replies that target it (§5.9.8/9).
242+
return {
243+
payload: concatBytes([sourceHash, signature, msgpackData]),
244+
messageId: messageHash,
245+
};
225246
}
226247

227248
// ---- Helpers ---------------------------------------------------------

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "reticulum-lora-webclient",
3-
"version": "0.20.1",
3+
"version": "0.21.0",
44
"description": "Browser-based Reticulum messaging client for RNode LoRa modems.",
55
"type": "module",
66
"private": true,

tests/roundtrip.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async function main() {
9393

9494
// ---- Scenario B: Alice sends an opportunistic LXMF message to Bob --------
9595
const content = "hello from tests/roundtrip.mjs";
96-
const lxmfPayload = await packMessage(
96+
const { payload: lxmfPayload } = await packMessage(
9797
alice,
9898
bobDestHash,
9999
aliceDestHash,

0 commit comments

Comments
 (0)