step 5-6: compose experience
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user