@planningcenter/chat-react-native 4.2.1-qa-staging.2 → 4.2.1-qa-staging.3

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 (87) hide show
  1. package/build/components/conversation/message_form.d.ts.map +1 -1
  2. package/build/components/conversation/message_form.js +1 -1
  3. package/build/components/conversation/message_form.js.map +1 -1
  4. package/build/contexts/conversation_context.d.ts +3 -1
  5. package/build/contexts/conversation_context.d.ts.map +1 -1
  6. package/build/contexts/conversation_context.js +23 -18
  7. package/build/contexts/conversation_context.js.map +1 -1
  8. package/build/hooks/use_conversation_message_lookup.d.ts +7 -0
  9. package/build/hooks/use_conversation_message_lookup.d.ts.map +1 -0
  10. package/build/hooks/use_conversation_message_lookup.js +52 -0
  11. package/build/hooks/use_conversation_message_lookup.js.map +1 -0
  12. package/build/hooks/use_conversation_messages.d.ts +1 -0
  13. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  14. package/build/hooks/use_conversation_messages.js +8 -1
  15. package/build/hooks/use_conversation_messages.js.map +1 -1
  16. package/build/hooks/use_conversation_messages_jolt_events.d.ts +12 -4
  17. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  18. package/build/hooks/use_conversation_messages_jolt_events.js +82 -88
  19. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  20. package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -1
  21. package/build/hooks/use_fetch_newer_messages.js +27 -21
  22. package/build/hooks/use_fetch_newer_messages.js.map +1 -1
  23. package/build/hooks/use_fetch_newer_on_foreground.d.ts +1 -1
  24. package/build/hooks/use_fetch_newer_on_foreground.d.ts.map +1 -1
  25. package/build/hooks/use_fetch_newer_on_foreground.js +2 -1
  26. package/build/hooks/use_fetch_newer_on_foreground.js.map +1 -1
  27. package/build/hooks/use_held_back_messages.d.ts +11 -0
  28. package/build/hooks/use_held_back_messages.d.ts.map +1 -0
  29. package/build/hooks/use_held_back_messages.js +24 -0
  30. package/build/hooks/use_held_back_messages.js.map +1 -0
  31. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  32. package/build/hooks/use_message_create_or_update.js +91 -80
  33. package/build/hooks/use_message_create_or_update.js.map +1 -1
  34. package/build/hooks/use_message_reaction_toggle.d.ts.map +1 -1
  35. package/build/hooks/use_message_reaction_toggle.js +45 -40
  36. package/build/hooks/use_message_reaction_toggle.js.map +1 -1
  37. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  38. package/build/screens/conversation_screen.d.ts.map +1 -1
  39. package/build/screens/conversation_screen.js +485 -156
  40. package/build/screens/conversation_screen.js.map +1 -1
  41. package/build/screens/message_actions_screen.d.ts.map +1 -1
  42. package/build/screens/message_actions_screen.js +21 -26
  43. package/build/screens/message_actions_screen.js.map +1 -1
  44. package/build/screens/message_report_screen.d.ts.map +1 -1
  45. package/build/screens/message_report_screen.js +17 -44
  46. package/build/screens/message_report_screen.js.map +1 -1
  47. package/build/screens/reactions_screen.d.ts.map +1 -1
  48. package/build/screens/reactions_screen.js +70 -88
  49. package/build/screens/reactions_screen.js.map +1 -1
  50. package/build/utils/cache/find_message_in_caches.d.ts +4 -0
  51. package/build/utils/cache/find_message_in_caches.d.ts.map +1 -0
  52. package/build/utils/cache/find_message_in_caches.js +16 -0
  53. package/build/utils/cache/find_message_in_caches.js.map +1 -0
  54. package/build/utils/cache/optimistically_update_message.d.ts +2 -1
  55. package/build/utils/cache/optimistically_update_message.d.ts.map +1 -1
  56. package/build/utils/cache/optimistically_update_message.js +4 -4
  57. package/build/utils/cache/optimistically_update_message.js.map +1 -1
  58. package/build/utils/cache/prepend_newer_page.d.ts +1 -1
  59. package/build/utils/cache/prepend_newer_page.d.ts.map +1 -1
  60. package/build/utils/cache/prepend_newer_page.js +10 -9
  61. package/build/utils/cache/prepend_newer_page.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/__tests__/hooks/use_conversation_messages.test.ts +22 -0
  64. package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +22 -25
  65. package/src/components/conversation/message_form.tsx +1 -2
  66. package/src/contexts/conversation_context.tsx +6 -1
  67. package/src/hooks/__tests__/use_fetch_newer_messages.test.tsx +68 -0
  68. package/src/hooks/__tests__/use_held_back_messages.test.tsx +60 -0
  69. package/src/hooks/__tests__/use_message_reaction_toggle.test.tsx +73 -0
  70. package/src/hooks/use_conversation_message_lookup.ts +29 -0
  71. package/src/hooks/use_conversation_messages.ts +9 -0
  72. package/src/hooks/use_conversation_messages_jolt_events.ts +43 -41
  73. package/src/hooks/use_fetch_newer_messages.ts +19 -12
  74. package/src/hooks/use_fetch_newer_on_foreground.ts +2 -2
  75. package/src/hooks/use_held_back_messages.ts +33 -0
  76. package/src/hooks/use_message_create_or_update.ts +8 -8
  77. package/src/hooks/use_message_reaction_toggle.ts +3 -1
  78. package/src/screens/conversation_new/components/services_form.tsx +1 -1
  79. package/src/screens/conversation_screen.tsx +20 -20
  80. package/src/screens/message_actions_screen.tsx +3 -5
  81. package/src/screens/message_report_screen.tsx +2 -3
  82. package/src/screens/reactions_screen.tsx +2 -4
  83. package/src/utils/cache/__tests__/find_message_in_caches.test.ts +37 -0
  84. package/src/utils/cache/__tests__/prepend_newer_page.test.ts +13 -14
  85. package/src/utils/cache/find_message_in_caches.ts +20 -0
  86. package/src/utils/cache/optimistically_update_message.ts +2 -5
  87. package/src/utils/cache/prepend_newer_page.ts +7 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "4.2.1-qa-staging.2",
