@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.
- package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/giphy_attachment.js +2 -2
- package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +5 -3
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/message.d.ts +3 -1
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +183 -158
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +3 -3
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +143 -88
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/components/conversation/system_message.js +3 -3
- package/build/components/conversation/system_message.js.map +1 -1
- package/build/components/conversations/conversations.d.ts.map +1 -1
- package/build/components/conversations/conversations.js +0 -6
- package/build/components/conversations/conversations.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +28 -25
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_animated_message_background_color.d.ts +1 -0
- package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
- package/build/hooks/use_animated_message_background_color.js +26 -9
- package/build/hooks/use_animated_message_background_color.js.map +1 -1
- package/build/hooks/use_chat_permissions.d.ts +10 -2
- package/build/hooks/use_chat_permissions.d.ts.map +1 -1
- package/build/hooks/use_chat_permissions.js +8 -0
- package/build/hooks/use_chat_permissions.js.map +1 -1
- package/build/hooks/use_debounced_search_term.d.ts +2 -0
- package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
- package/build/hooks/use_debounced_search_term.js +40 -0
- package/build/hooks/use_debounced_search_term.js.map +1 -0
- package/build/hooks/use_features.d.ts +2 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +3 -1
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_jolt.d.ts.map +1 -1
- package/build/hooks/use_jolt.js +2 -1
- package/build/hooks/use_jolt.js.map +1 -1
- package/build/hooks/use_product_analytics.d.ts +9 -0
- package/build/hooks/use_product_analytics.d.ts.map +1 -1
- package/build/hooks/use_product_analytics.js +4 -2
- package/build/hooks/use_product_analytics.js.map +1 -1
- package/build/hooks/use_search_enabled.d.ts +2 -0
- package/build/hooks/use_search_enabled.d.ts.map +1 -0
- package/build/hooks/use_search_enabled.js +18 -0
- package/build/hooks/use_search_enabled.js.map +1 -0
- package/build/hooks/use_search_results.d.ts +19 -0
- package/build/hooks/use_search_results.d.ts.map +1 -0
- package/build/hooks/use_search_results.js +153 -0
- package/build/hooks/use_search_results.js.map +1 -0
- package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
- package/build/screens/age_check/age_check_underage_screen.js +2 -1
- package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/bug_report_screen.d.ts.map +1 -1
- package/build/screens/bug_report_screen.js +2 -2
- package/build/screens/bug_report_screen.js.map +1 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
- package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
- package/build/screens/conversations/components/search_list_header_component.js +3 -3
- package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +2 -5
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/screens/get_help_screen.js +2 -2
- package/build/screens/get_help_screen.js.map +1 -1
- package/build/screens/message_actions_screen.js +6 -2
- package/build/screens/message_actions_screen.js.map +1 -1
- package/build/screens/search/components/conversation_result_row.d.ts +8 -0
- package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
- package/build/screens/search/components/conversation_result_row.js +80 -0
- package/build/screens/search/components/conversation_result_row.js.map +1 -0
- package/build/screens/search/components/conversation_results.d.ts +8 -0
- package/build/screens/search/components/conversation_results.d.ts.map +1 -0
- package/build/screens/search/components/conversation_results.js +77 -0
- package/build/screens/search/components/conversation_results.js.map +1 -0
- package/build/screens/search/components/message_result_row.d.ts +8 -0
- package/build/screens/search/components/message_result_row.d.ts.map +1 -0
- package/build/screens/search/components/message_result_row.js +152 -0
- package/build/screens/search/components/message_result_row.js.map +1 -0
- package/build/screens/search/components/result_row.d.ts +10 -0
- package/build/screens/search/components/result_row.d.ts.map +1 -0
- package/build/screens/search/components/result_row.js +102 -0
- package/build/screens/search/components/result_row.js.map +1 -0
- package/build/screens/search/components/results_section.d.ts +9 -0
- package/build/screens/search/components/results_section.d.ts.map +1 -0
- package/build/screens/search/components/results_section.js +51 -0
- package/build/screens/search/components/results_section.js.map +1 -0
- package/build/screens/search/components/search_results_list.d.ts +13 -0
- package/build/screens/search/components/search_results_list.d.ts.map +1 -0
- package/build/screens/search/components/search_results_list.js +344 -0
- package/build/screens/search/components/search_results_list.js.map +1 -0
- package/build/screens/search/components/snippet.d.ts +8 -0
- package/build/screens/search/components/snippet.d.ts.map +1 -0
- package/build/screens/search/components/snippet.js +68 -0
- package/build/screens/search/components/snippet.js.map +1 -0
- package/build/screens/search/search_screen.d.ts.map +1 -1
- package/build/screens/search/search_screen.js +92 -20
- package/build/screens/search/search_screen.js.map +1 -1
- package/build/utils/client/request_helpers.js +1 -1
- package/build/utils/client/request_helpers.js.map +1 -1
- package/build/utils/native_adapters/linking.d.ts +11 -3
- package/build/utils/native_adapters/linking.d.ts.map +1 -1
- package/build/utils/native_adapters/linking.js.map +1 -1
- package/build/utils/response_error.d.ts +1 -1
- package/build/utils/response_error.d.ts.map +1 -1
- package/build/utils/response_error.js +1 -1
- package/build/utils/response_error.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
- package/src/__tests__/contexts/api_provider.test.tsx +50 -0
- package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
- package/src/__tests__/screens/get_help_screen.test.ts +36 -0
- package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
- package/src/__tests__/utils/client.ts +3 -4
- package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
- package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
- package/src/components/conversation/attachments/image_attachment.tsx +2 -3
- package/src/components/conversation/message.tsx +12 -1
- package/src/components/conversation/message_form.tsx +2 -2
- package/src/components/conversation/message_list.tsx +26 -1
- package/src/components/conversation/system_message.tsx +3 -3
- package/src/components/conversations/conversations.tsx +0 -7
- package/src/contexts/__tests__/api_provider.test.tsx +106 -0
- package/src/contexts/api_provider.tsx +6 -9
- package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
- package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
- package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
- package/src/hooks/use_animated_message_background_color.ts +37 -12
- package/src/hooks/use_chat_permissions.ts +15 -2
- package/src/hooks/use_debounced_search_term.ts +20 -0
- package/src/hooks/use_features.ts +2 -0
- package/src/hooks/use_jolt.ts +10 -7
- package/src/hooks/use_product_analytics.ts +3 -1
- package/src/hooks/use_search_enabled.ts +9 -0
- package/src/hooks/use_search_results.ts +78 -0
- package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
- package/src/screens/bug_report_screen.tsx +2 -10
- package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
- package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
- package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
- package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
- package/src/screens/conversations/conversations_screen.tsx +2 -3
- package/src/screens/get_help_screen.tsx +2 -2
- package/src/screens/message_actions_screen.tsx +6 -2
- package/src/screens/search/__tests__/search_screen.test.tsx +194 -0
- package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
- package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
- package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
- package/src/screens/search/components/__tests__/search_results_list.test.tsx +180 -0
- package/src/screens/search/components/conversation_result_row.tsx +36 -0
- package/src/screens/search/components/conversation_results.tsx +43 -0
- package/src/screens/search/components/message_result_row.tsx +48 -0
- package/src/screens/search/components/result_row.tsx +58 -0
- package/src/screens/search/components/results_section.tsx +34 -0
- package/src/screens/search/components/search_results_list.tsx +168 -0
- package/src/screens/search/components/snippet.tsx +37 -0
- package/src/screens/search/search_screen.tsx +50 -4
- package/src/utils/__tests__/response_error.test.ts +7 -2
- package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
- package/src/utils/client/request_helpers.ts +1 -1
- package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
- package/src/utils/native_adapters/linking.ts +12 -3
- 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 {
|
|
12
|
+
import { appGrantsQueryOptions } from '../hooks/use_chat_permissions'
|
|
13
13
|
import { featuresQueryOptions } from '../hooks/use_features'
|
|
14
|
-
import {
|
|
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
|
|
21
|
+
const pressProgress = useSharedValue(0)
|
|
22
|
+
const flashProgress = useSharedValue(0)
|
|
14
23
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
default: 'transparent',
|
|
18
|
-
})
|
|
24
|
+
const tintColor = colors.fillColorNeutral050Base
|
|
25
|
+
const tintsOnPress = Platform.OS === 'ios'
|
|
19
26
|
|
|
20
27
|
const animatedBackgroundColor = useAnimatedStyle(() => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
37
|
+
pressProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const handleMessagePressOut = () => {
|
|
36
|
-
|
|
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 {
|
|
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> = {
|
package/src/hooks/use_jolt.ts
CHANGED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
return apiClient
|
|
45
|
+
.post<ApiResource<JoltResponse>>({
|
|
46
|
+
url: '/me/jolt_authorize',
|
|
46
47
|
data: {
|
|
47
|
-
|
|
48
|
-
|
|
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,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 {
|
|
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,
|
|
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
|
|