@planningcenter/chat-react-native 4.3.0-rc.9 → 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.
Files changed (75) hide show
  1. package/build/components/conversation/message_form.js +1 -1
  2. package/build/components/conversation/message_form.js.map +1 -1
  3. package/build/hooks/use_debounced_search_term.d.ts +2 -0
  4. package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
  5. package/build/hooks/use_debounced_search_term.js +40 -0
  6. package/build/hooks/use_debounced_search_term.js.map +1 -0
  7. package/build/hooks/use_search_results.d.ts +19 -0
  8. package/build/hooks/use_search_results.d.ts.map +1 -0
  9. package/build/hooks/use_search_results.js +153 -0
  10. package/build/hooks/use_search_results.js.map +1 -0
  11. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
  12. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
  13. package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
  14. package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
  15. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  16. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
  17. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  18. package/build/screens/search/components/conversation_result_row.d.ts +8 -0
  19. package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
  20. package/build/screens/search/components/conversation_result_row.js +80 -0
  21. package/build/screens/search/components/conversation_result_row.js.map +1 -0
  22. package/build/screens/search/components/conversation_results.d.ts +8 -0
  23. package/build/screens/search/components/conversation_results.d.ts.map +1 -0
  24. package/build/screens/search/components/conversation_results.js +77 -0
  25. package/build/screens/search/components/conversation_results.js.map +1 -0
  26. package/build/screens/search/components/message_result_row.d.ts +8 -0
  27. package/build/screens/search/components/message_result_row.d.ts.map +1 -0
  28. package/build/screens/search/components/message_result_row.js +152 -0
  29. package/build/screens/search/components/message_result_row.js.map +1 -0
  30. package/build/screens/search/components/result_row.d.ts +10 -0
  31. package/build/screens/search/components/result_row.d.ts.map +1 -0
  32. package/build/screens/search/components/result_row.js +102 -0
  33. package/build/screens/search/components/result_row.js.map +1 -0
  34. package/build/screens/search/components/results_section.d.ts +9 -0
  35. package/build/screens/search/components/results_section.d.ts.map +1 -0
  36. package/build/screens/search/components/results_section.js +51 -0
  37. package/build/screens/search/components/results_section.js.map +1 -0
  38. package/build/screens/search/components/search_results_list.d.ts +8 -0
  39. package/build/screens/search/components/search_results_list.d.ts.map +1 -0
  40. package/build/screens/search/components/search_results_list.js +314 -0
  41. package/build/screens/search/components/search_results_list.js.map +1 -0
  42. package/build/screens/search/components/snippet.d.ts +8 -0
  43. package/build/screens/search/components/snippet.d.ts.map +1 -0
  44. package/build/screens/search/components/snippet.js +68 -0
  45. package/build/screens/search/components/snippet.js.map +1 -0
  46. package/build/screens/search/search_screen.d.ts.map +1 -1
  47. package/build/screens/search/search_screen.js +18 -9
  48. package/build/screens/search/search_screen.js.map +1 -1
  49. package/build/utils/client/request_helpers.js +1 -1
  50. package/build/utils/client/request_helpers.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/__tests__/utils/client.ts +3 -4
  53. package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
  54. package/src/components/conversation/message_form.tsx +1 -1
  55. package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
  56. package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
  57. package/src/hooks/use_debounced_search_term.ts +20 -0
  58. package/src/hooks/use_search_results.ts +78 -0
  59. package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
  60. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
  61. package/src/screens/search/__tests__/search_screen.test.tsx +64 -0
  62. package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
  63. package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
  64. package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
  65. package/src/screens/search/components/__tests__/search_results_list.test.tsx +135 -0
  66. package/src/screens/search/components/conversation_result_row.tsx +36 -0
  67. package/src/screens/search/components/conversation_results.tsx +39 -0
  68. package/src/screens/search/components/message_result_row.tsx +48 -0
  69. package/src/screens/search/components/result_row.tsx +58 -0
  70. package/src/screens/search/components/results_section.tsx +34 -0
  71. package/src/screens/search/components/search_results_list.tsx +147 -0
  72. package/src/screens/search/components/snippet.tsx +37 -0
  73. package/src/screens/search/search_screen.tsx +9 -1
  74. package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
  75. package/src/utils/client/request_helpers.ts +1 -1
@@ -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,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
+ })