Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[🐴] 🤞 This should be the final message list change - Use dispatchCommand so we don't need to know the content height #4090

Merged
merged 13 commits into from
May 18, 2024
6 changes: 1 addition & 5 deletions src/screens/Messages/Conversation/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/ico

export function MessageInput({
onSendMessage,
scrollToEnd,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
Expand Down Expand Up @@ -75,10 +73,8 @@ export function MessageInput({

setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)

scrollToEnd()
},
[scrollToEnd, topInset],
[topInset],
)

return (
Expand Down
1 change: 0 additions & 1 deletion src/screens/Messages/Conversation/MessageInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
Expand Down
183 changes: 109 additions & 74 deletions src/screens/Messages/Conversation/MessagesList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import Animated, {
dispatchCommand,
runOnJS,
scrollTo,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef,
Expand Down Expand Up @@ -92,15 +92,14 @@ export function MessagesList({

// 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 contentHeight = useSharedValue(0)
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)

// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false)
const keyboardIsAnimating = useSharedValue(false)
const isDragging = useSharedValue(false)
const layoutHeight = useSharedValue(0)

// -- 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
// 2. Old messages are being prepended to the top
Expand All @@ -117,85 +116,78 @@ export function MessagesList({
// previous off whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasScrolled) {
flatListRef.current?.scrollToOffset({
offset: height - contentHeight.value,
offset: height - prevContentHeight.current,
animated: false,
})
}

// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) {
let newOffset = height
if (height > 50 && isAtBottom.value) {
// If the size of the content is changing by more than the height of the screen, then we should only
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
// really large - and the normal chat behavior would be to still scroll to the end if it's only one
// message - we ignore this rule if there's only one additional message
if (
hasScrolled &&
height - contentHeight.value > layoutHeight.value - 50 &&
height - prevContentHeight.current > layoutHeight.value - 50 &&
convoState.items.length - prevItemCount.current > 1
) {
newOffset = contentHeight.value - 50
flatListRef.current?.scrollToOffset({
offset: height - layoutHeight.value + 50,
animated: hasScrolled,
})
setShowNewMessagesPill(true)
} else if (!hasScrolled && !convoState.isFetchingHistory) {
setHasScrolled(true)
}
} else {
flatListRef.current?.scrollToOffset({
offset: height,
animated: hasScrolled,
})

flatListRef.current?.scrollToOffset({
offset: newOffset,
animated: hasScrolled,
})
isMomentumScrolling.value = true
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
// because otherwise there is too much of a delay between the time the content
// scrolls and the time the screen appears, causing a flicker.
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
// is actually async itself - all the info has to come across the bridge first.
if (!hasScrolled && !convoState.isFetchingHistory) {
setTimeout(() => {
setHasScrolled(true)
}, 100)
}
}
}
contentHeight.value = height

prevContentHeight.current = height
prevItemCount.current = convoState.items.length
},
[
hasScrolled,
convoState.items.length,
convoState.isFetchingHistory,
setHasScrolled,
// all of these are stable
contentHeight,
convoState.isFetchingHistory,
convoState.items.length,
// these are stable
flatListRef,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
keyboardIsAnimating.value,
isAtBottom.value,
layoutHeight.value,
],
)

const onBeginDrag = React.useCallback(() => {
'worklet'
isDragging.value = true
}, [isDragging])

const onEndDrag = React.useCallback(() => {
'worklet'
isDragging.value = false
}, [isDragging])

const onStartReached = useCallback(() => {
if (hasScrolled) {
convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled])

const onSendMessage = useCallback(
async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true})
await rt.detectFacets(getAgent())
rt = shortenLinks(rt)

// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
}
return true
})

convoState.sendMessage({
text: rt.text,
facets: rt.facets,
})
},
[convoState, getAgent],
)

const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
Expand All @@ -218,22 +210,12 @@ export function MessagesList({
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
)

// This tells us when we are no longer scrolling
const onMomentumEnd = React.useCallback(() => {
'worklet'
isMomentumScrolling.value = false
}, [isMomentumScrolling])

const scrollToEndNow = React.useCallback(() => {
if (isMomentumScrolling.value) return
flatListRef.current?.scrollToEnd({animated: false})
}, [flatListRef, isMomentumScrolling.value])

// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
const finalKeyboardHeight = useSharedValue(0)

// On web, we don't want to do anything.
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
Expand All @@ -244,16 +226,19 @@ export function MessagesList({
'worklet'
// This never applies on web
if (isWeb) {
keyboardIsAnimating.value = false
return
}

// We only need to scroll to end while the keyboard is _opening_. During close, the position changes as we
// "expand" the view.
if (prev && now > prev) {
scrollTo(flatListRef, 0, contentHeight.value + now, false)
// Only call this on every frame while _opening_ the keyboard
if (prev && now > 0 && now >= prev) {
dispatchCommand(flatListRef, 'scrollToEnd', [false])
}

// We want to store the full keyboard height after it fully opens so we can make some
// assumptions in onLayout
if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) {
finalKeyboardHeight.value = now
}
keyboardIsAnimating.value = Boolean(prev) && now !== prev
},
)

Expand All @@ -266,10 +251,62 @@ export function MessagesList({
: bottomOffset,
}))

// -- Message sending
const onSendMessage = useCallback(
async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true})
await rt.detectFacets(getAgent())
rt = shortenLinks(rt)

// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
}
return true
})

convoState.sendMessage({
text: rt.text,
facets: rt.facets,
})
},
[convoState, getAgent],
)

// Any time the List layout changes, we want to scroll to the bottom. This only happens whenever
// the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`.
// This accounts for things like the emoji keyboard opening, changes in block state, etc.
const onListLayout = React.useCallback(() => {
if (isDragging.value) return

const kh = animatedKeyboard.height.value
const fkh = finalKeyboardHeight.value

// We only run the layout scroll if:
// - We're on web
// - The keyboard is not open. This accounts for changing block states
// - The final keyboard height has been initially set and the keyboard height is greater than that
if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) {
flatListRef.current?.scrollToEnd({animated: true})
}
}, [
flatListRef,
finalKeyboardHeight.value,
animatedKeyboard.height.value,
isDragging.value,
])

return (
<Animated.View style={[a.flex_1, animatedStyle]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
<ScrollProvider
onScroll={onScroll}
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}>
<List
ref={flatListRef}
data={convoState.items}
Expand All @@ -288,6 +325,7 @@ export function MessagesList({
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onLayout={onListLayout}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
Expand All @@ -301,10 +339,7 @@ export function MessagesList({
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : (
<MessageInput
onSendMessage={onSendMessage}
scrollToEnd={scrollToEndNow}
/>
<MessageInput onSendMessage={onSendMessage} />
)}
</>
) : (
Expand Down
Loading