Fix blank message list after submit on Android (#11042)

This commit is contained in:
DS Boyce
2026-07-02 09:35:04 -07:00
committed by GitHub
parent b27ef2b6df
commit 40df0d773a
+172 -100
View File
@@ -86,6 +86,8 @@ import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel'
import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel'
import {KeyboardStickyView} from './vendor/KeyboardStickyView' import {KeyboardStickyView} from './vendor/KeyboardStickyView'
const SATURATING_SCROLL_OFFSET = 1e7
function MaybeLoader({isLoading}: {isLoading: boolean}) { function MaybeLoader({isLoading}: {isLoading: boolean}) {
return ( return (
<View <View
@@ -198,39 +200,52 @@ export function MessagesList({
[inputHeightUI], [inputHeightUI],
) )
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items /**
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to * We need to keep track of when the scroll offset is at the bottom of the list
// the bottom. * to know when to scroll as new items are added to the list. For example, if
* the user is scrolled up to view older messages, we don't want to scroll to
* the bottom.
*/
const isAtBottom = useSharedValue(true) const isAtBottom = useSharedValue(true)
// Set when the local user sends a message so we follow it to the end even /**
// from a scrolled-up position. On native, onContentSizeChange can't be relied * Set when the local user sends a message so we follow it to the end even
// on: with maintainVisibleContentPosition anchored to item 0, appending a * from a scrolled-up position. On native, onContentSizeChange can't be relied
// message below the viewport reports no content-size change, so that callback * on: with maintainVisibleContentPosition anchored to item 0, appending a
// never fires for the send. We watch the rendered item count instead and * message below the viewport reports no content-size change, so that callback
// scroll imperatively once our pending message lands (APP-2223). On web, * never fires for the send. We watch the rendered item count instead and
// onContentSizeChange also fires for the send, so the scroll may run from both * scroll imperatively once our pending message lands (APP-2223). On web,
// paths - both target the end, so the result is correct. * onContentSizeChange also fires for the send, so the scroll may run from both
* paths - both target the end, so the result is correct.
*/
const pendingSendScroll = useRef(false) const pendingSendScroll = useRef(false)
// Handle for the in-flight send-scroll burst (see startSendScrollBurst). Held /**
// here so the re-init effect below can cancel a burst that belongs to the * Handle for the in-flight send-scroll burst (see startSendScrollBurst). Held
// previous convo lifecycle. * here so the re-init effect below can cancel a burst that belongs to the
* previous convo lifecycle.
*/
const sendScrollRaf = useRef(0) const sendScrollRaf = useRef(0)
// This will be used on web to assist in determining if we need to maintain the content offset /** This will be used on web to assist in determining if we need to maintain the content offset */
const isAtTop = useSharedValue(true) const isAtTop = useSharedValue(true)
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing /**
// onStartReached to fire. * Used to keep track of the current content height. We'll need this in
* `onScroll` so we know when to start allowing onStartReached to fire.
*/
const prevContentHeight = useRef(0) const prevContentHeight = useRef(0)
const prevItemCount = useRef(0) const prevItemCount = useRef(0)
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset /**
// (which causes an early onScroll with negative offset) can't prevent the first scroll. * Tracks whether the initial scroll-to-bottom has been triggered. Separated
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding). * from isAtBottom so that contentInset (which causes an early onScroll with
// `didInitialScroll` is the reactive mirror of the ref so the reveal effect below can depend on it; the ref * negative offset) can't prevent the first scroll. Reset when hasScrolled goes
// itself stays as the synchronous re-entry guard inside onContentSizeChange. * back to false (e.g. convo re-initialization after backgrounding).
* `didInitialScroll` is the reactive mirror of the ref so the reveal effect
* below can depend on it; the ref itself stays as the synchronous re-entry
* guard inside onContentSizeChange.
*/
const hasInitiallyScrolled = useRef(false) const hasInitiallyScrolled = useRef(false)
const [didInitialScroll, setDidInitialScroll] = useState(false) const [didInitialScroll, setDidInitialScroll] = useState(false)
const prevHasScrolled = useRef(hasScrolled) const prevHasScrolled = useRef(hasScrolled)
@@ -238,9 +253,11 @@ export function MessagesList({
if (prevHasScrolled.current && !hasScrolled) { if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false hasInitiallyScrolled.current = false
setDidInitialScroll(false) setDidInitialScroll(false)
// Drop any unfired send pin and stop an in-flight scroll burst: the /*
// initial-scroll path owns positioning during re-init, and the pending * Drop any unfired send pin and stop an in-flight scroll burst: the
// message they referred to belongs to the previous lifecycle. * initial-scroll path owns positioning during re-init, and the pending
* message they referred to belongs to the previous lifecycle.
*/
pendingSendScroll.current = false pendingSendScroll.current = false
cancelAnimationFrame(sendScrollRaf.current) cancelAnimationFrame(sendScrollRaf.current)
sendScrollRaf.current = 0 sendScrollRaf.current = 0
@@ -248,12 +265,17 @@ export function MessagesList({
prevHasScrolled.current = hasScrolled prevHasScrolled.current = hasScrolled
}, [hasScrolled]) }, [hasScrolled])
// Reveal the list once history has finished loading. We can't reveal earlier because the list isn't inverted - /*
// we must scroll to the bottom (newest message) before fading in, or the user sees a flash of top-anchored content. * Reveal the list once history has finished loading. We can't reveal earlier
// This is purely state-driven so it doesn't depend on a layout callback firing: a firehose-delivered message can * because the list isn't inverted - we must scroll to the bottom (newest
// dedupe against the fetched history and produce no content-size change, in which case nothing would otherwise * message) before fading in, or the user sees a flash of top-anchored content.
// reveal the list and it would stay hidden forever (APP-2238). Either the initial scroll has run, or there's * This is purely state-driven so it doesn't depend on a layout callback
// nothing to scroll (empty convo) - both are safe to reveal once !isFetchingHistory. * firing: a firehose-delivered message can dedupe against the fetched history
* and produce no content-size change, in which case nothing would otherwise
* reveal the list and it would stay hidden forever (APP-2238). Either the
* initial scroll has run, or there's nothing to scroll (empty convo) - both
* are safe to reveal once !isFetchingHistory.
*/
useEffect(() => { useEffect(() => {
if (hasScrolled || convoState.isFetchingHistory) return if (hasScrolled || convoState.isFetchingHistory) return
if (didInitialScroll || renderItems.length === 0) { if (didInitialScroll || renderItems.length === 0) {
@@ -277,29 +299,41 @@ export function MessagesList({
} }
}, [convoState.status]) }, [convoState.status])
// Scroll to a saturating offset rather than scrollToEnd: when the keyboard is /**
// open, KeyboardChatScrollView lifts the content via extraContentPadding, and * Scroll to a saturating offset rather than scrollToEnd: when the keyboard is
// scrollToEnd's internal target is unaware of that lift, so it lands short by * open, KeyboardChatScrollView lifts the content via extraContentPadding, and
// the keyboard height. An over-large offset clamps to the true bottom. * scrollToEnd's internal target is unaware of that lift, so it lands short by
* the keyboard height. An over-large offset clamps to the true bottom.
*
* The offset must stay within Android's native scroll range: View.scrollTo
* takes a 32-bit int, so Number.MAX_SAFE_INTEGER (~9e15) overflows and lands
* on a garbage position - the list paints blank and maintainVisibleContent
* Position then re-anchors to index 0 (the top). iOS uses a CGFloat offset and
* clamps MAX_SAFE_INTEGER fine, but a value larger than any conceivable chat
* content clamps to the true bottom just as well on both, so we use one that
* fits comfortably in int32. (APP-2223)
*/
const scrollSendToBottom = useCallback(() => { const scrollSendToBottom = useCallback(() => {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: Number.MAX_SAFE_INTEGER, offset: SATURATING_SCROLL_OFFSET,
animated: true, animated: true,
}) })
}, [flatListRef]) }, [flatListRef])
// A single scroll can't follow a send to the bottom: a multi-line send settles /**
// over several layout passes (the tall pending item being measured, then the * A single scroll can't follow a send to the bottom: a multi-line send settles
// composer collapsing back to one line), and the content bottom keeps moving * over several layout passes (the tall pending item being measured, then the
// after the scroll target was clamped. We can't drive this off the composer's * composer collapsing back to one line), and the content bottom keeps moving
// height drop either - that signal is global, outlives the send, and races the * after the scroll target was clamped. We can't drive this off the composer's
// pending-message append. Instead we re-assert the saturating scroll across a * height drop either - that signal is global, outlives the send, and races the
// short window keyed to the send. Each call re-clamps to the *current* true * pending-message append. Instead we re-assert the saturating scroll across a
// bottom, so the last one lands settled regardless of how many passes it took. * short window keyed to the send. Each call re-clamps to the *current* true
// The burst is bounded and self-terminating, so it can't leak into a later * bottom, so the last one lands settled regardless of how many passes it took.
// unrelated resize, and it polls geometry rather than depending on * The burst is bounded and self-terminating, so it can't leak into a later
// onContentSizeChange (which doesn't fire for the send append on native - see * unrelated resize, and it polls geometry rather than depending on
// the pendingSendScroll declaration). * onContentSizeChange (which doesn't fire for the send append on native - see
* the pendingSendScroll declaration).
*/
const stopSendScrollBurst = useCallback(() => { const stopSendScrollBurst = useCallback(() => {
cancelAnimationFrame(sendScrollRaf.current) cancelAnimationFrame(sendScrollRaf.current)
sendScrollRaf.current = 0 sendScrollRaf.current = 0
@@ -318,11 +352,13 @@ export function MessagesList({
// Cancel any in-flight burst on unmount. // Cancel any in-flight burst on unmount.
useEffect(() => stopSendScrollBurst, [stopSendScrollBurst]) useEffect(() => stopSendScrollBurst, [stopSendScrollBurst])
// Follow a just-sent message to the end. This runs when the rendered item /*
// count changes, but only fires once the tail item is our own optimistic * Follow a just-sent message to the end. This runs when the rendered item
// pending message (pending-message items are local-only). That way a foreign * count changes, but only fires once the tail item is our own optimistic
// message arriving between send and our append doesn't consume the pin or yank * pending message (pending-message items are local-only). That way a foreign
// a scrolled-up reader down to it - the pin waits for our message to land. * message arriving between send and our append doesn't consume the pin or yank
* a scrolled-up reader down to it - the pin waits for our message to land.
*/
useEffect(() => { useEffect(() => {
if (!pendingSendScroll.current) return if (!pendingSendScroll.current) return
if (renderItems.at(-1)?.type !== 'pending-message') return if (renderItems.at(-1)?.type !== 'pending-message') return
@@ -332,20 +368,28 @@ export function MessagesList({
// -- Scroll handling // -- Scroll handling
// Every time the content size changes, that means one of two things is happening: /**
// 1. New messages are being added from the log or from a message you have sent * Every time the content size changes, that means one of two things is happening:
// 2. Old messages are being prepended to the top * 1. New messages are being added from the log or from a message you have sent
// * 2. Old messages are being prepended to the top
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on *
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated. * The first time that the content size changes is when the initial items are
// * rendered. Because we cannot rely on `initialScrollIndex`, we need to
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of * immediately scroll to the bottom of the list. That scroll will not be
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However * animated.
// we will not scroll whenever new items get prepended to the top. *
* Subsequent resizes will only scroll to the bottom if the user is at the
* bottom of the list (within 100 pixels of the bottom). Therefore, any new
* messages that come in or are sent will result in an animated scroll to end.
* However we will not scroll whenever new items get prepended to the top.
*/
const onContentSizeChange = useCallback( const onContentSizeChange = useCallback(
(_: number, height: number) => { (_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the /*
// previous off whenever we add new content to the previous offset whenever we add new content to the list. * Because web does not have `maintainVisibleContentPosition` support, we
* will need to manually scroll to the previous offset whenever we add new
* content to the list.
*/
if (IS_WEB && isAtTop.get() && hasScrolled) { if (IS_WEB && isAtTop.get() && hasScrolled) {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: height - prevContentHeight.current, offset: height - prevContentHeight.current,
@@ -353,10 +397,14 @@ export function MessagesList({
}) })
} }
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset /*
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. * Initial scroll to bottom - unconditional, not gated on isAtBottom. This
// Empty convos take this path too (once history is done). Revealing the list is handled by the effect above, * is separated because contentInset can cause an early onScroll with a
// which fires once history finishes - we just record that the scroll has happened. * negative offset that sets isAtBottom to false before we get here. Empty
* convos take this path too (once history is done). Revealing the list is
* handled by the effect above, which fires once history finishes - we just
* record that the scroll has happened.
*/
if ( if (
!hasInitiallyScrolled.current && !hasInitiallyScrolled.current &&
(renderItems.length > 0 || !convoState.isFetchingHistory) (renderItems.length > 0 || !convoState.isFetchingHistory)
@@ -371,10 +419,14 @@ export function MessagesList({
// Subsequent: auto-scroll only if user is at the bottom // Subsequent: auto-scroll only if user is at the bottom
if (isAtBottom.get()) { if (isAtBottom.get()) {
// If the size of the content is changing by more than the height of the screen, then we don't /*
// want to scroll further than the start of all the new content. Since we are storing the previous offset, * If the size of the content is changing by more than the height of the
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill * screen, then we don't want to scroll further than the start of all the
// that can be pressed to immediately scroll to the end. * new content. Since we are storing the previous offset, we can just
* scroll the user to that offset and add a little bit of padding. We'll
* also show the pill that can be pressed to immediately scroll to the
* end.
*/
if ( if (
didBackground.current && didBackground.current &&
hasScrolled && hasScrolled &&
@@ -392,9 +444,11 @@ export function MessagesList({
} else { } else {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: height, offset: height,
// only animate when new items were appended - pure layout growth /*
// (e.g. the composer spacer getting its height on web) should * only animate when new items were appended - pure layout growth
// snap instantly rather than visibly scrolling * (e.g. the composer spacer getting its height on web) should snap
* instantly rather than visibly scrolling
*/
animated: animated:
hasScrolled && hasScrolled &&
height > prevContentHeight.current && height > prevContentHeight.current &&
@@ -429,8 +483,11 @@ export function MessagesList({
layoutHeight.set(e.layoutMeasurement.height) layoutHeight.set(e.layoutMeasurement.height)
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom /*
// when a new message is added, hence the 100 pixel offset * Most apps have a little bit of space the user can scroll past while
* still automatically scrolling to the bottom when a new message is added,
* hence the 100 pixel offset
*/
isAtBottom.set(e.contentSize.height - 100 < bottomOffset) isAtBottom.set(e.contentSize.height - 100 < bottomOffset)
isAtTop.set(e.contentOffset.y <= 1) isAtTop.set(e.contentOffset.y <= 1)
@@ -461,9 +518,12 @@ export function MessagesList({
) => { ) => {
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
// detect facets without resolution first - this is used to see if there's /*
// any post links in the text that we can embed. We do this first because * detect facets without resolution first - this is used to see if there's
// we want to remove the post link from the text, re-trim, then detect facets * any post links in the text that we can embed. We do this first because
* we want to remove the post link from the text, re-trim, then detect
* facets
*/
rt.detectFacetsWithoutResolution() rt.detectFacetsWithoutResolution()
let embed: let embed:
@@ -476,8 +536,10 @@ export function MessagesList({
| undefined | undefined
let replyTo: ChatBskyConvoDefs.ReplyRef | undefined let replyTo: ChatBskyConvoDefs.ReplyRef | undefined
// Find the embedded link facet and, if it's at the start or end of the /**
// message, remove it from the text (the embed card replaces it). * Find the embedded link facet and, if it's at the start or end of the
* message, remove it from the text (the embed card replaces it).
*/
const stripLinkFacet = (predicate: (uri: string) => boolean) => { const stripLinkFacet = (predicate: (uri: string) => boolean) => {
const linkFacet = rt.facets?.find(facet => const linkFacet = rt.facets?.find(facet =>
facet.features.find( facet.features.find(
@@ -517,8 +579,10 @@ export function MessagesList({
if (!isBskyPostUrl(uri)) return false if (!isBskyPostUrl(uri)) return false
const url = convertBskyAppUrlIfNeeded(uri) const url = convertBskyAppUrlIfNeeded(uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID /*
// so just compare the rkey - not particularly dangerous * this might have a handle instead of a DID so just compare the
* rkey - not particularly dangerous
*/
return post.uri.endsWith(rkey) return post.uri.endsWith(rkey)
}) })
} }
@@ -556,9 +620,11 @@ export function MessagesList({
setHasScrolled(true) setHasScrolled(true)
} }
// Sending your own message should always take you to it, regardless of /*
// current scroll position. The effect watching renderItems.length scrolls * Sending your own message should always take you to it, regardless of
// to the end once the pending message is appended. * current scroll position. The effect watching renderItems.length scrolls
* to the end once the pending message is appended.
*/
pendingSendScroll.current = true pendingSendScroll.current = true
convoState.sendMessage( convoState.sendMessage(
@@ -614,10 +680,12 @@ export function MessagesList({
}) })
}, [flatListRef]) }, [flatListRef])
// Scroll to a message by id, if it's currently loaded in the list. Per the /**
// feature scope, we don't fetch history to find unloaded messages - tapping a * Scroll to a message by id, if it's currently loaded in the list. Per the
// reply to an out-of-window message is a no-op. Returns whether the message * feature scope, we don't fetch history to find unloaded messages - tapping a
// was found, so the caller knows whether to flash it. * reply to an out-of-window message is a no-op. Returns whether the message
* was found, so the caller knows whether to flash it.
*/
const scrollToMessage = useNonReactiveCallback((messageId: string) => { const scrollToMessage = useNonReactiveCallback((messageId: string) => {
const index = renderItems.findIndex( const index = renderItems.findIndex(
item => item =>
@@ -897,9 +965,11 @@ function getFooterState(
const isRequest = const isRequest =
convoState.convo.view.status === 'request' && !hasAcceptOverride convoState.convo.view.status === 'request' && !hasAcceptOverride
// For group chats, the request footer is driven purely off status: the owner /*
// is always 'accepted' so never sees it, while members the owner added are * For group chats, the request footer is driven purely off status: the owner
// 'request' until they accept. This holds even before any messages load. * is always 'accepted' so never sees it, while members the owner added are
* 'request' until they accept. This holds even before any messages load.
*/
if (convoState.convo.kind === 'group' && isRequest) { if (convoState.convo.kind === 'group' && isRequest) {
return 'request' return 'request'
} }
@@ -912,11 +982,13 @@ function getFooterState(
} }
} }
// For direct chats, only show the request footer once there's a message. The /*
// viewer's status stays 'request' until they send their first message, so an * For direct chats, only show the request footer once there's a message. The
// empty direct request is one the viewer started themselves (show the * viewer's status stays 'request' until they send their first message, so an
// composer), whereas any message present must be an incoming one from the * empty direct request is one the viewer started themselves (show the
// other user (show the accept/reject footer). * composer), whereas any message present must be an incoming one from the
* other user (show the accept/reject footer).
*/
if (isRequest) { if (isRequest) {
return 'request' return 'request'
} }