feat: order streams by last child creation

This commit is contained in:
talksik
2026-03-19 10:46:19 -07:00
parent 0723afc412
commit 71dd0149bc
7 changed files with 76 additions and 35 deletions
+3
View File
@@ -138,6 +138,9 @@ export const ParticleSchema = z.discriminatedUnion("type", [
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
// Marks emails to their `playback_position_at`: where they left off in a conversation // Marks emails to their `playback_position_at`: where they left off in a conversation
markers: z.record(z.string(), z.coerce.date()).optional(), 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({ ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema, type: z.literal("folder"), properties: FolderPropertiesSchema,
+14 -21
View File
@@ -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 { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } 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 } from "@/lib/particle-path";
import type { ParticlePath } from "@/lib/particle-path"; import type { ParticlePath } from "@/lib/particle-path";
import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { TextComposeStep } from "@/features/compose/text-compose-step"; import { TextComposeStep } from "@/features/compose/text-compose-step";
@@ -14,6 +14,7 @@ type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring"
interface ComposeOverlayProps { interface ComposeOverlayProps {
networkId: string; networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
targetPath?: ParticlePath; targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void; onActiveChange?: (active: boolean) => void;
} }
@@ -101,12 +102,12 @@ export function ComposeOverlay({
); );
const createChildParticle = useCallback( const createChildParticle = useCallback(
async (collectionPath: string) => { async (path: ParticlePath) => {
if (!userEmail) return; if (!userEmail) return;
if (textContent.trim()) { if (textContent.trim()) {
await createParticle.mutateAsync({ await createParticle.mutateAsync({
collectionPath, path,
type: "text", type: "text",
properties: { content: textContent }, properties: { content: textContent },
createdByEmail: userEmail, createdByEmail: userEmail,
@@ -118,7 +119,7 @@ export function ComposeOverlay({
); );
await createParticle.mutateAsync({ await createParticle.mutateAsync({
collectionPath, path,
type: "media", type: "media",
properties: { properties: {
object_id, object_id,
@@ -142,25 +143,19 @@ export function ComposeOverlay({
); );
// Reply mode: create particle directly under targetPath // Reply mode: create particle directly under targetPath
const submitReply = useCallback(async () => { const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail) return; if (!targetPath || !userEmail) return;
const collectionPath = toFirestoreChildrenPath(targetPath); await createChildParticle(targetPath);
await createChildParticle(collectionPath);
cancel(); cancel();
}, [targetPath, userEmail, createChildParticle, cancel]); });
const submitReplyRef = useRef(submitReply);
submitReplyRef.current = submitReply;
// New stream mode: create stream + first child // New stream mode: create stream + first child
const handleStreamSubmit = useCallback( const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => { async (streamName: string, visibleTo: string[]) => {
if (!userEmail) return; if (!userEmail) return;
const collectionPath = toFirestoreChildrenPath(particlePath(networkId));
const streamId = await createStream.mutateAsync({ const streamId = await createStream.mutateAsync({
collectionPath, networkId,
properties: { properties: {
name: streamName, name: streamName,
status: "open", status: "open",
@@ -169,11 +164,9 @@ export function ComposeOverlay({
visibleTo, visibleTo,
}); });
const streamChildrenPath = toFirestoreChildrenPath( const streamChildrenPath = particlePath(networkId, [streamId]);
particlePath(networkId, [streamId]),
);
await createChildParticle(streamChildrenPath); await createChildParticle(streamChildrenPath);
cancel(); cancel();
}, },
[networkId, userEmail, createParticle, createChildParticle, cancel], [networkId, userEmail, createParticle, createChildParticle, cancel],
@@ -226,7 +219,7 @@ export function ComposeOverlay({
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (targetPath) { if (targetPath) {
submitReplyRef.current(); onSubmitReply();
} else { } else {
setStep("configuring"); setStep("configuring");
} }
@@ -256,7 +249,7 @@ export function ComposeOverlay({
if (step === "idle") return null; if (step === "idle") return null;
const handleTextAdvance = targetPath const handleTextAdvance = targetPath
? submitReply ? onSubmitReply
: () => setStep("configuring"); : () => setStep("configuring");
return ( return (
@@ -204,7 +204,7 @@ interface ParticleListViewProps {
* List of stream particles for a container (network root, folder, etc.). * List of stream particles for a container (network root, folder, etc.).
*/ */
export function ParticleListView({ path }: ParticleListViewProps) { 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 { networkId } = parseParticlePath(path);
const navigate = useNavigate(); const navigate = useNavigate();
+1 -1
View File
@@ -96,7 +96,7 @@ interface StreamViewProps {
export function StreamView({ path, streamParticle }: StreamViewProps) { export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path); const { networkId } = parseParticlePath(path);
const navigate = useNavigate(); const navigate = useNavigate();
const { children } = useLiveParticleChildren(path); const { children } = useLiveParticleChildren(path, "created_at", "asc");
const [state, dispatch] = useReducer(playbackReducer, initialState); const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
+25 -11
View File
@@ -1,9 +1,11 @@
import { useMutation } from "@tanstack/react-query"; 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 type { ParticleType, ParticlePropertiesMap } from "@/api/types";
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
interface CreateParticleParams<T extends ParticleType = ParticleType> { interface CreateParticleParams<T extends ParticleType = ParticleType> {
collectionPath: string; // Path to which the new particle will be added as a child
path: ParticlePath;
type: T; type: T;
properties: ParticlePropertiesMap[T]; properties: ParticlePropertiesMap[T];
createdByEmail: string; createdByEmail: string;
@@ -11,29 +13,41 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
export function useCreateParticle() { export function useCreateParticle() {
return useMutation({ return useMutation({
mutationFn: (params: CreateParticleParams) => mutationFn: async (params: CreateParticleParams) => {
createParticle( const collectionPath = toFirestoreChildrenPath(params.path);
params.collectionPath, const result = await createParticle(
collectionPath,
params.type, params.type,
params.properties, params.properties,
params.createdByEmail, params.createdByEmail,
), );
const streamDocPath = toFirestoreDocPath(params.path);
await updateStreamParticleLastChildParticle(streamDocPath);
return result;
}
}); });
} }
type CreateStreamParticleParams = Omit<CreateParticleParams<"stream">, "type"> & { type CreateStreamParticleParams = {
networkId: string;
properties: ParticlePropertiesMap["stream"];
createdByEmail: string;
visibleTo?: string[]; visibleTo?: string[];
}; };
export function useCreateStreamParticle() { export function useCreateStreamParticle() {
return useMutation({ return useMutation({
mutationFn: (params: CreateStreamParticleParams) => mutationFn: async (params: CreateStreamParticleParams) => {
createParticle( const path = particlePath(params.networkId, []);
params.collectionPath, const networkCollectionPath = toFirestoreChildrenPath(path);
return await createParticle(
networkCollectionPath,
"stream", "stream",
params.properties, params.properties,
params.createdByEmail, params.createdByEmail,
params.visibleTo, params.visibleTo,
), );
}
}); });
} }
+4
View File
@@ -57,6 +57,8 @@ interface UseLiveParticleChildrenResult {
export function useLiveParticleChildren( export function useLiveParticleChildren(
path: ParticlePath, path: ParticlePath,
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
): UseLiveParticleChildrenResult { ): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]); const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -79,6 +81,8 @@ export function useLiveParticleChildren(
setError(err); setError(err);
setIsLoading(false); setIsLoading(false);
}, },
orderByField,
orderDirection,
); );
return unsubscribe; return unsubscribe;
+28 -1
View File
@@ -58,6 +58,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
]), ]),
) )
: undefined, : undefined,
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
}); });
case "folder": case "folder":
return ParticleSchema.parse({ return ParticleSchema.parse({
@@ -127,8 +128,10 @@ export function subscribeToParticleChildren(
collectionPath: string, collectionPath: string,
onData: (children: Particle[]) => void, onData: (children: Particle[]) => void,
onError: (error: Error) => void, onError: (error: Error) => void,
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
): Unsubscribe { ): Unsubscribe {
const q = query(typedCollection(collectionPath), orderBy("created_at")); const q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
return onSnapshot( return onSnapshot(
q, q,
(snap) => { (snap) => {
@@ -222,3 +225,27 @@ export async function updateParticleVisibleTo(
updated_at: serverTimestamp(), 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<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
updated_at: serverTimestamp(),
});
}
export async function updateStreamParticleLastChildParticle(
docPath: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
last_child_created_at: serverTimestamp(),
updated_at: serverTimestamp(),
});
}