@planningcenter/chat-react-native 3.35.0-rc.2 → 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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
  3. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  4. package/build/components/conversation/jump_to_bottom_button.js +39 -7
  5. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  7. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  8. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  9. package/build/components/conversation/unread_divider.d.ts +6 -0
  10. package/build/components/conversation/unread_divider.d.ts.map +1 -0
  11. package/build/components/conversation/unread_divider.js +59 -0
  12. package/build/components/conversation/unread_divider.js.map +1 -0
  13. package/build/contexts/conversation_context.d.ts +2 -0
  14. package/build/contexts/conversation_context.d.ts.map +1 -1
  15. package/build/contexts/conversation_context.js +13 -5
  16. package/build/contexts/conversation_context.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts +2 -0
  18. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  19. package/build/hooks/use_conversation_messages.js +9 -5
  20. package/build/hooks/use_conversation_messages.js.map +1 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_conversations_actions.d.ts +5 -0
  25. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  26. package/build/hooks/use_conversations_actions.js +12 -0
  27. package/build/hooks/use_conversations_actions.js.map +1 -1
  28. package/build/hooks/use_flat_list_viewability.d.ts +20 -0
  29. package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
  30. package/build/hooks/use_flat_list_viewability.js +30 -0
  31. package/build/hooks/use_flat_list_viewability.js.map +1 -0
  32. package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
  33. package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
  34. package/build/hooks/use_jump_to_bottom_action.js +62 -0
  35. package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
  36. package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
  37. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
  38. package/build/hooks/use_jump_to_unread_anchor.js +53 -0
  39. package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
  40. package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
  41. package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
  42. package/build/hooks/use_jump_to_unread_gates.js +10 -0
  43. package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  45. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  46. package/build/hooks/use_mark_latest_message_read.js +17 -1
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_scroll_tracking.d.ts +13 -0
  49. package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
  50. package/build/hooks/use_scroll_tracking.js +45 -0
  51. package/build/hooks/use_scroll_tracking.js.map +1 -0
  52. package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
  53. package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
  54. package/build/hooks/use_track_highest_seen_message.js +35 -0
  55. package/build/hooks/use_track_highest_seen_message.js.map +1 -0
  56. package/build/navigation/index.d.ts.map +1 -1
  57. package/build/screens/conversation_screen.d.ts +0 -19
  58. package/build/screens/conversation_screen.d.ts.map +1 -1
  59. package/build/screens/conversation_screen.js +87 -139
  60. package/build/screens/conversation_screen.js.map +1 -1
  61. package/build/utils/cache/messages_cache.d.ts +1 -0
  62. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  63. package/build/utils/cache/messages_cache.js +4 -0
  64. package/build/utils/cache/messages_cache.js.map +1 -1
  65. package/build/utils/group_messages.d.ts +28 -0
  66. package/build/utils/group_messages.d.ts.map +1 -0
  67. package/build/utils/group_messages.js +142 -0
  68. package/build/utils/group_messages.js.map +1 -0
  69. package/build/utils/highest_seen_tracker.d.ts +12 -0
  70. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  71. package/build/utils/highest_seen_tracker.js +37 -0
  72. package/build/utils/highest_seen_tracker.js.map +1 -0
  73. package/build/utils/message_viewability.d.ts +24 -0
  74. package/build/utils/message_viewability.d.ts.map +1 -0
  75. package/build/utils/message_viewability.js +29 -0
  76. package/build/utils/message_viewability.js.map +1 -0
  77. package/build/utils/unread_divider_helpers.d.ts +18 -0
  78. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  79. package/build/utils/unread_divider_helpers.js +13 -0
  80. package/build/utils/unread_divider_helpers.js.map +1 -0
  81. package/package.json +10 -4
  82. package/src/__tests__/contexts/session_context.tsx +1 -1
  83. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  84. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  85. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  86. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  87. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  88. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  89. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  90. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  91. package/src/components/conversation/unread_divider.tsx +90 -0
  92. package/src/contexts/conversation_context.tsx +15 -13
  93. package/src/hooks/use_conversation_messages.ts +19 -3
  94. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  95. package/src/hooks/use_conversations_actions.ts +15 -0
  96. package/src/hooks/use_flat_list_viewability.ts +50 -0
  97. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  98. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  99. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  100. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  101. package/src/hooks/use_scroll_tracking.ts +64 -0
  102. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  103. package/src/screens/conversation_screen.tsx +173 -197
  104. package/src/utils/__tests__/group_messages.test.ts +214 -0
  105. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  106. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  107. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  108. package/src/utils/cache/messages_cache.ts +5 -0
  109. package/src/utils/group_messages.ts +217 -0
  110. package/src/utils/highest_seen_tracker.ts +42 -0
  111. package/src/utils/message_viewability.ts +49 -0
  112. package/src/utils/unread_divider_helpers.ts +25 -0
