@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.
Files changed (124) 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 +2 -2
  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_features.d.ts +2 -0
  34. package/build/hooks/use_features.d.ts.map +1 -1
  35. package/build/hooks/use_features.js +3 -1
  36. package/build/hooks/use_features.js.map +1 -1
  37. package/build/hooks/use_jolt.d.ts.map +1 -1
  38. package/build/hooks/use_jolt.js +2 -1
  39. package/build/hooks/use_jolt.js.map +1 -1
  40. package/build/hooks/use_product_analytics.d.ts +9 -0
  41. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  42. package/build/hooks/use_product_analytics.js +4 -2
  43. package/build/hooks/use_product_analytics.js.map +1 -1
  44. package/build/hooks/use_search_enabled.d.ts +2 -0
  45. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  46. package/build/hooks/use_search_enabled.js +18 -0
  47. package/build/hooks/use_search_enabled.js.map +1 -0
  48. package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
  49. package/build/screens/age_check/age_check_underage_screen.js +2 -1
  50. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  51. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  52. package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
  53. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  54. package/build/screens/bug_report_screen.d.ts.map +1 -1
  55. package/build/screens/bug_report_screen.js +2 -2
  56. package/build/screens/bug_report_screen.js.map +1 -1
  57. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  58. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  59. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  60. package/build/screens/conversations/conversations_screen.js +2 -5
  61. package/build/screens/conversations/conversations_screen.js.map +1 -1
  62. package/build/screens/get_help_screen.js +2 -2
  63. package/build/screens/get_help_screen.js.map +1 -1
  64. package/build/screens/message_actions_screen.js +6 -2
  65. package/build/screens/message_actions_screen.js.map +1 -1
  66. package/build/screens/search/components/conversation_results.d.ts +1 -1
  67. package/build/screens/search/components/conversation_results.d.ts.map +1 -1
  68. package/build/screens/search/components/conversation_results.js +2 -2
  69. package/build/screens/search/components/conversation_results.js.map +1 -1
  70. package/build/screens/search/components/search_results_list.d.ts +7 -2
  71. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  72. package/build/screens/search/components/search_results_list.js +135 -105
  73. package/build/screens/search/components/search_results_list.js.map +1 -1
  74. package/build/screens/search/search_screen.d.ts.map +1 -1
  75. package/build/screens/search/search_screen.js +88 -25
  76. package/build/screens/search/search_screen.js.map +1 -1
  77. package/build/utils/native_adapters/linking.d.ts +11 -3
  78. package/build/utils/native_adapters/linking.d.ts.map +1 -1
  79. package/build/utils/native_adapters/linking.js.map +1 -1
  80. package/build/utils/response_error.d.ts +1 -1
  81. package/build/utils/response_error.d.ts.map +1 -1
  82. package/build/utils/response_error.js +1 -1
  83. package/build/utils/response_error.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
  86. package/src/__tests__/contexts/api_provider.test.tsx +50 -0
  87. package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
  88. package/src/__tests__/screens/get_help_screen.test.ts +36 -0
  89. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  90. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  91. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
  92. package/src/components/conversation/attachments/image_attachment.tsx +2 -3
  93. package/src/components/conversation/message.tsx +12 -1
  94. package/src/components/conversation/message_form.tsx +1 -1
  95. package/src/components/conversation/message_list.tsx +26 -1
  96. package/src/components/conversation/system_message.tsx +3 -3
  97. package/src/components/conversations/conversations.tsx +0 -7
  98. package/src/contexts/__tests__/api_provider.test.tsx +106 -0
  99. package/src/contexts/api_provider.tsx +6 -9
  100. package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
  101. package/src/hooks/use_animated_message_background_color.ts +37 -12
  102. package/src/hooks/use_chat_permissions.ts +15 -2
  103. package/src/hooks/use_features.ts +2 -0
  104. package/src/hooks/use_jolt.ts +10 -7
  105. package/src/hooks/use_product_analytics.ts +3 -1
  106. package/src/hooks/use_search_enabled.ts +9 -0
  107. package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
  108. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
  109. package/src/screens/bug_report_screen.tsx +2 -10
  110. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  111. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  112. package/src/screens/conversations/conversations_screen.tsx +2 -3
  113. package/src/screens/get_help_screen.tsx +2 -2
  114. package/src/screens/message_actions_screen.tsx +6 -2
  115. package/src/screens/search/__tests__/search_screen.test.tsx +130 -0
  116. package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
  117. package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
  118. package/src/screens/search/components/conversation_results.tsx +7 -3
  119. package/src/screens/search/components/search_results_list.tsx +25 -4
  120. package/src/screens/search/search_screen.tsx +44 -6
  121. package/src/utils/__tests__/response_error.test.ts +7 -2
  122. package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
  123. package/src/utils/native_adapters/linking.ts +12 -3
  124. 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 { featureEnabled } = useFeatures()
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, Linking, StyleSheet, View } from 'react-native'
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
- Clipboard.setStringAsync(message?.text || attachmentForCopy() || '')
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
- return render(<SearchResultsList term="potluck" onSelect={jest.fn()} />)
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 key={result.id} result={result} onPress={() => onSelect(result)} />
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 }) => <MessageResultRow result={item} onPress={onSelect} />}
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 { noop } from 'lodash'
7
- import React, { useState } from 'react'
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 key={debouncedTerm} term={debouncedTerm} onSelect={noop} />
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('passes through non-response errors untouched', async () => {
56
+ it('rethrows non-response errors synchronously instead of returning a rejected promise', () => {
57
57
  const error = new Error('network down')
58
- await expect(throwResponseError(error)).rejects.toBe(error)
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 class LinkingAdapter {
4
- openURL: Linking['openURL']
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: Pick<Linking, 'openURL' | 'canOpenURL'>) {
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
- return Promise.reject(error)
47
+ throw error
48
48
  }