import { FileSystemUploadType, getInfoAsync, uploadAsync, } from 'expo-file-system/legacy'; import { apiClient } from '@/api/client'; import { createParticle, createStreamParticle, } from '@/lib/firestore-particles'; import { particlePath, toFirestoreChildrenPath, type ParticlePath, } from '@/lib/particle-path'; interface UploadMediaParticleParams { networkId: string; /** Path of the destination container (stream — possibly with sub-segments). */ targetPath: ParticlePath; fileUri: string; mimeType: string; durationMs: number; source: 'camera' | 'screen'; createdByHumanId: string; } /** * Upload a recorded file and create the corresponding `media` particle in * Firestore. Order matches desktop's `use-recorder` flow exactly: * prepareUpload → PUT → confirmUpload → createParticle. * * Returns the new particle's id, or throws on any failure (no half-states — * if any step fails the caller cancels and reports). */ export async function uploadMediaParticle({ networkId, targetPath, fileUri, mimeType, durationMs, source, createdByHumanId, }: UploadMediaParticleParams): Promise { const info = await getInfoAsync(fileUri); if (!info.exists || info.size === undefined) { throw new Error('Recording file disappeared before upload.'); } const sizeBytes = info.size; const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video'; const ext = extensionFromMime(mimeType); const name = `${namePrefix}-${Date.now()}${ext}`; const { object_id, upload_url, upload_headers } = await apiClient.prepareUpload({ network_id: networkId, name, content_type: mimeType, content_length: sizeBytes, }); const uploadResult = await uploadAsync(upload_url, fileUri, { httpMethod: 'PUT', uploadType: FileSystemUploadType.BINARY_CONTENT, headers: upload_headers, }); if (uploadResult.status < 200 || uploadResult.status >= 300) { throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`); } await apiClient.confirmUpload(object_id); const collectionPath = toFirestoreChildrenPath(targetPath); return createParticle( collectionPath, 'media', { object_id, mime_type: mimeType, duration_ms: durationMs, size_bytes: sizeBytes, source, }, createdByHumanId, ); } interface CreateTextParticleParams { networkId: string; targetPath: ParticlePath; content: string; createdByHumanId: string; } export async function createTextParticle({ targetPath, content, createdByHumanId, }: CreateTextParticleParams): Promise { const collectionPath = toFirestoreChildrenPath(targetPath); return createParticle(collectionPath, 'text', { content }, createdByHumanId); } function extensionFromMime(mime: string): string { if (mime === 'video/mp4') return '.mp4'; if (mime === 'video/quicktime') return '.mov'; if (mime === 'audio/mp4') return '.m4a'; if (mime === 'audio/webm') return '.webm'; return ''; } // Helper kept here so callers can construct a fresh stream's child-path before // the stream particle has been written. export function streamChildrenPath( networkId: string, streamId: string, ): ParticlePath { return particlePath(networkId, [streamId]); } // --- New-stream flow --- interface CreateStreamWithFirstParticleParams { networkId: string; name: string; /** ["network:{id}"] for everyone; ["human:{id}", ...] for specific people. */ visibleTo: string[]; createdByHumanId: string; /** First particle to write into the new stream. Required — empty streams are not useful. */ firstParticle: | { type: 'text'; content: string } | { type: 'media'; fileUri: string; mimeType: string; durationMs: number; source: 'camera' | 'screen'; }; } interface CreateStreamWithFirstParticleResult { streamId: string; } /** * Create a top-level stream particle plus its first child particle, in that * order. Mirrors desktop's "create new stream" submit path (compose-overlay * §handleStreamSubmit). On any failure the caller is responsible for retry — * we don't roll back the stream particle on child failure because Firestore * doesn't expose a multi-write transaction across these subcollections, and * an empty stream is harmless (the user can retry composing into it). */ export async function createStreamWithFirstParticle({ networkId, name, visibleTo, createdByHumanId, firstParticle, }: CreateStreamWithFirstParticleParams): Promise { // 1. The stream particle goes at the network root. const rootChildrenPath = toFirestoreChildrenPath(particlePath(networkId, [])); const streamId = await createStreamParticle( rootChildrenPath, { name }, createdByHumanId, visibleTo, ); const streamPath = particlePath(networkId, [streamId]); // 2. The first child goes inside the new stream. if (firstParticle.type === 'text') { await createTextParticle({ networkId, targetPath: streamPath, content: firstParticle.content, createdByHumanId, }); } else { await uploadMediaParticle({ networkId, targetPath: streamPath, fileUri: firstParticle.fileUri, mimeType: firstParticle.mimeType, durationMs: firstParticle.durationMs, source: firstParticle.source, createdByHumanId, }); } return { streamId }; }