@@ -0,0 +1,64 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
3
+ import { useConversationContext } from '../contexts/conversation_context'
4
+
5
+ const JUMP_TO_BOTTOM_OFFSET_THRESHOLD = 200
6
+ const AT_BOTTOM_OFFSET_TOLERANCE = 5
7
+ const FETCH_NEWER_OFFSET_THRESHOLD = 600
8
+
9
+ interface UseScrollTrackingArgs {
10
+ hasMoreNewerMessages: boolean
11
+ isFetchingNewerMessages: boolean
12
+ fetchNewerMessages: () => void
13
+ cancelFetchNewerMessages: () => void
14
+ }
15
+
16
+ export function useScrollTracking({
17
+ hasMoreNewerMessages,
18
+ isFetchingNewerMessages,
19
+ fetchNewerMessages,
20
+ cancelFetchNewerMessages,
21
+ }: UseScrollTrackingArgs) {
22
+ const { atEndOfMessageHistory, setAtEndOfMessageHistory } = useConversationContext()
23
+ const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
24
+ const withinBoundaryRef = useRef(false)
25
+
26
+ useEffect(() => {
27
+ return () => {
28
+ cancelFetchNewerMessages()
29
+ }
30
+ }, [cancelFetchNewerMessages])
31
+
32
+ useEffect(() => {
33
+ if (!isFetchingNewerMessages) withinBoundaryRef.current = false
34
+ }, [isFetchingNewerMessages])
35
+
36
+ const onScroll = useCallback(
37
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
38
+ const offsetY = event.nativeEvent.contentOffset.y
39
+ setShowJumpToBottomButton(offsetY > JUMP_TO_BOTTOM_OFFSET_THRESHOLD)
40
+
41
+ const atBottom = offsetY < AT_BOTTOM_OFFSET_TOLERANCE
42
+ const atEnd = atBottom && !hasMoreNewerMessages
43
+ if (atEnd !== atEndOfMessageHistory) setAtEndOfMessageHistory(atEnd)
44
+
45
+ if (offsetY >= FETCH_NEWER_OFFSET_THRESHOLD) {
46
+ withinBoundaryRef.current = false
47
+ return
48
+ }
49
+ if (withinBoundaryRef.current) return
50
+ if (!hasMoreNewerMessages || isFetchingNewerMessages) return
51
+ withinBoundaryRef.current = true
52
+ fetchNewerMessages()
53
+ },
54
+ [
55
+ hasMoreNewerMessages,
56
+ isFetchingNewerMessages,
57
+ fetchNewerMessages,
58
+ atEndOfMessageHistory,
59
+ setAtEndOfMessageHistory,
60
+ ]
61
+ )
62
+
63
+ return { onScroll, showJumpToBottomButton }
64
+ }
@@ -0,0 +1,43 @@
1
+ import { useCallback, useEffect, useMemo } from 'react'
2
+ import { AppState } from 'react-native'
3
+ import { useConversationContext } from '../contexts/conversation_context'
4
+ import { makeHighestSeenTracker } from '../utils/highest_seen_tracker'
5
+ import { useConversationsMarkReadUpTo } from './use_conversations_actions'
6
+ import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
7
+
8
+ export function useTrackHighestSeenMessage() {
9
+ const { conversationId, currentPageReplyRootId } = useConversationContext()
10
+ const { jumpToUnreadActive } = useJumpToUnreadGates()
11
+ const enabled = jumpToUnreadActive && !currentPageReplyRootId
12
+ const { mutate: markReadUpTo } = useConversationsMarkReadUpTo({ conversationId })
13
+
14
+ const tracker = useMemo(
15
+ () => makeHighestSeenTracker(conversationId, ({ sortKey }) => markReadUpTo({ sortKey })),
16
+ [conversationId, markReadUpTo]
17
+ )
18
+
19
+ const onMessageSeen = useCallback(
20
+ (sortKey: string) => {
21
+ if (!enabled) return
22
+ tracker.onSeen(sortKey)
23
+ },
24
+ [enabled, tracker]
25
+ )
26
+
27
+ useEffect(() => {
28
+ if (!enabled) return
29
+ const sub = AppState.addEventListener('change', state => {
30
+ if (state !== 'active') tracker.flushNow()
31
+ })
32
+ return () => sub.remove()
33
+ }, [enabled, tracker])
34
+
35
+ useEffect(() => {
36
+ return () => {
37
+ tracker.flushNow()
38
+ tracker.cancel()
39
+ }
40
+ }, [tracker])
41
+
42
+ return { onMessageSeen }
43
+ }
@@ -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,24 +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 { ConversationContextProvider } from '../contexts/conversation_context'
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'
40
- import { MessageResource } from '../types'
49
+ import { useScrollTracking } from '../hooks/use_scroll_tracking'
50
+ import { useTrackHighestSeenMessage } from '../hooks/use_track_highest_seen_message'
41
51
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
42
52
  import { getRelativeDateStatus } from '../utils/date'
