mobile: follow a just-sent particle when at the end of a stream (#226)

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 <noreply@anthropic.com>
This commit was merged in pull request #226.
This commit is contained in:
Arjun Patel
2026-05-30 11:09:44 -07:00
committed by GitHub
parent 0cd126818a
commit 7b000429e9
2 changed files with 42 additions and 5 deletions
+12 -2
View File
@@ -56,6 +56,13 @@ interface ComposeDockProps {
silentPresence?: boolean;
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
submitText?: (content: string) => Promise<void>;
/**
* 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);
});
@@ -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. */}
<ComposeDock networkId={networkId} targetPath={path} />
<ComposeDock
networkId={networkId}
targetPath={path}
onParticleCreated={handleParticleCreated}
/>
{/* Reaction sheet — slides up over everything, suspends playback
internally while open. */}