@planningcenter/chat-react-native 4.3.0-rc.8 → 4.3.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/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_blank_states.d.ts +7 -0
- package/build/screens/search/components/search_blank_states.d.ts.map +1 -0
- package/build/screens/search/components/search_blank_states.js +72 -0
- package/build/screens/search/components/search_blank_states.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 +37 -12
- 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__/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/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_blank_states.test.tsx +20 -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_blank_states.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 +10 -0
- package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
- package/src/utils/client/request_helpers.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PlatformPressable } from '@react-navigation/elements'
|
|
2
2
|
import { StackActions, useNavigation } from '@react-navigation/native'
|
|
3
|
-
import { RefObject,
|
|
3
|
+
import { RefObject, useRef, useState } from 'react'
|
|
4
4
|
import { FlatList, Pressable, StyleSheet, TextInput, View } from 'react-native'
|
|
5
5
|
import { Avatar, Badge, Banner, Icon, KeyboardView, Spinner, Text } from '../../components'
|
|
6
6
|
import { ActionButton } from '../../components/display/action_button'
|
|
@@ -11,6 +11,7 @@ import { useDirectMessageAffiliations } from '../../hooks/direct_messages/use_di
|
|
|
11
11
|
import { useDirectMessageCandidates } from '../../hooks/direct_messages/use_direct_message_candidates'
|
|
12
12
|
import { useDirectMessageConversationValidate } from '../../hooks/direct_messages/use_direct_message_conversation_validate'
|
|
13
13
|
import { useFindOrCreateDirectMessageConversation } from '../../hooks/direct_messages/use_find_or_create_direct_message_conversation'
|
|
14
|
+
import { useDebouncedSearchTerm } from '../../hooks/use_debounced_search_term'
|
|
14
15
|
import { availableFeatures, useFeatures } from '../../hooks/use_features'
|
|
15
16
|
import { DirectMessageCandidateResource } from '../../types/resources/direct_message_candidate'
|
|
16
17
|
import { ResultsHeading } from './components/results_heading'
|
|
@@ -26,7 +27,7 @@ export function ConversationSelectDirectMessageRecipientsScreen({
|
|
|
26
27
|
const navigation = useNavigation()
|
|
27
28
|
const { featureEnabled } = useFeatures()
|
|
28
29
|
const [searchTerm, setSearchTerm] = useState('')
|
|
29
|
-
const
|
|
30
|
+
const debouncedTerm = useDebouncedSearchTerm(searchTerm)
|
|
30
31
|
const [selectedPeople, setSelectedPeople] = useState<Map<string, DirectMessageCandidateResource>>(
|
|
31
32
|
() =>
|
|
32
33
|
new Map(
|
|
@@ -45,16 +46,10 @@ export function ConversationSelectDirectMessageRecipientsScreen({
|
|
|
45
46
|
)
|
|
46
47
|
const searchInputRef = useRef<TextInput>(null)
|
|
47
48
|
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 200)
|
|
50
|
-
return () => clearTimeout(timer)
|
|
51
|
-
}, [searchTerm])
|
|
52
|
-
|
|
53
49
|
const selectPerson = (person: DirectMessageCandidateResource) => {
|
|
54
50
|
if (selectedPeople.size >= MAX_RECIPIENTS) return
|
|
55
51
|
setSelectedPeople(prev => new Map(prev).set(person.id, person))
|
|
56
52
|
setSearchTerm('')
|
|
57
|
-
setDebouncedTerm('')
|
|
58
53
|
// Controlled TextInput can retain stale native text on iOS when the value
|
|
59
54
|
// is cleared while it stays focused (keyboardShouldPersistTaps keeps it
|
|
60
55
|
// focused through the tap) — clear() forces the native view to sync. Gated
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { act, fireEvent, render } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type { useSearchResults } from '../../../hooks/use_search_results'
|
|
4
|
+
import { SearchScreen } from '../search_screen'
|
|
5
|
+
|
|
6
|
+
const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [string]>()
|
|
7
|
+
jest.mock('../../../hooks/use_search_results', () => ({
|
|
8
|
+
useSearchResults: (term: string) => mockUseSearchResults(term),
|
|
9
|
+
}))
|
|
10
|
+
jest.mock('../../../components/display/keyboard_view', () => ({
|
|
11
|
+
...jest.requireActual('../../../components/display/keyboard_view'),
|
|
12
|
+
KeyboardView: ({ children }: { children: React.ReactNode }) => children,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const inputLabel = 'Search titles and messages'
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.useFakeTimers()
|
|
19
|
+
mockUseSearchResults.mockReset().mockReturnValue({
|
|
20
|
+
results: [],
|
|
21
|
+
failedTypes: [],
|
|
22
|
+
isPending: true,
|
|
23
|
+
isError: false,
|
|
24
|
+
isFetchNextPageError: false,
|
|
25
|
+
loadMore: jest.fn(),
|
|
26
|
+
hasNextPage: false,
|
|
27
|
+
isFetchingNextPage: false,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => jest.useRealTimers())
|
|
32
|
+
|
|
33
|
+
describe('SearchScreen', () => {
|
|
34
|
+
it('searches once with the trimmed term after typing settles', () => {
|
|
35
|
+
const view = render(<SearchScreen />)
|
|
36
|
+
const input = view.getByLabelText(inputLabel)
|
|
37
|
+
|
|
38
|
+
fireEvent.changeText(input, 'bud')
|
|
39
|
+
fireEvent.changeText(input, ' budget ')
|
|
40
|
+
|
|
41
|
+
expect(view.getByText('Search chat')).toBeTruthy()
|
|
42
|
+
expect(mockUseSearchResults).not.toHaveBeenCalled()
|
|
43
|
+
|
|
44
|
+
act(() => jest.advanceTimersByTime(200))
|
|
45
|
+
|
|
46
|
+
const searchedTerms = new Set(mockUseSearchResults.mock.calls.map(([term]) => term))
|
|
47
|
+
expect([...searchedTerms]).toEqual(['budget'])
|
|
48
|
+
expect(view.queryByText('Search chat')).toBeNull()
|
|
49
|
+
expect(view.getByLabelText('Loading results')).toBeTruthy()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns to the prompt as soon as the term is cleared', () => {
|
|
53
|
+
const view = render(<SearchScreen />)
|
|
54
|
+
const input = view.getByLabelText(inputLabel)
|
|
55
|
+
|
|
56
|
+
fireEvent.changeText(input, 'budget')
|
|
57
|
+
act(() => jest.advanceTimersByTime(200))
|
|
58
|
+
expect(view.queryByText('Search chat')).toBeNull()
|
|
59
|
+
|
|
60
|
+
fireEvent.changeText(input, ' ')
|
|
61
|
+
|
|
62
|
+
expect(view.getByText('Search chat')).toBeTruthy()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -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,20 @@
|
|
|
1
|
+
import { render } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { NoResultsBlankState, SearchPromptBlankState } from '../search_blank_states'
|
|
4
|
+
|
|
5
|
+
describe('SearchPromptBlankState', () => {
|
|
6
|
+
it('renders the prompt heading', () => {
|
|
7
|
+
const view = render(<SearchPromptBlankState />)
|
|
8
|
+
|
|
9
|
+
expect(view.getByText('Search chat')).toBeTruthy()
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('NoResultsBlankState', () => {
|
|
14
|
+
it('renders the heading and the searched term', () => {
|
|
15
|
+
const view = render(<NoResultsBlankState term="budget" />)
|
|
16
|
+
|
|
17
|
+
expect(view.getByText('No results')).toBeTruthy()
|
|
18
|
+
expect(view.getByText(/Nothing matches “budget\.”/)).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -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,34 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import BlankState from '../../../components/primitive/blank_state_primitive'
|
|
3
|
+
|
|
4
|
+
export function SearchPromptBlankState() {
|
|
5
|
+
return (
|
|
6
|
+
<BlankState.Root>
|
|
7
|
+
<BlankState.Imagery name="general.search" />
|
|
8
|
+
<BlankState.Content>
|
|
9
|
+
<BlankState.Heading>Search chat</BlankState.Heading>
|
|
10
|
+
<BlankState.Text>
|
|
11
|
+
Find conversations by title, or search for words from a message.
|
|
12
|
+
</BlankState.Text>
|
|
13
|
+
</BlankState.Content>
|
|
14
|
+
</BlankState.Root>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface NoResultsBlankStateProps {
|
|
19
|
+
term: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function NoResultsBlankState({ term }: NoResultsBlankStateProps) {
|
|
23
|
+
return (
|
|
24
|
+
<BlankState.Root>
|
|
25
|
+
<BlankState.Imagery name="general.search" />
|
|
26
|
+
<BlankState.Content>
|
|
27
|
+
<BlankState.Heading>No results</BlankState.Heading>
|
|
28
|
+
<BlankState.Text>
|
|
29
|
+
Nothing matches “{term}.” Try a conversation title, or a word from a message.
|
|
30
|
+
</BlankState.Text>
|
|
31
|
+
</BlankState.Content>
|
|
32
|
+
</BlankState.Root>
|
|
33
|
+
)
|
|
34
|
+
}
|