@planningcenter/chat-react-native 4.3.0-rc.9 → 4.3.1-qa-989.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 (178) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/giphy_attachment.js +2 -2
  3. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  5. package/build/components/conversation/attachments/image_attachment.js +5 -3
  6. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  7. package/build/components/conversation/message.d.ts +3 -1
  8. package/build/components/conversation/message.d.ts.map +1 -1
  9. package/build/components/conversation/message.js +183 -158
  10. package/build/components/conversation/message.js.map +1 -1
  11. package/build/components/conversation/message_form.d.ts.map +1 -1
  12. package/build/components/conversation/message_form.js +3 -3
  13. package/build/components/conversation/message_form.js.map +1 -1
  14. package/build/components/conversation/message_list.d.ts.map +1 -1
  15. package/build/components/conversation/message_list.js +143 -88
  16. package/build/components/conversation/message_list.js.map +1 -1
  17. package/build/components/conversation/system_message.js +3 -3
  18. package/build/components/conversation/system_message.js.map +1 -1
  19. package/build/components/conversations/conversations.d.ts.map +1 -1
  20. package/build/components/conversations/conversations.js +0 -6
  21. package/build/components/conversations/conversations.js.map +1 -1
  22. package/build/contexts/api_provider.d.ts.map +1 -1
  23. package/build/contexts/api_provider.js +28 -25
  24. package/build/contexts/api_provider.js.map +1 -1
  25. package/build/hooks/use_animated_message_background_color.d.ts +1 -0
  26. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  27. package/build/hooks/use_animated_message_background_color.js +26 -9
  28. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  29. package/build/hooks/use_chat_permissions.d.ts +10 -2
  30. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  31. package/build/hooks/use_chat_permissions.js +8 -0
  32. package/build/hooks/use_chat_permissions.js.map +1 -1
  33. package/build/hooks/use_debounced_search_term.d.ts +2 -0
  34. package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
  35. package/build/hooks/use_debounced_search_term.js +40 -0
  36. package/build/hooks/use_debounced_search_term.js.map +1 -0
  37. package/build/hooks/use_features.d.ts +2 -0
  38. package/build/hooks/use_features.d.ts.map +1 -1
  39. package/build/hooks/use_features.js +3 -1
  40. package/build/hooks/use_features.js.map +1 -1
  41. package/build/hooks/use_jolt.d.ts.map +1 -1
  42. package/build/hooks/use_jolt.js +2 -1
  43. package/build/hooks/use_jolt.js.map +1 -1
  44. package/build/hooks/use_product_analytics.d.ts +9 -0
  45. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  46. package/build/hooks/use_product_analytics.js +4 -2
  47. package/build/hooks/use_product_analytics.js.map +1 -1
  48. package/build/hooks/use_search_enabled.d.ts +2 -0
  49. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  50. package/build/hooks/use_search_enabled.js +18 -0
  51. package/build/hooks/use_search_enabled.js.map +1 -0
  52. package/build/hooks/use_search_results.d.ts +19 -0
  53. package/build/hooks/use_search_results.d.ts.map +1 -0
  54. package/build/hooks/use_search_results.js +153 -0
  55. package/build/hooks/use_search_results.js.map +1 -0
  56. package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
  57. package/build/screens/age_check/age_check_underage_screen.js +2 -1
  58. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  59. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  60. package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
  61. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  62. package/build/screens/bug_report_screen.d.ts.map +1 -1
  63. package/build/screens/bug_report_screen.js +2 -2
  64. package/build/screens/bug_report_screen.js.map +1 -1
  65. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
  66. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
  67. package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
  68. package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
  69. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  70. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
  71. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  72. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  73. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  74. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  75. package/build/screens/conversations/conversations_screen.js +2 -5
  76. package/build/screens/conversations/conversations_screen.js.map +1 -1
  77. package/build/screens/get_help_screen.js +2 -2
  78. package/build/screens/get_help_screen.js.map +1 -1
  79. package/build/screens/message_actions_screen.js +6 -2
  80. package/build/screens/message_actions_screen.js.map +1 -1
  81. package/build/screens/search/components/conversation_result_row.d.ts +8 -0
  82. package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
  83. package/build/screens/search/components/conversation_result_row.js +80 -0
  84. package/build/screens/search/components/conversation_result_row.js.map +1 -0
  85. package/build/screens/search/components/conversation_results.d.ts +8 -0
  86. package/build/screens/search/components/conversation_results.d.ts.map +1 -0
  87. package/build/screens/search/components/conversation_results.js +77 -0
  88. package/build/screens/search/components/conversation_results.js.map +1 -0
  89. package/build/screens/search/components/message_result_row.d.ts +8 -0
  90. package/build/screens/search/components/message_result_row.d.ts.map +1 -0
  91. package/build/screens/search/components/message_result_row.js +152 -0
  92. package/build/screens/search/components/message_result_row.js.map +1 -0
  93. package/build/screens/search/components/result_row.d.ts +10 -0
  94. package/build/screens/search/components/result_row.d.ts.map +1 -0
  95. package/build/screens/search/components/result_row.js +102 -0
  96. package/build/screens/search/components/result_row.js.map +1 -0
  97. package/build/screens/search/components/results_section.d.ts +9 -0
  98. package/build/screens/search/components/results_section.d.ts.map +1 -0
  99. package/build/screens/search/components/results_section.js +51 -0
  100. package/build/screens/search/components/results_section.js.map +1 -0
  101. package/build/screens/search/components/search_results_list.d.ts +13 -0
  102. package/build/screens/search/components/search_results_list.d.ts.map +1 -0
  103. package/build/screens/search/components/search_results_list.js +344 -0
  104. package/build/screens/search/components/search_results_list.js.map +1 -0
  105. package/build/screens/search/components/snippet.d.ts +8 -0
  106. package/build/screens/search/components/snippet.d.ts.map +1 -0
  107. package/build/screens/search/components/snippet.js +68 -0
  108. package/build/screens/search/components/snippet.js.map +1 -0
  109. package/build/screens/search/search_screen.d.ts.map +1 -1
  110. package/build/screens/search/search_screen.js +92 -20
  111. package/build/screens/search/search_screen.js.map +1 -1
  112. package/build/utils/client/request_helpers.js +1 -1
  113. package/build/utils/client/request_helpers.js.map +1 -1
  114. package/build/utils/native_adapters/linking.d.ts +11 -3
  115. package/build/utils/native_adapters/linking.d.ts.map +1 -1
  116. package/build/utils/native_adapters/linking.js.map +1 -1
  117. package/build/utils/response_error.d.ts +1 -1
  118. package/build/utils/response_error.d.ts.map +1 -1
  119. package/build/utils/response_error.js +1 -1
  120. package/build/utils/response_error.js.map +1 -1
  121. package/package.json +3 -3
  122. package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
  123. package/src/__tests__/contexts/api_provider.test.tsx +50 -0
  124. package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
  125. package/src/__tests__/screens/get_help_screen.test.ts +36 -0
  126. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  127. package/src/__tests__/utils/client.ts +3 -4
  128. package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
  129. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  130. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
  131. package/src/components/conversation/attachments/image_attachment.tsx +2 -3
  132. package/src/components/conversation/message.tsx +12 -1
  133. package/src/components/conversation/message_form.tsx +2 -2
  134. package/src/components/conversation/message_list.tsx +26 -1
  135. package/src/components/conversation/system_message.tsx +3 -3
  136. package/src/components/conversations/conversations.tsx +0 -7
  137. package/src/contexts/__tests__/api_provider.test.tsx +106 -0
  138. package/src/contexts/api_provider.tsx +6 -9
  139. package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
  140. package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
  141. package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
  142. package/src/hooks/use_animated_message_background_color.ts +37 -12
  143. package/src/hooks/use_chat_permissions.ts +15 -2
  144. package/src/hooks/use_debounced_search_term.ts +20 -0
  145. package/src/hooks/use_features.ts +2 -0
  146. package/src/hooks/use_jolt.ts +10 -7
  147. package/src/hooks/use_product_analytics.ts +3 -1
  148. package/src/hooks/use_search_enabled.ts +9 -0
  149. package/src/hooks/use_search_results.ts +78 -0
  150. package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
  151. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
  152. package/src/screens/bug_report_screen.tsx +2 -10
  153. package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
  154. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
  155. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  156. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  157. package/src/screens/conversations/conversations_screen.tsx +2 -3
  158. package/src/screens/get_help_screen.tsx +2 -2
  159. package/src/screens/message_actions_screen.tsx +6 -2
  160. package/src/screens/search/__tests__/search_screen.test.tsx +194 -0
  161. package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
  162. package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
  163. package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
  164. package/src/screens/search/components/__tests__/search_results_list.test.tsx +180 -0
  165. package/src/screens/search/components/conversation_result_row.tsx +36 -0
  166. package/src/screens/search/components/conversation_results.tsx +43 -0
  167. package/src/screens/search/components/message_result_row.tsx +48 -0
  168. package/src/screens/search/components/result_row.tsx +58 -0
  169. package/src/screens/search/components/results_section.tsx +34 -0
  170. package/src/screens/search/components/search_results_list.tsx +168 -0
  171. package/src/screens/search/components/snippet.tsx +37 -0
  172. package/src/screens/search/search_screen.tsx +50 -4
  173. package/src/utils/__tests__/response_error.test.ts +7 -2
  174. package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
  175. package/src/utils/client/request_helpers.ts +1 -1
  176. package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
  177. package/src/utils/native_adapters/linking.ts +12 -3
  178. package/src/utils/response_error.ts +2 -2
