refactor: extract properties for container particles to flat fields in firestore

This commit is contained in:
talksik
2026-03-19 09:05:12 -07:00
parent c36d72493d
commit 1bf5b466d1
8 changed files with 138 additions and 40 deletions
+14 -8
View File
@@ -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<typeof StreamPropertiesSchema>;
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<typeof FolderPropertiesSchema>;
@@ -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 }),
+4 -4
View File
@@ -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(
+3 -13
View File
@@ -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 (
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
@@ -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);
+3 -3
View File
@@ -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);
+17
View File
@@ -20,3 +20,20 @@ export function useCreateParticle() {
),
});
}
type CreateStreamParticleParams = Omit<CreateParticleParams<"stream">, "type"> & {
visibleTo?: string[];
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: (params: CreateStreamParticleParams) =>
createParticle(
params.collectionPath,
"stream",
params.properties,
params.createdByEmail,
params.visibleTo,
),
});
}
+15
View File
@@ -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,
});
}
+81 -11
View File
@@ -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<Particle> = {
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<T extends ParticleType>(
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: 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_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<T extends ParticleType>(
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> {
@@ -152,3 +201,24 @@ export async function updateParticle<T extends ParticleType>(
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(),
});
}