@@ -40,6 +40,7 @@ import {
4040import { Avatar } from "heroui-native" ;
4141import { SafeAreaView } from "react-native-safe-area-context" ;
4242import { useAction , useMutation , useQuery } from "convex/react" ;
43+ import type { FunctionArgs } from "convex/server" ;
4344import { api } from "@convex-chat/example-backend/api" ;
4445import * as Clipboard from "expo-clipboard" ;
4546import * as Crypto from "expo-crypto" ;
@@ -61,11 +62,20 @@ import {
6162} from "@/lib/chat" ;
6263import { 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 </ >
0 commit comments