From 8db567cd18baf38646bf742a8d5feb7287df4819 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 09:32:22 -0800 Subject: [PATCH 1/2] fix(messages): show honest send status on every outbound bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the silent status: a state only existed for an outbound message after 'await send()' resolved — getSendState(msgId) had no idToSeq mapping until then, so MessageBubble rendered no status row at all. With no route to the peer the native send promise can stall indefinitely (known send-hang), so the offline-sent bubble stayed bare forever; sendMsg/handleMedia also had no catch, so a bridge throw left the same silent bubble plus an unhandled rejection. Wire the existing states through the whole lifecycle instead: - beginOutbound registers a pseudo-seq (negative msgId, the trick the failed path already used) as 'queued' BEFORE awaiting the native send, so every outbound bubble shows clock/'queued' from the moment it exists and the queued banner counts it immediately. - The pseudo entry feeds seqQueuedAt, so a send whose promise never settles flips to 'stale' ("Waiting for peer…") after 45s — the honest fallback when the data layer reports nothing. - adoptSeq migrates the bookkeeping to the real seq once native accepts, without clobbering a state a native event already set; the existing 2s presume-'sent' timer and messageQueued/Delivered/Failed + DB-ack reconciliation flows are unchanged on top of it. - send() is wrapped in try/catch on all three paths (text/media/grid, mirroring the existing QA-05 guard) → 'failed', and the no-peer / node-down early returns now mark the bubble 'failed' too. - resolveSeq refuses to downgrade terminal delivered/failed to sent/queued, and the stale sweep only flips entries still 'queued', so late timers can never roll back a confirmed outcome. No new states invented: queued/sent/delivered/failed/stale and their copy already existed in MessageBubble — they were just never assigned on this path. --- mobile_app/screens/MessagesScreen.tsx | 124 +++++++++++++++++++------- 1 file changed, 92 insertions(+), 32 deletions(-) diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index fc59fef..f05c150 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -407,9 +407,14 @@ export default function MessagesScreen() { if (staleSeqs.length === 0) return; staleSeqs.forEach(seq => seqQueuedAt.current.delete(seq)); setSeqStates(m => { + // Only a send still 'queued' may go stale — a delivery/failure that + // landed between sweeps must not be rolled back to "Waiting for peer…". const next = new Map(m); - staleSeqs.forEach(seq => next.set(seq, 'stale')); - return next; + let changed = false; + staleSeqs.forEach(seq => { + if (next.get(seq) === 'queued') { next.set(seq, 'stale'); changed = true; } + }); + return changed ? next : m; }); }, 10_000); return () => clearInterval(id); @@ -427,7 +432,13 @@ export default function MessagesScreen() { if (state === 'delivered' || state === 'failed') { pendingSendsRef.current.delete(seq); } - setSeqStates(m => new Map(m).set(seq, state)); + setSeqStates(m => { + // delivered/failed are terminal. A late presume-'sent' timer or queued + // signal must never roll a confirmed outcome back to a weaker one. + const prev = m.get(seq); + if ((prev === 'delivered' || prev === 'failed') && (state === 'sent' || state === 'queued')) return m; + return new Map(m).set(seq, state); + }); // Flip enc to true only on confirmed delivery — never before the native // module reports messageDelivered. AUDIT T9. @@ -452,6 +463,43 @@ export default function MessagesScreen() { } }, []); + // Every outbound bubble must show an honest state from the moment it exists. + // Register a pseudo-seq (negative msgId — same trick the failed path uses) as + // 'queued' BEFORE awaiting the native send: with no route to the peer the + // bridge promise can stall indefinitely (the known send-hang), and a state + // assigned only after `await send()` resolves would leave the bubble + // status-less forever. The pseudo entry feeds seqQueuedAt too, so the stale + // sweep flips a never-acked send to "Waiting for peer…" after QUEUE_STALE_MS. + const beginOutbound = useCallback((msgId: number): number => { + const pseudoSeq = -msgId; + idToSeqRef.current.set(msgId, pseudoSeq); + seqQueuedAt.current.set(pseudoSeq, Date.now()); + setSeqStates(m => new Map(m).set(pseudoSeq, 'queued')); + return pseudoSeq; + }, []); + + // Native accepted the send and returned its real seq — move the bookkeeping + // from the pseudo key to the real one so messageQueued/Delivered/Failed + // events and DB reconciliation (all keyed by real seq) resolve this message. + // Never clobber a state a native event already set for the real seq. + const adoptSeq = useCallback((msgId: number, pseudoSeq: number, seq: number) => { + idToSeqRef.current.set(msgId, seq); + const queuedAt = seqQueuedAt.current.get(pseudoSeq); + seqQueuedAt.current.delete(pseudoSeq); + if (queuedAt !== undefined && !seqQueuedAt.current.has(seq)) seqQueuedAt.current.set(seq, queuedAt); + setSeqStates(m => { + const next = new Map(m); + const carried = next.get(pseudoSeq) ?? 'queued'; + next.delete(pseudoSeq); + if (!next.has(seq)) next.set(seq, carried); + return next; + }); + // Presume 'sent' if the native layer stays silent for 2s — a messageQueued + // event (no path to peer) cancels this and keeps the honest 'queued'. + const timer = setTimeout(() => resolveSeq(seq, 'sent'), 2000); + immediateTimers.current.set(seq, timer); + }, [resolveSeq]); + // Reconcile sends stuck "queued"/"stale" over TCP: a messageDelivered proof // may never arrive over multi-hop Reticulum, but the native DB marks the // outbound row acked once stored by the recipient. Poll the DB and flip any @@ -607,46 +655,59 @@ export default function MessagesScreen() { // a lock icon on a still-queued (or eventually failed) send is a false // present-tense claim per AUDIT T9 / ROADMAP § 0.3. setMsgs(m => [...m, { id: msgId, from: 'me', me: true, time: now, text, enc: false }]); + const pseudoSeq = beginOutbound(msgId); if (!activePeerHex) { + resolveSeq(pseudoSeq, 'failed'); setMsgs(m => [...m, { id: nextId(), kind: 'sys' as const, text: 'no peer selected — open drawer and pick one' }]); return; } if (!isRunning) { + resolveSeq(pseudoSeq, 'failed'); setMsgs(m => [...m, { id: nextId(), kind: 'sys' as const, text: 'node not running yet — wait a moment' }]); return; } const bodyB64 = utf8ToBase64(text); - const seq = await send(activePeerHex, bodyB64); + let seq: number; + try { + seq = await send(activePeerHex, bodyB64); + } catch (err) { + // A native-bridge / transport throw must not surface as an unhandled + // rejection — it is a failed send, mark it so (mirrors handleGridAction). + console.warn('[messages] send threw', err); + seq = -1; + } if (seq < 0) { - const pseudoSeq = -msgId; - idToSeqRef.current.set(msgId, pseudoSeq); - setSeqStates(m => new Map(m).set(pseudoSeq, 'failed')); - } else { - // seq >= 0: queued (not yet delivered) — track it - idToSeqRef.current.set(msgId, seq); - pendingSendsRef.current.set(seq, { dest: activePeerHex, bodyB64 }); - const timer = setTimeout(() => resolveSeq(seq, 'sent'), 2000); - immediateTimers.current.set(seq, timer); + resolveSeq(pseudoSeq, 'failed'); + return; } - }, [activePeerHex, isRunning, send, resolveSeq]); + // seq >= 0: accepted by native (not yet delivered) — track it + pendingSendsRef.current.set(seq, { dest: activePeerHex, bodyB64 }); + adoptSeq(msgId, pseudoSeq, seq); + }, [activePeerHex, isRunning, send, resolveSeq, beginOutbound, adoptSeq]); const handleMedia = useCallback(async (media: MediaPayload) => { const now = new Date().toTimeString().slice(0, 8); const msgId = nextId(); setMsgs(m => [...m, { id: msgId, kind: 'media' as const, from: 'me', me: true, time: now, uri: media.uri, mimeType: media.mimeType, width: media.width, height: media.height }]); - if (!activePeerHex || !isRunning) return; - const seq = await send(activePeerHex, utf8ToBase64(''), { image: { mimeType: media.mimeType, data: media.base64 } }); + const pseudoSeq = beginOutbound(msgId); + if (!activePeerHex || !isRunning) { + resolveSeq(pseudoSeq, 'failed'); + return; + } + let seq: number; + try { + seq = await send(activePeerHex, utf8ToBase64(''), { image: { mimeType: media.mimeType, data: media.base64 } }); + } catch (err) { + console.warn('[messages] media send threw', err); + seq = -1; + } if (seq < 0) { - const pseudoSeq = -msgId; - idToSeqRef.current.set(msgId, pseudoSeq); - setSeqStates(m => new Map(m).set(pseudoSeq, 'failed')); - } else { - idToSeqRef.current.set(msgId, seq); - const timer = setTimeout(() => resolveSeq(seq, 'sent'), 2000); - immediateTimers.current.set(seq, timer); + resolveSeq(pseudoSeq, 'failed'); + return; } - }, [activePeerHex, isRunning, send, resolveSeq]); + adoptSeq(msgId, pseudoSeq, seq); + }, [activePeerHex, isRunning, send, resolveSeq, beginOutbound, adoptSeq]); const handleGridAction = useCallback(async (a: GridAction) => { const now = new Date().toTimeString().slice(0, 8); @@ -673,11 +734,14 @@ export default function MessagesScreen() { // grid action was fire-and-forget — no peer check, no node-up check, the // promise unawaited, and no queued/failed signal. A user could tap "request // 2 SOL" with the node down and see the bubble appear as if it sent. + const pseudoSeq = beginOutbound(msgId); if (!activePeerHex) { + resolveSeq(pseudoSeq, 'failed'); setMsgs(m => [...m, { id: nextId(), kind: 'sys' as const, text: 'no peer selected — open drawer and pick one' }]); return; } if (!isRunning) { + resolveSeq(pseudoSeq, 'failed'); setMsgs(m => [...m, { id: nextId(), kind: 'sys' as const, text: 'node not running yet — wait a moment' }]); return; } @@ -694,15 +758,11 @@ export default function MessagesScreen() { // Track the seq so the queued/stale banner reflects this send too, exactly // like a text message. if (seq < 0) { - const pseudoSeq = -msgId; - idToSeqRef.current.set(msgId, pseudoSeq); - setSeqStates(m => new Map(m).set(pseudoSeq, 'failed')); - } else { - idToSeqRef.current.set(msgId, seq); - const timer = setTimeout(() => resolveSeq(seq, 'sent'), 2000); - immediateTimers.current.set(seq, timer); + resolveSeq(pseudoSeq, 'failed'); + return; } - }, [publicKey, activePeerHex, isRunning, send, resolveSeq]); + adoptSeq(msgId, pseudoSeq, seq); + }, [publicKey, activePeerHex, isRunning, send, resolveSeq, beginOutbound, adoptSeq]); const pickPeer = useCallback((p: Peer) => { const prevHash = activePeerHexRef.current; From f8f252087c5f851671ca978023de0e07c363a231 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 09:33:43 -0800 Subject: [PATCH 2/2] feat(messages): long-press a text bubble to copy its message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-pressing a bubble previously did nothing. The bubble container is now a Pressable: long-press (350ms, matching RecipientPicker) fires a light haptic via the design-system haptics helper, copies the message text with expo-clipboard (already a dependency — no new deps), and confirms with the imperative showToast, the documented surface for transient "copied" feedback. Subtle pressed opacity signals the affordance; plain taps remain inert. Files-only bubbles (empty text) don't attach the handler — there is no text to copy, and the inner FileRow keeps its own tap. No clipboard auto-clear: that pattern is reserved for sensitive material like group keys (ChannelShareSheet), not the user's own conversation text. --- .../components/messages/MessageBubble.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/mobile_app/components/messages/MessageBubble.tsx b/mobile_app/components/messages/MessageBubble.tsx index 8115586..f42501e 100644 --- a/mobile_app/components/messages/MessageBubble.tsx +++ b/mobile_app/components/messages/MessageBubble.tsx @@ -1,7 +1,10 @@ -import React, { memo } from 'react'; +import React, { memo, useCallback } from 'react'; import { View, Text, Pressable, Alert, StyleSheet } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; import { Feather } from '@expo/vector-icons'; import { useTheme, fontFamily, fontSize, radii } from '@/theme'; +import * as haptics from '@/src/design-system/haptics'; +import { showToast } from '@/components/ui/Toast'; import { useGlass } from '../../hooks/useGlass'; import type { ChatMsg } from './types'; @@ -59,6 +62,20 @@ function FileRow({ file, colors }: { export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props) { const { colors } = useTheme(); const glass = useGlass(m.me ? 'accent' : 'base'); + const hasText = m.text.length > 0; + + const copyText = useCallback(async () => { + haptics.lightPress(); + try { + await Clipboard.setStringAsync(m.text); + showToast('Message copied'); + } catch (err) { + // The user long-pressed for a copy and got nothing — surface the + // failure in logs instead of silently swallowing it. + console.warn('[messages/MessageBubble] copy failed', err); + } + }, [m.text]); + return ( @@ -66,22 +83,28 @@ export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props {m.time} {m.enc && } - - {m.text.length > 0 && ( + [ + S.bubble, glass, + { borderBottomRightRadius: m.me ? 4 : 16, borderBottomLeftRadius: m.me ? 16 : 4 }, + pressed && hasText && S.bubblePressed, + ]} + > + {hasText && ( {m.text} )} {m.files && m.files.length > 0 && ( - 0 && S.filesWithText]}> + {m.files.map(f => )} )} {m.me && sendState && ( )} - + ); }); @@ -92,6 +115,7 @@ const S = StyleSheet.create({ from: { fontSize: 10, letterSpacing: 0.5 }, time: { fontSize: 10, letterSpacing: 0.5 }, bubble: { maxWidth: '78%', padding: 10, paddingHorizontal: 13, borderRadius: radii.lg }, + bubblePressed:{ opacity: 0.85 }, text: { fontSize: fontSize.md, lineHeight: 21 }, files: { gap: 4 }, filesWithText:{ marginTop: 8 },