Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions mobile_app/components/messages/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -59,29 +62,49 @@ 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 (
<View style={[S.wrap, { alignItems: m.me ? 'flex-end' : 'flex-start' }]}>
<View style={[S.meta, { justifyContent: m.me ? 'flex-end' : 'flex-start' }]}>
{!m.me && <Text style={[S.from, { color: colors.textSecondary }]}>{m.from}{' '}</Text>}
<Text style={[S.time, { color: colors.textTertiary }]}>{m.time}</Text>
{m.enc && <Feather name="lock" size={10} color={colors.primary} style={{ marginLeft: 4 }} />}
</View>
<View style={[
S.bubble, glass,
{ borderBottomRightRadius: m.me ? 4 : 16, borderBottomLeftRadius: m.me ? 16 : 4 },
]}>
{m.text.length > 0 && (
<Pressable
onLongPress={hasText ? copyText : undefined}
delayLongPress={350}
accessibilityHint={hasText ? 'long press to copy message text' : undefined}
style={({ pressed }) => [
S.bubble, glass,
{ borderBottomRightRadius: m.me ? 4 : 16, borderBottomLeftRadius: m.me ? 16 : 4 },
pressed && hasText && S.bubblePressed,
]}
>
{hasText && (
<Text style={[S.text, { color: colors.textPrimary }]}>{m.text}</Text>
)}
{m.files && m.files.length > 0 && (
<View style={[S.files, m.text.length > 0 && S.filesWithText]}>
<View style={[S.files, hasText && S.filesWithText]}>
{m.files.map(f => <FileRow key={f.name} file={f} colors={colors} />)}
</View>
)}
{m.me && sendState && (
<SendStatus state={sendState} colors={colors} />
)}
</View>
</Pressable>
</View>
);
});
Expand All @@ -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 },
Expand Down
124 changes: 92 additions & 32 deletions mobile_app/screens/MessagesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
Loading