@@ -0,0 +1,106 @@
1
+ import { render, screen, waitFor } from '@testing-library/react-native'
2
+ import React, { useContext, useMemo } from 'react'
3
+ import { Text } from 'react-native'
4
+ import { useApiGet } from '../../hooks/use_api'
5
+ import { ApiClient, useApiClient } from '../../hooks/use_api_client'
6
+ import { Session } from '../../utils'
7
+ import { ApiProvider, chatQueryClient } from '../api_provider'
8
+ import { ChatContext, ChatContextValue } from '../chat_context'
9
+
10
+ jest.mock('../../hooks/use_api_client')
11
+
12
+ const mockedUseApiClient = useApiClient as jest.MockedFunction<typeof useApiClient>
13
+
14
+ type Deferred = { promise: Promise<unknown>; reject: (_reason: unknown) => void }
15
+
16
+ const defer = (): Deferred => {
17
+ let reject: Deferred['reject'] = () => {}
18
+ const promise = new Promise((_, rej) => {
19
+ reject = rej
20
+ })
21
+ promise.catch(() => {})
22
+ return { promise, reject }
23
+ }
24
+
25
+ let get: jest.Mock
26
+ let staleRequest: Deferred
27
+
28
+ const meRequests = () =>
29
+ get.mock.calls.filter(([url]) => url === '/me').map(([, token]) => token as string)
30
+
31
+ const Probe = () => {
32
+ const { status, fetchStatus, data } = useApiGet<{ type: 'Person'; id: number }>({
33
+ url: '/me',
34
+ data: { fields: { Person: ['id'] } },
35
+ })
36
+
37
+ return <Text>{`${status}:${fetchStatus}:${data?.id ?? ''}`}</Text>
38
+ }
39
+
40
+ const Tree = ({ accessToken }: { accessToken: string }) => {
41
+ const value = useMemo(() => {
42
+ const session = new Session({ token: { access_token: accessToken } })
43
+ return {
44
+ session,
45
+ token: session.token,
46
+ env: 'production',
47
+ onUnauthorizedResponse: () => {},
48
+ } as unknown as ChatContextValue
49
+ }, [accessToken])
50
+
51
+ return (
52
+ <ChatContext.Provider value={value}>
53
+ <ApiProvider>
54
+ <Probe />
55
+ </ApiProvider>
56
+ </ChatContext.Provider>
57
+ )
58
+ }
59
+
60
+ beforeAll(() => {
61
+ chatQueryClient.setDefaultOptions({
62
+ queries: { ...chatQueryClient.getDefaultOptions().queries, gcTime: 0 },
63
+ })
64
+ })
65
+
66
+ beforeEach(() => {
67
+ staleRequest = defer()
68
+ get = jest.fn((url: string, accessToken: string) => {
69
+ if (url !== '/me') return Promise.resolve({ data: [] })
70
+ if (accessToken === 'stale') return staleRequest.promise
71
+
72
+ return Promise.resolve({ data: { type: 'Person', id: 42 } })
73
+ })
74
+
75
+ mockedUseApiClient.mockImplementation(() => {
76
+ const { session } = useContext(ChatContext)
77
+ const accessToken = session.token?.access_token
78
+ const client = { get: ({ url }: { url: string }) => get(url, accessToken) }
79
+
80
+ return {
81
+ chat: client,
82
+ groups: client,
83
+ services: client,
84
+ people: client,
85
+ } as unknown as ApiClient
86
+ })
87
+ })
88
+
89
+ afterEach(() => {
90
+ chatQueryClient.clear()
91
+ jest.clearAllMocks()
92
+ })
93
+
94
+ describe('ApiProvider on access token change', () => {
95
+ it('restarts in-flight requests with the new token and ignores the stale result', async () => {
96
+ const { rerender } = render(<Tree accessToken="stale" />)
97
+ await waitFor(() => expect(meRequests()).toEqual(['stale']))
98
+
99
+ rerender(<Tree accessToken="fresh" />)
100
+ await waitFor(() => expect(meRequests()).toEqual(['stale', 'fresh']))
101
+
102
+ staleRequest.reject(new Response(null, { status: 401, statusText: 'Unauthorized' }))
103
+
104
+ expect(await screen.findByText('success:idle:42')).toBeTruthy()
105
+ })
106
+ })
@@ -9,9 +9,9 @@ import React, { useContext, useEffect, useRef } from 'react'
9
9
  import { ViewProps } from 'react-native'