3
+ "version": "4.2.1-qa-staging.3",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@fortawesome/fontawesome-svg-core": "^7.2.0",
35
35
  "@fortawesome/react-native-fontawesome": "^0.3.2",
36
- "@planningcenter/emoji-keyboard": "4.2.1-qa-staging.2",
36
+ "@planningcenter/emoji-keyboard": "4.2.1-qa-staging.3",
37
37
  "lodash-inflection": "^1.5.0"
38
38
  },
39
39
  "peerDependencies": {
@@ -84,5 +84,5 @@
84
84
  "sf-symbols-typescript": "^2.2.0",
85
85
  "typescript": "~6.0.3"
86
86
  },
87
- "gitHead": "c5e62783ef9da7bf5ecbae701318df69b9686608"
87
+ "gitHead": "5a997b1865ce5d2f022cbd294812ef27726ad8b2"
88
88
  }
@@ -0,0 +1,22 @@
1
+ import { anchoredRetry } from '../../hooks/use_conversation_messages'
2
+ import { FailedResponse } from '../../types/api_primitives'
3
+ import { ResponseError } from '../../utils/response_error'
4
+
5
+ const responseError = (status: number) =>
6
+ new ResponseError({ status, statusText: 'nope' } as unknown as FailedResponse)
7
+
8
+ describe('anchoredRetry', () => {
9
+ it('gives up straight away on a missing anchor', () => {
10
+ expect(anchoredRetry(0, responseError(404))).toBe(false)
11
+ })
12
+
13
+ it('does not hammer a rate limit', () => {
14
+ expect(anchoredRetry(0, responseError(429))).toBe(false)
15
+ })
16
+
17
+ it('retries other failures a few times', () => {
18
+ expect(anchoredRetry(0, responseError(500))).toBe(true)
19
+ expect(anchoredRetry(2, new Error('network'))).toBe(true)
20
+ expect(anchoredRetry(3, responseError(500))).toBe(false)
21
+ })
22
+ })
@@ -1,36 +1,33 @@
1
- import {
2
- queryKeysForEvent,
3
- resolveMessageCaches,
4
- } from '../../hooks/use_conversation_messages_jolt_events'
1
+ import { queryKeysForEvent } from '../../hooks/use_conversation_messages_jolt_events'
5
2
 
6
- const main = ['main']
7
- const threaded = ['threaded']
3
+ const cache = { queryKey: ['shown'] }
4
+ const gated = { queryKey: ['shown'], canUpdate: (e: string) => e !== 'message.created' }
5
+ const topLevel = { sort_key: '01M', reply_root_id: null }
6
+ const replyInThread = { sort_key: '01R', reply_root_id: '01ROOT' }
7
+ const replyElsewhere = { sort_key: '01S', reply_root_id: '01OTHER' }
8
+ const theRoot = { sort_key: '01ROOT', reply_root_id: '01ROOT' }
8
9
 
