Skip to content

Commit f6b81a2

Browse files
committed
fix(attachments): preserve retry identity
1 parent decc943 commit f6b81a2

5 files changed

Lines changed: 129 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
- Reuse attachment message IDs across Expo upload retries, add current-session
6+
retry controls for images and voice messages, and document the reliability
7+
boundary between `convex-chat` and host storage.
8+
59
## 0.1.0-rc.1 - 2026-08-09
610

711
- Freeze and document the supported `0.1` contract, upgrade and rollback

apps/example-native/app/conversation/[id].tsx

Lines changed: 90 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
import { Avatar } from "heroui-native";
4141
import { SafeAreaView } from "react-native-safe-area-context";
4242
import { useAction, useMutation, useQuery } from "convex/react";
43+
import type { FunctionArgs } from "convex/server";
4344
import { api } from "@convex-chat/example-backend/api";
4445
import * as Clipboard from "expo-clipboard";
4546
import * as Crypto from "expo-crypto";
@@ -61,11 +62,20 @@ import {
6162
} from "@/lib/chat";
6263
import { capitalize } from "@/lib/subjects";
6364

64-
type PendingVoice = {
65-
id: string;
65+
type AttachmentGrantId = FunctionArgs<
66+
typeof api.attachments.commitAttachment
67+
>["grantId"];
68+
69+
type PendingAttachment = {
70+
clientMessageId: string;
71+
kind: "image" | "voice";
6672
uri: string;
67-
durationMs: number;
73+
filename: string;
74+
mediaType: string;
75+
durationMs?: number;
76+
caption?: string;
6877
replyToMessageId?: string;
78+
grantId?: AttachmentGrantId;
6979
status: "sending" | "failed";
7080
error?: string;
7181
};
@@ -82,8 +92,8 @@ export default function ConversationScreen() {
8292
const [overflowOpen, setOverflowOpen] = useState(false);
8393
const [error, setError] = useState<string | null>(null);
8494
const [hint, setHint] = useState<string | null>(null);
85-
const [uploadingImage, setUploadingImage] = useState(false);
86-
const [pendingVoice, setPendingVoice] = useState<PendingVoice | null>(null);
95+
const [pendingAttachment, setPendingAttachment] =
96+
useState<PendingAttachment | null>(null);
8797
const [voiceGestureCancelled, setVoiceGestureCancelled] = useState(false);
8898
const listRef = useRef<FlatList<DemoMessage>>(null);
8999
const inputRef = useRef<TextInput>(null);
@@ -125,15 +135,18 @@ export default function ConversationScreen() {
125135

126136
const enqueueVoice = useCallback(
127137
async (uri: string, durationMs: number) => {
128-
const pending: PendingVoice = {
129-
id: Crypto.randomUUID(),
138+
const pending: PendingAttachment = {
139+
clientMessageId: Crypto.randomUUID(),
140+
kind: "voice",
130141
uri,
142+
filename: `voice-message-${Date.now()}.m4a`,
143+
mediaType: "audio/mp4",
131144
durationMs,
132145
replyToMessageId: replyTo?.id,
133146
status: "sending",
134147
};
135-
setPendingVoice(pending);
136-
void sendPendingVoice(pending);
148+
setPendingAttachment(pending);
149+
void sendPendingAttachment(pending);
137150
},
138151
// The reply target is captured in the queued item so a retry sends the
139152
// same logical message even if the composer state changes meanwhile.
@@ -385,60 +398,65 @@ export default function ConversationScreen() {
385398
}
386399

387400
async function chooseImage() {
388-
if (uploadingImage || editing) return;
401+
if (pendingAttachment || editing) return;
389402
setError(null);
390403
const result = await ImagePicker.launchImageLibraryAsync({
391404
mediaTypes: ["images"],
392405
quality: 0.9,
393406
});
394407
if (result.canceled || !result.assets[0]) return;
395408
const asset = result.assets[0];
396-
setUploadingImage(true);
397-
forceScrollToBottom.current = true;
398-
try {
399-
await uploadLocalAttachment({
400-
uri: asset.uri,
401-
filename: asset.fileName ?? `image-${Date.now()}.jpg`,
402-
mediaType: asset.mimeType ?? "image/jpeg",
403-
caption: text.trim() || undefined,
404-
replyToMessageId: replyTo?.id,
405-
});
406-
setText("");
407-
setReplyTo(null);
408-
pinnedToBottom.current = true;
409-
scrollToBottom();
410-
} catch (cause) {
411-
forceScrollToBottom.current = false;
412-
setError(errorMessage(cause));
413-
} finally {
414-
setUploadingImage(false);
415-
}
409+
const pending: PendingAttachment = {
410+
clientMessageId: Crypto.randomUUID(),
411+
kind: "image",
412+
uri: asset.uri,
413+
filename: asset.fileName ?? `image-${Date.now()}.jpg`,
414+
mediaType: asset.mimeType ?? "image/jpeg",
415+
caption: text.trim() || undefined,
416+
replyToMessageId: replyTo?.id,
417+
status: "sending",
418+
};
419+
setPendingAttachment(pending);
420+
void sendPendingAttachment(pending);
416421
}
417422

418-
async function sendPendingVoice(pending: PendingVoice) {
423+
async function sendPendingAttachment(pending: PendingAttachment) {
419424
setError(null);
420425
forceScrollToBottom.current = true;
426+
let attempted = pending;
421427
try {
422-
await uploadLocalAttachment({
423-
uri: pending.uri,
424-
filename: `voice-message-${Date.now()}.m4a`,
425-
mediaType: "audio/mp4",
426-
durationMs: pending.durationMs,
427-
replyToMessageId: pending.replyToMessageId,
428+
if (!attempted.grantId) {
429+
const grantId = await uploadLocalAttachment(attempted);
430+
attempted = { ...attempted, grantId };
431+
setPendingAttachment((value) =>
432+
value?.clientMessageId === attempted.clientMessageId
433+
? attempted
434+
: value,
435+
);
436+
}
437+
const grantId = attempted.grantId;
438+
if (!grantId) throw new Error("The attachment upload is incomplete");
439+
await commitAttachment({
440+
grantId,
441+
subjectId,
442+
clientMessageId: attempted.clientMessageId,
443+
caption: attempted.caption,
444+
replyToMessageId: attempted.replyToMessageId,
428445
});
429-
setPendingVoice((current) =>
430-
current?.id === pending.id ? null : current,
446+
setPendingAttachment((current) =>
447+
current?.clientMessageId === pending.clientMessageId ? null : current,
431448
);
449+
if (pending.kind === "image") setText("");
432450
setReplyTo(null);
433451
pinnedToBottom.current = true;
434452
scrollToBottom();
435453
} catch (cause) {
436454
forceScrollToBottom.current = false;
437455
const message = errorMessage(cause);
438-
setPendingVoice((current) =>
439-
current?.id === pending.id
440-
? { ...current, status: "failed", error: message }
441-
: current,
456+
setPendingAttachment((value) =>
457+
value?.clientMessageId === pending.clientMessageId
458+
? { ...attempted, status: "failed", error: message }
459+
: value,
442460
);
443461
setError(message);
444462
}
@@ -458,7 +476,7 @@ export default function ConversationScreen() {
458476
durationMs?: number;
459477
caption?: string;
460478
replyToMessageId?: string;
461-
}) {
479+
}): Promise<AttachmentGrantId> {
462480
const file = new File(uri);
463481
if (!file.exists || !file.size)
464482
throw new Error("The selected file is unavailable");
@@ -482,13 +500,7 @@ export default function ConversationScreen() {
482500
body: bytes,
483501
});
484502
if (!response.ok) throw new Error(`Upload failed (${response.status})`);
485-
await commitAttachment({
486-
grantId: upload.grantId,
487-
subjectId,
488-
clientMessageId: Crypto.randomUUID(),
489-
caption,
490-
replyToMessageId,
491-
});
503+
return upload.grantId;
492504
}
493505

494506
function onListScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
@@ -559,7 +571,7 @@ export default function ConversationScreen() {
559571
extraData={{
560572
editingId: editing?.id,
561573
selectedId: selected?.id,
562-
pendingVoice,
574+
pendingAttachment,
563575
}}
564576
keyExtractor={(message) => message.id}
565577
keyboardDismissMode={
@@ -599,14 +611,17 @@ export default function ConversationScreen() {
599611
</View>
600612
}
601613
ListFooterComponent={
602-
pendingVoice ? (
603-
<PendingVoiceBubble
604-
pending={pendingVoice}
605-
onDismiss={() => setPendingVoice(null)}
614+
pendingAttachment ? (
615+
<PendingAttachmentBubble
616+
pending={pendingAttachment}
617+
onDismiss={() => setPendingAttachment(null)}
606618
onRetry={() => {
607-
const retry = { ...pendingVoice, status: "sending" as const };
608-
setPendingVoice(retry);
609-
void sendPendingVoice(retry);
619+
const retry = {
620+
...pendingAttachment,
621+
status: "sending" as const,
622+
};
623+
setPendingAttachment(retry);
624+
void sendPendingAttachment(retry);
610625
}}
611626
/>
612627
) : null
@@ -702,11 +717,12 @@ export default function ConversationScreen() {
702717
{!editing && (
703718
<Pressable
704719
accessibilityLabel="Choose image"
705-
disabled={uploadingImage}
720+
disabled={Boolean(pendingAttachment)}
706721
onPress={() => void chooseImage()}
707722
style={styles.inputIcon}
708723
>
709-
{uploadingImage ? (
724+
{pendingAttachment?.kind === "image" &&
725+
pendingAttachment.status === "sending" ? (
710726
<ActivityIndicator color="#8294aa" size="small" />
711727
) : (
712728
<ImagePlus color="#8294aa" size={21} />
@@ -777,7 +793,7 @@ export default function ConversationScreen() {
777793
active={recording}
778794
cancelled={voiceGestureCancelled}
779795
onPressIn={(pageX) => {
780-
if (recording) return;
796+
if (recording || pendingAttachment) return;
781797
Keyboard.dismiss();
782798
voiceCancelledRef.current = false;
783799
setVoiceGestureCancelled(false);
@@ -1018,15 +1034,16 @@ function MicButton({
10181034
);
10191035
}
10201036

1021-
function PendingVoiceBubble({
1037+
function PendingAttachmentBubble({
10221038
onDismiss,
10231039
onRetry,
10241040
pending,
10251041
}: {
10261042
onDismiss: () => void;
10271043
onRetry: () => void;
1028-
pending: PendingVoice;
1044+
pending: PendingAttachment;
10291045
}) {
1046+
const label = pending.kind === "voice" ? "voice message" : "image";
10301047
return (
10311048
<View style={styles.pendingVoiceRow}>
10321049
<View style={styles.pendingVoiceBubble}>
@@ -1037,15 +1054,20 @@ function PendingVoiceBubble({
10371054
)}
10381055
<Text style={styles.pendingVoiceText}>
10391056
{pending.status === "sending"
1040-
? `Sending voice message · ${formatDuration(pending.durationMs)}`
1041-
: "Voice message failed"}
1057+
? pending.kind === "voice"
1058+
? `Sending voice message · ${formatDuration(pending.durationMs ?? 0)}`
1059+
: "Sending image"
1060+
: `${label[0]?.toUpperCase()}${label.slice(1)} failed`}
10421061
</Text>
10431062
{pending.status === "failed" && (
10441063
<>
1045-
<Pressable onPress={onRetry}>
1064+
<Pressable accessibilityLabel={`Retry ${label}`} onPress={onRetry}>
10461065
<Text style={styles.pendingVoiceAction}>Retry</Text>
10471066
</Pressable>
1048-
<Pressable onPress={onDismiss}>
1067+
<Pressable
1068+
accessibilityLabel={`Dismiss failed ${label}`}
1069+
onPress={onDismiss}
1070+
>
10491071
<Text style={styles.pendingVoiceAction}>Dismiss</Text>
10501072
</Pressable>
10511073
</>

apps/web/content/docs/guides/attachments.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,21 @@ metadata, creates short-lived download URLs, and deletes objects. Message
5959
deletion returns the opaque attachment keys that the host should delete. This
6060
keeps R2, native Convex storage, S3, and other providers outside the durable
6161
chat domain without requiring a provider adapter package from `convex-chat`.
62+
63+
## Reliable retries
64+
65+
Create `clientMessageId` before the first upload attempt. Keep it with the
66+
local pending attachment and reuse the same value for every retry. After the
67+
bytes upload succeeds, also retain that upload grant or storage key. Retry the
68+
commit with the same grant and `clientMessageId`. The component then returns the
69+
existing message if the first commit succeeded but its response did not reach
70+
the client. Request a new grant only when the upload itself did not complete.
71+
72+
Keep the local file URI, attachment metadata, caption, reply target, and
73+
`clientMessageId` together until the send succeeds or the user dismisses it.
74+
The Expo example keeps one pending attachment for the current screen session.
75+
It does not implement an offline queue or restore pending files after restart.
76+
77+
The host storage layer should expire unused upload grants and remove orphaned
78+
objects from failed or superseded upload attempts. `convex-chat` owns message
79+
idempotency. The host application owns upload retry policy and provider cleanup.

packages/example-backend/convex/attachments.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,22 @@ export const commitAttachment = action({
7676
returns: v.object({ messageId: v.string() }),
7777
handler: async (ctx, args): Promise<{ messageId: string }> => {
7878
const grant: Doc<"pendingAttachments"> = await ctx.runQuery(
79-
internal.attachments.getPendingGrant,
79+
internal.attachments.getGrantForCommit,
8080
{
8181
grantId: args.grantId,
8282
subjectId: args.subjectId,
8383
},
8484
);
85+
if (grant.state === "committed") {
86+
if (
87+
!grant.messageId ||
88+
!grant.clientMessageId ||
89+
grant.clientMessageId !== args.clientMessageId
90+
) {
91+
throw new Error("Upload grant was committed for another message");
92+
}
93+
return { messageId: grant.messageId };
94+
}
8595
await storage.syncMetadata(ctx, grant.storageKey);
8696
const metadata = await storage.getMetadata(ctx, grant.storageKey);
8797
if (!metadata?.size || !metadata.contentType) {
@@ -138,6 +148,7 @@ export const commitAttachment = action({
138148
await ctx.runMutation(internal.attachments.markGrantCommitted, {
139149
grantId: args.grantId,
140150
messageId: message.id,
151+
clientMessageId: args.clientMessageId,
141152
});
142153
return { messageId: message.id };
143154
},
@@ -185,7 +196,7 @@ export const deleteMessage = mutation({
185196
},
186197
});
187198

188-
export const getPendingGrant = internalQuery({
199+
export const getGrantForCommit = internalQuery({
189200
args: {
190201
grantId: v.id("pendingAttachments"),
191202
subjectId: v.string(),
@@ -195,8 +206,7 @@ export const getPendingGrant = internalQuery({
195206
if (
196207
!grant ||
197208
grant.subjectId !== args.subjectId ||
198-
grant.state !== "pending" ||
199-
grant.expiresAt < Date.now()
209+
(grant.state === "pending" && grant.expiresAt < Date.now())
200210
) {
201211
throw new Error("Upload grant is invalid or expired");
202212
}
@@ -208,6 +218,7 @@ export const markGrantCommitted = internalMutation({
208218
args: {
209219
grantId: v.id("pendingAttachments"),
210220
messageId: v.string(),
221+
clientMessageId: v.string(),
211222
},
212223
returns: v.null(),
213224
handler: async (ctx, args) => {
@@ -217,6 +228,7 @@ export const markGrantCommitted = internalMutation({
217228
state: "committed",
218229
committedAt: Date.now(),
219230
messageId: args.messageId,
231+
clientMessageId: args.clientMessageId,
220232
});
221233
return null;
222234
},

packages/example-backend/convex/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineSchema({
1919
expiresAt: v.number(),
2020
committedAt: v.optional(v.number()),
2121
messageId: v.optional(v.string()),
22+
clientMessageId: v.optional(v.string()),
2223
})
2324
.index("storageKey", ["storageKey"])
2425
.index("subject_state", ["subjectId", "state"]),

0 commit comments

Comments
 (0)