@planningcenter/chat-react-native 4.4.0-rc.0 → 4.4.0-rc.2

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 (51) hide show
  1. package/build/components/conversation/message.d.ts +3 -1
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +183 -158
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts.map +1 -1
  6. package/build/components/conversation/message_list.js +143 -88
  7. package/build/components/conversation/message_list.js.map +1 -1
  8. package/build/hooks/use_animated_message_background_color.d.ts +1 -0
  9. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  10. package/build/hooks/use_animated_message_background_color.js +26 -9
  11. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  12. package/build/hooks/use_product_analytics.d.ts +9 -0
  13. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  14. package/build/hooks/use_product_analytics.js +4 -2
  15. package/build/hooks/use_product_analytics.js.map +1 -1
  16. package/build/hooks/use_search_enabled.d.ts +2 -0
  17. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  18. package/build/hooks/use_search_enabled.js +18 -0
  19. package/build/hooks/use_search_enabled.js.map +1 -0
  20. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  21. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  22. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  23. package/build/screens/conversations/conversations_screen.js +2 -5
  24. package/build/screens/conversations/conversations_screen.js.map +1 -1
  25. package/build/screens/search/components/conversation_results.d.ts +1 -1
  26. package/build/screens/search/components/conversation_results.d.ts.map +1 -1
  27. package/build/screens/search/components/conversation_results.js +2 -2
  28. package/build/screens/search/components/conversation_results.js.map +1 -1
  29. package/build/screens/search/components/search_results_list.d.ts +7 -2
  30. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  31. package/build/screens/search/components/search_results_list.js +135 -105
  32. package/build/screens/search/components/search_results_list.js.map +1 -1
  33. package/build/screens/search/search_screen.d.ts.map +1 -1
  34. package/build/screens/search/search_screen.js +69 -31
  35. package/build/screens/search/search_screen.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  38. package/src/components/conversation/message.tsx +12 -1
  39. package/src/components/conversation/message_list.tsx +26 -1
  40. package/src/hooks/use_animated_message_background_color.ts +37 -12
  41. package/src/hooks/use_product_analytics.ts +3 -1
  42. package/src/hooks/use_search_enabled.ts +9 -0
  43. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  44. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  45. package/src/screens/conversations/conversations_screen.tsx +2 -3
  46. package/src/screens/search/__tests__/search_screen.test.tsx +56 -2
  47. package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
  48. package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
  49. package/src/screens/search/components/conversation_results.tsx +7 -3
  50. package/src/screens/search/components/search_results_list.tsx +25 -4
  51. package/src/screens/search/search_screen.tsx +24 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "4.4.0-rc.0",
3
+ "version": "4.4.0-rc.2",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -84,5 +84,5 @@
84
84
  "sf-symbols-typescript": "^2.2.0",
85
85
  "typescript": "~6.0.3"
86
86
  },
87
- "gitHead": "0539b5f633f3d60712eb79c502ca354180a9f0ac"
87
+ "gitHead": "4b22c1a4ec8c6b491b71fe443842fe7be3cd3ff4"
88
88
  }
@@ -28,6 +28,15 @@ import { getMessagesQueryKey } from '../../../utils/request/get_messages'
28
28
  import { MessageList } from '../message_list'
29
29
 
30
30
  jest.mock('../../../hooks/use_api_client')
