@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,136 @@
|
|
|
1
|
+
import { HeaderHeightContext } from '@react-navigation/elements'
|
|
2
|
+
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
|
3
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import { fireEvent, render, waitFor } from '@testing-library/react-native'
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
|
7
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
8
|
+
import {
|
|
9
|
+
MessageActionsScreen,
|
|
10
|
+
MessageActionsScreenProps,
|
|
11
|
+
} from '../../screens/message_actions_screen'
|
|
12
|
+
import { MessageResource } from '../../types'
|
|
13
|
+
import {
|
|
14
|
+
AudioAdapter,
|
|
15
|
+
ChatAdapters,
|
|
16
|
+
ClipboardAdapter,
|
|
17
|
+
HapticAdapter,
|
|
18
|
+
ImagePickerAdapter,
|
|
19
|
+
VideoAdapter,
|
|
20
|
+
} from '../../utils/native_adapters'
|
|
21
|
+
|
|
22
|
+
const mockMessage: MessageResource = {
|
|
23
|
+
type: 'Message',
|
|
24
|
+
id: '1',
|
|
25
|
+
text: 'Copy me',
|
|
26
|
+
html: '<p>Copy me</p>',
|
|
27
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
28
|
+
deletedAt: null,
|
|
29
|
+
textEditedAt: null,
|
|
30
|
+
mine: false,
|
|
31
|
+
attachments: [],
|
|
32
|
+
author: { id: 1, type: 'Person', name: 'Author', avatar: '' },
|
|
33
|
+
reactionCounts: [],
|
|
34
|
+
replyCount: 0,
|
|
35
|
+
replyRootId: null,
|
|
36
|
+
messageType: 'user',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let mockCurrentMessage = mockMessage
|
|
40
|
+
|
|
41
|
+
const mockGoBack = jest.fn()
|
|
42
|
+
jest.mock('@react-navigation/native', () => ({
|
|
43
|
+
...jest.requireActual('@react-navigation/native'),
|
|
44
|
+
useNavigation: () => ({ navigate: jest.fn(), dispatch: jest.fn(), goBack: mockGoBack }),
|
|
45
|
+
}))
|
|
46
|
+
jest.mock('../../hooks/use_api_client', () => ({ useApiClient: () => ({}) }))
|
|
47
|
+
jest.mock('../../hooks/use_features', () => ({
|
|
48
|
+
...jest.requireActual('../../hooks/use_features'),
|
|
49
|
+
useFeatures: () => ({ features: [], featureEnabled: () => false }),
|
|
50
|
+
}))
|
|
51
|
+
jest.mock('../../hooks/use_message_reaction_toggle', () => ({
|
|
52
|
+
useMessageReactionToggle: () => ({ handleReactionToggle: jest.fn(), isPending: false }),
|
|
53
|
+
}))
|
|
54
|
+
jest.mock('../../hooks/use_conversation_messages', () => ({
|
|
55
|
+
useConversationMessages: () => ({ messages: [mockCurrentMessage], refetch: jest.fn() }),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const setStringAsync = jest.fn(async (_: string) => {})
|
|
59
|
+
const notificationSuccess = jest.fn()
|
|
60
|
+
|
|
61
|
+
const safeAreaMetrics = {
|
|
62
|
+
frame: { x: 0, y: 0, width: 390, height: 844 },
|
|
63
|
+
insets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const route = {
|
|
67
|
+
key: 'MessageActions',
|
|
68
|
+
name: 'MessageActions',
|
|
69
|
+
params: { conversation_id: 1, message_id: mockMessage.id },
|
|
70
|
+
} as MessageActionsScreenProps['route']
|
|
71
|
+
|
|
72
|
+
const renderScreen = () =>
|
|
73
|
+
render(
|
|
74
|
+
<QueryClientProvider client={buildTestQueryClient()}>
|
|
75
|
+
<SafeAreaProvider initialMetrics={safeAreaMetrics}>
|
|
76
|
+
<ThemeProvider value={DefaultTheme}>
|
|
77
|
+
<HeaderHeightContext.Provider value={0}>
|
|
78
|
+
<MessageActionsScreen route={route} />
|
|
79
|
+
</HeaderHeightContext.Provider>
|
|
80
|
+
</ThemeProvider>
|
|
81
|
+
</SafeAreaProvider>
|
|
82
|
+
</QueryClientProvider>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
describe('MessageActionsScreen', () => {
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
ChatAdapters.configure({
|
|
88
|
+
clipboard: new ClipboardAdapter({ getStringAsync: async () => '', setStringAsync }),
|
|
89
|
+
audio: new AudioAdapter({ useAudio: () => ({}) as any }),
|
|
90
|
+
video: new VideoAdapter({ Player: React.forwardRef(() => null) }),
|
|
91
|
+
imagePicker: new ImagePickerAdapter({
|
|
92
|
+
openCameraAsync: async () => ({ canceled: true, assets: null }),
|
|
93
|
+
openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
|
|
94
|
+
}),
|
|
95
|
+
haptic: new HapticAdapter({
|
|
96
|
+
impactLight: jest.fn(),
|
|
97
|
+
impactMedium: jest.fn(),
|
|
98
|
+
impactHeavy: jest.fn(),
|
|
99
|
+
rigid: jest.fn(),
|
|
100
|
+
soft: jest.fn(),
|
|
101
|
+
notificationSuccess,
|
|
102
|
+
notificationWarning: jest.fn(),
|
|
103
|
+
notificationError: jest.fn(),
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
jest.clearAllMocks()
|
|
110
|
+
mockCurrentMessage = mockMessage
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('copies the message text and gives success haptic feedback before closing', async () => {
|
|
114
|
+
const { getByText } = renderScreen()
|
|
115
|
+
|
|
116
|
+
fireEvent.press(getByText('Copy text'))
|
|
117
|
+
|
|
118
|
+
expect(setStringAsync).toHaveBeenCalledWith('Copy me')
|
|
119
|
+
await waitFor(() => expect(mockGoBack).toHaveBeenCalledTimes(1))
|
|
120
|
+
expect(notificationSuccess).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(notificationSuccess.mock.invocationCallOrder[0]).toBeLessThan(
|
|
122
|
+
mockGoBack.mock.invocationCallOrder[0]
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('leaves the clipboard alone and gives no haptic when there is nothing to copy', async () => {
|
|
127
|
+
mockCurrentMessage = { ...mockMessage, text: null }
|
|
128
|
+
const { getByText } = renderScreen()
|
|
129
|
+
|
|
130
|
+
fireEvent.press(getByText('Copy text'))
|
|
131
|
+
|
|
132
|
+
await waitFor(() => expect(mockGoBack).toHaveBeenCalledTimes(1))
|
|
133
|
+
expect(setStringAsync).not.toHaveBeenCalled()
|
|
134
|
+
expect(notificationSuccess).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -59,8 +59,7 @@ describe('get', () => {
|
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
62
|
-
|
|
63
|
-
APP_BASE_URL + '/records?fields[Record]=id&per_page=100&where[id]=1',
|
|
62
|
+
APP_BASE_URL + '/records?fields%5BRecord%5D=id&per_page=100&where%5Bid%5D=1',
|
|
64
63
|
expect.any(Object)
|
|
65
64
|
)
|
|
66
65
|
})
|
|
@@ -75,7 +74,7 @@ describe('get', () => {
|
|
|
75
74
|
})
|
|
76
75
|
|
|
77
76
|
it('walks pagination when walk = true', async () => {
|
|
78
|
-
const expectUrl = APP_BASE_URL + '/records?fields
|
|
77
|
+
const expectUrl = APP_BASE_URL + '/records?fields%5BRecord%5D=id&per_page=1'
|
|
79
78
|
const nextUrl = expectUrl + '&offset=1'
|
|
80
79
|
|
|
81
80
|
MockServer.get(url, { ...DefaultFixtures.records(), links: { next: nextUrl } }, 200, {
|
|
@@ -92,7 +91,7 @@ describe('get', () => {
|
|
|
92
91
|
})
|
|
93
92
|
|
|
94
93
|
it('does not overwrite params in `url` when walking', async () => {
|
|
95
|
-
const expectUrl = APP_BASE_URL + '/records?fields
|
|
94
|
+
const expectUrl = APP_BASE_URL + '/records?fields%5BRecord%5D=id'
|
|
96
95
|
const nextUrl = expectUrl + '&offset=1&per_page=1'
|
|
97
96
|
MockServer.get(url, { ...DefaultFixtures.records(), links: { next: nextUrl } }, 200, {
|
|
98
97
|
once: true,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
|
2
|
+
import { QueryClientProvider, notifyManager } from '@tanstack/react-query'
|
|
3
|
+
import { act, fireEvent, render, screen } from '@testing-library/react-native'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { chatQueryClient } from '../../../contexts/api_provider'
|
|
6
|
+
import type { ConversationResource } from '../../../types'
|
|
7
|
+
import { MessageForm } from '../message_form'
|
|
8
|
+
|
|
9
|
+
jest.mock('../../../contexts/api_provider', () => {
|
|
10
|
+
const { buildTestQueryClient } = require('../../../__utils__/query_client')
|
|
11
|
+
return { chatQueryClient: buildTestQueryClient() }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const mockPost = jest.fn()
|
|
15
|
+
jest.mock('../../../hooks/use_api_client', () => ({
|
|
16
|
+
useApiClient: () => ({ chat: { post: mockPost, patch: jest.fn() } }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('../../../hooks/use_current_person', () => ({
|
|
20
|
+
useCurrentPerson: () => ({ type: 'Person', id: 1, name: 'Me', avatar: null }),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
jest.mock('../../../hooks/use_message_draft', () => ({
|
|
24
|
+
useMessageDraft: () => ({ draft: null, saveDraft: jest.fn(), clearDraft: jest.fn() }),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('../../../hooks/use_attachment_uploader', () => ({
|
|
28
|
+
useAttachmentUploader: () => ({
|
|
29
|
+
attachments: [],
|
|
30
|
+
attachmentIds: [],
|
|
31
|
+
pendingUploads: false,
|
|
32
|
+
flaggedAttachmentCount: 0,
|
|
33
|
+
reset: jest.fn(),
|
|
34
|
+
}),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
jest.mock('../../../hooks/use_broadcast_typing_status', () => ({
|
|
38
|
+
useBroadcastTypingStatus: () => jest.fn(),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
jest.mock('@react-navigation/native', () => ({
|
|
42
|
+
...jest.requireActual('@react-navigation/native'),
|
|
43
|
+
useNavigation: () => ({ navigate: jest.fn(), setParams: jest.fn() }),
|
|
44
|
+
useRoute: () => ({ params: { conversation_id: 7 } }),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
notifyManager.setNotifyFunction(fn => act(fn))
|
|
48
|
+
|
|
49
|
+
const conversation = { id: 7, disabled: false } as unknown as ConversationResource
|
|
50
|
+
|
|
51
|
+
describe('MessageForm', () => {
|
|
52
|
+
let finishSend: (value: unknown) => void
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
chatQueryClient.clear()
|
|
56
|
+
mockPost.mockReset().mockReturnValue(new Promise(resolve => (finishSend = resolve)))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await act(async () => finishSend({ data: { type: 'Message', id: 'sent', mine: true } }))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps the input editable while a sent message is in flight', async () => {
|
|
64
|
+
render(
|
|
65
|
+
<QueryClientProvider client={chatQueryClient}>
|
|
66
|
+
<ThemeProvider value={DefaultTheme}>
|
|
67
|
+
<MessageForm.Root conversation={conversation}>
|
|
68
|
+
<MessageForm.TextInput />
|
|
69
|
+
<MessageForm.SubmitButton />
|
|
70
|
+
</MessageForm.Root>
|
|
71
|
+
</ThemeProvider>
|
|
72
|
+
</QueryClientProvider>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
fireEvent.changeText(screen.getByTestId('message-composer-input'), 'hello')
|
|
76
|
+
await act(async () => {
|
|
77
|
+
fireEvent.press(screen.getByLabelText('Send message'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(mockPost).toHaveBeenCalled()
|
|
81
|
+
expect(screen.getByTestId('message-composer-input').props.editable).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-native'
|
|
2
|
+
import { useDebouncedSearchTerm } from '../use_debounced_search_term'
|
|
3
|
+
|
|
4
|
+
beforeEach(() => jest.useFakeTimers())
|
|
5
|
+
afterEach(() => jest.useRealTimers())
|
|
6
|
+
|
|
7
|
+
describe('useDebouncedSearchTerm', () => {
|
|
8
|
+
it('settles on the trimmed term once typing pauses', () => {
|
|
9
|
+
const { result, rerender } = renderHook(
|
|
10
|
+
({ term }: { term: string }) => useDebouncedSearchTerm(term),
|
|
11
|
+
{
|
|
12
|
+
initialProps: { term: 'bud' },
|
|
13
|
+
}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
rerender({ term: ' budget ' })
|
|
17
|
+
act(() => jest.advanceTimersByTime(199))
|
|
18
|
+
expect(result.current).toBe('')
|
|
19
|
+
|
|
20
|
+
act(() => jest.advanceTimersByTime(1))
|
|
21
|
+
expect(result.current).toBe('budget')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('clears immediately when the term empties', () => {
|
|
25
|
+
const { result, rerender } = renderHook(
|
|
26
|
+
({ term }: { term: string }) => useDebouncedSearchTerm(term),
|
|
27
|
+
{
|
|
28
|
+
initialProps: { term: 'budget' },
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
act(() => jest.advanceTimersByTime(200))
|
|
32
|
+
expect(result.current).toBe('budget')
|
|
33
|
+
|
|
34
|
+
rerender({ term: ' ' })
|
|
35
|
+
|
|
36
|
+
expect(result.current).toBe('')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
+
import type { SearchResultResource } from '../../types/resources/search_result'
|
|
6
|
+
import { NO_FAILED_TYPES, useSearchResults } from '../use_search_results'
|
|
7
|
+
|
|
8
|
+
const mockGet = jest.fn()
|
|
9
|
+
jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: { get: mockGet } }) }))
|
|
10
|
+
|
|
11
|
+
const result = (type: SearchResultResource['type'], id: string) =>
|
|
12
|
+
({ type, id, title: `${type} ${id}`, url: `/${type}s/${id}` }) as SearchResultResource
|
|
13
|
+
|
|
14
|
+
const page = (data: SearchResultResource[], meta: Record<string, unknown> = {}) => ({
|
|
15
|
+
data,
|
|
16
|
+
links: {},
|
|
17
|
+
meta: { count: data.length, totalCount: data.length, ...meta },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
21
|
+
<QueryClientProvider client={buildTestQueryClient()}>{children}</QueryClientProvider>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
beforeEach(() => mockGet.mockReset())
|
|
25
|
+
|
|
26
|
+
describe('useSearchResults', () => {
|
|
27
|
+
it('flattens pages into one list without repeating a result', async () => {
|
|
28
|
+
mockGet
|
|
29
|
+
.mockResolvedValueOnce(
|
|
30
|
+
page([result('conversation', '1'), result('message', '1')], { next: { offset: '20' } })
|
|
31
|
+
)
|
|
32
|
+
.mockResolvedValueOnce(page([result('message', '1'), result('message', '2')]))
|
|
33
|
+
|
|
34
|
+
const { result: hook } = renderHook(() => useSearchResults('potluck'), { wrapper })
|
|
35
|
+
await waitFor(() => expect(hook.current.hasNextPage).toBe(true))
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
hook.current.loadMore()
|
|
39
|
+
})
|
|
40
|
+
await waitFor(() => expect(hook.current.hasNextPage).toBe(false))
|
|
41
|
+
|
|
42
|
+
expect(hook.current.results.map(({ type, id }) => `${type}:${id}`)).toEqual([
|
|
43
|
+
'conversation:1',
|
|
44
|
+
'message:1',
|
|
45
|
+
'message:2',
|
|
46
|
+
])
|
|
47
|
+
expect(mockGet).toHaveBeenLastCalledWith({
|
|
48
|
+
url: '/search_results',
|
|
49
|
+
data: expect.objectContaining({ q: 'potluck', offset: 20 }),
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reads failedTypes from the first page', async () => {
|
|
54
|
+
mockGet
|
|
55
|
+
.mockResolvedValueOnce(page([], { failedTypes: ['message'], next: { offset: '20' } }))
|
|
56
|
+
.mockResolvedValueOnce(page([], { failedTypes: ['conversation'] }))
|
|
57
|
+
|
|
58
|
+
const { result: hook } = renderHook(() => useSearchResults('potluck'), { wrapper })
|
|
59
|
+
await waitFor(() => expect(hook.current.hasNextPage).toBe(true))
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
hook.current.loadMore()
|
|
63
|
+
})
|
|
64
|
+
await waitFor(() => expect(hook.current.hasNextPage).toBe(false))
|
|
65
|
+
|
|
66
|
+
expect(hook.current.failedTypes).toEqual(['message'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('makes no request for an empty or whitespace-only term', () => {
|
|
70
|
+
const { result: hook } = renderHook(() => useSearchResults(' '), { wrapper })
|
|
71
|
+
|
|
72
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
73
|
+
expect(hook.current.results).toEqual([])
|
|
74
|
+
expect(hook.current.failedTypes).toBe(NO_FAILED_TYPES)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('searches with the trimmed term', async () => {
|
|
78
|
+
mockGet.mockResolvedValueOnce(page([]))
|
|
79
|
+
|
|
80
|
+
const { result: hook } = renderHook(() => useSearchResults(' potluck '), { wrapper })
|
|
81
|
+
await waitFor(() => expect(hook.current.isPending).toBe(false))
|
|
82
|
+
|
|
83
|
+
expect(mockGet).toHaveBeenCalledWith({
|
|
84
|
+
url: '/search_results',
|
|
85
|
+
data: expect.objectContaining({ q: 'potluck' }),
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DELAY_MS = 200
|
|
4
|
+
|
|
5
|
+
export function useDebouncedSearchTerm(term: string, delayMs = DEFAULT_DELAY_MS) {
|
|
6
|
+
const [debouncedTerm, setDebouncedTerm] = useState('')
|
|
7
|
+
const trimmedTerm = term.trim()
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (trimmedTerm.length === 0) {
|
|
11
|
+
setDebouncedTerm('')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const timer = setTimeout(() => setDebouncedTerm(trimmedTerm), delayMs)
|
|
16
|
+
return () => clearTimeout(timer)
|
|
17
|
+
}, [trimmedTerm, delayMs])
|
|
18
|
+
|
|
19
|
+
return debouncedTerm
|
|
20
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useCallback, useMemo } from 'react'
|
|
3
|
+
import type { ApiCollection } from '../types'
|
|
4
|
+
import type { SearchResultResource } from '../types/resources/search_result'
|
|
5
|
+
import {
|
|
6
|
+
getSearchResultsQueryKey,
|
|
7
|
+
getSearchResultsRequestArgs,
|
|
8
|
+
} from '../utils/request/get_search_results'
|
|
9
|
+
import { ONE_MINUTE } from '../utils/time'
|
|
10
|
+
import { useApiClient } from './use_api_client'
|
|
11
|
+
|
|
12
|
+
export type SearchResultsCollection = ApiCollection<SearchResultResource> & {
|
|
13
|
+
meta: { failedTypes?: SearchResultResource['type'][] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type SearchResultsPageParam = { offset?: number }
|
|
17
|
+
|
|
18
|
+
export const NO_FAILED_TYPES: SearchResultResource['type'][] = []
|
|
19
|
+
|
|
20
|
+
export function useSearchResults(term: string) {
|
|
21
|
+
const apiClient = useApiClient()
|
|
22
|
+
const trimmedTerm = term.trim()
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
data,
|
|
26
|
+
isPending,
|
|
27
|
+
isError,
|
|
28
|
+
isFetchNextPageError,
|
|
29
|
+
fetchNextPage,
|
|
30
|
+
hasNextPage,
|
|
31
|
+
isFetchingNextPage,
|
|
32
|
+
} = useInfiniteQuery({
|
|
33
|
+
queryKey: getSearchResultsQueryKey(trimmedTerm),
|
|
34
|
+
queryFn: ({ pageParam }) => {
|
|
35
|
+
const requestArgs = getSearchResultsRequestArgs(trimmedTerm)
|
|
36
|
+
return apiClient.chat.get<SearchResultsCollection>({
|
|
37
|
+
url: requestArgs.url,
|
|
38
|
+
data: { ...requestArgs.data, ...pageParam },
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
initialPageParam: {} as SearchResultsPageParam,
|
|
42
|
+
getNextPageParam: lastPage => nextPageParam(lastPage.meta.next?.offset),
|
|
43
|
+
enabled: trimmedTerm.length >= 1,
|
|
44
|
+
staleTime: ONE_MINUTE,
|
|
45
|
+
refetchOnWindowFocus: false,
|
|
46
|
+
refetchOnMount: false,
|
|
47
|
+
retry: 1,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const results = useMemo(() => uniqueResults(data?.pages.flatMap(page => page.data) ?? []), [data])
|
|
51
|
+
const failedTypes = data?.pages[0]?.meta.failedTypes ?? NO_FAILED_TYPES
|
|
52
|
+
const loadMore = useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage])
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
results,
|
|
56
|
+
failedTypes,
|
|
57
|
+
isPending,
|
|
58
|
+
isError: isError && !isFetchNextPageError,
|
|
59
|
+
isFetchNextPageError,
|
|
60
|
+
loadMore,
|
|
61
|
+
hasNextPage,
|
|
62
|
+
isFetchingNextPage,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function nextPageParam(offset?: string): SearchResultsPageParam | undefined {
|
|
67
|
+
return offset ? { offset: Number(offset) } : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function uniqueResults(results: SearchResultResource[]) {
|
|
71
|
+
const seen = new Set<string>()
|
|
72
|
+
return results.filter(result => {
|
|
73
|
+
const key = `${result.type}:${result.id}`
|
|
74
|
+
if (seen.has(key)) return false
|
|
75
|
+
seen.add(key)
|
|
76
|
+
return true
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -4,15 +4,24 @@ import { TextButton } from '../../../components'
|
|
|
4
4
|
interface ViewMoreLinkRowProps {
|
|
5
5
|
onPress: () => void
|
|
6
6
|
accessibilityHint: string
|
|
7
|
+
expanded?: boolean
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
export const ViewMoreLinkRow = ({
|
|
10
|
+
export const ViewMoreLinkRow = ({
|
|
11
|
+
accessibilityHint,
|
|
12
|
+
expanded = false,
|
|
13
|
+
onPress,
|
|
14
|
+
}: ViewMoreLinkRowProps) => {
|
|
10
15
|
const styles = useRowStyles()
|
|
11
16
|
|
|
12
17
|
return (
|
|
13
18
|
<View style={[styles.row]}>
|
|
14
|
-
<TextButton
|
|
15
|
-
|
|
19
|
+
<TextButton
|
|
20
|
+
onPress={onPress}
|
|
21
|
+
accessibilityHint={accessibilityHint}
|
|
22
|
+
accessibilityState={{ expanded }}
|
|
23
|
+
>
|
|
24
|
+
{expanded ? 'View less' : 'View more'}
|
|
16
25
|
</TextButton>
|
|
17
26
|
</View>
|
|
18
27
|
)
|
|
@@ -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
|
|
@@ -124,8 +124,12 @@ function MessageActionsScreenContent({
|
|
|
124
124
|
})
|
|
125
125
|
}, [navigation, conversation_id, message.id, message.replyRootId, replyRootAuthorName])
|
|
126
126
|
|
|
127
|
-
const handleCopyPress = () => {
|
|
128
|
-
|
|
127
|
+
const handleCopyPress = async () => {
|
|
128
|
+
const textToCopy = message?.text || attachmentForCopy()
|
|
129
|
+
if (textToCopy) {
|
|
130
|
+
await Clipboard.setStringAsync(textToCopy)
|
|
131
|
+
Haptic.notificationSuccess()
|
|
132
|
+
}
|
|
129
133
|
navigation.goBack()
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -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
|
+
})
|