@planningcenter/chat-react-native 4.3.0 → 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.
- package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/giphy_attachment.js +2 -2
- package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +5 -3
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/message.d.ts +3 -1
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +183 -158
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +2 -2
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +143 -88
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/components/conversation/system_message.js +3 -3
- package/build/components/conversation/system_message.js.map +1 -1
- package/build/components/conversations/conversations.d.ts.map +1 -1
- package/build/components/conversations/conversations.js +0 -6
- package/build/components/conversations/conversations.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +28 -25
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_animated_message_background_color.d.ts +1 -0
- package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
- package/build/hooks/use_animated_message_background_color.js +26 -9
- package/build/hooks/use_animated_message_background_color.js.map +1 -1
- package/build/hooks/use_chat_permissions.d.ts +10 -2
- package/build/hooks/use_chat_permissions.d.ts.map +1 -1
- package/build/hooks/use_chat_permissions.js +8 -0
- package/build/hooks/use_chat_permissions.js.map +1 -1
- package/build/hooks/use_features.d.ts +2 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +3 -1
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_jolt.d.ts.map +1 -1
- package/build/hooks/use_jolt.js +2 -1
- package/build/hooks/use_jolt.js.map +1 -1
- package/build/hooks/use_product_analytics.d.ts +9 -0
- package/build/hooks/use_product_analytics.d.ts.map +1 -1
- package/build/hooks/use_product_analytics.js +4 -2
- package/build/hooks/use_product_analytics.js.map +1 -1
- package/build/hooks/use_search_enabled.d.ts +2 -0
- package/build/hooks/use_search_enabled.d.ts.map +1 -0
- package/build/hooks/use_search_enabled.js +18 -0
- package/build/hooks/use_search_enabled.js.map +1 -0
- package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
- package/build/screens/age_check/age_check_underage_screen.js +2 -1
- package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/bug_report_screen.d.ts.map +1 -1
- package/build/screens/bug_report_screen.js +2 -2
- package/build/screens/bug_report_screen.js.map +1 -1
- package/build/screens/conversations/components/search_list_header_component.js +3 -3
- package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +2 -5
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/screens/get_help_screen.js +2 -2
- package/build/screens/get_help_screen.js.map +1 -1
- package/build/screens/message_actions_screen.js +6 -2
- package/build/screens/message_actions_screen.js.map +1 -1
- package/build/screens/search/components/conversation_results.d.ts +1 -1
- package/build/screens/search/components/conversation_results.d.ts.map +1 -1
- package/build/screens/search/components/conversation_results.js +2 -2
- package/build/screens/search/components/conversation_results.js.map +1 -1
- package/build/screens/search/components/search_results_list.d.ts +7 -2
- package/build/screens/search/components/search_results_list.d.ts.map +1 -1
- package/build/screens/search/components/search_results_list.js +135 -105
- package/build/screens/search/components/search_results_list.js.map +1 -1
- package/build/screens/search/search_screen.d.ts.map +1 -1
- package/build/screens/search/search_screen.js +88 -25
- package/build/screens/search/search_screen.js.map +1 -1
- package/build/utils/native_adapters/linking.d.ts +11 -3
- package/build/utils/native_adapters/linking.d.ts.map +1 -1
- package/build/utils/native_adapters/linking.js.map +1 -1
- package/build/utils/response_error.d.ts +1 -1
- package/build/utils/response_error.d.ts.map +1 -1
- package/build/utils/response_error.js +1 -1
- package/build/utils/response_error.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
- package/src/__tests__/contexts/api_provider.test.tsx +50 -0
- package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
- package/src/__tests__/screens/get_help_screen.test.ts +36 -0
- package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
- package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
- package/src/components/conversation/attachments/image_attachment.tsx +2 -3
- package/src/components/conversation/message.tsx +12 -1
- package/src/components/conversation/message_form.tsx +1 -1
- package/src/components/conversation/message_list.tsx +26 -1
- package/src/components/conversation/system_message.tsx +3 -3
- package/src/components/conversations/conversations.tsx +0 -7
- package/src/contexts/__tests__/api_provider.test.tsx +106 -0
- package/src/contexts/api_provider.tsx +6 -9
- package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
- package/src/hooks/use_animated_message_background_color.ts +37 -12
- package/src/hooks/use_chat_permissions.ts +15 -2
- package/src/hooks/use_features.ts +2 -0
- package/src/hooks/use_jolt.ts +10 -7
- package/src/hooks/use_product_analytics.ts +3 -1
- package/src/hooks/use_search_enabled.ts +9 -0
- package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
- package/src/screens/bug_report_screen.tsx +2 -10
- package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
- package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
- package/src/screens/conversations/conversations_screen.tsx +2 -3
- package/src/screens/get_help_screen.tsx +2 -2
- package/src/screens/message_actions_screen.tsx +6 -2
- package/src/screens/search/__tests__/search_screen.test.tsx +130 -0
- package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
- package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
- package/src/screens/search/components/conversation_results.tsx +7 -3
- package/src/screens/search/components/search_results_list.tsx +25 -4
- package/src/screens/search/search_screen.tsx +44 -6
- package/src/utils/__tests__/response_error.test.ts +7 -2
- package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
- package/src/utils/native_adapters/linking.ts +12 -3
- package/src/utils/response_error.ts +2 -2
|
@@ -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
|
}}
|
|
@@ -21,7 +21,7 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
21
21
|
const navigation = useNavigation()
|
|
22
22
|
const { colors } = useTheme()
|
|
23
23
|
const text = systemMessageText(names, overflowCount, action)
|
|
24
|
-
const
|
|
24
|
+
const isTruncated = overflowCount > 0
|
|
25
25
|
const hasReactions = reactionCounts.length > 0
|
|
26
26
|
const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
|
|
27
27
|
useAnimatedMessageBackgroundColor()
|
|
@@ -53,14 +53,14 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
55
|
<Pressable
|
|
56
|
-
onPress={
|
|
56
|
+
onPress={isTruncated ? handlePress : undefined}
|
|
57
57
|
onLongPress={handleLongPress}
|
|
58
58
|
onPressIn={handleMessagePressIn}
|
|
59
59
|
onPressOut={handleMessagePressOut}
|
|
60
60
|
android_ripple={{ color: colors.androidRippleNeutral, borderless: false, foreground: true }}
|
|
61
61
|
accessibilityRole="button"
|
|
62
62
|
accessibilityHint={
|
|
63
|
-
|
|
63
|
+
isTruncated
|
|
64
64
|
? 'Tap to see people. Long press for message actions.'
|
|
65
65
|
: 'Long press for message actions.'
|
|
66
66
|
}
|
|
@@ -6,7 +6,6 @@ import { useTheme } from '../../hooks'
|
|
|
6
6
|
import { useCanCreateConversations } from '../../hooks/use_chat_permissions'
|
|
7
7
|
import { useConversationsJoltEvents } from '../../hooks/use_conversations_jolt_events'
|
|
8
8
|
import { ConversationResource } from '../../types'
|
|
9
|
-
import { throwResponseError } from '../../utils/response_error'
|
|
10
9
|
import { ConversationPreview, ConversationPreviewSkeleton } from './conversation_preview'
|
|
11
10
|
import { ConversationsBlankState } from './conversations_blank_state'
|
|
12
11
|
|
|
@@ -27,8 +26,6 @@ export const Conversations = ({ ListHeaderComponent }: ConversationsProps) => {
|
|
|
27
26
|
refetch,
|
|
28
27
|
isRefetching,
|
|
29
28
|
isFetched,
|
|
30
|
-
isError,
|
|
31
|
-
error,
|
|
32
29
|
args: { chat_group_graph_id, group_source_app_name },
|
|
33
30
|
} = useConversationsContext()
|
|
34
31
|
const navigation = useNavigation()
|
|
@@ -48,10 +45,6 @@ export const Conversations = ({ ListHeaderComponent }: ConversationsProps) => {
|
|
|
48
45
|
}))
|
|
49
46
|
}, [conversations, isLoading])
|
|
50
47
|
|
|
51
|
-
if (isError) {
|
|
52
|
-
throwResponseError(error)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
useConversationsJoltEvents()
|
|
56
49
|
|
|
57
50
|
return (
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react-native'
|
|
2
|
+
import React, { useContext, useMemo } from 'react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { useApiGet } from '../../hooks/use_api'
|
|
5
|
+
import { ApiClient, useApiClient } from '../../hooks/use_api_client'
|
|
6
|
+
import { Session } from '../../utils'
|
|
7
|
+
import { ApiProvider, chatQueryClient } from '../api_provider'
|
|
8
|
+
import { ChatContext, ChatContextValue } from '../chat_context'
|
|
9
|
+
|
|
10
|
+
jest.mock('../../hooks/use_api_client')
|
|
11
|
+
|
|
12
|
+
const mockedUseApiClient = useApiClient as jest.MockedFunction<typeof useApiClient>
|
|
13
|
+
|
|
14
|
+
type Deferred = { promise: Promise<unknown>; reject: (_reason: unknown) => void }
|
|
15
|
+
|
|
16
|
+
const defer = (): Deferred => {
|
|
17
|
+
let reject: Deferred['reject'] = () => {}
|
|
18
|
+
const promise = new Promise((_, rej) => {
|
|
19
|
+
reject = rej
|
|
20
|
+
})
|
|
21
|
+
promise.catch(() => {})
|
|
22
|
+
return { promise, reject }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let get: jest.Mock
|
|
26
|
+
let staleRequest: Deferred
|
|
27
|
+
|
|
28
|
+
const meRequests = () =>
|
|
29
|
+
get.mock.calls.filter(([url]) => url === '/me').map(([, token]) => token as string)
|
|
30
|
+
|
|
31
|
+
const Probe = () => {
|
|
32
|
+
const { status, fetchStatus, data } = useApiGet<{ type: 'Person'; id: number }>({
|
|
33
|
+
url: '/me',
|
|
34
|
+
data: { fields: { Person: ['id'] } },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return <Text>{`${status}:${fetchStatus}:${data?.id ?? ''}`}</Text>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const Tree = ({ accessToken }: { accessToken: string }) => {
|
|
41
|
+
const value = useMemo(() => {
|
|
42
|
+
const session = new Session({ token: { access_token: accessToken } })
|
|
43
|
+
return {
|
|
44
|
+
session,
|
|
45
|
+
token: session.token,
|
|
46
|
+
env: 'production',
|
|
47
|
+
onUnauthorizedResponse: () => {},
|
|
48
|
+
} as unknown as ChatContextValue
|
|
49
|
+
}, [accessToken])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ChatContext.Provider value={value}>
|
|
53
|
+
<ApiProvider>
|
|
54
|
+
<Probe />
|
|
55
|
+
</ApiProvider>
|
|
56
|
+
</ChatContext.Provider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeAll(() => {
|
|
61
|
+
chatQueryClient.setDefaultOptions({
|
|
62
|
+
queries: { ...chatQueryClient.getDefaultOptions().queries, gcTime: 0 },
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
staleRequest = defer()
|
|
68
|
+
get = jest.fn((url: string, accessToken: string) => {
|
|
69
|
+
if (url !== '/me') return Promise.resolve({ data: [] })
|
|
70
|
+
if (accessToken === 'stale') return staleRequest.promise
|
|
71
|
+
|
|
72
|
+
return Promise.resolve({ data: { type: 'Person', id: 42 } })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
mockedUseApiClient.mockImplementation(() => {
|
|
76
|
+
const { session } = useContext(ChatContext)
|
|
77
|
+
const accessToken = session.token?.access_token
|
|
78
|
+
const client = { get: ({ url }: { url: string }) => get(url, accessToken) }
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
chat: client,
|
|
82
|
+
groups: client,
|
|
83
|
+
services: client,
|
|
84
|
+
people: client,
|
|
85
|
+
} as unknown as ApiClient
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
chatQueryClient.clear()
|
|
91
|
+
jest.clearAllMocks()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('ApiProvider on access token change', () => {
|
|
95
|
+
it('restarts in-flight requests with the new token and ignores the stale result', async () => {
|
|
96
|
+
const { rerender } = render(<Tree accessToken="stale" />)
|
|
97
|
+
await waitFor(() => expect(meRequests()).toEqual(['stale']))
|
|
98
|
+
|
|
99
|
+
rerender(<Tree accessToken="fresh" />)
|
|
100
|
+
await waitFor(() => expect(meRequests()).toEqual(['stale', 'fresh']))
|
|
101
|
+
|
|
102
|
+
staleRequest.reject(new Response(null, { status: 401, statusText: 'Unauthorized' }))
|
|
103
|
+
|
|
104
|
+
expect(await screen.findByText('success:idle:42')).toBeTruthy()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -9,9 +9,9 @@ import React, { useContext, useEffect, useRef } from 'react'
|
|
|
9
9
|
import { ViewProps } from 'react-native'
|
|
10
10
|
import { ApiClient, useApiClient } from '../hooks/use_api_client'
|
|
11
11
|
import { useAppState } from '../hooks/use_app_state'
|
|
12
|
-
import {
|
|
12
|
+
import { appGrantsQueryOptions } from '../hooks/use_chat_permissions'
|
|
13
13
|
import { featuresQueryOptions } from '../hooks/use_features'
|
|
14
|
-
import {
|
|
14
|
+
import { RequestQueryKey } from '../hooks/use_suspense_api'
|
|
15
15
|
import { emitAuthRefresh } from '../utils/auth_events'
|
|
16
16
|
import { ResponseError } from '../utils/response_error'
|
|
17
17
|
import { ChatContext, ChatContextValue } from './chat_context'
|
|
@@ -48,7 +48,7 @@ export const chatQueryClient = new QueryClient({
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
export function ApiProvider({ children }: ViewProps) {
|
|
51
|
-
const { token, env } = useContext(ChatContext)
|
|
51
|
+
const { token, env, session } = useContext(ChatContext)
|
|
52
52
|
const sessionChanged = useSessionChanged({ token, env })
|
|
53
53
|
|
|
54
54
|
const client = useApiClient()
|
|
@@ -59,7 +59,7 @@ export function ApiProvider({ children }: ViewProps) {
|
|
|
59
59
|
emitAuthRefresh()
|
|
60
60
|
|
|
61
61
|
if (chatQueryClient.isFetching()) {
|
|
62
|
-
chatQueryClient.invalidateQueries()
|
|
62
|
+
chatQueryClient.cancelQueries().then(() => chatQueryClient.invalidateQueries())
|
|
63
63
|
return
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -72,7 +72,7 @@ export function ApiProvider({ children }: ViewProps) {
|
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<QueryClientProvider client={chatQueryClient}>
|
|
75
|
-
<PrefetchQueries />
|
|
75
|
+
{session.isAuthenticated && <PrefetchQueries />}
|
|
76
76
|
{children}
|
|
77
77
|
</QueryClientProvider>
|
|
78
78
|
)
|
|
@@ -84,10 +84,7 @@ export function ApiProvider({ children }: ViewProps) {
|
|
|
84
84
|
const PrefetchQueries = () => {
|
|
85
85
|
const client = useApiClient()
|
|
86
86
|
|
|
87
|
-
usePrefetchQuery(
|
|
88
|
-
queryKey: getRequestQueryKey(appGrantsRequestArgs),
|
|
89
|
-
queryFn: defaultQueryFn,
|
|
90
|
-
})
|
|
87
|
+
usePrefetchQuery(appGrantsQueryOptions(client))
|
|
91
88
|
usePrefetchQuery(featuresQueryOptions(client))
|
|
92
89
|
|
|
93
90
|
return null
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { render, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import React, { PropsWithChildren, Suspense } from 'react'
|
|
4
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
+
import { ChatContext, ChatContextValue } from '../../contexts/chat_context'
|
|
6
|
+
import { Session } from '../../utils'
|
|
7
|
+
import { Client } from '../../utils/client'
|
|
8
|
+
import { ResponseError } from '../../utils/response_error'
|
|
9
|
+
import { useJoltClient } from '../use_jolt'
|
|
10
|
+
|
|
11
|
+
let caught: unknown
|
|
12
|
+
|
|
13
|
+
class Catch extends React.Component<PropsWithChildren, { failed: boolean }> {
|
|
14
|
+
state = { failed: false }
|
|
15
|
+
|
|
16
|
+
static getDerivedStateFromError(error: unknown) {
|
|
17
|
+
caught = error
|
|
18
|
+
return { failed: true }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
render() {
|
|
22
|
+
return this.state.failed ? null : this.props.children
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Probe = () => {
|
|
27
|
+
useJoltClient()
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const renderJoltClient = () => {
|
|
32
|
+
const session = new Session({ token: { access_token: 'a-real-token' } })
|
|
33
|
+
|
|
34
|
+
return render(
|
|
35
|
+
<QueryClientProvider client={buildTestQueryClient()}>
|
|
36
|
+
<ChatContext.Provider value={{ session } as unknown as ChatContextValue}>
|
|
37
|
+
<Catch>
|
|
38
|
+
<Suspense fallback={null}>
|
|
39
|
+
<Probe />
|
|
40
|
+
</Suspense>
|
|
41
|
+
</Catch>
|
|
42
|
+
</ChatContext.Provider>
|
|
43
|
+
</QueryClientProvider>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('useJoltClient', () => {
|
|
48
|
+
let post: jest.SpyInstance
|
|
49
|
+
let consoleError: jest.SpyInstance
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
caught = undefined
|
|
53
|
+
post = jest.spyOn(Client.prototype, 'post')
|
|
54
|
+
consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
post.mockRestore()
|
|
59
|
+
consoleError.mockRestore()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('surfaces a failed jolt_authorize as a ResponseError', async () => {
|
|
63
|
+
post.mockRejectedValue(new Response(null, { status: 401, statusText: 'Unauthorized' }))
|
|
64
|
+
|
|
65
|
+
renderJoltClient()
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(caught).toBeInstanceOf(ResponseError))
|
|
68
|
+
expect((caught as ResponseError).status).toBe(401)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -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
|
|
21
|
+
const pressProgress = useSharedValue(0)
|
|
22
|
+
const flashProgress = useSharedValue(0)
|
|
14
23
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
default: 'transparent',
|
|
18
|
-
})
|
|
24
|
+
const tintColor = colors.fillColorNeutral050Base
|
|
25
|
+
const tintsOnPress = Platform.OS === 'ios'
|
|
19
26
|
|
|
20
27
|
const animatedBackgroundColor = useAnimatedStyle(() => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
37
|
+
pressProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const handleMessagePressOut = () => {
|
|
36
|
-
|
|
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,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { queryOptions } from '@tanstack/react-query'
|
|
2
|
+
import { ApiCollection, AppGrantsResource } from '../types'
|
|
3
|
+
import { throwResponseError } from '../utils/response_error'
|
|
2
4
|
import { FIVE_MINUTES } from '../utils/time'
|
|
3
5
|
import { useApiGet } from './use_api'
|
|
4
|
-
import { App } from './use_api_client'
|
|
6
|
+
import { ApiClient, App } from './use_api_client'
|
|
7
|
+
import { getRequestQueryKey } from './use_suspense_api'
|
|
5
8
|
|
|
6
9
|
export const appGrantsRequestArgs = {
|
|
7
10
|
url: '/me/app_grants',
|
|
@@ -13,6 +16,16 @@ export const appGrantsRequestArgs = {
|
|
|
13
16
|
app: 'chat' as App,
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
export const appGrantsQueryOptions = (apiClient: ApiClient) =>
|
|
20
|
+
queryOptions({
|
|
21
|
+
queryKey: getRequestQueryKey(appGrantsRequestArgs),
|
|
22
|
+
queryFn: (): Promise<ApiCollection<AppGrantsResource>> =>
|
|
23
|
+
apiClient[appGrantsRequestArgs.app]
|
|
24
|
+
.get<ApiCollection<AppGrantsResource>>(appGrantsRequestArgs)
|
|
25
|
+
.catch(throwResponseError),
|
|
26
|
+
staleTime: FIVE_MINUTES,
|
|
27
|
+
})
|
|
28
|
+
|
|
16
29
|
export function useAppGrants() {
|
|
17
30
|
return useApiGet<AppGrantsResource[]>(appGrantsRequestArgs, {
|
|
18
31
|
staleTime: FIVE_MINUTES,
|
|
@@ -57,6 +57,8 @@ export const availableFeatures = {
|
|
|
57
57
|
dm_search_clear_fix: 'ROLLOUT_MOBILE_dm_search_clear_fix',
|
|
58
58
|
search: 'QA_MOBILE_search',
|
|
59
59
|
search_rollout: 'ROLLOUT_MOBILE_search',
|
|
60
|
+
manual_archiving: 'QA_MOBILE_manual_archiving',
|
|
61
|
+
manual_archiving_rollout: 'ROLLOUT_MOBILE_manual_archiving',
|
|
60
62
|
} as const satisfies Record<string, `${'QA' | 'ROLLOUT'}_${string}`>
|
|
61
63
|
|
|
62
64
|
const stableEmptyFeatures: ApiCollection<FeatureResource> = {
|
package/src/hooks/use_jolt.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { useChatContext } from '../contexts/chat_context'
|
|
|
13
13
|
import { ApiResource } from '../types'
|
|
14
14
|
import type { JoltSubscriptionPattern } from '../types/jolt_events'
|
|
15
15
|
import { Client, Uri } from '../utils'
|
|
16
|
+
import { throwResponseError } from '../utils/response_error'
|
|
16
17
|
import { FIVE_SECONDS } from '../utils/time'
|
|
17
18
|
|
|
18
19
|
interface JoltResponse {
|
|
@@ -40,15 +41,17 @@ export const useJoltClient = (): JoltClient | undefined => {
|
|
|
40
41
|
return queryClient.fetchQuery({
|
|
41
42
|
queryKey: ['jolt-token'],
|
|
42
43
|
queryFn: () => {
|
|
43
|
-
return apiClient
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
return apiClient
|
|
45
|
+
.post<ApiResource<JoltResponse>>({
|
|
46
|
+
url: '/me/jolt_authorize',
|
|
46
47
|
data: {
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
data: {
|
|
49
|
+
type: 'JoltToken',
|
|
50
|
+
attributes: {},
|
|
51
|
+
},
|
|
49
52
|
},
|
|
50
|
-
}
|
|
51
|
-
|
|
53
|
+
})
|
|
54
|
+
.catch(throwResponseError)
|
|
52
55
|
},
|
|
53
56
|
staleTime: FIVE_SECONDS,
|
|
54
57
|
})
|
|
@@ -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(
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useRoute, RouteProp } from '@react-navigation/native'
|
|
2
2
|
import React from 'react'
|
|
3
|
-
import {
|
|
3
|
+
import { StyleSheet, View, ScrollView } from 'react-native'
|
|
4
4
|
import { Heading } from '../../components'
|
|
5
5
|
import { Icon, Text, TextInlineButton } from '../../components/display'
|
|
6
6
|
import { useTheme } from '../../hooks'
|
|
7
|
+
import { Linking } from '../../utils'
|
|
7
8
|
import type { AgeCheckParams } from './screen_props'
|
|
8
9
|
|
|
9
10
|
export interface AgeCheckUnderageScreenProps {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
|
2
2
|
import React, { useCallback } from 'react'
|
|
3
|
-
import { Alert,
|
|
3
|
+
import { Alert, StyleSheet } from 'react-native'
|
|
4
4
|
import FormSheet, { getFormSheetScreenOptions } from '../../components/primitive/form_sheet'
|
|
5
|
+
import { Linking } from '../../utils'
|
|
5
6
|
import { useDeleteAttachment } from './hooks/useDeleteAttachment'
|
|
6
7
|
|
|
7
8
|
export const AttachmentActionsScreenOptions = getFormSheetScreenOptions({
|
|
@@ -57,7 +58,7 @@ export function AttachmentActionsScreen({ route }: AttachmentActionsScreenProps)
|
|
|
57
58
|
}, [attachmentName, handleDeleteAttachment, navigation])
|
|
58
59
|
|
|
59
60
|
const handleOpenInBrowser = () => {
|
|
60
|
-
Linking.openURL(attachmentUrl)
|
|
61
|
+
Linking.openURL(attachmentUrl, { forceOpenInBrowser: true })
|
|
61
62
|
navigation.goBack()
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -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
|
|
|
@@ -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.
|
|
267
|
+
borderBottomColor: theme.colors.fillColorNeutral050Base,
|
|
268
268
|
paddingBottom: 16,
|
|
269
269
|
gap: 16,
|
|
270
270
|
},
|