@planningcenter/chat-react-native 4.3.0-rc.9 → 4.3.1-qa-989.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 (178) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/giphy_attachment.js +2 -2
  3. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  5. package/build/components/conversation/attachments/image_attachment.js +5 -3
  6. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  7. package/build/components/conversation/message.d.ts +3 -1
  8. package/build/components/conversation/message.d.ts.map +1 -1
  9. package/build/components/conversation/message.js +183 -158
  10. package/build/components/conversation/message.js.map +1 -1
  11. package/build/components/conversation/message_form.d.ts.map +1 -1
  12. package/build/components/conversation/message_form.js +3 -3
  13. package/build/components/conversation/message_form.js.map +1 -1
  14. package/build/components/conversation/message_list.d.ts.map +1 -1
  15. package/build/components/conversation/message_list.js +143 -88
  16. package/build/components/conversation/message_list.js.map +1 -1
  17. package/build/components/conversation/system_message.js +3 -3
  18. package/build/components/conversation/system_message.js.map +1 -1
  19. package/build/components/conversations/conversations.d.ts.map +1 -1
  20. package/build/components/conversations/conversations.js +0 -6
  21. package/build/components/conversations/conversations.js.map +1 -1
  22. package/build/contexts/api_provider.d.ts.map +1 -1
  23. package/build/contexts/api_provider.js +28 -25
  24. package/build/contexts/api_provider.js.map +1 -1
  25. package/build/hooks/use_animated_message_background_color.d.ts +1 -0
  26. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  27. package/build/hooks/use_animated_message_background_color.js +26 -9
  28. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  29. package/build/hooks/use_chat_permissions.d.ts +10 -2
  30. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  31. package/build/hooks/use_chat_permissions.js +8 -0
  32. package/build/hooks/use_chat_permissions.js.map +1 -1
  33. package/build/hooks/use_debounced_search_term.d.ts +2 -0
  34. package/build/hooks/use_debounced_search_term.d.ts.map +1 -0
  35. package/build/hooks/use_debounced_search_term.js +40 -0
  36. package/build/hooks/use_debounced_search_term.js.map +1 -0
  37. package/build/hooks/use_features.d.ts +2 -0
  38. package/build/hooks/use_features.d.ts.map +1 -1
  39. package/build/hooks/use_features.js +3 -1
  40. package/build/hooks/use_features.js.map +1 -1
  41. package/build/hooks/use_jolt.d.ts.map +1 -1
  42. package/build/hooks/use_jolt.js +2 -1
  43. package/build/hooks/use_jolt.js.map +1 -1
  44. package/build/hooks/use_product_analytics.d.ts +9 -0
  45. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  46. package/build/hooks/use_product_analytics.js +4 -2
  47. package/build/hooks/use_product_analytics.js.map +1 -1
  48. package/build/hooks/use_search_enabled.d.ts +2 -0
  49. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  50. package/build/hooks/use_search_enabled.js +18 -0
  51. package/build/hooks/use_search_enabled.js.map +1 -0
  52. package/build/hooks/use_search_results.d.ts +19 -0
  53. package/build/hooks/use_search_results.d.ts.map +1 -0
  54. package/build/hooks/use_search_results.js +153 -0
  55. package/build/hooks/use_search_results.js.map +1 -0
  56. package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
  57. package/build/screens/age_check/age_check_underage_screen.js +2 -1
  58. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  59. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  60. package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
  61. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  62. package/build/screens/bug_report_screen.d.ts.map +1 -1
  63. package/build/screens/bug_report_screen.js +2 -2
  64. package/build/screens/bug_report_screen.js.map +1 -1
  65. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts +2 -1
  66. package/build/screens/conversation_select_recipients/components/view_more_link_row.d.ts.map +1 -1
  67. package/build/screens/conversation_select_recipients/components/view_more_link_row.js +39 -24
  68. package/build/screens/conversation_select_recipients/components/view_more_link_row.js.map +1 -1
  69. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  70. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +142 -158
  71. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  72. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  73. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  74. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  75. package/build/screens/conversations/conversations_screen.js +2 -5
  76. package/build/screens/conversations/conversations_screen.js.map +1 -1
  77. package/build/screens/get_help_screen.js +2 -2
  78. package/build/screens/get_help_screen.js.map +1 -1
  79. package/build/screens/message_actions_screen.js +6 -2
  80. package/build/screens/message_actions_screen.js.map +1 -1
  81. package/build/screens/search/components/conversation_result_row.d.ts +8 -0
  82. package/build/screens/search/components/conversation_result_row.d.ts.map +1 -0
  83. package/build/screens/search/components/conversation_result_row.js +80 -0
  84. package/build/screens/search/components/conversation_result_row.js.map +1 -0
  85. package/build/screens/search/components/conversation_results.d.ts +8 -0
  86. package/build/screens/search/components/conversation_results.d.ts.map +1 -0
  87. package/build/screens/search/components/conversation_results.js +77 -0
  88. package/build/screens/search/components/conversation_results.js.map +1 -0
  89. package/build/screens/search/components/message_result_row.d.ts +8 -0
  90. package/build/screens/search/components/message_result_row.d.ts.map +1 -0
  91. package/build/screens/search/components/message_result_row.js +152 -0
  92. package/build/screens/search/components/message_result_row.js.map +1 -0
  93. package/build/screens/search/components/result_row.d.ts +10 -0
  94. package/build/screens/search/components/result_row.d.ts.map +1 -0
  95. package/build/screens/search/components/result_row.js +102 -0
  96. package/build/screens/search/components/result_row.js.map +1 -0
  97. package/build/screens/search/components/results_section.d.ts +9 -0
  98. package/build/screens/search/components/results_section.d.ts.map +1 -0
  99. package/build/screens/search/components/results_section.js +51 -0
  100. package/build/screens/search/components/results_section.js.map +1 -0
  101. package/build/screens/search/components/search_results_list.d.ts +13 -0
  102. package/build/screens/search/components/search_results_list.d.ts.map +1 -0
  103. package/build/screens/search/components/search_results_list.js +344 -0
  104. package/build/screens/search/components/search_results_list.js.map +1 -0
  105. package/build/screens/search/components/snippet.d.ts +8 -0
  106. package/build/screens/search/components/snippet.d.ts.map +1 -0
  107. package/build/screens/search/components/snippet.js +68 -0
  108. package/build/screens/search/components/snippet.js.map +1 -0
  109. package/build/screens/search/search_screen.d.ts.map +1 -1
  110. package/build/screens/search/search_screen.js +92 -20
  111. package/build/screens/search/search_screen.js.map +1 -1
  112. package/build/utils/client/request_helpers.js +1 -1
  113. package/build/utils/client/request_helpers.js.map +1 -1
  114. package/build/utils/native_adapters/linking.d.ts +11 -3
  115. package/build/utils/native_adapters/linking.d.ts.map +1 -1
  116. package/build/utils/native_adapters/linking.js.map +1 -1
  117. package/build/utils/response_error.d.ts +1 -1
  118. package/build/utils/response_error.d.ts.map +1 -1
  119. package/build/utils/response_error.js +1 -1
  120. package/build/utils/response_error.js.map +1 -1
  121. package/package.json +3 -3
  122. package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
  123. package/src/__tests__/contexts/api_provider.test.tsx +50 -0
  124. package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
  125. package/src/__tests__/screens/get_help_screen.test.ts +36 -0
  126. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  127. package/src/__tests__/utils/client.ts +3 -4
  128. package/src/components/conversation/__tests__/message_form.test.tsx +83 -0
  129. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  130. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
  131. package/src/components/conversation/attachments/image_attachment.tsx +2 -3
  132. package/src/components/conversation/message.tsx +12 -1
  133. package/src/components/conversation/message_form.tsx +2 -2
  134. package/src/components/conversation/message_list.tsx +26 -1
  135. package/src/components/conversation/system_message.tsx +3 -3
  136. package/src/components/conversations/conversations.tsx +0 -7
  137. package/src/contexts/__tests__/api_provider.test.tsx +106 -0
  138. package/src/contexts/api_provider.tsx +6 -9
  139. package/src/hooks/__tests__/use_debounced_search_term.test.tsx +38 -0
  140. package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
  141. package/src/hooks/__tests__/use_search_results.test.tsx +88 -0
  142. package/src/hooks/use_animated_message_background_color.ts +37 -12
  143. package/src/hooks/use_chat_permissions.ts +15 -2
  144. package/src/hooks/use_debounced_search_term.ts +20 -0
  145. package/src/hooks/use_features.ts +2 -0
  146. package/src/hooks/use_jolt.ts +10 -7
  147. package/src/hooks/use_product_analytics.ts +3 -1
  148. package/src/hooks/use_search_enabled.ts +9 -0
  149. package/src/hooks/use_search_results.ts +78 -0
  150. package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
  151. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
  152. package/src/screens/bug_report_screen.tsx +2 -10
  153. package/src/screens/conversation_select_recipients/components/view_more_link_row.tsx +12 -3
  154. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +3 -8
  155. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  156. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  157. package/src/screens/conversations/conversations_screen.tsx +2 -3
  158. package/src/screens/get_help_screen.tsx +2 -2
  159. package/src/screens/message_actions_screen.tsx +6 -2
  160. package/src/screens/search/__tests__/search_screen.test.tsx +194 -0
  161. package/src/screens/search/components/__tests__/conversation_results.test.tsx +55 -0
  162. package/src/screens/search/components/__tests__/message_result_row.test.tsx +65 -0
  163. package/src/screens/search/components/__tests__/result_row.test.tsx +33 -0
  164. package/src/screens/search/components/__tests__/search_results_list.test.tsx +180 -0
  165. package/src/screens/search/components/conversation_result_row.tsx +36 -0
  166. package/src/screens/search/components/conversation_results.tsx +43 -0
  167. package/src/screens/search/components/message_result_row.tsx +48 -0
  168. package/src/screens/search/components/result_row.tsx +58 -0
  169. package/src/screens/search/components/results_section.tsx +34 -0
  170. package/src/screens/search/components/search_results_list.tsx +168 -0
  171. package/src/screens/search/components/snippet.tsx +37 -0
  172. package/src/screens/search/search_screen.tsx +50 -4
  173. package/src/utils/__tests__/response_error.test.ts +7 -2
  174. package/src/utils/client/__tests__/request_helpers.test.ts +40 -0
  175. package/src/utils/client/request_helpers.ts +1 -1
  176. package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
  177. package/src/utils/native_adapters/linking.ts +12 -3
  178. package/src/utils/response_error.ts +2 -2