10
10
  import { ApiClient, useApiClient } from '../hooks/use_api_client'
11
11
  import { useAppState } from '../hooks/use_app_state'
12
- import { appGrantsRequestArgs } from '../hooks/use_chat_permissions'
12
+ import { appGrantsQueryOptions } from '../hooks/use_chat_permissions'
13
13
  import { featuresQueryOptions } from '../hooks/use_features'
14
- import { getRequestQueryKey, RequestQueryKey } from '../hooks/use_suspense_api'
14
+ import { RequestQueryKey } from '../hooks/use_suspense_api'
15
15
  import { emitAuthRefresh } from '../utils/auth_events'
16
16
  import { ResponseError } from '../utils/response_error'
17
17
  import { ChatContext, ChatContextValue } from './chat_context'
@@ -48,7 +48,7 @@ export const chatQueryClient = new QueryClient({
48
48
  })
49
49
 
50
50
  export function ApiProvider({ children }: ViewProps) {
51
- const { token, env } = useContext(ChatContext)
51
+ const { token, env, session } = useContext(ChatContext)
52
52
  const sessionChanged = useSessionChanged({ token, env })
53
53
 
54
54
  const client = useApiClient()
@@ -59,7 +59,7 @@ export function ApiProvider({ children }: ViewProps) {
59
59
  emitAuthRefresh()
60
60
 
61
61
  if (chatQueryClient.isFetching()) {
62
- chatQueryClient.invalidateQueries()
62
+ chatQueryClient.cancelQueries().then(() => chatQueryClient.invalidateQueries())
63
63
  return
64
64
  }
65
65
 
@@ -72,7 +72,7 @@ export function ApiProvider({ children }: ViewProps) {
72
72
 
73
73
  return (
74
74
  <QueryClientProvider client={chatQueryClient}>
75
- <PrefetchQueries />
75
+ {session.isAuthenticated && <PrefetchQueries />}
76
76
  {children}
77
77
  </QueryClientProvider>
78
78
  )
@@ -84,10 +84,7 @@ export function ApiProvider({ children }: ViewProps) {
84
84
  const PrefetchQueries = () => {
85
85
  const client = useApiClient()
86
86
 
87
- usePrefetchQuery({
88
- queryKey: getRequestQueryKey(appGrantsRequestArgs),
89
- queryFn: defaultQueryFn,
90
- })
87
+ usePrefetchQuery(appGrantsQueryOptions(client))
91
88
  usePrefetchQuery(featuresQueryOptions(client))
92
89
 
93
90
  return null
@@ -0,0 +1,38 @@
1
+ import { act, renderHook } from '@testing-library/react-native'
2
+ import { useDebouncedSearchTerm } from '../use_debounced_search_term'
3
+
4
+ beforeEach(() => jest.useFakeTimers())
5
+ afterEach(() => jest.useRealTimers())
6
+
7
+ describe('useDebouncedSearchTerm', () => {
8
+ it('settles on the trimmed term once typing pauses', () => {
9
+ const { result, rerender } = renderHook(
10
+ ({ term }: { term: string }) => useDebouncedSearchTerm(term),
11
+ {
12
+ initialProps: { term: 'bud' },
13
+ }
14
+ )
15
+
16
+ rerender({ term: ' budget ' })
17
+ act(() => jest.advanceTimersByTime(199))
18
+ expect(result.current).toBe('')
19
+
20
+ act(() => jest.advanceTimersByTime(1))
21
+ expect(result.current).toBe('budget')
22
+ })
23
+
24
+ it('clears immediately when the term empties', () => {
25
+ const { result, rerender } = renderHook(
26
+ ({ term }: { term: string }) => useDebouncedSearchTerm(term),
27
+ {
28
+ initialProps: { term: 'budget' },
29
+ }
30
+ )
31
+ act(() => jest.advanceTimersByTime(200))
32
+ expect(result.current).toBe('budget')
33
+
34
+ rerender({ term: ' ' })
35
+
36
+ expect(result.current).toBe('')
37
+ })
38
+ })
@@ -0,0 +1,70 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { render, waitFor } from '@testing-library/react-native'
3
+ import React, { PropsWithChildren, Suspense } from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import { ChatContext, ChatContextValue } from '../../contexts/chat_context'
6
+ import { Session } from '../../utils'
7
+ import { Client } from '../../utils/client'
8
+ import { ResponseError } from '../../utils/response_error'
9
+ import { useJoltClient } from '../use_jolt'
10
+
11
+ let caught: unknown
12
+
13
+ class Catch extends React.Component<PropsWithChildren, { failed: boolean }> {
14
+ state = { failed: false }
15
+
16
+ static getDerivedStateFromError(error: unknown) {
17
+ caught = error
18
+ return { failed: true }
19
+ }
20
+
21
+ render() {
22
+ return this.state.failed ? null : this.props.children
23
+ }
24
+ }
25
+
26
+ const Probe = () => {
27
+ useJoltClient()
28
+ return null
29
+ }
30
+
31
+ const renderJoltClient = () => {
32
+ const session = new Session({ token: { access_token: 'a-real-token' } })
33
+
34
+ return render(
35
+ <QueryClientProvider client={buildTestQueryClient()}>
36
+ <ChatContext.Provider value={{ session } as unknown as ChatContextValue}>
37
+ <Catch>
38
+ <Suspense fallback={null}>
39
+ <Probe />
40
+ </Suspense>
41
+ </Catch>
42
+ </ChatContext.Provider>
43
+ </QueryClientProvider>
44
+ )
45
+ }
46
+
47
+ describe('useJoltClient', () => {
48
+ let post: jest.SpyInstance
49
+ let consoleError: jest.SpyInstance
50
+
51
+ beforeEach(() => {
52
+ caught = undefined
53
+ post = jest.spyOn(Client.prototype, 'post')
54
+ consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
55
+ })
56
+
57
+ afterEach(() => {
58
+ post.mockRestore()
59
+ consoleError.mockRestore()
60
+ })
61
+
62
+ it('surfaces a failed jolt_authorize as a ResponseError', async () => {
63
+ post.mockRejectedValue(new Response(null, { status: 401, statusText: 'Unauthorized' }))
64
+
65
+ renderJoltClient()
66
+
67
+ await waitFor(() => expect(caught).toBeInstanceOf(ResponseError))
68
+ expect((caught as ResponseError).status).toBe(401)
69
+ })
70
+ })
@@ -0,0 +1,88 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { act, renderHook, waitFor } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import type { SearchResultResource } from '../../types/resources/search_result'
6
+ import { NO_FAILED_TYPES, useSearchResults } from '../use_search_results'
7
+
8
+ const mockGet = jest.fn()
9
+ jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: { get: mockGet } }) }))
10
+
11
+ const result = (type: SearchResultResource['type'], id: string) =>
12
+ ({ type, id, title: `${type} ${id}`, url: `/${type}s/${id}` }) as SearchResultResource
13
+
14
+ const page = (data: SearchResultResource[], meta: Record<string, unknown> = {}) => ({
15
+ data,
16
+ links: {},
17
+ meta: { count: data.length, totalCount: data.length, ...meta },
18
+ })
19
+
20
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
21
+ <QueryClientProvider client={buildTestQueryClient()}>{children}</QueryClientProvider>
22
+ )
23
+
24
+ beforeEach(() => mockGet.mockReset())
25
+
26
+ describe('useSearchResults', () => {
27
+ it('flattens pages into one list without repeating a result', async () => {
28
+ mockGet
29
+ .mockResolvedValueOnce(
30
+ page([result('conversation', '1'), result('message', '1')], { next: { offset: '20' } })
31
+ )
32
+ .mockResolvedValueOnce(page([result('message', '1'), result('message', '2')]))
33
+
34
+ const { result: hook } = renderHook(() => useSearchResults('potluck'), { wrapper })
35
+ await waitFor(() => expect(hook.current.hasNextPage).toBe(true))
36
+
37
+ act(() => {
38
+ hook.current.loadMore()
39
+ })
40
+ await waitFor(() => expect(hook.current.hasNextPage).toBe(false))
41
+
42
+ expect(hook.current.results.map(({ type, id }) => `${type}:${id}`)).toEqual([
43
+ 'conversation:1',
44
+ 'message:1',
45
+ 'message:2',
46
+ ])
47
+ expect(mockGet).toHaveBeenLastCalledWith({
48
+ url: '/search_results',
49
+ data: expect.objectContaining({ q: 'potluck', offset: 20 }),
50
+ })
51
+ })
52
+
53
+ it('reads failedTypes from the first page', async () => {
54
+ mockGet
55
+ .mockResolvedValueOnce(page([], { failedTypes: ['message'], next: { offset: '20' } }))
56
+ .mockResolvedValueOnce(page([], { failedTypes: ['conversation'] }))
57
+
58
+ const { result: hook } = renderHook(() => useSearchResults('potluck'), { wrapper })
59
+ await waitFor(() => expect(hook.current.hasNextPage).toBe(true))
60
+
61
+ act(() => {
62
+ hook.current.loadMore()
63
+ })
64
+ await waitFor(() => expect(hook.current.hasNextPage).toBe(false))
65
+
66
+ expect(hook.current.failedTypes).toEqual(['message'])
67
+ })
68
+
69
+ it('makes no request for an empty or whitespace-only term', () => {
70
+ const { result: hook } = renderHook(() => useSearchResults(' '), { wrapper })
71
+
72
+ expect(mockGet).not.toHaveBeenCalled()
73
+ expect(hook.current.results).toEqual([])
74
+ expect(hook.current.failedTypes).toBe(NO_FAILED_TYPES)
75
+ })
76
+
77
+ it('searches with the trimmed term', async () => {
78
+ mockGet.mockResolvedValueOnce(page([]))
79
+
80
+ const { result: hook } = renderHook(() => useSearchResults(' potluck '), { wrapper })
81
+ await waitFor(() => expect(hook.current.isPending).toBe(false))
82
+
83
+ expect(mockGet).toHaveBeenCalledWith({
84
+ url: '/search_results',
85
+ data: expect.objectContaining({ q: 'potluck' }),
86
+ })
87
+ })
88
+ })
@@ -1,43 +1,68 @@
1
+ import { useCallback } from 'react'
1
2
  import { Platform } from 'react-native'
2
3
  import {
3
4
  useSharedValue,
4
5
  useAnimatedStyle,
6
+ withDelay,
7
+ withSequence,
5
8
  withTiming,
6
9
  interpolateColor,
7
10
  Easing,
11
+ ReduceMotion,
8
12
  } from 'react-native-reanimated'
9
13
  import { useTheme } from './use_theme'
10
14
 
15
+ const FLASH_IN_MS = 150
16
+ const FLASH_HOLD_MS = 500
17
+ const FLASH_OUT_MS = 1500
18
+
11
19
  export function useAnimatedMessageBackgroundColor() {
12
20
  const { colors } = useTheme()
13
- const bgFadeProgress = useSharedValue(0)
21
+ const pressProgress = useSharedValue(0)
22
+ const flashProgress = useSharedValue(0)
14
23
 
15
- const pressedColor = Platform.select({
16
- ios: colors.fillColorNeutral050Base,
17
- default: 'transparent',
18
- })
24
+ const tintColor = colors.fillColorNeutral050Base
25
+ const tintsOnPress = Platform.OS === 'ios'
19
26
 
20
27
  const animatedBackgroundColor = useAnimatedStyle(() => {
21
- const backgroundColor = interpolateColor(
22
- bgFadeProgress.value,
23
- [0, 1],
24
- ['transparent', pressedColor]
25
- )
28
+ const pressTint = tintsOnPress ? pressProgress.value : 0
29
+ const progress = Math.max(pressTint, flashProgress.value)
30
+ const backgroundColor = interpolateColor(progress, [0, 1], ['transparent', tintColor])
26
31
  return {
27
32
  backgroundColor,
28
33
  }
29
34
  })
30
35
 
31
36
  const handleMessagePressIn = () => {
32
- bgFadeProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
37
+ pressProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
33
38
  }
34
39
 
35
40
  const handleMessagePressOut = () => {
36
- bgFadeProgress.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.ease) })
41
+ pressProgress.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.ease) })
37
42
  }
38
43
 
44
+ const flash = useCallback(() => {
45
+ flashProgress.value = withSequence(
46
+ withTiming(1, {
47
+ duration: FLASH_IN_MS,
48
+ easing: Easing.out(Easing.ease),
49
+ reduceMotion: ReduceMotion.Never,
50
+ }),
51
+ withDelay(
52
+ FLASH_HOLD_MS,
53
+ withTiming(0, {
54
+ duration: FLASH_OUT_MS,
55
+ easing: Easing.out(Easing.ease),
56
+ reduceMotion: ReduceMotion.System,
57
+ }),
58
+ ReduceMotion.Never
59
+ )
60
+ )
61
+ }, [flashProgress])
62
+
39
63
  return {
40
64
  animatedBackgroundColor,
65
+ flash,
41
66
  handleMessagePressIn,
42
67
  handleMessagePressOut,
43
68
  }
@@ -1,7 +1,10 @@
1
- import { AppGrantsResource } from '../types'
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { ApiCollection, AppGrantsResource } from '../types'
3
+ import { throwResponseError } from '../utils/response_error'
2
4
  import { FIVE_MINUTES } from '../utils/time'
3
5
  import { useApiGet } from './use_api'
4
- import { App } from './use_api_client'
6
+ import { ApiClient, App } from './use_api_client'
7
+ import { getRequestQueryKey } from './use_suspense_api'
5
8
 