43
- import dayjs from '../utils/dayjs'
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'
44
64
  import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
45
65
  import { isSystemMessage } from '../utils/system_messages'
46
66
 
@@ -61,6 +81,9 @@ export type ConversationRouteProps = {
61
81
 
62
82
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
63
83
 
84
+ const extractItemKey = (item: EnrichedMessage) => String(item.id)
85
+ const maintainVisibleContentPosition = { minIndexForVisible: 0 }
86
+
64
87
  export function ConversationScreen({ route }: ConversationScreenProps) {
65
88
  const { conversation_id, message_id, reply_root_id } = route.params
66
89
 
@@ -73,7 +96,8 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
73
96
  })
74
97
 
75
98
  const lastReadMessageSortKey = conversation.conversationMembership?.lastReadMessageSortKey ?? null
76
- const jumpToUnreadAnchor = featureEnabled('jump_to_unread') ? lastReadMessageSortKey : null
99
+ const jumpToUnreadAnchor =
100
+ featureEnabled('jump_to_unread') && !reply_root_id ? lastReadMessageSortKey : null
77
101
  const initialMessageId = message_id ?? jumpToUnreadAnchor
78
102
  const initialMessageIdIsAnchor = !!initialMessageId && !message_id
79
103
 
@@ -92,29 +116,49 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
92
116
  function ConversationScreenContent({ route }: ConversationScreenProps) {
93
117
  const styles = useStyles()
94
118
  const navigation = useNavigation()
95
- const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
96
- route.params
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
97
125
  const { data: conversation } = useConversation(route.params)
98
- const { messages, refetch, isRefetching, fetchOlderMessages } = useConversationMessages({
99
- conversation_id,
100
- reply_root_id,
101
- })
102
- useConversationJoltEvents({ conversationId: conversation_id })
103
- useConversationMessagesJoltEvents({ conversationId: conversation_id })
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 })
104
140
  useEnsureConversationsRouteExists()
105
141
  useMarkLatestMessageRead({ conversation, messages })
106
- const messagesWithSeparators = groupMessages({
107
- ms: messages,
108
- inReplyScreen: !!reply_root_id,
109
- })
110
- const noMessages = messagesWithSeparators.length === 0
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
111
155
 
112
156
  const { repliesDisabled, memberAbility, badges, title } = conversation
113
157
  const canReply = memberAbility?.canReply
114
158
  const showLeaderDisabledReplyBanner = canReply && repliesDisabled
115
159
  const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
116
- const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id))
117
- const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
160
+ const currentlyEditingMessage = messages.find(m => String(m.id) === String(editingMessageId))
161
+ const replyRootAuthorFirstName = replyRootAuthorName?.split(' ')[0]
118
162
  const replyHeaderTitle = replyRootAuthorFirstName
119
163
  ? `Reply to ${replyRootAuthorFirstName}`
120
164
  : 'Reply'
@@ -122,21 +166,96 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
122
166
  const muted = conversation.conversationMembership?.muted ?? conversation.muted
123
167
 
124
168
  const listRef = useRef<FlatList>(null)
125
- const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
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
+ )
126
182
 
127
- const trackScroll = (event: any) => {
128
- const offsetY = event.nativeEvent.contentOffset.y
129
- setShowJumpToBottomButton(offsetY > 200)
130
- }
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
+ )
131
221
 
132
- const handleReturnToBottom = useCallback(() => {
133
- listRef.current?.scrollToOffset({
134
- offset: 0,
135
- })
136
- }, [])
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
+ )
137
256
 
138
257
  useEffect(() => {
139
- if (reply_root_id) {
258
+ if (replyRootId) {
140
259
  navigation.setParams({
141
260
  title: replyHeaderTitle,
142
261
  })
@@ -148,7 +267,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
148
267
  muted,
149
268
  })
150
269
  }
151
- }, [navigation, title, badges, conversation?.deleted, reply_root_id, replyHeaderTitle, muted])
270
+ }, [navigation, title, badges, conversation?.deleted, replyRootId, replyHeaderTitle, muted])
152
271
 
