[🐴] Fully implement keyboard controller (#4106)

* Revert "[🐴] Ensure keyboard gets dismissed when leaving screen (#4104)"

This reverts commit 3ca671d9aa.

* getting somewhere

* remove some now nuneeded code

* fully implement keyboard controller

* onStartReached check

* fix new messages pill alignment

* scroll to end on press

* simplify pill scroll logic

* update comment

* adjust logic on when to hide the pill

* fix backgrounding jank

* improve look of deleting messages

* add double tap on messages

* better onStartReached logic

* nit

* add hit slop to the gesture

* better gestures for press and hold

* nits
This commit is contained in:
Hailey 2024-05-19 19:25:49 -07:00 committed by GitHub
parent 7de0b0a58c
commit 52beb29a0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 419 additions and 356 deletions

View file

@ -58,6 +58,9 @@ export function MessageInput({
onSendMessage(message.trimEnd())
playHaptic()
setMessage('')
// Pressing the send button causes the text input to lose focus, so we need to
// re-focus it after sending
setTimeout(() => {
inputRef.current?.focus()
}, 100)

View file

@ -1,10 +1,12 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import Animated, {
import {
KeyboardStickyView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {
runOnJS,
scrollTo,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
@ -24,7 +26,6 @@ import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a} from '#/alf'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
@ -80,7 +81,10 @@ export function MessagesList({
const flatListRef = useAnimatedRef<FlatList>()
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
const [newMessagesPill, setNewMessagesPill] = React.useState({
show: false,
startContentOffset: 0,
})
// 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
@ -95,8 +99,14 @@ export function MessagesList({
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)
const isDragging = useSharedValue(false)
// -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0)
const didBackground = React.useRef(false)
React.useEffect(() => {
if (convoState.status === ConvoStatus.Backgrounded) {
didBackground.current = true
}
}, [convoState.status])
// -- Scroll handling
@ -123,24 +133,28 @@ export function MessagesList({
// This number _must_ be the height of the MaybeLoader component
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 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,
// 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 (
didBackground.current &&
hasScrolled &&
height - prevContentHeight.current > layoutHeight.value - 50 &&
convoState.items.length - prevItemCount.current > 1
) {
flatListRef.current?.scrollToOffset({
offset: height - layoutHeight.value + 50,
animated: hasScrolled,
offset: prevContentHeight.current - 65,
animated: true,
})
setNewMessagesPill({
show: true,
startContentOffset: prevContentHeight.current - 65,
})
setShowNewMessagesPill(true)
} else {
flatListRef.current?.scrollToOffset({
offset: height,
animated: hasScrolled,
animated: hasScrolled && height > prevContentHeight.current,
})
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
@ -158,6 +172,7 @@ export function MessagesList({
prevContentHeight.current = height
prevItemCount.current = convoState.items.length
didBackground.current = false
},
[
hasScrolled,
@ -172,88 +187,66 @@ export function MessagesList({
],
)
const onBeginDrag = React.useCallback(() => {
'worklet'
isDragging.value = true
}, [isDragging])
const onEndDrag = React.useCallback(() => {
'worklet'
isDragging.value = false
}, [isDragging])
const onStartReached = useCallback(() => {
if (hasScrolled) {
if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled])
}, [convoState, hasScrolled, layoutHeight.value])
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
layoutHeight.value = e.layoutMeasurement.height
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
if (
showNewMessagesPill &&
e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset
) {
runOnJS(setShowNewMessagesPill)(false)
}
// 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
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1
if (
newMessagesPill.show &&
(e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
isAtBottom.value)
) {
runOnJS(setNewMessagesPill)({
show: false,
startContentOffset: 0,
})
}
},
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
[layoutHeight, newMessagesPill, isAtBottom, isAtTop],
)
// -- 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
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction(
() => animatedKeyboard.height.value,
(now, prev) => {
const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false)
useKeyboardHandler({
onStart: () => {
'worklet'
// This never applies on web
if (isWeb) {
return
}
// We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not
// any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the
// scrollview synchronously.
// On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd`
// method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated
// doesn't support a `scrollToEnd`.
if (prev && now > 0 && now >= prev) {
keyboardIsOpening.value = true
},
onMove: e => {
'worklet'
keyboardHeight.value = e.height
if (e.height > bottomOffset) {
scrollTo(flatListRef, 0, 1e7, 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
}
},
)
onEnd: () => {
'worklet'
keyboardIsOpening.value = false
},
})
// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedStyle = useAnimatedStyle(() => ({
const animatedListStyle = useAnimatedStyle(() => ({
marginBottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
}))
// -- Message sending
@ -282,36 +275,25 @@ export function MessagesList({
[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.
// -- List layout changes (opening emoji keyboard, 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)) {
if (keyboardIsOpening.value) return
if (isWeb || !keyboardIsOpening.value) {
flatListRef.current?.scrollToEnd({animated: true})
}
}, [
flatListRef,
finalKeyboardHeight.value,
animatedKeyboard.height.value,
isDragging.value,
])
}, [flatListRef, keyboardIsOpening.value])
const scrollToEndOnPress = React.useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current,
animated: true,
})
}, [flatListRef])
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}
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={convoState.items}
@ -319,13 +301,14 @@ export function MessagesList({
keyExtractor={keyExtractor}
containWeb={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
initialNumToRender={isNative ? 32 : 62}
maxToRenderPerBatch={isWeb ? 32 : 62}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 1,
minIndexForVisible: 0,
}}
removeClippedSubviews={false}
sideBorders={false}
@ -339,18 +322,20 @@ export function MessagesList({
}
/>
</ScrollProvider>
{!blocked ? (
<>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : (
<MessageInput onSendMessage={onSendMessage} />
)}
</>
) : (
footer
)}
{showNewMessagesPill && <NewMessagesPill />}
</Animated.View>
<KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
{!blocked ? (
<>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : (
<MessageInput onSendMessage={onSendMessage} />
)}
</>
) : (
footer
)}
</KeyboardStickyView>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
)
}

View file

@ -83,6 +83,14 @@ function Inner() {
!convoState.isFetchingHistory &&
convoState.items.length === 0)
// Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
// state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
React.useEffect(() => {
if (convoState.status === ConvoStatus.Initializing) {
setHasScrolled(false)
}
}, [convoState.status])
if (convoState.status === ConvoStatus.Error) {
return (
<CenteredView style={a.flex_1} sideBorders>