@@ -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+
22802347function 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.
0 commit comments