@@ -5,15 +5,7 @@ import type {
5
5
  } from '@react-navigation/native-stack'
6
6
  import { startsWith } from 'lodash'
7
7
  import React, { useCallback, useState } from 'react'
8
- import {
9
- View,
10
- StyleSheet,
11
- TextInput,
12
- Linking,
13
- ScrollView,
14
- TouchableOpacity,
15
- Platform,
16
- } from 'react-native'
8
+ import { View, StyleSheet, TextInput, ScrollView, TouchableOpacity, Platform } from 'react-native'
17
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
18
10
  import {
19
11
  Button,
@@ -38,7 +30,7 @@ import { useTheme } from '../hooks'
38
30
  import { useAppName } from '../hooks/use_app_name'
39
31
  import { useReportBugAction } from '../hooks/use_report_bug_action'
40
32
  import { useUploadClient } from '../hooks/use_upload_client'
41
- import { ImagePicker, ImagePickerResult, platformFontWeightBold } from '../utils'
33
+ import { ImagePicker, ImagePickerResult, Linking, platformFontWeightBold } from '../utils'
42
34
 
43
35
  const MAX_DESCRIPTION_LENGTH = 2000
44
36
 
@@ -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
@@ -0,0 +1,71 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { render } from '@testing-library/react-native'
3
+ import React, { Suspense } from 'react'
4
+ import { buildTestQueryClient } from '../../../__utils__/query_client'
5
+ import { ConversationsScreen } from '../conversations_screen'
6
+
7
+ const mockGet = jest.fn()
8
+ jest.mock('../../../hooks/use_api_client', () => ({
9
+ useApiClient: () => ({ chat: { get: mockGet } }),
10
+ }))
11
+ jest.mock('../../../hooks/use_product_analytics', () => ({
12
+ ...jest.requireActual('../../../hooks/use_product_analytics'),
13
+ usePublishProductAnalyticsEvent: () => {},
14
+ }))
15
+ jest.mock('../../../contexts/conversations_context', () => ({
16
+ ConversationsContextProvider: ({ children }: { children: React.ReactNode }) => children,
17
+ }))
18
+ jest.mock('../../../components', () => ({
19
+ Conversations: ({ ListHeaderComponent }: { ListHeaderComponent: React.ComponentType }) => (
20
+ <ListHeaderComponent />
21
+ ),
22
+ }))
23
+ jest.mock('../components/list_header_component', () => {
24
+ const { Text } = require('react-native')
25
+ return { ListHeaderComponent: () => <Text>Default header</Text> }
26
+ })
27
+ jest.mock('../components/search_list_header_component', () => {
28
+ const { Text } = require('react-native')
29
+ return { SearchListHeaderComponent: () => <Text>Search header</Text> }
30
+ })
31
+
32
+ const renderWithFeatures = (enabledFeatures: string[]) => {
33
+ mockGet.mockResolvedValue({
34
+ data: enabledFeatures.map((name, index) => ({
35
+ type: 'Feature',
36
+ id: String(index + 1),
37
+ name,
38
+ enabled: true,
39
+ })),
40
+ links: {},
41
+ meta: { count: enabledFeatures.length, totalCount: enabledFeatures.length },
42
+ })
43
+
44
+ return render(
45
+ <QueryClientProvider client={buildTestQueryClient()}>
46
+ <Suspense fallback={null}>
47
+ <ConversationsScreen route={{ params: {} }} />
48
+ </Suspense>
49
+ </QueryClientProvider>
50
+ )
51
+ }
52
+
53
+ describe('ConversationsScreen header gating', () => {
54
+ it('shows the search header with only the QA flag on', async () => {
55
+ const view = renderWithFeatures(['QA_MOBILE_search'])
56
+
57
+ expect(await view.findByText('Search header')).toBeTruthy()
58
+ })
59
+
60
+ it('shows the search header with only the rollout flag on', async () => {
61
+ const view = renderWithFeatures(['ROLLOUT_MOBILE_search'])
62
+
63
+ expect(await view.findByText('Search header')).toBeTruthy()
64
+ })
65
+
66
+ it('shows the default header with neither flag on', async () => {
67
+ const view = renderWithFeatures(['ROLLOUT_WEB_search'])
68
+
69
+ expect(await view.findByText('Default header')).toBeTruthy()
70
+ })
71
+ })
@@ -264,7 +264,7 @@ const useStyles = ({
264
264
  return StyleSheet.create({
265
265
  listHeader: {
266
266
  borderBottomWidth: 1,
267
- borderBottomColor: theme.colors.statusErrorBorder,
267
+ borderBottomColor: theme.colors.fillColorNeutral050Base,
268
268
  paddingBottom: 16,
269
269
  gap: 16,
270
270
  },
@@ -3,8 +3,8 @@ import React from 'react'
3
3
  import { StyleSheet, View } from 'react-native'
4
4
  import { Conversations } from '../../components'
5
5
  import { ConversationsContextProvider } from '../../contexts/conversations_context'
6
- import { availableFeatures, useFeatures } from '../../hooks/use_features'
7
6
  import { analyticsEvents, usePublishProductAnalyticsEvent } from '../../hooks/use_product_analytics'
7
+ import { useSearchEnabled } from '../../hooks/use_search_enabled'
8
8
  import { AppName } from '../../types/resources/app_name'
9
9
  import { GraphId } from '../../types/resources/group_resource'
10
10
  import { ListHeaderComponent } from './components/list_header_component'
@@ -19,8 +19,7 @@ export type ConversationsScreenProps = StaticScreenProps<{
19
19
 
20
20
  export function ConversationsScreen({ route }: ConversationsScreenProps) {
21
21
  const styles = useStyles()
22
- const { featureEnabled } = useFeatures()
23
- const searchEnabled = featureEnabled(availableFeatures.search)
22
+ const searchEnabled = useSearchEnabled()
24
23
  const Header = searchEnabled ? SearchListHeaderComponent : ListHeaderComponent
25
24
 
26
25
  usePublishProductAnalyticsEvent(analyticsEvents.conversation_index_opened)
@@ -1,12 +1,12 @@
1
1
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
2
  import { useCallback, useMemo } from 'react'
3
- import { Alert, Linking, StyleSheet, View } from 'react-native'
3
+ import { Alert, StyleSheet, View } from 'react-native'
4
4
  import { Heading, PressableRow, Text, TextInlineButton } from '../components'
5
5
  import { useApiGet, useTheme } from '../hooks'
6
6
  import { useAppName } from '../hooks/use_app_name'
7
7
  import { useOrganization } from '../hooks/use_organization'
8
8
  import { ResourceObject } from '../types'
9
- import { Clipboard } from '../utils'
9
+ import { Clipboard, Linking } from '../utils'
10
10
 
11
11
  type GetHelpScreenRouteProps = {
12
12
  type?: 'chat' | 'general'
@@ -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,194 @@
1
+ import { act, fireEvent, render } from '@testing-library/react-native'
2
+ import React from 'react'
3
+ import { Keyboard } 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 { SearchScreen } from '../search_screen'
10
+
11
+ const mockNavigate = jest.fn()
12
+ jest.mock('@react-navigation/native', () => ({
13
+ ...jest.requireActual('@react-navigation/native'),
14
+ useNavigation: () => ({ navigate: mockNavigate }),
15
+ }))
16
+
17
+ const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [string]>()
18
+ jest.mock('../../../hooks/use_search_results', () => ({
19
+ useSearchResults: (term: string) => mockUseSearchResults(term),
20
+ }))
21
+ const mockPublishEvent = jest.fn()
22
+ jest.mock('../../../hooks/use_product_analytics', () => ({
23
+ ...jest.requireActual('../../../hooks/use_product_analytics'),
24
+ useProductAnalytics: () => ({ publishEvent: mockPublishEvent }),
25
+ }))
26
+ jest.mock('../../../components/display/keyboard_view', () => ({
27
+ ...jest.requireActual('../../../components/display/keyboard_view'),
28
+ KeyboardView: ({ children }: { children: React.ReactNode }) => children,
29
+ }))
30
+
31
+ const inputLabel = 'Search titles and messages'
32
+
33
+ const conversationResult: ConversationSearchResultResource = {
34
+ type: 'conversation',
35
+ id: '42',
36
+ title: 'Budget Team',
37
+ url: '/conversations/42',
38
+ extra: { avatar: { previewAvatarUrls: [] } },
39
+ }
40
+
41
+ const messageResult: MessageSearchResultResource = {
42
+ type: 'message',
43
+ id: '01JMESSAGEULID',
44
+ title: 'Finance',
45
+ url: '/conversations/7/messages/01JMESSAGEULID',
46
+ extra: {
47
+ conversationId: 7,
48
+ authorName: 'Ann',
49
+ snippet: [{ text: 'budget', match: true }],
50
+ createdAt: '2026-01-01T00:00:00Z',
51
+ avatar: { previewAvatarUrls: [] },
52
+ },
53
+ }
54
+
55
+ const secondMessageResult: MessageSearchResultResource = {
56
+ ...messageResult,
57
+ id: '01JSECONDULID',
58
+ title: 'Youth',
59
+ url: '/conversations/9/messages/01JSECONDULID',
60
+ extra: { ...messageResult.extra, conversationId: 9, authorName: 'Bob' },
61
+ }
62
+
63
+ const renderWithResults = () => {
64
+ mockUseSearchResults.mockReturnValue({
65
+ results: [conversationResult, messageResult, secondMessageResult],
66
+ failedTypes: [],
67
+ isPending: false,
68
+ isError: false,
69
+ isFetchNextPageError: false,
70
+ loadMore: jest.fn(),
71
+ hasNextPage: false,
72
+ isFetchingNextPage: false,
73
+ })
74
+ const view = render(<SearchScreen />)
75
+ const input = view.getByLabelText(inputLabel)
76
+ fireEvent.changeText(input, 'bud')
77
+ fireEvent.changeText(input, 'budget')
78
+ act(() => jest.advanceTimersByTime(200))
79
+ return view
80
+ }
81
+
82
+ beforeEach(() => {
83
+ jest.useFakeTimers()
84
+ mockNavigate.mockReset()
85
+ mockPublishEvent.mockReset()
86
+ mockUseSearchResults.mockReset().mockReturnValue({
87
+ results: [],
88
+ failedTypes: [],
89
+ isPending: true,
90
+ isError: false,
91
+ isFetchNextPageError: false,
92
+ loadMore: jest.fn(),
93
+ hasNextPage: false,
94
+ isFetchingNextPage: false,
95
+ })
96
+ })
97
+
98
+ afterEach(() => jest.useRealTimers())
99
+
100
+ describe('SearchScreen', () => {
101
+ it('searches once with the trimmed term after typing settles', () => {
102
+ const view = render(<SearchScreen />)
103
+ const input = view.getByLabelText(inputLabel)
104
+
105
+ fireEvent.changeText(input, 'bud')
106
+ fireEvent.changeText(input, ' budget ')
107
+
108
+ expect(view.getByText('Search chat')).toBeTruthy()
109
+ expect(mockUseSearchResults).not.toHaveBeenCalled()
110
+
111
+ act(() => jest.advanceTimersByTime(200))
112
+
113
+ const searchedTerms = new Set(mockUseSearchResults.mock.calls.map(([term]) => term))
114
+ expect([...searchedTerms]).toEqual(['budget'])
115
+ expect(view.queryByText('Search chat')).toBeNull()
116
+ expect(view.getByLabelText('Loading results')).toBeTruthy()
117
+ })
118
+
119
+ it('returns to the prompt as soon as the term is cleared', () => {
120
+ const view = render(<SearchScreen />)
121
+ const input = view.getByLabelText(inputLabel)
122
+
123
+ fireEvent.changeText(input, 'budget')
124
+ act(() => jest.advanceTimersByTime(200))
125
+ expect(view.queryByText('Search chat')).toBeNull()
126
+
127
+ fireEvent.changeText(input, ' ')
128
+
129
+ expect(view.getByText('Search chat')).toBeTruthy()
130
+ })
131
+
132
+ it('opens a conversation result at the latest message', () => {
133
+ const dismiss = jest.spyOn(Keyboard, 'dismiss')
134
+ const view = renderWithResults()
135
+
136
+ fireEvent.press(view.getByLabelText('Budget Team'))
137
+
138
+ expect(dismiss).toHaveBeenCalled()
139
+ expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
140
+ conversation_id: 42,
141
+ title: 'Budget Team',
142
+ })
143
+ })
144
+
145
+ it('opens a message result anchored at that message', () => {
146
+ const view = renderWithResults()
147
+
148
+ fireEvent.press(view.getByLabelText(/^Finance, Ann: budget/))
149
+
150
+ expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
151
+ conversation_id: 7,
152
+ initial_message_id: '01JMESSAGEULID',
153
+ title: 'Finance',
154
+ })
155
+ })
156
+
157
+ it('publishes one search event per settled term with result counts, never the term', () => {
158
+ renderWithResults()
159
+
160
+ expect(mockPublishEvent).toHaveBeenCalledTimes(1)
161
+ expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.performed', {
162
+ term_length: 6,
163
+ conversation_result_count: 1,
164
+ message_result_count: 2,
165
+ })
166
+ expect(JSON.stringify(mockPublishEvent.mock.calls)).not.toContain('budget')
167
+ })
168
+
169
+ it('does not publish a retyped term whose results are already cached', () => {
170
+ const view = renderWithResults()
171
+ const input = view.getByLabelText(inputLabel)
172
+
173
+ fireEvent.changeText(input, 'budge')
174
+ act(() => jest.advanceTimersByTime(200))
175
+ fireEvent.changeText(input, 'budget')
176
+ act(() => jest.advanceTimersByTime(200))
177
+
178
+ const performed = mockPublishEvent.mock.calls.filter(
179
+ ([name]) => name === 'chat.mobile.search.performed'
180
+ )
181
+ expect(performed.map(([, meta]) => meta.term_length)).toEqual([6, 5])
182
+ })
183
+
184
+ it('publishes the opened result type and its position within its section', () => {
185
+ const view = renderWithResults()
186
+
187
+ fireEvent.press(view.getByLabelText(/^Youth, Bob: budget/))
188
+
189
+ expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.result.opened', {
190
+ result_type: 'message',
191
+ position: 1,
192
+ })
193
+ })
194
+ })
@@ -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 and its position 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], 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
+ })