@planningcenter/chat-react-native 4.2.0-rc.0 → 4.2.0-rc.2

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 (90) hide show
  1. package/build/components/conversation/message.d.ts +3 -2
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +23 -22
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts +21 -0
  6. package/build/components/conversation/message_list.d.ts.map +1 -0
  7. package/build/components/conversation/message_list.js +273 -0
  8. package/build/components/conversation/message_list.js.map +1 -0
  9. package/build/components/conversation/message_read_receipts.d.ts +2 -4
  10. package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
  11. package/build/components/conversation/message_read_receipts.js +2 -3
  12. package/build/components/conversation/message_read_receipts.js.map +1 -1
  13. package/build/components/conversation/reply_shadow_message.d.ts +2 -1
  14. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  15. package/build/components/conversation/reply_shadow_message.js +47 -46
  16. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  17. package/build/contexts/api_provider.d.ts.map +1 -1
  18. package/build/contexts/api_provider.js +12 -1
  19. package/build/contexts/api_provider.js.map +1 -1
  20. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  21. package/build/hooks/use_conversation_messages.js +40 -28
  22. package/build/hooks/use_conversation_messages.js.map +1 -1
  23. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  24. package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
  25. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  26. package/build/hooks/use_features.d.ts +16 -0
  27. package/build/hooks/use_features.d.ts.map +1 -1
  28. package/build/hooks/use_features.js +43 -47
  29. package/build/hooks/use_features.js.map +1 -1
  30. package/build/screens/conversation_screen.d.ts.map +1 -1
  31. package/build/screens/conversation_screen.js +184 -365
  32. package/build/screens/conversation_screen.js.map +1 -1
  33. package/build/screens/conversations/components/search_list_header_component.d.ts +2 -0
  34. package/build/screens/conversations/components/search_list_header_component.d.ts.map +1 -0
  35. package/build/screens/conversations/components/search_list_header_component.js +496 -0
  36. package/build/screens/conversations/components/search_list_header_component.js.map +1 -0
  37. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  38. package/build/screens/conversations/conversations_screen.js +28 -16
  39. package/build/screens/conversations/conversations_screen.js.map +1 -1
  40. package/build/types/resources/message.d.ts +1 -0
  41. package/build/types/resources/message.d.ts.map +1 -1
  42. package/build/types/resources/message.js.map +1 -1
  43. package/build/utils/cache/messages_cache.d.ts +1 -0
  44. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  45. package/build/utils/cache/messages_cache.js +10 -1
  46. package/build/utils/cache/messages_cache.js.map +1 -1
  47. package/build/utils/client/transform_response.d.ts.map +1 -1
  48. package/build/utils/client/transform_response.js +9 -5
  49. package/build/utils/client/transform_response.js.map +1 -1
  50. package/build/utils/group_messages.d.ts +1 -0
  51. package/build/utils/group_messages.d.ts.map +1 -1
  52. package/build/utils/group_messages.js +10 -1
  53. package/build/utils/group_messages.js.map +1 -1
  54. package/build/utils/replace_equal_by_id.d.ts +6 -0
  55. package/build/utils/replace_equal_by_id.d.ts.map +1 -0
  56. package/build/utils/replace_equal_by_id.js +41 -0
  57. package/build/utils/replace_equal_by_id.js.map +1 -0
  58. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  59. package/build/utils/request/messages_data_options.js +1 -1
  60. package/build/utils/request/messages_data_options.js.map +1 -1
  61. package/build/utils/time.d.ts +1 -0
  62. package/build/utils/time.d.ts.map +1 -1
  63. package/build/utils/time.js +1 -0
  64. package/build/utils/time.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/__tests__/hooks/use_features.test.ts +61 -0
  67. package/src/components/conversation/__tests__/message_list.test.tsx +277 -0
  68. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
  69. package/src/components/conversation/message.tsx +15 -18
  70. package/src/components/conversation/message_list.tsx +157 -0
  71. package/src/components/conversation/message_read_receipts.tsx +3 -5
  72. package/src/components/conversation/reply_shadow_message.tsx +9 -7
  73. package/src/contexts/api_provider.tsx +4 -0
  74. package/src/hooks/use_conversation_messages.ts +5 -1
  75. package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
  76. package/src/hooks/use_features.ts +26 -14
  77. package/src/screens/conversation_screen.tsx +11 -104
  78. package/src/screens/conversations/components/search_list_header_component.tsx +291 -0
  79. package/src/screens/conversations/conversations_screen.tsx +6 -1
  80. package/src/types/resources/message.ts +1 -0
  81. package/src/utils/__tests__/group_messages.test.ts +48 -0
  82. package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
  83. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
  84. package/src/utils/cache/messages_cache.ts +21 -1
  85. package/src/utils/client/__tests__/transform_response.test.ts +62 -0
  86. package/src/utils/client/transform_response.ts +7 -5
  87. package/src/utils/group_messages.ts +18 -1
  88. package/src/utils/replace_equal_by_id.ts +42 -0
  89. package/src/utils/request/messages_data_options.ts +1 -1
  90. package/src/utils/time.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import { some } from 'lodash'
