this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[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>

authored by

Hailey
Eric Bailey
and committed by
GitHub
87681667 6a4199fe

+158 -95
+8 -4
src/components/dms/MessageItem.tsx
··· 10 10 import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 11 11 import {Text} from '#/components/Typography' 12 12 13 - export function MessageItem({ 13 + export let MessageItem = ({ 14 14 item, 15 15 next, 16 16 pending, ··· 21 21 | ChatBskyConvoDefs.DeletedMessageView 22 22 | null 23 23 pending?: boolean 24 - }) { 24 + }): React.ReactNode => { 25 25 const t = useTheme() 26 26 const {currentAccount} = useSession() 27 27 ··· 97 97 ) 98 98 } 99 99 100 - export function MessageItemMetadata({ 100 + MessageItem = React.memo(MessageItem) 101 + 102 + let MessageItemMetadata = ({ 101 103 message, 102 104 isLastInGroup, 103 105 style, ··· 105 107 message: ChatBskyConvoDefs.MessageView 106 108 isLastInGroup: boolean 107 109 style: StyleProp<TextStyle> 108 - }) { 110 + }): React.ReactNode => { 109 111 const t = useTheme() 110 112 const {_} = useLingui() 111 113 ··· 173 175 </TimeElapsed> 174 176 ) 175 177 } 178 + 179 + MessageItemMetadata = React.memo(MessageItemMetadata) 176 180 177 181 function localDateString(date: Date) { 178 182 // can't use toISOString because it should be in local time
+1 -4
src/screens/Messages/Conversation/MessageInput.tsx
··· 19 19 export function MessageInput({ 20 20 onSendMessage, 21 21 onFocus, 22 - onBlur, 23 22 }: { 24 23 onSendMessage: (message: string) => void 25 - onFocus: () => void 26 - onBlur: () => void 24 + onFocus?: () => void 27 25 }) { 28 26 const {_} = useLingui() 29 27 const t = useTheme() ··· 85 83 scrollEnabled={isInputScrollable} 86 84 blurOnSubmit={false} 87 85 onFocus={onFocus} 88 - onBlur={onBlur} 89 86 onContentSizeChange={onInputLayout} 90 87 ref={inputRef} 91 88 />
-1
src/screens/Messages/Conversation/MessageInput.web.tsx
··· 12 12 }: { 13 13 onSendMessage: (message: string) => void 14 14 onFocus: () => void 15 - onBlur: () => void 16 15 }) { 17 16 const {_} = useLingui() 18 17 const t = useTheme()
+105 -62
src/screens/Messages/Conversation/MessagesList.tsx
··· 1 1 import React, {useCallback, useRef} from 'react' 2 - import { 3 - FlatList, 4 - NativeScrollEvent, 5 - NativeSyntheticEvent, 6 - Platform, 7 - View, 8 - } from 'react-native' 2 + import {FlatList, Platform, View} from 'react-native' 9 3 import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 4 + import {runOnJS, useSharedValue} from 'react-native-reanimated' 5 + import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' 10 6 import {useSafeAreaInsets} from 'react-native-safe-area-context' 11 7 import {msg, Trans} from '@lingui/macro' 12 8 import {useLingui} from '@lingui/react' ··· 14 10 import {isIOS} from '#/platform/detection' 15 11 import {useChat} from '#/state/messages' 16 12 import {ConvoItem, ConvoStatus} from '#/state/messages/convo' 13 + import {ScrollProvider} from 'lib/ScrollContext' 14 + import {isWeb} from 'platform/detection' 15 + import {List} from 'view/com/util/List' 17 16 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' 18 17 import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' 18 + import {useScrollToEndOnFocus} from '#/screens/Messages/Conversation/useScrollToEndOnFocus' 19 19 import {atoms as a, useBreakpoints} from '#/alf' 20 20 import {Button, ButtonText} from '#/components/Button' 21 21 import {MessageItem} from '#/components/dms/MessageItem' ··· 79 79 return item.key 80 80 } 81 81 82 - function onScrollToEndFailed() { 82 + function onScrollToIndexFailed() { 83 83 // Placeholder function. You have to give FlatList something or else it will error. 84 84 } 85 85 86 86 export function MessagesList() { 87 87 const chat = useChat() 88 88 const flatListRef = useRef<FlatList>(null) 89 - // We use this to know if we should scroll after a new clop is added to the list 90 - const isAtBottom = useRef(false) 91 - const currentOffset = React.useRef(0) 92 89 93 - const onContentSizeChange = useCallback(() => { 94 - if (currentOffset.current <= 100) { 95 - flatListRef.current?.scrollToOffset({offset: 0, animated: true}) 96 - } 97 - }, []) 90 + // 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 91 + // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to 92 + // the bottom. 93 + const isAtBottom = useSharedValue(true) 98 94 99 - const onEndReached = useCallback(() => { 100 - if (chat.status === ConvoStatus.Ready) { 101 - chat.fetchMessageHistory() 102 - } 103 - }, [chat]) 95 + // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing 96 + // onStartReached to fire. 97 + const contentHeight = useSharedValue(0) 104 98 105 - const onInputFocus = useCallback(() => { 106 - if (!isAtBottom.current) { 107 - flatListRef.current?.scrollToOffset({offset: 0, animated: true}) 108 - } 109 - }, []) 99 + const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false) 110 100 111 - const onInputBlur = useCallback(() => {}, []) 101 + // This is only used on native because `Keyboard` can't be imported on web. On web, an input focus will immediately 102 + // trigger scrolling to the bottom. On native however, we need to wait for the keyboard to present before scrolling, 103 + // which is what this hook listens for 104 + useScrollToEndOnFocus(flatListRef) 105 + 106 + // Every time the content size changes, that means one of two things is happening: 107 + // 1. New messages are being added from the log or from a message you have sent 108 + // 2. Old messages are being prepended to the top 109 + // 110 + // The first time that the content size changes is when the initial items are rendered. Because we cannot rely on 111 + // `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated. 112 + // 113 + // Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of 114 + // the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However 115 + // we will not scroll whenever new items get prepended to the top. 116 + const onContentSizeChange = useCallback( 117 + (_: number, height: number) => { 118 + contentHeight.value = height 119 + 120 + // This number _must_ be the height of the MaybeLoader component 121 + if (height <= 50 || !isAtBottom.value) { 122 + return 123 + } 124 + 125 + flatListRef.current?.scrollToOffset({ 126 + animated: hasInitiallyScrolled, 127 + offset: height, 128 + }) 129 + }, 130 + [contentHeight, hasInitiallyScrolled, isAtBottom.value], 131 + ) 132 + 133 + // The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached` 134 + // immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls. 135 + const onStartReached = useCallback(() => { 136 + if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) { 137 + chat.fetchMessageHistory() 138 + } 139 + }, [chat, hasInitiallyScrolled]) 112 140 113 141 const onSendMessage = useCallback( 114 142 (text: string) => { ··· 122 150 ) 123 151 124 152 const onScroll = React.useCallback( 125 - (e: NativeSyntheticEvent<NativeScrollEvent>) => { 126 - currentOffset.current = e.nativeEvent.contentOffset.y 153 + (e: ReanimatedScrollEvent) => { 154 + 'worklet' 155 + const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height 156 + 157 + // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom 158 + // when a new message is added, hence the 100 pixel offset 159 + isAtBottom.value = e.contentSize.height - 100 < bottomOffset 160 + 161 + // This number _must_ be the height of the MaybeLoader component. 162 + // We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which 163 + // adds a 50 pixel offset. 164 + if (contentHeight.value > 50 && !hasInitiallyScrolled) { 165 + runOnJS(setHasInitiallyScrolled)(true) 166 + } 127 167 }, 128 - [], 168 + [contentHeight.value, hasInitiallyScrolled, isAtBottom], 129 169 ) 170 + 171 + const onInputFocus = React.useCallback(() => { 172 + flatListRef.current?.scrollToEnd({animated: true}) 173 + }, [flatListRef]) 130 174 131 175 const {bottom: bottomInset} = useSafeAreaInsets() 132 176 const {gtMobile} = useBreakpoints() ··· 139 183 keyboardVerticalOffset={keyboardVerticalOffset} 140 184 behavior="padding" 141 185 contentContainerStyle={a.flex_1}> 142 - <FlatList 143 - ref={flatListRef} 144 - data={chat.status === ConvoStatus.Ready ? chat.items : undefined} 145 - keyExtractor={keyExtractor} 146 - renderItem={renderItem} 147 - contentContainerStyle={{paddingHorizontal: 10}} 148 - // In the future, we might want to adjust this value. Not very concerning right now as long as we are only 149 - // dealing with text. But whenever we have images or other media and things are taller, we will want to lower 150 - // this...probably. 151 - initialNumToRender={20} 152 - // Same with the max to render per batch. Let's be safe for now though. 153 - maxToRenderPerBatch={25} 154 - inverted={true} 155 - onEndReached={onEndReached} 156 - onScrollToIndexFailed={onScrollToEndFailed} 157 - onContentSizeChange={onContentSizeChange} 158 - onScroll={onScroll} 159 - // We don't really need to call this much since there are not any animations that rely on this 160 - scrollEventThrottle={100} 161 - maintainVisibleContentPosition={{ 162 - minIndexForVisible: 1, 163 - }} 164 - ListFooterComponent={ 165 - <MaybeLoader 166 - isLoading={ 167 - chat.status === ConvoStatus.Ready && chat.isFetchingHistory 186 + {/* This view keeps the scroll bar and content within the CenterView on web, otherwise the entire window would scroll */} 187 + {/* @ts-expect-error web only */} 188 + <View style={[{flex: 1}, isWeb && {'overflow-y': 'scroll'}]}> 189 + {/* Custom scroll provider so we can use the `onScroll` event in our custom List implementation */} 190 + <ScrollProvider onScroll={onScroll}> 191 + <List 192 + ref={flatListRef} 193 + data={chat.status === ConvoStatus.Ready ? chat.items : undefined} 194 + renderItem={renderItem} 195 + keyExtractor={keyExtractor} 196 + disableVirtualization={true} 197 + initialNumToRender={isWeb ? 50 : 25} 198 + maxToRenderPerBatch={isWeb ? 50 : 25} 199 + keyboardDismissMode="on-drag" 200 + maintainVisibleContentPosition={{ 201 + minIndexForVisible: 1, 202 + }} 203 + removeClippedSubviews={false} 204 + onContentSizeChange={onContentSizeChange} 205 + onStartReached={onStartReached} 206 + onScrollToIndexFailed={onScrollToIndexFailed} 207 + scrollEventThrottle={100} 208 + ListHeaderComponent={ 209 + <MaybeLoader 210 + isLoading={ 211 + chat.status === ConvoStatus.Ready && chat.isFetchingHistory 212 + } 213 + /> 168 214 } 169 215 /> 170 - } 171 - removeClippedSubviews={true} 172 - keyboardDismissMode="on-drag" 173 - /> 216 + </ScrollProvider> 217 + </View> 174 218 <MessageInput 175 219 onSendMessage={onSendMessage} 176 - onFocus={onInputFocus} 177 - onBlur={onInputBlur} 220 + onFocus={isWeb ? onInputFocus : undefined} 178 221 /> 179 222 </KeyboardAvoidingView> 180 223 )
+16
src/screens/Messages/Conversation/useScrollToEndOnFocus.ts
··· 1 + import React from 'react' 2 + import {FlatList, Keyboard} from 'react-native' 3 + 4 + export function useScrollToEndOnFocus(flatListRef: React.RefObject<FlatList>) { 5 + React.useEffect(() => { 6 + const listener = Keyboard.addListener('keyboardDidShow', () => { 7 + requestAnimationFrame(() => { 8 + flatListRef.current?.scrollToEnd({animated: true}) 9 + }) 10 + }) 11 + 12 + return () => { 13 + listener.remove() 14 + } 15 + }, [flatListRef]) 16 + }
+6
src/screens/Messages/Conversation/useScrollToEndOnFocus.web.ts
··· 1 + import React from 'react' 2 + import {FlatList} from 'react-native' 3 + 4 + // Stub for web 5 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 + export function useScrollToEndOnFocus(flatListRef: React.RefObject<FlatList>) {}
+22 -24
src/state/messages/convo.ts
··· 710 710 getItems(): ConvoItem[] { 711 711 const items: ConvoItem[] = [] 712 712 713 - // `newMessages` is in insertion order, unshift to reverse 714 - this.newMessages.forEach(m => { 713 + this.headerItems.forEach(item => { 714 + items.push(item) 715 + }) 716 + 717 + this.pastMessages.forEach(m => { 715 718 if (ChatBskyConvoDefs.isMessageView(m)) { 716 719 items.unshift({ 717 720 type: 'message', ··· 729 732 } 730 733 }) 731 734 732 - // `newMessages` is in insertion order, unshift to reverse 733 - this.pendingMessages.forEach(m => { 734 - items.unshift({ 735 - type: 'pending-message', 736 - key: m.id, 737 - message: { 738 - ...m.message, 739 - id: nanoid(), 740 - rev: '__fake__', 741 - sentAt: new Date().toISOString(), 742 - sender: this.sender, 743 - }, 744 - nextMessage: null, 745 - }) 746 - }) 747 - 748 - this.footerItems.forEach(item => { 749 - items.unshift(item) 750 - }) 751 - 752 - this.pastMessages.forEach(m => { 735 + this.newMessages.forEach(m => { 753 736 if (ChatBskyConvoDefs.isMessageView(m)) { 754 737 items.push({ 755 738 type: 'message', ··· 767 750 } 768 751 }) 769 752 770 - this.headerItems.forEach(item => { 753 + this.pendingMessages.forEach(m => { 754 + items.push({ 755 + type: 'pending-message', 756 + key: m.id, 757 + message: { 758 + ...m.message, 759 + id: nanoid(), 760 + rev: '__fake__', 761 + sentAt: new Date().toISOString(), 762 + sender: this.sender, 763 + }, 764 + nextMessage: null, 765 + }) 766 + }) 767 + 768 + this.footerItems.forEach(item => { 771 769 items.push(item) 772 770 }) 773 771