mobile v0.1 with deployment for ios (#191)
* stage 1: project init * stage 2: skeleton with navigation * step 2.5: streams list * step 4: stream playback experience * step 5-6: compose experience * fix: broken record * transcode media particles to mp4 * build: reproducible go generate * build: rename skaffold module for particle processor worker * infra: increase particle processor worker resources Was dealing with OOM errors * tweaks to mobile * log transcode work * view on desktop placeholder * tweak padding * cap video resolution to save on memory * infra: bump memory limits as insurance * ux improvements * update bundle id for mobile * config for mobile
This commit was merged in pull request #191.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import { appEnv } from "@/config/env";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a free-plan network attempts to create a non-container particle
|
||||
* after hitting its daily message limit. Compose UI also disables triggers
|
||||
* proactively via `useNetworkUsage` — this throw is a last-line defense.
|
||||
*/
|
||||
export class QuotaExceededError extends Error {
|
||||
constructor(public readonly networkId: string) {
|
||||
super("Daily message limit reached");
|
||||
this.name = "QuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessage(message: string): string {
|
||||
return message.replace(/^Error:\s*/, "").trim();
|
||||
}
|
||||
|
||||
export function toUserMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401) return "Please sign in again.";
|
||||
if (err.status === 403) return "You don't have permission to do that.";
|
||||
if (err.status === 404) return "Not found.";
|
||||
if (err.status === 408 || err.status === 429) {
|
||||
return "Please try again in a moment.";
|
||||
}
|
||||
if (err.status >= 500) {
|
||||
return "Something went wrong on our end. Please try again.";
|
||||
}
|
||||
return normalizeMessage(err.message) || "Request failed.";
|
||||
}
|
||||
|
||||
if (err instanceof z.ZodError) {
|
||||
return "Received unexpected data from the server.";
|
||||
}
|
||||
|
||||
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
||||
return "Network error. Check your connection.";
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return normalizeMessage(err.message) || "Something went wrong.";
|
||||
}
|
||||
|
||||
return "Something went wrong.";
|
||||
}
|
||||
|
||||
type ErrorContext = Record<string, unknown>;
|
||||
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
|
||||
|
||||
// Sentry (or any observability backend) installs itself via `installErrorSinks`
|
||||
// from App.tsx. Until then, logError is a dev-only console call and reportError
|
||||
// always prints — no call site needs to know.
|
||||
let captureSink: ErrorSink | null = null;
|
||||
let breadcrumbSink: ErrorSink | null = null;
|
||||
|
||||
export function installErrorSinks(sinks: {
|
||||
capture: ErrorSink;
|
||||
breadcrumb: ErrorSink;
|
||||
}): void {
|
||||
captureSink = sinks.capture;
|
||||
breadcrumbSink = sinks.breadcrumb;
|
||||
}
|
||||
|
||||
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
|
||||
export function logError(err: unknown, context?: ErrorContext): void {
|
||||
if (appEnv === "dev") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[error]", err, context ?? {});
|
||||
}
|
||||
breadcrumbSink?.(err, context);
|
||||
}
|
||||
|
||||
/** Unexpected failures the user may not see. Always captured. */
|
||||
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[error]", err, context ?? {});
|
||||
captureSink?.(err, context);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
onSnapshot,
|
||||
addDoc,
|
||||
getDoc,
|
||||
getDocs,
|
||||
updateDoc,
|
||||
query,
|
||||
orderBy,
|
||||
limit,
|
||||
serverTimestamp,
|
||||
where,
|
||||
Timestamp,
|
||||
arrayUnion,
|
||||
arrayRemove,
|
||||
type DocumentData,
|
||||
type FirestoreDataConverter,
|
||||
type QueryDocumentSnapshot,
|
||||
type SnapshotOptions,
|
||||
type Unsubscribe,
|
||||
type QueryFieldFilterConstraint,
|
||||
} from "firebase/firestore";
|
||||
import { firestoreDb } from "@/firebase";
|
||||
import { isContainerType, ParticleSchema } from "@/api/types";
|
||||
import type {
|
||||
Particle,
|
||||
ParticleType,
|
||||
ParticlePropertiesMap,
|
||||
Reactions,
|
||||
} from "@/api/types";
|
||||
|
||||
// --- Converter ---
|
||||
|
||||
const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
toFirestore(particle: Particle): DocumentData {
|
||||
const { id: _id, created_at, updated_at, ...rest } = particle;
|
||||
const deletedAt =
|
||||
"deleted_at" in particle ? particle.deleted_at : undefined;
|
||||
return {
|
||||
...rest,
|
||||
created_at: Timestamp.fromDate(created_at),
|
||||
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
|
||||
...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
|
||||
};
|
||||
},
|
||||
fromFirestore(
|
||||
snap: QueryDocumentSnapshot,
|
||||
options?: SnapshotOptions,
|
||||
): Particle {
|
||||
const raw = snap.data(options);
|
||||
if (typeof raw.type !== "string") {
|
||||
throw new Error(`Invalid particle type: ${raw.type}`);
|
||||
}
|
||||
const type = raw.type as ParticleType;
|
||||
switch (type) {
|
||||
case "stream":
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties: raw.properties,
|
||||
created_at: (raw.created_at as Timestamp).toDate(),
|
||||
created_by_human_id: raw.created_by_human_id,
|
||||
updated_at: raw.updated_at
|
||||
? (raw.updated_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
visible_to: raw.visible_to,
|
||||
playback_markers: raw.playback_markers
|
||||
? Object.fromEntries(
|
||||
Object.entries(raw.playback_markers).map(([key, value]) => [
|
||||
key,
|
||||
(value as Timestamp).toDate(),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
last_child_created_at: raw.last_child_created_at
|
||||
? (raw.last_child_created_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
huddle_active_participants:
|
||||
raw.huddle_active_participants ?? undefined,
|
||||
status: raw.status ?? undefined,
|
||||
});
|
||||
case "folder":
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties: raw.properties,
|
||||
created_at: (raw.created_at as Timestamp).toDate(),
|
||||
created_by_human_id: raw.created_by_human_id,
|
||||
updated_at: raw.updated_at
|
||||
? (raw.updated_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
visible_to: raw.visible_to,
|
||||
});
|
||||
case "media":
|
||||
case "file":
|
||||
case "text":
|
||||
case "quest":
|
||||
case "paper": {
|
||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||
// particles carry `properties.edited_at`, so coerce it if present.
|
||||
const properties =
|
||||
type === "text" && raw.properties?.edited_at
|
||||
? {
|
||||
...raw.properties,
|
||||
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
||||
}
|
||||
: raw.properties;
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties,
|
||||
created_at: (raw.created_at as Timestamp).toDate(),
|
||||
created_by_human_id: raw.created_by_human_id,
|
||||
updated_at: raw.updated_at
|
||||
? (raw.updated_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
reactions: raw.reactions ?? undefined,
|
||||
deleted_at: raw.deleted_at
|
||||
? (raw.deleted_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown particle type: ${type}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// --- Typed reference helpers ---
|
||||
|
||||
function typedDoc(path: string) {
|
||||
return doc(firestoreDb, path).withConverter(particleConverter);
|
||||
}
|
||||
|
||||
function typedCollection(path: string) {
|
||||
return collection(firestoreDb, path).withConverter(particleConverter);
|
||||
}
|
||||
|
||||
// --- Exported operations ---
|
||||
|
||||
export function subscribeToParticle(
|
||||
docPath: string,
|
||||
onData: (particle: Particle | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Unsubscribe {
|
||||
return onSnapshot(
|
||||
typedDoc(docPath),
|
||||
(snap) => {
|
||||
onData(snap.exists() ? snap.data() : null);
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getParticle(docPath: string): Promise<Particle | null> {
|
||||
const docSnap = await getDoc(typedDoc(docPath));
|
||||
if (!docSnap.exists()) {
|
||||
return null;
|
||||
}
|
||||
return docSnap.data();
|
||||
}
|
||||
|
||||
export interface GetParticleChildrenOptions {
|
||||
orderByField: string;
|
||||
orderDirection: "asc" | "desc";
|
||||
}
|
||||
|
||||
export async function getParticleChildren(
|
||||
collectionPath: string,
|
||||
{
|
||||
orderByField = "created_at",
|
||||
orderDirection = "asc",
|
||||
}: GetParticleChildrenOptions = {
|
||||
orderByField: "created_at",
|
||||
orderDirection: "asc",
|
||||
},
|
||||
): Promise<Particle[]> {
|
||||
const q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
const snap = await getDocs(q);
|
||||
return snap.docs.map((d) => d.data());
|
||||
}
|
||||
|
||||
export interface SubscribeToParticleChildrenOptions {
|
||||
onData: (children: Particle[]) => void;
|
||||
onError: (error: Error) => void;
|
||||
visibilityScopes?: string[];
|
||||
orderByField?: string;
|
||||
orderDirection?: "asc" | "desc";
|
||||
onAdded?: (child: Particle) => void;
|
||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||
whereFilter?: QueryFieldFilterConstraint;
|
||||
/** Optional cap on results. Applied after order/where constraints. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function subscribeToParticleChildren(
|
||||
collectionPath: string,
|
||||
{
|
||||
onData,
|
||||
onError,
|
||||
visibilityScopes = [],
|
||||
orderByField = "created_at",
|
||||
orderDirection = "desc",
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit: limitValue,
|
||||
}: SubscribeToParticleChildrenOptions,
|
||||
): Unsubscribe {
|
||||
let q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
if (visibilityScopes.length > 0) {
|
||||
q = query(q, where("visible_to", "array-contains-any", visibilityScopes));
|
||||
}
|
||||
if (whereFilter) {
|
||||
q = query(q, whereFilter);
|
||||
}
|
||||
if (limitValue !== undefined) {
|
||||
q = query(q, limit(limitValue));
|
||||
}
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
const updatedChildren = snap.docs.map((d) => d.data());
|
||||
onData(updatedChildren);
|
||||
|
||||
if (onAdded || onRemoved) {
|
||||
for (const change of snap.docChanges()) {
|
||||
if (change.type === "added" && onAdded) onAdded(change.doc.data());
|
||||
if (change.type === "removed" && onRemoved)
|
||||
onRemoved(change.doc.data(), updatedChildren);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeToLatestChild(
|
||||
collectionPath: string,
|
||||
onData: (child: Particle | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Unsubscribe {
|
||||
const q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy("created_at", "desc"),
|
||||
limit(1),
|
||||
);
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
onData(snap.empty ? null : snap.docs[0].data());
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
// This creates a new particle document with the given properties and returns its ID.
|
||||
export async function createParticle<T extends ParticleType>(
|
||||
collectionPath: string,
|
||||
type: T,
|
||||
properties: ParticlePropertiesMap[T],
|
||||
createdByHumanId: string,
|
||||
// Must be passed for container types
|
||||
visibleTo?: string[],
|
||||
): Promise<string> {
|
||||
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
|
||||
throw new Error(
|
||||
`visibleTo is required for container type ${type} and cannot be empty`,
|
||||
);
|
||||
}
|
||||
|
||||
const particle: Particle = ParticleSchema.parse({
|
||||
id: "", // ignored by toFirestore, but needed to satisfy the type
|
||||
type,
|
||||
properties,
|
||||
created_at: new Date(),
|
||||
created_by_human_id: createdByHumanId,
|
||||
...(visibleTo ? { visible_to: visibleTo } : {}),
|
||||
});
|
||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
export async function createStreamParticle(
|
||||
collectionPath: string,
|
||||
properties: ParticlePropertiesMap["stream"],
|
||||
createdByHumanId: string,
|
||||
visibleTo?: string[],
|
||||
): Promise<string> {
|
||||
if (!visibleTo || visibleTo.length === 0) {
|
||||
throw new Error("visibleTo is required for streams and cannot be empty");
|
||||
}
|
||||
|
||||
const particle: Particle = ParticleSchema.parse({
|
||||
id: "",
|
||||
type: "stream",
|
||||
properties,
|
||||
created_at: new Date(),
|
||||
created_by_human_id: createdByHumanId,
|
||||
visible_to: visibleTo,
|
||||
status: "open",
|
||||
});
|
||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
// This allows updating properties without overwriting the entire properties object
|
||||
export async function updateParticleProperties<T extends ParticleType>(
|
||||
docPath: string,
|
||||
properties: Partial<ParticlePropertiesMap[T]>,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
// Take the partial and create a new object with dot notation
|
||||
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
|
||||
const updatedProperties: Record<string, unknown> = {};
|
||||
for (const key in properties) {
|
||||
updatedProperties[`properties.${key}`] = properties[key];
|
||||
}
|
||||
await updateDoc(particleRef, {
|
||||
...updatedProperties,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// Edits the body of a text particle and stamps `properties.edited_at` so
|
||||
// readers can see that the message was edited (distinct from `updated_at`,
|
||||
// which is bumped by any write — visibility, reactions, etc.).
|
||||
export async function editTextParticleContent(
|
||||
docPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
"properties.content": content,
|
||||
"properties.edited_at": serverTimestamp(),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateParticleVisibleTo(
|
||||
docPath: string,
|
||||
visibleTo: string[],
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const particle = await getParticle(docPath);
|
||||
if (!particle) {
|
||||
throw new Error(`Particle not found at path: ${docPath}`);
|
||||
}
|
||||
if (!isContainerType(particle.type)) {
|
||||
throw new Error(
|
||||
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
await updateDoc(particleRef, {
|
||||
visible_to: visibleTo,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// CAUTION: use the other type safe update functions in most cases
|
||||
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
|
||||
export async function updateParticle(
|
||||
docPath: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
[fieldName]: value,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStreamStatus(
|
||||
docPath: string,
|
||||
status: "open" | "closed",
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
|
||||
* in place so concurrent viewers see the deletion inline rather than being
|
||||
* bumped to an adjacent particle. Idempotent — re-calling on an already
|
||||
* tombstoned doc just refreshes the timestamp.
|
||||
*/
|
||||
export async function softDeleteParticle(
|
||||
docPath: string,
|
||||
humanId: string,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
deleted_at: serverTimestamp(),
|
||||
deleted_by_human_id: humanId,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStreamPlaybackMarker(
|
||||
docPath: string,
|
||||
humanId: string,
|
||||
playbackPositionAt: Date,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const markerField = `playback_markers.${humanId}`;
|
||||
await updateDoc(particleRef, {
|
||||
[markerField]: Timestamp.fromDate(playbackPositionAt),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleParticleReaction(
|
||||
docPath: string,
|
||||
emoji: string,
|
||||
humanId: string,
|
||||
currentReactions?: Reactions,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const field = `reactions.${emoji}`;
|
||||
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false;
|
||||
await updateDoc(particleRef, {
|
||||
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Human } from "@/api/types";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
export const REMOVED_MEMBER_LABEL = "Removed member";
|
||||
export const REMOVED_MEMBER_INITIALS = "–";
|
||||
|
||||
export interface HumanDisplay {
|
||||
/** True when the human was found in the provided list. */
|
||||
exists: boolean;
|
||||
/** Short name for inline text (e.g. message sender). */
|
||||
displayName: string;
|
||||
/** Full email or fallback label for tooltips. */
|
||||
email: string;
|
||||
/** Initials for avatar fallback. */
|
||||
initials: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human's display info by id, falling back consistently when the
|
||||
* human has been removed from the network. Member content (particles, reactions,
|
||||
* etc.) is retained after removal, so every render path needs a graceful fallback
|
||||
* instead of leaking raw ids into the UI.
|
||||
*/
|
||||
export function resolveHumanDisplay(
|
||||
humanId: string | null | undefined,
|
||||
humans: Human[] | undefined,
|
||||
): HumanDisplay {
|
||||
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
|
||||
if (!human) {
|
||||
return {
|
||||
exists: false,
|
||||
displayName: REMOVED_MEMBER_LABEL,
|
||||
email: REMOVED_MEMBER_LABEL,
|
||||
initials: REMOVED_MEMBER_INITIALS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
displayName: human.email_prefix,
|
||||
email: human.email,
|
||||
initials: getInitials(human.email),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ParticlePath is a branded string type representing a URL-style path
|
||||
* to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
|
||||
*
|
||||
* Using a branded type prevents accidentally passing raw strings where
|
||||
* a validated particle path is expected.
|
||||
*/
|
||||
declare const __brand: unique symbol;
|
||||
export type ParticlePath = string & { readonly [__brand]: true };
|
||||
|
||||
/**
|
||||
* Construct a ParticlePath from a network ID and optional particle segments.
|
||||
*
|
||||
* @example
|
||||
* particlePath("net1", []) // => "/net1"
|
||||
* particlePath("net1", ["p1"]) // => "/net1/p1"
|
||||
* particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
|
||||
*/
|
||||
export function particlePath(
|
||||
networkId: string,
|
||||
segments: string[] = [],
|
||||
): ParticlePath {
|
||||
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ParticlePath back into its network ID and particle segments.
|
||||
*/
|
||||
export function parseParticlePath(path: ParticlePath): {
|
||||
networkId: string;
|
||||
segments: string[];
|
||||
} {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return { networkId: parts[0], segments: parts.slice(1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParticlePath to the Firestore document path for that particle.
|
||||
*
|
||||
* Firestore structure:
|
||||
* /net1 → networks/net1/children (collection)
|
||||
* /net1/p1 → networks/net1/children/p1 (document)
|
||||
* /net1/p1/p2 → networks/net1/children/p1/children/p2 (document)
|
||||
*/
|
||||
export function toFirestoreDocPath(path: ParticlePath): string {
|
||||
const { networkId, segments } = parseParticlePath(path);
|
||||
const base = `networks/${networkId}/children`;
|
||||
if (segments.length === 0) return base;
|
||||
|
||||
const parts: string[] = [base, segments[0]];
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
parts.push("children", segments[i]);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParticlePath to the Firestore collection path for its children.
|
||||
*
|
||||
* /net1 → networks/net1/children (root particles)
|
||||
* /net1/p1 → networks/net1/children/p1/children
|
||||
* /net1/p1/p2 → networks/net1/children/p1/children/p2/children
|
||||
*/
|
||||
export function toFirestoreChildrenPath(path: ParticlePath): string {
|
||||
const { segments } = parseParticlePath(path);
|
||||
if (segments.length === 0) {
|
||||
return toFirestoreDocPath(path);
|
||||
}
|
||||
return `${toFirestoreDocPath(path)}/children`;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* PusherClient manages a WebSocket connection to the pusher service.
|
||||
* Handles authentication, reconnection with exponential backoff, channel
|
||||
* subscriptions, and event dispatching.
|
||||
*
|
||||
* Identical behavior to the desktop client (js/desktop/src/lib/pusher-client.ts).
|
||||
* React Native ships a WebSocket polyfill, so this code runs unchanged.
|
||||
*/
|
||||
|
||||
import { logError, reportError } from "@/lib/errors";
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting";
|
||||
|
||||
export interface ChannelMessage {
|
||||
humanId: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ServerMessage {
|
||||
type: "subscribed" | "join" | "leave" | "message" | "error";
|
||||
channel?: string;
|
||||
humanId?: string;
|
||||
presence?: string[];
|
||||
payload?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
|
||||
type ChannelEventCallback = (msg: ServerMessage) => void;
|
||||
|
||||
interface PusherClientConfig {
|
||||
url: string;
|
||||
getToken: () => string | null;
|
||||
}
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
|
||||
|
||||
export class PusherClient {
|
||||
private config: PusherClientConfig;
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = "disconnected";
|
||||
private stateListeners = new Set<(state: ConnectionState) => void>();
|
||||
|
||||
private listeners = new Map<
|
||||
string,
|
||||
Map<ChannelEventType, Set<ChannelEventCallback>>
|
||||
>();
|
||||
|
||||
private activeSubscriptions = new Set<string>();
|
||||
|
||||
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = false;
|
||||
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config: PusherClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (!token) {
|
||||
console.warn("[pusher] no token available, cannot connect");
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setState(
|
||||
this.state === "reconnecting" ? "reconnecting" : "connecting",
|
||||
);
|
||||
|
||||
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setState("connected");
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.startPing();
|
||||
this.resubscribeAll();
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.cleanup();
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
// onclose fires after onerror — reconnection is handled there.
|
||||
logError(event, { scope: "pusher.ws" });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data as string);
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearReconnectTimer();
|
||||
this.cleanup();
|
||||
this.activeSubscriptions.clear();
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
subscribe(channelId: string): void {
|
||||
this.activeSubscriptions.add(channelId);
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
|
||||
unsubscribe(channelId: string): void {
|
||||
this.activeSubscriptions.delete(channelId);
|
||||
this.send({ type: "unsubscribe", channel: channelId });
|
||||
}
|
||||
|
||||
sendMessage(channelId: string, payload: unknown): void {
|
||||
this.send({ type: "message", channel: channelId, payload });
|
||||
}
|
||||
|
||||
on(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
if (!this.listeners.has(channelId)) {
|
||||
this.listeners.set(channelId, new Map());
|
||||
}
|
||||
const channelListeners = this.listeners.get(channelId)!;
|
||||
if (!channelListeners.has(event)) {
|
||||
channelListeners.set(event, new Set());
|
||||
}
|
||||
channelListeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
const channelListeners = this.listeners.get(channelId);
|
||||
if (!channelListeners) return;
|
||||
const eventListeners = channelListeners.get(event);
|
||||
if (!eventListeners) return;
|
||||
eventListeners.delete(callback);
|
||||
|
||||
if (eventListeners.size === 0) channelListeners.delete(event);
|
||||
if (channelListeners.size === 0) this.listeners.delete(channelId);
|
||||
}
|
||||
|
||||
onStateChange(callback: (state: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(callback);
|
||||
return () => {
|
||||
this.stateListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private send(msg: {
|
||||
type: string;
|
||||
channel?: string;
|
||||
payload?: unknown;
|
||||
}): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
if (data === "pong") return;
|
||||
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch (err) {
|
||||
logError(err, { scope: "pusher.parse", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
logError(new Error(msg.message ?? "pusher server error"), {
|
||||
scope: "pusher.server",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.channel) return;
|
||||
|
||||
const channelListeners = this.listeners.get(msg.channel);
|
||||
if (!channelListeners) return;
|
||||
|
||||
const eventListeners = channelListeners.get(msg.type as ChannelEventType);
|
||||
if (!eventListeners) return;
|
||||
|
||||
for (const cb of eventListeners) {
|
||||
try {
|
||||
cb(msg);
|
||||
} catch (err) {
|
||||
reportError(err, { scope: "pusher.listener", channel: msg.channel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeAll(): void {
|
||||
for (const channelId of this.activeSubscriptions) {
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState("reconnecting");
|
||||
|
||||
const jitter = Math.random() * 0.5 + 0.75;
|
||||
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectDelay = Math.min(
|
||||
this.reconnectDelay * 2,
|
||||
MAX_RECONNECT_DELAY,
|
||||
);
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.stopPing();
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onmessage = null;
|
||||
if (
|
||||
this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
this.ws.close();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send("ping");
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
for (const cb of this.stateListeners) {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { appConfig } from "@/config/env";
|
||||
|
||||
const PusherContext = createContext<PusherClient | null>(null);
|
||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
||||
|
||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||
const token = useSessionStore((s) => s.token);
|
||||
const clientRef = useRef<PusherClient | null>(null);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
setConnectionState("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new PusherClient({
|
||||
url: appConfig.pusherUrl,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
const unsubscribeState = client.onStateChange((state) => {
|
||||
setConnectionState(state);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
client.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<PusherContext.Provider value={clientRef.current}>
|
||||
<PusherStateContext.Provider value={connectionState}>
|
||||
{children}
|
||||
</PusherStateContext.Provider>
|
||||
</PusherContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePusherClient(): PusherClient | null {
|
||||
return useContext(PusherContext);
|
||||
}
|
||||
|
||||
export function usePusherConnectionState(): ConnectionState {
|
||||
return useContext(PusherStateContext);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
MutationCache,
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
queryMeta: { toastOnError?: boolean };
|
||||
mutationMeta: { suppressToast?: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
||||
if (err instanceof ApiError) {
|
||||
// Retry only on transient status codes; 4xx generally won't succeed on retry.
|
||||
if (err.status === 408 || err.status === 429) return failureCount < 2;
|
||||
if (err.status >= 400 && err.status < 500) return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: shouldRetryQuery,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
// Mutations have side effects — never auto-retry.
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (err, query) => {
|
||||
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||
if (query.meta?.toastOnError) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err, _variables, _context, mutation) => {
|
||||
reportError(err, {
|
||||
scope: "mutation",
|
||||
mutationKey: mutation.options.mutationKey,
|
||||
});
|
||||
if (mutation.meta?.suppressToast) return;
|
||||
toast.error(toUserMessage(err));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const ADJECTIVES = [
|
||||
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
|
||||
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
|
||||
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
|
||||
"bright", "clear", "deep", "fresh", "grand", "swift",
|
||||
];
|
||||
|
||||
const NOUNS = [
|
||||
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
|
||||
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
|
||||
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
|
||||
"brook", "cliff", "delta", "frost", "glow", "reef",
|
||||
];
|
||||
|
||||
export function generateRandomName(): string {
|
||||
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
||||
return `${adj}-${noun}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { removeDuplicates } from "@/lib/utils";
|
||||
|
||||
const HUMAN_PREFIX = "human:";
|
||||
const NETWORK_PREFIX = "network:";
|
||||
|
||||
export type StreamVisibility =
|
||||
| { mode: "network" }
|
||||
| { mode: "custom"; humanIds: string[] };
|
||||
|
||||
export function parseVisibleTo(
|
||||
visibleTo: string[],
|
||||
networkId: string,
|
||||
): StreamVisibility {
|
||||
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
||||
return { mode: "network" };
|
||||
}
|
||||
const humanIds = visibleTo
|
||||
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
||||
.map((v) => v.slice(HUMAN_PREFIX.length));
|
||||
return { mode: "custom", humanIds };
|
||||
}
|
||||
|
||||
export function buildNetworkVisibility(networkId: string): string[] {
|
||||
return [`${NETWORK_PREFIX}${networkId}`];
|
||||
}
|
||||
|
||||
export function buildCustomVisibility(humanIds: string[]): string[] {
|
||||
return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
const WEEK = 604800;
|
||||
const MONTH = 2592000;
|
||||
const YEAR = 31536000;
|
||||
|
||||
export function formatDistanceToNow(date: Date | string): string {
|
||||
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime();
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000);
|
||||
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < MINUTE) return `${seconds}s ago`;
|
||||
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
|
||||
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
|
||||
if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
|
||||
if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
|
||||
if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
|
||||
return `${Math.floor(seconds / YEAR)}y ago`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getInitials(email: string): string {
|
||||
const prefix = email.split("@")[0] ?? "";
|
||||
return prefix.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function removeDuplicates<T>(array: T[]): T[] {
|
||||
return [...new Set(array)];
|
||||
}
|
||||
Reference in New Issue
Block a user