@planningcenter/chat-react-native 4.3.0 → 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 +2 -2
- 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_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/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/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_results.d.ts +1 -1
- package/build/screens/search/components/conversation_results.d.ts.map +1 -1
- package/build/screens/search/components/conversation_results.js +2 -2
- package/build/screens/search/components/conversation_results.js.map +1 -1
- package/build/screens/search/components/search_results_list.d.ts +7 -2
- package/build/screens/search/components/search_results_list.d.ts.map +1 -1
- package/build/screens/search/components/search_results_list.js +135 -105
- package/build/screens/search/components/search_results_list.js.map +1 -1
- package/build/screens/search/search_screen.d.ts.map +1 -1
- package/build/screens/search/search_screen.js +88 -25
- package/build/screens/search/search_screen.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/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 +1 -1
- 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_jolt.test.tsx +70 -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_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/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/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 +130 -0
- package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
- package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
- package/src/screens/search/components/conversation_results.tsx +7 -3
- package/src/screens/search/components/search_results_list.tsx +25 -4
- package/src/screens/search/search_screen.tsx +44 -6
- package/src/utils/__tests__/response_error.test.ts +7 -2
- 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
|
@@ -3,8 +3,8 @@ import React from 'react'
|
|
|
3
3
|
import { StyleSheet, View } from 'react-native'
|
|
4
4
|
import { Conversations } from '../../components'
|
|
5
5
|
import { ConversationsContextProvider } from '../../contexts/conversations_context'
|
|
6
|
-
import { availableFeatures, useFeatures } from '../../hooks/use_features'
|
|
7
6
|
import { analyticsEvents, usePublishProductAnalyticsEvent } from '../../hooks/use_product_analytics'
|
|
7
|
+
import { useSearchEnabled } from '../../hooks/use_search_enabled'
|
|
8
8
|
import { AppName } from '../../types/resources/app_name'
|
|
9
9
|
import { GraphId } from '../../types/resources/group_resource'
|
|
10
10
|
import { ListHeaderComponent } from './components/list_header_component'
|
|
@@ -19,8 +19,7 @@ export type ConversationsScreenProps = StaticScreenProps<{
|
|
|
19
19
|
|
|
20
20
|
export function ConversationsScreen({ route }: ConversationsScreenProps) {
|
|
21
21
|
const styles = useStyles()
|
|
22
|
-
const
|
|
23
|
-
const searchEnabled = featureEnabled(availableFeatures.search)
|
|
22
|
+
const searchEnabled = useSearchEnabled()
|
|
24
23
|
const Header = searchEnabled ? SearchListHeaderComponent : ListHeaderComponent
|
|
25
24
|
|
|
26
25
|
usePublishProductAnalyticsEvent(analyticsEvents.conversation_index_opened)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
|
2
2
|
import { useCallback, useMemo } from 'react'
|
|
3
|
-
import { Alert,
|
|
3
|
+
import { Alert, StyleSheet, View } from 'react-native'
|
|
4
4
|
import { Heading, PressableRow, Text, TextInlineButton } from '../components'
|
|
5
5
|
import { useApiGet, useTheme } from '../hooks'
|
|
6
6
|
import { useAppName } from '../hooks/use_app_name'
|
|
7
7
|
import { useOrganization } from '../hooks/use_organization'
|
|
8
8
|
import { ResourceObject } from '../types'
|
|
9
|
-
import { Clipboard } from '../utils'
|
|
9
|
+
import { Clipboard, Linking } from '../utils'
|
|
10
10
|
|
|
11
11
|
type GetHelpScreenRouteProps = {
|
|
12
12
|
type?: 'chat' | 'general'
|
|
@@ -124,8 +124,12 @@ function MessageActionsScreenContent({
|
|
|
124
124
|
})
|
|
125
125
|
}, [navigation, conversation_id, message.id, message.replyRootId, replyRootAuthorName])
|
|
126
126
|
|
|
127
|
-
const handleCopyPress = () => {
|
|
128
|
-
|
|
127
|
+
const handleCopyPress = async () => {
|
|
128
|
+
const textToCopy = message?.text || attachmentForCopy()
|
|
129
|
+
if (textToCopy) {
|
|
130
|
+
await Clipboard.setStringAsync(textToCopy)
|
|
131
|
+
Haptic.notificationSuccess()
|
|
132
|
+
}
|
|
129
133
|
navigation.goBack()
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { act, fireEvent, render } from '@testing-library/react-native'
|
|
2
2
|
import React from 'react'
|
|
3
|
+
import { Keyboard } from 'react-native'
|
|
3
4
|
import type { useSearchResults } from '../../../hooks/use_search_results'
|
|
5
|
+
import type {
|
|
6
|
+
ConversationSearchResultResource,
|
|
7
|
+
MessageSearchResultResource,
|
|
8
|
+
} from '../../../types/resources/search_result'
|
|
4
9
|
import { SearchScreen } from '../search_screen'
|
|
5
10
|
|
|
11
|
+
const mockNavigate = jest.fn()
|
|
12
|
+
jest.mock('@react-navigation/native', () => ({
|
|
13
|
+
...jest.requireActual('@react-navigation/native'),
|
|
14
|
+
useNavigation: () => ({ navigate: mockNavigate }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
6
17
|
const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [string]>()
|
|
7
18
|
jest.mock('../../../hooks/use_search_results', () => ({
|
|
8
19
|
useSearchResults: (term: string) => mockUseSearchResults(term),
|
|
9
20
|
}))
|
|
21
|
+
const mockPublishEvent = jest.fn()
|
|
22
|
+
jest.mock('../../../hooks/use_product_analytics', () => ({
|
|
23
|
+
...jest.requireActual('../../../hooks/use_product_analytics'),
|
|
24
|
+
useProductAnalytics: () => ({ publishEvent: mockPublishEvent }),
|
|
25
|
+
}))
|
|
10
26
|
jest.mock('../../../components/display/keyboard_view', () => ({
|
|
11
27
|
...jest.requireActual('../../../components/display/keyboard_view'),
|
|
12
28
|
KeyboardView: ({ children }: { children: React.ReactNode }) => children,
|
|
@@ -14,8 +30,59 @@ jest.mock('../../../components/display/keyboard_view', () => ({
|
|
|
14
30
|
|
|
15
31
|
const inputLabel = 'Search titles and messages'
|
|
16
32
|
|
|
33
|
+
const conversationResult: ConversationSearchResultResource = {
|
|
34
|
+
type: 'conversation',
|
|
35
|
+
id: '42',
|
|
36
|
+
title: 'Budget Team',
|
|
37
|
+
url: '/conversations/42',
|
|
38
|
+
extra: { avatar: { previewAvatarUrls: [] } },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const messageResult: MessageSearchResultResource = {
|
|
42
|
+
type: 'message',
|
|
43
|
+
id: '01JMESSAGEULID',
|
|
44
|
+
title: 'Finance',
|
|
45
|
+
url: '/conversations/7/messages/01JMESSAGEULID',
|
|
46
|
+
extra: {
|
|
47
|
+
conversationId: 7,
|
|
48
|
+
authorName: 'Ann',
|
|
49
|
+
snippet: [{ text: 'budget', match: true }],
|
|
50
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
51
|
+
avatar: { previewAvatarUrls: [] },
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const secondMessageResult: MessageSearchResultResource = {
|
|
56
|
+
...messageResult,
|
|
57
|
+
id: '01JSECONDULID',
|
|
58
|
+
title: 'Youth',
|
|
59
|
+
url: '/conversations/9/messages/01JSECONDULID',
|
|
60
|
+
extra: { ...messageResult.extra, conversationId: 9, authorName: 'Bob' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const renderWithResults = () => {
|
|
64
|
+
mockUseSearchResults.mockReturnValue({
|
|
65
|
+
results: [conversationResult, messageResult, secondMessageResult],
|
|
66
|
+
failedTypes: [],
|
|
67
|
+
isPending: false,
|
|
68
|
+
isError: false,
|
|
69
|
+
isFetchNextPageError: false,
|
|
70
|
+
loadMore: jest.fn(),
|
|
71
|
+
hasNextPage: false,
|
|
72
|
+
isFetchingNextPage: false,
|
|
73
|
+
})
|
|
74
|
+
const view = render(<SearchScreen />)
|
|
75
|
+
const input = view.getByLabelText(inputLabel)
|
|
76
|
+
fireEvent.changeText(input, 'bud')
|
|
77
|
+
fireEvent.changeText(input, 'budget')
|
|
78
|
+
act(() => jest.advanceTimersByTime(200))
|
|
79
|
+
return view
|
|
80
|
+
}
|
|
81
|
+
|
|
17
82
|
beforeEach(() => {
|
|
18
83
|
jest.useFakeTimers()
|
|
84
|
+
mockNavigate.mockReset()
|
|
85
|
+
mockPublishEvent.mockReset()
|
|
19
86
|
mockUseSearchResults.mockReset().mockReturnValue({
|
|
20
87
|
results: [],
|
|
21
88
|
failedTypes: [],
|
|
@@ -61,4 +128,67 @@ describe('SearchScreen', () => {
|
|
|
61
128
|
|
|
62
129
|
expect(view.getByText('Search chat')).toBeTruthy()
|
|
63
130
|
})
|
|
131
|
+
|
|
132
|
+
it('opens a conversation result at the latest message', () => {
|
|
133
|
+
const dismiss = jest.spyOn(Keyboard, 'dismiss')
|
|
134
|
+
const view = renderWithResults()
|
|
135
|
+
|
|
136
|
+
fireEvent.press(view.getByLabelText('Budget Team'))
|
|
137
|
+
|
|
138
|
+
expect(dismiss).toHaveBeenCalled()
|
|
139
|
+
expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
|
|
140
|
+
conversation_id: 42,
|
|
141
|
+
title: 'Budget Team',
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('opens a message result anchored at that message', () => {
|
|
146
|
+
const view = renderWithResults()
|
|
147
|
+
|
|
148
|
+
fireEvent.press(view.getByLabelText(/^Finance, Ann: budget/))
|
|
149
|
+
|
|
150
|
+
expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
|
|
151
|
+
conversation_id: 7,
|
|
152
|
+
initial_message_id: '01JMESSAGEULID',
|
|
153
|
+
title: 'Finance',
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('publishes one search event per settled term with result counts, never the term', () => {
|
|
158
|
+
renderWithResults()
|
|
159
|
+
|
|
160
|
+
expect(mockPublishEvent).toHaveBeenCalledTimes(1)
|
|
161
|
+
expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.performed', {
|
|
162
|
+
term_length: 6,
|
|
163
|
+
conversation_result_count: 1,
|
|
164
|
+
message_result_count: 2,
|
|
165
|
+
})
|
|
166
|
+
expect(JSON.stringify(mockPublishEvent.mock.calls)).not.toContain('budget')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('does not publish a retyped term whose results are already cached', () => {
|
|
170
|
+
const view = renderWithResults()
|
|
171
|
+
const input = view.getByLabelText(inputLabel)
|
|
172
|
+
|
|
173
|
+
fireEvent.changeText(input, 'budge')
|
|
174
|
+
act(() => jest.advanceTimersByTime(200))
|
|
175
|
+
fireEvent.changeText(input, 'budget')
|
|
176
|
+
act(() => jest.advanceTimersByTime(200))
|
|
177
|
+
|
|
178
|
+
const performed = mockPublishEvent.mock.calls.filter(
|
|
179
|
+
([name]) => name === 'chat.mobile.search.performed'
|
|
180
|
+
)
|
|
181
|
+
expect(performed.map(([, meta]) => meta.term_length)).toEqual([6, 5])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('publishes the opened result type and its position within its section', () => {
|
|
185
|
+
const view = renderWithResults()
|
|
186
|
+
|
|
187
|
+
fireEvent.press(view.getByLabelText(/^Youth, Bob: budget/))
|
|
188
|
+
|
|
189
|
+
expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.result.opened', {
|
|
190
|
+
result_type: 'message',
|
|
191
|
+
position: 1,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
64
194
|
})
|
|
@@ -43,13 +43,13 @@ describe('ConversationResults', () => {
|
|
|
43
43
|
expect(view.getByText('View less')).toBeTruthy()
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
it('passes the pressed result to onSelect', () => {
|
|
46
|
+
it('passes the pressed result and its position to onSelect', () => {
|
|
47
47
|
const onSelect = jest.fn()
|
|
48
48
|
const results = buildResults(2)
|
|
49
49
|
const view = render(<ConversationResults results={results} onSelect={onSelect} />)
|
|
50
50
|
|
|
51
51
|
fireEvent.press(view.getByText('Conversation 2'))
|
|
52
52
|
|
|
53
|
-
expect(onSelect).toHaveBeenCalledWith(results[1])
|
|
53
|
+
expect(onSelect).toHaveBeenCalledWith(results[1], 1)
|
|
54
54
|
})
|
|
55
55
|
})
|
|
@@ -49,9 +49,15 @@ const messageResult = (id: string): MessageSearchResultResource => ({
|
|
|
49
49
|
},
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
const renderList = (state: Partial<SearchState
|
|
52
|
+
const renderList = (state: Partial<SearchState>, onSettled = jest.fn()) => {
|
|
53
53
|
mockUseSearchResults.mockReturnValue(searchState(state))
|
|
54
|
-
|
|
54
|
+
const list = () => <SearchResultsList term="potluck" onSelect={jest.fn()} onSettled={onSettled} />
|
|
55
|
+
const view = render(list())
|
|
56
|
+
const rerenderWith = (next: Partial<SearchState>) => {
|
|
57
|
+
mockUseSearchResults.mockReturnValue(searchState(next))
|
|
58
|
+
view.rerender(list())
|
|
59
|
+
}
|
|
60
|
+
return { ...view, rerenderWith }
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
describe('SearchResultsList', () => {
|
|
@@ -119,6 +125,45 @@ describe('SearchResultsList', () => {
|
|
|
119
125
|
expect(loadMore).not.toHaveBeenCalled()
|
|
120
126
|
})
|
|
121
127
|
|
|
128
|
+
it('passes a message result and its position within the section to onSelect', () => {
|
|
129
|
+
const onSelect = jest.fn()
|
|
130
|
+
const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
|
|
131
|
+
mockUseSearchResults.mockReturnValue(searchState({ results }))
|
|
132
|
+
const view = render(
|
|
133
|
+
<SearchResultsList term="potluck" onSelect={onSelect} onSettled={jest.fn()} />
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
fireEvent.press(view.getByLabelText(/^Message 2/))
|
|
137
|
+
|
|
138
|
+
expect(onSelect).toHaveBeenCalledWith(results[2], 1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('reports the settled term and counts once, not while pending or after paging', () => {
|
|
142
|
+
const onSettled = jest.fn()
|
|
143
|
+
const view = renderList({ isPending: true }, onSettled)
|
|
144
|
+
expect(onSettled).not.toHaveBeenCalled()
|
|
145
|
+
|
|
146
|
+
const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
|
|
147
|
+
view.rerenderWith({ results })
|
|
148
|
+
|
|
149
|
+
expect(onSettled).toHaveBeenCalledTimes(1)
|
|
150
|
+
expect(onSettled).toHaveBeenCalledWith('potluck', {
|
|
151
|
+
conversationResultCount: 1,
|
|
152
|
+
messageResultCount: 2,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
view.rerenderWith({ results: [...results, messageResult('3')] })
|
|
156
|
+
|
|
157
|
+
expect(onSettled).toHaveBeenCalledTimes(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('does not report a failed search', () => {
|
|
161
|
+
const onSettled = jest.fn()
|
|
162
|
+
renderList({ isError: true }, onSettled)
|
|
163
|
+
|
|
164
|
+
expect(onSettled).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
|
|
122
167
|
it('offers a retry after a paging failure', () => {
|
|
123
168
|
const loadMore = jest.fn()
|
|
124
169
|
const view = renderList({
|
|
@@ -8,7 +8,7 @@ const COLLAPSED_COUNT = 5
|
|
|
8
8
|
|
|
9
9
|
interface ConversationResultsProps {
|
|
10
10
|
results: ConversationSearchResultResource[]
|
|
11
|
-
onSelect: (result: ConversationSearchResultResource) => void
|
|
11
|
+
onSelect: (result: ConversationSearchResultResource, position: number) => void
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function ConversationResults({ results, onSelect }: ConversationResultsProps) {
|
|
@@ -31,8 +31,12 @@ export function ConversationResults({ results, onSelect }: ConversationResultsPr
|
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
33
|
<ResultsSection title="Conversations" footer={footer}>
|
|
34
|
-
{visible.map(result => (
|
|
35
|
-
<ConversationResultRow
|
|
34
|
+
{visible.map((result, index) => (
|
|
35
|
+
<ConversationResultRow
|
|
36
|
+
key={result.id}
|
|
37
|
+
result={result}
|
|
38
|
+
onPress={() => onSelect(result, index)}
|
|
39
|
+
/>
|
|
36
40
|
))}
|
|
37
41
|
</ResultsSection>
|
|
38
42
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
2
|
import { FlatList, StyleSheet, View } from 'react-native'
|
|
3
3
|
import { Banner, Spinner, Text, TextButton } from '../../../components'
|
|
4
4
|
import { useSearchResults } from '../../../hooks/use_search_results'
|
|
@@ -14,12 +14,18 @@ import { NoResultsBlankState } from './search_blank_states'
|
|
|
14
14
|
|
|
15
15
|
const SEARCH_ERROR_MESSAGE = "Couldn't search right now. Try again."
|
|
16
16
|
|
|
17
|
+
export interface SearchResultCounts {
|
|
18
|
+
conversationResultCount: number
|
|
19
|
+
messageResultCount: number
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
interface SearchResultsListProps {
|
|
18
23
|
term: string
|
|
19
|
-
onSelect: (result: SearchResultResource) => void
|
|
24
|
+
onSelect: (result: SearchResultResource, position: number) => void
|
|
25
|
+
onSettled: (term: string, counts: SearchResultCounts) => void
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
|
|
28
|
+
export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsListProps) {
|
|
23
29
|
const {
|
|
24
30
|
results,
|
|
25
31
|
failedTypes,
|
|
@@ -32,6 +38,19 @@ export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
|
|
|
32
38
|
} = useSearchResults(term)
|
|
33
39
|
const conversationResults = results.filter(isConversationResult)
|
|
34
40
|
const messageResults = results.filter(isMessageResult)
|
|
41
|
+
const settled = !isPending && !isError
|
|
42
|
+
|
|
43
|
+
const settledTermRef = useRef<string | undefined>(undefined)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!settled || settledTermRef.current === term) return
|
|
47
|
+
|
|
48
|
+
settledTermRef.current = term
|
|
49
|
+
onSettled(term, {
|
|
50
|
+
conversationResultCount: conversationResults.length,
|
|
51
|
+
messageResultCount: messageResults.length,
|
|
52
|
+
})
|
|
53
|
+
}, [settled, term, conversationResults.length, messageResults.length, onSettled])
|
|
35
54
|
|
|
36
55
|
return (
|
|
37
56
|
<FlatList
|
|
@@ -40,7 +59,9 @@ export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
|
|
|
40
59
|
keyboardShouldPersistTaps="handled"
|
|
41
60
|
keyboardDismissMode="on-drag"
|
|
42
61
|
contentContainerStyle={styles.content}
|
|
43
|
-
renderItem={({ item }) =>
|
|
62
|
+
renderItem={({ item, index }) => (
|
|
63
|
+
<MessageResultRow result={item} onPress={() => onSelect(item, index)} />
|
|
64
|
+
)}
|
|
44
65
|
ListHeaderComponent={
|
|
45
66
|
<>
|
|
46
67
|
{conversationResults.length > 0 && (
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { ParamListBase } from '@react-navigation/native'
|
|
1
|
+
import { ParamListBase, useNavigation } from '@react-navigation/native'
|
|
2
2
|
import {
|
|
3
3
|
NativeStackNavigationOptions,
|
|
4
4
|
NativeStackScreenProps,
|
|
5
5
|
} from '@react-navigation/native-stack'
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import { StyleSheet, View } from 'react-native'
|
|
6
|
+
import React, { useRef, useState } from 'react'
|
|
7
|
+
import { Keyboard, StyleSheet, View } from 'react-native'
|
|
9
8
|
import { KeyboardView } from '../../components'
|
|
10
9
|
import {
|
|
11
10
|
composeOptions,
|
|
12
11
|
withMinimalBack,
|
|
13
12
|
} from '../../components/display/platform_modal_header_buttons'
|
|
14
13
|
import { useDebouncedSearchTerm } from '../../hooks/use_debounced_search_term'
|
|
14
|
+
import { analyticsEvents, useProductAnalytics } from '../../hooks/use_product_analytics'
|
|
15
|
+
import type { SearchResultResource } from '../../types/resources/search_result'
|
|
15
16
|
import { SearchPromptBlankState } from './components/search_blank_states'
|
|
16
17
|
import { SearchField } from './components/search_field'
|
|
17
|
-
import { SearchResultsList } from './components/search_results_list'
|
|
18
|
+
import { SearchResultsList, type SearchResultCounts } from './components/search_results_list'
|
|
18
19
|
|
|
19
20
|
export const SearchScreenOptions = ({
|
|
20
21
|
navigation,
|
|
@@ -25,15 +26,52 @@ export const SearchScreenOptions = ({
|
|
|
25
26
|
)
|
|
26
27
|
|
|
27
28
|
export function SearchScreen() {
|
|
29
|
+
const navigation = useNavigation()
|
|
28
30
|
const [term, setTerm] = useState('')
|
|
29
31
|
const debouncedTerm = useDebouncedSearchTerm(term)
|
|
32
|
+
const { publishEvent } = useProductAnalytics()
|
|
33
|
+
const publishedTerms = useRef(new Set<string>())
|
|
34
|
+
|
|
35
|
+
const publishSearch = (settledTerm: string, counts: SearchResultCounts) => {
|
|
36
|
+
if (publishedTerms.current.has(settledTerm)) return
|
|
37
|
+
|
|
38
|
+
publishedTerms.current.add(settledTerm)
|
|
39
|
+
publishEvent(analyticsEvents.search_performed, {
|
|
40
|
+
term_length: settledTerm.length,
|
|
41
|
+
conversation_result_count: counts.conversationResultCount,
|
|
42
|
+
message_result_count: counts.messageResultCount,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const openResult = (result: SearchResultResource, position: number) => {
|
|
47
|
+
Keyboard.dismiss()
|
|
48
|
+
publishEvent(analyticsEvents.search_result_opened, { result_type: result.type, position })
|
|
49
|
+
|
|
50
|
+
if (result.type === 'conversation') {
|
|
51
|
+
navigation.navigate('Conversation', {
|
|
52
|
+
conversation_id: Number(result.id),
|
|
53
|
+
title: result.title,
|
|
54
|
+
})
|
|
55
|
+
} else {
|
|
56
|
+
navigation.navigate('Conversation', {
|
|
57
|
+
conversation_id: result.extra.conversationId,
|
|
58
|
+
initial_message_id: result.id,
|
|
59
|
+
title: result.title,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
30
63
|
|
|
31
64
|
return (
|
|
32
65
|
<KeyboardView>
|
|
33
66
|
<View style={styles.container} testID="search-screen">
|
|
34
67
|
<SearchField term={term} onChange={setTerm} />
|
|
35
68
|
{debouncedTerm.length > 0 ? (
|
|
36
|
-
<SearchResultsList
|
|
69
|
+
<SearchResultsList
|
|
70
|
+
key={debouncedTerm}
|
|
71
|
+
term={debouncedTerm}
|
|
72
|
+
onSelect={openResult}
|
|
73
|
+
onSettled={publishSearch}
|
|
74
|
+
/>
|
|
37
75
|
) : (
|
|
38
76
|
<SearchPromptBlankState />
|
|
39
77
|
)}
|
|
@@ -53,8 +53,13 @@ describe('throwResponseError', () => {
|
|
|
53
53
|
expect(() => throwResponseError(fetchResponseLike(401))).toThrow(ResponseError)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it('
|
|
56
|
+
it('rethrows non-response errors synchronously instead of returning a rejected promise', () => {
|
|
57
57
|
const error = new Error('network down')
|
|
58
|
-
|
|
58
|
+
expect(() => throwResponseError(error)).toThrow(error)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('rethrows an existing ResponseError without re-wrapping it', () => {
|
|
62
|
+
const error = new ResponseError(fetchResponseLike(401) as never)
|
|
63
|
+
expect(() => throwResponseError(error)).toThrow(error)
|
|
59
64
|
})
|
|
60
65
|
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { LinkingAdapter } from '../linking'
|
|
2
|
+
|
|
3
|
+
describe('LinkingAdapter', () => {
|
|
4
|
+
it('forwards open options to the host', async () => {
|
|
5
|
+
const openURL = jest.fn(async () => {})
|
|
6
|
+
const adapter = new LinkingAdapter({ openURL, canOpenURL: async () => true })
|
|
7
|
+
|
|
8
|
+
await adapter.openURL('https://example.com', { forceOpenInBrowser: true })
|
|
9
|
+
|
|
10
|
+
expect(openURL).toHaveBeenCalledWith('https://example.com', { forceOpenInBrowser: true })
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { Linking } from 'react-native'
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
|
|
3
|
+
export type LinkingOpenURLOptions = {
|
|
4
|
+
forceOpenInBrowser?: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type LinkingMethods = {
|
|
8
|
+
openURL: (url: string, options?: LinkingOpenURLOptions) => Promise<unknown>
|
|
5
9
|
canOpenURL: Linking['canOpenURL']
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class LinkingAdapter {
|
|
13
|
+
openURL: LinkingMethods['openURL']
|
|
14
|
+
canOpenURL: LinkingMethods['canOpenURL']
|
|
6
15
|
|
|
7
|
-
constructor(methods:
|
|
16
|
+
constructor(methods: LinkingMethods) {
|
|
8
17
|
this.openURL = methods.openURL.bind(Linking)
|
|
9
18
|
this.canOpenURL = methods.canOpenURL.bind(Linking)
|
|
10
19
|
}
|
|
@@ -39,10 +39,10 @@ export const isResponseLike = (value: unknown): value is Response => {
|
|
|
39
39
|
)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export const throwResponseError = (error: unknown) => {
|
|
42
|
+
export const throwResponseError = (error: unknown): never => {
|
|
43
43
|
if (isResponseLike(error)) {
|
|
44
44
|
throw new ResponseError(error as FailedResponse)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
throw error
|
|
48
48
|
}
|