[Clipclops] Refactor message list (#3832)

* rework the list for accessibility

* Reverse reverse

* progress

* good to start testing

* memo `MessageItem`

* small hack

* use our custom `List` impl

* use `ScrollProvider` for `onScroll` event

* remove use of `runOnJS`

* actually, let's keep it

* add some comments

---------

Co-authored-by: Eric Bailey <git@esb.lol>
zio/stable
Hailey 2024-05-03 14:18:01 -07:00 committed by GitHub
parent 6a4199febb
commit 876816675e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 160 additions and 97 deletions

View File

@ -10,7 +10,7 @@ import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function MessageItem({ export let MessageItem = ({
item, item,
next, next,
pending, pending,
@ -21,7 +21,7 @@ export function MessageItem({
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
| null | null
pending?: boolean pending?: boolean
}) { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@ -97,7 +97,9 @@ export function MessageItem({
) )
} }
export function MessageItemMetadata({ MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message, message,
isLastInGroup, isLastInGroup,
style, style,
@ -105,7 +107,7 @@ export function MessageItemMetadata({
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean isLastInGroup: boolean
style: StyleProp<TextStyle> style: StyleProp<TextStyle>
}) { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@ -174,6 +176,8 @@ export function MessageItemMetadata({
) )
} }
MessageItemMetadata = React.memo(MessageItemMetadata)
function localDateString(date: Date) { function localDateString(date: Date) {
// can't use toISOString because it should be in local time // can't use toISOString because it should be in local time
const mm = date.getMonth() const mm = date.getMonth()

View File

@ -19,11 +19,9 @@ import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/ico
export function MessageInput({ export function MessageInput({
onSendMessage, onSendMessage,
onFocus, onFocus,
onBlur,
}: { }: {
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
onFocus: () => void onFocus?: () => void
onBlur: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@ -85,7 +83,6 @@ export function MessageInput({
scrollEnabled={isInputScrollable} scrollEnabled={isInputScrollable}
blurOnSubmit={false} blurOnSubmit={false}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur}
onContentSizeChange={onInputLayout} onContentSizeChange={onInputLayout}
ref={inputRef} ref={inputRef}
/> />

View File

@ -12,7 +12,6 @@ export function MessageInput({
}: { }: {
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
onFocus: () => void onFocus: () => void
onBlur: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()

View File

@ -1,12 +1,8 @@
import React, {useCallback, useRef} from 'react' import React, {useCallback, useRef} from 'react'
import { import {FlatList, Platform, View} from 'react-native'
FlatList,
NativeScrollEvent,
NativeSyntheticEvent,
Platform,
View,
} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -14,8 +10,12 @@ import {useLingui} from '@lingui/react'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useChat} from '#/state/messages' import {useChat} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo' import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {useScrollToEndOnFocus} from '#/screens/Messages/Conversation/useScrollToEndOnFocus'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
@ -79,36 +79,64 @@ function keyExtractor(item: ConvoItem) {
return item.key return item.key
} }
function onScrollToEndFailed() { function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error. // Placeholder function. You have to give FlatList something or else it will error.
} }
export function MessagesList() { export function MessagesList() {
const chat = useChat() const chat = useChat()
const flatListRef = useRef<FlatList>(null) const flatListRef = useRef<FlatList>(null)
// We use this to know if we should scroll after a new clop is added to the list
const isAtBottom = useRef(false)
const currentOffset = React.useRef(0)
const onContentSizeChange = useCallback(() => { // 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
if (currentOffset.current <= 100) { // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
flatListRef.current?.scrollToOffset({offset: 0, animated: true}) // the bottom.
} const isAtBottom = useSharedValue(true)
}, [])
const onEndReached = useCallback(() => { // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
if (chat.status === ConvoStatus.Ready) { // onStartReached to fire.
const contentHeight = useSharedValue(0)
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
// This is only used on native because `Keyboard` can't be imported on web. On web, an input focus will immediately
// trigger scrolling to the bottom. On native however, we need to wait for the keyboard to present before scrolling,
// which is what this hook listens for
useScrollToEndOnFocus(flatListRef)
// 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
//
// 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.
//
// 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(
(_: number, height: number) => {
contentHeight.value = height
// This number _must_ be the height of the MaybeLoader component
if (height <= 50 || !isAtBottom.value) {
return
}
flatListRef.current?.scrollToOffset({
animated: hasInitiallyScrolled,
offset: height,
})
},
[contentHeight, hasInitiallyScrolled, isAtBottom.value],
)
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
const onStartReached = useCallback(() => {
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
chat.fetchMessageHistory() chat.fetchMessageHistory()
} }
}, [chat]) }, [chat, hasInitiallyScrolled])
const onInputFocus = useCallback(() => {
if (!isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
}, [])
const onInputBlur = useCallback(() => {}, [])
const onSendMessage = useCallback( const onSendMessage = useCallback(
(text: string) => { (text: string) => {
@ -122,12 +150,28 @@ export function MessagesList() {
) )
const onScroll = React.useCallback( const onScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => { (e: ReanimatedScrollEvent) => {
currentOffset.current = e.nativeEvent.contentOffset.y 'worklet'
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
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
// This number _must_ be the height of the MaybeLoader component.
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
// adds a 50 pixel offset.
if (contentHeight.value > 50 && !hasInitiallyScrolled) {
runOnJS(setHasInitiallyScrolled)(true)
}
}, },
[], [contentHeight.value, hasInitiallyScrolled, isAtBottom],
) )
const onInputFocus = React.useCallback(() => {
flatListRef.current?.scrollToEnd({animated: true})
}, [flatListRef])
const {bottom: bottomInset} = useSafeAreaInsets() const {bottom: bottomInset} = useSafeAreaInsets()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60 const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
@ -139,42 +183,41 @@ export function MessagesList() {
keyboardVerticalOffset={keyboardVerticalOffset} keyboardVerticalOffset={keyboardVerticalOffset}
behavior="padding" behavior="padding"
contentContainerStyle={a.flex_1}> contentContainerStyle={a.flex_1}>
<FlatList {/* This view keeps the scroll bar and content within the CenterView on web, otherwise the entire window would scroll */}
ref={flatListRef} {/* @ts-expect-error web only */}
data={chat.status === ConvoStatus.Ready ? chat.items : undefined} <View style={[{flex: 1}, isWeb && {'overflow-y': 'scroll'}]}>
keyExtractor={keyExtractor} {/* Custom scroll provider so we can use the `onScroll` event in our custom List implementation */}
renderItem={renderItem} <ScrollProvider onScroll={onScroll}>
contentContainerStyle={{paddingHorizontal: 10}} <List
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only ref={flatListRef}
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower data={chat.status === ConvoStatus.Ready ? chat.items : undefined}
// this...probably. renderItem={renderItem}
initialNumToRender={20} keyExtractor={keyExtractor}
// Same with the max to render per batch. Let's be safe for now though. disableVirtualization={true}
maxToRenderPerBatch={25} initialNumToRender={isWeb ? 50 : 25}
inverted={true} maxToRenderPerBatch={isWeb ? 50 : 25}
onEndReached={onEndReached} keyboardDismissMode="on-drag"
onScrollToIndexFailed={onScrollToEndFailed} maintainVisibleContentPosition={{
onContentSizeChange={onContentSizeChange} minIndexForVisible: 1,
onScroll={onScroll} }}
// We don't really need to call this much since there are not any animations that rely on this removeClippedSubviews={false}
scrollEventThrottle={100} onContentSizeChange={onContentSizeChange}
maintainVisibleContentPosition={{ onStartReached={onStartReached}
minIndexForVisible: 1, onScrollToIndexFailed={onScrollToIndexFailed}
}} scrollEventThrottle={100}
ListFooterComponent={ ListHeaderComponent={
<MaybeLoader <MaybeLoader
isLoading={ isLoading={
chat.status === ConvoStatus.Ready && chat.isFetchingHistory chat.status === ConvoStatus.Ready && chat.isFetchingHistory
}
/>
} }
/> />
} </ScrollProvider>
removeClippedSubviews={true} </View>
keyboardDismissMode="on-drag"
/>
<MessageInput <MessageInput
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
onFocus={onInputFocus} onFocus={isWeb ? onInputFocus : undefined}
onBlur={onInputBlur}
/> />
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )

View File

@ -0,0 +1,16 @@
import React from 'react'
import {FlatList, Keyboard} from 'react-native'
export function useScrollToEndOnFocus(flatListRef: React.RefObject<FlatList>) {
React.useEffect(() => {
const listener = Keyboard.addListener('keyboardDidShow', () => {
requestAnimationFrame(() => {
flatListRef.current?.scrollToEnd({animated: true})
})
})
return () => {
listener.remove()
}
}, [flatListRef])
}

View File

@ -0,0 +1,6 @@
import React from 'react'
import {FlatList} from 'react-native'
// Stub for web
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useScrollToEndOnFocus(flatListRef: React.RefObject<FlatList>) {}

View File

@ -710,8 +710,11 @@ export class Convo {
getItems(): ConvoItem[] { getItems(): ConvoItem[] {
const items: ConvoItem[] = [] const items: ConvoItem[] = []
// `newMessages` is in insertion order, unshift to reverse this.headerItems.forEach(item => {
this.newMessages.forEach(m => { items.push(item)
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) { if (ChatBskyConvoDefs.isMessageView(m)) {
items.unshift({ items.unshift({
type: 'message', type: 'message',
@ -729,9 +732,26 @@ export class Convo {
} }
}) })
// `newMessages` is in insertion order, unshift to reverse this.newMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.push({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
this.pendingMessages.forEach(m => { this.pendingMessages.forEach(m => {
items.unshift({ items.push({
type: 'pending-message', type: 'pending-message',
key: m.id, key: m.id,
message: { message: {
@ -746,28 +766,6 @@ export class Convo {
}) })
this.footerItems.forEach(item => { this.footerItems.forEach(item => {
items.unshift(item)
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.push({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
this.headerItems.forEach(item => {
items.push(item) items.push(item)
}) })