@planningcenter/chat-react-native 3.35.0-rc.3 → 3.35.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +39 -7
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/components/conversation/unread_divider.d.ts +6 -0
- package/build/components/conversation/unread_divider.d.ts.map +1 -0
- package/build/components/conversation/unread_divider.js +59 -0
- package/build/components/conversation/unread_divider.js.map +1 -0
- package/build/contexts/conversation_context.d.ts +2 -0
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -5
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +2 -0
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -5
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_conversations_actions.d.ts +5 -0
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +12 -0
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_flat_list_viewability.d.ts +20 -0
- package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
- package/build/hooks/use_flat_list_viewability.js +30 -0
- package/build/hooks/use_flat_list_viewability.js.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.js +62 -0
- package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.js +53 -0
- package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.js +10 -0
- package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +17 -1
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_scroll_tracking.d.ts +13 -0
- package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
- package/build/hooks/use_scroll_tracking.js +45 -0
- package/build/hooks/use_scroll_tracking.js.map +1 -0
- package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
- package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
- package/build/hooks/use_track_highest_seen_message.js +35 -0
- package/build/hooks/use_track_highest_seen_message.js.map +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +87 -44
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +4 -0
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +9 -2
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +20 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/highest_seen_tracker.d.ts +12 -0
- package/build/utils/highest_seen_tracker.d.ts.map +1 -0
- package/build/utils/highest_seen_tracker.js +37 -0
- package/build/utils/highest_seen_tracker.js.map +1 -0
- package/build/utils/message_viewability.d.ts +24 -0
- package/build/utils/message_viewability.d.ts.map +1 -0
- package/build/utils/message_viewability.js +29 -0
- package/build/utils/message_viewability.js.map +1 -0
- package/build/utils/unread_divider_helpers.d.ts +18 -0
- package/build/utils/unread_divider_helpers.d.ts.map +1 -0
- package/build/utils/unread_divider_helpers.js +13 -0
- package/build/utils/unread_divider_helpers.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/contexts/session_context.tsx +1 -1
- package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
- package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
- package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
- package/src/components/conversation/reply_shadow_message.tsx +4 -2
- package/src/components/conversation/unread_divider.tsx +90 -0
- package/src/contexts/conversation_context.tsx +15 -13
- package/src/hooks/use_conversation_messages.ts +19 -3
- package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
- package/src/hooks/use_conversations_actions.ts +15 -0
- package/src/hooks/use_flat_list_viewability.ts +50 -0
- package/src/hooks/use_jump_to_bottom_action.ts +75 -0
- package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
- package/src/hooks/use_jump_to_unread_gates.ts +10 -0
- package/src/hooks/use_mark_latest_message_read.ts +16 -2
- package/src/hooks/use_scroll_tracking.ts +64 -0
- package/src/hooks/use_track_highest_seen_message.ts +43 -0
- package/src/screens/conversation_screen.tsx +173 -70
- package/src/utils/__tests__/group_messages.test.ts +71 -0
- package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
- package/src/utils/__tests__/message_viewability.test.ts +168 -0
- package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
- package/src/utils/cache/messages_cache.ts +5 -0
- package/src/utils/group_messages.ts +42 -2
- package/src/utils/highest_seen_tracker.ts +42 -0
- package/src/utils/message_viewability.ts +49 -0
- package/src/utils/unread_divider_helpers.ts +25 -0
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
useTheme as useNavigationTheme,
|
|
9
9
|
useRoute,
|
|
10
10
|
} from '@react-navigation/native'
|
|
11
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
|
-
import { FlatList, Platform, StyleSheet, View } from 'react-native'
|
|
11
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { ActivityIndicator, FlatList, Platform, StyleSheet, View } from 'react-native'
|
|
13
|
+
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
|
13
14
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
14
15
|
import { Badge, Icon, Text } from '../components'
|
|
15
16
|
import { EmptyConversationBlankState } from '../components/conversation/empty_conversation_blank_state'
|
|
@@ -23,23 +24,43 @@ import {
|
|
|
23
24
|
import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
|
|
24
25
|
import { SystemMessage } from '../components/conversation/system_message'
|
|
25
26
|
import { TypingIndicator } from '../components/conversation/typing_indicator'
|
|
27
|
+
import { UnreadDivider } from '../components/conversation/unread_divider'
|
|
26
28
|
import { KeyboardView } from '../components/display/keyboard_view'
|
|
27
29
|
import BlankState from '../components/primitive/blank_state_primitive'
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ConversationContextProvider,
|
|
32
|
+
useConversationContext,
|
|
33
|
+
} from '../contexts/conversation_context'
|
|
29
34
|
import { useTheme } from '../hooks'
|
|
30
35
|
import { useConversation } from '../hooks/use_conversation'
|
|
31
36
|
import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
|
|
32
37
|
import { useConversationMessages } from '../hooks/use_conversation_messages'
|
|
33
38
|
import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
|
|
34
39
|
import { useFeatures } from '../hooks/use_features'
|
|
40
|
+
import { useFlatListViewability } from '../hooks/use_flat_list_viewability'
|
|
41
|
+
import { useJumpToBottomAction } from '../hooks/use_jump_to_bottom_action'
|
|
42
|
+
import { useJumpToUnreadAnchor } from '../hooks/use_jump_to_unread_anchor'
|
|
43
|
+
import { useJumpToUnreadGates } from '../hooks/use_jump_to_unread_gates'
|
|
35
44
|
import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
|
|
36
45
|
import {
|
|
37
46
|
normalizeAnalyticsMetadata,
|
|
38
47
|
usePublishProductAnalyticsEvent,
|
|
39
48
|
} from '../hooks/use_product_analytics'
|
|
49
|
+
import { useScrollTracking } from '../hooks/use_scroll_tracking'
|
|
50
|
+
import { useTrackHighestSeenMessage } from '../hooks/use_track_highest_seen_message'
|
|
40
51
|
import { ConversationBadgeResource } from '../types/resources/conversation_badge'
|
|
41
52
|
import { getRelativeDateStatus } from '../utils/date'
|
|
42
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
groupMessages,
|
|
55
|
+
UNREAD_DIVIDER_KEY,
|
|
56
|
+
type DateSeparator,
|
|
57
|
+
type EnrichedMessage,
|
|
58
|
+
} from '../utils/group_messages'
|
|
59
|
+
import {
|
|
60
|
+
detectDividerExitTowardNewer,
|
|
61
|
+
reportViewableMessages,
|
|
62
|
+
type ViewabilityObserver,
|
|
63
|
+
} from '../utils/message_viewability'
|
|
43
64
|
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
|
|
44
65
|
import { isSystemMessage } from '../utils/system_messages'
|
|
45
66
|
|
|
@@ -60,6 +81,9 @@ export type ConversationRouteProps = {
|
|
|
60
81
|
|
|
61
82
|
export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
|
|
62
83
|
|
|
84
|
+
const extractItemKey = (item: EnrichedMessage) => String(item.id)
|
|
85
|
+
const maintainVisibleContentPosition = { minIndexForVisible: 0 }
|
|
86
|
+
|
|
63
87
|
export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
64
88
|
const { conversation_id, message_id, reply_root_id } = route.params
|
|
65
89
|
|
|
@@ -72,7 +96,8 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
|
72
96
|
})
|
|
73
97
|
|
|
74
98
|
const lastReadMessageSortKey = conversation.conversationMembership?.lastReadMessageSortKey ?? null
|
|
75
|
-
const jumpToUnreadAnchor =
|
|
99
|
+
const jumpToUnreadAnchor =
|
|
100
|
+
featureEnabled('jump_to_unread') && !reply_root_id ? lastReadMessageSortKey : null
|
|
76
101
|
const initialMessageId = message_id ?? jumpToUnreadAnchor
|
|
77
102
|
const initialMessageIdIsAnchor = !!initialMessageId && !message_id
|
|
78
103
|
|
|
@@ -91,29 +116,49 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
|
91
116
|
function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
92
117
|
const styles = useStyles()
|
|
93
118
|
const navigation = useNavigation()
|
|
94
|
-
const {
|
|
95
|
-
|
|
119
|
+
const {
|
|
120
|
+
conversation_id: conversationId,
|
|
121
|
+
editing_message_id: editingMessageId,
|
|
122
|
+
reply_root_id: replyRootId,
|
|
123
|
+
reply_root_author_name: replyRootAuthorName,
|
|
124
|
+
} = route.params
|
|
96
125
|
const { data: conversation } = useConversation(route.params)
|
|
97
|
-
const {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
const {
|
|
127
|
+
messages,
|
|
128
|
+
fetchOlderMessages,
|
|
129
|
+
fetchNewerMessages,
|
|
130
|
+
hasMoreNewerMessages,
|
|
131
|
+
isFetchingNewerMessages,
|
|
132
|
+
cancelFetchNewerMessages,
|
|
133
|
+
} = useConversationMessages({ conversation_id: conversationId, reply_root_id: replyRootId })
|
|
134
|
+
|
|
135
|
+
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
136
|
+
const { initialMessageId } = useConversationContext()
|
|
137
|
+
|
|
138
|
+
useConversationJoltEvents({ conversationId })
|
|
139
|
+
useConversationMessagesJoltEvents({ conversationId })
|
|
103
140
|
useEnsureConversationsRouteExists()
|
|
104
141
|
useMarkLatestMessageRead({ conversation, messages })
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
142
|
+
const { onMessageSeen } = useTrackHighestSeenMessage()
|
|
143
|
+
|
|
144
|
+
const items = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
groupMessages({
|
|
147
|
+
ms: messages,
|
|
148
|
+
inReplyScreen: !!replyRootId,
|
|
149
|
+
jumpToUnreadActive,
|
|
150
|
+
initialMessageId,
|
|
151
|
+
}),
|
|
152
|
+
[messages, replyRootId, jumpToUnreadActive, initialMessageId]
|
|
153
|
+
)
|
|
154
|
+
const noMessages = items.length === 0
|
|
110
155
|
|
|
111
156
|
const { repliesDisabled, memberAbility, badges, title } = conversation
|
|
112
157
|
const canReply = memberAbility?.canReply
|
|
113
158
|
const showLeaderDisabledReplyBanner = canReply && repliesDisabled
|
|
114
159
|
const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
115
|
-
const currentlyEditingMessage = messages.find(m => String(m.id) === String(
|
|
116
|
-
const replyRootAuthorFirstName =
|
|
160
|
+
const currentlyEditingMessage = messages.find(m => String(m.id) === String(editingMessageId))
|
|
161
|
+
const replyRootAuthorFirstName = replyRootAuthorName?.split(' ')[0]
|
|
117
162
|
const replyHeaderTitle = replyRootAuthorFirstName
|
|
118
163
|
? `Reply to ${replyRootAuthorFirstName}`
|
|
119
164
|
: 'Reply'
|
|
@@ -121,21 +166,96 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
121
166
|
const muted = conversation.conversationMembership?.muted ?? conversation.muted
|
|
122
167
|
|
|
123
168
|
const listRef = useRef<FlatList>(null)
|
|
124
|
-
const [
|
|
169
|
+
const [dividerScrolledPast, setDividerScrolledPast] = useState(false)
|
|
170
|
+
|
|
171
|
+
const observers = useMemo<ViewabilityObserver<EnrichedMessage>[]>(
|
|
172
|
+
() => [
|
|
173
|
+
reportViewableMessages(onMessageSeen),
|
|
174
|
+
detectDividerExitTowardNewer({
|
|
175
|
+
dividerKey: UNREAD_DIVIDER_KEY,
|
|
176
|
+
initialMessageId,
|
|
177
|
+
onExited: () => setDividerScrolledPast(true),
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
[onMessageSeen, initialMessageId]
|
|
181
|
+
)
|
|
125
182
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
183
|
+
const { viewabilityConfigCallbackPairs, onScrollBeginDrag: viewabilityOnScrollBeginDrag } =
|
|
184
|
+
useFlatListViewability({ observers })
|
|
185
|
+
const {
|
|
186
|
+
onContentSizeChange,
|
|
187
|
+
onScrollToIndexFailed,
|
|
188
|
+
onScrollBeginDrag: anchorOnScrollBeginDrag,
|
|
189
|
+
} = useJumpToUnreadAnchor({ listRef, items })
|
|
190
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
191
|
+
viewabilityOnScrollBeginDrag()
|
|
192
|
+
anchorOnScrollBeginDrag()
|
|
193
|
+
}, [viewabilityOnScrollBeginDrag, anchorOnScrollBeginDrag])
|
|
194
|
+
const { onScroll, showJumpToBottomButton } = useScrollTracking({
|
|
195
|
+
hasMoreNewerMessages,
|
|
196
|
+
isFetchingNewerMessages,
|
|
197
|
+
fetchNewerMessages,
|
|
198
|
+
cancelFetchNewerMessages,
|
|
199
|
+
})
|
|
200
|
+
const { handleJumpToBottom, isJumpingToBottom } = useJumpToBottomAction({ listRef })
|
|
201
|
+
|
|
202
|
+
const listHeader = useMemo(
|
|
203
|
+
() => (
|
|
204
|
+
<View>
|
|
205
|
+
{isFetchingNewerMessages && (
|
|
206
|
+
<Animated.View
|
|
207
|
+
entering={FadeIn.duration(750)}
|
|
208
|
+
exiting={FadeOut.duration(750)}
|
|
209
|
+
style={styles.loadingFooter}
|
|
210
|
+
accessibilityRole="progressbar"
|
|
211
|
+
accessibilityLabel="Loading more messages"
|
|
212
|
+
>
|
|
213
|
+
<ActivityIndicator />
|
|
214
|
+
</Animated.View>
|
|
215
|
+
)}
|
|
216
|
+
<View style={styles.listHeader} />
|
|
217
|
+
</View>
|
|
218
|
+
),
|
|
219
|
+
[isFetchingNewerMessages, styles.loadingFooter, styles.listHeader]
|
|
220
|
+
)
|
|
130
221
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
222
|
+
const renderItem = useCallback(
|
|
223
|
+
({ item }: { item: EnrichedMessage }) => {
|
|
224
|
+
if (item.type === 'DateSeparator') return <InlineDateSeparator {...item} />
|
|
225
|
+
if (item.type === 'UnreadDivider') return <UnreadDivider scrolledPast={dividerScrolledPast} />
|
|
226
|
+
if (item.type === 'ReplyShadowMessage') {
|
|
227
|
+
return (
|
|
228
|
+
<ReplyShadowMessage
|
|
229
|
+
{...item}
|
|
230
|
+
conversation_id={conversationId}
|
|
231
|
+
inReplyScreen={!!replyRootId}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
if (isSystemMessage(item)) {
|
|
236
|
+
return <SystemMessage message={item} conversationId={conversationId} />
|
|
237
|
+
}
|
|
238
|
+
return (
|
|
239
|
+
<Message
|
|
240
|
+
{...item}
|
|
241
|
+
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
242
|
+
conversation_id={conversationId}
|
|
243
|
+
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
244
|
+
inReplyScreen={!!replyRootId}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
[
|
|
249
|
+
dividerScrolledPast,
|
|
250
|
+
conversationId,
|
|
251
|
+
replyRootId,
|
|
252
|
+
canDeleteNonAuthoredMessages,
|
|
253
|
+
conversation?.latestReadMessageSortKey,
|
|
254
|
+
]
|
|
255
|
+
)
|
|
136
256
|
|
|
137
257
|
useEffect(() => {
|
|
138
|
-
if (
|
|
258
|
+
if (replyRootId) {
|
|
139
259
|
navigation.setParams({
|
|
140
260
|
title: replyHeaderTitle,
|
|
141
261
|
})
|
|
@@ -147,7 +267,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
147
267
|
muted,
|
|
148
268
|
})
|
|
149
269
|
}
|
|
150
|
-
}, [navigation, title, badges, conversation?.deleted,
|
|
270
|
+
}, [navigation, title, badges, conversation?.deleted, replyRootId, replyHeaderTitle, muted])
|
|
151
271
|
|
|
152
272
|
if (!conversation || conversation.deleted) {
|
|
153
273
|
return (
|
|
@@ -178,53 +298,32 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
178
298
|
inverted
|
|
179
299
|
ref={listRef}
|
|
180
300
|
contentContainerStyle={styles.listContainer}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
scrollEventThrottle={
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (item.type === 'ReplyShadowMessage') {
|
|
193
|
-
return (
|
|
194
|
-
<ReplyShadowMessage
|
|
195
|
-
{...item}
|
|
196
|
-
conversation_id={conversation_id}
|
|
197
|
-
inReplyScreen={!!reply_root_id}
|
|
198
|
-
/>
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (isSystemMessage(item)) {
|
|
203
|
-
return <SystemMessage message={item} conversationId={conversation_id} />
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return (
|
|
207
|
-
<Message
|
|
208
|
-
{...item}
|
|
209
|
-
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
210
|
-
conversation_id={conversation_id}
|
|
211
|
-
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
212
|
-
inReplyScreen={!!reply_root_id}
|
|
213
|
-
/>
|
|
214
|
-
)
|
|
215
|
-
}}
|
|
301
|
+
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
302
|
+
data={items}
|
|
303
|
+
keyExtractor={extractItemKey}
|
|
304
|
+
onScroll={onScroll}
|
|
305
|
+
onScrollBeginDrag={onScrollBeginDrag}
|
|
306
|
+
scrollEventThrottle={64}
|
|
307
|
+
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
|
308
|
+
onContentSizeChange={onContentSizeChange}
|
|
309
|
+
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
310
|
+
renderItem={renderItem}
|
|
216
311
|
onEndReached={() => fetchOlderMessages()}
|
|
217
|
-
ListHeaderComponent={
|
|
312
|
+
ListHeaderComponent={listHeader}
|
|
218
313
|
/>
|
|
219
314
|
)}
|
|
220
|
-
<JumpToBottomButton
|
|
315
|
+
<JumpToBottomButton
|
|
316
|
+
onPress={handleJumpToBottom}
|
|
317
|
+
visible={showJumpToBottomButton}
|
|
318
|
+
loading={isJumpingToBottom}
|
|
319
|
+
/>
|
|
221
320
|
{!noMessages && <TypingIndicator />}
|
|
222
321
|
{showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
|
|
223
322
|
{canReply ? (
|
|
224
323
|
<MessageForm.Root
|
|
225
324
|
replyRootAuthorFirstName={replyRootAuthorFirstName}
|
|
226
325
|
conversation={conversation}
|
|
227
|
-
replyRootId={
|
|
326
|
+
replyRootId={replyRootId}
|
|
228
327
|
currentlyEditingMessage={currentlyEditingMessage}
|
|
229
328
|
// We use a separate key so that it remounts component when switching between new
|
|
230
329
|
// and edit message. This simplifies internal state handling.
|
|
@@ -379,6 +478,10 @@ const useStyles = () => {
|
|
|
379
478
|
// Just whitespace to provide space where the typing indicator can be
|
|
380
479
|
height: 16,
|
|
381
480
|
},
|
|
481
|
+
loadingFooter: {
|
|
482
|
+
paddingVertical: 12,
|
|
483
|
+
alignItems: 'center',
|
|
484
|
+
},
|
|
382
485
|
})
|
|
383
486
|
}
|
|
384
487
|
|
|
@@ -124,6 +124,77 @@ describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbo
|
|
|
124
124
|
})
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
describe('groupMessages — unread divider', () => {
|
|
128
|
+
it('inserts the divider between the read and unread boundary when jumpToUnreadActive', () => {
|
|
129
|
+
const messages = [
|
|
130
|
+
message('04', { createdAt: '2026-01-01T00:04:00Z' }),
|
|
131
|
+
message('03', { createdAt: '2026-01-01T00:03:00Z' }),
|
|
132
|
+
message('02', { createdAt: '2026-01-01T00:02:00Z' }),
|
|
133
|
+
message('01', { createdAt: '2026-01-01T00:01:00Z' }),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
const enriched = groupMessages({
|
|
137
|
+
ms: messages,
|
|
138
|
+
jumpToUnreadActive: true,
|
|
139
|
+
initialMessageId: '02',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const dividerIdx = enriched.findIndex(item => 'type' in item && item.type === 'UnreadDivider')
|
|
143
|
+
const msg03Idx = enriched.findIndex(
|
|
144
|
+
item => 'id' in item && item.id === '03' && !('type' in item && item.type !== 'Message')
|
|
145
|
+
)
|
|
146
|
+
const msg02Idx = enriched.findIndex(
|
|
147
|
+
item => 'id' in item && item.id === '02' && !('type' in item && item.type !== 'Message')
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(dividerIdx).toBeGreaterThan(msg03Idx)
|
|
151
|
+
expect(dividerIdx).toBeLessThan(msg02Idx)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not insert the divider when jumpToUnreadActive is false', () => {
|
|
155
|
+
const enriched = groupMessages({
|
|
156
|
+
ms: [message('02'), message('01')],
|
|
157
|
+
jumpToUnreadActive: false,
|
|
158
|
+
initialMessageId: '01',
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(enriched.some(item => 'type' in item && item.type === 'UnreadDivider')).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not insert the divider when no message crosses the boundary', () => {
|
|
165
|
+
const enriched = groupMessages({
|
|
166
|
+
ms: [message('05'), message('04'), message('03')],
|
|
167
|
+
jumpToUnreadActive: true,
|
|
168
|
+
initialMessageId: '02',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(enriched.some(item => 'type' in item && item.type === 'UnreadDivider')).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('orders ULID-style ids consistently with backend sort_key comparisons', () => {
|
|
175
|
+
const enriched = groupMessages({
|
|
176
|
+
ms: [
|
|
177
|
+
message('01KQSTAY189PHCJBT8T13R9VMP'),
|
|
178
|
+
message('01KQST9HZAB10K3CXR7TYN2QWE'),
|
|
179
|
+
message('01KQST73KZRPXNRDA7TYN19KXQ'),
|
|
180
|
+
message('01KQST5JKZRPXNRDA7TYN19ABC'),
|
|
181
|
+
],
|
|
182
|
+
jumpToUnreadActive: true,
|
|
183
|
+
initialMessageId: '01KQST73KZRPXNRDA7TYN19KXQ',
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const dividerIdx = enriched.findIndex(item => 'type' in item && item.type === 'UnreadDivider')
|
|
187
|
+
const newerIdx = enriched.findIndex(
|
|
188
|
+
item =>
|
|
189
|
+
'id' in item &&
|
|
190
|
+
item.id === '01KQST9HZAB10K3CXR7TYN2QWE' &&
|
|
191
|
+
!('type' in item && item.type !== 'Message')
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(dividerIdx).toBeGreaterThan(newerIdx)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
127
198
|
describe('groupMessages — system messages', () => {
|
|
128
199
|
it('flags lastInGroup true and renderAuthor false on system messages', () => {
|
|
129
200
|
const messages = [
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { makeHighestSeenTracker } from '../highest_seen_tracker'
|
|
2
|
+
|
|
3
|
+
describe('makeHighestSeenTracker', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.useFakeTimers()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.useRealTimers()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('debounces and sends the highest sort key seen so far', () => {
|
|
13
|
+
const send = jest.fn()
|
|
14
|
+
const tracker = makeHighestSeenTracker(42, send, 2000)
|
|
15
|
+
|
|
16
|
+
tracker.onSeen('10')
|
|
17
|
+
tracker.onSeen('15')
|
|
18
|
+
tracker.onSeen('12')
|
|
19
|
+
|
|
20
|
+
expect(send).not.toHaveBeenCalled()
|
|
21
|
+
jest.advanceTimersByTime(2000)
|
|
22
|
+
|
|
23
|
+
expect(send).toHaveBeenCalledWith({ conversationId: 42, sortKey: '15' })
|
|
24
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('ignores sort keys that are not strictly greater than the current highest', () => {
|
|
28
|
+
const send = jest.fn()
|
|
29
|
+
const tracker = makeHighestSeenTracker(1, send, 1000)
|
|
30
|
+
|
|
31
|
+
tracker.onSeen('20')
|
|
32
|
+
tracker.onSeen('20')
|
|
33
|
+
tracker.onSeen('19')
|
|
34
|
+
jest.advanceTimersByTime(1000)
|
|
35
|
+
|
|
36
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
37
|
+
expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '20' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not re-send the same sort key on subsequent flushes', () => {
|
|
41
|
+
const send = jest.fn()
|
|
42
|
+
const tracker = makeHighestSeenTracker(1, send, 1000)
|
|
43
|
+
|
|
44
|
+
tracker.onSeen('5')
|
|
45
|
+
jest.advanceTimersByTime(1000)
|
|
46
|
+
tracker.flushNow()
|
|
47
|
+
|
|
48
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('flushNow fires immediately without waiting for the debounce', () => {
|
|
52
|
+
const send = jest.fn()
|
|
53
|
+
const tracker = makeHighestSeenTracker(1, send, 5000)
|
|
54
|
+
|
|
55
|
+
tracker.onSeen('3')
|
|
56
|
+
tracker.flushNow()
|
|
57
|
+
|
|
58
|
+
expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '3' })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('cancel prevents a pending fire', () => {
|
|
62
|
+
const send = jest.fn()
|
|
63
|
+
const tracker = makeHighestSeenTracker(1, send, 1000)
|
|
64
|
+
|
|
65
|
+
tracker.onSeen('7')
|
|
66
|
+
tracker.cancel()
|
|
67
|
+
jest.advanceTimersByTime(1000)
|
|
68
|
+
|
|
69
|
+
expect(send).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('orders fixed-width sort keys via lexicographic comparison', () => {
|
|
73
|
+
const send = jest.fn()
|
|
74
|
+
const tracker = makeHighestSeenTracker(1, send, 100)
|
|
75
|
+
|
|
76
|
+
tracker.onSeen('09')
|
|
77
|
+
tracker.onSeen('10')
|
|
78
|
+
jest.advanceTimersByTime(100)
|
|
79
|
+
|
|
80
|
+
expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '10' })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectDividerExitTowardNewer,
|
|
3
|
+
reportViewableMessages,
|
|
4
|
+
type ViewabilityEvent,
|
|
5
|
+
} from '../message_viewability'
|
|
6
|
+
|
|
7
|
+
type Msg = { id: string; type: 'Message' }
|
|
8
|
+
|
|
9
|
+
const event = (overrides: Partial<ViewabilityEvent<Msg>> = {}): ViewabilityEvent<Msg> => ({
|
|
10
|
+
viewableItems: [],
|
|
11
|
+
changed: [],
|
|
12
|
+
userHasScrolled: true,
|
|
13
|
+
...overrides,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('reportViewableMessages', () => {
|
|
17
|
+
it('fires onMessageSeen for every viewable Message item by id', () => {
|
|
18
|
+
const onSeen = jest.fn()
|
|
19
|
+
const observer = reportViewableMessages<Msg>(onSeen)
|
|
20
|
+
|
|
21
|
+
observer(
|
|
22
|
+
event({
|
|
23
|
+
viewableItems: [
|
|
24
|
+
{ key: '10', isViewable: true, item: { id: '10', type: 'Message' } },
|
|
25
|
+
{ key: '11', isViewable: true, item: { id: '11', type: 'Message' } },
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(onSeen).toHaveBeenCalledTimes(2)
|
|
31
|
+
expect(onSeen).toHaveBeenNthCalledWith(1, '10')
|
|
32
|
+
expect(onSeen).toHaveBeenNthCalledWith(2, '11')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('skips non-Message items (dividers, separators, shadows)', () => {
|
|
36
|
+
const onSeen = jest.fn()
|
|
37
|
+
const observer = reportViewableMessages<{ id?: string; type?: string }>(onSeen)
|
|
38
|
+
|
|
39
|
+
observer(
|
|
40
|
+
event({
|
|
41
|
+
viewableItems: [
|
|
42
|
+
{ key: 'divider', isViewable: true, item: { id: 'divider', type: 'UnreadDivider' } },
|
|
43
|
+
{
|
|
44
|
+
key: 'day-divider-05',
|
|
45
|
+
isViewable: true,
|
|
46
|
+
item: { id: 'day-divider-05', type: 'DateSeparator' },
|
|
47
|
+
},
|
|
48
|
+
{ key: '5', isViewable: true, item: { id: '5', type: 'Message' } },
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(onSeen).toHaveBeenCalledTimes(1)
|
|
54
|
+
expect(onSeen).toHaveBeenCalledWith('5')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not fire before the user has scrolled', () => {
|
|
58
|
+
const onSeen = jest.fn()
|
|
59
|
+
const observer = reportViewableMessages<Msg>(onSeen)
|
|
60
|
+
|
|
61
|
+
observer(
|
|
62
|
+
event({
|
|
63
|
+
userHasScrolled: false,
|
|
64
|
+
viewableItems: [{ key: '10', isViewable: true, item: { id: '10', type: 'Message' } }],
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(onSeen).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('detectDividerExitTowardNewer', () => {
|
|
73
|
+
const baseArgs = { dividerKey: 'unread-divider', initialMessageId: '050' }
|
|
74
|
+
|
|
75
|
+
it('fires onExited when the divider leaves and only newer messages remain visible', () => {
|
|
76
|
+
const onExited = jest.fn()
|
|
77
|
+
const observer = detectDividerExitTowardNewer<{ id: string; type?: string }>({
|
|
78
|
+
...baseArgs,
|
|
79
|
+
onExited,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
observer(
|
|
83
|
+
event({
|
|
84
|
+
changed: [
|
|
85
|
+
{
|
|
86
|
+
key: 'unread-divider',
|
|
87
|
+
isViewable: false,
|
|
88
|
+
item: { id: 'unread-divider', type: 'UnreadDivider' },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
viewableItems: [
|
|
92
|
+
{ key: '055', isViewable: true, item: { id: '055', type: 'Message' } },
|
|
93
|
+
{ key: '056', isViewable: true, item: { id: '056', type: 'Message' } },
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(onExited).toHaveBeenCalledTimes(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does not fire when divider leaves toward older messages', () => {
|
|
102
|
+
const onExited = jest.fn()
|
|
103
|
+
const observer = detectDividerExitTowardNewer<{ id: string; type?: string }>({
|
|
104
|
+
...baseArgs,
|
|
105
|
+
onExited,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
observer(
|
|
109
|
+
event({
|
|
110
|
+
changed: [
|
|
111
|
+
{
|
|
112
|
+
key: 'unread-divider',
|
|
113
|
+
isViewable: false,
|
|
114
|
+
item: { id: 'unread-divider', type: 'UnreadDivider' },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
viewableItems: [
|
|
118
|
+
{ key: '045', isViewable: true, item: { id: '045', type: 'Message' } },
|
|
119
|
+
{ key: '048', isViewable: true, item: { id: '048', type: 'Message' } },
|
|
120
|
+
],
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expect(onExited).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not fire before the user has scrolled', () => {
|
|
128
|
+
const onExited = jest.fn()
|
|
129
|
+
const observer = detectDividerExitTowardNewer<{ id: string; type?: string }>({
|
|
130
|
+
...baseArgs,
|
|
131
|
+
onExited,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
observer(
|
|
135
|
+
event({
|
|
136
|
+
userHasScrolled: false,
|
|
137
|
+
changed: [
|
|
138
|
+
{
|
|
139
|
+
key: 'unread-divider',
|
|
140
|
+
isViewable: false,
|
|
141
|
+
item: { id: 'unread-divider', type: 'UnreadDivider' },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
viewableItems: [{ key: '055', isViewable: true, item: { id: '055', type: 'Message' } }],
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(onExited).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('no-ops when initialMessageId is null (observer is always installed)', () => {
|
|
152
|
+
const onExited = jest.fn()
|
|
153
|
+
const observer = detectDividerExitTowardNewer<{ id: string; type?: string }>({
|
|
154
|
+
dividerKey: 'unread-divider',
|
|
155
|
+
initialMessageId: null,
|
|
156
|
+
onExited,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
observer(
|
|
160
|
+
event({
|
|
161
|
+
changed: [{ key: 'unread-divider', isViewable: false, item: { id: 'unread-divider' } }],
|
|
162
|
+
viewableItems: [{ key: '055', isViewable: true, item: { id: '055' } }],
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(onExited).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
})
|