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(),
|
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 }),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user