diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 6cc26c9..17ae94e 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -136,8 +136,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [ // 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(), + // Marks human_id to their `playback_position_at`: where they left off in a conversation + playback_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(), diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 7ec910e..987eb29 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -84,6 +84,7 @@ function StreamRow({ const streamPath = particlePath(networkId, [particle.id]); const { latestChild } = useLiveLatestChild(streamPath); const user = useAuthStore((s) => s.user); + const userId = user?.id ?? ""; const userEmail = user?.email ?? ""; const isDM = @@ -107,9 +108,9 @@ function StreamRow({ if (!latestChild) return false; const latestChildTimestamp = latestChild.created_at.getTime(); const userPlaybackPosition = - particle.markers?.[userEmail]?.getTime() ?? 0; + particle.playback_markers?.[userId]?.getTime() ?? 0; return latestChildTimestamp > userPlaybackPosition; - }, [latestChild, particle.markers, userEmail]); + }, [latestChild, particle.playback_markers, userId]); const senderPrefix = useMemo(() => { if (!latestChild) return null; diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index f92f3da..4f9aba8 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -1,13 +1,15 @@ -import { useState, useEffect, useCallback, useReducer } from "react"; +import { useState, useEffect, useCallback, useReducer, useRef } from "react"; import { useNavigate } from "react-router-dom"; +import { useAuthStore } from "@/stores/auth-store"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; -import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; +import { parseParticlePath, toFirestoreDocPath, 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"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import ControlsIndicator from "@/features/compose/controls-indicator"; +import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; // --- Playback reducer --- @@ -20,7 +22,7 @@ interface PlaybackState { } type PlaybackAction = - | { type: "INIT"; particleCount: number } + | { type: "INIT"; particleCount: number, initialIndex?: number } | { type: "NEXT"; particleCount: number } | { type: "PREV" } | { type: "GO_TO"; index: number; particleCount: number } @@ -35,7 +37,7 @@ function playbackReducer( switch (action.type) { case "INIT": return { - currentIndex: 0, + currentIndex: action.initialIndex ?? 0, status: action.particleCount > 0 ? "playing" : "idle", paused: false, }; @@ -89,7 +91,7 @@ const initialState: PlaybackState = { // --- StreamView --- interface StreamViewProps { - streamParticle: Particle; + streamParticle: Particle & { type: "stream" }; path: ParticlePath; } @@ -100,16 +102,36 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { const [state, dispatch] = useReducer(playbackReducer, initialState); const [composeActive, setComposeActive] = useState(false); + const hasInitializedRef = useRef(null); - // Init playback when the stream particle changes - useEffect(() => { - dispatch({ type: "INIT", particleCount: children.length }); - }, [streamParticle.id]); + const userId = useAuthStore((s) => s.user?.id); - // Sync when children list changes (e.g. new particle appended via Firestore) + // Init playback once per stream entry, only after children have loaded useEffect(() => { + if (children.length === 0) return; + if (hasInitializedRef.current === streamParticle.id) return; + hasInitializedRef.current = streamParticle.id; + + const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; + let initialIndex = 0; + + if (playbackPosition) { + const foundIndex = children.findIndex( + (c) => c.created_at.getTime() === playbackPosition.getTime(), + ); + if (foundIndex !== -1) { + initialIndex = foundIndex; + } + } + + dispatch({ type: "INIT", particleCount: children.length, initialIndex }); + }, [streamParticle.id, userId, children]); + + // Sync on subsequent changes (new particle appended, removed, etc.) + useEffect(() => { + if (hasInitializedRef.current !== streamParticle.id) return; dispatch({ type: "SYNC_PARTICLES", particleCount: children.length }); - }, [children.length]); + }, [children.length, streamParticle.id]); // Pause/resume playback when compose overlay opens/closes useEffect(() => { @@ -133,8 +155,9 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { ); // Playback keyboard: arrows, escape - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { if (composeActive) return; const target = e.target as HTMLElement; @@ -162,22 +185,24 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { navigate(`/${networkId}`); break; } - }, + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [composeActive, next, prev, navigate, networkId], ); - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); - const currentParticle = children[state.currentIndex] ?? null; + useEffect(() => { + if (!userId || !currentParticle) return; + + const streamDocPath = toFirestoreDocPath(path); + updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at); + }, [currentParticle, path]) + // Stream name from properties (narrowed to stream type) - const streamName = - streamParticle.type === "stream" - ? streamParticle.properties.name - : ""; + const streamName = streamParticle.properties.name // Author info from current particle const authorEmail = currentParticle?.created_by_email ?? ""; diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 7bb5099..7561af8 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { createParticle, updateStreamParticleLastChildParticle } from "@/lib/firestore-particles"; +import { createParticle, updateStreamLastChildAt } from "@/lib/firestore-particles"; import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path"; @@ -23,7 +23,7 @@ export function useCreateParticle() { ); const streamDocPath = toFirestoreDocPath(params.path); - await updateStreamParticleLastChildParticle(streamDocPath); + await updateStreamLastChildAt(streamDocPath); return result; } }); diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 5cb5ef1..4e51642 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -50,9 +50,9 @@ const particleConverter: FirestoreDataConverter = { 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 + playback_markers: raw.playback_markers ? Object.fromEntries( - Object.entries(raw.markers).map(([key, value]) => [ + Object.entries(raw.playback_markers).map(([key, value]) => [ key, (value as Timestamp).toDate(), ]), @@ -240,7 +240,7 @@ export async function updateParticle( }); } -export async function updateStreamParticleLastChildParticle( +export async function updateStreamLastChildAt( docPath: string, ): Promise { const particleRef = typedDoc(docPath); @@ -249,3 +249,16 @@ export async function updateStreamParticleLastChildParticle( updated_at: serverTimestamp(), }); } + +export async function updateStreamPlaybackMarker( + docPath: string, + humanId: string, + playbackPositionAt: Date, +): Promise { + const particleRef = typedDoc(docPath); + const markerField = `playback_markers.${humanId}`; + await updateDoc(particleRef, { + [markerField]: Timestamp.fromDate(playbackPositionAt), + updated_at: serverTimestamp(), + }); +}