@planningcenter/chat-react-native 4.3.0-rc.9 → 4.3.1-qa-989.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/giphy_attachment.js +2 -2
  3. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  5. package/build/components/conversation/attachments/image_attachment.js +5 -3
  6. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  7. package/build/components/conversation/message.d.ts +3 -1
  8. package/build/components/conversation/message.d.ts.map +1 -1
  9. package/build/components/conversation/message.js +183 -158
  10. package/build/components/conversation/message.js.map +1 -1
  11. package/build/components/conversation/message_form.d.ts.map +1 -1
  12. package/build/components/conversation/message_form.js +3 -3
  13. package/build/components/conversation/message_form.js.map +1 -1
  14. package/build/components/conversation/message_list.d.ts.map +1 -1
  15. package/build/components/conversation/message_list.js +143 -88
  16. package/build/components/conversation/message_list.js.map +1 -1
  17. package/build/components/conversation/system_message.js +3 -3
  18. package/build/components/conversation/system_message.js.map +1 -1
  19. package/build/components/conversations/conversations.d.ts.map +1 -1
  20. package/build/components/conversations/conversations.js +0 -6
  21. package/build/components/conversations/conversations.js.map +1 -1
  22. package/build/contexts/api_provider.d.ts.map +1 -1
  23. package/build/contexts/api_provider.js +28 -25
  24. package/build/contexts/api_provider.js.map +1 -1
  25. package/build/hooks/use_animated_message_background_color.d.ts +1 -0
  26. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  27. package/build/hooks/use_animated_message_background_color.js +26 -9
  28. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  29. package/build/hooks/use_chat_permissions.d.ts +10 -2
  30. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  31. package/build/hooks/use_chat_permissions.js +8 -0
  32. package/build/hooks/use_chat_permissions.js.map +1 -1
  33. package/build/hooks/use_debounced_search_term.d.ts +2 -0
  34. package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
  35. package/build/hooks/use_debounced_search_term.js +40 -0
  36. package/build/hooks/use_debounced_search_term.js.map +1 -0
  37. package/build/hooks/use_features.d.ts +2 -0
  38. package/build/hooks/use_features.d.ts.map +1 -1
  39. package/build/hooks/use_features.js +3 -1
  40. package/build/hooks/use_features.js.map +1 -1
  41. package/build/hooks/use_jolt.d.ts.map +1 -1
  42. package/build/hooks/use_jolt.js +2 -1
  43. package/build/hooks/use_jolt.js.map +1 -1
  44. package/build/hooks/use_product_analytics.d.ts +9 -0
  45. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  46. package/build/hooks/use_product_analytics.js +4 -2
  47. package/build/hooks/use_product_analytics.js.map +1 -1
  48. package/build/hooks/use_search_enabled.d.ts +2 -0
  49. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  50. package/build/hooks/use_search_enabled.js +18 -0
  51. package/build/hooks/use_search_enabled.js.map +1 -0
  52. package/build/hooks/use_search_results.d.ts +19 -0
  53. package/build/hooks/use_search_results.d.ts.map +1 -0
  54. package/build/hooks/use_search_results.js +153 -0
  55. package/build/hooks/use_search_results.js.map +1 -0
  56. package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
  57. package/build/screens/age_check/age_check_underage_screen.js +2 -1
  58. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  59. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  60. package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
  61. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  62. package/build/screens/bug_report_screen.d.ts.map +1 -1
  63. package/build/screens/bug_report_screen.js +2 -2
  64. package/build/screens/bug_report_screen.js.map +1 -1
  65. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
  66. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
  67. package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
  68. package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
  69. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  70. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
  71. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  72. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  73. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  74. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  75. package/build/screens/conversations/conversations_screen.js +2 -5
  76. package/build/screens/conversations/conversations_screen.js.map +1 -1
  77. package/build/screens/get_help_screen.js +2 -2
  78. package/build/screens/get_help_screen.js.map +1 -1
  79. package/build/screens/message_actions_screen.js +6 -2
  80. package/build/screens/message_actions_screen.js.map +1 -1
  81. package/build/screens/search/components/conversation_result_row.d.ts +8 -0
  82. package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
  83. package/build/screens/search/components/conversation_result_row.js +80 -0
  84. package/build/screens/search/components/conversation_result_row.js.map +1 -0
  85. package/build/screens/search/components/conversation_results.d.ts +8 -0
  86. package/build/screens/search/components/conversation_results.d.ts.map +1 -0
  87. package/build/screens/search/components/conversation_results.js +77 -0
  88. package/build/screens/search/components/conversation_results.js.map +1 -0
  89. package/build/screens/search/components/message_result_row.d.ts +8 -0
  90. package/build/screens/search/components/message_result_row.d.ts.map +1 -0
  91. package/build/screens/search/components/message_result_row.js +152 -0
  92. package/build/screens/search/components/message_result_row.js.map +1 -0
  93. package/build/screens/search/components/result_row.d.ts +10 -0
  94. package/build/screens/search/components/result_row.d.ts.map +1 -0
  95. package/build/screens/search/components/result_row.js +102 -0
  96. package/build/screens/search/components/result_row.js.map +1 -0
  97. package/build/screens/search/components/results_section.d.ts +9 -0
  98. package/build/screens/search/components/results_section.d.ts.map +1 -0
  99. package/build/screens/search/components/results_section.js +51 -0
  100. package/build/screens/search/components/results_section.js.map +1 -0
  101. package/build/screens/search/components/search_results_list.d.ts +13 -0
  102. package/build/screens/search/components/search_results_list.d.ts.map +1 -0
  103. package/build/screens/search/components/search_results_list.js +344 -0
  104. package/build/screens/search/components/search_results_list.js.map +1 -0
  105. package/build/screens/search/components/snippet.d.ts +8 -0
  106. package/build/screens/search/components/snippet.d.ts.map +1 -0
  107. package/build/screens/search/components/snippet.js +68 -0
  108. package/build/screens/search/components/snippet.js.map +1 -0
  109. package/build/screens/search/search_screen.d.ts.map +1 -1
  110. package/build/screens/search/search_screen.js +92 -20
  111. package/build/screens/search/search_screen.js.map +1 -1
  112. package/build/utils/client/request_helpers.js +1 -1
  113. package/build/utils/client/request_helpers.js.map +1 -1
  114. package/build/utils/native_adapters/linking.d.ts +11 -3
  115. package/build/utils/native_adapters/linking.d.ts.map +1 -1
  116. package/build/utils/native_adapters/linking.js.map +1 -1
  117. package/build/utils/response_error.d.ts +1 -1
  118. package/build/utils/response_error.d.ts.map +1 -1
  119. package/build/utils/response_error.js +1 -1
  120. package/build/utils/response_error.js.map +1 -1
  121. package/package.json +3 -3
  122. package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
  123. package/src/__tests__/contexts/api_provider.test.tsx +50 -0
  124. package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
  125. package/src/__tests__/screens/get_help_screen.test.ts +36 -0
  126. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  127. package/src/__tests__/utils/client.ts +3 -4
  128. package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
  129. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  130. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
  131. package/src/components/conversation/attachments/image_attachment.tsx +2 -3
  132. package/src/components/conversation/message.tsx +12 -1
  133. package/src/components/conversation/message_form.tsx +2 -2
  134. package/src/components/conversation/message_list.tsx +26 -1
  135. package/src/components/conversation/system_message.tsx +3 -3
  136. package/src/components/conversations/conversations.tsx +0 -7
  137. package/src/contexts/__tests__/api_provider.test.tsx +106 -0
  138. package/src/contexts/api_provider.tsx +6 -9
  139. package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
  140. package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
  141. package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
  142. package/src/hooks/use_animated_message_background_color.ts +37 -12
  143. package/src/hooks/use_chat_permissions.ts +15 -2
  144. package/src/hooks/use_debounced_search_term.ts +20 -0
  145. package/src/hooks/use_features.ts +2 -0
  146. package/src/hooks/use_jolt.ts +10 -7
  147. package/src/hooks/use_product_analytics.ts +3 -1
  148. package/src/hooks/use_search_enabled.ts +9 -0
  149. package/src/hooks/use_search_results.ts +78 -0
  150. package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
  151. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
  152. package/src/screens/bug_report_screen.tsx +2 -10
  153. package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
  154. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
  155. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  156. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  157. package/src/screens/conversations/conversations_screen.tsx +2 -3
  158. package/src/screens/get_help_screen.tsx +2 -2
  159. package/src/screens/message_actions_screen.tsx +6 -2
  160. package/src/screens/search/__tests__/search_screen.test.tsx +194 -0
  161. package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
  162. package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
  163. package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
  164. package/src/screens/search/components/__tests__/search_results_list.test.tsx +180 -0
  165. package/src/screens/search/components/conversation_result_row.tsx +36 -0
  166. package/src/screens/search/components/conversation_results.tsx +43 -0
  167. package/src/screens/search/components/message_result_row.tsx +48 -0
  168. package/src/screens/search/components/result_row.tsx +58 -0
  169. package/src/screens/search/components/results_section.tsx +34 -0
  170. package/src/screens/search/components/search_results_list.tsx +168 -0
  171. package/src/screens/search/components/snippet.tsx +37 -0
  172. package/src/screens/search/search_screen.tsx +50 -4
  173. package/src/utils/__tests__/response_error.test.ts +7 -2
  174. package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
  175. package/src/utils/client/request_helpers.ts +1 -1
  176. package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
  177. package/src/utils/native_adapters/linking.ts +12 -3
  178. package/src/utils/response_error.ts +2 -2
