Files
llink/js/mobile/src/lib/upload.ts
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

195 lines
5.4 KiB
TypeScript

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<string> {
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<string> {
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<CreateStreamWithFirstParticleResult> {
// 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 };
}