@planningcenter/chat-react-native 4.3.0-rc.9 → 4.3.1-rc.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/message_form.js +1 -1
- package/build/components/conversation/message_form.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_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/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/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 +8 -0
- package/build/screens/search/components/search_results_list.d.ts.map +1 -0
- package/build/screens/search/components/search_results_list.js +314 -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 +18 -9
- 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/package.json +3 -3
- 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/message_form.tsx +1 -1
- package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
- package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
- package/src/hooks/use_debounced_search_term.ts +20 -0
- package/src/hooks/use_search_results.ts +78 -0
- 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/message_actions_screen.tsx +6 -2
- package/src/screens/search/__tests__/search_screen.test.tsx +64 -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 +135 -0
- package/src/screens/search/components/conversation_result_row.tsx +36 -0
- package/src/screens/search/components/conversation_results.tsx +39 -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 +147 -0
- package/src/screens/search/components/snippet.tsx +37 -0
- package/src/screens/search/search_screen.tsx +9 -1
- package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
- package/src/utils/client/request_helpers.ts +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { fireEvent, render } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type { ConversationSearchResultResource } from '../../../../types'
|
|
4
|
+
import { ConversationResults } from '../conversation_results'
|
|
5
|
+
|
|
6
|
+
const buildResults = (count: number, prefix = 'Conversation') =>
|
|
7
|
+
Array.from({ length: count }, (_, index) => ({
|
|
8
|
+
type: 'conversation' as const,
|
|
9
|
+
id: `${prefix}-${index + 1}`,
|
|
10
|
+
title: `${prefix} ${index + 1}`,
|
|
11
|
+
url: `/conversations/${index + 1}`,
|
|
12
|
+
extra: { avatar: { previewAvatarUrls: [] } },
|
|
13
|
+
})) satisfies ConversationSearchResultResource[]
|
|
14
|
+
|
|
15
|
+
describe('ConversationResults', () => {
|
|
16
|
+
it('caps at five rows until View more is pressed, then offers View less', () => {
|
|
17
|
+
const view = render(<ConversationResults results={buildResults(6)} onSelect={jest.fn()} />)
|
|
18
|
+
|
|
19
|
+
expect(view.getByText('Conversation 5')).toBeTruthy()
|
|
20
|
+
expect(view.queryByText('Conversation 6')).toBeNull()
|
|
21
|
+
|
|
22
|
+
fireEvent.press(view.getByText('View more'))
|
|
23
|
+
|
|
24
|
+
expect(view.getByText('Conversation 6')).toBeTruthy()
|
|
25
|
+
expect(view.getByText('View less')).toBeTruthy()
|
|
26
|
+
expect(view.queryByText('View more')).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders no footer for five or fewer results', () => {
|
|
30
|
+
const view = render(<ConversationResults results={buildResults(4)} onSelect={jest.fn()} />)
|
|
31
|
+
|
|
32
|
+
expect(view.getByText('Conversation 4')).toBeTruthy()
|
|
33
|
+
expect(view.queryByText('View more')).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('stays expanded when the parent re-renders with a new results array', () => {
|
|
37
|
+
const view = render(<ConversationResults results={buildResults(6)} onSelect={jest.fn()} />)
|
|
38
|
+
fireEvent.press(view.getByText('View more'))
|
|
39
|
+
|
|
40
|
+
view.rerender(<ConversationResults results={buildResults(6)} onSelect={jest.fn()} />)
|
|
41
|
+
|
|
42
|
+
expect(view.getByText('Conversation 6')).toBeTruthy()
|
|
43
|
+
expect(view.getByText('View less')).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('passes the pressed result to onSelect', () => {
|
|
47
|
+
const onSelect = jest.fn()
|
|
48
|
+
const results = buildResults(2)
|
|
49
|
+
const view = render(<ConversationResults results={results} onSelect={onSelect} />)
|
|
50
|
+
|
|
51
|
+
fireEvent.press(view.getByText('Conversation 2'))
|
|
52
|
+
|
|
53
|
+
expect(onSelect).toHaveBeenCalledWith(results[1])
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { fireEvent, render } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type { MessageSearchResultResource } from '../../../../types/resources/search_result'
|
|
4
|
+
import { formatDatePreview } from '../../../../utils/date'
|
|
5
|
+
import { defaultTheme } from '../../../../utils/theme'
|
|
6
|
+
import { MessageResultRow } from '../message_result_row'
|
|
7
|
+
|
|
8
|
+
const result: MessageSearchResultResource = {
|
|
9
|
+
id: '42',
|
|
10
|
+
type: 'message',
|
|
11
|
+
title: 'Worship Team',
|
|
12
|
+
url: '/conversations/7?message_id=42',
|
|
13
|
+
extra: {
|
|
14
|
+
conversationId: 7,
|
|
15
|
+
authorName: 'Ada Lovelace',
|
|
16
|
+
snippet: [
|
|
17
|
+
{ text: '…planning the ', match: false },
|
|
18
|
+
{ text: 'budget', match: true },
|
|
19
|
+
{ text: ' meeting for next week', match: false },
|
|
20
|
+
],
|
|
21
|
+
createdAt: '2026-03-04T15:30:00Z',
|
|
22
|
+
avatar: {
|
|
23
|
+
customAvatarType: null,
|
|
24
|
+
customAvatarKey: null,
|
|
25
|
+
customAvatarColor: null,
|
|
26
|
+
customAvatarImageUrl: null,
|
|
27
|
+
previewAvatarUrls: [],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const interactionColor = defaultTheme('light').colors.interaction
|
|
33
|
+
|
|
34
|
+
describe('MessageResultRow', () => {
|
|
35
|
+
it('renders the title, author, snippet, and date', () => {
|
|
36
|
+
const view = render(<MessageResultRow result={result} onPress={jest.fn()} />)
|
|
37
|
+
|
|
38
|
+
expect(view.getByText('Worship Team')).toBeTruthy()
|
|
39
|
+
expect(view.getByText('Ada Lovelace:', { exact: false })).toBeTruthy()
|
|
40
|
+
expect(view.getByText('…planning the ')).toBeTruthy()
|
|
41
|
+
expect(view.getByText('budget')).toBeTruthy()
|
|
42
|
+
expect(view.getByText(' meeting for next week')).toBeTruthy()
|
|
43
|
+
expect(view.getByText(formatDatePreview(result.extra.createdAt))).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('colors matched segments with the interaction color', () => {
|
|
47
|
+
const view = render(<MessageResultRow result={result} onPress={jest.fn()} />)
|
|
48
|
+
|
|
49
|
+
expect(view.getByText('budget')).toHaveStyle({ color: interactionColor })
|
|
50
|
+
expect(view.getByText('…planning the ')).not.toHaveStyle({ color: interactionColor })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('exposes the row as a button with a composed label and reports presses', () => {
|
|
54
|
+
const onPress = jest.fn()
|
|
55
|
+
const date = formatDatePreview(result.extra.createdAt)
|
|
56
|
+
const view = render(<MessageResultRow result={result} onPress={onPress} />)
|
|
57
|
+
|
|
58
|
+
const row = view.getByRole('button', {
|
|
59
|
+
name: `Worship Team, Ada Lovelace: …planning the budget meeting for next week, ${date}`,
|
|
60
|
+
})
|
|
61
|
+
fireEvent.press(row)
|
|
62
|
+
|
|
63
|
+
expect(onPress).toHaveBeenCalledWith(result)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { useAtFontScaleBreakpoint } from '../../../../hooks/use_at_font_scale_breakpoint'
|
|
5
|
+
import { ResultRow } from '../result_row'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../../../hooks/use_at_font_scale_breakpoint')
|
|
8
|
+
|
|
9
|
+
const mockUseAtFontScaleBreakpoint = jest.mocked(useAtFontScaleBreakpoint)
|
|
10
|
+
|
|
11
|
+
describe('ResultRow', () => {
|
|
12
|
+
it('lays children out in a row below the font scale breakpoint', () => {
|
|
13
|
+
mockUseAtFontScaleBreakpoint.mockReturnValue(false)
|
|
14
|
+
const view = render(
|
|
15
|
+
<ResultRow accessibilityLabel="Row" onPress={jest.fn()} alignItems="center">
|
|
16
|
+
<Text>child</Text>
|
|
17
|
+
</ResultRow>
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(view.getByRole('button')).toHaveStyle({ flexDirection: 'row', alignItems: 'center' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('stacks children at the font scale breakpoint', () => {
|
|
24
|
+
mockUseAtFontScaleBreakpoint.mockReturnValue(true)
|
|
25
|
+
const view = render(
|
|
26
|
+
<ResultRow accessibilityLabel="Row" onPress={jest.fn()} alignItems="center">
|
|
27
|
+
<Text>child</Text>
|
|
28
|
+
</ResultRow>
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(view.getByRole('button')).toHaveStyle({ flexDirection: 'column', alignItems: 'stretch' })
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
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>) => {
|
|
53
|
+
mockUseSearchResults.mockReturnValue(searchState(state))
|
|
54
|
+
return render(<SearchResultsList term="potluck" onSelect={jest.fn()} />)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('SearchResultsList', () => {
|
|
58
|
+
it('shows a spinner while pending', () => {
|
|
59
|
+
const view = renderList({ isPending: true })
|
|
60
|
+
|
|
61
|
+
expect(view.getByLabelText('Loading results')).toBeTruthy()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('shows an error banner when the search fails', () => {
|
|
65
|
+
const view = renderList({ isError: true })
|
|
66
|
+
|
|
67
|
+
expect(view.getByText("Couldn't search right now. Try again.")).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('shows the no-results state with the term when nothing matches', () => {
|
|
71
|
+
const view = renderList({})
|
|
72
|
+
|
|
73
|
+
expect(view.getByText(/Nothing matches “potluck\.”/)).toBeTruthy()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders both section headings when both types have results', () => {
|
|
77
|
+
const view = renderList({ results: [conversationResult('1'), messageResult('1')] })
|
|
78
|
+
|
|
79
|
+
expect(view.getByText('Conversations')).toBeTruthy()
|
|
80
|
+
expect(view.getByText('Messages')).toBeTruthy()
|
|
81
|
+
expect(view.getByText('Message 1')).toBeTruthy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders the failure text for a failed type instead of the no-results state', () => {
|
|
85
|
+
const view = renderList({ failedTypes: ['message'] })
|
|
86
|
+
|
|
87
|
+
expect(view.getByText('Couldn’t load messages.')).toBeTruthy()
|
|
88
|
+
expect(view.queryByText('No results')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps the no-results state hidden when only conversations match', () => {
|
|
92
|
+
const view = renderList({ results: [conversationResult('1')] })
|
|
93
|
+
|
|
94
|
+
expect(view.getByText('Conversations')).toBeTruthy()
|
|
95
|
+
expect(view.queryByText('No results')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('loads the next page once when the end is reached', () => {
|
|
99
|
+
const loadMore = jest.fn()
|
|
100
|
+
const view = renderList({ results: [messageResult('1')], hasNextPage: true, loadMore })
|
|
101
|
+
|
|
102
|
+
fireEvent(view.UNSAFE_getByType(FlatList), 'onEndReached')
|
|
103
|
+
|
|
104
|
+
expect(loadMore).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not load more while a page is already fetching', () => {
|
|
108
|
+
const loadMore = jest.fn()
|
|
109
|
+
const view = renderList({
|
|
110
|
+
results: [messageResult('1')],
|
|
111
|
+
hasNextPage: true,
|
|
112
|
+
isFetchingNextPage: true,
|
|
113
|
+
loadMore,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
fireEvent(view.UNSAFE_getByType(FlatList), 'onEndReached')
|
|
117
|
+
|
|
118
|
+
expect(view.getByLabelText('Loading more results')).toBeTruthy()
|
|
119
|
+
expect(loadMore).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('offers a retry after a paging failure', () => {
|
|
123
|
+
const loadMore = jest.fn()
|
|
124
|
+
const view = renderList({
|
|
125
|
+
results: [messageResult('1')],
|
|
126
|
+
hasNextPage: true,
|
|
127
|
+
isFetchNextPageError: true,
|
|
128
|
+
loadMore,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
fireEvent.press(view.getByText('Try again'))
|
|
132
|
+
|
|
133
|
+
expect(loadMore).toHaveBeenCalledTimes(1)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -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,39 @@
|
|
|
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) => 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 => (
|
|
35
|
+
<ConversationResultRow key={result.id} result={result} onPress={() => onSelect(result)} />
|
|
36
|
+
))}
|
|
37
|
+
</ResultsSection>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -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,147 @@
|
|
|
1
|
+
import React 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
|
+
interface SearchResultsListProps {
|
|
18
|
+
term: string
|
|
19
|
+
onSelect: (result: SearchResultResource) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
|
|
23
|
+
const {
|
|
24
|
+
results,
|
|
25
|
+
failedTypes,
|
|
26
|
+
isPending,
|
|
27
|
+
isError,
|
|
28
|
+
isFetchNextPageError,
|
|
29
|
+
loadMore,
|
|
30
|
+
hasNextPage,
|
|
31
|
+
isFetchingNextPage,
|
|
32
|
+
} = useSearchResults(term)
|
|
33
|
+
const conversationResults = results.filter(isConversationResult)
|
|
34
|
+
const messageResults = results.filter(isMessageResult)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<FlatList
|
|
38
|
+
data={messageResults}
|
|
39
|
+
keyExtractor={item => item.id}
|
|
40
|
+
keyboardShouldPersistTaps="handled"
|
|
41
|
+
keyboardDismissMode="on-drag"
|
|
42
|
+
contentContainerStyle={styles.content}
|
|
43
|
+
renderItem={({ item }) => <MessageResultRow result={item} onPress={onSelect} />}
|
|
44
|
+
ListHeaderComponent={
|
|
45
|
+
<>
|
|
46
|
+
{conversationResults.length > 0 && (
|
|
47
|
+
<ConversationResults results={conversationResults} onSelect={onSelect} />
|
|
48
|
+
)}
|
|
49
|
+
{failedTypes.includes('conversation') && <SectionFailure type="conversation" />}
|
|
50
|
+
{messageResults.length > 0 && <ResultsSection title="Messages" />}
|
|
51
|
+
</>
|
|
52
|
+
}
|
|
53
|
+
ListEmptyComponent={
|
|
54
|
+
<ListEmpty
|
|
55
|
+
term={term}
|
|
56
|
+
isError={isError}
|
|
57
|
+
isPending={isPending}
|
|
58
|
+
noResults={results.length === 0 && failedTypes.length === 0}
|
|
59
|
+
/>
|
|
60
|
+
}
|
|
61
|
+
ListFooterComponent={
|
|
62
|
+
<>
|
|
63
|
+
{isFetchingNextPage && (
|
|
64
|
+
<View accessibilityLabel="Loading more results" style={styles.loading}>
|
|
65
|
+
<Spinner />
|
|
66
|
+
</View>
|
|
67
|
+
)}
|
|
68
|
+
{isFetchNextPageError && !isFetchingNextPage && (
|
|
69
|
+
<TextButton onPress={loadMore} style={styles.retry}>
|
|
70
|
+
Try again
|
|
71
|
+
</TextButton>
|
|
72
|
+
)}
|
|
73
|
+
{failedTypes.includes('message') && <SectionFailure type="message" />}
|
|
74
|
+
</>
|
|
75
|
+
}
|
|
76
|
+
onEndReached={() => {
|
|
77
|
+
if (hasNextPage && !isFetchingNextPage && !isFetchNextPageError) loadMore()
|
|
78
|
+
}}
|
|
79
|
+
onEndReachedThreshold={0.5}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ListEmptyProps {
|
|
85
|
+
term: string
|
|
86
|
+
isError: boolean
|
|
87
|
+
isPending: boolean
|
|
88
|
+
noResults: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ListEmpty({ term, isError, isPending, noResults }: ListEmptyProps) {
|
|
92
|
+
if (isError) {
|
|
93
|
+
return <Banner appearance="error" description={SEARCH_ERROR_MESSAGE} style={styles.banner} />
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isPending) {
|
|
97
|
+
return (
|
|
98
|
+
<View accessibilityLabel="Loading results" style={styles.loading}>
|
|
99
|
+
<Spinner />
|
|
100
|
+
</View>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (noResults) return <NoResultsBlankState term={term} />
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function SectionFailure({ type }: { type: SearchResultResource['type'] }) {
|
|
110
|
+
return (
|
|
111
|
+
<Text accessibilityRole="alert" variant="secondary" style={styles.sectionFailure}>
|
|
112
|
+
Couldn’t load {type}s.
|
|
113
|
+
</Text>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isConversationResult(
|
|
118
|
+
result: SearchResultResource
|
|
119
|
+
): result is ConversationSearchResultResource {
|
|
120
|
+
return result.type === 'conversation'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isMessageResult(result: SearchResultResource): result is MessageSearchResultResource {
|
|
124
|
+
return result.type === 'message'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const styles = StyleSheet.create({
|
|
128
|
+
content: {
|
|
129
|
+
flexGrow: 1,
|
|
130
|
+
},
|
|
131
|
+
banner: {
|
|
132
|
+
margin: 16,
|
|
133
|
+
},
|
|
134
|
+
loading: {
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
paddingVertical: 16,
|
|
137
|
+
},
|
|
138
|
+
retry: {
|
|
139
|
+
alignSelf: 'center',
|
|
140
|
+
paddingVertical: 16,
|
|
141
|
+
},
|
|
142
|
+
sectionFailure: {
|
|
143
|
+
fontSize: 14,
|
|
144
|
+
paddingHorizontal: 16,
|
|
145
|
+
paddingVertical: 8,
|
|
146
|
+
},
|
|
147
|
+
})
|