@@ -0,0 +1,180 @@
1
+ import { fireEvent, render } from '@testing-library/react-native'
2
+ import React from 'react'
3
+ import { FlatList } from 'react-native'
4
+ import type { useSearchResults } from '../../../../hooks/use_search_results'
5
+ import type {
6
+ ConversationSearchResultResource,
7
+ MessageSearchResultResource,
8
+ } from '../../../../types/resources/search_result'
9
+ import { SearchResultsList } from '../search_results_list'
10
+
11
+ const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [string]>()
12
+ jest.mock('../../../../hooks/use_search_results', () => ({
13
+ useSearchResults: (term: string) => mockUseSearchResults(term),
14
+ }))
15
+
16
+ type SearchState = ReturnType<typeof useSearchResults>
17
+
18
+ const searchState = (overrides: Partial<SearchState> = {}): SearchState => ({
19
+ results: [],
20
+ failedTypes: [],
21
+ isPending: false,
22
+ isError: false,
23
+ isFetchNextPageError: false,
24
+ loadMore: jest.fn(),
25
+ hasNextPage: false,
26
+ isFetchingNextPage: false,
27
+ ...overrides,
28
+ })
29
+
30
+ const conversationResult = (id: string): ConversationSearchResultResource => ({
31
+ type: 'conversation',
32
+ id,
33
+ title: `Conversation ${id}`,
34
+ url: `/conversations/${id}`,
35
+ extra: { avatar: { previewAvatarUrls: [] } },
36
+ })
37
+
38
+ const messageResult = (id: string): MessageSearchResultResource => ({
39
+ type: 'message',
40
+ id,
41
+ title: `Message ${id}`,
42
+ url: `/conversations/1/messages/${id}`,
43
+ extra: {
44
+ conversationId: 1,
45
+ authorName: 'Ann',
46
+ snippet: [{ text: 'potluck', match: true }],
47
+ createdAt: '2026-01-01T00:00:00Z',
48
+ avatar: { previewAvatarUrls: [] },
49
+ },
50
+ })
51
+
52
+ const renderList = (state: Partial<SearchState>, onSettled = jest.fn()) => {
53
+ mockUseSearchResults.mockReturnValue(searchState(state))
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 }
61
+ }
62
+
63
+ describe('SearchResultsList', () => {
64
+ it('shows a spinner while pending', () => {
65
+ const view = renderList({ isPending: true })
66
+
67
+ expect(view.getByLabelText('Loading results')).toBeTruthy()
68
+ })
69
+
70
+ it('shows an error banner when the search fails', () => {
71
+ const view = renderList({ isError: true })
72
+
73
+ expect(view.getByText("Couldn't search right now. Try again.")).toBeTruthy()
74
+ })
75
+
76
+ it('shows the no-results state with the term when nothing matches', () => {
77
+ const view = renderList({})
78
+
79
+ expect(view.getByText(/Nothing matches “potluck\.”/)).toBeTruthy()
80
+ })
81
+
82
+ it('renders both section headings when both types have results', () => {
83
+ const view = renderList({ results: [conversationResult('1'), messageResult('1')] })
84
+
85
+ expect(view.getByText('Conversations')).toBeTruthy()
86
+ expect(view.getByText('Messages')).toBeTruthy()
87
+ expect(view.getByText('Message 1')).toBeTruthy()
88
+ })
89
+
90
+ it('renders the failure text for a failed type instead of the no-results state', () => {
91
+ const view = renderList({ failedTypes: ['message'] })
92
+
93
+ expect(view.getByText('Couldn’t load messages.')).toBeTruthy()
94
+ expect(view.queryByText('No results')).toBeNull()
95
+ })
96
+
97
+ it('keeps the no-results state hidden when only conversations match', () => {
98
+ const view = renderList({ results: [conversationResult('1')] })
99
+
100
+ expect(view.getByText('Conversations')).toBeTruthy()
101
+ expect(view.queryByText('No results')).toBeNull()
102
+ })
103
+
104
+ it('loads the next page once when the end is reached', () => {
105
+ const loadMore = jest.fn()
106
+ const view = renderList({ results: [messageResult('1')], hasNextPage: true, loadMore })
107
+
108
+ fireEvent(view.UNSAFE_getByType(FlatList), 'onEndReached')
109
+
110
+ expect(loadMore).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('does not load more while a page is already fetching', () => {
114
+ const loadMore = jest.fn()
115
+ const view = renderList({
116
+ results: [messageResult('1')],
117
+ hasNextPage: true,
118
+ isFetchingNextPage: true,
119
+ loadMore,
120
+ })
121
+
122
+ fireEvent(view.UNSAFE_getByType(FlatList), 'onEndReached')
123
+
124
+ expect(view.getByLabelText('Loading more results')).toBeTruthy()
125
+ expect(loadMore).not.toHaveBeenCalled()
126
+ })
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
+
167
+ it('offers a retry after a paging failure', () => {
168
+ const loadMore = jest.fn()
169
+ const view = renderList({
170
+ results: [messageResult('1')],
171
+ hasNextPage: true,
172
+ isFetchNextPageError: true,
173
+ loadMore,
174
+ })
175
+
176
+ fireEvent.press(view.getByText('Try again'))
177
+
178
+ expect(loadMore).toHaveBeenCalledTimes(1)
179
+ })
180
+ })
@@ -0,0 +1,36 @@
1
+ import { StyleSheet } from 'react-native'
2
+ import { ConversationAvatar, Heading } from '../../../components'
3
+ import { useScalableNumberOfLines, useTheme } from '../../../hooks'
4
+ import type { ConversationSearchResultResource } from '../../../types'
5
+ import { ResultRow } from './result_row'
6
+
7
+ interface ConversationResultRowProps {
8
+ result: ConversationSearchResultResource
9
+ onPress: () => void
10
+ }
11
+
12
+ export function ConversationResultRow({ result, onPress }: ConversationResultRowProps) {
13
+ const styles = useStyles()
14
+ const numberOfLines = useScalableNumberOfLines(1)
15
+
16
+ return (
17
+ <ResultRow accessibilityLabel={result.title} onPress={onPress} alignItems="center">
18
+ <ConversationAvatar conversation={result.extra.avatar} size="lg" />
19
+ <Heading variant="h3" numberOfLines={numberOfLines} style={styles.title}>
20
+ {result.title}
21
+ </Heading>
22
+ </ResultRow>
23
+ )
24
+ }
25
+
26
+ const useStyles = () => {
27
+ const { colors } = useTheme()
28
+
29
+ return StyleSheet.create({
30
+ title: {
31
+ flex: 1,
32
+ fontSize: 16,
33
+ color: colors.textColorDefaultPrimary,
34
+ },
35
+ })
36
+ }
@@ -0,0 +1,43 @@
1
+ import { useState } from 'react'
2
+ import type { ConversationSearchResultResource } from '../../../types'
3
+ import { ViewMoreLinkRow } from '../../conversation_select_recipients/components/view_more_link_row'
4
+ import { ConversationResultRow } from './conversation_result_row'
5
+ import { ResultsSection } from './results_section'
6
+
7
+ const COLLAPSED_COUNT = 5
8
+
9
+ interface ConversationResultsProps {
10
+ results: ConversationSearchResultResource[]
11
+ onSelect: (result: ConversationSearchResultResource, position: number) => void
12
+ }
13
+
14
+ export function ConversationResults({ results, onSelect }: ConversationResultsProps) {
15
+ const [expanded, setExpanded] = useState(false)
16
+ const visible = expanded ? results : results.slice(0, COLLAPSED_COUNT)
17
+
18
+ if (results.length === 0) return null
19
+
20
+ const footer = results.length > COLLAPSED_COUNT && (
21
+ <ViewMoreLinkRow
22
+ expanded={expanded}
23
+ onPress={() => setExpanded(!expanded)}
24
+ accessibilityHint={
25
+ expanded
26
+ ? `Shows the first ${COLLAPSED_COUNT} conversation results`
27
+ : `Shows all ${results.length} conversation results`
28
+ }
29
+ />
30
+ )
31
+
32
+ return (
33
+ <ResultsSection title="Conversations" footer={footer}>
34
+ {visible.map((result, index) => (
35
+ <ConversationResultRow
36
+ key={result.id}
37
+ result={result}
38
+ onPress={() => onSelect(result, index)}
39
+ />
40
+ ))}
41
+ </ResultsSection>
42
+ )
43
+ }
@@ -0,0 +1,48 @@
1
+ import React from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { ConversationAvatar, Text } from '../../../components/display'
4
+ import { useScalableNumberOfLines } from '../../../hooks'
5
+ import type { MessageSearchResultResource } from '../../../types/resources/search_result'
6
+ import { platformFontWeightMedium } from '../../../utils'
7
+ import { formatDatePreview } from '../../../utils/date'
8
+ import { ResultRow } from './result_row'
9
+ import { plainSnippetText, Snippet } from './snippet'
10
+
11
+ interface MessageResultRowProps {
12
+ result: MessageSearchResultResource
13
+ onPress: (result: MessageSearchResultResource) => void
14
+ }
15
+
16
+ export function MessageResultRow({ result, onPress }: MessageResultRowProps) {
17
+ const { authorName, avatar, snippet, createdAt } = result.extra
18
+ const titleNumberOfLines = useScalableNumberOfLines(1)
19
+ const previewNumberOfLines = useScalableNumberOfLines(2)
20
+ const date = formatDatePreview(createdAt)
21
+ const accessibilityLabel = `${result.title}, ${authorName}: ${plainSnippetText(snippet)}, ${date}`
22
+
23
+ return (
24
+ <ResultRow accessibilityLabel={accessibilityLabel} onPress={() => onPress(result)}>
25
+ <ConversationAvatar conversation={avatar} size="lg" />
26
+ <View style={styles.body}>
27
+ <Text numberOfLines={titleNumberOfLines} style={styles.title}>
28
+ {result.title}
29
+ </Text>
30
+ <Text variant="tertiary" numberOfLines={previewNumberOfLines}>
31
+ {authorName}: <Snippet segments={snippet} />
32
+ </Text>
33
+ </View>
34
+ <Text variant="footnote">{date}</Text>
35
+ </ResultRow>
36
+ )
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ body: {
41
+ flex: 1,
42
+ rowGap: 2,
43
+ },
44
+ title: {
45
+ fontSize: 16,
46
+ fontWeight: platformFontWeightMedium,
47
+ },
48
+ })
@@ -0,0 +1,58 @@
1
+ import React, { type ReactNode } from 'react'
2
+ import { Pressable, StyleSheet } from 'react-native'
3
+ import { useAtFontScaleBreakpoint, useTheme } from '../../../hooks'
4
+ import { platformPressedOpacityStyle } from '../../../utils'
5
+
6
+ interface ResultRowProps {
7
+ accessibilityLabel: string
8
+ onPress: () => void
9
+ alignItems?: 'flex-start' | 'center'
10
+ children: ReactNode
11
+ }
12
+
13
+ export function ResultRow({
14
+ accessibilityLabel,
15
+ onPress,
16
+ alignItems = 'flex-start',
17
+ children,
18
+ }: ResultRowProps) {
19
+ const { colors } = useTheme()
20
+ const atFontScaleBreakpoint = useAtFontScaleBreakpoint()
21
+ const styles = useStyles()
22
+
23
+ return (
24
+ <Pressable
25
+ accessibilityRole="button"
26
+ accessibilityLabel={accessibilityLabel}
27
+ onPress={onPress}
28
+ style={({ pressed }) => [
29
+ styles.row,
30
+ atFontScaleBreakpoint ? styles.stacked : { alignItems },
31
+ pressed && platformPressedOpacityStyle,
32
+ ]}
33
+ android_ripple={{ color: colors.androidRippleNeutral, borderless: false, foreground: true }}
34
+ >
35
+ {children}
36
+ </Pressable>
37
+ )
38
+ }
39
+
40
+ const useStyles = () => {
41
+ const { colors } = useTheme()
42
+
43
+ return StyleSheet.create({
44
+ row: {
45
+ flexDirection: 'row',
46
+ gap: 8,
47
+ paddingVertical: 12,
48
+ paddingHorizontal: 16,
49
+ borderBottomWidth: 1,
50
+ borderBottomColor: colors.borderColorDefaultDim,
51
+ },
52
+ stacked: {
53
+ flexDirection: 'column',
54
+ alignItems: 'stretch',
55
+ borderBottomColor: colors.borderColorDefaultDark,
56
+ },
57
+ })
58
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Text } from '../../../components'
4
+
5
+ interface ResultsSectionProps {
6
+ title: string
7
+ footer?: ReactNode
8
+ children?: ReactNode
9
+ }
10
+
11
+ export function ResultsSection({ title, footer, children }: ResultsSectionProps) {
12
+ return (
13
+ <View>
14
+ <View style={styles.headingRow}>
15
+ <Text accessibilityRole="header" style={styles.heading}>
16
+ {title}
17
+ </Text>
18
+ </View>
19
+ {children}
20
+ {footer}
21
+ </View>
22
+ )
23
+ }
24
+
25
+ const styles = StyleSheet.create({
26
+ headingRow: {
27
+ paddingHorizontal: 16,
28
+ paddingTop: 16,
29
+ paddingBottom: 8,
30
+ },
31
+ heading: {
32
+ fontWeight: '600',
33
+ },
34
+ })
@@ -0,0 +1,168 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { FlatList, StyleSheet, View } from 'react-native'
3
+ import { Banner, Spinner, Text, TextButton } from '../../../components'
4
+ import { useSearchResults } from '../../../hooks/use_search_results'
5
+ import type {
6
+ ConversationSearchResultResource,
7
+ MessageSearchResultResource,
8
+ SearchResultResource,
9
+ } from '../../../types/resources/search_result'
10
+ import { ConversationResults } from './conversation_results'
11
+ import { MessageResultRow } from './message_result_row'
12
+ import { ResultsSection } from './results_section'
13
+ import { NoResultsBlankState } from './search_blank_states'
14
+
15
+ const SEARCH_ERROR_MESSAGE = "Couldn't search right now. Try again."
16
+
17
+ export interface SearchResultCounts {
18
+ conversationResultCount: number
19
+ messageResultCount: number
20
+ }
21
+
22
+ interface SearchResultsListProps {
23
+ term: string
24
+ onSelect: (result: SearchResultResource, position: number) => void
25
+ onSettled: (term: string, counts: SearchResultCounts) => void
26
+ }
27
+
28
+ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsListProps) {
29
+ const {
30
+ results,
31
+ failedTypes,
32
+ isPending,
33
+ isError,
34
+ isFetchNextPageError,
35
+ loadMore,
36
+ hasNextPage,
37
+ isFetchingNextPage,
38
+ } = useSearchResults(term)
39
+ const conversationResults = results.filter(isConversationResult)
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])
54
+
55
+ return (
56
+ <FlatList
57
+ data={messageResults}
58
+ keyExtractor={item => item.id}
59
+ keyboardShouldPersistTaps="handled"
60
+ keyboardDismissMode="on-drag"
61
+ contentContainerStyle={styles.content}
62
+ renderItem={({ item, index }) => (
63
+ <MessageResultRow result={item} onPress={() => onSelect(item, index)} />
64
+ )}
65
+ ListHeaderComponent={
66
+ <>
67
+ {conversationResults.length > 0 && (
68
+ <ConversationResults results={conversationResults} onSelect={onSelect} />
69
+ )}
70
+ {failedTypes.includes('conversation') && <SectionFailure type="conversation" />}
71
+ {messageResults.length > 0 && <ResultsSection title="Messages" />}
72
+ </>
73
+ }
74
+ ListEmptyComponent={
75
+ <ListEmpty
76
+ term={term}
77
+ isError={isError}
78
+ isPending={isPending}
79
+ noResults={results.length === 0 && failedTypes.length === 0}
80
+ />
81
+ }
82
+ ListFooterComponent={
83
+ <>
84
+ {isFetchingNextPage && (
85
+ <View accessibilityLabel="Loading more results" style={styles.loading}>
86
+ <Spinner />
87
+ </View>
88
+ )}
89
+ {isFetchNextPageError && !isFetchingNextPage && (
90
+ <TextButton onPress={loadMore} style={styles.retry}>
91
+ Try again
92
+ </TextButton>
93
+ )}
94
+ {failedTypes.includes('message') && <SectionFailure type="message" />}
95
+ </>
96
+ }
97
+ onEndReached={() => {
98
+ if (hasNextPage && !isFetchingNextPage && !isFetchNextPageError) loadMore()
99
+ }}
100
+ onEndReachedThreshold={0.5}
101
+ />
102
+ )
103
+ }
104
+
105
+ interface ListEmptyProps {
106
+ term: string
107
+ isError: boolean
108
+ isPending: boolean
109
+ noResults: boolean
110
+ }
111
+
112
+ function ListEmpty({ term, isError, isPending, noResults }: ListEmptyProps) {
113
+ if (isError) {
114
+ return <Banner appearance="error" description={SEARCH_ERROR_MESSAGE} style={styles.banner} />
115
+ }
116
+
117
+ if (isPending) {
118
+ return (
119
+ <View accessibilityLabel="Loading results" style={styles.loading}>
120
+ <Spinner />
121
+ </View>
122
+ )
123
+ }
124
+
125
+ if (noResults) return <NoResultsBlankState term={term} />
126
+
127
+ return null
128
+ }
129
+
130
+ function SectionFailure({ type }: { type: SearchResultResource['type'] }) {
131
+ return (
132
+ <Text accessibilityRole="alert" variant="secondary" style={styles.sectionFailure}>
133
+ Couldn&rsquo;t load {type}s.
134
+ </Text>
135
+ )
136
+ }
137
+
138
+ function isConversationResult(
139
+ result: SearchResultResource
140
+ ): result is ConversationSearchResultResource {
141
+ return result.type === 'conversation'
142
+ }
143
+
144
+ function isMessageResult(result: SearchResultResource): result is MessageSearchResultResource {
145
+ return result.type === 'message'
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ content: {
150
+ flexGrow: 1,
151
+ },
152
+ banner: {
153
+ margin: 16,
154
+ },
155
+ loading: {
156
+ alignItems: 'center',
157
+ paddingVertical: 16,
158
+ },
159
+ retry: {
160
+ alignSelf: 'center',
161
+ paddingVertical: 16,
162
+ },
163
+ sectionFailure: {
164
+ fontSize: 14,
165
+ paddingHorizontal: 16,
166
+ paddingVertical: 8,
167
+ },
168
+ })
@@ -0,0 +1,37 @@
1
+ import React from 'react'
2
+ import { StyleSheet } from 'react-native'
3
+ import { Text } from '../../../components/display'
4
+ import { useTheme } from '../../../hooks'
5
+ import type { SnippetSegment } from '../../../types/resources/search_result'
6
+
7
+ interface SnippetProps {
8
+ segments: SnippetSegment[]
9
+ }
10
+
11
+ export function Snippet({ segments }: SnippetProps) {
12
+ const styles = useStyles()
13
+
14
+ return (
15
+ <>
16
+ {segments.map((segment, index) => (
17
+ <Text key={index} variant="tertiary" style={segment.match && styles.match}>
18
+ {segment.text}
19
+ </Text>
20
+ ))}
21
+ </>
22
+ )
23
+ }
24
+
25
+ export function plainSnippetText(segments: SnippetSegment[]) {
26
+ return segments.map(segment => segment.text).join('')
27
+ }
28
+
29
+ const useStyles = () => {
30
+ const { colors } = useTheme()
31
+
32
+ return StyleSheet.create({
33
+ match: {
34
+ color: colors.interaction,
35
+ },
36
+ })
37
+ }
@@ -1,17 +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 React, { useState } from 'react'
7
- import { StyleSheet, View } from 'react-native'
6
+ import React, { useRef, useState } from 'react'
7
+ import { Keyboard, StyleSheet, View } from 'react-native'
8
8
  import { KeyboardView } from '../../components'
