@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.
Files changed (124) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/giphy_attachment.js +2 -2
  3. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  5. package/build/components/conversation/attachments/image_attachment.js +5 -3
  6. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  7. package/build/components/conversation/message.d.ts +3 -1
  8. package/build/components/conversation/message.d.ts.map +1 -1
  9. package/build/components/conversation/message.js +183 -158
  10. package/build/components/conversation/message.js.map +1 -1
  11. package/build/components/conversation/message_form.d.ts.map +1 -1
  12. package/build/components/conversation/message_form.js +2 -2
  13. package/build/components/conversation/message_form.js.map +1 -1
  14. package/build/components/conversation/message_list.d.ts.map +1 -1
  15. package/build/components/conversation/message_list.js +143 -88
  16. package/build/components/conversation/message_list.js.map +1 -1
  17. package/build/components/conversation/system_message.js +3 -3
  18. package/build/components/conversation/system_message.js.map +1 -1
  19. package/build/components/conversations/conversations.d.ts.map +1 -1
  20. package/build/components/conversations/conversations.js +0 -6
  21. package/build/components/conversations/conversations.js.map +1 -1
  22. package/build/contexts/api_provider.d.ts.map +1 -1
  23. package/build/contexts/api_provider.js +28 -25
  24. package/build/contexts/api_provider.js.map +1 -1
  25. package/build/hooks/use_animated_message_background_color.d.ts +1 -0
  26. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  27. package/build/hooks/use_animated_message_background_color.js +26 -9
  28. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  29. package/build/hooks/use_chat_permissions.d.ts +10 -2
  30. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  31. package/build/hooks/use_chat_permissions.js +8 -0
  32. package/build/hooks/use_chat_permissions.js.map +1 -1
  33. package/build/hooks/use_features.d.ts +2 -0
  34. package/build/hooks/use_features.d.ts.map +1 -1
  35. package/build/hooks/use_features.js +3 -1
  36. package/build/hooks/use_features.js.map +1 -1
  37. package/build/hooks/use_jolt.d.ts.map +1 -1
  38. package/build/hooks/use_jolt.js +2 -1
  39. package/build/hooks/use_jolt.js.map +1 -1
  40. package/build/hooks/use_product_analytics.d.ts +9 -0
  41. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  42. package/build/hooks/use_product_analytics.js +4 -2
  43. package/build/hooks/use_product_analytics.js.map +1 -1
  44. package/build/hooks/use_search_enabled.d.ts +2 -0
  45. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  46. package/build/hooks/use_search_enabled.js +18 -0
  47. package/build/hooks/use_search_enabled.js.map +1 -0
  48. package/build/screens/age_check/age_check_underage_screen.d.ts.map +1 -1
  49. package/build/screens/age_check/age_check_underage_screen.js +2 -1
  50. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  51. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  52. package/build/screens/attachment_actions/attachment_actions_screen.js +5 -2
  53. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  54. package/build/screens/bug_report_screen.d.ts.map +1 -1
  55. package/build/screens/bug_report_screen.js +2 -2
  56. package/build/screens/bug_report_screen.js.map +1 -1
  57. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  58. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  59. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  60. package/build/screens/conversations/conversations_screen.js +2 -5
  61. package/build/screens/conversations/conversations_screen.js.map +1 -1
  62. package/build/screens/get_help_screen.js +2 -2
  63. package/build/screens/get_help_screen.js.map +1 -1
  64. package/build/screens/message_actions_screen.js +6 -2
  65. package/build/screens/message_actions_screen.js.map +1 -1
  66. package/build/screens/search/components/conversation_results.d.ts +1 -1
  67. package/build/screens/search/components/conversation_results.d.ts.map +1 -1
  68. package/build/screens/search/components/conversation_results.js +2 -2
  69. package/build/screens/search/components/conversation_results.js.map +1 -1
  70. package/build/screens/search/components/search_results_list.d.ts +7 -2
  71. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  72. package/build/screens/search/components/search_results_list.js +135 -105
  73. package/build/screens/search/components/search_results_list.js.map +1 -1
  74. package/build/screens/search/search_screen.d.ts.map +1 -1
  75. package/build/screens/search/search_screen.js +88 -25
  76. package/build/screens/search/search_screen.js.map +1 -1
  77. package/build/utils/native_adapters/linking.d.ts +11 -3
  78. package/build/utils/native_adapters/linking.d.ts.map +1 -1
  79. package/build/utils/native_adapters/linking.js.map +1 -1
  80. package/build/utils/response_error.d.ts +1 -1
  81. package/build/utils/response_error.d.ts.map +1 -1
  82. package/build/utils/response_error.js +1 -1
  83. package/build/utils/response_error.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/__tests__/components/conversation/attachments/giphy_attachment.test.tsx +76 -0
  86. package/src/__tests__/contexts/api_provider.test.tsx +50 -0
  87. package/src/__tests__/screens/attachment_actions_screen.test.tsx +81 -0
  88. package/src/__tests__/screens/get_help_screen.test.ts +36 -0
  89. package/src/__tests__/screens/message_actions_screen.test.tsx +136 -0
  90. package/src/components/conversation/__tests__/message_list.test.tsx +71 -3
  91. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -3
  92. package/src/components/conversation/attachments/image_attachment.tsx +2 -3
  93. package/src/components/conversation/message.tsx +12 -1
  94. package/src/components/conversation/message_form.tsx +1 -1
  95. package/src/components/conversation/message_list.tsx +26 -1
  96. package/src/components/conversation/system_message.tsx +3 -3
  97. package/src/components/conversations/conversations.tsx +0 -7
  98. package/src/contexts/__tests__/api_provider.test.tsx +106 -0
  99. package/src/contexts/api_provider.tsx +6 -9
  100. package/src/hooks/__tests__/use_jolt.test.tsx +70 -0
  101. package/src/hooks/use_animated_message_background_color.ts +37 -12
  102. package/src/hooks/use_chat_permissions.ts +15 -2
  103. package/src/hooks/use_features.ts +2 -0
  104. package/src/hooks/use_jolt.ts +10 -7
  105. package/src/hooks/use_product_analytics.ts +3 -1
  106. package/src/hooks/use_search_enabled.ts +9 -0
  107. package/src/screens/age_check/age_check_underage_screen.tsx +2 -1
  108. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -2
  109. package/src/screens/bug_report_screen.tsx +2 -10
  110. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  111. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  112. package/src/screens/conversations/conversations_screen.tsx +2 -3
  113. package/src/screens/get_help_screen.tsx +2 -2
  114. package/src/screens/message_actions_screen.tsx +6 -2
  115. package/src/screens/search/__tests__/search_screen.test.tsx +130 -0
  116. package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
  117. package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
  118. package/src/screens/search/components/conversation_results.tsx +7 -3
  119. package/src/screens/search/components/search_results_list.tsx +25 -4
  120. package/src/screens/search/search_screen.tsx +44 -6
  121. package/src/utils/__tests__/response_error.test.ts +7 -2
  122. package/src/utils/native_adapters/__tests__/linking.test.ts +12 -0
  123. package/src/utils/native_adapters/linking.ts +12 -3
  124. 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 hasPeople = personIdsForSystemEvent.length > 0
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={hasPeople ? handlePress : undefined}
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
- hasPeople
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 { appGrantsRequestArgs } from '../hooks/use_chat_permissions'
12
+ import { appGrantsQueryOptions } from '../hooks/use_chat_permissions'
13
13
  import { featuresQueryOptions } from '../hooks/use_features'
14
- import { getRequestQueryKey, RequestQueryKey } from '../hooks/use_suspense_api'
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 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,7 +1,10 @@
1
- import { AppGrantsResource } from '../types'
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> = {
@@ -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.post<ApiResource<JoltResponse>>({
44
- url: '/me/jolt_authorize',
45
- data: {
44
+ return apiClient
45
+ .post<ApiResource<JoltResponse>>({
46
+ url: '/me/jolt_authorize',
46
47
  data: {
47
- type: 'JoltToken',
48
- attributes: {},
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(
@@ -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
+ }
@@ -1,9 +1,10 @@
1
1
  import { useRoute, RouteProp } from '@react-navigation/native'
2
2
  import React from 'react'
3
- import { Linking, StyleSheet, View, ScrollView } from 'react-native'
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, Linking, StyleSheet } from 'react-native'
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.statusErrorBorder,
267
+ borderBottomColor: theme.colors.fillColorNeutral050Base,
268
268
  paddingBottom: 16,
269
269
  gap: 16,
270
270
  },