@planningcenter/chat-react-native 4.2.0 → 4.2.1-qa-staging.1

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 (123) hide show
  1. package/build/components/conversation/anchor_not_found_boundary.d.ts +18 -0
  2. package/build/components/conversation/anchor_not_found_boundary.d.ts.map +1 -0
  3. package/build/components/conversation/anchor_not_found_boundary.js +20 -0
  4. package/build/components/conversation/anchor_not_found_boundary.js.map +1 -0
  5. package/build/components/conversation/message_form.d.ts +2 -1
  6. package/build/components/conversation/message_form.d.ts.map +1 -1
  7. package/build/components/conversation/message_form.js +54 -51
  8. package/build/components/conversation/message_form.js.map +1 -1
  9. package/build/components/conversation/message_list.d.ts +3 -2
  10. package/build/components/conversation/message_list.d.ts.map +1 -1
  11. package/build/components/conversation/message_list.js +152 -66
  12. package/build/components/conversation/message_list.js.map +1 -1
  13. package/build/hooks/index.d.ts +1 -0
  14. package/build/hooks/index.d.ts.map +1 -1
  15. package/build/hooks/index.js +1 -0
  16. package/build/hooks/index.js.map +1 -1
  17. package/build/hooks/paginator_meta.d.ts +3 -1
  18. package/build/hooks/paginator_meta.d.ts.map +1 -1
  19. package/build/hooks/paginator_meta.js +25 -8
  20. package/build/hooks/paginator_meta.js.map +1 -1
  21. package/build/hooks/use_api.d.ts.map +1 -1
  22. package/build/hooks/use_api.js +5 -8
  23. package/build/hooks/use_api.js.map +1 -1
  24. package/build/hooks/use_conversation_messages.d.ts +8 -2
  25. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  26. package/build/hooks/use_conversation_messages.js +77 -45
  27. package/build/hooks/use_conversation_messages.js.map +1 -1
  28. package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -1
  29. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  30. package/build/hooks/use_conversation_messages_jolt_events.js +88 -73
  31. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  32. package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
  33. package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
  34. package/build/hooks/use_direct_message_eligibility.js +87 -0
  35. package/build/hooks/use_direct_message_eligibility.js.map +1 -0
  36. package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
  37. package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
  38. package/build/hooks/use_fetch_newer_messages.js +56 -0
  39. package/build/hooks/use_fetch_newer_messages.js.map +1 -0
  40. package/build/hooks/use_fetch_newer_on_foreground.d.ts +8 -0
  41. package/build/hooks/use_fetch_newer_on_foreground.d.ts.map +1 -0
  42. package/build/hooks/use_fetch_newer_on_foreground.js +41 -0
  43. package/build/hooks/use_fetch_newer_on_foreground.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +2 -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 +4 -3
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  49. package/build/hooks/use_suspense_api.js +5 -8
  50. package/build/hooks/use_suspense_api.js.map +1 -1
  51. package/build/screens/conversation_new/components/services_form.js +9 -8
  52. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  53. package/build/screens/conversation_screen.d.ts +1 -0
  54. package/build/screens/conversation_screen.d.ts.map +1 -1
  55. package/build/screens/conversation_screen.js +241 -355
  56. package/build/screens/conversation_screen.js.map +1 -1
  57. package/build/types/api_primitives.d.ts +2 -0
  58. package/build/types/api_primitives.d.ts.map +1 -1
  59. package/build/types/api_primitives.js.map +1 -1
  60. package/build/types/resources/member.d.ts +1 -0
  61. package/build/types/resources/member.d.ts.map +1 -1
  62. package/build/types/resources/member.js.map +1 -1
  63. package/build/utils/cache/prepend_newer_page.d.ts +7 -0
  64. package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
  65. package/build/utils/cache/prepend_newer_page.js +14 -0
  66. package/build/utils/cache/prepend_newer_page.js.map +1 -0
  67. package/build/utils/client/types.d.ts +1 -0
  68. package/build/utils/client/types.d.ts.map +1 -1
  69. package/build/utils/client/types.js.map +1 -1
  70. package/build/utils/gated_message_cache.d.ts +7 -0
  71. package/build/utils/gated_message_cache.d.ts.map +1 -0
  72. package/build/utils/gated_message_cache.js +13 -0
  73. package/build/utils/gated_message_cache.js.map +1 -0
  74. package/build/utils/generate_placeholder_ulid.d.ts +0 -6
  75. package/build/utils/generate_placeholder_ulid.d.ts.map +1 -1
  76. package/build/utils/generate_placeholder_ulid.js +2 -2
  77. package/build/utils/generate_placeholder_ulid.js.map +1 -1
  78. package/build/utils/group_messages.js +4 -1
  79. package/build/utils/group_messages.js.map +1 -1
  80. package/build/utils/request/get_messages.d.ts +18 -6
  81. package/build/utils/request/get_messages.d.ts.map +1 -1
  82. package/build/utils/request/get_messages.js +10 -12
  83. package/build/utils/request/get_messages.js.map +1 -1
  84. package/build/utils/ulid.d.ts +3 -0
  85. package/build/utils/ulid.d.ts.map +1 -0
  86. package/build/utils/ulid.js +7 -0
  87. package/build/utils/ulid.js.map +1 -0
  88. package/package.json +3 -3
  89. package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
  90. package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
  91. package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
  92. package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
  93. package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
  94. package/src/__tests__/utils/request/get_messages.test.ts +24 -0
  95. package/src/__tests__/utils/ulid.test.ts +15 -0
  96. package/src/components/conversation/__tests__/anchor_not_found_boundary.test.tsx +51 -0
  97. package/src/components/conversation/__tests__/message_list.test.tsx +42 -0
  98. package/src/components/conversation/anchor_not_found_boundary.tsx +21 -0
  99. package/src/components/conversation/message_form.tsx +4 -0
  100. package/src/components/conversation/message_list.tsx +58 -4
  101. package/src/hooks/index.ts +1 -0
  102. package/src/hooks/paginator_meta.ts +29 -5
  103. package/src/hooks/use_api.ts +4 -5
  104. package/src/hooks/use_conversation_messages.ts +43 -11
  105. package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
  106. package/src/hooks/use_direct_message_eligibility.ts +40 -0
  107. package/src/hooks/use_fetch_newer_messages.ts +37 -0
  108. package/src/hooks/use_fetch_newer_on_foreground.ts +25 -0
  109. package/src/hooks/use_mark_latest_message_read.ts +4 -3
  110. package/src/hooks/use_suspense_api.ts +4 -5
  111. package/src/screens/conversation_new/components/services_form.tsx +5 -5
  112. package/src/screens/conversation_screen.tsx +120 -17
  113. package/src/types/api_primitives.ts +2 -0
  114. package/src/types/resources/member.ts +1 -0
  115. package/src/utils/__tests__/group_messages.test.ts +29 -0
  116. package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
  117. package/src/utils/cache/prepend_newer_page.ts +22 -0
  118. package/src/utils/client/types.ts +1 -0
  119. package/src/utils/gated_message_cache.ts +19 -0
  120. package/src/utils/generate_placeholder_ulid.ts +2 -2
  121. package/src/utils/group_messages.ts +4 -1
  122. package/src/utils/request/get_messages.ts +12 -14
  123. package/src/utils/ulid.ts +11 -0