153
272
  if (!conversation || conversation.deleted) {
154
273
  return (
@@ -179,53 +298,32 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
179
298
  inverted
180
299
  ref={listRef}
181
300
  contentContainerStyle={styles.listContainer}
182
- refreshing={isRefetching}
183
- onRefresh={refetch}
184
- data={messagesWithSeparators}
185
- keyExtractor={item => item.id}
186
- onScroll={trackScroll}
187
- scrollEventThrottle={10}
188
- renderItem={({ item }) => {
189
- if (item.type === 'DateSeparator') {
190
- return <InlineDateSeparator {...item} />
191
- }
192
-
193
- if (item.type === 'ReplyShadowMessage') {
194
- return (
195
- <ReplyShadowMessage
196
- {...item}
197
- conversation_id={conversation_id}
198
- inReplyScreen={!!reply_root_id}
199
- />
200
- )
201
- }
202
-
203
- if (isSystemMessage(item)) {
204
- return <SystemMessage message={item} conversationId={conversation_id} />
205
- }
206
-
207
- return (
208
- <Message
209
- {...item}
210
- canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
211
- conversation_id={conversation_id}
212
- latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
213
- inReplyScreen={!!reply_root_id}
214
- />
215
- )
216
- }}
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}
217
311
  onEndReached={() => fetchOlderMessages()}
218
- ListHeaderComponent={<View style={styles.listHeader} />}
312
+ ListHeaderComponent={listHeader}
219
313
  />
220
314
  )}
221
- <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
315
+ <JumpToBottomButton
316
+ onPress={handleJumpToBottom}
317
+ visible={showJumpToBottomButton}
318
+ loading={isJumpingToBottom}
319
+ />
222
320
  {!noMessages && <TypingIndicator />}
223
321
  {showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
224
322
  {canReply ? (
225
323
  <MessageForm.Root
226
324
  replyRootAuthorFirstName={replyRootAuthorFirstName}
227
325
  conversation={conversation}
228
- replyRootId={reply_root_id}
326
+ replyRootId={replyRootId}
229
327
  currentlyEditingMessage={currentlyEditingMessage}
230
328
  // We use a separate key so that it remounts component when switching between new
231
329
  // and edit message. This simplifies internal state handling.
@@ -248,8 +346,6 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
248
346
  )
249
347
  }
250
348
 
251
- export type DateSeparator = { type: 'DateSeparator'; id: string; date: string }
252
-
253
349
  function InlineDateSeparator({ date }: DateSeparator) {
254
350
  const styles = useDateSeparatorStyles()
255
351
  const { isThisYear } = getRelativeDateStatus(date)
@@ -288,130 +384,6 @@ const useDateSeparatorStyles = () => {
288
384
  })
289
385
  }
290
386
 
