[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
parent
6a4199febb
commit
876816675e
|
@ -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()
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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])
|
||||||
|
}
|
|
@ -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>) {}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue