@planningcenter/chat-react-native 4.4.1-qa-staging.11 → 4.4.1-qa-staging.13

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 (44) hide show
  1. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.js +42 -40
  3. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  4. package/build/components/display/action_button.d.ts.map +1 -1
  5. package/build/components/display/action_button.js +18 -16
  6. package/build/components/display/action_button.js.map +1 -1
  7. package/build/contexts/api_provider.d.ts.map +1 -1
  8. package/build/contexts/api_provider.js +21 -9
  9. package/build/contexts/api_provider.js.map +1 -1
  10. package/build/hooks/index.d.ts +1 -0
  11. package/build/hooks/index.d.ts.map +1 -1
  12. package/build/hooks/index.js +1 -0
  13. package/build/hooks/index.js.map +1 -1
  14. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  15. package/build/hooks/use_animated_message_background_color.js +5 -3
  16. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  18. package/build/hooks/use_conversation_messages.js +29 -26
  19. package/build/hooks/use_conversation_messages.js.map +1 -1
  20. package/build/hooks/use_jolt.d.ts +1 -0
  21. package/build/hooks/use_jolt.d.ts.map +1 -1
  22. package/build/hooks/use_jolt.js +38 -0
  23. package/build/hooks/use_jolt.js.map +1 -1
  24. package/build/hooks/use_reduce_motion.d.ts +2 -0
  25. package/build/hooks/use_reduce_motion.d.ts.map +1 -0
  26. package/build/hooks/use_reduce_motion.js +27 -0
  27. package/build/hooks/use_reduce_motion.js.map +1 -0
  28. package/build/screens/search/search_screen.d.ts.map +1 -1
  29. package/build/screens/search/search_screen.js +10 -2
  30. package/build/screens/search/search_screen.js.map +1 -1
  31. package/package.json +4 -4
  32. package/src/components/conversation/jump_to_bottom_button.tsx +4 -3
  33. package/src/components/display/action_button.tsx +4 -3
  34. package/src/contexts/api_provider.tsx +8 -0
  35. package/src/hooks/__tests__/use_conversation_messages.test.tsx +10 -2
  36. package/src/hooks/__tests__/use_jolt.test.tsx +78 -1
  37. package/src/hooks/__tests__/use_reduce_motion.test.tsx +49 -0
  38. package/src/hooks/index.ts +1 -0
  39. package/src/hooks/use_animated_message_background_color.ts +6 -3
  40. package/src/hooks/use_conversation_messages.ts +7 -5
  41. package/src/hooks/use_jolt.ts +32 -1
  42. package/src/hooks/use_reduce_motion.ts +18 -0
  43. package/src/screens/search/__tests__/search_screen.test.tsx +13 -1
  44. package/src/screens/search/search_screen.tsx +8 -2
@@ -1,12 +1,15 @@
1
+ import JoltClient from '@planningcenter/jolt-client'
2
+ import { JoltSubscription } from '@planningcenter/jolt-client/dist/types/JoltSubscription'
1
3
  import { QueryClientProvider } from '@tanstack/react-query'
2
4
  import { render, waitFor } from '@testing-library/react-native'
3
5
  import React, { PropsWithChildren, Suspense } from 'react'
6
+ import { AppState, AppStateStatus } from 'react-native'
4
7
  import { buildTestQueryClient } from '../../__utils__/query_client'
5
8
  import { ChatContext, ChatContextValue } from '../../contexts/chat_context'
6
9
  import { Session } from '../../utils'
7
10
  import { Client } from '../../utils/client'
8
11
  import { ResponseError } from '../../utils/response_error'
9
- import { useJoltClient } from '../use_jolt'
12
+ import { useJoltClient, useJoltForegroundPing } from '../use_jolt'
10
13
 
11
14
  let caught: unknown
12
15
 
@@ -68,3 +71,77 @@ describe('useJoltClient', () => {
68
71
  expect((caught as ResponseError).status).toBe(401)
69
72
  })
70
73
  })