9
- describe('resolveMessageCaches', () => {
10
- it('lets an override replace the default entry for the same key', () => {
11
- const gate = { queryKey: ['main'], canUpdate: () => false }
12
-
13
- expect(resolveMessageCaches([main, threaded], [gate])).toEqual([gate, { queryKey: threaded }])
14
- })
15
-
16
- it('appends caches that are not among the defaults', () => {
17
- const extra = { queryKey: ['anchored'] }
10
+ describe('queryKeysForEvent', () => {
11
+ it('writes every message event to the conversation view', () => {
12
+ const view = { replyRootId: null, cache }
18
13
 
19
- expect(resolveMessageCaches([main], [extra])).toEqual([{ queryKey: main }, extra])
14
+ expect(queryKeysForEvent('message.created', view, topLevel)).toEqual([['shown']])
15
+ expect(queryKeysForEvent('message.created', view, replyInThread)).toEqual([['shown']])
20
16
  })
21
- })
22
17
 
23
- describe('queryKeysForEvent', () => {
24
- const noInserts = { queryKey: ['anchored'], canUpdate: (e: string) => e !== 'message.created' }
18
+ it('writes only this thread and its root to a reply view', () => {
19
+ const view = { replyRootId: '01ROOT', cache }
25
20
 
26
- it('returns every cache that accepts the event', () => {
27
- expect(queryKeysForEvent('message.updated', [main], [noInserts])).toEqual([main, ['anchored']])
21
+ expect(queryKeysForEvent('message.created', view, replyInThread)).toEqual([['shown']])
22
+ expect(queryKeysForEvent('message.updated', view, theRoot)).toEqual([['shown']])
23
+ expect(queryKeysForEvent('message.created', view, topLevel)).toEqual([])
24
+ expect(queryKeysForEvent('message.created', view, replyElsewhere)).toEqual([])
28
25
  })
29
26
 
30
- it('skips caches whose predicate rejects the event, including a gated default', () => {
31
- const gatedMain = { queryKey: ['main'], canUpdate: (e: string) => e !== 'message.created' }
27
+ it('honors the gate on the shown cache', () => {
28
+ const view = { replyRootId: null, cache: gated }
32
29
 
33
- expect(queryKeysForEvent('message.created', [main], [noInserts])).toEqual([main])
34
- expect(queryKeysForEvent('message.created', [main, threaded], [gatedMain])).toEqual([threaded])
30
+ expect(queryKeysForEvent('message.created', view, topLevel)).toEqual([])
31
+ expect(queryKeysForEvent('reaction', view, topLevel)).toEqual([['shown']])
35
32
  })
36
33
  })
@@ -229,8 +229,6 @@ function MessageFormRoot({
229
229
  const handleSubmit = () => {
230
230
  if (!canSubmit) return
231
231
 
232
- onBeforeSubmit?.()
233
-
234
232
  if (canGiphy && usingGiphy) {
235
233
  TextInput.State.blurTextInput(TextInput.State.currentlyFocusedInput())
236
234
  Haptic.impactLight()
@@ -252,6 +250,7 @@ function MessageFormRoot({
252
250
  mutateAsync({ text: messageText })
253
251
  reset()
254
252
  } else {
253
+ onBeforeSubmit?.()
255
254
  mutateAsync({ text: messageText, attachments: attachmentsForSubmit })
256
255
  reset()
257
256
  }
@@ -3,29 +3,34 @@ import React, { createContext, PropsWithChildren, useContext, useMemo } from 're
3
3
  interface ConversationContextValue {
4
4
  conversationId: number
5
5
  currentPageReplyRootId: string | null
6
+ messagesQueryKey: unknown[] | null
6
7
  }
7
8
 
8
9
  interface ConversationContextProviderProps extends PropsWithChildren {
9
10
  conversationId: number
10
11
  currentPageReplyRootId: string | null
12
+ messagesQueryKey?: unknown[] | null
11
13
  }
12
14
 
13
15
  const ConversationContext = createContext<ConversationContextValue>({
14
16
  conversationId: 0,
15
17
  currentPageReplyRootId: null,
18
+ messagesQueryKey: null,
16
19
  })
17
20
 
18
21
  export const ConversationContextProvider = ({
19
22
  children,
20
23
  conversationId,
21
24
  currentPageReplyRootId,
25
+ messagesQueryKey = null,
22
26
  }: ConversationContextProviderProps) => {
23
27
  const value = useMemo(
24
28
  () => ({
25
29
  conversationId,
26
30
  currentPageReplyRootId,
31
+ messagesQueryKey,
27
32
  }),
28
- [conversationId, currentPageReplyRootId]
33
+ [conversationId, currentPageReplyRootId, messagesQueryKey]
29
34
  )
30
35
 
31
36
  return <ConversationContext.Provider value={value}>{children}</ConversationContext.Provider>
@@ -0,0 +1,68 @@
1
+ import { InfiniteData, QueryClientProvider } from '@tanstack/react-query'
2
+ import { act, renderHook } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import type { ApiCollection, MessageResource } from '../../types'
6
+ import { getMessagesQueryKey } from '../../utils/request/get_messages'
7
+ import { ApiClient, useApiClient } from '../use_api_client'
8
+ import { useFetchNewerMessages } from '../use_fetch_newer_messages'
9
+
10
+ jest.mock('../use_api_client')
11
+
12
+ type QueryData = InfiniteData<ApiCollection<MessageResource>>
13
+
14
+ const page = (ids: string[], next?: { idGt: string }) => ({
15
+ data: ids.map(id => ({ id, type: 'Message' }) as unknown as MessageResource),
16
+ links: {},
17
+ meta: { count: ids.length, totalCount: ids.length, next },
18
+ })
19
+
20
+ const queryKey = getMessagesQueryKey({ conversation_id: 1 })
21
+ let queryClient = buildTestQueryClient()
22
+ const get = jest.fn()
23
+
24
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
25
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
26
+ )
27
+
28
+ const cachedIds = () =>
29
+ queryClient.getQueryData<QueryData>(queryKey)?.pages.flatMap(p => p.data.map(m => m.id))
30
+
31
+ beforeEach(() => {
32
+ queryClient = buildTestQueryClient()
33
+ queryClient.setQueryData<QueryData>(queryKey, {
34
+ pages: [page(['01C', '01B']), page(['01A'])],
35
+ pageParams: [{}, { where: { id_lt: '01B' } }],
36
+ })
37
+ get.mockReset()
38
+ ;(useApiClient as jest.MockedFunction<typeof useApiClient>).mockReturnValue({
39
+ chat: { get },
40
+ } as unknown as ApiClient)
41
+ })
42
+
43
+ describe('useFetchNewerMessages', () => {
44
+ it('follows meta.next until everything newer is in the cache', async () => {
45
+ get
46
+ .mockResolvedValueOnce(page(['01D', '01E'], { idGt: '01E' }))
47
+ .mockResolvedValueOnce(page(['01F']))
48
+ const { result } = renderHook(() => useFetchNewerMessages({ conversation_id: 1, queryKey }), {
49
+ wrapper,
50
+ })
51
+
52
+ await act(() => result.current('01C'))
53
+
54
+ expect(get).toHaveBeenCalledTimes(2)
55
+ expect(get.mock.calls[1][0].data.where).toEqual({ id_gt: '01E' })
56
+ expect(cachedIds()).toEqual(['01D', '01E', '01F', '01C', '01B', '01A'])
57
+ })
58
+
59
+ it('rejects when the request fails so the caller can try again later', async () => {
60
+ get.mockRejectedValueOnce(new Error('offline'))
61
+ const { result } = renderHook(() => useFetchNewerMessages({ conversation_id: 1, queryKey }), {
62
+ wrapper,
63
+ })
64
+
65
+ await expect(act(() => result.current('01C'))).rejects.toThrow('offline')
66
+ expect(cachedIds()).toEqual(['01C', '01B', '01A'])
67
+ })
68
+ })
@@ -0,0 +1,60 @@
1
+ import { act, renderHook } from '@testing-library/react-native'
2
+ import { useHeldBackMessages } from '../use_held_back_messages'
3
+
4
+ type Props = { newestMessageId?: string; readingHistory: boolean; caughtUp: boolean }
5
+
6
+ const setup = (fetchNewer: jest.Mock, initial: Props) =>
7
+ renderHook((props: Props) => useHeldBackMessages({ fetchNewer, ...props }), {
8
+ initialProps: initial,
9
+ })
10
+
11
+ describe('useHeldBackMessages', () => {
12
+ it('catches up from where the first skip happened, not from the newest message', async () => {
13
+ const fetchNewer = jest.fn().mockResolvedValue(undefined)
14
+ const { result, rerender } = setup(fetchNewer, {
15
+ newestMessageId: 'A',
16
+ readingHistory: true,
17
+ caughtUp: true,
18
+ })
19
+
20
+ act(() => result.current.onSkippedNewMessage())
21
+ rerender({ newestMessageId: 'B', readingHistory: true, caughtUp: true })
22
+ await act(async () => rerender({ newestMessageId: 'B', readingHistory: false, caughtUp: true }))
23
+
24
+ expect(fetchNewer).toHaveBeenCalledTimes(1)
25
+ expect(fetchNewer).toHaveBeenCalledWith('A')
26
+ })
27
+
28
+ it('keeps the cursor when the catch-up fails and tries again next time', async () => {
29
+ const fetchNewer = jest
30
+ .fn()
31
+ .mockRejectedValueOnce(new Error('offline'))
32
+ .mockResolvedValue(undefined)
33
+ const { result, rerender } = setup(fetchNewer, {
34
+ newestMessageId: 'A',
35
+ readingHistory: true,
36
+ caughtUp: true,
37
+ })
38
+
39
+ act(() => result.current.onSkippedNewMessage())
40
+ await act(async () => rerender({ newestMessageId: 'A', readingHistory: false, caughtUp: true }))
41
+ rerender({ newestMessageId: 'C', readingHistory: true, caughtUp: true })
42
+ await act(async () => rerender({ newestMessageId: 'C', readingHistory: false, caughtUp: true }))
43
+
44
+ expect(fetchNewer).toHaveBeenCalledTimes(2)
45
+ expect(fetchNewer).toHaveBeenLastCalledWith('A')
46
+ })
47
+
48
+ it('does nothing when nothing was held back', async () => {
49
+ const fetchNewer = jest.fn()
50
+ const { rerender } = setup(fetchNewer, {
51
+ newestMessageId: 'A',
52
+ readingHistory: true,
53
+ caughtUp: true,
54
+ })
55
+
56
+ await act(async () => rerender({ newestMessageId: 'A', readingHistory: false, caughtUp: true }))
57
+
58
+ expect(fetchNewer).not.toHaveBeenCalled()
59
+ })
60
+ })
@@ -0,0 +1,73 @@
1
+ import { InfiniteData, QueryClientProvider } from '@tanstack/react-query'
2
+ import { act, renderHook } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import { ConversationContextProvider } from '../../contexts/conversation_context'
6
+ import type { ApiCollection, MessageResource } from '../../types'
7
+ import { getMessagesQueryKey } from '../../utils/request/get_messages'
8
+ import { ApiClient, useApiClient } from '../use_api_client'
9
+ import { useMessageReactionToggle } from '../use_message_reaction_toggle'
10
+
11
+ jest.mock('../use_api_client')
12
+
13
+ type QueryData = InfiniteData<ApiCollection<MessageResource>>
14
+
15
+ const message = { id: '01M', type: 'Message', reactionCounts: [] } as unknown as MessageResource
16
+ const liveKey = getMessagesQueryKey({ conversation_id: 1 })
17
+ const anchoredKey = getMessagesQueryKey({ conversation_id: 1, anchor: '01A' })
18
+ const seed = (): QueryData => ({
19
+ pages: [{ data: [message], links: {}, meta: { count: 1, totalCount: 1 } }],
20
+ pageParams: [{}],
21
+ })
22
+
23
+ const heartsIn = (queryClient: ReturnType<typeof buildTestQueryClient>, key: unknown[]) =>
24
+ queryClient
25
+ .getQueryData<QueryData>(key)
26
+ ?.pages[0].data[0].reactionCounts.map(r => `${r.value}:${r.count}`)
27
+
28
+ describe('useMessageReactionToggle', () => {
29
+ beforeEach(() => {
30
+ ;(useApiClient as jest.MockedFunction<typeof useApiClient>).mockReturnValue({
31
+ chat: { post: jest.fn(() => new Promise(() => {})) },
32
+ } as unknown as ApiClient)
33
+ })
34
+
35
+ it('applies the optimistic reaction to the cache the screen is showing', async () => {
36
+ const queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
37
+ queryClient.setQueryData<QueryData>(anchoredKey, seed())
38
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
39
+ <QueryClientProvider client={queryClient}>
40
+ <ConversationContextProvider
41
+ conversationId={1}
42
+ currentPageReplyRootId={null}
43
+ messagesQueryKey={anchoredKey}
44
+ >
45
+ {children}
46
+ </ConversationContextProvider>
47
+ </QueryClientProvider>
48
+ )
49
+ const { result } = renderHook(() => useMessageReactionToggle({ conversation_id: 1, message }), {
50
+ wrapper,
51
+ })
52
+
53
+ await act(async () => result.current.handleReactionToggle({ value: 'heart', mine: false }))
54
+
55
+ expect(heartsIn(queryClient, anchoredKey)).toEqual(['heart:1'])
56
+ expect(queryClient.getQueryData(liveKey)).toBeUndefined()
57
+ })
58
+
59
+ it('falls back to the live cache when no screen key is provided', async () => {
60
+ const queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
61
+ queryClient.setQueryData<QueryData>(liveKey, seed())
62
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
63
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
64
+ )
65
+ const { result } = renderHook(() => useMessageReactionToggle({ conversation_id: 1, message }), {
66
+ wrapper,
67
+ })
68
+
69
+ await act(async () => result.current.handleReactionToggle({ value: 'heart', mine: false }))
70
+
71
+ expect(heartsIn(queryClient, liveKey)).toEqual(['heart:1'])
72
+ })
73
+ })
@@ -0,0 +1,29 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import { useCallback, useSyncExternalStore } from 'react'
3
+ import { findMessageInCaches } from '../utils/cache/find_message_in_caches'
4
+ import { useConversationMessage } from './use_conversation_message'
5
+
6
+ type Options = {
7
+ conversation_id: number
8
+ message_id: string
9
+ }
10
+
11
+ export const useConversationMessageLookup = ({ conversation_id, message_id }: Options) => {
12
+ const queryClient = useQueryClient()
13
+ const subscribe = useCallback(
14
+ (onChange: () => void) => queryClient.getQueryCache().subscribe(onChange),
15
+ [queryClient]
16
+ )
17
+ const getSnapshot = useCallback(
18
+ () => findMessageInCaches(queryClient, conversation_id, message_id),
19
+ [conversation_id, message_id, queryClient]
20
+ )
21
+ const cached = useSyncExternalStore(subscribe, getSnapshot)
22
+ const fetched = useConversationMessage({
23
+ conversation_id,
24
+ messageId: message_id,
25
+ enabled: !cached,
26
+ })
27
+
28
+ return cached ?? fetched.message
29
+ }
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
2
2
  import { MessageResource } from '../types'
3
3
  import { replaceEqualById } from '../utils/replace_equal_by_id'
4
4
  import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
5
+ import { ResponseError } from '../utils/response_error'
5
6
  import { getNewerPageParam } from './paginator_meta'
6
7
  import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
7
8
 
@@ -11,6 +12,13 @@ type Options = {
11
12
  anchor?: string | null
12
13
  }
13
14
 
15
+ export const anchoredRetry = (failureCount: number, error: unknown) => {
16
+ const status = error instanceof ResponseError ? error.status : undefined
17
+ if (status === 404 || status === 429) return false
18
+
19
+ return failureCount < 3
20
+ }
21
+
14
22
  export const useConversationMessages = (
15
23
  { conversation_id, reply_root_id, anchor }: Options,
16
24
  opts?: SuspensePaginatorOptions
@@ -33,6 +41,7 @@ export const useConversationMessages = (
33
41
  getPreviousPageParam: (page, _pages, pageParam) => getNewerPageParam(page, pageParam),
34
42
  gcTime: 0,
35
43
  staleTime: Infinity,
44
+ retry: anchoredRetry,
36
45
  }
37
46
  : {}),
38
47
  ...opts,
@@ -1,4 +1,4 @@
1
- import { hashKey, InfiniteData, useQueryClient } from '@tanstack/react-query'
1
+ import { InfiniteData, useQueryClient } from '@tanstack/react-query'
2
2
  import { ApiCollection, MessageResource } from '../types'
3
3
  import { JoltReactionEvent, JoltTypingEvent } from '../types/jolt_events'
4
4
  import {
@@ -12,16 +12,14 @@ import {
12
12
  updateCacheWithIndividualMessage,
13
13
  updateCacheWithReaction,
14
14
  updateCacheReplyRoots,
15
- getThreadedMessagesQueryKey,
16
15
  } from '../utils/cache/messages_cache'
17
16
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
18
17
  import { completeMessageCreationTracking } from '../utils/performance_tracking'
19
18
  import { getMessageQueryKey } from '../utils/request/get_message'
20
- import { getMessagesRequestArgs } from '../utils/request/get_messages'
19
+ import { getMessagesQueryKey } from '../utils/request/get_messages'
21
20
  import { useApiClient } from './use_api_client'
22
21
  import { useCurrentPerson } from './use_current_person'
23
22
  import { useJoltChannel, useJoltEvent } from './use_jolt'
24
- import { getRequestQueryKey } from './use_suspense_api'
25
23
  import { TYPING_TIMEOUT_INTERVAL, useTypingStatusCache } from './use_typing_status_cache'
26
24
 
27
25
  export type MessagesJoltEvent =
@@ -35,38 +33,48 @@ export interface MessagesCache {
35
33
  canUpdate?: (event: MessagesJoltEvent) => boolean
36
34
  }
37
35
 
36
+ export interface MessagesView {
37
+ replyRootId: string | null
38
+ cache: MessagesCache
39
+ }
40
+
41
+ type MessageEventData = { sort_key: string; reply_root_id?: string | null }
42
+
38
43
  interface Props {
39
44
  conversationId: number
40
- messageCaches?: MessagesCache[]
45
+ replyRootId?: string | null
46
+ messageCache?: MessagesCache
41
47
  }
42
48
 
43
- export const resolveMessageCaches = (defaults: unknown[][], overrides: MessagesCache[]) => {
44
- const overrideByHash = new Map(overrides.map(cache => [hashKey(cache.queryKey), cache]))
45
- const defaultHashes = new Set(defaults.map(hashKey))
46
- const resolved = defaults.map(queryKey => overrideByHash.get(hashKey(queryKey)) ?? { queryKey })
47
- const extras = overrides.filter(cache => !defaultHashes.has(hashKey(cache.queryKey)))
48
- return [...resolved, ...extras]
49
- }
49
+ const belongsToView = (replyRootId: string | null, data: MessageEventData) =>
50
+ replyRootId === null || data.reply_root_id === replyRootId || data.sort_key === replyRootId
50
51
 
51
52
  export const queryKeysForEvent = (
52
53
  event: MessagesJoltEvent,
53
- defaults: unknown[][],
54
- overrides: MessagesCache[]
55
- ) =>
56
- resolveMessageCaches(defaults, overrides)
57
- .filter(cache => !cache.canUpdate || cache.canUpdate(event))
58
- .map(cache => cache.queryKey)
59
-
60
- export function useConversationMessagesJoltEvents({ conversationId, messageCaches = [] }: Props) {
54
+ view: MessagesView,
55
+ data: MessageEventData
56
+ ) => {
57
+ const accepted = !view.cache.canUpdate || view.cache.canUpdate(event)
58
+ return belongsToView(view.replyRootId, data) && accepted ? [view.cache.queryKey] : []
59
+ }
60
+
61
+ export function useConversationMessagesJoltEvents({
62
+ conversationId,
63
+ replyRootId = null,
64
+ messageCache,
65
+ }: Props) {
61
66
  const queryClient = useQueryClient()
62
67
  const currentPerson = useCurrentPerson()
63
68
  const joltChannel = useJoltChannel(`chat.conversations.${conversationId}`)
64
- const messagesRequestArgs = getMessagesRequestArgs({ conversation_id: conversationId })
65
- const messagesQueryKey = getRequestQueryKey(messagesRequestArgs)
66
- const cachesTouchedBy = (replyRootId?: string | null): unknown[][] =>
67
- replyRootId
68
- ? [messagesQueryKey, getThreadedMessagesQueryKey(conversationId, replyRootId)]
69
- : [messagesQueryKey]
69
+ const view: MessagesView = {
70
+ replyRootId,
71
+ cache: messageCache ?? {
72
+ queryKey: getMessagesQueryKey({
73
+ conversation_id: conversationId,
74
+ reply_root_id: replyRootId,
75
+ }),
76
+ },
77
+ }
70
78
  const apiClient = useApiClient()
71
79
 
72
80
  const { addTypingEvent, removeTypingEventById, removeAllTypingEventsByAuthorId } =
@@ -87,11 +95,7 @@ export function useConversationMessagesJoltEvents({ conversationId, messageCache
87
95
  }
88
96
  }
89
97
 
90
- const targetQueryKeys = queryKeysForEvent(
91
- e.event,
92
- cachesTouchedBy(data.reply_root_id),
93
- messageCaches
94
- )
98
+ const targetQueryKeys = queryKeysForEvent(e.event, view, data)
95
99
  targetQueryKeys.forEach(queryKey =>
96
100
  updateCacheWithMessage(queryClient, queryKey, message, e.event)
97
101
  )
@@ -116,11 +120,7 @@ export function useConversationMessagesJoltEvents({ conversationId, messageCache
116
120
  currentPersonId: currentPerson.id,
117
121
  })
118
122
 
119
- queryKeysForEvent(
120
- 'message.destroyed',
121
- cachesTouchedBy(data.reply_root_id),
122
- messageCaches
123
- ).forEach(queryKey =>
123
+ queryKeysForEvent('message.destroyed', view, data).forEach(queryKey =>
124
124
  queryClient.setQueryData<QueryData>(queryKey, prev =>
125
125
  deleteRecordInPagesData({ data: prev, record: message })
126
126
  )
@@ -128,11 +128,13 @@ export function useConversationMessagesJoltEvents({ conversationId, messageCache
128
128
  }
129
129
 
130
130
  const handleReactionJoltEvent = async (e: JoltReactionEvent) => {
131
- queryKeysForEvent(
132
- 'reaction',
133
- cachesTouchedBy(e.data.data.reply_root_id),
134
- messageCaches
135
- ).forEach(queryKey => updateCacheWithReaction(queryClient, queryKey, e, currentPerson.id))
131
+ const data = {
132
+ sort_key: e.data.data.message_sort_key,
133
+ reply_root_id: e.data.data.reply_root_id,
134
+ }
135
+ queryKeysForEvent('reaction', view, data).forEach(queryKey =>
136
+ updateCacheWithReaction(queryClient, queryKey, e, currentPerson.id)
137
+ )
136
138
  }
137
139
 
138
140
  const handleTypingEvent = async (e: JoltTypingEvent) => {
@@ -2,10 +2,11 @@ import { useQueryClient } from '@tanstack/react-query'
2
2
  import { useCallback } from 'react'
3
3
  import { ApiCollection, MessageResource } from '../types'
4
4
  import { prependNewerPage } from '../utils/cache/prepend_newer_page'
5
- import { RequestData } from '../utils/client'
6
5
  import { getMessagesRequestArgs } from '../utils/request/get_messages'
7
6
  import { useApiClient } from './use_api_client'
8
7
 
8
+ const MAX_PAGES = 5
9
+
9
10
  type Options = {
10
11
  conversation_id: number
11
12
  reply_root_id?: string | null
@@ -17,20 +18,26 @@ export const useFetchNewerMessages = ({ conversation_id, reply_root_id, queryKey
17
18
  const apiClient = useApiClient()
18
19
 
19
20
  return useCallback(
20
- (newestMessageId: string) => {
21
+ async (newestMessageId: string) => {
21
22
  const { url, data } = getMessagesRequestArgs({ conversation_id, reply_root_id })
22
- const where = { id_gt: newestMessageId }
23
- const pageParam: Partial<RequestData> = { where, order: 'asc' }
23
+ const pages: ApiCollection<MessageResource>[] = []
24
+ let cursor: string | undefined = newestMessageId
24
25
 
25
- return apiClient.chat
26
- .get<ApiCollection<MessageResource>>({ url, data: { ...data, where, order: 'asc' } })
27
- .then(page => {
28
- queryClient.setQueryData<Parameters<typeof prependNewerPage<MessageResource>>[0]>(
29
- queryKey,
30
- prev => prependNewerPage(prev, page, pageParam)
31
- )
26
+ while (cursor && pages.length < MAX_PAGES) {
27
+ const page: ApiCollection<MessageResource> = await apiClient.chat.get({
28
+ url,
29
+ data: { ...data, where: { id_gt: cursor }, order: 'asc' },
32
30
  })
33
- .catch(() => {})
31
+ pages.push(page)
32
+ cursor = page.meta?.next?.idGt
33
+ }
34
+
35
+ queryClient.setQueryData<Parameters<typeof prependNewerPage<MessageResource>>[0]>(
36
+ queryKey,
37
+ prev => prependNewerPage(prev, pages)
38
+ )
39
+ const moreThanWeFetched = !!cursor
40
+ if (moreThanWeFetched) await queryClient.invalidateQueries({ queryKey })
34
41
  },
35
42
  [apiClient, conversation_id, queryClient, queryKey, reply_root_id]
36
43
  )
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import { useAppState } from './use_app_state'
3
3
 
4
4
  type Options = {
5
- fetchNewer: (newestMessageId: string) => void
5
+ fetchNewer: (newestMessageId: string) => Promise<void>
6
6
  newestMessageId?: string
7
7
  enabled: boolean
8
8
  }
@@ -20,6 +20,6 @@ export const useFetchNewerOnForeground = ({ fetchNewer, newestMessageId, enabled
20
20
  wasInBackground.current = false
21
21
  if (!returnedToForeground || !enabled || !newestMessageId) return
22
22
 
23
- fetchNewer(newestMessageId)
23
+ fetchNewer(newestMessageId).catch(() => {})
24
24
  }, [appState, enabled, fetchNewer, newestMessageId])
25
25
  }
@@ -0,0 +1,33 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+
3
+ type Options = {
4
+ fetchNewer: (newestMessageId: string) => Promise<void>
5
+ newestMessageId?: string
6
+ readingHistory: boolean
7
+ caughtUp: boolean
8
+ }
9
+
10
+ export const useHeldBackMessages = ({
11
+ fetchNewer,
12
+ newestMessageId,
13
+ readingHistory,
14
+ caughtUp,
15
+ }: Options) => {
16
+ const heldBackSince = useRef<string | null>(null)
17
+
18
+ const onSkippedNewMessage = useCallback(() => {
19
+ heldBackSince.current ??= newestMessageId ?? null
20
+ }, [newestMessageId])
21
+
22
+ useEffect(() => {
23
+ const since = heldBackSince.current
24
+ if (readingHistory || !caughtUp || !since) return
25
+
26
+ heldBackSince.current = null
27
+ fetchNewer(since).catch(() => {
28
+ heldBackSince.current ??= since
29
+ })
30
+ }, [caughtUp, fetchNewer, readingHistory])
31
+
32
+ return { onSkippedNewMessage }
33
+ }