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(), name: z.string(),
status: z.enum(["open", "closed"]), status: z.enum(["open", "closed"]),
description: z.string().optional(), description: z.string().optional(),
// e.g. ["human:[email protected]", "human:[email protected]"] - 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 type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderPropertiesSchema = z.object({ export const FolderPropertiesSchema = z.object({
name: z.string(), name: z.string(),
color: z.string().optional(), color: z.string().optional(),
// e.g. ["human:[email protected]", "human:[email protected]"] - 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>; export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
@@ -137,8 +131,20 @@ const ParticleBaseSchema = z.object({
}); });
export const ParticleSchema = z.discriminatedUnion("type", [ export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }), ParticleBaseSchema.extend({
ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }), type: z.literal("stream"), properties: StreamPropertiesSchema,
// e.g. ["human:[email protected]", "human:[email protected]"] - 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:[email protected]", "human:[email protected]"] - 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("media"), properties: MediaPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
+4 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store"; 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 { useRecordingMode } from "@/hooks/use-recording-mode";
import { useRecorder } from "@/features/compose/use-recorder"; import { useRecorder } from "@/features/compose/use-recorder";
import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
@@ -38,6 +38,7 @@ export function ComposeOverlay({
const [recordingMode] = useRecordingMode(); const [recordingMode] = useRecordingMode();
const userEmail = useAuthStore((s) => s.user?.email); const userEmail = useAuthStore((s) => s.user?.email);
const createParticle = useCreateParticle(); const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
// Refs to avoid stale closures in keyboard handler // Refs to avoid stale closures in keyboard handler
const stepRef = useRef(step); const stepRef = useRef(step);
@@ -158,15 +159,14 @@ export function ComposeOverlay({
const collectionPath = toFirestoreChildrenPath(particlePath(networkId)); const collectionPath = toFirestoreChildrenPath(particlePath(networkId));
const streamId = await createParticle.mutateAsync({ const streamId = await createStream.mutateAsync({
collectionPath, collectionPath,
type: "stream",
properties: { properties: {
name: streamName, name: streamName,
status: "open", status: "open",
visible_to: visibleTo,
}, },
createdByEmail: userEmail, createdByEmail: userEmail,
visibleTo,
}); });
const streamChildrenPath = toFirestoreChildrenPath( const streamChildrenPath = toFirestoreChildrenPath(
+3 -13
View File
@@ -13,7 +13,7 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from "@/hooks/use-networks";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; 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 { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getParticle } from "@/lib/firestore-particles"; import { getParticle } from "@/lib/firestore-particles";
@@ -60,19 +60,9 @@ function TopBar() {
const { networkId, "*": rest } = useParams(); const { networkId, "*": rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean); 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( const { data: particle } = useParticle(path);
{
queryKey: ["particle", path],
queryFn: async () => {
if (!path) return null;
const particle = await getParticle(toFirestoreDocPath(path));
return particle;
},
enabled: !!path,
},
);
return ( return (
<div className="drag-region flex items-center gap-3 border-b px-3 py-1"> <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() { export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams(); const { networkId, "*": rest } = useParams();
const segments = (rest ?? "").split("/").filter(Boolean); 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); const { particle, isLoading, error } = useLiveParticle(path);
+3 -3
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, useReducer } from "react"; 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 type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle"; 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 { ComposeOverlay } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator"; import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { ParticleRenderer } from "@/features/playback/particle-renderer"; import { ParticleRenderer } from "@/features/playback/particle-renderer";
@@ -94,7 +94,7 @@ interface StreamViewProps {
} }
export function StreamView({ path, streamParticle }: StreamViewProps) { export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = useParams(); const { networkId } = parseParticlePath(path);
const navigate = useNavigate(); const navigate = useNavigate();
const { children } = useLiveParticleChildren(path); 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, subscribeToParticle,
subscribeToParticleChildren, subscribeToParticleChildren,
subscribeToLatestChild, subscribeToLatestChild,
getParticle,
} from "@/lib/firestore-particles"; } from "@/lib/firestore-particles";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
import { import {
@@ -10,6 +11,7 @@ import {
toFirestoreDocPath, toFirestoreDocPath,
toFirestoreChildrenPath, toFirestoreChildrenPath,
} from "@/lib/particle-path"; } from "@/lib/particle-path";
import { useQuery } from "@tanstack/react-query";
interface UseLiveParticleResult { interface UseLiveParticleResult {
particle: Particle | null; particle: Particle | null;
@@ -116,3 +118,16 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
return { latestChild, isLoading }; 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, type Unsubscribe,
} from "firebase/firestore"; } from "firebase/firestore";
import { firestoreDb } from "@/firebase"; import { firestoreDb } from "@/firebase";
import { ParticleSchema } from "@/api/types"; import { isContainerType, ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types"; import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
// --- Converter --- // --- Converter ---
@@ -36,15 +36,55 @@ const particleConverter: FirestoreDataConverter<Particle> = {
options?: SnapshotOptions, options?: SnapshotOptions,
): Particle { ): Particle {
const raw = snap.data(options); const raw = snap.data(options);
return ParticleSchema.parse({ if (typeof raw.type !== "string") {
id: snap.id, throw new Error(`Invalid particle type: ${raw.type}`);
type: raw.type, }
properties: raw.properties, const type = raw.type as ParticleType;
created_at: (raw.created_at as Timestamp).toDate(), switch (type) {
created_by_email: raw.created_by_email, case "stream":
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, return ParticleSchema.parse({
visible_to: raw.visible_to, 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, type: T,
properties: ParticlePropertiesMap[T], properties: ParticlePropertiesMap[T],
createdByEmail: string, createdByEmail: string,
// Must be passed for container types
visibleTo?: string[],
): Promise<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({ const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type id: "", // ignored by toFirestore, but needed to satisfy the type
type, type,
properties, properties,
created_at: new Date(), created_at: new Date(),
created_by_email: createdByEmail, created_by_email: createdByEmail,
...(visibleTo ? { visible_to: visibleTo } : {}),
}); });
const ref = await addDoc(typedCollection(collectionPath), particle); const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id; return ref.id;
} }
// This allows updating properties without overwriting the entire properties object // 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, docPath: string,
properties: Partial<ParticlePropertiesMap[T]>, properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> { ): Promise<void> {
@@ -152,3 +201,24 @@ export async function updateParticle<T extends ParticleType>(
updated_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(),
});
}