291
- type ReplyShadowMessage = {
292
- type: 'ReplyShadowMessage'
293
- id: string
294
- messageId: string
295
- isReplyShadowMessage: boolean
296
- nextRendersAuthor: boolean
297
- }
298
-
299
- interface GroupMessagesProps {
300
- ms: MessageResource[]
301
- inReplyScreen?: boolean
302
- }
303
-
304
- export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
305
- let enrichedMessages: (MessageResource | DateSeparator | ReplyShadowMessage)[] = []
306
- let encounteredOneOfMyMessages = false
307
-
308
- ms.forEach((message, i) => {
309
- const prevMessage = ms[i + 1]
310
- const nextMessage = ms[i - 1]
311
- const date = dayjs(message.createdAt).format('YYYY-MM-DD')
312
-
313
- const prevMessageIsDateSeparator =
314
- prevMessage && date !== dayjs(prevMessage.createdAt).format('YYYY-MM-DD')
315
-
316
- if (isSystemMessage(message)) {
317
- message.myLatestInConversation = false
318
- message.lastInGroup = true
319
- message.renderAuthor = false
320
- message.nextRendersAuthor = nextMessage?.renderAuthor
321
- message.isReplyShadowMessage = false
322
- message.nextIsReplyShadowMessage = false
323
- message.threadPosition = null
324
- enrichedMessages.push(message)
325
- if (prevMessageIsDateSeparator) {
326
- enrichedMessages.push({ type: 'DateSeparator', id: `day-divider-${message.id}`, date })
327
- }
328
- return
329
- }
330
-
331
- const inThread = message.replyRootId !== null
332
- const nextMessageInThread = nextMessage?.replyRootId !== null
333
- const threadRoot = message.replyRootId === message.id
334
- const nextMessageThreadRoot = nextMessage?.replyRootId === nextMessage?.id
335
- const prevMessageDifferentThread = message.replyRootId !== prevMessage?.replyRootId
336
- const nextMessageDifferentThread = message.replyRootId !== nextMessage?.replyRootId
337
- const prevMessageDifferentAuthor = message.author?.id !== prevMessage?.author?.id
338
- const nextMessageDifferentAuthor = message.author?.id !== nextMessage?.author?.id
339
- const prevMessageMoreThan5Minutes =
340
- prevMessage &&
341
- new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() > 60000 * 5
342
- const nextMessageMoreThan5Minutes =
343
- nextMessage &&
344
- new Date(nextMessage.createdAt).getTime() - new Date(message.createdAt).getTime() > 60000 * 5
345
- const nextMessageIsDateSeparator =
346
- nextMessage && date !== dayjs(nextMessage.createdAt).format('YYYY-MM-DD')
347
- const insertReplyShadowMessage =
348
- message.replyRootId &&
349
- !threadRoot &&
350
- (prevMessageDifferentThread || prevMessageIsDateSeparator)
351
- const lastInGroup =
352
- !nextMessage ||
353
- nextMessageDifferentAuthor ||
354
- nextMessageMoreThan5Minutes ||
355
- nextMessageDifferentThread ||
356
- nextMessageIsDateSeparator
357
- const renderAuthor =
358
- !message.mine &&
359
- (!prevMessage ||
360
- prevMessageDifferentAuthor ||
361
- prevMessageMoreThan5Minutes ||
362
- prevMessageDifferentThread ||
363
- prevMessageIsDateSeparator)
364
- const nextIsReplyShadowMessage =
365
- nextMessageInThread &&
366
- !nextMessageThreadRoot &&
367
- (nextMessageDifferentThread || nextMessageIsDateSeparator)
368
-
369
- if (message.mine && !encounteredOneOfMyMessages) {
370
- encounteredOneOfMyMessages = true
371
- message.myLatestInConversation = true
372
- } else {
373
- message.myLatestInConversation = false
374
- }
375
- message.lastInGroup = lastInGroup
376
- message.renderAuthor = renderAuthor
377
- message.threadPosition = null
378
- message.nextRendersAuthor = nextMessage?.renderAuthor
379
- message.isReplyShadowMessage = false
380
- message.nextIsReplyShadowMessage = nextIsReplyShadowMessage
381
-
382
- if (!inReplyScreen && inThread) {
383
- message.prevIsMyReply = prevMessage?.mine
384
- message.nextIsMyReply = nextMessage?.mine
385
-
386
- const firstInThread = threadRoot
387
- const lastInThread = nextMessageDifferentThread || nextMessageIsDateSeparator
388
-
389
- if (firstInThread && lastInThread)
390
- message.threadPosition = null // ensures we don't render a connector for root replies that aren't immediately followed up a reply
391
- else if (firstInThread) message.threadPosition = 'first'
392
- else if (lastInThread) message.threadPosition = 'last'
393
- else message.threadPosition = 'center'
394
- }
395
-
396
- enrichedMessages.push(message)
397
-
398
- if (insertReplyShadowMessage) {
399
- enrichedMessages.push({
400
- type: 'ReplyShadowMessage',
401
- id: `${message.id}-${message.replyRootId}`,
402
- messageId: message.replyRootId!,
403
- isReplyShadowMessage: true,
404
- nextRendersAuthor: message?.renderAuthor,
405
- })
406
- }
407
-
408
- if (!prevMessage || date !== dayjs(prevMessage.createdAt).format('YYYY-MM-DD')) {
409
- enrichedMessages.push({ type: 'DateSeparator', id: `day-divider-${message.id}`, date })
410
- }
411
- })
412
-
413
- return enrichedMessages
414
- }
415
387
  interface ConversationScreenTitleProps extends HeaderTitleProps {
416
388
  conversation_id: number
417
389
  badge?: ConversationBadgeResource
@@ -506,6 +478,10 @@ const useStyles = () => {
506
478
  // Just whitespace to provide space where the typing indicator can be
507
479
  height: 16,
508
480
  },
481
+ loadingFooter: {
482
+ paddingVertical: 12,
483
+ alignItems: 'center',
484
+ },
509
485
  })
510
486
  }
511
487