3
- import React from 'react'
3
+ import React, { memo } from 'react'
4
4
  import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
5
5
  import Animated from 'react-native-reanimated'
6
6
  import {
@@ -37,18 +37,20 @@ interface ReplyShadowMessageProps extends MessageResource {
37
37
  nextRendersAuthor: boolean
38
38
  }
39
39
 
40
- export function ReplyShadowMessage({
40
+ export const ReplyShadowMessage = memo(function ReplyShadowMessageRow({
41
41
  conversation_id,
42
42
  inReplyScreen,
43
43
  messageId,
44
+ replyRoot,
44
45
  isReplyShadowMessage,
45
46
  nextRendersAuthor,
46
47
  }: ReplyShadowMessageProps) {
47
- const { message, isError, isLoading } = useConversationMessage({ conversation_id, messageId })
48
+ const fetched = useConversationMessage({ conversation_id, messageId, enabled: !replyRoot })
49
+ const message = replyRoot ?? fetched.message
48
50
 
49
51
  if (inReplyScreen) return null
50
- if (isLoading) return <ShadowMessageFallback text="Loading..." />
51
- if (isError || !message) return <ShadowMessageFallback text="Message deleted" />
52
+ if (!message && fetched.isLoading) return <ShadowMessageFallback text="Loading..." />
53
+ if (!message) return <ShadowMessageFallback text="Message deleted" />
52
54
 
53
55
  const enrichedMessage = {
54
56
  ...message,
@@ -57,7 +59,7 @@ export function ReplyShadowMessage({
57
59
  }
58
60
 
59
61
  return <ShadowMessageContent conversation_id={conversation_id} {...enrichedMessage} />
60
- }
62
+ })
61
63
 
62
64
  interface ShadowMessageContentProps extends MessageResource {
63
65
  conversation_id: number
@@ -82,7 +84,7 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
82
84
  const handleNavigateToReplies = () => {
83
85
  navigation.navigate('ConversationReply', {
84
86
  conversation_id,
85
- reply_root_id: message.replyRootId,
87
+ reply_root_id: message.replyRootId ?? message.id,
86
88
  reply_root_author_name: message.author.name,
87
89
  })
88
90
  }
@@ -10,6 +10,7 @@ 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
12
  import { appGrantsRequestArgs } from '../hooks/use_chat_permissions'
13
+ import { featuresQueryOptions } from '../hooks/use_features'
13
14
  import { getRequestQueryKey, RequestQueryKey } from '../hooks/use_suspense_api'
14
15
  import { emitAuthRefresh } from '../utils/auth_events'
15
16
  import { ResponseError } from '../utils/response_error'
@@ -81,10 +82,13 @@ export function ApiProvider({ children }: ViewProps) {
81
82
  // This needs to live in the provider so that it can access the api client
82
83
  // and the chat query client
83
84
  const PrefetchQueries = () => {
85
+ const client = useApiClient()
86
+
84
87
  usePrefetchQuery({
85
88
  queryKey: getRequestQueryKey(appGrantsRequestArgs),
86
89
  queryFn: defaultQueryFn,
87
90
  })
91
+ usePrefetchQuery(featuresQueryOptions(client))
88
92
 
89
93
  return null
90
94
  }
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from 'react'
2
2
  import { MessageResource } from '../types'
3
+ import { replaceEqualById } from '../utils/replace_equal_by_id'
3
4
  import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
4
5
  import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
5
6
 
@@ -10,7 +11,10 @@ export const useConversationMessages = (
10
11
  const { data, refetch, isRefetching, fetchNextPage, hasNextPage } =
11
12
  useSuspensePaginator<MessageResource>(
12
13
  getMessagesRequestArgs({ conversation_id, reply_root_id }),
13
- opts
14
+ {
15
+ structuralSharing: replaceEqualById,
16
+ ...opts,
17
+ }
14
18
  )
15
19
  const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
16
20
  const messages = useMemo(
@@ -11,6 +11,7 @@ import {
11
11
  updateCacheWithMessage,
12
12
  updateCacheWithIndividualMessage,
13
13
  updateCacheWithReaction,
14
+ updateCacheReplyRoots,
14
15
  getThreadedMessagesQueryKey,
15
16
  } from '../utils/cache/messages_cache'
16
17
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
@@ -44,6 +45,7 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
44
45
  data,
45
46
  currentPersonId: currentPerson.id,
46
47
  })
48
+ const isReplyRootUpdate = e.event === 'message.updated' && data.reply_root_id === data.sort_key
47
49
 
48
50
  if (e.event === 'message.created' && data.author_id) {
49
51
  removeAllTypingEventsByAuthorId(data.author_id)
@@ -62,6 +64,11 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
62
64
  data.reply_root_id
63
65
  )
64
66
  updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
67
+
68
+ if (isReplyRootUpdate) {
69
+ updateCacheReplyRoots(queryClient, messagesQueryKey, message)
70
+ updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
71
+ }
65
72
  }
66
73
 
67
74
  // Update individual message query cache so reply shadow messages update immediately
@@ -1,26 +1,20 @@
1
- import { useSuspenseQuery } from '@tanstack/react-query'
1
+ import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
2
2
  import { useCallback } from 'react'
3
3
  import { ApiCollection } from '../types'
4
4
  import type { FeatureResource } from '../types/resources/feature_resource'
5
5
  import { getFeaturesRequestArgs, getFeaturesQueryKey } from '../utils/request/get_features'
6
- import { FIVE_MINUTES } from '../utils/time'
7
- import { useApiClient } from './use_api_client'
6
+ import { FIVE_MINUTES, ONE_MINUTE } from '../utils/time'
7
+ import { ApiClient, useApiClient } from './use_api_client'
8
8
 
9
9
  type FeatureName = (typeof availableFeatures)[keyof typeof availableFeatures]
10
10
 
11
+ type FeaturesCollection = ApiCollection<FeatureResource> & {
12
+ failure?: { status?: number }
13
+ }
14
+
11
15
  export function useFeatures() {
12
16
  const apiClient = useApiClient()
13
- const requestArgs = getFeaturesRequestArgs()
14
-
15
- const { data } = useSuspenseQuery({
16
- queryKey: getFeaturesQueryKey(),
17
- queryFn: () => {
18
- return apiClient.chat
19
- .get<ApiCollection<FeatureResource>>(requestArgs)
20
- .catch(() => stableEmptyFeatures)
21
- },
22
- staleTime: FIVE_MINUTES,
23
- })
17
+ const { data } = useSuspenseQuery(featuresQueryOptions(apiClient))
24
18
 
25
19
  const features = data.data
26
20
 
@@ -36,6 +30,23 @@ export function useFeatures() {
36
30
  }
37
31
  }
38
32
 
33
+ export const featuresQueryOptions = (apiClient: ApiClient) =>
34
+ queryOptions({
35
+ queryKey: getFeaturesQueryKey(),
36
+ queryFn: (): Promise<FeaturesCollection> => {
37
+ return apiClient.chat
38
+ .get<ApiCollection<FeatureResource>>(getFeaturesRequestArgs())
39
+ .catch(error => ({ ...stableEmptyFeatures, failure: { status: error?.status } }))
40
+ },
41
+ staleTime: query => {
42
+ const failure = query.state.data?.failure
43
+ if (!failure) return FIVE_MINUTES
44
+
45
+ return failure.status === 429 ? ONE_MINUTE : 0
46
+ },
47
+ gcTime: Infinity,
48
+ })
49
+
39
50
  export const availableFeatures = {
40
51
  gender_specific_conversations: 'ROLLOUT_gender_specific_conversations',
41
52
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
@@ -44,6 +55,7 @@ export const availableFeatures = {
44
55
  conversation_safety_lock: 'ROLLOUT_conversation_safety_lock',
45
56
  video_moderation: 'ROLLOUT_MOBILE_video_moderation',
46
57
  dm_search_clear_fix: 'ROLLOUT_MOBILE_dm_search_clear_fix',
58
+ search: 'ROLLOUT_search',
47
59
  } as const satisfies Record<string, `ROLLOUT_${string}`>
48
60
 
49
61
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -1,4 +1,3 @@
1
- import { date as formatDate } from '@planningcenter/datetime-fmt'
2
1
  import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements'
3
2
  import {
4
3
  CommonActions,
@@ -11,24 +10,21 @@ import {
11
10
  import React, { useCallback, useEffect, useRef, useState } from 'react'
12
11
  import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
13
12
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
14
- import { Badge, Icon, Text } from '../components'
13
+ import { Badge, Icon } from '../components'
15
14
  import { ConversationStartBanner } from '../components/conversation/conversation_start_banner'
16
15
  import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
17
- import { Message } from '../components/conversation/message'
18
16
  import { MessageForm } from '../components/conversation/message_form'
17
+ import { MessageList } from '../components/conversation/message_list'
19
18
  import {
20
19
  ConversationDisabledBanner,
21
20
  LeaderMessagesDisabledBanner,
22
21
  MemberMessagesDisabledBanner,
23
22
  } from '../components/conversation/messages_disabled_banners'
24
- import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
25
- import { SystemMessage } from '../components/conversation/system_message'
26
23
  import { TypingIndicator } from '../components/conversation/typing_indicator'
27
24
  import { KeyboardView } from '../components/display/keyboard_view'
28
25
  import { useHeaderSlotReserve } from '../components/display/platform_modal_header_buttons'
29
26
  import BlankState from '../components/primitive/blank_state_primitive'
30
27
  import { ConversationContextProvider } from '../contexts/conversation_context'
31
- import { useTheme } from '../hooks'
32
28
  import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action'
33
29
  import { useConversation } from '../hooks/use_conversation'
34
30
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
@@ -45,10 +41,6 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
45
41
  import { ConversationResource } from '../types/resources/conversation'
46
42
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
47
43
  import { MessageResource } from '../types/resources/message'
48
- import { getRelativeDateStatus } from '../utils/date'
49
- import { groupMessages, type DateSeparator } from '../utils/group_messages'
50
- import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
51
- import { isSystemMessage } from '../utils/system_messages'
52
44
 
53
45
  export type ConversationRouteProps = {
54
46
  conversation_id: number
@@ -101,16 +93,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
101
93
  const { markHandledByMessageForm } = useVideoModerationJoltEvents()
102
94
  useEnsureConversationsRouteExists()
103
95
  useMarkLatestMessageRead({ conversation, messages })
104
- const messagesWithSeparators = groupMessages({
105
- ms: messages,
106
- inReplyScreen: !!reply_root_id,
107
- })
108
- const noMessages = messagesWithSeparators.length === 0
96
+ const noMessages = messages.length === 0
109
97
 
110
98
  const { repliesDisabled, memberAbility, badges, title } = conversation
111
99
  const canReply = memberAbility?.canReply
112
100
  const showLeaderDisabledReplyBanner = canReply && repliesDisabled
113
- const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
114
101
  const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id))
115
102
  const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
116
103
  const replyHeaderTitle = replyRootAuthorFirstName
@@ -181,54 +168,19 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
181
168
  <ConversationStartBanner conversation={conversation} hasMessages={false} />
182
169
  </View>
183
170
  ) : (
184
- <FlatList
185
- inverted
186
- ref={listRef}
187
- contentContainerStyle={styles.listContainer}
188
- data={messagesWithSeparators}
189
- keyExtractor={item => item.id}
171
+ <MessageList
172
+ listRef={listRef}
173
+ conversation={conversation}
174
+ conversationId={conversation_id}
175
+ messages={messages}
176
+ replyRootId={reply_root_id}
177
+ hasNextPage={hasNextPage}
178
+ onEndReached={() => fetchNextPage()}
190
179
  onScroll={trackScroll}
191
180
  onLayout={onLayout}
192
181
  onContentSizeChange={onContentSizeChange}
193
182
  onScrollEndDrag={onScrollEndDrag}
194
183
  onMomentumScrollEnd={onMomentumScrollEnd}
195
- scrollEventThrottle={10}
196
- ListFooterComponent={
197
- hasNextPage ? null : (
198
- <ConversationStartBanner conversation={conversation} hasMessages={true} />
199
- )
200
- }
201
- renderItem={({ item }) => {
202
- if (item.type === 'DateSeparator') {
203
- return <InlineDateSeparator {...item} />
204
- }
205
-
206
- if (item.type === 'ReplyShadowMessage') {
207
- return (
208
- <ReplyShadowMessage
209
- {...(item as any)}
210
- conversation_id={conversation_id}
211
- inReplyScreen={!!reply_root_id}
212
- />
213
- )
214
- }
215
-
216
- if (isSystemMessage(item)) {
217
- return <SystemMessage message={item} conversationId={conversation_id} />
218
- }
219
-
220
- return (
221
- <Message
222
- {...item}
223
- canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
224
- conversation_id={conversation_id}
225
- latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
226
- inReplyScreen={!!reply_root_id}
227
- />
228
- )
229
- }}
230
- onEndReached={() => fetchNextPage()}
231
- ListHeaderComponent={<View style={styles.listHeader} />}
232
184
  />
233
185
  )}
234
186
  <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
@@ -295,44 +247,6 @@ function ConversationBottomBar({
295
247
  )
296
248
  }
297
249
 
298
- function InlineDateSeparator({ date }: DateSeparator) {
299
- const styles = useDateSeparatorStyles()
300
- const { isThisYear } = getRelativeDateStatus(date)
301
- const showYear = !isThisYear
302
- const dateStamp = formatDate(date, { style: 'long', year: showYear })
303
-
304
- return (
305
- <View style={styles.container}>
306
- <View style={styles.separator} />
307
- <Text variant="footnote" style={styles.dateText}>
308
- {dateStamp}
309
- </Text>
310
- <View style={styles.separator} />
311
- </View>
312
- )
313
- }
314
-
315
- const useDateSeparatorStyles = () => {
316
- const theme = useTheme()
317
- return StyleSheet.create({
318
- container: {
319
- alignItems: 'center',
320
- flexDirection: 'row',
321
- paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
322
- paddingVertical: 16,
323
- },
324
- separator: {
325
- flex: 1,
326
- height: 1,
327
- borderTopWidth: 1,
328
- borderTopColor: theme.colors.borderColorDefaultBase,
329
- },
330
- dateText: {
331
- paddingHorizontal: 8,
332
- },
333
- })
334
- }
335
-
336
250
  interface ConversationScreenTitleProps extends HeaderTitleProps {
337
251
  conversation_id: number
338
252
  badge?: ConversationBadgeResource
@@ -429,13 +343,6 @@ const useStyles = () => {
429
343
  flex: 1,
430
344
  justifyContent: 'center',
431
345
  },
432
- listContainer: {
433
- paddingVertical: 12,
434
- },
435
- listHeader: {
436
- // Just whitespace to provide space where the typing indicator can be
437
- height: 16,
438
- },
439
346
  })
440
347
  }
441
348
 
@@ -0,0 +1,291 @@
1
+ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'
2
+ import React, { useCallback, useMemo } from 'react'
3
+ import { Alert, ScrollView, StyleSheet, View } from 'react-native'
4
+ import { Heading, TextButton, ToggleButton } from '../../../components'
5
+ import {
6
+ useAtFontScaleBreakpoint,
7
+ useCanCreateConversations,
8
+ useCurrentPersonCache,
9
+ useDirectMessagesEligible,
10
+ useHasDirectMessages,
11
+ useNewConversationEntry,
12
+ useTheme,
13
+ } from '../../../hooks'
14
+ import { useMarkAllRead } from '../../../hooks/use_conversations_actions'
15
+ import { useCanDisplayGroups } from '../../../hooks/use_groups'
16
+ import { useNewConversationFromFilter } from '../../../hooks/use_new_conversation_from_filter'
17
+ import { GraphId } from '../../../types/resources/group_resource'
18
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../../utils'
19
+ import { Haptic } from '../../../utils/native_adapters'
20
+ import { ConversationFiltersParams } from '../../conversation_filters/screen_props'
21
+ import { ConversationsScreenProps } from '../conversations_screen'
22
+ import { ChatGroupBadge } from './chat_group_badge'
23
+ import { ListHeaderActionButton } from './list_header_action_button'
24
+
25
+ const ROW_PADDING_HORIZONTAL = 16
26
+
27
+ enum FilterTypes {
28
+ All = 'All',
29
+ Groups = 'Groups',
30
+ Teams = 'Teams',
31
+ DMs = 'DMs',
32
+ More = 'More',
33
+ }
34
+
35
+ export const SearchListHeaderComponent = () => {
36
+ const navigation = useNavigation()
37
+ const canFilterByTeams = useCanDisplayGroups({ source_app_name: 'Services', source_type: 'Team' })
38
+ const canFilterByGroups = useCanDisplayGroups({ source_app_name: 'Groups', source_type: 'Group' })
39
+ const directMessagesEligible = useDirectMessagesEligible()
40
+ const hasDirectMessages = useHasDirectMessages()
41
+ const canFilterByDMs = directMessagesEligible && hasDirectMessages
42
+ const route = useRoute<RouteProp<ConversationsScreenProps['route']>>()
43
+ const {
44
+ chat_group_graph_id,
45
+ group_source_app_name = '',
46
+ show_direct_messages,
47
+ } = route.params || {}
48
+ const currentPersonCache = useCurrentPersonCache()
49
+ const { markAllRead, isPending } = useMarkAllRead()
50
+ const canCreateConversations = useCanCreateConversations()
51
+ const entryMode = useNewConversationEntry()
52
+ const newConversationFromFilter = useNewConversationFromFilter(
53
+ chat_group_graph_id as GraphId | undefined
54
+ )
55
+
56
+ const active: FilterTypes = useMemo(() => {
57
+ if (chat_group_graph_id) {
58
+ return FilterTypes.More
59
+ } else if (/groups/i.test(group_source_app_name)) {
60
+ return FilterTypes.Groups
61
+ } else if (/services/i.test(group_source_app_name)) {
62
+ return FilterTypes.Teams
63
+ } else if (show_direct_messages) {
64
+ return FilterTypes.DMs
65
+ }
66
+
67
+ return FilterTypes.All
68
+ }, [chat_group_graph_id, group_source_app_name, show_direct_messages])
69
+
70
+ const handleNewConversationNavigation = useCallback(() => {
71
+ if (newConversationFromFilter || entryMode === 'select_type') {
72
+ return navigation.navigate('ConversationSelectType', {
73
+ ...route.params,
74
+ newConversationFromFilter,
75
+ })
76
+ }
77
+
78
+ if (entryMode === 'groups') {
79
+ return navigation.navigate('New', {
80
+ screen: 'ConversationSelectGroupRecipients',
81
+ params: { ...route.params },
82
+ })
83
+ }
84
+
85
+ if (entryMode === 'teams') {
86
+ return navigation.navigate('New', {
87
+ screen: 'ConversationSelectTeamsILeadRecipients',
88
+ params: { ...route.params },
89
+ })
90
+ }
91
+
92
+ if (entryMode === 'direct_message') {
93
+ return navigation.navigate('New', {
94
+ screen: 'ConversationSelectDirectMessageRecipients',
95
+ params: { ...route.params },
96
+ })
97
+ }
98
+ }, [navigation, route.params, entryMode, newConversationFromFilter])
99
+
100
+ const handleSetFilter = useCallback(
101
+ (params: ConversationFiltersParams) => {
102
+ navigation.setParams({
103
+ title: undefined,
104
+ chat_group_graph_id: undefined,
105
+ group_source_app_name: undefined,
106
+ show_direct_messages: undefined,
107
+ ...params,
108
+ })
109
+ },
110
+ [navigation]
111
+ )
112
+
113
+ const handleChatSettings = useCallback(() => {
114
+ navigation.navigate('ChatSettings', {})
115
+ }, [navigation])
116
+
117
+ const handleOpenConversationFilters = useCallback(() => {
118
+ navigation.navigate('ConversationFilters', {
119
+ chat_group_graph_id,
120
+ group_source_app_name,
121
+ show_direct_messages,
122
+ })
123
+ }, [navigation, chat_group_graph_id, group_source_app_name, show_direct_messages])
124
+
125
+ const handleMarkAllRead = useCallback(() => {
126
+ currentPersonCache.update({ unreadCount: 0 })
127
+ Haptic.notificationSuccess()
128
+ markAllRead()
129
+ }, [currentPersonCache, markAllRead])
130
+
131
+ const alertMarkAllRead = useCallback(() => {
132
+ if (active === FilterTypes.All) return handleMarkAllRead()
133
+
134
+ Alert.alert('Mark all read', 'This includes conversations not shown by the current filter.', [
135
+ { text: 'Cancel' },
136
+ { text: 'OK', onPress: () => handleMarkAllRead() },
137
+ ])
138
+ }, [active, handleMarkAllRead])
139
+
140
+ const hasSomeSourceFilterCapability = canFilterByTeams || canFilterByGroups
141
+ const hasMultiSourceFilterCapabilities = canFilterByTeams && canFilterByGroups
142
+ const showFilterRow = hasMultiSourceFilterCapabilities || canFilterByDMs
143
+ const shouldShowHeaderFiltersButton = hasSomeSourceFilterCapability && !showFilterRow
144
+ const moreFiltersButtonA11yLabel = shouldShowHeaderFiltersButton
145
+ ? 'View filters'
146
+ : 'View more filters'
147
+
148
+ const moreFiltersButtonProps = {
149
+ onPress: handleOpenConversationFilters,
150
+ active: active === FilterTypes.More,
151
+ iconNameRight: 'general.threeReducingHorizontalBars' as const,
152
+ accessibilityLabel: moreFiltersButtonA11yLabel,
153
+ }
154
+
155
+ const styles = useStyles({ shouldShowHeaderFiltersButton })
156
+
157
+ return (
158
+ <View style={styles.listHeader}>
159
+ <View style={styles.headingRow}>
160
+ <View style={styles.titleContainer}>
161
+ <Heading numberOfLines={1} variant="h2">
162
+ Conversations
163
+ </Heading>
164
+ <TextButton
165
+ onPress={alertMarkAllRead}
166
+ disabled={isPending}
167
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
168
+ accessibilityShowsLargeContentViewer
169
+ accessibilityLargeContentTitle="Mark all read"
170
+ >
171
+ Mark all read
172
+ </TextButton>
173
+ </View>
174
+ <View style={styles.actionsContainer}>
175
+ <ListHeaderActionButton
176
+ name="general.cog"
177
+ onPress={handleChatSettings}
178
+ accessibilityLabel="Chat settings"
179
+ testID="conversations-chat-settings-button"
180
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
181
+ />
182
+ {canCreateConversations && (
183
+ <ListHeaderActionButton
184
+ name="churchCenter.signups"
185
+ onPress={handleNewConversationNavigation}
186
+ accessibilityLabel="New conversation"
187
+ testID="conversations-new-conversation-button"
188
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
189
+ />
190
+ )}
191
+ {shouldShowHeaderFiltersButton && <ToggleButton {...moreFiltersButtonProps} />}
192
+ </View>
193
+ </View>
194
+ {showFilterRow && (
195
+ <ScrollView
196
+ horizontal
197
+ showsHorizontalScrollIndicator={false}
198
+ contentContainerStyle={styles.filterRow}
199
+ accessibilityRole="toolbar"
200
+ >
201
+ <ToggleButton
202
+ title={FilterTypes.All}
203
+ active={active === FilterTypes.All}
204
+ onPress={() => handleSetFilter({})}
205
+ accessibilityLabel="Show all conversations"
206
+ style={styles.filterButton}
207
+ />
208
+ {canFilterByGroups && (
209
+ <ToggleButton
210
+ title={FilterTypes.Groups}
211
+ active={active === FilterTypes.Groups}
212
+ onPress={() => handleSetFilter({ group_source_app_name: 'Groups' })}
213
+ accessibilityLabel="Filter to group conversations"
214
+ style={styles.filterButton}
215
+ />
216
+ )}
217
+ {canFilterByTeams && (
218
+ <ToggleButton
219
+ title={FilterTypes.Teams}
220
+ active={active === FilterTypes.Teams}
221
+ onPress={() => handleSetFilter({ group_source_app_name: 'Services' })}
222
+ accessibilityLabel="Filter to team conversations"
223
+ style={styles.filterButton}
224
+ />
225
+ )}
226
+ {canFilterByDMs && (
227
+ <ToggleButton
228
+ title={FilterTypes.DMs}
229
+ active={active === FilterTypes.DMs}
230
+ onPress={() => handleSetFilter({ show_direct_messages: true })}
231
+ accessibilityLabel="Filter to direct message conversations"
232
+ style={styles.filterButton}
233
+ />
234
+ )}
235
+ <ToggleButton {...moreFiltersButtonProps} />
236
+ </ScrollView>
237
+ )}
238
+ <ChatGroupBadge rowPaddingHorizontal={ROW_PADDING_HORIZONTAL} />
239
+ </View>
240
+ )
241
+ }
242
+
243
+ const useStyles = ({
244
+ shouldShowHeaderFiltersButton,
245
+ }: {
246
+ shouldShowHeaderFiltersButton: boolean
247
+ }) => {
248
+ const theme = useTheme()
249
+ const atFontScaleBreakpoint = useAtFontScaleBreakpoint(
250
+ shouldShowHeaderFiltersButton ? 1.2 : undefined
251
+ )
252
+
253
+ return StyleSheet.create({
254
+ listHeader: {
255
+ borderBottomWidth: 1,
256
+ borderBottomColor: theme.colors.statusErrorBorder,
257
+ paddingBottom: 16,
258
+ gap: 16,
259
+ },
260
+ headingRow: {
261
+ gap: 12,
262
+ flexDirection: atFontScaleBreakpoint ? 'column' : 'row',
263
+ alignItems: atFontScaleBreakpoint ? 'flex-start' : 'center',
264
+ justifyContent: 'space-between',
265
+ paddingBottom: atFontScaleBreakpoint ? 4 : 0,
266
+ paddingHorizontal: ROW_PADDING_HORIZONTAL,
267
+ },
268
+ titleContainer: {
269
+ gap: 8,
270
+ },
271
+ actionsContainer: {
272
+ flexDirection: 'row',
273
+ gap: 8,
274
+ alignItems: 'center',
275
+ },
276
+ markAllReadRow: {
277
+ paddingBottom: 4,
278
+ },
279
+ filterRow: {
280
+ flexDirection: 'row',
281
+ justifyContent: 'flex-start',
282
+ gap: 8,
283
+ paddingHorizontal: ROW_PADDING_HORIZONTAL,
284
+ flexGrow: 1,
285
+ },
286
+ filterButton: {
287
+ flex: 1,
288
+ minWidth: 65,
289
+ },
290
+ })
291
+ }