From 7b000429e92fa82cb1e14e41a8708728184dbe14 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sat, 30 May 2026 11:09:44 -0700 Subject: [PATCH] mobile: follow a just-sent particle when at the end of a stream (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the local user sends a particle while positioned on the last one in a stream, advance playback to the newly created particle so they stay caught up and the stream isn't left in an unseen state. If they're browsing earlier particles, their position is left untouched. The mobile reducer only auto-advanced on PARTICLE_ADDED when status was already "ended", so sending while sitting on (but not finished with) the last particle left it unseen. This threads an onParticleCreated callback from ComposeDock's default send paths up to StreamView, which follows the particle via goToParticle only when currentIndex is the last slot — mirroring desktop's StreamView.handleParticleCreated. Co-authored-by: Claude --- .../src/features/compose/ComposeDock.tsx | 14 ++++++-- .../src/features/stream-view/StreamView.tsx | 33 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index 25a6577..001f6b8 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -56,6 +56,13 @@ interface ComposeDockProps { silentPresence?: boolean; submitMedia?: (params: SubmitMediaParams) => Promise; submitText?: (content: string) => Promise; + /** + * Called with the new particle's id right after it's created on the default + * send path. Not fired when `submitMedia`/`submitText` overrides are supplied, + * since those own the created particle themselves. Lets the stream follow a + * just-sent particle when the user was at the end. + */ + onParticleCreated?: (particleId: string) => void; } export function ComposeDock({ @@ -64,6 +71,7 @@ export function ComposeDock({ silentPresence = false, submitMedia, submitText: submitTextOverride, + onParticleCreated, }: ComposeDockProps) { const userId = useAuthStore((s) => s.user?.id); @@ -147,7 +155,7 @@ export function ComposeDock({ source: "camera", }); } else { - await uploadMediaParticle({ + const particleId = await uploadMediaParticle({ networkId, targetPath, fileUri: captured.uri, @@ -156,6 +164,7 @@ export function ComposeDock({ source: "camera", createdByHumanId: userId, }); + onParticleCreated?.(particleId); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setUi({ kind: "idle" }); @@ -174,12 +183,13 @@ export function ComposeDock({ if (submitTextOverride) { await submitTextOverride(content); } else { - await createTextParticle({ + const particleId = await createTextParticle({ networkId, targetPath, content, createdByHumanId: userId, }); + onParticleCreated?.(particleId); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index d4cf69d..cc65acf 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -105,8 +105,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const insets = useSafeAreaInsets(); const { composingUsers } = useStreamComposing(); - const { children, currentParticle, currentIndex, status, next, prev } = - useStreamPlayback(streamParticle, path); + const { + children, + currentParticle, + currentIndex, + status, + next, + prev, + goToParticle, + } = useStreamPlayback(streamParticle, path); const paused = usePlaybackPauseStore(selectIsPaused); const composing = usePlaybackPauseStore(selectIsComposing); @@ -257,6 +264,22 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const openReactions = useCallback(() => setReactionsOpen(true), []); + // When the local user sends a particle while sitting on the last one, follow + // it forward so they stay caught up and the stream isn't left unseen. If they + // were browsing earlier particles, leave their position untouched. `children` + // is still the pre-send snapshot here (the live query hasn't delivered the new + // particle yet), so the last slot is children.length - 1; goToParticle resolves + // once it arrives. Mirrors desktop's StreamView.handleParticleCreated. + const handleParticleCreated = useCallback( + (particleId: string) => { + if (currentIndex === -1) return; + if (currentIndex === children.length - 1) { + goToParticle(particleId); + } + }, + [children.length, currentIndex, goToParticle], + ); + // Reset progress whenever the active particle changes. useEffect(() => { setProgress(0); @@ -642,7 +665,11 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { {/* Compose dock + recording overlays. Sits above the GestureDetector so its hold-FAB pan gesture isn't competed-with by the StreamView tap zones. */} - + {/* Reaction sheet — slides up over everything, suspends playback internally while open. */}