* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
195 lines
5.4 KiB
TypeScript
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 };
|
|
}
|