diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 6cbf8bb..6cc26c9 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -138,6 +138,9 @@ export const ParticleSchema = z.discriminatedUnion("type", [ 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(), + // Timestamp of the most recent child particle + // used for sorting streams by recent activity without needing to query subcollections + last_child_created_at: z.coerce.date().optional(), }), ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema, diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 1dbe838..2c095e1 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { useAuthStore } from "@/stores/auth-store"; 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"; +import { particlePath } from "@/lib/particle-path"; import type { ParticlePath } from "@/lib/particle-path"; import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { TextComposeStep } from "@/features/compose/text-compose-step"; @@ -14,6 +14,7 @@ type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" interface ComposeOverlayProps { networkId: string; + // Optional target path for reply mode. If not provided, compose creates a new stream. targetPath?: ParticlePath; onActiveChange?: (active: boolean) => void; } @@ -101,12 +102,12 @@ export function ComposeOverlay({ ); const createChildParticle = useCallback( - async (collectionPath: string) => { + async (path: ParticlePath) => { if (!userEmail) return; if (textContent.trim()) { await createParticle.mutateAsync({ - collectionPath, + path, type: "text", properties: { content: textContent }, createdByEmail: userEmail, @@ -118,7 +119,7 @@ export function ComposeOverlay({ ); await createParticle.mutateAsync({ - collectionPath, + path, type: "media", properties: { object_id, @@ -142,25 +143,19 @@ export function ComposeOverlay({ ); // Reply mode: create particle directly under targetPath - const submitReply = useCallback(async () => { + const onSubmitReply = useEffectEvent(async () => { if (!targetPath || !userEmail) return; - const collectionPath = toFirestoreChildrenPath(targetPath); - await createChildParticle(collectionPath); + await createChildParticle(targetPath); cancel(); - }, [targetPath, userEmail, createChildParticle, cancel]); - - const submitReplyRef = useRef(submitReply); - submitReplyRef.current = submitReply; + }); // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { if (!userEmail) return; - const collectionPath = toFirestoreChildrenPath(particlePath(networkId)); - const streamId = await createStream.mutateAsync({ - collectionPath, + networkId, properties: { name: streamName, status: "open", @@ -169,11 +164,9 @@ export function ComposeOverlay({ visibleTo, }); - const streamChildrenPath = toFirestoreChildrenPath( - particlePath(networkId, [streamId]), - ); - + const streamChildrenPath = particlePath(networkId, [streamId]); await createChildParticle(streamChildrenPath); + cancel(); }, [networkId, userEmail, createParticle, createChildParticle, cancel], @@ -226,7 +219,7 @@ export function ComposeOverlay({ } else if (e.key === "Enter") { e.preventDefault(); if (targetPath) { - submitReplyRef.current(); + onSubmitReply(); } else { setStep("configuring"); } @@ -256,7 +249,7 @@ export function ComposeOverlay({ if (step === "idle") return null; const handleTextAdvance = targetPath - ? submitReply + ? onSubmitReply : () => setStep("configuring"); return ( diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 13994ff..7ec910e 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -204,7 +204,7 @@ interface ParticleListViewProps { * List of stream particles for a container (network root, folder, etc.). */ export function ParticleListView({ path }: ParticleListViewProps) { - const { children, isLoading } = useLiveParticleChildren(path); + const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc"); const { networkId } = parseParticlePath(path); const navigate = useNavigate(); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 07b4dfd..f92f3da 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -96,7 +96,7 @@ interface StreamViewProps { export function StreamView({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); const navigate = useNavigate(); - const { children } = useLiveParticleChildren(path); + const { children } = useLiveParticleChildren(path, "created_at", "asc"); const [state, dispatch] = useReducer(playbackReducer, initialState); const [composeActive, setComposeActive] = useState(false); diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 4a328df..7bb5099 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -1,9 +1,11 @@ import { useMutation } from "@tanstack/react-query"; -import { createParticle } from "@/lib/firestore-particles"; +import { createParticle, updateStreamParticleLastChildParticle } from "@/lib/firestore-particles"; import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; +import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path"; interface CreateParticleParams { - collectionPath: string; + // Path to which the new particle will be added as a child + path: ParticlePath; type: T; properties: ParticlePropertiesMap[T]; createdByEmail: string; @@ -11,29 +13,41 @@ interface CreateParticleParams { export function useCreateParticle() { return useMutation({ - mutationFn: (params: CreateParticleParams) => - createParticle( - params.collectionPath, + mutationFn: async (params: CreateParticleParams) => { + const collectionPath = toFirestoreChildrenPath(params.path); + const result = await createParticle( + collectionPath, params.type, params.properties, params.createdByEmail, - ), + ); + + const streamDocPath = toFirestoreDocPath(params.path); + await updateStreamParticleLastChildParticle(streamDocPath); + return result; + } }); } -type CreateStreamParticleParams = Omit, "type"> & { +type CreateStreamParticleParams = { + networkId: string; + properties: ParticlePropertiesMap["stream"]; + createdByEmail: string; visibleTo?: string[]; }; export function useCreateStreamParticle() { return useMutation({ - mutationFn: (params: CreateStreamParticleParams) => - createParticle( - params.collectionPath, + mutationFn: async (params: CreateStreamParticleParams) => { + const path = particlePath(params.networkId, []); + const networkCollectionPath = toFirestoreChildrenPath(path); + return await createParticle( + networkCollectionPath, "stream", params.properties, params.createdByEmail, params.visibleTo, - ), + ); + } }); } diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index c349a0e..6d17f61 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -57,6 +57,8 @@ interface UseLiveParticleChildrenResult { export function useLiveParticleChildren( path: ParticlePath, + orderByField: string = "created_at", + orderDirection: "asc" | "desc" = "desc", ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -79,6 +81,8 @@ export function useLiveParticleChildren( setError(err); setIsLoading(false); }, + orderByField, + orderDirection, ); return unsubscribe; diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 41e0338..5cb5ef1 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -58,6 +58,7 @@ const particleConverter: FirestoreDataConverter = { ]), ) : undefined, + last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined, }); case "folder": return ParticleSchema.parse({ @@ -127,8 +128,10 @@ export function subscribeToParticleChildren( collectionPath: string, onData: (children: Particle[]) => void, onError: (error: Error) => void, + orderByField: string = "created_at", + orderDirection: "asc" | "desc" = "desc", ): Unsubscribe { - const q = query(typedCollection(collectionPath), orderBy("created_at")); + const q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection)); return onSnapshot( q, (snap) => { @@ -222,3 +225,27 @@ export async function updateParticleVisibleTo( updated_at: serverTimestamp(), }); } + +// CAUTION: use the other type safe update functions in most cases +// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly +export async function updateParticle( + docPath: string, + fieldName: string, + value: any, +): Promise { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { + [fieldName]: value, + updated_at: serverTimestamp(), + }); +} + +export async function updateStreamParticleLastChildParticle( + docPath: string, +): Promise { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { + last_child_created_at: serverTimestamp(), + updated_at: serverTimestamp(), + }); +}