diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 7660817..6cbf8bb 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -67,18 +67,12 @@ export const StreamPropertiesSchema = z.object({ name: z.string(), status: z.enum(["open", "closed"]), description: z.string().optional(), - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John - // e.g. ["network:123"] - visible to everyone in the network - visible_to: z.array(z.string()) }); export type StreamProperties = z.infer; export const FolderPropertiesSchema = z.object({ name: z.string(), color: z.string().optional(), - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John - // e.g. ["network:123"] - visible to everyone in the network - visible_to: z.array(z.string()) }); export type FolderProperties = z.infer; @@ -137,8 +131,20 @@ const ParticleBaseSchema = z.object({ }); export const ParticleSchema = z.discriminatedUnion("type", [ - ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }), - ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }), + ParticleBaseSchema.extend({ + type: z.literal("stream"), properties: StreamPropertiesSchema, + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()), + // Marks emails to their `playback_position_at`: where they left off in a conversation + markers: z.record(z.string(), z.coerce.date()).optional(), + }), + ParticleBaseSchema.extend({ + type: z.literal("folder"), properties: FolderPropertiesSchema, + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()), + }), ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index e6b23b1..1dbe838 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/stores/auth-store"; -import { useCreateParticle } from "@/hooks/use-create-particle"; +import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; import { useRecordingMode } from "@/hooks/use-recording-mode"; import { useRecorder } from "@/features/compose/use-recorder"; import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; @@ -38,6 +38,7 @@ export function ComposeOverlay({ const [recordingMode] = useRecordingMode(); const userEmail = useAuthStore((s) => s.user?.email); const createParticle = useCreateParticle(); + const createStream = useCreateStreamParticle(); // Refs to avoid stale closures in keyboard handler const stepRef = useRef(step); @@ -158,15 +159,14 @@ export function ComposeOverlay({ const collectionPath = toFirestoreChildrenPath(particlePath(networkId)); - const streamId = await createParticle.mutateAsync({ + const streamId = await createStream.mutateAsync({ collectionPath, - type: "stream", properties: { name: streamName, status: "open", - visible_to: visibleTo, }, createdByEmail: userEmail, + visibleTo, }); const streamChildrenPath = toFirestoreChildrenPath( diff --git a/js/src/features/layout.tsx b/js/src/features/layout.tsx index 8804aec..c35664c 100644 --- a/js/src/features/layout.tsx +++ b/js/src/features/layout.tsx @@ -13,7 +13,7 @@ import { import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useNetworks } from "@/hooks/use-networks"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; -import { useLiveParticle } from "@/hooks/use-particle"; +import { useLiveParticle, useParticle } from "@/hooks/use-particle"; import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { getParticle } from "@/lib/firestore-particles"; @@ -60,19 +60,9 @@ function TopBar() { const { networkId, "*": rest } = useParams(); const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean); - const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : null; + const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : undefined; - const { data: particle } = useQuery( - { - queryKey: ["particle", path], - queryFn: async () => { - if (!path) return null; - const particle = await getParticle(toFirestoreDocPath(path)); - return particle; - }, - enabled: !!path, - }, - ); + const { data: particle } = useParticle(path); return (
diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx index 76ef87f..469f0b0 100644 --- a/js/src/features/particles/particle-view-resolver.tsx +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -14,7 +14,7 @@ import { ParticleListView } from "@/features/particles/particle-list-view"; export default function ParticleViewResolver() { const { networkId, "*": rest } = useParams(); const segments = (rest ?? "").split("/").filter(Boolean); - const path = particlePath(networkId!, segments); + const path = particlePath(networkId!, segments); // path of current container particle const { particle, isLoading, error } = useLiveParticle(path); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index f43e33c..07b4dfd 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback, useReducer } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; -import type { ParticlePath } from "@/lib/particle-path"; +import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator"; import { ParticleRenderer } from "@/features/playback/particle-renderer"; @@ -94,7 +94,7 @@ interface StreamViewProps { } export function StreamView({ path, streamParticle }: StreamViewProps) { - const { networkId } = useParams(); + const { networkId } = parseParticlePath(path); const navigate = useNavigate(); const { children } = useLiveParticleChildren(path); diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index f86a915..4a328df 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -20,3 +20,20 @@ export function useCreateParticle() { ), }); } + +type CreateStreamParticleParams = Omit, "type"> & { + visibleTo?: string[]; +}; + +export function useCreateStreamParticle() { + return useMutation({ + mutationFn: (params: CreateStreamParticleParams) => + createParticle( + params.collectionPath, + "stream", + params.properties, + params.createdByEmail, + params.visibleTo, + ), + }); +} diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 72fdfd4..c349a0e 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -3,6 +3,7 @@ import { subscribeToParticle, subscribeToParticleChildren, subscribeToLatestChild, + getParticle, } from "@/lib/firestore-particles"; import type { Particle } from "@/api/types"; import { @@ -10,6 +11,7 @@ import { toFirestoreDocPath, toFirestoreChildrenPath, } from "@/lib/particle-path"; +import { useQuery } from "@tanstack/react-query"; interface UseLiveParticleResult { particle: Particle | null; @@ -116,3 +118,16 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult return { latestChild, isLoading }; } + +export function useParticle(path?: ParticlePath) { + return useQuery({ + queryKey: ["particle", path], + queryFn: async () => { + if (!path) return null; + const docPath = toFirestoreDocPath(path); + const particle = await getParticle(docPath); + return particle; + }, + enabled: !!path, + }); +} diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 368c025..41e0338 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -17,7 +17,7 @@ import { type Unsubscribe, } from "firebase/firestore"; import { firestoreDb } from "@/firebase"; -import { ParticleSchema } from "@/api/types"; +import { isContainerType, ParticleSchema } from "@/api/types"; import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types"; // --- Converter --- @@ -36,15 +36,55 @@ const particleConverter: FirestoreDataConverter = { options?: SnapshotOptions, ): Particle { const raw = snap.data(options); - return ParticleSchema.parse({ - id: snap.id, - type: raw.type, - properties: raw.properties, - created_at: (raw.created_at as Timestamp).toDate(), - created_by_email: raw.created_by_email, - updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, - visible_to: raw.visible_to, - }); + 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_email: raw.created_by_email, + updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, + visible_to: raw.visible_to, + markers: raw.markers + ? Object.fromEntries( + Object.entries(raw.markers).map(([key, value]) => [ + key, + (value as Timestamp).toDate(), + ]), + ) + : 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_email: raw.created_by_email, + 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": + return ParticleSchema.parse({ + id: snap.id, + type: raw.type, + properties: raw.properties, + created_at: (raw.created_at as Timestamp).toDate(), + created_by_email: raw.created_by_email, + updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, + }); + default: + throw new Error(`Unknown particle type: ${type}`); + } }, }; @@ -123,20 +163,29 @@ export async function createParticle( type: T, properties: ParticlePropertiesMap[T], createdByEmail: string, + // Must be passed for container types + visibleTo?: string[], ): Promise { + 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_email: createdByEmail, + ...(visibleTo ? { visible_to: visibleTo } : {}), }); const ref = await addDoc(typedCollection(collectionPath), particle); return ref.id; } // This allows updating properties without overwriting the entire properties object -export async function updateParticle( +export async function updateParticleProperties( docPath: string, properties: Partial, ): Promise { @@ -152,3 +201,24 @@ export async function updateParticle( updated_at: serverTimestamp(), }); } + +export async function updateParticleVisibleTo( + docPath: string, + visibleTo: string[], +): Promise { + 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(), + }); +}