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

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 (83) hide show
  1. package/build/components/conversation/message_form.d.ts +2 -1
  2. package/build/components/conversation/message_form.d.ts.map +1 -1
  3. package/build/components/conversation/message_form.js +54 -51
  4. package/build/components/conversation/message_form.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts +2 -2
  6. package/build/components/conversation/message_list.d.ts.map +1 -1
  7. package/build/components/conversation/message_list.js +19 -16
  8. package/build/components/conversation/message_list.js.map +1 -1
  9. package/build/hooks/index.d.ts +1 -0
  10. package/build/hooks/index.d.ts.map +1 -1
  11. package/build/hooks/index.js +1 -0
  12. package/build/hooks/index.js.map +1 -1
  13. package/build/hooks/paginator_meta.d.ts +3 -1
  14. package/build/hooks/paginator_meta.d.ts.map +1 -1
  15. package/build/hooks/paginator_meta.js +25 -8
  16. package/build/hooks/paginator_meta.js.map +1 -1
  17. package/build/hooks/use_api.d.ts.map +1 -1
  18. package/build/hooks/use_api.js +5 -8
  19. package/build/hooks/use_api.js.map +1 -1
  20. package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -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 +88 -73
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
  25. package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
  26. package/build/hooks/use_direct_message_eligibility.js +87 -0
  27. package/build/hooks/use_direct_message_eligibility.js.map +1 -0
  28. package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
  29. package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
  30. package/build/hooks/use_fetch_newer_messages.js +56 -0
  31. package/build/hooks/use_fetch_newer_messages.js.map +1 -0
  32. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  33. package/build/hooks/use_suspense_api.js +5 -8
  34. package/build/hooks/use_suspense_api.js.map +1 -1
  35. package/build/screens/conversation_new/components/services_form.js +9 -8
  36. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  37. package/build/screens/conversation_screen.d.ts.map +1 -1
  38. package/build/screens/conversation_screen.js +137 -322
  39. package/build/screens/conversation_screen.js.map +1 -1
  40. package/build/types/api_primitives.d.ts +2 -0
  41. package/build/types/api_primitives.d.ts.map +1 -1
  42. package/build/types/api_primitives.js.map +1 -1
  43. package/build/types/resources/member.d.ts +1 -0
  44. package/build/types/resources/member.d.ts.map +1 -1
  45. package/build/types/resources/member.js.map +1 -1
  46. package/build/utils/cache/prepend_newer_page.d.ts +7 -0
  47. package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
  48. package/build/utils/cache/prepend_newer_page.js +14 -0
  49. package/build/utils/cache/prepend_newer_page.js.map +1 -0
  50. package/build/utils/client/types.d.ts +1 -0
  51. package/build/utils/client/types.d.ts.map +1 -1
  52. package/build/utils/client/types.js.map +1 -1
  53. package/build/utils/gated_message_cache.d.ts +7 -0
  54. package/build/utils/gated_message_cache.d.ts.map +1 -0
  55. package/build/utils/gated_message_cache.js +13 -0
  56. package/build/utils/gated_message_cache.js.map +1 -0
  57. package/build/utils/group_messages.js +4 -1
  58. package/build/utils/group_messages.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
  61. package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
  62. package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
  63. package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
  64. package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
  65. package/src/components/conversation/message_form.tsx +4 -0
  66. package/src/components/conversation/message_list.tsx +8 -1
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/paginator_meta.ts +29 -5
  69. package/src/hooks/use_api.ts +4 -5
  70. package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
  71. package/src/hooks/use_direct_message_eligibility.ts +40 -0
  72. package/src/hooks/use_fetch_newer_messages.ts +37 -0
  73. package/src/hooks/use_suspense_api.ts +4 -5
  74. package/src/screens/conversation_new/components/services_form.tsx +5 -5
  75. package/src/screens/conversation_screen.tsx +36 -4
  76. package/src/types/api_primitives.ts +2 -0
  77. package/src/types/resources/member.ts +1 -0
  78. package/src/utils/__tests__/group_messages.test.ts +29 -0
  79. package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
  80. package/src/utils/cache/prepend_newer_page.ts +22 -0
  81. package/src/utils/client/types.ts +1 -0
  82. package/src/utils/gated_message_cache.ts +19 -0
  83. package/src/utils/group_messages.ts +4 -1
@@ -30,6 +30,7 @@ import { useConversation } from '../hooks/use_conversation'
30
30
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
31
31
  import { useConversationMessages } from '../hooks/use_conversation_messages'
