@planningcenter/chat-react-native 4.3.0-rc.9 → 4.3.1-qa-989.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/giphy_attachment.js +2 -2
- package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +5 -3
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/message.d.ts +3 -1
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +183 -158
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +3 -3
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +143 -88
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/components/conversation/system_message.js +3 -3
- package/build/components/conversation/system_message.js.map +1 -1
- package/build/components/conversations/conversations.d.ts.map +1 -1
- package/build/components/conversations/conversations.js +0 -6
- package/build/components/conversations/conversations.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +28 -25
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_animated_message_background_color.d.ts +1 -0
- package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
- package/build/hooks/use_animated_message_background_color.js +26 -9
- package/build/hooks/use_animated_message_background_color.js.map +1 -1
- package/build/hooks/use_chat_permissions.d.ts +10 -2
- package/build/hooks/use_chat_permissions.d.ts.map +1 -1
- package/build/hooks/use_chat_permissions.js +8 -0
- package/build/hooks/use_chat_permissions.js.map +1 -1
- package/build/hooks/use_debounced_search_term.d.ts +2 -0
- package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
- package/build/hooks/use_debounced_search_term.js +40 -0
- package/build/hooks/use_debounced_search_term.js.map +1 -0
- package/build/hooks/use_features.d.ts +2 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +3 -1
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_jolt.d.ts.map +1 -1
- package/build/hooks/use_jolt.js +2 -1
- package/build/hooks/use_jolt.js.map +1 -1
- package/build/hooks/use_product_analytics.d.ts +9 -0
- package/build/hooks/use_product_analytics.d.ts.map +1 -1
- package/build/hooks/use_product_analytics.js +4 -2
- package/build/hooks/use_product_analytics.js.map +1 -1
- package/build/hooks/use_search_enabled.d.ts +2 -0
- package/build/hooks/use_search_enabled.d.ts.map +1 -0
- package/build/hooks/use_search_enabled.js +18 -0
- package/build/hooks/use_search_enabled.js.map +1 -0
- package/build/hooks/use_search_results.d.ts +19 -0
- package/build/hooks/use_search_results.d.ts.map +1 -0
- package/build/hooks/use_search_results.js +153 -0
- package/build/hooks/use_search_results.js.map +1 -0
- package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
- package/build/screens/age_check/age_check_underage_screen.js +2 -1
- package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/bug_report_screen.d.ts.map +1 -1
- package/build/screens/bug_report_screen.js +2 -2
- package/build/screens/bug_report_screen.js.map +1 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
- package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
- package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
- package/build/screens/conversations/components/search_list_header_component.js +3 -3
- package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +2 -5
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/screens/get_help_screen.js +2 -2
- package/build/screens/get_help_screen.js.map +1 -1
- package/build/screens/message_actions_screen.js +6 -2
- package/build/screens/message_actions_screen.js.map +1 -1
- package/build/screens/search/components/conversation_result_row.d.ts +8 -0
- package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
- package/build/screens/search/components/conversation_result_row.js +80 -0
- package/build/screens/search/components/conversation_result_row.js.map +1 -0
- package/build/screens/search/components/conversation_results.d.ts +8 -0
- package/build/screens/search/components/conversation_results.d.ts.map +1 -0
- package/build/screens/search/components/conversation_results.js +77 -0
- package/build/screens/search/components/conversation_results.js.map +1 -0
- package/build/screens/search/components/message_result_row.d.ts +8 -0
- package/build/screens/search/components/message_result_row.d.ts.map +1 -0
- package/build/screens/search/components/message_result_row.js +152 -0
- package/build/screens/search/components/message_result_row.js.map +1 -0
- package/build/screens/search/components/result_row.d.ts +10 -0
- package/build/screens/search/components/result_row.d.ts.map +1 -0
- package/build/screens/search/components/result_row.js +102 -0
- package/build/screens/search/components/result_row.js.map +1 -0
- package/build/screens/search/components/results_section.d.ts +9 -0
- package/build/screens/search/components/results_section.d.ts.map +1 -0
- package/build/screens/search/components/results_section.js +51 -0
- package/build/screens/search/components/results_section.js.map +1 -0
- package/build/screens/search/components/search_results_list.d.ts +13 -0
- package/build/screens/search/components/search_results_list.d.ts.map +1 -0
- package/build/screens/search/components/search_results_list.js +344 -0
- package/build/screens/search/components/search_results_list.js.map +1 -0
- package/build/screens/search/components/snippet.d.ts +8 -0
- package/build/screens/search/components/snippet.d.ts.map +1 -0
- package/build/screens/search/components/snippet.js +68 -0
- package/build/screens/search/components/snippet.js.map +1 -0
- package/build/screens/search/search_screen.d.ts.map +1 -1
- package/build/screens/search/search_screen.js +92 -20
- package/build/screens/search/search_screen.js.map +1 -1
- package/build/utils/client/request_helpers.js +1 -1
- package/build/utils/client/request_helpers.js.map +1 -1
- package/build/utils/native_adapters/linking.d.ts +11 -3
- package/build/utils/native_adapters/linking.d.ts.map +1 -1
- package/build/utils/native_adapters/linking.js.map +1 -1
- package/build/utils/response_error.d.ts +1 -1
- package/build/utils/response_error.d.ts.map +1 -1
- package/build/utils/response_error.js +1 -1
- package/build/utils/response_error.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
- package/src/__tests__/contexts/api_provider.test.tsx +50 -0
- package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
- package/src/__tests__/screens/get_help_screen.test.ts +36 -0
- package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
- package/src/__tests__/utils/client.ts +3 -4
- package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
- package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
- package/src/components/conversation/attachments/image_attachment.tsx +2 -3
- package/src/components/conversation/message.tsx +12 -1
- package/src/components/conversation/message_form.tsx +2 -2
- package/src/components/conversation/message_list.tsx +26 -1
- package/src/components/conversation/system_message.tsx +3 -3
- package/src/components/conversations/conversations.tsx +0 -7
- package/src/contexts/__tests__/api_provider.test.tsx +106 -0
- package/src/contexts/api_provider.tsx +6 -9
- package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
- package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
- package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
- package/src/hooks/use_animated_message_background_color.ts +37 -12
- package/src/hooks/use_chat_permissions.ts +15 -2
- package/src/hooks/use_debounced_search_term.ts +20 -0
- package/src/hooks/use_features.ts +2 -0
- package/src/hooks/use_jolt.ts +10 -7
- package/src/hooks/use_product_analytics.ts +3 -1
- package/src/hooks/use_search_enabled.ts +9 -0
- package/src/hooks/use_search_results.ts +78 -0
- package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
- package/src/screens/bug_report_screen.tsx +2 -10
- package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
- package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
- package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
- package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
- package/src/screens/conversations/conversations_screen.tsx +2 -3
- package/src/screens/get_help_screen.tsx +2 -2
- package/src/screens/message_actions_screen.tsx +6 -2
- package/src/screens/search/__tests__/search_screen.test.tsx +194 -0
- package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
- package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
- package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
- package/src/screens/search/components/__tests__/search_results_list.test.tsx +180 -0
- package/src/screens/search/components/conversation_result_row.tsx +36 -0
- package/src/screens/search/components/conversation_results.tsx +43 -0
- package/src/screens/search/components/message_result_row.tsx +48 -0
- package/src/screens/search/components/result_row.tsx +58 -0
- package/src/screens/search/components/results_section.tsx +34 -0
- package/src/screens/search/components/search_results_list.tsx +168 -0
- package/src/screens/search/components/snippet.tsx +37 -0
- package/src/screens/search/search_screen.tsx +50 -4
- package/src/utils/__tests__/response_error.test.ts +7 -2
- package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
- package/src/utils/client/request_helpers.ts +1 -1
- package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
- package/src/utils/native_adapters/linking.ts +12 -3
- package/src/utils/response_error.ts +2 -2
|
@@ -0,0 +1,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’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
|
-
|
|
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
|
)
|