refactor: extract properties for container particles to flat fields in firestore
This commit is contained in:
+14
-8
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user