74
+
75
+ describe('useJoltForegroundPing', () => {
76
+ const addEventListener = AppState.addEventListener as jest.Mock
77
+ const confirmedSubscription = {} as JoltSubscription
78
+
79
+ const PingProbe = () => {
80
+ useJoltForegroundPing()
81
+ return null
82
+ }
83
+
84
+ const renderForegroundPing = (joltClient: Partial<JoltClient>) => {
85
+ const queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
86
+ queryClient.setQueryData(['jolt-client', 'wss://jolt.test', 'a-real-token'], joltClient)
87
+
88
+ render(
89
+ <QueryClientProvider client={queryClient}>
90
+ <PingProbe />
91
+ </QueryClientProvider>
92
+ )
93
+
94
+ const [, onChange] = addEventListener.mock.calls.at(-1) as [
95
+ string,
96
+ (_state: AppStateStatus) => void,
97
+ ]
98
+
99
+ return onChange
100
+ }
101
+
102
+ beforeEach(() => {
103
+ addEventListener.mockClear()
104
+ })
105
+
106
+ it('pings the jolt client once when the app returns to the foreground', () => {
107
+ const ping = jest.fn()
108
+ const onChange = renderForegroundPing({ ping, subscriptions: [confirmedSubscription] })
109
+
110
+ onChange('background')
111
+ onChange('active')
112
+
113
+ expect(ping).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('does not ping when there are no confirmed subscriptions', () => {
117
+ const ping = jest.fn()
118
+ const onChange = renderForegroundPing({ ping, subscriptions: [] })
119
+
120
+ onChange('background')
121
+ onChange('active')
122
+
123
+ expect(ping).not.toHaveBeenCalled()
124
+ })
125
+
126
+ it('does not ping when the app leaves the foreground', () => {
127
+ const ping = jest.fn()
128
+ const onChange = renderForegroundPing({ ping, subscriptions: [confirmedSubscription] })
129
+
130
+ onChange('inactive')
131
+ onChange('background')
132
+
133
+ expect(ping).not.toHaveBeenCalled()
134
+ })
135
+
136
+ it('swallows errors from pinging a closed socket', () => {
137
+ const ping = jest.fn(() => {
138
+ throw new Error('No WebSocket')
139
+ })
140
+ const onChange = renderForegroundPing({ ping, subscriptions: [confirmedSubscription] })
141
+
142
+ onChange('background')
143
+
144
+ expect(() => onChange('active')).not.toThrow()
145
+ expect(ping).toHaveBeenCalledTimes(1)
146
+ })
147
+ })
@@ -0,0 +1,49 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react-native'
2
+ import { AccessibilityInfo, EmitterSubscription } from 'react-native'
3
+ import { useReduceMotion } from '../use_reduce_motion'
4
+
5
+ type ReduceMotionListener = (enabled: boolean) => void
6
+
7
+ describe('useReduceMotion', () => {
8
+ let listener: ReduceMotionListener | undefined
9
+ const remove = jest.fn()
10
+
11
+ beforeEach(() => {
12
+ listener = undefined
13
+ remove.mockClear()
14
+ jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(true)
15
+ const addEventListener: typeof AccessibilityInfo.addEventListener = (_event, handler) => {
16
+ listener = handler as ReduceMotionListener
17
+ return { remove } as unknown as EmitterSubscription
18
+ }
19
+ jest.spyOn(AccessibilityInfo, 'addEventListener').mockImplementation(addEventListener)
20
+ })
21
+
22
+ afterEach(() => jest.restoreAllMocks())
23
+
24
+ it('seeds from the system setting', async () => {
25
+ const { result } = renderHook(() => useReduceMotion())
26
+
27
+ expect(result.current).toBe(false)
28
+ await waitFor(() => expect(result.current).toBe(true))
29
+ })
30
+
31
+ it('follows reduceMotionChanged while mounted', async () => {
32
+ const { result } = renderHook(() => useReduceMotion())
33
+ await waitFor(() => expect(result.current).toBe(true))
34
+
35
+ act(() => listener?.(false))
36
+ expect(result.current).toBe(false)
37
+
38
+ act(() => listener?.(true))
39
+ expect(result.current).toBe(true)
40
+ })
41
+
42
+ it('removes the listener on unmount', () => {
43
+ const { unmount } = renderHook(() => useReduceMotion())
44
+
45
+ expect(remove).not.toHaveBeenCalled()
46
+ unmount()
47
+ expect(remove).toHaveBeenCalledTimes(1)
48
+ })
49
+ })
@@ -23,6 +23,7 @@ export * from './use_people_person'
23
23
  export * from './use_preview_avatar_diameter'
24
24
  export * from './use_product_analytics'
25
25
  export * from './use_qualified_by_age'
26
+ export * from './use_reduce_motion'
26
27
  export * from './use_scalable_number_of_lines'
27
28
  export * from './use_submit_age_check'
28
29
  export * from './use_suspense_api'
@@ -10,6 +10,7 @@ import {
10
10
  Easing,
11
11
  ReduceMotion,
12
12
  } from 'react-native-reanimated'
13
+ import { useReduceMotion } from './use_reduce_motion'
13
14
  import { useTheme } from './use_theme'
14
15
 
15
16
  const FLASH_IN_MS = 150
@@ -18,10 +19,12 @@ const FLASH_OUT_MS = 1500
18
19
 
19
20
  export function useAnimatedMessageBackgroundColor() {
20
21
  const { colors } = useTheme()
22
+ const reduceMotion = useReduceMotion()
21
23
  const pressProgress = useSharedValue(0)
22
24
  const flashProgress = useSharedValue(0)
23
25
 
24
- const tintColor = colors.fillColorNeutral050Base
26
+ const tintColor =
27
+ colors.name === 'dark' ? colors.fillColorNeutral040 : colors.fillColorNeutral050Base
25
28
  const tintsOnPress = Platform.OS === 'ios'
26
29
 
27
30
  const animatedBackgroundColor = useAnimatedStyle(() => {
@@ -53,12 +56,12 @@ export function useAnimatedMessageBackgroundColor() {
53
56
  withTiming(0, {
54
57
  duration: FLASH_OUT_MS,
55
58
  easing: Easing.out(Easing.ease),
56
- reduceMotion: ReduceMotion.System,
59
+ reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
57
60
  }),
58
61
  ReduceMotion.Never
59
62
  )
60
63
  )
61
- }, [flashProgress])
64
+ }, [flashProgress, reduceMotion])
62
65
 
