@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.
Files changed (79) 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/message_actions_screen.js +6 -2
  19. package/build/screens/message_actions_screen.js.map +1 -1
  20. package/build/screens/search/components/conversation_result_row.d.ts +8 -0
  21. package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
  22. package/build/screens/search/components/conversation_result_row.js +80 -0
  23. package/build/screens/search/components/conversation_result_row.js.map +1 -0
  24. package/build/screens/search/components/conversation_results.d.ts +8 -0
  25. package/build/screens/search/components/conversation_results.d.ts.map +1 -0
  26. package/build/screens/search/components/conversation_results.js +77 -0
  27. package/build/screens/search/components/conversation_results.js.map +1 -0
  28. package/build/screens/search/components/message_result_row.d.ts +8 -0
  29. package/build/screens/search/components/message_result_row.d.ts.map +1 -0
  30. package/build/screens/search/components/message_result_row.js +152 -0
  31. package/build/screens/search/components/message_result_row.js.map +1 -0
  32. package/build/screens/search/components/result_row.d.ts +10 -0
  33. package/build/screens/search/components/result_row.d.ts.map +1 -0
  34. package/build/screens/search/components/result_row.js +102 -0
  35. package/build/screens/search/components/result_row.js.map +1 -0
  36. package/build/screens/search/components/results_section.d.ts +9 -0
  37. package/build/screens/search/components/results_section.d.ts.map +1 -0
  38. package/build/screens/search/components/results_section.js +51 -0
  39. package/build/screens/search/components/results_section.js.map +1 -0
  40. package/build/screens/search/components/search_results_list.d.ts +8 -0
  41. package/build/screens/search/components/search_results_list.d.ts.map +1 -0
  42. package/build/screens/search/components/search_results_list.js +314 -0
  43. package/build/screens/search/components/search_results_list.js.map +1 -0
  44. package/build/screens/search/components/snippet.d.ts +8 -0
  45. package/build/screens/search/components/snippet.d.ts.map +1 -0
  46. package/build/screens/search/components/snippet.js +68 -0
  47. package/build/screens/search/components/snippet.js.map +1 -0
  48. package/build/screens/search/search_screen.d.ts.map +1 -1
  49. package/build/screens/search/search_screen.js +18 -9
  50. package/build/screens/search/search_screen.js.map +1 -1
  51. package/build/utils/client/request_helpers.js +1 -1
  52. package/build/utils/client/request_helpers.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  55. package/src/__tests__/utils/client.ts +3 -4
  56. package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
  57. package/src/components/conversation/message_form.tsx +1 -1
  58. package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
  59. package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
  60. package/src/hooks/use_debounced_search_term.ts +20 -0
  61. package/src/hooks/use_search_results.ts +78 -0
  62. package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
  63. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
  64. package/src/screens/message_actions_screen.tsx +6 -2
  65. package/src/screens/search/__tests__/search_screen.test.tsx +64 -0
  66. package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
  67. package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
  68. package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
  69. package/src/screens/search/components/__tests__/search_results_list.test.tsx +135 -0
  70. package/src/screens/search/components/conversation_result_row.tsx +36 -0
  71. package/src/screens/search/components/conversation_results.tsx +39 -0
  72. package/src/screens/search/components/message_result_row.tsx +48 -0
  73. package/src/screens/search/components/result_row.tsx +58 -0
  74. package/src/screens/search/components/results_section.tsx +34 -0
  75. package/src/screens/search/components/search_results_list.tsx +147 -0
  76. package/src/screens/search/components/snippet.tsx +37 -0
  77. package/src/screens/search/search_screen.tsx +9 -1
  78. package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
  79. 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
- // Do not url encode the query params or iOS will double encode
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[Record]=id&per_page=1'
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[Record]=id'
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
+ })
@@ -276,8 +276,8 @@ function MessageFormRoot({
276
276
  // added earlier.
277
277
  if (!(await catchUpBeforeSend())) return
278
278
 
279
- reset()
280
279
  mutateAsync({ text: messageText, attachments: attachmentsForSubmit })
280
+ reset()
281
281
  }
282
282
  }
283
283
  }
@@ -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 = ({ accessibilityHint, onPress }: ViewMoreLinkRowProps) => {
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 onPress={onPress} accessibilityHint={accessibilityHint} accessibilityRole="link">
15
- View more
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, useEffect, useRef, useState } from 'react'
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 [debouncedTerm, setDebouncedTerm] = useState('')
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
- Clipboard.setStringAsync(message?.text || attachmentForCopy() || '')
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
+ })