@planningcenter/chat-react-native 3.35.0-rc.3 → 3.35.0-rc.5

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 (121) 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/screens/group_notification_settings_screen.d.ts.map +1 -1
  61. package/build/screens/group_notification_settings_screen.js +6 -3
  62. package/build/screens/group_notification_settings_screen.js.map +1 -1
  63. package/build/screens/notification_settings_screen.js +2 -2
  64. package/build/screens/notification_settings_screen.js.map +1 -1
  65. package/build/screens/preferred_app_selection_screen.js +3 -3
  66. package/build/screens/preferred_app_selection_screen.js.map +1 -1
  67. package/build/utils/cache/messages_cache.d.ts +1 -0
  68. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  69. package/build/utils/cache/messages_cache.js +4 -0
  70. package/build/utils/cache/messages_cache.js.map +1 -1
  71. package/build/utils/group_messages.d.ts +9 -2
  72. package/build/utils/group_messages.d.ts.map +1 -1
  73. package/build/utils/group_messages.js +20 -1
  74. package/build/utils/group_messages.js.map +1 -1
  75. package/build/utils/highest_seen_tracker.d.ts +12 -0
  76. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  77. package/build/utils/highest_seen_tracker.js +37 -0
  78. package/build/utils/highest_seen_tracker.js.map +1 -0
  79. package/build/utils/message_viewability.d.ts +24 -0
  80. package/build/utils/message_viewability.d.ts.map +1 -0
  81. package/build/utils/message_viewability.js +29 -0
  82. package/build/utils/message_viewability.js.map +1 -0
  83. package/build/utils/unread_divider_helpers.d.ts +18 -0
  84. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  85. package/build/utils/unread_divider_helpers.js +13 -0
  86. package/build/utils/unread_divider_helpers.js.map +1 -0
  87. package/package.json +10 -4
  88. package/src/__tests__/contexts/session_context.tsx +1 -1
  89. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  90. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  91. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  92. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  93. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  94. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  95. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  96. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  97. package/src/components/conversation/unread_divider.tsx +90 -0
  98. package/src/contexts/conversation_context.tsx +15 -13
  99. package/src/hooks/use_conversation_messages.ts +19 -3
  100. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  101. package/src/hooks/use_conversations_actions.ts +15 -0
  102. package/src/hooks/use_flat_list_viewability.ts +50 -0
  103. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  104. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  105. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  106. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  107. package/src/hooks/use_scroll_tracking.ts +64 -0
  108. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  109. package/src/screens/conversation_screen.tsx +173 -70
  110. package/src/screens/group_notification_settings_screen.tsx +6 -3
  111. package/src/screens/notification_settings_screen.tsx +2 -2
  112. package/src/screens/preferred_app_selection_screen.tsx +3 -3
  113. package/src/utils/__tests__/group_messages.test.ts +71 -0
  114. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  115. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  116. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  117. package/src/utils/cache/messages_cache.ts +5 -0
  118. package/src/utils/group_messages.ts +42 -2
  119. package/src/utils/highest_seen_tracker.ts +42 -0
  120. package/src/utils/message_viewability.ts +49 -0
  121. package/src/utils/unread_divider_helpers.ts +25 -0