9
9
  import {
10
10
  composeOptions,
11
11
  withMinimalBack,
12
12
  } from '../../components/display/platform_modal_header_buttons'
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'
13
16
  import { SearchPromptBlankState } from './components/search_blank_states'
14
17
  import { SearchField } from './components/search_field'
18
+ import { SearchResultsList, type SearchResultCounts } from './components/search_results_list'
15
19
 
16
20
  export const SearchScreenOptions = ({
17
21
  navigation,
@@ -22,13 +26,55 @@ export const SearchScreenOptions = ({
22
26
  )
23
27
 
24
28
  export function SearchScreen() {
29
+ const navigation = useNavigation()
25
30
  const [term, setTerm] = useState('')
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
+ }
26
63
 
27
64
  return (
28
65
  <KeyboardView>
29
66
  <View style={styles.container} testID="search-screen">
30
67
  <SearchField term={term} onChange={setTerm} />
31
- <SearchPromptBlankState />
68
+ {debouncedTerm.length > 0 ? (
69
+ <SearchResultsList
70
+ key={debouncedTerm}
71
+ term={debouncedTerm}
72
+ onSelect={openResult}
73
+ onSettled={publishSearch}
74
+ />
75
+ ) : (
76
+ <SearchPromptBlankState />
77
+ )}
32
78
  </View>
33
79
  </KeyboardView>
34
80
  )