@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.
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +42 -40
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/display/action_button.d.ts.map +1 -1
- package/build/components/display/action_button.js +18 -16
- package/build/components/display/action_button.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +21 -9
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
- package/build/hooks/use_animated_message_background_color.js +5 -3
- package/build/hooks/use_animated_message_background_color.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +29 -26
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_jolt.d.ts +1 -0
- package/build/hooks/use_jolt.d.ts.map +1 -1
- package/build/hooks/use_jolt.js +38 -0
- package/build/hooks/use_jolt.js.map +1 -1
- package/build/hooks/use_reduce_motion.d.ts +2 -0
- package/build/hooks/use_reduce_motion.d.ts.map +1 -0
- package/build/hooks/use_reduce_motion.js +27 -0
- package/build/hooks/use_reduce_motion.js.map +1 -0
- package/build/screens/search/search_screen.d.ts.map +1 -1
- package/build/screens/search/search_screen.js +10 -2
- package/build/screens/search/search_screen.js.map +1 -1
- package/package.json +4 -4
- package/src/components/conversation/jump_to_bottom_button.tsx +4 -3
- package/src/components/display/action_button.tsx +4 -3
- package/src/contexts/api_provider.tsx +8 -0
- package/src/hooks/__tests__/use_conversation_messages.test.tsx +10 -2
- package/src/hooks/__tests__/use_jolt.test.tsx +78 -1
- package/src/hooks/__tests__/use_reduce_motion.test.tsx +49 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_animated_message_background_color.ts +6 -3
- package/src/hooks/use_conversation_messages.ts +7 -5
- package/src/hooks/use_jolt.ts +32 -1
- package/src/hooks/use_reduce_motion.ts +18 -0
- package/src/screens/search/__tests__/search_screen.test.tsx +13 -1
- 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
|
+
})
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
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])
|
package/src/hooks/use_jolt.ts
CHANGED
|
@@ -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
|
-
<
|
|
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
|
})
|