32
32
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
33
+ import { useFetchNewerMessages } from '../hooks/use_fetch_newer_messages'
33
34
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
34
35
  import {
35
36
  analyticsEvents,
@@ -41,6 +42,7 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
41
42
  import { ConversationResource } from '../types/resources/conversation'
42
43
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
43
44
  import { MessageResource } from '../types/resources/message'
45
+ import { gatedMessageCache } from '../utils/gated_message_cache'
44
46
 
45
47
  export type ConversationRouteProps = {
46
48
  conversation_id: number
@@ -84,15 +86,29 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
84
86
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
85
87
  route.params
86
88
  const { data: conversation } = useConversation(route.params)
87
- const { messages, fetchNextPage, hasNextPage } = useConversationMessages({
89
+ const { messages, fetchNextPage, hasNextPage, queryKey } = useConversationMessages({
88
90
  conversation_id,
89
91
  reply_root_id,
90
92
  })
93
+ const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
94
+ const readingHistory = showJumpToBottomButton
95
+ const skippedNewMessages = useRef(false)
96
+ const messageCaches = [
97
+ gatedMessageCache({
98
+ queryKey,
99
+ acceptNewMessages: !readingHistory,
100
+ onSkippedNewMessage: () => {
101
+ skippedNewMessages.current = true
102
+ },
103
+ }),
104
+ ]
105
+ const newestMessageId = messages[0]?.id
91
106
  useConversationJoltEvents({ conversationId: conversation_id })
92
- useConversationMessagesJoltEvents({ conversationId: conversation_id })
107
+ useConversationMessagesJoltEvents({ conversationId: conversation_id, messageCaches })
93
108
  const { markHandledByMessageForm } = useVideoModerationJoltEvents()
94
109
  useEnsureConversationsRouteExists()
95
110
  useMarkLatestMessageRead({ conversation, messages })
111
+ const fetchNewer = useFetchNewerMessages({ conversation_id, reply_root_id, queryKey })
96
112
  const noMessages = messages.length === 0
97
113
 
98
114
  const { repliesDisabled, memberAbility, badges, title } = conversation
@@ -107,7 +123,6 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
107
123
  const muted = conversation.conversationMembership?.muted ?? conversation.muted
108
124
 
109
125
  const listRef = useRef<FlatList>(null)
110
- const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
111
126
 
112
127
  const trackScroll = (event: any) => {
113
128
  const offsetY = event.nativeEvent.contentOffset.y
@@ -126,6 +141,18 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
126
141
  })
127
142
  }, [restOffset])
128
143
 
144
+ const jumpToLatest = useCallback(() => {
145
+ setShowJumpToBottomButton(false)
146
+ handleReturnToBottom()
147
+ }, [handleReturnToBottom])
148
+
149
+ useEffect(() => {
150
+ if (readingHistory || !newestMessageId || !skippedNewMessages.current) return
151
+
152
+ skippedNewMessages.current = false
153
+ fetchNewer(newestMessageId)
154
+ }, [fetchNewer, newestMessageId, readingHistory])
155
+
129
156
  useEffect(() => {
130
157
  if (reply_root_id) {
131
158
  navigation.setParams({
@@ -181,9 +208,10 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
181
208
  onContentSizeChange={onContentSizeChange}
182
209
  onScrollEndDrag={onScrollEndDrag}
183
210
  onMomentumScrollEnd={onMomentumScrollEnd}
211
+ maintainVisibleContentPosition={readingHistory ? { minIndexForVisible: 0 } : undefined}
184
212
  />
185
213
  )}
186
- <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
214
+ <JumpToBottomButton onPress={jumpToLatest} visible={showJumpToBottomButton} />
187
215
  {!noMessages && <TypingIndicator />}
188
216
  {showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
189
217
  <ConversationBottomBar
@@ -193,6 +221,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
193
221
  replyRootId={reply_root_id}
194
222
  currentlyEditingMessage={currentlyEditingMessage}
195
223
  onFlaggedInMessageForm={markHandledByMessageForm}
224
+ onBeforeSubmit={jumpToLatest}
196
225
  />
197
226
  </KeyboardView>
198
227
  </View>
@@ -206,6 +235,7 @@ interface ConversationBottomBarProps {
206
235
  replyRootId: string | null | undefined
207
236
  currentlyEditingMessage: MessageResource | undefined
208
237
  onFlaggedInMessageForm?: (id: string) => void
238
+ onBeforeSubmit?: () => void
209
239
  }
210
240
 
211
241
  function ConversationBottomBar({
@@ -215,6 +245,7 @@ function ConversationBottomBar({
215
245
  replyRootId,
216
246
  currentlyEditingMessage,
217
247
  onFlaggedInMessageForm,
248
+ onBeforeSubmit,
218
249
  }: ConversationBottomBarProps) {
219
250
  const messageLink = useMessageAvailableMembersAction(conversation)
220
251
 
@@ -233,6 +264,7 @@ function ConversationBottomBar({
233
264
  replyRootId={replyRootId}
234
265
  currentlyEditingMessage={currentlyEditingMessage}
235
266
  onFlaggedInMessageForm={onFlaggedInMessageForm}
267
+ onBeforeSubmit={onBeforeSubmit}
236
268
  key={
237
269
  currentlyEditingMessage
238
270
  ? `edit-message-form-${currentlyEditingMessage.id}`
@@ -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
+ })
@@ -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(