diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx index b756a46cef6..095cb498a17 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx @@ -431,6 +431,60 @@ describe('MobileNativeChatQueuedMessages', () => { } }) + it('places a failed row by the height a returning message lays out at, not the one it left with', async () => { + const scrollTo = vi.fn() + const mounted = create(createElement('View'), { + createNodeMock: (node) => + node.type === 'ScrollView' + ? { scrollTo, scrollToEnd: vi.fn(), flashScrollIndicators: vi.fn() } + : null + }) + renderer = mounted + // Rows render in queue order. + const layRow = async (index: number, height: number) => { + const row = rows(mounted)[index]! + await act(async () => row.props.onLayout({ nativeEvent: { layout: { height } } })) + } + await act(async () => + mounted.update( + element({ + cards: [card({ messageId: 'a' }), card({ messageId: 'b' }), card({ messageId: 'c' })] + }) + ) + ) + const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView') + await act(async () => { + scroll.props.onLayout({ nativeEvent: { layout: { height: 100 } } }) + scroll.props.onContentSizeChange(0, 300) + }) + // b leaves tall (an open editor or expanded text), then comes back as a plain 60pt row. + await layRow(0, 60) + await layRow(1, 180) + await layRow(2, 60) + await act(async () => + mounted.update(element({ cards: [card({ messageId: 'a' }), card({ messageId: 'c' })] })) + ) + scrollTo.mockClear() + await act(async () => + mounted.update( + element({ + cards: [ + card({ messageId: 'a' }), + card({ messageId: 'b' }), + card({ messageId: 'c', needsAttention: true, caption: "Couldn't send" }) + ] + }) + ) + ) + // The failed row reports before b does; b's old height must not place it. + await layRow(2, 60) + expect(scrollTo).not.toHaveBeenCalled() + await layRow(1, 60) + // c spans 120–180 of a 100pt list: the nearest scroll that shows it, clear of the edge fade. + expect(scrollTo).toHaveBeenCalledTimes(1) + expect(scrollTo).toHaveBeenCalledWith({ y: 96, animated: false }) + }) + it('scrolls to a failure the list mounts open for, and settles when no row lays out again', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts b/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts index 75e169bec58..d0f77f17ff2 100644 --- a/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts +++ b/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts @@ -31,6 +31,10 @@ export const QUEUED_ROW_SHOW_SETTLE_MS = 300 type ShowRow = (row: LayoutRectangle) => void +// Message ids cannot contain NUL. +const ID_SEPARATOR = '\u0000' +const NO_IDS: ReadonlySet = new Set() + /** * Scrolls the queue to one row (a failure that opened the list) once that row has laid out again. * A row whose height never changes reports no layout, so the request settles shortly after either way. @@ -51,6 +55,20 @@ export function useQueuedRowShow( useLayoutEffect(() => { order.current = messageIds }) + // Only queued rows keep a height, so a long conversation holds none for messages that left, and + // an id that comes back (a requeued draft) waits for its own fresh layout. + const liveKey = messageIds.join(ID_SEPARATOR) + // Filled before any row lays out: layout effects run before native layout events. + const live = useRef(NO_IDS) + useLayoutEffect(() => { + live.current = new Set(liveKey ? liveKey.split(ID_SEPARATOR) : []) + for (const messageId of heights.current.keys()) { + if (!live.current.has(messageId)) { + heights.current.delete(messageId) + fresh.current.delete(messageId) + } + } + }, [liveKey]) useLayoutEffect(() => { fresh.current.clear() }, [showId]) @@ -73,6 +91,10 @@ export function useQueuedRowShow( height: number, show: ShowRow ) => { + // A row that already left can still report a layout queued before it went. + if (!live.current.has(messageId)) { + return + } const { showAll } = list shownAll.current = showAll heights.current.set(messageId, height)