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

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.
@@ -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
  }
@@ -1,8 +1,19 @@
1
1
  import { act, fireEvent, render } from '@testing-library/react-native'
2
2
  import React from 'react'
3
+ import { Keyboard } from 'react-native'
3
4
  import type { useSearchResults } from '../../../hooks/use_search_results'
5
+ import type {
6
+ ConversationSearchResultResource,
7
+ MessageSearchResultResource,
8
+ } from '../../../types/resources/search_result'
4
9
  import { SearchScreen } from '../search_screen'
5
10
 
11
+ const mockNavigate = jest.fn()
12
+ jest.mock('@react-navigation/native', () => ({
13
+ ...jest.requireActual('@react-navigation/native'),
14
+ useNavigation: () => ({ navigate: mockNavigate }),
15
+ }))
16
+
6
17
  const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [string]>()
7
18
  jest.mock('../../../hooks/use_search_results', () => ({
8
19
  useSearchResults: (term: string) => mockUseSearchResults(term),
@@ -14,8 +25,48 @@ jest.mock('../../../components/display/keyboard_view', () => ({
14
25
 
15
26
  const inputLabel = 'Search titles and messages'
16
27
 
28
+ const conversationResult: ConversationSearchResultResource = {
29
+ type: 'conversation',
30
+ id: '42',
31
+ title: 'Budget Team',
32
+ url: '/conversations/42',
33
+ extra: { avatar: { previewAvatarUrls: [] } },
34
+ }
35
+
36
+ const messageResult: MessageSearchResultResource = {
37
+ type: 'message',
38
+ id: '01JMESSAGEULID',
39
+ title: 'Finance',
40
+ url: '/conversations/7/messages/01JMESSAGEULID',
41
+ extra: {
42
+ conversationId: 7,
43
+ authorName: 'Ann',
44
+ snippet: [{ text: 'budget', match: true }],
45
+ createdAt: '2026-01-01T00:00:00Z',
46
+ avatar: { previewAvatarUrls: [] },
47
+ },
48
+ }
49
+
50
+ const renderWithResults = () => {
51
+ mockUseSearchResults.mockReturnValue({
52
+ results: [conversationResult, messageResult],
53
+ failedTypes: [],
54
+ isPending: false,
55
+ isError: false,
56
+ isFetchNextPageError: false,
57
+ loadMore: jest.fn(),
58
+ hasNextPage: false,
59
+ isFetchingNextPage: false,
60
+ })
61
+ const view = render(<SearchScreen />)
62
+ fireEvent.changeText(view.getByLabelText(inputLabel), 'budget')
63
+ act(() => jest.advanceTimersByTime(200))
64
+ return view
65
+ }
66
+
17
67
  beforeEach(() => {
18
68
  jest.useFakeTimers()
69
+ mockNavigate.mockReset()
19
70
  mockUseSearchResults.mockReset().mockReturnValue({
20
71
  results: [],
21
72
  failedTypes: [],
@@ -61,4 +112,29 @@ describe('SearchScreen', () => {
61
112
 
62
113
  expect(view.getByText('Search chat')).toBeTruthy()
63
114
  })
115
+
116
+ it('opens a conversation result at the latest message', () => {
117
+ const dismiss = jest.spyOn(Keyboard, 'dismiss')
118
+ const view = renderWithResults()
119
+
120
+ fireEvent.press(view.getByLabelText('Budget Team'))
121
+
122
+ expect(dismiss).toHaveBeenCalled()
123
+ expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
124
+ conversation_id: 42,
125
+ title: 'Budget Team',
126
+ })
127
+ })
128
+
129
+ it('opens a message result anchored at that message', () => {
130
+ const view = renderWithResults()
131
+
132
+ fireEvent.press(view.getByLabelText(/^Finance, Ann: budget/))
133
+
134
+ expect(mockNavigate).toHaveBeenCalledWith('Conversation', {
135
+ conversation_id: 7,
136
+ initial_message_id: '01JMESSAGEULID',
137
+ title: 'Finance',
138
+ })
139
+ })
64
140
  })
@@ -1,17 +1,17 @@
1
- import { ParamListBase } from '@react-navigation/native'
1
+ import { ParamListBase, useNavigation } from '@react-navigation/native'
2
2
  import {
3
3
  NativeStackNavigationOptions,
4
4
  NativeStackScreenProps,
5
5
  } from '@react-navigation/native-stack'
6
- import { noop } from 'lodash'
7
6
  import React, { useState } from 'react'
8
- import { StyleSheet, View } from 'react-native'
7
+ import { Keyboard, StyleSheet, View } from 'react-native'
9
8
  import { KeyboardView } from '../../components'
10
9
  import {
11
10
  composeOptions,
12
11
  withMinimalBack,
13
12
  } from '../../components/display/platform_modal_header_buttons'
14
13
  import { useDebouncedSearchTerm } from '../../hooks/use_debounced_search_term'
14
+ import type { SearchResultResource } from '../../types/resources/search_result'
15
15
  import { SearchPromptBlankState } from './components/search_blank_states'
16
16
  import { SearchField } from './components/search_field'
17
17
  import { SearchResultsList } from './components/search_results_list'
@@ -25,15 +25,33 @@ export const SearchScreenOptions = ({
25
25
  )
26
26
 
27
27
  export function SearchScreen() {
28
+ const navigation = useNavigation()
28
29
  const [term, setTerm] = useState('')
29
30
  const debouncedTerm = useDebouncedSearchTerm(term)
30
31
 
32
+ const openResult = (result: SearchResultResource) => {
33
+ Keyboard.dismiss()
34
+
35
+ if (result.type === 'conversation') {
36
+ navigation.navigate('Conversation', {
37
+ conversation_id: Number(result.id),
38
+ title: result.title,
39
+ })
40
+ } else {
41
+ navigation.navigate('Conversation', {
42
+ conversation_id: result.extra.conversationId,
43
+ initial_message_id: result.id,
44
+ title: result.title,
45
+ })
46
+ }
47
+ }
48
+
31
49
  return (
32
50
  <KeyboardView>
33
51
  <View style={styles.container} testID="search-screen">
34
52
  <SearchField term={term} onChange={setTerm} />
35
53
  {debouncedTerm.length > 0 ? (
36
- <SearchResultsList key={debouncedTerm} term={debouncedTerm} onSelect={noop} />
54
+ <SearchResultsList key={debouncedTerm} term={debouncedTerm} onSelect={openResult} />
37
55
  ) : (
38
56
  <SearchPromptBlankState />
39
57
  )}