@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.
- package/README.md +1 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +39 -7
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/components/conversation/unread_divider.d.ts +6 -0
- package/build/components/conversation/unread_divider.d.ts.map +1 -0
- package/build/components/conversation/unread_divider.js +59 -0
- package/build/components/conversation/unread_divider.js.map +1 -0
- package/build/contexts/conversation_context.d.ts +2 -0
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -5
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +2 -0
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -5
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_conversations_actions.d.ts +5 -0
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +12 -0
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_flat_list_viewability.d.ts +20 -0
- package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
- package/build/hooks/use_flat_list_viewability.js +30 -0
- package/build/hooks/use_flat_list_viewability.js.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.js +62 -0
- package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.js +53 -0
- package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.js +10 -0
- package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +17 -1
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_scroll_tracking.d.ts +13 -0
- package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
- package/build/hooks/use_scroll_tracking.js +45 -0
- package/build/hooks/use_scroll_tracking.js.map +1 -0
- package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
- package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
- package/build/hooks/use_track_highest_seen_message.js +35 -0
- package/build/hooks/use_track_highest_seen_message.js.map +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/screens/conversation_screen.d.ts +0 -19
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +87 -139
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +4 -0
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +28 -0
- package/build/utils/group_messages.d.ts.map +1 -0
- package/build/utils/group_messages.js +142 -0
- package/build/utils/group_messages.js.map +1 -0
- package/build/utils/highest_seen_tracker.d.ts +12 -0
- package/build/utils/highest_seen_tracker.d.ts.map +1 -0
- package/build/utils/highest_seen_tracker.js +37 -0
- package/build/utils/highest_seen_tracker.js.map +1 -0
- package/build/utils/message_viewability.d.ts +24 -0
- package/build/utils/message_viewability.d.ts.map +1 -0
- package/build/utils/message_viewability.js +29 -0
- package/build/utils/message_viewability.js.map +1 -0
- package/build/utils/unread_divider_helpers.d.ts +18 -0
- package/build/utils/unread_divider_helpers.d.ts.map +1 -0
- package/build/utils/unread_divider_helpers.js +13 -0
- package/build/utils/unread_divider_helpers.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/contexts/session_context.tsx +1 -1
- package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
- package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
- package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
- package/src/components/conversation/reply_shadow_message.tsx +4 -2
- package/src/components/conversation/unread_divider.tsx +90 -0
- package/src/contexts/conversation_context.tsx +15 -13
- package/src/hooks/use_conversation_messages.ts +19 -3
- package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
- package/src/hooks/use_conversations_actions.ts +15 -0
- package/src/hooks/use_flat_list_viewability.ts +50 -0
- package/src/hooks/use_jump_to_bottom_action.ts +75 -0
- package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
- package/src/hooks/use_jump_to_unread_gates.ts +10 -0
- package/src/hooks/use_mark_latest_message_read.ts +16 -2
- package/src/hooks/use_scroll_tracking.ts +64 -0
- package/src/hooks/use_track_highest_seen_message.ts +43 -0
- package/src/screens/conversation_screen.tsx +173 -197
- package/src/utils/__tests__/group_messages.test.ts +214 -0
- package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
- package/src/utils/__tests__/message_viewability.test.ts +168 -0
- package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
- package/src/utils/cache/messages_cache.ts +5 -0
- package/src/utils/group_messages.ts +217 -0
- package/src/utils/highest_seen_tracker.ts +42 -0
- package/src/utils/message_viewability.ts +49 -0
- package/src/utils/unread_divider_helpers.ts +25 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { MessageResource } from '../../types/resources/message'
|
|
2
|
+
import type { PersonResource } from '../../types/resources/person'
|
|
3
|
+
import { groupMessages } from '../group_messages'
|
|
4
|
+
|
|
5
|
+
const author = { id: 1, type: 'Person', name: 'A', avatar: null } as unknown as PersonResource
|
|
6
|
+
const otherAuthor = {
|
|
7
|
+
id: 2,
|
|
8
|
+
type: 'Person',
|
|
9
|
+
name: 'B',
|
|
10
|
+
avatar: null,
|
|
11
|
+
} as unknown as PersonResource
|
|
12
|
+
|
|
13
|
+
const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
|
|
14
|
+
({
|
|
15
|
+
type: 'Message',
|
|
16
|
+
id,
|
|
17
|
+
text: `msg ${id}`,
|
|
18
|
+
html: `<p>msg ${id}</p>`,
|
|
19
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
20
|
+
deletedAt: null,
|
|
21
|
+
textEditedAt: null,
|
|
22
|
+
mine: false,
|
|
23
|
+
attachments: [],
|
|
24
|
+
author,
|
|
25
|
+
reactionCounts: [],
|
|
26
|
+
replyCount: 0,
|
|
27
|
+
replyRootId: null,
|
|
28
|
+
messageType: 'message',
|
|
29
|
+
personIdsForSystemEvent: null,
|
|
30
|
+
systemTextParts: null,
|
|
31
|
+
...overrides,
|
|
32
|
+
}) as MessageResource
|
|
33
|
+
|
|
34
|
+
const findEnriched = (items: ReturnType<typeof groupMessages>, id: string) =>
|
|
35
|
+
items.find(item => 'id' in item && item.id === id && !('type' in item && item.type !== 'Message'))
|
|
36
|
+
|
|
37
|
+
describe('groupMessages — immutability', () => {
|
|
38
|
+
it('does not mutate the input messages', () => {
|
|
39
|
+
const input = [message('02'), message('01')]
|
|
40
|
+
const snapshot = JSON.parse(JSON.stringify(input))
|
|
41
|
+
|
|
42
|
+
groupMessages({ ms: input })
|
|
43
|
+
|
|
44
|
+
expect(input).toEqual(snapshot)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('groupMessages — grouping', () => {
|
|
49
|
+
it('breaks groups across a >5min gap (lastInGroup flips on the older message)', () => {
|
|
50
|
+
const messages = [
|
|
51
|
+
message('02', { createdAt: '2026-01-01T00:06:00Z' }),
|
|
52
|
+
message('01', { createdAt: '2026-01-01T00:00:00Z' }),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const enriched = groupMessages({ ms: messages })
|
|
56
|
+
|
|
57
|
+
expect((findEnriched(enriched, '01') as MessageResource).lastInGroup).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('breaks groups when authors differ (renderAuthor flips on the newer message)', () => {
|
|
61
|
+
const messages = [message('02', { author }), message('01', { author: otherAuthor })]
|
|
62
|
+
|
|
63
|
+
const enriched = groupMessages({ ms: messages })
|
|
64
|
+
|
|
65
|
+
expect((findEnriched(enriched, '02') as MessageResource).renderAuthor).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('marks the first own message as myLatestInConversation and not later ones', () => {
|
|
69
|
+
const messages = [
|
|
70
|
+
message('03', { mine: true, createdAt: '2026-01-01T00:02:00Z' }),
|
|
71
|
+
message('02', { mine: true, createdAt: '2026-01-01T00:01:00Z' }),
|
|
72
|
+
message('01', { mine: false, createdAt: '2026-01-01T00:00:00Z' }),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const enriched = groupMessages({ ms: messages })
|
|
76
|
+
|
|
77
|
+
expect((findEnriched(enriched, '03') as MessageResource).myLatestInConversation).toBe(true)
|
|
78
|
+
expect((findEnriched(enriched, '02') as MessageResource).myLatestInConversation).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('groupMessages — render order at thread + date boundaries', () => {
|
|
83
|
+
it('reply shadow precedes date separator so the date renders above', () => {
|
|
84
|
+
const messages = [
|
|
85
|
+
message('02', { createdAt: '2026-01-02T12:00:00Z', replyRootId: 'rootB' }),
|
|
86
|
+
message('01', { createdAt: '2026-01-01T12:00:00Z', replyRootId: 'rootA' }),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const enriched = groupMessages({ ms: messages })
|
|
90
|
+
|
|
91
|
+
const shadowIdx = enriched.findIndex(
|
|
92
|
+
item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === '02-rootB'
|
|
93
|
+
)
|
|
94
|
+
const dateSepIdx = enriched.findIndex(
|
|
95
|
+
item => 'type' in item && item.type === 'DateSeparator' && item.id === 'day-divider-02'
|
|
96
|
+
)
|
|
97
|
+
expect(shadowIdx).toBeGreaterThan(-1)
|
|
98
|
+
expect(dateSepIdx).toBeGreaterThan(shadowIdx)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('skips reply-shadow insertion when inReplyScreen is false but threadPosition still resolves', () => {
|
|
102
|
+
const messages = [
|
|
103
|
+
message('02', { replyRootId: 'rootA', createdAt: '2026-01-01T00:01:00Z' }),
|
|
104
|
+
message('01', { id: 'rootA', replyRootId: 'rootA', createdAt: '2026-01-01T00:00:00Z' }),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
const enriched = groupMessages({ ms: messages })
|
|
108
|
+
|
|
109
|
+
expect((findEnriched(enriched, '02') as MessageResource).threadPosition).toBe('last')
|
|
110
|
+
expect((findEnriched(enriched, 'rootA') as MessageResource).threadPosition).toBe('first')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbor', () => {
|
|
115
|
+
it('propagates the newer neighbor’s computed renderAuthor across an author-change boundary', () => {
|
|
116
|
+
const messages = [message('02', { author: otherAuthor }), message('01', { author })]
|
|
117
|
+
|
|
118
|
+
const enriched = groupMessages({ ms: messages })
|
|
119
|
+
|
|
120
|
+
const newer = findEnriched(enriched, '02') as MessageResource
|
|
121
|
+
const older = findEnriched(enriched, '01') as MessageResource
|
|
122
|
+
expect(newer.renderAuthor).toBe(true)
|
|
123
|
+
expect(older.nextRendersAuthor).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
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
|
+
|
|
198
|
+
describe('groupMessages — system messages', () => {
|
|
199
|
+
it('flags lastInGroup true and renderAuthor false on system messages', () => {
|
|
200
|
+
const messages = [
|
|
201
|
+
message('sys', {
|
|
202
|
+
messageType: 'user_joined',
|
|
203
|
+
systemTextParts: { names: ['A'], overflowCount: 0, action: 'joined' },
|
|
204
|
+
personIdsForSystemEvent: [1],
|
|
205
|
+
}),
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const enriched = groupMessages({ ms: messages })
|
|
209
|
+
|
|
210
|
+
const sys = findEnriched(enriched, 'sys') as MessageResource
|
|
211
|
+
expect(sys.lastInGroup).toBe(true)
|
|
212
|
+
expect(sys.renderAuthor).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -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
|