1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
|
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import {
KeyboardAvoidingView,
useKeyboardHandler,
} 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isIOS} from '#/platform/detection'
import {useConvo} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
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 {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
<View
style={{
height: 50,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
{isLoading && <Loader size="xl" />}
</View>
)
}
function RetryButton({onPress}: {onPress: () => unknown}) {
const {_} = useLingui()
return (
<View style={{alignItems: 'center'}}>
<Button
label={_(msg`Press to Retry`)}
onPress={onPress}
variant="ghost"
color="negative"
size="small">
<ButtonText>
<Trans>Press to Retry</Trans>
</ButtonText>
</Button>
</View>
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return (
<MessageItem
item={item.message}
next={item.nextMessage}
pending={item.type === 'pending-message'}
/>
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'pending-retry') {
return <RetryButton onPress={item.retry} />
} else if (item.type === 'error-recoverable') {
return <MessageListError item={item} />
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList() {
const convo = useConvo()
const flatListRef = useRef<FlatList>(null)
// 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
// the bottom.
const isAtBottom = useSharedValue(true)
// This will be used on web to assist in determing if we need to maintain the content offset
const isAtTop = useSharedValue(true)
// 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)
// 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 [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
// 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) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// previous offset whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasInitiallyScrolled) {
flatListRef.current?.scrollToOffset({
animated: false,
offset: height - contentHeight.value,
})
}
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,
})
isMomentumScrolling.value = true
},
[
contentHeight,
hasInitiallyScrolled,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
],
)
// 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 (convo.status === ConvoStatus.Ready && hasInitiallyScrolled) {
convo.fetchMessageHistory()
}
}, [convo, hasInitiallyScrolled])
const onSendMessage = useCallback(
(text: string) => {
if (convo.status === ConvoStatus.Ready) {
convo.sendMessage({
text,
})
}
},
[convo],
)
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'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
isAtTop.value = e.contentOffset.y <= 1
// 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, isAtTop],
)
const onMomentumEnd = React.useCallback(() => {
'worklet'
isMomentumScrolling.value = false
}, [isMomentumScrolling])
const scrollToEnd = React.useCallback(() => {
requestAnimationFrame(() => {
if (isMomentumScrolling.value) return
flatListRef.current?.scrollToEnd({animated: true})
isMomentumScrolling.value = true
})
}, [isMomentumScrolling])
const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
// This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly.
const scrollToEndNow = React.useCallback(() => {
flatListRef.current?.scrollToEnd({animated: false})
}, [])
useKeyboardHandler({
onMove: () => {
'worklet'
runOnJS(scrollToEndNow)()
},
})
return (
<KeyboardAvoidingView
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
behavior="padding"
contentContainerStyle={a.flex_1}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
<List
ref={flatListRef}
data={convo.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableVirtualization={true}
initialNumToRender={isWeb ? 50 : 25}
maxToRenderPerBatch={isWeb ? 50 : 25}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
containWeb={true}
contentContainerStyle={{paddingHorizontal: 10}}
removeClippedSubviews={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convo.isFetchingHistory} />
}
/>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</KeyboardAvoidingView>
)
}
|