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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
|
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import Animated, {
runOnJS,
scrollTo,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
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 {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {isIOS, isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {ConvoItem} from '#/state/messages/convo/types'
import {useAgent} from '#/state/session'
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 {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
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 renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return <MessageItem item={item} />
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
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({
hasScrolled,
setHasScrolled,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
}) {
const convo = useConvoActive()
const {getAgent} = useAgent()
const flatListRef = useAnimatedRef<FlatList>()
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
// 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)
const prevItemCount = useRef(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 keyboardIsAnimating = useSharedValue(false)
const layoutHeight = useSharedValue(0)
// 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 off whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasScrolled) {
flatListRef.current?.scrollToOffset({
offset: height - contentHeight.value,
animated: false,
})
}
// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) {
let newOffset = height
// 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 (
hasScrolled &&
height - contentHeight.value > layoutHeight.value - 50 &&
convo.items.length - prevItemCount.current > 1
) {
newOffset = contentHeight.value - 50
setShowNewMessagesPill(true)
} else if (!hasScrolled && !convo.isFetchingHistory) {
setHasScrolled(true)
}
flatListRef.current?.scrollToOffset({
offset: newOffset,
animated: hasScrolled,
})
isMomentumScrolling.value = true
}
contentHeight.value = height
prevItemCount.current = convo.items.length
},
[
hasScrolled,
convo.items.length,
convo.isFetchingHistory,
setHasScrolled,
// all of these are stable
contentHeight,
flatListRef,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
keyboardIsAnimating.value,
layoutHeight.value,
],
)
const onStartReached = useCallback(() => {
if (hasScrolled) {
convo.fetchMessageHistory()
}
}, [convo, hasScrolled])
const onSendMessage = useCallback(
async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true})
await rt.detectFacets(getAgent())
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
}
return true
})
convo.sendMessage({
text: rt.text,
facets: rt.facets,
})
},
[convo, getAgent],
)
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
},
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
)
// This tells us when we are no longer scrolling
const onMomentumEnd = React.useCallback(() => {
'worklet'
isMomentumScrolling.value = false
}, [isMomentumScrolling])
const scrollToEndNow = React.useCallback(() => {
if (isMomentumScrolling.value) return
flatListRef.current?.scrollToEnd({animated: false})
}, [flatListRef, isMomentumScrolling.value])
// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {gtMobile} = useBreakpoints()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset =
isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight
// 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) => {
'worklet'
// This never applies on web
if (isWeb) {
keyboardIsAnimating.value = false
return
}
// We only need to scroll to end while the keyboard is _opening_. During close, the position changes as we
// "expand" the view.
if (prev && now > prev) {
scrollTo(flatListRef, 0, contentHeight.value + now, false)
}
keyboardIsAnimating.value = Boolean(prev) && now !== prev
},
)
// 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(() => ({
marginBottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
}))
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} onMomentumEnd={onMomentumEnd}>
<List
ref={flatListRef}
data={convo.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
containWeb={true}
contentContainerStyle={[a.px_md]}
disableVirtualization={true}
// 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,
}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convo.isFetchingHistory} />
}
/>
</ScrollProvider>
<MessageInput
onSendMessage={onSendMessage}
scrollToEnd={scrollToEndNow}
/>
{showNewMessagesPill && <NewMessagesPill />}
</Animated.View>
)
}
|