31
+ const mockFlash = jest.fn()
32
+ jest.mock('../../../hooks/use_animated_message_background_color', () => ({
33
+ useAnimatedMessageBackgroundColor: () => ({
34
+ animatedBackgroundColor: {},
35
+ flash: mockFlash,
36
+ handleMessagePressIn: jest.fn(),
37
+ handleMessagePressOut: jest.fn(),
38
+ }),
39
+ }))
31
40
  jest.mock('@react-navigation/native', () => ({
32
41
  ...jest.requireActual('@react-navigation/native'),
33
42
  useNavigation: () => ({ navigate: jest.fn() }),
@@ -105,7 +114,7 @@ let queryClient: QueryClient
105
114
  // A top-level rerender would rebuild the wrapper context and redraw every row.
106
115
  let setHarnessConversation: (next: ConversationResource) => void = () => {}
107
116
 
108
- function Harness() {
117
+ function Harness({ anchorMessageId }: { anchorMessageId?: string }) {
109
118
  const [current, setCurrent] = React.useState(conversation)
110
119
  React.useEffect(() => {
111
120
  setHarnessConversation = setCurrent
@@ -117,6 +126,7 @@ function Harness() {
117
126
  conversation={current}
118
127
  conversationId={CONVERSATION_ID}
119
128
  messages={loaded}
129
+ anchorMessageId={anchorMessageId}
120
130
  hasNextPage
121
131
  onEndReached={() => {}}
122
132
  />
@@ -151,16 +161,24 @@ const renderedRowIds = () => {
151
161
  return Array.from(new Set(ids)).sort()
152
162
  }
153
163
 
154
- const renderList = (seed: MessageResource[] = messages) => {
164
+ const renderList = (seed: MessageResource[] = messages, anchorMessageId?: string) => {
155
165
  queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, {
156
166
  pages: [{ data: seed, links: {}, meta: { count: seed.length, totalCount: seed.length } }],
157
167
  pageParams: [{}],
158
168
  })
159
- render(<Harness />, { wrapper: Wrapper })
169
+ render(<Harness anchorMessageId={anchorMessageId} />, { wrapper: Wrapper })
160
170
  seed.forEach(m => screen.getByTestId(`message-${m.id}`))
161
171
  renderSpy.mockClear()
162
172
  }
163
173
 
174
+ const reportViewable = (items: MessageResource[]) =>
175
+ act(() =>
176
+ screen.UNSAFE_getByType(FlatList).props.onViewableItemsChanged({
177
+ viewableItems: items.map(item => ({ item, key: item.id, index: null, isViewable: true })),
178
+ changed: [],
179
+ })
180
+ )
181
+
164
182
  // TanStack notifies observers on a setTimeout; deliver inline so one write is one act().
165
183
  beforeAll(() => notifyManager.setScheduler(callback => callback()))
166
184
  afterAll(() => notifyManager.setScheduler(callback => setTimeout(callback, 0)))
@@ -274,4 +292,54 @@ describe('MessageList', () => {
274
292
  expect(screen.queryByTestId(`message-${target.id}`)).toBeNull()
275
293
  expect(renderedRowIds()).toEqual([])
276
294
  })
295
+
296
+ describe('opened at an anchor message', () => {
297
+ const anchor = messages[4]
298
+
299
+ it('flashes the anchor row once it comes into view, redrawing only that row', () => {
300
+ renderList(messages, anchor.id)
301
+
302
+ reportViewable([messages[1], messages[2]])
303
+ expect(mockFlash).not.toHaveBeenCalled()
304
+ expect(renderedRowIds()).toEqual([])
305
+
306
+ reportViewable([messages[3], anchor])
307
+ expect(mockFlash).toHaveBeenCalledTimes(1)
308
+ expect(renderedRowIds()).toEqual([anchor.id])
309
+ })
310
+
311
+ it('flashes only once, through later viewability changes, redraws, and a remount', () => {
312
+ renderList(messages, anchor.id)
313
+ reportViewable([anchor])
314
+ expect(mockFlash).toHaveBeenCalledTimes(1)
315
+
316
+ reportViewable([anchor, messages[5]])
317
+ act(() => updateCacheWithReaction(queryClient, messagesQueryKey, reactionEvent(anchor), ME))
318
+ expect(screen.getByTestId(`reaction-${anchor.id}-heart`)).toBeTruthy()
319
+
320
+ act(() =>
321
+ queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
322
+ deleteRecordInPagesData({ data: prev, record: anchor })
323
+ )
324
+ )
325
+ expect(screen.queryByTestId(`message-${anchor.id}`)).toBeNull()
326
+ act(() =>
327
+ queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
328
+ addRecordInPagesData({ data: prev, record: anchor })
329
+ )
330
+ )
331
+ expect(screen.getByTestId(`message-${anchor.id}`)).toBeTruthy()
332
+ reportViewable([anchor])
333
+
334
+ expect(mockFlash).toHaveBeenCalledTimes(1)
335
+ })
336
+
337
+ it('never flashes when there is no anchor', () => {
338
+ renderList()
339
+
340
+ reportViewable([messages[0], messages[1]])
341
+
342
+ expect(mockFlash).not.toHaveBeenCalled()
343
+ })
344
+ })
277
345
  })
@@ -46,6 +46,8 @@ interface MessageProps extends MessageResource {
46
46
  conversation_id: number
47
47
  readByOthers?: boolean
48
48
  inReplyScreen?: boolean
49
+ highlighted?: boolean
50
+ onHighlightShown?: () => void
49
51
  }
50
52
 
51
53
  export const Message = memo(function MessageRow({
@@ -53,6 +55,8 @@ export const Message = memo(function MessageRow({
53
55
  conversation_id,
54
56
  readByOthers,
55
57
  inReplyScreen,
58
+ highlighted = false,
59
+ onHighlightShown,
56
60
  ...message
57
61
  }: MessageProps) {
58
62
  const { reactionCounts, pending, error, attachments, author } = message
@@ -71,7 +75,7 @@ export const Message = memo(function MessageRow({
71
75
  const isPersisted = !isNewMessage(message)
72
76
  const [temporarilyHidePendingState, setTemporarilyHidePendingState] = React.useState(true)
73
77
  const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0)
74
- const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
78
+ const { animatedBackgroundColor, flash, handleMessagePressIn, handleMessagePressOut } =
75
79
  useAnimatedMessageBackgroundColor()
76
80
  const isReplyRootMessage = message.replyRootId === message.id
77
81
  const isDeletedReplyRootMessage = isReplyRootMessage && !!message.deletedAt
@@ -116,6 +120,13 @@ export const Message = memo(function MessageRow({
116
120
  messageText || ''
117
121
  } ${saveStatusLabel} ${timestamp} ${replyCountLabel}`
118
122
 
123
+ useEffect(() => {
124
+ if (!highlighted) return
125
+
126
+ flash()
127
+ onHighlightShown?.()
128
+ }, [highlighted, flash, onHighlightShown])
129
+
119
130
  useEffect(() => {
120
131
  if (pending) {
121
132
  const timer = setTimeout(() => {
@@ -1,6 +1,6 @@
1
1
  import { date as formatDate } from '@planningcenter/datetime-fmt'
2
2
  import { noop } from 'lodash'
3
- import React, { RefObject, useRef } from 'react'
3
+ import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'
4
4
  import { FlatList, FlatListProps, StyleSheet, View } from 'react-native'
5
5
  import { useTheme } from '../../hooks'
6
6
  import { ConversationResource } from '../../types/resources/conversation'
@@ -45,6 +45,7 @@ interface MessageListProps extends ScrollHandlers {
45
45
  }
46
46
 
47
47
  const ANCHOR_CLEARS_JUMP_BUTTON = 56
48
+ const ANCHOR_VIEWABILITY = { itemVisiblePercentThreshold: 50 }
48
49
 
49
50
  /** MessageList
50
51
  * The inverted list of messages, reply previews, and date separators for a conversation
@@ -80,6 +81,13 @@ export function MessageList({
80
81
  const contentHeight = useRef(0)
81
82
  const userHasScrolled = useRef(false)
82
83
  const draggedSinceStartReached = useRef(false)
84
+ const latestAnchorMessageId = useRef(anchorMessageId)
85
+ const flashedAnchorId = useRef<string | null>(null)
86
+ const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
87
+
88
+ useEffect(() => {
89
+ latestAnchorMessageId.current = anchorMessageId
90
+ }, [anchorMessageId])
83
91
 
84
92
  // Row heights keep settling after the first layout (images, attachments), so we
85
93
  // re-pin on every layout pass until the reader takes over with a drag.
@@ -119,6 +127,19 @@ export function MessageList({
119
127
  onScrollBeginDrag?.(event)
120
128
  }
121
129
 
130
+ const flashAnchorOnceViewable: FlatListProps<EnrichedMessage>['onViewableItemsChanged'] = ({
131
+ viewableItems,
132
+ }) => {
133
+ const anchorId = latestAnchorMessageId.current
134
+ if (!anchorId || anchorId === flashedAnchorId.current) return
135
+ if (!viewableItems.some(token => token.item.id === anchorId)) return
136
+
137
+ flashedAnchorId.current = anchorId
138
+ setHighlightedMessageId(anchorId)
139
+ }
140
+
141
+ const clearHighlight = useCallback(() => setHighlightedMessageId(null), [])
142
+
122
143
  // VirtualizedList re-checks its edges on every content size change using a JS offset
123
144
  // the native hold never updates, so without this each merged page re-fires it.
124
145
  const handleStartReached = () => {
@@ -143,6 +164,8 @@ export function MessageList({
143
164
  onScrollToIndexFailed={noop}
144
165
  onScrollEndDrag={onScrollEndDrag}
145
166
  onMomentumScrollEnd={onMomentumScrollEnd}
167
+ onViewableItemsChanged={flashAnchorOnceViewable}
168
+ viewabilityConfig={ANCHOR_VIEWABILITY}
146
169
  scrollEventThrottle={10}
147
170
  maintainVisibleContentPosition={behind ? HOLD_ONLY : HOLD_AND_FOLLOW}
148
171
  ListFooterComponent={
@@ -181,6 +204,8 @@ export function MessageList({
181
204
  conversation_id={conversationId}
182
205
  readByOthers={readByOthers}
183
206
  inReplyScreen={inReplyScreen}
207
+ highlighted={item.id === highlightedMessageId}
208
+ onHighlightShown={clearHighlight}
184
209
  />
185
210
  )
186
211
  }}
@@ -1,43 +1,68 @@
1
+ import { useCallback } from 'react'
1
2
  import { Platform } from 'react-native'
2
3
  import {
3
4
  useSharedValue,
4
5
  useAnimatedStyle,
6
+ withDelay,
7
+ withSequence,
5
8
  withTiming,
6
9
  interpolateColor,
7
10
  Easing,
11
+ ReduceMotion,
8
12
  } from 'react-native-reanimated'
9
13
  import { useTheme } from './use_theme'
10
14
 
15
+ const FLASH_IN_MS = 150
16
+ const FLASH_HOLD_MS = 500
17
+ const FLASH_OUT_MS = 1500
18
+
11
19
  export function useAnimatedMessageBackgroundColor() {
12
20
  const { colors } = useTheme()
13
- const bgFadeProgress = useSharedValue(0)
21
+ const pressProgress = useSharedValue(0)
22
+ const flashProgress = useSharedValue(0)
14
23
 
15
- const pressedColor = Platform.select({
16
- ios: colors.fillColorNeutral050Base,
17
- default: 'transparent',
18
- })
24
+ const tintColor = colors.fillColorNeutral050Base
25
+ const tintsOnPress = Platform.OS === 'ios'
19
26
 
20
27
  const animatedBackgroundColor = useAnimatedStyle(() => {
21
- const backgroundColor = interpolateColor(
22
- bgFadeProgress.value,
23
- [0, 1],
24
- ['transparent', pressedColor]
25
- )
28
+ const pressTint = tintsOnPress ? pressProgress.value : 0
29
+ const progress = Math.max(pressTint, flashProgress.value)
30
+ const backgroundColor = interpolateColor(progress, [0, 1], ['transparent', tintColor])
26
31
  return {
27
32
  backgroundColor,
28
33
  }
29
34
  })
30
35
 
31
36
  const handleMessagePressIn = () => {
32
- bgFadeProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
37
+ pressProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
33
38
  }
34
39
 
35
40
  const handleMessagePressOut = () => {
36
- bgFadeProgress.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.ease) })
41
+ pressProgress.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.ease) })
37
42
  }
38
43
 
44
+ const flash = useCallback(() => {
45
+ flashProgress.value = withSequence(
46
+ withTiming(1, {
47
+ duration: FLASH_IN_MS,
48
+ easing: Easing.out(Easing.ease),
49
+ reduceMotion: ReduceMotion.Never,
50
+ }),
51
+ withDelay(
52
+ FLASH_HOLD_MS,
53
+ withTiming(0, {
54
+ duration: FLASH_OUT_MS,
55
+ easing: Easing.out(Easing.ease),
56
+ reduceMotion: ReduceMotion.System,
57
+ }),
58
+ ReduceMotion.Never
59
+ )
60
+ )
61
+ }, [flashProgress])
62
+
39
63
  return {
40
64
  animatedBackgroundColor,
65
+ flash,
41
66
  handleMessagePressIn,
42
67
  handleMessagePressOut,
43
68
  }
@@ -12,6 +12,8 @@ export interface EventMetadata {
12
12
  export const analyticsEvents = {
13
13
  conversation_index_opened: 'chat.mobile.conversations.index.opened',
14
14
  conversation_show_opened: 'chat.mobile.conversations.show.opened',
15
+ search_performed: 'chat.mobile.search.performed',
16
+ search_result_opened: 'chat.mobile.search.result.opened',
15
17
  } as const satisfies Record<string, `chat.mobile.${string}`>
16
18
 
17
19
  type AnalyticsEventName = (typeof analyticsEvents)[keyof typeof analyticsEvents]
@@ -53,7 +55,7 @@ function sendEvents(events: AnalyticsEvent[], config: ProductAnalyticsConfig): P
53
55
  })
54
56
  }
55
57
 
56
- const useProductAnalytics = () => {
58
+ export const useProductAnalytics = () => {
57
59
  const { productAnalyticsConfig } = useChatContext()
58
60
 
59
61
  const snakeCasedBaseMeta = useMemo(
@@ -0,0 +1,9 @@
1
+ import { availableFeatures, useFeatures } from './use_features'
2
+
3
+ export function useSearchEnabled() {
4
+ const { featureEnabled } = useFeatures()
5
+
6
+ return (
7
+ featureEnabled(availableFeatures.search) || featureEnabled(availableFeatures.search_rollout)
8
+ )
9
+ }
@@ -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)
@@ -18,6 +18,11 @@ const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [strin
18
18
  jest.mock('../../../hooks/use_search_results', () => ({
19
19
  useSearchResults: (term: string) => mockUseSearchResults(term),
20
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
+ }))
21
26
  jest.mock('../../../components/display/keyboard_view', () => ({
22
27
  ...jest.requireActual('../../../components/display/keyboard_view'),
23
28
  KeyboardView: ({ children }: { children: React.ReactNode }) => children,
@@ -47,9 +52,17 @@ const messageResult: MessageSearchResultResource = {
47
52
  },
48
53
  }
49
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
+
50
63
  const renderWithResults = () => {
51
64
  mockUseSearchResults.mockReturnValue({
52
- results: [conversationResult, messageResult],
65
+ results: [conversationResult, messageResult, secondMessageResult],
53
66
  failedTypes: [],
54
67
  isPending: false,
55
68
  isError: false,
@@ -59,7 +72,9 @@ const renderWithResults = () => {
59
72
  isFetchingNextPage: false,
60
73
  })
61
74
  const view = render(<SearchScreen />)
62
- fireEvent.changeText(view.getByLabelText(inputLabel), 'budget')
75
+ const input = view.getByLabelText(inputLabel)
76
+ fireEvent.changeText(input, 'bud')
77
+ fireEvent.changeText(input, 'budget')
63
78
  act(() => jest.advanceTimersByTime(200))
64
79
  return view
65
80
  }
@@ -67,6 +82,7 @@ const renderWithResults = () => {
67
82
  beforeEach(() => {
68
83
  jest.useFakeTimers()
69
84
  mockNavigate.mockReset()
85
+ mockPublishEvent.mockReset()
70
86
  mockUseSearchResults.mockReset().mockReturnValue({
71
87
  results: [],
72
88
  failedTypes: [],
@@ -137,4 +153,42 @@ describe('SearchScreen', () => {
137
153
  title: 'Finance',
138
154
  })
139
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
+ })
140
194
  })
@@ -43,13 +43,13 @@ describe('ConversationResults', () => {
43
43
  expect(view.getByText('View less')).toBeTruthy()
44
44
  })
45
45
 
46
- it('passes the pressed result to onSelect', () => {
46
+ it('passes the pressed result and its position to onSelect', () => {
47
47
  const onSelect = jest.fn()
48
48
  const results = buildResults(2)
49
49
  const view = render(<ConversationResults results={results} onSelect={onSelect} />)
50
50
 
51
51
  fireEvent.press(view.getByText('Conversation 2'))
52
52
 
53
- expect(onSelect).toHaveBeenCalledWith(results[1])
53
+ expect(onSelect).toHaveBeenCalledWith(results[1], 1)
54
54
  })
55
55
  })
@@ -49,9 +49,15 @@ const messageResult = (id: string): MessageSearchResultResource => ({
49
49
  },
50
50
  })
51
51
 
52
- const renderList = (state: Partial<SearchState>) => {
52
+ const renderList = (state: Partial<SearchState>, onSettled = jest.fn()) => {
53
53
  mockUseSearchResults.mockReturnValue(searchState(state))
54
- return render(<SearchResultsList term="potluck" onSelect={jest.fn()} />)
54
+ const list = () => <SearchResultsList term="potluck" onSelect={jest.fn()} onSettled={onSettled} />
55
+ const view = render(list())
56
+ const rerenderWith = (next: Partial<SearchState>) => {
57
+ mockUseSearchResults.mockReturnValue(searchState(next))
58
+ view.rerender(list())
59
+ }
60
+ return { ...view, rerenderWith }
55
61
  }
56
62
 
57
63
  describe('SearchResultsList', () => {
@@ -119,6 +125,45 @@ describe('SearchResultsList', () => {
119
125
  expect(loadMore).not.toHaveBeenCalled()
120
126
  })
121
127
 
128
+ it('passes a message result and its position within the section to onSelect', () => {
129
+ const onSelect = jest.fn()
130
+ const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
131
+ mockUseSearchResults.mockReturnValue(searchState({ results }))
132
+ const view = render(
133
+ <SearchResultsList term="potluck" onSelect={onSelect} onSettled={jest.fn()} />
134
+ )
135
+
136
+ fireEvent.press(view.getByLabelText(/^Message 2/))
137
+
138
+ expect(onSelect).toHaveBeenCalledWith(results[2], 1)
139
+ })
140
+
141
+ it('reports the settled term and counts once, not while pending or after paging', () => {
142
+ const onSettled = jest.fn()
143
+ const view = renderList({ isPending: true }, onSettled)
144
+ expect(onSettled).not.toHaveBeenCalled()
145
+
146
+ const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
147
+ view.rerenderWith({ results })
148
+
149
+ expect(onSettled).toHaveBeenCalledTimes(1)
150
+ expect(onSettled).toHaveBeenCalledWith('potluck', {
151
+ conversationResultCount: 1,
152
+ messageResultCount: 2,
153
+ })
154
+
155
+ view.rerenderWith({ results: [...results, messageResult('3')] })
156
+
157
+ expect(onSettled).toHaveBeenCalledTimes(1)
158
+ })
159
+
160
+ it('does not report a failed search', () => {
161
+ const onSettled = jest.fn()
162
+ renderList({ isError: true }, onSettled)
163
+
164
+ expect(onSettled).not.toHaveBeenCalled()
165
+ })
166
+
122
167
  it('offers a retry after a paging failure', () => {
123
168
  const loadMore = jest.fn()
124
169
  const view = renderList({
@@ -8,7 +8,7 @@ const COLLAPSED_COUNT = 5
8
8
 
9
9
  interface ConversationResultsProps {
10
10
  results: ConversationSearchResultResource[]
11
- onSelect: (result: ConversationSearchResultResource) => void
11
+ onSelect: (result: ConversationSearchResultResource, position: number) => void
12
12
  }
13
13
 
14
14
  export function ConversationResults({ results, onSelect }: ConversationResultsProps) {
@@ -31,8 +31,12 @@ export function ConversationResults({ results, onSelect }: ConversationResultsPr
31
31
 
32
32
  return (
33
33
  <ResultsSection title="Conversations" footer={footer}>
34
- {visible.map(result => (
35
- <ConversationResultRow key={result.id} result={result} onPress={() => onSelect(result)} />
34
+ {visible.map((result, index) => (
35
+ <ConversationResultRow
36
+ key={result.id}
37
+ result={result}
38
+ onPress={() => onSelect(result, index)}
39
+ />
36
40
  ))}
37
41
  </ResultsSection>
38
42
  )