@@ -7,10 +7,11 @@ import {
7
7
  useTheme as useNavigationTheme,
8
8
  useRoute,
9
9
  } from '@react-navigation/native'
10
- import React, { useCallback, useEffect, useRef, useState } from 'react'
10
+ import React, { startTransition, useCallback, useEffect, useRef, useState } from 'react'
11
11
  import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
12
12
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
13
13
  import { Badge, Icon } from '../components'
14
+ import { AnchorNotFoundBoundary } from '../components/conversation/anchor_not_found_boundary'
14
15
  import { ConversationStartBanner } from '../components/conversation/conversation_start_banner'
15
16
  import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
16
17
  import { MessageForm } from '../components/conversation/message_form'
@@ -30,6 +31,8 @@ import { useConversation } from '../hooks/use_conversation'
30
31
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
31
32
  import { useConversationMessages } from '../hooks/use_conversation_messages'
32
33
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
34
+ import { useFetchNewerMessages } from '../hooks/use_fetch_newer_messages'
35
+ import { useFetchNewerOnForeground } from '../hooks/use_fetch_newer_on_foreground'
33
36
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
34
37
  import {
35
38
  analyticsEvents,
@@ -41,6 +44,8 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
41
44
  import { ConversationResource } from '../types/resources/conversation'
42
45
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
43
46
  import { MessageResource } from '../types/resources/message'
47
+ import { gatedMessageCache } from '../utils/gated_message_cache'
48
+ import { decodeUlidTime } from '../utils/ulid'
44
49
 
45
50
  export type ConversationRouteProps = {
46
51
  conversation_id: number
@@ -49,6 +54,7 @@ export type ConversationRouteProps = {
49
54
  chat_group_graph_id?: string
50
55
  clear_input?: boolean
51
56
  editing_message_id?: number | null
57
+ initial_message_id?: string | null
52
58
  title?: string
53
59
  subtitle?: string
54
60
  badge?: ConversationBadgeResource
@@ -59,9 +65,16 @@ export type ConversationRouteProps = {
59
65
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
60
66
 
61
67
  export function ConversationScreen({ route }: ConversationScreenProps) {
62
- const { conversation_id, reply_root_id } = route.params
68
+ const { conversation_id, reply_root_id, initial_message_id } = route.params
63
69
 
64
70
  const { data: conversation } = useConversation({ conversation_id })
71
+ const [anchor, setAnchor] = useState(() =>
72
+ isBeforeLatestMessage(initial_message_id, conversation?.lastMessageCreatedAt)
73
+ ? initial_message_id
74
+ : null
75
+ )
76
+ const exitAnchoredMode = useCallback(() => startTransition(() => setAnchor(null)), [])
77
+ const dropAnchor = useCallback(() => setAnchor(null), [])
65
78
 
66
79
  usePublishProductAnalyticsEvent(analyticsEvents.conversation_show_opened, {
67
80
  reply_root_id,
@@ -73,26 +86,72 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
73
86
  conversationId={conversation_id}
74
87
  currentPageReplyRootId={reply_root_id ?? null}
75
88
  >
76
- <ConversationScreenContent route={route} />
89
+ {anchor ? (
90
+ <AnchorNotFoundBoundary onAnchorNotFound={dropAnchor}>
91
+ <ConversationScreenContent
92
+ route={route}
93
+ anchor={anchor}
94
+ onExitAnchoredMode={exitAnchoredMode}
95
+ />
96
+ </AnchorNotFoundBoundary>
97
+ ) : (
98
+ <ConversationScreenContent
99
+ route={route}
100
+ anchor={null}
101
+ onExitAnchoredMode={exitAnchoredMode}
102
+ />
103
+ )}
77
104
  </ConversationContextProvider>
78
105
  )
79
106
  }
80
107
 
81
- function ConversationScreenContent({ route }: ConversationScreenProps) {
108
+ interface ConversationScreenContentProps extends ConversationScreenProps {
109
+ anchor: string | null | undefined
110
+ onExitAnchoredMode: () => void
111
+ }
112
+
113
+ function ConversationScreenContent({
114
+ route,
115
+ anchor,
116
+ onExitAnchoredMode,
117
+ }: ConversationScreenContentProps) {
82
118
  const styles = useStyles()
83
119
  const navigation = useNavigation()
84
120
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
85
121
  route.params
86
122
  const { data: conversation } = useConversation(route.params)
87
- const { messages, fetchNextPage, hasNextPage } = useConversationMessages({
88
- conversation_id,
89
- reply_root_id,
90
- })
123
+ const anchored = !!anchor
124
+ const {
125
+ messages,
126
+ fetchNextPage,
127
+ hasNextPage,
128
+ fetchPreviousPage,
129
+ hasPreviousPage,
130
+ isFetchingPreviousPage,
131
+ queryKey,
132
+ } = useConversationMessages({ conversation_id, reply_root_id, anchor })
133
+ const behindLatest = anchored && hasPreviousPage
134
+ const caughtUp = !hasPreviousPage
135
+ const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
136
+ const readingHistory = showJumpToBottomButton
137
+ const skippedNewMessages = useRef(false)
138
+ const messageCaches = [
139
+ gatedMessageCache({
140
+ queryKey,
141
+ acceptNewMessages: caughtUp && !readingHistory,
142
+ onSkippedNewMessage: () => {
143
+ skippedNewMessages.current = true
144
+ },
145
+ }),
146
+ ]
147
+ const newestMessageId = messages[0]?.id
91
148
  useConversationJoltEvents({ conversationId: conversation_id })
92
- useConversationMessagesJoltEvents({ conversationId: conversation_id })
149
+ useConversationMessagesJoltEvents({ conversationId: conversation_id, messageCaches })
93
150
  const { markHandledByMessageForm } = useVideoModerationJoltEvents()
94
151
  useEnsureConversationsRouteExists()
95
- useMarkLatestMessageRead({ conversation, messages })
152
+ useMarkLatestMessageRead({ conversation, messages, enabled: !behindLatest })
153
+ const fetchNewer = useFetchNewerMessages({ conversation_id, reply_root_id, queryKey })
154
+ useFetchNewerOnForeground({ fetchNewer, newestMessageId, enabled: anchored && caughtUp })
96
155
  const noMessages = messages.length === 0
97
156
 
98
157
  const { repliesDisabled, memberAbility, badges, title } = conversation
@@ -107,12 +166,8 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
107
166
  const muted = conversation.conversationMembership?.muted ?? conversation.muted
108
167
 
109
168
  const listRef = useRef<FlatList>(null)
110
- const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
111
-
112
- const trackScroll = (event: any) => {
113
- const offsetY = event.nativeEvent.contentOffset.y
114
- setShowJumpToBottomButton(offsetY > 200)
115
- }
169
+ const [hasDragged, setHasDragged] = useState(false)
170
+ const holdListPosition = behindLatest || readingHistory
116
171
 
117
172
  const { restOffset, onLayout, onContentSizeChange, onMomentumScrollEnd, onScrollEndDrag } =
118
173
  useRestAwayFromDismissEdge({
@@ -126,6 +181,35 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
126
181
  })
127
182
  }, [restOffset])
128
183
 
184
+ const exitAnchoredMode = useCallback(() => {
185
+ onExitAnchoredMode()
186
+ handleReturnToBottom()
187
+ }, [handleReturnToBottom, onExitAnchoredMode])
188
+ const jumpToLatest = useCallback(() => {
189
+ setShowJumpToBottomButton(false)
190
+ if (behindLatest) exitAnchoredMode()
191
+ else handleReturnToBottom()
192
+ }, [behindLatest, exitAnchoredMode, handleReturnToBottom])
193
+
194
+ const trackScroll = (event: any) => {
195
+ const offsetY = event.nativeEvent.contentOffset.y
196
+ setShowJumpToBottomButton(offsetY > 200)
197
+ }
198
+
199
+ const loadNewerMessages = () => {
200
+ if (!hasPreviousPage || isFetchingPreviousPage) return
201
+
202
+ setHasDragged(false)
203
+ fetchPreviousPage()
204
+ }
205
+
206
+ useEffect(() => {
207
+ if (readingHistory || !caughtUp || !newestMessageId || !skippedNewMessages.current) return
208
+
209
+ skippedNewMessages.current = false
210
+ fetchNewer(newestMessageId)
211
+ }, [caughtUp, fetchNewer, newestMessageId, readingHistory])
212
+
129
213
  useEffect(() => {
130
214
  if (reply_root_id) {
131
215
  navigation.setParams({
@@ -174,6 +258,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
174
258
  conversationId={conversation_id}
175
259
  messages={messages}
176
260
  replyRootId={reply_root_id}
261
+ anchorMessageId={anchor}
177
262
  hasNextPage={hasNextPage}
178
263
  onEndReached={() => fetchNextPage()}
179
264
  onScroll={trackScroll}
@@ -181,9 +266,18 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
181
266
  onContentSizeChange={onContentSizeChange}
182
267
  onScrollEndDrag={onScrollEndDrag}
183
268
  onMomentumScrollEnd={onMomentumScrollEnd}
269
+ onScrollBeginDrag={anchored ? () => setHasDragged(true) : undefined}
270
+ onStartReached={anchored && hasDragged ? loadNewerMessages : undefined}
271
+ onStartReachedThreshold={anchored ? 0.5 : undefined}
272
+ maintainVisibleContentPosition={
273
+ holdListPosition ? { minIndexForVisible: 0 } : undefined
274
+ }
184
275
  />
185
276
  )}
186
- <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
277
+ <JumpToBottomButton
278
+ onPress={jumpToLatest}
279
+ visible={behindLatest || showJumpToBottomButton}
280
+ />
187
281
  {!noMessages && <TypingIndicator />}
188
282
  {showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
189
283
  <ConversationBottomBar
@@ -193,6 +287,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
193
287
  replyRootId={reply_root_id}
194
288
  currentlyEditingMessage={currentlyEditingMessage}
195
289
  onFlaggedInMessageForm={markHandledByMessageForm}
290
+ onBeforeSubmit={jumpToLatest}
196
291
  />
197
292
  </KeyboardView>
198
293
  </View>
@@ -206,6 +301,7 @@ interface ConversationBottomBarProps {
206
301
  replyRootId: string | null | undefined
207
302
  currentlyEditingMessage: MessageResource | undefined
208
303
  onFlaggedInMessageForm?: (id: string) => void
304
+ onBeforeSubmit?: () => void
209
305
  }
210
306
 
211
307
  function ConversationBottomBar({
@@ -215,6 +311,7 @@ function ConversationBottomBar({
215
311
  replyRootId,
216
312
  currentlyEditingMessage,
217
313
  onFlaggedInMessageForm,
314
+ onBeforeSubmit,
218
315
  }: ConversationBottomBarProps) {
219
316
  const messageLink = useMessageAvailableMembersAction(conversation)
220
317
 
@@ -233,6 +330,7 @@ function ConversationBottomBar({
233
330
  replyRootId={replyRootId}
234
331
  currentlyEditingMessage={currentlyEditingMessage}
235
332
  onFlaggedInMessageForm={onFlaggedInMessageForm}
333
+ onBeforeSubmit={onBeforeSubmit}
236
334
  key={
237
335
  currentlyEditingMessage
238
336
  ? `edit-message-form-${currentlyEditingMessage.id}`
@@ -247,6 +345,11 @@ function ConversationBottomBar({
247
345
  )
248
346
  }
249
347
 
348
+ const isBeforeLatestMessage = (anchor?: string | null, latestMessageCreatedAt?: string) =>
349
+ !!anchor &&
350
+ !!latestMessageCreatedAt &&
351
+ decodeUlidTime(anchor) < Date.parse(latestMessageCreatedAt)
352
+
250
353
  interface ConversationScreenTitleProps extends HeaderTitleProps {
251
354
  conversation_id: number
252
355
  badge?: ConversationBadgeResource
@@ -33,12 +33,14 @@ export interface NextPageCursor {
33
33
  idLte?: string
34
34
  idGt?: string
35
35
  idGte?: string
36
+ order?: 'asc' | 'desc'
36
37
  }
37
38
 
38
39
  export interface CollectionMeta {
39
40
  count: number
40
41
  totalCount: number
41
42
  next?: NextPageCursor
43
+ prev?: NextPageCursor
42
44
  parent?: ResourceObject
43
45
  [attributeName: string]: unknown
44
46
  }
@@ -6,6 +6,7 @@ export interface MemberResource {
6
6
  badges: MemberBadge[]
7
7
  child: boolean
8
8
  directMessagesDisabled?: boolean
9
+ canDirectMessage?: boolean
9
10
  gender: string | null
10
11
  role?: string
11
12
  }
@@ -170,6 +170,35 @@ describe('groupMessages — reply roots', () => {
170
170
 
171
171
  expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: null })
172
172
  })
173
+
174
+ it.each([
175
+ ['is still saving', { pending: true }],
176
+ ['failed to save', { error: 'Failed to send message' }],
177
+ ])('keeps the saved snapshot while a root edit %s', (_label, unsavedState) => {
178
+ const snapshot = message('rootA', { replyRootId: 'rootA', text: 'saved' })
179
+ const messages = [
180
+ message('03', { replyRootId: 'rootA', replyRoot: snapshot }),
181
+ message('02'),
182
+ message('rootA', { replyRootId: 'rootA', text: 'unsaved', ...unsavedState }),
183
+ ]
184
+
185
+ const enriched = groupMessages({ ms: messages })
186
+
187
+ expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'saved' } })
188
+ expect((findEnriched(enriched, 'rootA') as MessageResource).text).toBe('unsaved')
189
+ })
190
+
191
+ it('shows the unsaved root edit when the reply carries no snapshot to fall back on', () => {
192
+ const messages = [
193
+ message('03', { replyRootId: 'rootA' }),
194
+ message('02'),
195
+ message('rootA', { replyRootId: 'rootA', text: 'unsaved', pending: true }),
196
+ ]
197
+
198
+ const enriched = groupMessages({ ms: messages })
199
+
200
+ expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'unsaved' } })
201
+ })
173
202
  })
174
203
 
175
204
  describe('groupMessages — system messages', () => {
@@ -0,0 +1,35 @@
1
+ import { prependNewerPage } from '../prepend_newer_page'
2
+
3
+ const page = (ids: string[], next?: { idGt: string }) => ({
4
+ data: ids.map(id => ({ id, type: 'Message' })),
5
+ links: {},
6
+ meta: { count: ids.length, totalCount: ids.length, next },
7
+ })
8
+ const pageParam = { where: { id_gt: '01C' }, order: 'asc' }
9
+
10
+ describe('prependNewerPage', () => {
11
+ const cached = { pages: [page(['01C', '01B']), page(['01A'])], pageParams: [{}, { offset: 1 }] }
12
+
13
+ it('prepends only the messages the cache does not already hold', () => {
14
+ const result = prependNewerPage(cached, page(['01C', '01D', '01E']), pageParam)
15
+
16
+ expect(result?.pages[0].data.map(m => m.id)).toEqual(['01D', '01E'])
17
+ expect(result?.pages).toHaveLength(3)
18
+ expect(result?.pageParams[0]).toEqual(pageParam)
19
+ })
20
+
21
+ it('leaves the cache alone when nothing new came back', () => {
22
+ expect(prependNewerPage(cached, page(['01C']), pageParam)).toBe(cached)
23
+ })
24
+
25
+ it('still prepends an empty page when the server reports more newer messages', () => {
26
+ const result = prependNewerPage(cached, page(['01C'], { idGt: '01C' }), pageParam)
27
+
28
+ expect(result?.pages[0].data).toEqual([])
29
+ expect(result?.pages[0].meta.next).toEqual({ idGt: '01C' })
30
+ })
31
+
32
+ it('does nothing without cached data', () => {
33
+ expect(prependNewerPage(undefined, page(['01D']), pageParam)).toBeUndefined()
34
+ })
35
+ })
@@ -0,0 +1,22 @@
1
+ import { InfiniteData } from '@tanstack/react-query'
2
+ import { ApiCollection, ResourceObject } from '../../types'
3
+ import { RequestData } from '../client'
4
+
5
+ type PagesData<T extends ResourceObject> = InfiniteData<ApiCollection<T>, Partial<RequestData>>
6
+
7
+ export function prependNewerPage<T extends ResourceObject>(
8
+ data: PagesData<T> | undefined,
9
+ page: ApiCollection<T>,
10
+ pageParam: Partial<RequestData>
11
+ ): PagesData<T> | undefined {
12
+ if (!data) return data
13
+
14
+ const knownIds = new Set(data.pages.flatMap(p => p.data.map(record => record.id)))
15
+ const fresh = page.data.filter(record => !knownIds.has(record.id))
16
+ if (fresh.length === 0 && !page.meta?.next) return data
17
+
18
+ return {
19
+ pages: [{ ...page, data: fresh }, ...data.pages],
20
+ pageParams: [pageParam, ...data.pageParams],
21
+ }
22
+ }
@@ -65,4 +65,5 @@ export type RequestData = {
65
65
  data: Record<string, unknown>
66
66
  perPage: number
67
67
  offset: number
68
+ order: string
68
69
  }
@@ -0,0 +1,19 @@
1
+ import type { MessagesCache } from '../hooks/use_conversation_messages_jolt_events'
2
+
3
+ export const gatedMessageCache = ({
4
+ queryKey,
5
+ acceptNewMessages,
6
+ onSkippedNewMessage,
7
+ }: {
8
+ queryKey: unknown[]
9
+ acceptNewMessages: boolean
10
+ onSkippedNewMessage?: () => void
11
+ }): MessagesCache => ({
12
+ queryKey,
13
+ canUpdate: event => {
14
+ if (event !== 'message.created' || acceptNewMessages) return true
15
+
16
+ onSkippedNewMessage?.()
17
+ return false
18
+ },
19
+ })
@@ -1,3 +1,5 @@
1
+ import { ULID_CHARS } from './ulid'
2
+
1
3
  /**
2
4
  * The actual `ulid` library depends on a secure random number generator, which looks like it
3
5
  * may require a native dependency to polyfill in React Native.
@@ -5,8 +7,6 @@
5
7
  * implementation that doesn't require any native dependencies.
6
8
  */
7
9
 
8
- const ULID_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford's Base32
9
-
10
10
  function encodeBase32(value: number, length: number) {
11
11
  let result = ''
12
12
  for (let i = 0; i < length; i++) {
@@ -74,7 +74,10 @@ function resolveReplyRoot(
74
74
  messagesById: Map<string, MessageResource>
75
75
  ): MessageResource | null {
76
76
  if (!message.replyRootId || message.replyRootId === message.id) return null
77
- return messagesById.get(message.replyRootId) ?? message.replyRoot ?? null
77
+ const loadedRoot = messagesById.get(message.replyRootId)
78
+ const loadedRootIsSaved = !!loadedRoot && !loadedRoot.pending && !loadedRoot.error
79
+ if (loadedRootIsSaved) return loadedRoot
80
+ return message.replyRoot ?? loadedRoot ?? null
78
81
  }
79
82
 
80
83
  function enrichSystemMessage(
@@ -1,17 +1,22 @@
1
1
  import { getRequestQueryKey } from '../../hooks/use_suspense_api'
2
2
  import { getMessageFields, getMessagesInclude } from './messages_data_options'
3
3
 
4
+ type MessagesRequestOptions = {
5
+ conversation_id: number
6
+ reply_root_id?: string | null
7
+ anchor?: string | null
8
+ }
9
+
4
10
  export const getMessagesRequestArgs = ({
5
11
  conversation_id,
6
12
  reply_root_id,
7
- }: {
8
- conversation_id: number
9
- reply_root_id?: string | null
10
- }) => {
13
+ anchor,
14
+ }: MessagesRequestOptions) => {
11
15
  const normalizedReplyRootId = reply_root_id ?? null
12
16
  const url = normalizedReplyRootId
13
17
  ? `/me/conversations/${conversation_id}/messages/${normalizedReplyRootId}/replies`
14
18
  : `/me/conversations/${conversation_id}/messages`
19
+ const anchored = anchor ? { where: { id_around: anchor } } : {}
15
20
 
16
21
  return {
17
22
  url,
@@ -19,18 +24,11 @@ export const getMessagesRequestArgs = ({
19
24
  perPage: 25,
20
25
  fields: getMessageFields,
21
26
  include: getMessagesInclude,
27
+ ...anchored,
22
28
  },
23
29
  reply_root_id: normalizedReplyRootId,
24
30
  }
25
31
  }
26
32
 
27
- export const getMessagesQueryKey = ({
28
- conversation_id,
29
- reply_root_id,
30
- }: {
31
- conversation_id: number
32
- reply_root_id?: string | null
33
- }) => {
34
- const requestArgs = getMessagesRequestArgs({ conversation_id, reply_root_id })
35
- return getRequestQueryKey(requestArgs)
36
- }
33
+ export const getMessagesQueryKey = (options: MessagesRequestOptions) =>
34
+ getRequestQueryKey(getMessagesRequestArgs(options))
@@ -0,0 +1,11 @@
1
+ export const ULID_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford's Base32
2
+
3
+ const TIME_LENGTH = 10
4
+
5
+ export function decodeUlidTime(ulid: string): number {
6
+ return ulid
7
+ .slice(0, TIME_LENGTH)
8
+ .toUpperCase()
9
+ .split('')
10
+ .reduce((time, char) => time * 32 + ULID_CHARS.indexOf(char), 0)
11
+ }