63
66
  return {
64
67
  animatedBackgroundColor,
@@ -72,6 +72,8 @@ export const useConversationMessages = (
72
72
  [data]
73
73
  )
74
74
 
75
+ useEnsureWindowOpenedAtAnchor({ conversation_id, reply_root_id, anchor })
76
+
75
77
  // Once nothing newer remains, the top of the window is the live edge. Without this,
76
78
  // a refetch would replay from the anchor and drop rows Jolt has added since.
77
79
  useEffect(() => {
@@ -82,8 +84,6 @@ export const useConversationMessages = (
82
84
  }
83
85
  }, [infiniteData, queryClient, queryKey])
84
86
 
85
- useEnsureWindowHoldsAnchor({ conversation_id, reply_root_id, anchor })
86
-
87
87
  return {
88
88
  messages,
89
89
  behind,
@@ -97,7 +97,7 @@ export const useConversationMessages = (
97
97
  }
98
98
  }
99
99
 
100
- const useEnsureWindowHoldsAnchor = ({ conversation_id, reply_root_id, anchor }: Options) => {
100
+ const useEnsureWindowOpenedAtAnchor = ({ conversation_id, reply_root_id, anchor }: Options) => {
101
101
  const queryClient = useQueryClient()
102
102
 
103
103
  useEffect(() => {
@@ -105,8 +105,10 @@ const useEnsureWindowHoldsAnchor = ({ conversation_id, reply_root_id, anchor }:
105
105
 
106
106
  const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
107
107
  const cached = queryClient.getQueryData<MessagesWindow>(queryKey)
108
- const holdsAnchor = cached?.pages.some(page => page.data.some(m => m.id === anchor))
109
- if (cached && !holdsAnchor) {
108
+ if (!cached) return
109
+
110
+ const openedAtAnchor = cached.pageParams[0]?.where?.id_around === anchor
111
+ if (!openedAtAnchor) {
110
112
  queryClient.resetQueries({ queryKey, exact: true })
111
113
  }
112
114
  }, [anchor, conversation_id, reply_root_id, queryClient])
@@ -7,8 +7,9 @@ import {
7
7
  FetchSubscribeToken,
8
8
  JoltSubscription,
9
9
  } from '@planningcenter/jolt-client/dist/types/JoltSubscription'
10
- import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
10
+ import { QueryClient, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
11
11
  import { useCallback, useEffect, useMemo } from 'react'
12
+ import { AppState, AppStateStatus } from 'react-native'
12
13
  import { useChatContext } from '../contexts/chat_context'
13
14
  import { ApiResource } from '../types'
14
15
  import type { JoltSubscriptionPattern } from '../types/jolt_events'
@@ -125,6 +126,36 @@ export const useJoltClient = (): JoltClient | undefined => {
125
126
  return joltClient
126
127
  }
127
128
 
129
+ const pingSubscribedJoltClients = (queryClient: QueryClient) => {
130
+ queryClient
131
+ .getQueryCache()
132
+ .findAll({ queryKey: ['jolt-client'] })
133
+ .forEach(query => {
134
+ const joltClient = query.state.data as JoltClient | undefined
135
+ if (!joltClient || joltClient.subscriptions.length === 0) return
136
+
137
+ try {
138
+ joltClient.ping()
139
+ } catch {}
140
+ })
141
+ }
142
+
143
+ export const useJoltForegroundPing = () => {
144
+ const queryClient = useQueryClient()
145
+
146
+ useEffect(() => {
147
+ let previousState: AppStateStatus = AppState.currentState
148
+ const subscription = AppState.addEventListener('change', nextState => {
149
+ if (nextState === 'active' && previousState !== 'active') {
150
+ pingSubscribedJoltClients(queryClient)
151
+ }
152
+ previousState = nextState
153
+ })
154
+
155
+ return () => subscription.remove()
156
+ }, [queryClient])
157
+ }
158
+
128
159
  export function useJoltChannel(
129
160
  channelName: string,
130
161
  enabled: boolean = true
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { AccessibilityInfo } from 'react-native'
3
+
4
+ export const useReduceMotion = () => {
5
+ const [reduceMotion, setReduceMotion] = useState(false)
6
+
7
+ useEffect(() => {
8
+ AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
9
+
10
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion)
11
+
12
+ return () => {
13
+ subscription.remove()
14
+ }
15
+ }, [])
16
+
17
+ return reduceMotion
18
+ }
@@ -1,6 +1,6 @@
1
1
  import { act, fireEvent, render } from '@testing-library/react-native'
2
2
  import React from 'react'
3
- import { Keyboard } from 'react-native'
3
+ import { Keyboard, ScrollView } from 'react-native'
4
4
  import type { useSearchResults } from '../../../hooks/use_search_results'
5
5
  import type {
6
6
  ConversationSearchResultResource,
@@ -116,6 +116,18 @@ describe('SearchScreen', () => {
116
116
  expect(view.getByLabelText('Loading results')).toBeTruthy()
117
117
  })
118
118
 
119
+ it('lets the prompt scroll while taps still reach the field', () => {
120
+ const view = render(<SearchScreen />)
121
+
122
+ const scrollView = view.UNSAFE_getByType(ScrollView)
123
+ expect(scrollView.props.keyboardShouldPersistTaps).toBe('handled')
124
+ expect(scrollView.props.contentContainerStyle).toMatchObject({
125
+ flexGrow: 1,
126
+ justifyContent: 'center',
127
+ })
128
+ expect(view.getByText('Search chat')).toBeTruthy()
129
+ })
130
+
119
131
  it('returns to the prompt as soon as the term is cleared', () => {
120
132
  const view = render(<SearchScreen />)
121
133
  const input = view.getByLabelText(inputLabel)
@@ -4,7 +4,7 @@ import {
4
4
  NativeStackScreenProps,
5
5
  } from '@react-navigation/native-stack'
6
6
  import React, { useRef, useState } from 'react'
7
- import { Keyboard, StyleSheet, View } from 'react-native'
7
+ import { Keyboard, ScrollView, StyleSheet, View } from 'react-native'
8
8
  import { KeyboardView } from '../../components'
9
9
  import {
10
10
  composeOptions,
@@ -73,7 +73,9 @@ export function SearchScreen() {
73
73
  onSettled={publishSearch}
74
74
  />
75
75
  ) : (
76
- <SearchPromptBlankState />
76
+ <ScrollView contentContainerStyle={styles.prompt} keyboardShouldPersistTaps="handled">
77
+ <SearchPromptBlankState />
78
+ </ScrollView>
77
79
  )}
78
80
  </View>
79
81
  </KeyboardView>
@@ -84,4 +86,8 @@ const styles = StyleSheet.create({
84
86
  container: {
85
87
  flex: 1,
86
88
  },
89
+ prompt: {
90
+ flexGrow: 1,
91
+ justifyContent: 'center',
92
+ },
87
93
  })