@@ -90,7 +90,7 @@ export function NotificationSettingsScreen({}: NotificationSettingsScreenProps)
90
90
  ...chatTypes.map(type => ({
91
91
  type: SectionTypes.link,
92
92
  data: {
93
- title: `${type.title} Conversations`,
93
+ title: `${type.title} conversations`,
94
94
  rightLabel: type.preferredApp,
95
95
  onPress: () =>
96
96
  navigation.navigate('PreferredAppSelection', {
@@ -284,7 +284,7 @@ function LinkRow({ title, subtitle, rightLabel, onPress }: LinkRowProps) {
284
284
  <Text style={styles.title} numberOfLines={2}>
285
285
  {title}
286
286
  </Text>
287
- {Boolean(subtitle) && <Text variant="footnote">{subtitle}</Text>}
287
+ {Boolean(subtitle) && <Text variant="tertiary">{subtitle}</Text>}
288
288
  </View>
289
289
  <View style={styles.rightContent}>
290
290
  {isSourceType ? (
@@ -41,12 +41,12 @@ export function PreferredAppSelectionScreen({ route }: PreferredAppSelectionScre
41
41
  updateChatType(app)
42
42
  }
43
43
 
44
- const sectionTitle = `${chatType?.title} Conversations`
44
+ const sectionTitle = `${chatType?.title} conversations`
45
45
 
46
46
  return (
47
47
  <View style={styles.container}>
48
48
  <View style={styles.section}>
49
- <Heading variant="h3" style={styles.sectionHeading}>
49
+ <Heading variant="h2" style={styles.sectionHeading}>
50
50
  {sectionTitle}
51
51
  </Heading>
52
52
  {preferredAppOptions?.sort(sortPreferredApp('asc')).map((option, key) => (
@@ -120,7 +120,7 @@ const styles = StyleSheet.create({
120
120
  flex: 1,
121
121
  },
122
122
  section: {
123
- paddingTop: 16,
123
+ paddingTop: 24,
124
124
  },
125
125
  sectionHeading: {
126
126
  paddingHorizontal: 16,
@@ -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
+ })
@@ -0,0 +1,85 @@
1
+ import { dividerExitedTowardNewer } from '../unread_divider_helpers'
2
+
3
+ const dividerKey = 'unread-divider'
4
+ const initialMessageId = '050'
5
+ const msg = (id: string) => ({ item: { id, type: 'Message' } })
6
+
7
+ describe('dividerExitedTowardNewer', () => {
8
+ it('returns false while the divider is still viewable', () => {
9
+ const result = dividerExitedTowardNewer({
10
+ changed: [{ key: dividerKey, isViewable: true }],
11
+ viewableItems: [
12
+ msg('049'),
13
+ { item: { id: dividerKey, type: 'UnreadDivider' } },
14
+ msg('050'),
15
+ msg('051'),
16
+ ],
17
+ dividerKey,
18
+ initialMessageId,
19
+ })
20
+ expect(result).toBe(false)
21
+ })
22
+
23
+ it('returns true when divider exits and only newer messages are visible (scrolled toward newer)', () => {
24
+ const result = dividerExitedTowardNewer({
25
+ changed: [{ key: dividerKey, isViewable: false }],
26
+ viewableItems: [msg('055'), msg('056'), msg('057')],
27
+ dividerKey,
28
+ initialMessageId,
29
+ })
30
+ expect(result).toBe(true)
31
+ })
32
+
33
+ it('returns false when divider exits and only older messages are visible (scrolled toward older)', () => {
34
+ const result = dividerExitedTowardNewer({
35
+ changed: [{ key: dividerKey, isViewable: false }],
36
+ viewableItems: [msg('040'), msg('045'), msg('048')],
37
+ dividerKey,
38
+ initialMessageId,
39
+ })
40
+ expect(result).toBe(false)
41
+ })
42
+
43
+ it('returns false when divider exits but the visible window straddles the boundary', () => {
44
+ const result = dividerExitedTowardNewer({
45
+ changed: [{ key: dividerKey, isViewable: false }],
46
+ viewableItems: [msg('048'), msg('049'), msg('055')],
47
+ dividerKey,
48
+ initialMessageId,
49
+ })
50
+ expect(result).toBe(false)
51
+ })
52
+
53
+ it('returns false when the divider entry is not in the changed set', () => {
54
+ const result = dividerExitedTowardNewer({
55
+ changed: [{ key: '055', isViewable: true }],
56
+ viewableItems: [msg('055'), msg('056')],
57
+ dividerKey,
58
+ initialMessageId,
59
+ })
60
+ expect(result).toBe(false)
61
+ })
62
+
63
+ it('returns false when no message items are visible (only the divider was)', () => {
64
+ const result = dividerExitedTowardNewer({
65
+ changed: [{ key: dividerKey, isViewable: false }],
66
+ viewableItems: [],
67
+ dividerKey,
68
+ initialMessageId,
69
+ })
70
+ expect(result).toBe(false)
71
+ })
72
+
73
+ it('ignores non-Message items (date separators, reply shadows) when deciding direction', () => {
74
+ const result = dividerExitedTowardNewer({
75
+ changed: [{ key: dividerKey, isViewable: false }],
76
+ viewableItems: [
77
+ { item: { id: 'day-divider-200', type: 'DateSeparator' } },
78
+ { item: { id: '055-rootA', type: 'ReplyShadowMessage' } },
79
+ ],
80
+ dividerKey,
81
+ initialMessageId,
82
+ })
83
+ expect(result).toBe(false)
84
+ })
85
+ })
@@ -131,6 +131,11 @@ export function getThreadedMessagesQueryKey(conversationId: number, replyRootId:
131
131
  return getRequestQueryKey(requestArgs)
132
132
  }
133
133
 
134
+ export function hasUnloadedNewerPages(queryClient: QueryClient, queryKey: unknown[]): boolean {
135
+ const data = queryClient.getQueryData<MessagesQueryData>(queryKey)
136
+ return !!data?.pages?.[0]?.meta?.next?.idGt
137
+ }
138
+
134
139
  export function mergeMessageUpdate(
135
140
  record: MessageResource,
136
141
  current?: MessageResource
@@ -4,8 +4,12 @@ import { isSystemMessage } from './system_messages'
4
4
 
5
5
  const FIVE_MINUTES_MS = 5 * 60 * 1000
6
6
 
7
+ export const UNREAD_DIVIDER_KEY = 'unread-divider'
8
+
7
9
  export type DateSeparator = { type: 'DateSeparator'; id: string; date: string }
8
10
 
11
+ export type UnreadDividerItem = { type: 'UnreadDivider'; id: typeof UNREAD_DIVIDER_KEY }
12
+
9
13
  export type ReplyShadowMessage = {
10
14
  type: 'ReplyShadowMessage'
11
15
  id: string
@@ -14,14 +18,25 @@ export type ReplyShadowMessage = {
14
18
  nextRendersAuthor: boolean
15
19
  }
16
20
 
17
- export type EnrichedMessage = MessageResource | DateSeparator | ReplyShadowMessage
21
+ export type EnrichedMessage =
22
+ | MessageResource
23
+ | DateSeparator
24
+ | UnreadDividerItem
25
+ | ReplyShadowMessage
18
26
 
19
27
  interface GroupMessagesProps {
20
28
  ms: MessageResource[]
21
29
  inReplyScreen?: boolean
30
+ jumpToUnreadActive?: boolean
31
+ initialMessageId?: string | null
22
32
  }
23
33
 
24
- export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): EnrichedMessage[] {
34
+ export function groupMessages({
35
+ ms,
36
+ inReplyScreen,
37
+ jumpToUnreadActive,
38
+ initialMessageId,
39
+ }: GroupMessagesProps): EnrichedMessage[] {
25
40
  const items: EnrichedMessage[] = []
26
41
  let myLatestSeen = false
27
42
  let nextNeighborEnriched: MessageResource | undefined
@@ -33,6 +48,9 @@ export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): Enrich
33
48
  if (isSystemMessage(message)) {
34
49
  const enriched = enrichSystemMessage(message, next)
35
50
  items.push(enriched)
51
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
52
+ items.push(unreadDivider())
53
+ }
36
54
  if (datesDifferBetween(message, prev)) items.push(dateSeparator(message))
37
55
  nextNeighborEnriched = enriched
38
56
  return
@@ -44,6 +62,10 @@ export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): Enrich
44
62
  const enriched = enrichRegularMessage(message, prev, next, isMyLatest, !!inReplyScreen)
45
63
  items.push(enriched)
46
64
 
65
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
66
+ items.push(unreadDivider())
67
+ }
68
+
47
69
  const shadow = replyShadowFor(enriched, prev)
48
70
  if (shadow) items.push(shadow)
49
71
 
@@ -57,6 +79,24 @@ export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): Enrich
57
79
  return items
58
80
  }
59
81
 
82
+ function crossesUnreadBoundary(
83
+ message: MessageResource,
84
+ prev: MessageResource | undefined,
85
+ jumpToUnreadActive: boolean | undefined,
86
+ initialMessageId: string | null | undefined
87
+ ): boolean {
88
+ if (!jumpToUnreadActive) return false
89
+ if (!initialMessageId) return false
90
+ if (!prev) return false
91
+ return (
92
+ prev.id.localeCompare(initialMessageId) <= 0 && message.id.localeCompare(initialMessageId) > 0
93
+ )
94
+ }
95
+
96
+ function unreadDivider(): UnreadDividerItem {
97
+ return { type: 'UnreadDivider', id: UNREAD_DIVIDER_KEY }
98
+ }
99
+
60
100
  function neighborsOf<T>(arr: T[], i: number): { prev: T | undefined; next: T | undefined } {
61
101
  return { prev: arr[i + 1], next: arr[i - 1] }
62
102
  }
@@ -0,0 +1,42 @@
1
+ export const FLUSH_DELAY_MS = 2000
2
+
3
+ type SendFn = (args: { conversationId: number; sortKey: string }) => void
4
+
5
+ export function makeHighestSeenTracker(
6
+ conversationId: number,
7
+ send: SendFn,
8
+ flushDelayMs: number = FLUSH_DELAY_MS
9
+ ) {
10
+ let highest: string | null = null
11
+ let lastSent: string | null = null
12
+ let timer: ReturnType<typeof setTimeout> | null = null
13
+
14
+ const fire = () => {
15
+ timer = null
16
+ if (!highest || highest === lastSent) return
17
+ lastSent = highest
18
+ send({ conversationId, sortKey: highest })
19
+ }
20
+
21
+ return {
22
+ onSeen(sortKey: string) {
23
+ if (highest && sortKey.localeCompare(highest) <= 0) return
24
+ highest = sortKey
25
+ if (timer) clearTimeout(timer)
26
+ timer = setTimeout(fire, flushDelayMs)
27
+ },
28
+ flushNow() {
29
+ if (timer) {
30
+ clearTimeout(timer)
31
+ timer = null
32
+ }
33
+ fire()
34
+ },
35
+ cancel() {
36
+ if (timer) {
37
+ clearTimeout(timer)
38
+ timer = null
39
+ }
40
+ },
41
+ }
42
+ }
@@ -0,0 +1,49 @@
1
+ import { dividerExitedTowardNewer } from './unread_divider_helpers'
2
+
3
+ export interface ViewableEntry<Item> {
4
+ key: string
5
+ isViewable: boolean
6
+ item: Item
7
+ }
8
+
9
+ export interface ViewabilityEvent<Item> {
10
+ viewableItems: ViewableEntry<Item>[]
11
+ changed: ViewableEntry<Item>[]
12
+ userHasScrolled: boolean
13
+ }
14
+
15
+ export type ViewabilityObserver<Item> = (event: ViewabilityEvent<Item>) => void
16
+
17
+ export function reportViewableMessages<Item extends { id?: string; type?: string }>(
18
+ onMessageSeen: (id: string) => void
19
+ ): ViewabilityObserver<Item> {
20
+ return ({ viewableItems, userHasScrolled }) => {
21
+ if (!userHasScrolled) return
22
+ for (const entry of viewableItems) {
23
+ if (entry.item?.type !== 'Message') continue
24
+ const id = entry.item?.id
25
+ if (typeof id === 'string') onMessageSeen(id)
26
+ }
27
+ }
28
+ }
29
+
30
+ export function detectDividerExitTowardNewer<Item extends { id?: string; type?: string }>({
31
+ dividerKey,
32
+ initialMessageId,
33
+ onExited,
34
+ }: {
35
+ dividerKey: string
36
+ initialMessageId: string | null
37
+ onExited: () => void
38
+ }): ViewabilityObserver<Item> {
39
+ return ({ viewableItems, changed, userHasScrolled }) => {
40
+ if (!userHasScrolled || !initialMessageId) return
41
+ const exited = dividerExitedTowardNewer({
42
+ changed,
43
+ viewableItems,
44
+ dividerKey,
45
+ initialMessageId,
46
+ })
47
+ if (exited) onExited()
48
+ }
49
+ }
@@ -0,0 +1,25 @@
1
+ type ViewableChangeEntry = { key: string; isViewable: boolean }
2
+ type ViewableItem = { item: { id?: string; type?: string } }
3
+
4
+ export function dividerExitedTowardNewer({
5
+ changed,
6
+ viewableItems,
7
+ dividerKey,
8
+ initialMessageId,
9
+ }: {
10
+ changed: ViewableChangeEntry[]
11
+ viewableItems: ViewableItem[]
12
+ dividerKey: string
13
+ initialMessageId: string
14
+ }): boolean {
15
+ const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable)
16
+ if (!dividerExited) return false
17
+
18
+ const visibleMessageIds = viewableItems
19
+ .filter(v => v.item?.type === 'Message')
20
+ .map(v => v.item?.id)
21
+ .filter((id): id is string => !!id)
22
+ if (visibleMessageIds.length === 0) return false
23
+
24
+ return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0)
25
+ }