@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.
Files changed (111) 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.map +1 -1
  58. package/build/screens/conversation_screen.js +87 -44
  59. package/build/screens/conversation_screen.js.map +1 -1
  60. package/build/utils/cache/messages_cache.d.ts +1 -0
  61. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  62. package/build/utils/cache/messages_cache.js +4 -0
  63. package/build/utils/cache/messages_cache.js.map +1 -1
  64. package/build/utils/group_messages.d.ts +9 -2
  65. package/build/utils/group_messages.d.ts.map +1 -1
  66. package/build/utils/group_messages.js +20 -1
  67. package/build/utils/group_messages.js.map +1 -1
  68. package/build/utils/highest_seen_tracker.d.ts +12 -0
  69. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  70. package/build/utils/highest_seen_tracker.js +37 -0
  71. package/build/utils/highest_seen_tracker.js.map +1 -0
  72. package/build/utils/message_viewability.d.ts +24 -0
  73. package/build/utils/message_viewability.d.ts.map +1 -0
  74. package/build/utils/message_viewability.js +29 -0
  75. package/build/utils/message_viewability.js.map +1 -0
  76. package/build/utils/unread_divider_helpers.d.ts +18 -0
  77. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  78. package/build/utils/unread_divider_helpers.js +13 -0
  79. package/build/utils/unread_divider_helpers.js.map +1 -0
  80. package/package.json +10 -4
  81. package/src/__tests__/contexts/session_context.tsx +1 -1
  82. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  83. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  84. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  85. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  86. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  87. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  88. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  89. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  90. package/src/components/conversation/unread_divider.tsx +90 -0
  91. package/src/contexts/conversation_context.tsx +15 -13
  92. package/src/hooks/use_conversation_messages.ts +19 -3
  93. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  94. package/src/hooks/use_conversations_actions.ts +15 -0
  95. package/src/hooks/use_flat_list_viewability.ts +50 -0
  96. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  97. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  98. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  99. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  100. package/src/hooks/use_scroll_tracking.ts +64 -0
  101. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  102. package/src/screens/conversation_screen.tsx +173 -70
  103. package/src/utils/__tests__/group_messages.test.ts +71 -0
  104. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  105. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  106. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  107. package/src/utils/cache/messages_cache.ts +5 -0
  108. package/src/utils/group_messages.ts +42 -2
  109. package/src/utils/highest_seen_tracker.ts +42 -0
  110. package/src/utils/message_viewability.ts +49 -0
  111. 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 { 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'
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 { groupMessages, type DateSeparator } from '../utils/group_messages'
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 = featureEnabled('jump_to_unread') ? lastReadMessageSortKey : null
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 { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
95
- 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
96
125
  const { data: conversation } = useConversation(route.params)
97
- const { messages, refetch, isRefetching, fetchOlderMessages } = useConversationMessages({
98
- conversation_id,
99
- reply_root_id,
100
- })
101
- useConversationJoltEvents({ conversationId: conversation_id })
102
- 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 })
103
140
  useEnsureConversationsRouteExists()
104
141
  useMarkLatestMessageRead({ conversation, messages })
105
- const messagesWithSeparators = groupMessages({
106
- ms: messages,
107
- inReplyScreen: !!reply_root_id,
108
- })
109
- 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
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(editing_message_id))
116
- 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]
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 [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
+ )
125
182
 
126
- const trackScroll = (event: any) => {
127
- const offsetY = event.nativeEvent.contentOffset.y
128
- setShowJumpToBottomButton(offsetY > 200)
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 handleReturnToBottom = useCallback(() => {
132
- listRef.current?.scrollToOffset({
133
- offset: 0,
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 (reply_root_id) {
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, reply_root_id, replyHeaderTitle, muted])
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
- refreshing={isRefetching}
182
- onRefresh={refetch}
183
- data={messagesWithSeparators}
184
- keyExtractor={item => item.id}
185
- onScroll={trackScroll}
186
- scrollEventThrottle={10}
187
- renderItem={({ item }) => {
188
- if (item.type === 'DateSeparator') {
189
- return <InlineDateSeparator {...item} />
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={<View style={styles.listHeader} />}
312
+ ListHeaderComponent={listHeader}
218
313
  />
219
314
  )}
220
- <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
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={reply_root_id}
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
+ })