( client: KiloChatClient, conversationId: string, options: UseAttachmentQueueOptions )
| 241 | } |
| 242 | |
| 243 | export function useAttachmentQueue( |
| 244 | client: KiloChatClient, |
| 245 | conversationId: string, |
| 246 | options: UseAttachmentQueueOptions |
| 247 | ): UseAttachmentQueueResult { |
| 248 | const [state, dispatch] = useReducer(attachmentQueueReducer, { rows: [] }); |
| 249 | const { performUpload, maxBytes, generateTempId, onSizeRejected } = options; |
| 250 | const generate = generateTempId ?? defaultTempId; |
| 251 | |
| 252 | type Pending = { |
| 253 | abort: AbortController; |
| 254 | putUrl?: string; |
| 255 | putHeaders?: Record<string, string>; |
| 256 | putUrlExpiresAtMs?: number; |
| 257 | }; |
| 258 | const pendingRef = useRef<Map<string, Pending>>(new Map()); |
| 259 | // Blobs are tracked separately so previews keep working after the upload |
| 260 | // completes — pendingRef releases its entry on setReady to free upload |
| 261 | // bookkeeping, but the local bytes are still useful for the preview chip |
| 262 | // until the user sends or removes the attachment. |
| 263 | const blobsRef = useRef<Map<string, Blob>>(new Map()); |
| 264 | |
| 265 | type UploadArgs = { tempId: string; filename: string; mimeType: string; size: number }; |
| 266 | |
| 267 | const startUpload = useCallback( |
| 268 | async (args: UploadArgs) => { |
| 269 | const { tempId, filename, mimeType, size } = args; |
| 270 | const pending = pendingRef.current.get(tempId); |
| 271 | const blob = blobsRef.current.get(tempId); |
| 272 | if (!pending || !blob) return; |
| 273 | |
| 274 | try { |
| 275 | let putUrl = pending.putUrl; |
| 276 | let putHeaders = pending.putHeaders; |
| 277 | const expiresAtMs = pending.putUrlExpiresAtMs ?? 0; |
| 278 | // Treat the URL as expired a minute early to avoid racing R2 at the boundary. |
| 279 | const putUrlExpired = Date.now() > expiresAtMs - 60 * 1000; |
| 280 | |
| 281 | if (!putUrl || !putHeaders || putUrlExpired) { |
| 282 | const res = await client.initAttachment({ |
| 283 | conversationId, |
| 284 | mimeType, |
| 285 | size, |
| 286 | filename, |
| 287 | idempotencyKey: tempId, |
| 288 | }); |
| 289 | if (pending.abort.signal.aborted) return; |
| 290 | pending.putUrl = res.putUrl; |
| 291 | pending.putHeaders = res.putHeaders; |
| 292 | pending.putUrlExpiresAtMs = res.putUrlExpiresAt * 1000; |
| 293 | putUrl = res.putUrl; |
| 294 | putHeaders = res.putHeaders; |
| 295 | dispatch({ type: 'setInited', tempId, attachmentId: res.attachmentId }); |
| 296 | } |
| 297 | |
| 298 | await performUpload(blob, putUrl, putHeaders, { |
| 299 | signal: pending.abort.signal, |
| 300 | onProgress: fraction => dispatch({ type: 'setProgress', tempId, progress: fraction }), |
no test coverage detected