Files
llink/js/mobile/src/lib/upload.ts
T
2026-04-29 13:29:02 -07:00

202 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 };
}