6
9
  export const appGrantsRequestArgs = {
7
10
  url: '/me/app_grants',
@@ -13,6 +16,16 @@ export const appGrantsRequestArgs = {
13
16
  app: 'chat' as App,
14
17
  }
15
18
 
19
+ export const appGrantsQueryOptions = (apiClient: ApiClient) =>
20
+ queryOptions({
21
+ queryKey: getRequestQueryKey(appGrantsRequestArgs),
22
+ queryFn: (): Promise<ApiCollection<AppGrantsResource>> =>
23
+ apiClient[appGrantsRequestArgs.app]
24
+ .get<ApiCollection<AppGrantsResource>>(appGrantsRequestArgs)
25
+ .catch(throwResponseError),
26
+ staleTime: FIVE_MINUTES,
27
+ })
28
+
16
29
  export function useAppGrants() {
17
30
  return useApiGet<AppGrantsResource[]>(appGrantsRequestArgs, {
18
31
  staleTime: FIVE_MINUTES,
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ const DEFAULT_DELAY_MS = 200
4
+
5
+ export function useDebouncedSearchTerm(term: string, delayMs = DEFAULT_DELAY_MS) {
6
+ const [debouncedTerm, setDebouncedTerm] = useState('')
7
+ const trimmedTerm = term.trim()
8
+
9
+ useEffect(() => {
10
+ if (trimmedTerm.length === 0) {
11
+ setDebouncedTerm('')
12
+ return
13
+ }
14
+
15
+ const timer = setTimeout(() => setDebouncedTerm(trimmedTerm), delayMs)
16
+ return () => clearTimeout(timer)
17
+ }, [trimmedTerm, delayMs])
18
+
19
+ return debouncedTerm
20
+ }
@@ -57,6 +57,8 @@ export const availableFeatures = {
57
57
  dm_search_clear_fix: 'ROLLOUT_MOBILE_dm_search_clear_fix',
58
58
  search: 'QA_MOBILE_search',
59
59
  search_rollout: 'ROLLOUT_MOBILE_search',
60
+ manual_archiving: 'QA_MOBILE_manual_archiving',
61
+ manual_archiving_rollout: 'ROLLOUT_MOBILE_manual_archiving',
60
62
  } as const satisfies Record<string, `${'QA' | 'ROLLOUT'}_${string}`>
61
63
 
62
64
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -13,6 +13,7 @@ import { useChatContext } from '../contexts/chat_context'
13
13
  import { ApiResource } from '../types'
14
14
  import type { JoltSubscriptionPattern } from '../types/jolt_events'
15
15
  import { Client, Uri } from '../utils'
16
+ import { throwResponseError } from '../utils/response_error'
16
17
  import { FIVE_SECONDS } from '../utils/time'
17
18
 
18
19
  interface JoltResponse {
@@ -40,15 +41,17 @@ export const useJoltClient = (): JoltClient | undefined => {
40
41
  return queryClient.fetchQuery({
41
42
  queryKey: ['jolt-token'],
42
43
  queryFn: () => {
43
- return apiClient.post<ApiResource<JoltResponse>>({
44
- url: '/me/jolt_authorize',
45
- data: {
44
+ return apiClient
45
+ .post<ApiResource<JoltResponse>>({
46
+ url: '/me/jolt_authorize',
46
47
  data: {
47
- type: 'JoltToken',
48
- attributes: {},
48
+ data: {
49
+ type: 'JoltToken',
50
+ attributes: {},
51
+ },
49
52
  },
50
- },
51
- })
53
+ })
54
+ .catch(throwResponseError)
52
55
  },
53
56
  staleTime: FIVE_SECONDS,
54
57
  })
@@ -12,6 +12,8 @@ export interface EventMetadata {
12
12
  export const analyticsEvents = {
13
13
  conversation_index_opened: 'chat.mobile.conversations.index.opened',
14
14
  conversation_show_opened: 'chat.mobile.conversations.show.opened',
15
+ search_performed: 'chat.mobile.search.performed',
16
+ search_result_opened: 'chat.mobile.search.result.opened',
15
17
  } as const satisfies Record<string, `chat.mobile.${string}`>
16
18
 
17
19
  type AnalyticsEventName = (typeof analyticsEvents)[keyof typeof analyticsEvents]
@@ -53,7 +55,7 @@ function sendEvents(events: AnalyticsEvent[], config: ProductAnalyticsConfig): P
53
55
  })
54
56
  }
55
57
 
56
- const useProductAnalytics = () => {
58
+ export const useProductAnalytics = () => {
57
59
  const { productAnalyticsConfig } = useChatContext()
58
60
 
59
61
  const snakeCasedBaseMeta = useMemo(
@@ -0,0 +1,9 @@
1
+ import { availableFeatures, useFeatures } from './use_features'
2
+
3
+ export function useSearchEnabled() {
4
+ const { featureEnabled } = useFeatures()
5
+
6
+ return (
7
+ featureEnabled(availableFeatures.search) || featureEnabled(availableFeatures.search_rollout)
8
+ )
9
+ }
@@ -0,0 +1,78 @@
1
+ import { useInfiniteQuery } from '@tanstack/react-query'
2
+ import { useCallback, useMemo } from 'react'
3
+ import type { ApiCollection } from '../types'
4
+ import type { SearchResultResource } from '../types/resources/search_result'
5
+ import {
6
+ getSearchResultsQueryKey,
7
+ getSearchResultsRequestArgs,
8
+ } from '../utils/request/get_search_results'
9
+ import { ONE_MINUTE } from '../utils/time'
10
+ import { useApiClient } from './use_api_client'
11
+
12
+ export type SearchResultsCollection = ApiCollection<SearchResultResource> & {
13
+ meta: { failedTypes?: SearchResultResource['type'][] }
14
+ }
15
+
16
+ type SearchResultsPageParam = { offset?: number }
17
+
18
+ export const NO_FAILED_TYPES: SearchResultResource['type'][] = []
19
+
20
+ export function useSearchResults(term: string) {
21
+ const apiClient = useApiClient()
22
+ const trimmedTerm = term.trim()
23
+
24
+ const {
25
+ data,
26
+ isPending,
27
+ isError,
28
+ isFetchNextPageError,
29
+ fetchNextPage,
30
+ hasNextPage,
31
+ isFetchingNextPage,
32
+ } = useInfiniteQuery({
33
+ queryKey: getSearchResultsQueryKey(trimmedTerm),
34
+ queryFn: ({ pageParam }) => {
35
+ const requestArgs = getSearchResultsRequestArgs(trimmedTerm)
36
+ return apiClient.chat.get<SearchResultsCollection>({
37
+ url: requestArgs.url,
38
+ data: { ...requestArgs.data, ...pageParam },
39
+ })
40
+ },
41
+ initialPageParam: {} as SearchResultsPageParam,
42
+ getNextPageParam: lastPage => nextPageParam(lastPage.meta.next?.offset),
43
+ enabled: trimmedTerm.length >= 1,
44
+ staleTime: ONE_MINUTE,
45
+ refetchOnWindowFocus: false,
46
+ refetchOnMount: false,
47
+ retry: 1,
48
+ })
49
+
50
+ const results = useMemo(() => uniqueResults(data?.pages.flatMap(page => page.data) ?? []), [data])
51
+ const failedTypes = data?.pages[0]?.meta.failedTypes ?? NO_FAILED_TYPES
52
+ const loadMore = useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage])
53
+
54
+ return {
55
+ results,
56
+ failedTypes,
57
+ isPending,
58
+ isError: isError && !isFetchNextPageError,
59
+ isFetchNextPageError,
60
+ loadMore,
61
+ hasNextPage,
62
+ isFetchingNextPage,
63
+ }
64
+ }
65
+
66
+ function nextPageParam(offset?: string): SearchResultsPageParam | undefined {
67
+ return offset ? { offset: Number(offset) } : undefined
68
+ }
69
+
70
+ function uniqueResults(results: SearchResultResource[]) {
71
+ const seen = new Set<string>()
72
+ return results.filter(result => {
73
+ const key = `${result.type}:${result.id}`
74
+ if (seen.has(key)) return false
75
+ seen.add(key)
76
+ return true
77
+ })
78
+ }
@@ -1,9 +1,10 @@
1
1
  import { useRoute, RouteProp } from '@react-navigation/native'
2
2
  import React from 'react'
3
- import { Linking, StyleSheet, View, ScrollView } from 'react-native'
3
+ import { StyleSheet, View, ScrollView } from 'react-native'
4
4
  import { Heading } from '../../components'
5
5
  import { Icon, Text, TextInlineButton } from '../../components/display'
6
6
  import { useTheme } from '../../hooks'
7
+ import { Linking } from '../../utils'
7
8
  import type { AgeCheckParams } from './screen_props'
8
9
 
9
10
  export interface AgeCheckUnderageScreenProps {
@@ -1,7 +1,8 @@
1
1
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
2
  import React, { useCallback } from 'react'
3
- import { Alert, Linking, StyleSheet } from 'react-native'
3
+ import { Alert, StyleSheet } from 'react-native'
4
4
  import FormSheet, { getFormSheetScreenOptions } from '../../components/primitive/form_sheet'
5
+ import { Linking } from '../../utils'
5
6
  import { useDeleteAttachment } from './hooks/useDeleteAttachment'
6
7
 
7
8
  export const AttachmentActionsScreenOptions = getFormSheetScreenOptions({
@@ -57,7 +58,7 @@ export function AttachmentActionsScreen({ route }: AttachmentActionsScreenProps)
57
58
  }, [attachmentName, handleDeleteAttachment, navigation])
58
59
 
59
60
  const handleOpenInBrowser = () => {
60
- Linking.openURL(attachmentUrl)
61
+ Linking.openURL(attachmentUrl, { forceOpenInBrowser: true })
61
62
  navigation.goBack()
62
63
  }
63
64