@planningcenter/chat-react-native 3.39.0-rc.0 → 3.39.0-rc.10

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 (205) hide show
  1. package/build/components/conversation/messages_disabled_banners.d.ts +6 -1
  2. package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -1
  3. package/build/components/conversation/messages_disabled_banners.js +8 -2
  4. package/build/components/conversation/messages_disabled_banners.js.map +1 -1
  5. package/build/contexts/api_provider.js +2 -1
  6. package/build/contexts/api_provider.js.map +1 -1
  7. package/build/contexts/conversations_context.d.ts.map +1 -1
  8. package/build/contexts/conversations_context.js +6 -2
  9. package/build/contexts/conversations_context.js.map +1 -1
  10. package/build/hooks/direct_messages/use_direct_message_affiliations.d.ts +5 -0
  11. package/build/hooks/direct_messages/use_direct_message_affiliations.d.ts.map +1 -0
  12. package/build/hooks/direct_messages/use_direct_message_affiliations.js +20 -0
  13. package/build/hooks/direct_messages/use_direct_message_affiliations.js.map +1 -0
  14. package/build/hooks/direct_messages/use_direct_message_candidates.d.ts +4 -0
  15. package/build/hooks/direct_messages/use_direct_message_candidates.d.ts.map +1 -0
  16. package/build/hooks/direct_messages/use_direct_message_candidates.js +28 -0
  17. package/build/hooks/direct_messages/use_direct_message_candidates.js.map +1 -0
  18. package/build/hooks/direct_messages/use_direct_message_conversation_validate.d.ts +11 -0
  19. package/build/hooks/direct_messages/use_direct_message_conversation_validate.d.ts.map +1 -0
  20. package/build/hooks/direct_messages/use_direct_message_conversation_validate.js +25 -0
  21. package/build/hooks/direct_messages/use_direct_message_conversation_validate.js.map +1 -0
  22. package/build/hooks/direct_messages/use_find_or_create_direct_message_conversation.d.ts +13 -0
  23. package/build/hooks/direct_messages/use_find_or_create_direct_message_conversation.d.ts.map +1 -0
  24. package/build/hooks/direct_messages/use_find_or_create_direct_message_conversation.js +51 -0
  25. package/build/hooks/direct_messages/use_find_or_create_direct_message_conversation.js.map +1 -0
  26. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -1
  27. package/build/hooks/groups/use_group_chat_conversation_payload.js +2 -1
  28. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -1
  29. package/build/hooks/index.d.ts +1 -0
  30. package/build/hooks/index.d.ts.map +1 -1
  31. package/build/hooks/index.js +1 -0
  32. package/build/hooks/index.js.map +1 -1
  33. package/build/hooks/services/use_services_chat_conversation_payload.d.ts.map +1 -1
  34. package/build/hooks/services/use_services_chat_conversation_payload.js +2 -1
  35. package/build/hooks/services/use_services_chat_conversation_payload.js.map +1 -1
  36. package/build/hooks/use_api.d.ts +5 -1
  37. package/build/hooks/use_api.d.ts.map +1 -1
  38. package/build/hooks/use_api.js +2 -1
  39. package/build/hooks/use_api.js.map +1 -1
  40. package/build/hooks/use_chat_configuration.d.ts.map +1 -1
  41. package/build/hooks/use_chat_configuration.js +2 -1
  42. package/build/hooks/use_chat_configuration.js.map +1 -1
  43. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  44. package/build/hooks/use_chat_permissions.js +4 -1
  45. package/build/hooks/use_chat_permissions.js.map +1 -1
  46. package/build/hooks/use_conversation.d.ts.map +1 -1
  47. package/build/hooks/use_conversation.js +1 -0
  48. package/build/hooks/use_conversation.js.map +1 -1
  49. package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -1
  50. package/build/hooks/use_conversation_jolt_events.js +2 -2
  51. package/build/hooks/use_conversation_jolt_events.js.map +1 -1
  52. package/build/hooks/use_current_person.d.ts.map +1 -1
  53. package/build/hooks/use_current_person.js +4 -2
  54. package/build/hooks/use_current_person.js.map +1 -1
  55. package/build/hooks/use_features.d.ts.map +1 -1
  56. package/build/hooks/use_features.js +2 -1
  57. package/build/hooks/use_features.js.map +1 -1
  58. package/build/hooks/use_groups.d.ts.map +1 -1
  59. package/build/hooks/use_groups.js +2 -1
  60. package/build/hooks/use_groups.js.map +1 -1
  61. package/build/hooks/use_groups_groups.d.ts.map +1 -1
  62. package/build/hooks/use_groups_groups.js +2 -1
  63. package/build/hooks/use_groups_groups.js.map +1 -1
  64. package/build/hooks/use_has_direct_messages.d.ts +2 -0
  65. package/build/hooks/use_has_direct_messages.d.ts.map +1 -0
  66. package/build/hooks/use_has_direct_messages.js +17 -0
  67. package/build/hooks/use_has_direct_messages.js.map +1 -0
  68. package/build/hooks/use_jolt.d.ts.map +1 -1
  69. package/build/hooks/use_jolt.js +2 -1
  70. package/build/hooks/use_jolt.js.map +1 -1
  71. package/build/hooks/use_organization.d.ts.map +1 -1
  72. package/build/hooks/use_organization.js +4 -1
  73. package/build/hooks/use_organization.js.map +1 -1
  74. package/build/hooks/use_person_settings.d.ts +6 -0
  75. package/build/hooks/use_person_settings.d.ts.map +1 -0
  76. package/build/hooks/use_person_settings.js +26 -0
  77. package/build/hooks/use_person_settings.js.map +1 -0
  78. package/build/navigation/index.d.ts +13 -0
  79. package/build/navigation/index.d.ts.map +1 -1
  80. package/build/navigation/index.js +9 -0
  81. package/build/navigation/index.js.map +1 -1
  82. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  83. package/build/screens/conversation_details_screen.js +2 -1
  84. package/build/screens/conversation_details_screen.js.map +1 -1
  85. package/build/screens/conversation_filters/components/conversation_filters.d.ts.map +1 -1
  86. package/build/screens/conversation_filters/components/conversation_filters.js +20 -5
  87. package/build/screens/conversation_filters/components/conversation_filters.js.map +1 -1
  88. package/build/screens/conversation_filters/components/rows.d.ts +2 -1
  89. package/build/screens/conversation_filters/components/rows.d.ts.map +1 -1
  90. package/build/screens/conversation_filters/components/rows.js +2 -2
  91. package/build/screens/conversation_filters/components/rows.js.map +1 -1
  92. package/build/screens/conversation_filters/context/conversation_filter_context.d.ts.map +1 -1
  93. package/build/screens/conversation_filters/context/conversation_filter_context.js +4 -0
  94. package/build/screens/conversation_filters/context/conversation_filter_context.js.map +1 -1
  95. package/build/screens/conversation_filters/filter_types.d.ts +1 -0
  96. package/build/screens/conversation_filters/filter_types.d.ts.map +1 -1
  97. package/build/screens/conversation_filters/filter_types.js +1 -0
  98. package/build/screens/conversation_filters/filter_types.js.map +1 -1
  99. package/build/screens/conversation_filters/screen_props.d.ts +1 -0
  100. package/build/screens/conversation_filters/screen_props.d.ts.map +1 -1
  101. package/build/screens/conversation_filters/screen_props.js.map +1 -1
  102. package/build/screens/conversation_screen.d.ts.map +1 -1
  103. package/build/screens/conversation_screen.js +1 -1
  104. package/build/screens/conversation_screen.js.map +1 -1
  105. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts +1 -2
  106. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  107. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +319 -10
  108. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  109. package/build/screens/conversations/components/list_header_component.d.ts.map +1 -1
  110. package/build/screens/conversations/components/list_header_component.js +35 -17
  111. package/build/screens/conversations/components/list_header_component.js.map +1 -1
  112. package/build/screens/conversations/conversations_screen.d.ts +1 -0
  113. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  114. package/build/screens/conversations/conversations_screen.js.map +1 -1
  115. package/build/screens/get_help_screen.d.ts.map +1 -1
  116. package/build/screens/get_help_screen.js +2 -18
  117. package/build/screens/get_help_screen.js.map +1 -1
  118. package/build/screens/index.d.ts +1 -0
  119. package/build/screens/index.d.ts.map +1 -1
  120. package/build/screens/index.js +1 -0
  121. package/build/screens/index.js.map +1 -1
  122. package/build/screens/settings_screen.d.ts +5 -0
  123. package/build/screens/settings_screen.d.ts.map +1 -0
  124. package/build/screens/settings_screen.js +56 -0
  125. package/build/screens/settings_screen.js.map +1 -0
  126. package/build/types/jolt_events/conversation_events.d.ts +2 -0
  127. package/build/types/jolt_events/conversation_events.d.ts.map +1 -1
  128. package/build/types/jolt_events/conversation_events.js.map +1 -1
  129. package/build/types/resources/conversation.d.ts +2 -0
  130. package/build/types/resources/conversation.d.ts.map +1 -1
  131. package/build/types/resources/conversation.js.map +1 -1
  132. package/build/types/resources/direct_message_affiliations.d.ts +8 -0
  133. package/build/types/resources/direct_message_affiliations.d.ts.map +1 -0
  134. package/build/types/resources/direct_message_affiliations.js +2 -0
  135. package/build/types/resources/direct_message_affiliations.js.map +1 -0
  136. package/build/types/resources/direct_message_candidate.d.ts +12 -0
  137. package/build/types/resources/direct_message_candidate.d.ts.map +1 -0
  138. package/build/types/resources/direct_message_candidate.js +2 -0
  139. package/build/types/resources/direct_message_candidate.js.map +1 -0
  140. package/build/types/resources/index.d.ts +2 -0
  141. package/build/types/resources/index.d.ts.map +1 -1
  142. package/build/types/resources/index.js +2 -0
  143. package/build/types/resources/index.js.map +1 -1
  144. package/build/types/resources/organization.d.ts +1 -0
  145. package/build/types/resources/organization.d.ts.map +1 -1
  146. package/build/types/resources/organization.js.map +1 -1
  147. package/build/types/resources/person.d.ts +1 -0
  148. package/build/types/resources/person.d.ts.map +1 -1
  149. package/build/types/resources/person.js.map +1 -1
  150. package/build/utils/request/conversation.d.ts +2 -1
  151. package/build/utils/request/conversation.d.ts.map +1 -1
  152. package/build/utils/request/conversation.js +15 -6
  153. package/build/utils/request/conversation.js.map +1 -1
  154. package/build/utils/time.d.ts +8 -0
  155. package/build/utils/time.d.ts.map +1 -0
  156. package/build/utils/time.js +15 -0
  157. package/build/utils/time.js.map +1 -0
  158. package/package.json +2 -2
  159. package/src/components/conversation/messages_disabled_banners.tsx +16 -2
  160. package/src/contexts/api_provider.tsx +4 -1
  161. package/src/contexts/conversations_context.tsx +6 -2
  162. package/src/hooks/direct_messages/use_direct_message_affiliations.ts +39 -0
  163. package/src/hooks/direct_messages/use_direct_message_candidates.ts +31 -0
  164. package/src/hooks/direct_messages/use_direct_message_conversation_validate.ts +35 -0
  165. package/src/hooks/direct_messages/use_find_or_create_direct_message_conversation.ts +66 -0
  166. package/src/hooks/groups/use_group_chat_conversation_payload.ts +2 -1
  167. package/src/hooks/index.ts +1 -0
  168. package/src/hooks/services/use_services_chat_conversation_payload.ts +2 -1
  169. package/src/hooks/use_api.ts +10 -1
  170. package/src/hooks/use_chat_configuration.ts +2 -1
  171. package/src/hooks/use_chat_permissions.ts +4 -1
  172. package/src/hooks/use_conversation.ts +1 -0
  173. package/src/hooks/use_conversation_jolt_events.ts +3 -2
  174. package/src/hooks/use_current_person.ts +4 -2
  175. package/src/hooks/use_features.ts +2 -1
  176. package/src/hooks/use_groups.ts +16 -12
  177. package/src/hooks/use_groups_groups.ts +19 -15
  178. package/src/hooks/use_has_direct_messages.ts +21 -0
  179. package/src/hooks/use_jolt.ts +2 -1
  180. package/src/hooks/use_organization.ts +4 -1
  181. package/src/hooks/use_person_settings.ts +29 -0
  182. package/src/navigation/index.tsx +11 -0
  183. package/src/screens/conversation_details_screen.tsx +2 -1
  184. package/src/screens/conversation_filters/components/conversation_filters.tsx +25 -5
  185. package/src/screens/conversation_filters/components/rows.tsx +16 -3
  186. package/src/screens/conversation_filters/context/conversation_filter_context.tsx +4 -0
  187. package/src/screens/conversation_filters/filter_types.ts +1 -0
  188. package/src/screens/conversation_filters/screen_props.ts +1 -0
  189. package/src/screens/conversation_screen.tsx +2 -1
  190. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +513 -10
  191. package/src/screens/conversations/components/list_header_component.tsx +84 -34
  192. package/src/screens/conversations/conversations_screen.tsx +1 -0
  193. package/src/screens/get_help_screen.tsx +2 -29
  194. package/src/screens/index.ts +1 -0
  195. package/src/screens/settings_screen.tsx +67 -0
  196. package/src/types/jolt_events/conversation_events.ts +2 -0
  197. package/src/types/resources/conversation.ts +3 -0
  198. package/src/types/resources/direct_message_affiliations.ts +8 -0
  199. package/src/types/resources/direct_message_candidate.ts +12 -0
  200. package/src/types/resources/index.ts +2 -0
  201. package/src/types/resources/organization.ts +1 -0
  202. package/src/types/resources/person.ts +1 -0
  203. package/src/utils/__tests__/conversation.test.ts +14 -0
  204. package/src/utils/request/conversation.ts +13 -5
  205. package/src/utils/time.ts +17 -0
@@ -17,13 +17,26 @@ import { FilterTypes } from '../filter_types'
17
17
  import { FilteredGroup } from '../hooks/filters'
18
18
  import { ConversationFilterStackParamList } from '../screen_props'
19
19
 
20
- export type FilterProps = { group_source_app_name?: string; isActive: boolean; filter: FilterTypes }
20
+ export type FilterProps = {
21
+ group_source_app_name?: string
22
+ show_direct_messages?: boolean
23
+ isActive: boolean
24
+ filter: FilterTypes
25
+ }
21
26
 
22
- export const FilterRow = ({ group_source_app_name, isActive, filter }: FilterProps) => {
27
+ export const FilterRow = ({
28
+ group_source_app_name,
29
+ show_direct_messages,
30
+ isActive,
31
+ filter,
32
+ }: FilterProps) => {
23
33
  const { setAppFilter } = useContext(FilterContext)
24
34
 
25
35
  return (
26
- <PressableRow onPress={() => setAppFilter({ group_source_app_name })} isActive={isActive}>
36
+ <PressableRow
37
+ onPress={() => setAppFilter({ group_source_app_name, show_direct_messages })}
38
+ isActive={isActive}
39
+ >
27
40
  <Text>{filter}</Text>
28
41
  </PressableRow>
29
42
  )
@@ -21,6 +21,7 @@ export const FilterContext = createContext<{
21
21
  params: {
22
22
  chat_group_graph_id: undefined,
23
23
  group_source_app_name: undefined,
24
+ show_direct_messages: undefined,
24
25
  },
25
26
  scrollOffset: 0,
26
27
  setScrollOffset: () => {},
@@ -55,6 +56,7 @@ export const FilterProvider = ({ children }: PropsWithChildren) => {
55
56
  StackActions.popTo('Conversations', {
56
57
  chat_group_graph_id: undefined,
57
58
  group_source_app_name: undefined,
59
+ show_direct_messages: undefined,
58
60
  })
59
61
  )
60
62
  },
@@ -62,12 +64,14 @@ export const FilterProvider = ({ children }: PropsWithChildren) => {
62
64
  navigation.setParams({
63
65
  chat_group_graph_id: params.chat_group_graph_id,
64
66
  group_source_app_name: undefined,
67
+ show_direct_messages: undefined,
65
68
  })
66
69
  },
67
70
  setAppFilter: (params: Omit<ConversationFiltersParams, 'chat_group_graph_id'>) => {
68
71
  navigation.setParams({
69
72
  chat_group_graph_id: undefined,
70
73
  group_source_app_name: params.group_source_app_name,
74
+ show_direct_messages: params.show_direct_messages,
71
75
  })
72
76
  },
73
77
  applyFilters: (params: ConversationFiltersParams) => {
@@ -1,5 +1,6 @@
1
1
  export enum FilterTypes {
2
2
  All = 'All conversations',
3
+ DirectMessages = 'Direct messages',
3
4
  Groups = 'All my groups',
4
5
  Teams = 'All my teams and plans',
5
6
  More = 'More',
@@ -4,6 +4,7 @@ import { GraphId } from '../../types/resources/group_resource'
4
4
  export type ConversationFiltersParams = {
5
5
  chat_group_graph_id?: GraphId
6
6
  group_source_app_name?: string
7
+ show_direct_messages?: boolean
7
8
  }
8
9
 
9
10
  export type ConversationFiltersScreenProps = StaticScreenProps<ConversationFiltersParams>
@@ -239,7 +239,8 @@ function ConversationBottomBar({
239
239
  replyRootId,
240
240
  currentlyEditingMessage,
241
241
  }: ConversationBottomBarProps) {
242
- if (conversation.disabled) return <ConversationDisabledBanner />
242
+ if (conversation.disabled)
243
+ return <ConversationDisabledBanner disabledReason={conversation.disabledReason} />
243
244
  if (!canReply) return <MemberMessagesDisabledBanner />
244
245
  return (
245
246
  <MessageForm.Root
@@ -1,33 +1,536 @@
1
- import React from 'react'
2
- import { StyleSheet, View } from 'react-native'
3
- import { useSafeAreaInsets } from 'react-native-safe-area-context'
4
- import { Heading, Text } from '../../components'
1
+ import { PlatformPressable } from '@react-navigation/elements'
2
+ import { StackActions, useNavigation } from '@react-navigation/native'
3
+ import { useEffect, useState } from 'react'
4
+ import { FlatList, Pressable, StyleSheet, TextInput, View } from 'react-native'
5
+ import { Avatar, Badge, Banner, Icon, Spinner, Text } from '../../components'
6
+ import { ActionButton } from '../../components/display/action_button'
7
+ import { ComponentErrorBoundary } from '../../components/page/component_error_boundary'
8
+ import { useTheme } from '../../hooks'
9
+ import { useDirectMessageAffiliations } from '../../hooks/direct_messages/use_direct_message_affiliations'
10
+ import { useDirectMessageCandidates } from '../../hooks/direct_messages/use_direct_message_candidates'
11
+ import { useDirectMessageConversationValidate } from '../../hooks/direct_messages/use_direct_message_conversation_validate'
12
+ import { useFindOrCreateDirectMessageConversation } from '../../hooks/direct_messages/use_find_or_create_direct_message_conversation'
13
+ import { DirectMessageCandidateResource } from '../../types/resources/direct_message_candidate'
5
14
  import { ConversationSelectRecipientsScreenProps } from './types/screen_props'
6
15
 
16
+ const MAX_RECIPIENTS = 9
17
+
7
18
  export function ConversationSelectDirectMessageRecipientsScreen(
8
19
  _props: ConversationSelectRecipientsScreenProps
9
20
  ) {
10
21
  const styles = useStyles()
22
+ const navigation = useNavigation()
23
+ const [searchTerm, setSearchTerm] = useState('')
24
+ const [debouncedTerm, setDebouncedTerm] = useState('')
25
+ const [selectedPeople, setSelectedPeople] = useState<Map<string, DirectMessageCandidateResource>>(
26
+ new Map()
27
+ )
28
+
29
+ useEffect(() => {
30
+ const timer = setTimeout(() => setDebouncedTerm(searchTerm), 200)
31
+ return () => clearTimeout(timer)
32
+ }, [searchTerm])
33
+
34
+ const selectPerson = (person: DirectMessageCandidateResource) => {
35
+ if (selectedPeople.size >= MAX_RECIPIENTS) return
36
+ setSelectedPeople(prev => new Map(prev).set(person.id, person))
37
+ setSearchTerm('')
38
+ setDebouncedTerm('')
39
+ }
40
+
41
+ const removePerson = (id: string) => {
42
+ setSelectedPeople(prev => {
43
+ const next = new Map(prev)
44
+ next.delete(id)
45
+ return next
46
+ })
47
+ }
48
+
49
+ const recipientIds = [...selectedPeople.keys()]
50
+ const { warnings, validationPending } = useDirectMessageConversationValidate({ recipientIds })
51
+
52
+ const handleSuccess = (conversationId: number) => {
53
+ navigation.getParent()?.goBack()
54
+ navigation.dispatch(StackActions.push('Conversation', { conversation_id: conversationId }))
55
+ }
56
+
57
+ const { findOrCreateConversation, directMessageExists, isFinding, isError } =
58
+ useFindOrCreateDirectMessageConversation({ recipientIds, onSuccess: handleSuccess })
59
+
60
+ const buttonText = (() => {
61
+ if (selectedPeople.size === 0) return 'Start conversation'
62
+ if (selectedPeople.size === 1) {
63
+ const firstName = selectedPeople.values().next().value!.name.split(' ')[0]
64
+ return directMessageExists ? `Open message with ${firstName}` : `Message ${firstName}`
65
+ }
66
+ return directMessageExists
67
+ ? `Open direct message (${selectedPeople.size})`
68
+ : `Start direct message (${selectedPeople.size})`
69
+ })()
11
70
 
12
71
  return (
13
72
  <View style={styles.container}>
14
- <Heading variant="h2">New direct message</Heading>
15
- <Text variant="secondary">Coming soon</Text>
73
+ {isError && (
74
+ <Banner
75
+ appearance="error"
76
+ description="There was an issue starting the conversation. Please try again."
77
+ style={styles.errorBanner}
78
+ />
79
+ )}
80
+ <ToField
81
+ selectedPeople={selectedPeople}
82
+ searchTerm={searchTerm}
83
+ onSearchChange={setSearchTerm}
84
+ onRemove={removePerson}
85
+ onClear={() => setSelectedPeople(new Map())}
86
+ />
87
+ <View style={styles.candidateResultsContainer}>
88
+ <ComponentErrorBoundary
89
+ key={debouncedTerm}
90
+ fallback={
91
+ <Banner
92
+ appearance="error"
93
+ description="Couldn't load people right now. Try again."
94
+ style={styles.errorBanner}
95
+ />
96
+ }
97
+ >
98
+ <CandidateResults
99
+ term={debouncedTerm}
100
+ selectedPeople={selectedPeople}
101
+ onSelect={selectPerson}
102
+ />
103
+ </ComponentErrorBoundary>
104
+ </View>
105
+ <ActionButton
106
+ title={buttonText}
107
+ disabled={
108
+ selectedPeople.size === 0 || validationPending || warnings.length > 0 || isFinding
109
+ }
110
+ loading={validationPending || isFinding}
111
+ onPress={() => findOrCreateConversation(recipientIds)}
112
+ warnings={warnings}
113
+ />
114
+ </View>
115
+ )
116
+ }
117
+
118
+ function ToField({
119
+ selectedPeople,
120
+ searchTerm,
121
+ onSearchChange,
122
+ onRemove,
123
+ onClear,
124
+ }: {
125
+ selectedPeople: Map<string, DirectMessageCandidateResource>
126
+ searchTerm: string
127
+ onSearchChange: (term: string) => void
128
+ onRemove: (id: string) => void
129
+ onClear: () => void
130
+ }) {
131
+ const styles = useStyles()
132
+ const { colors } = useTheme()
133
+ const hasSelections = selectedPeople.size > 0
134
+
135
+ return (
136
+ <View style={styles.toField}>
137
+ <View style={styles.toFieldHeader}>
138
+ <Text style={styles.toLabel}>To</Text>
139
+ {hasSelections && (
140
+ <Pressable onPress={onClear} hitSlop={8}>
141
+ <Text style={styles.clearButton}>Clear</Text>
142
+ </Pressable>
143
+ )}
144
+ </View>
145
+ <View style={styles.toInputArea}>
146
+ {[...selectedPeople.values()].map(person => (
147
+ <PersonChip key={person.id} person={person} onRemove={onRemove} />
148
+ ))}
149
+ <TextInput
150
+ autoFocus
151
+ placeholder="Search"
152
+ placeholderTextColor={colors.textColorDefaultPlaceholder}
153
+ value={searchTerm}
154
+ onChangeText={onSearchChange}
155
+ style={styles.toTextInput}
156
+ returnKeyType="search"
157
+ />
158
+ </View>
159
+ </View>
160
+ )
161
+ }
162
+
163
+ function PersonChip({
164
+ person,
165
+ onRemove,
166
+ }: {
167
+ person: DirectMessageCandidateResource
168
+ onRemove: (id: string) => void
169
+ }) {
170
+ const styles = useStyles()
171
+
172
+ return (
173
+ <View style={styles.chip}>
174
+ <Avatar sourceUri={person.avatar} size="sm" showFallback />
175
+ <Text style={styles.chipName}>{person.name}</Text>
176
+ <Pressable
177
+ onPress={() => onRemove(person.id)}
178
+ hitSlop={8}
179
+ accessibilityLabel={`Remove ${person.name}`}
180
+ >
181
+ <Icon name="general.x" size={12} style={styles.chipRemoveIcon} />
182
+ </Pressable>
16
183
  </View>
17
184
  )
18
185
  }
19
186
 
187
+ function CandidateResults({
188
+ term,
189
+ selectedPeople,
190
+ onSelect,
191
+ }: {
192
+ term: string
193
+ selectedPeople: Map<string, DirectMessageCandidateResource>
194
+ onSelect: (candidate: DirectMessageCandidateResource) => void
195
+ }) {
196
+ const styles = useStyles()
197
+ const { data, isFetching } = useDirectMessageCandidates(term)
198
+ const { groups, teams } = useDirectMessageAffiliations()
199
+
200
+ if (term.length < 1) {
201
+ if (selectedPeople.size > 0) return null
202
+
203
+ return (
204
+ <View style={styles.emptyState}>
205
+ <Icon name="churchCenter.people" size={40} style={styles.emptyStateIcon} />
206
+ <Text style={styles.emptyStateTitle}>Search for people to direct message</Text>
207
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
208
+ Start a conversation with people in your groups and teams.
209
+ </Text>
210
+ </View>
211
+ )
212
+ }
213
+
214
+ const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS
215
+
216
+ if (isAtLimit) {
217
+ return (
218
+ <View style={styles.emptyState}>
219
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
220
+ You can add up to {MAX_RECIPIENTS} people to a direct message.
221
+ </Text>
222
+ </View>
223
+ )
224
+ }
225
+
226
+ const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id))
227
+ const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0)
228
+
229
+ if (isFetching && unselectedCandidates.length === 0) {
230
+ return (
231
+ <View style={styles.emptyState}>
232
+ <Spinner />
233
+ </View>
234
+ )
235
+ }
236
+
237
+ if (!isFetching && unselectedCandidates.length === 0) {
238
+ return (
239
+ <View style={styles.emptyState}>
240
+ <Icon name="general.search" size={32} style={styles.emptyStateIcon} />
241
+ <Text style={styles.emptyStateTitle}>No matches for "{term}"</Text>
242
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
243
+ Try a different name, or check that you share a group or team with them.
244
+ </Text>
245
+ </View>
246
+ )
247
+ }
248
+
249
+ return (
250
+ <CandidateList
251
+ candidates={unselectedCandidates}
252
+ hasTruncated={hasTruncated}
253
+ groups={groups}
254
+ teams={teams}
255
+ onSelect={onSelect}
256
+ />
257
+ )
258
+ }
259
+
260
+ function candidateBadges(candidate: DirectMessageCandidateResource) {
261
+ const badges = []
262
+ if (candidate.directMessagesDisabled) badges.push({ title: 'DMs off' })
263
+ if (candidate.under_18) badges.push({ title: 'Under 18' })
264
+ return badges
265
+ }
266
+
267
+ function candidateAffiliationNames(
268
+ candidate: DirectMessageCandidateResource,
269
+ groups: Map<number, string>,
270
+ teams: Map<number, string>
271
+ ) {
272
+ return [
273
+ ...candidate.groups.flatMap(id => {
274
+ const name = groups.get(id)
275
+ return name ? [name] : []
276
+ }),
277
+ ...candidate.teams.flatMap(id => {
278
+ const name = teams.get(id)
279
+ return name ? [name] : []
280
+ }),
281
+ ]
282
+ }
283
+
284
+ function CandidateList({
285
+ candidates,
286
+ hasTruncated,
287
+ groups,
288
+ teams,
289
+ onSelect,
290
+ }: {
291
+ candidates: DirectMessageCandidateResource[]
292
+ hasTruncated: boolean
293
+ groups: Map<number, string>
294
+ teams: Map<number, string>
295
+ onSelect: (candidate: DirectMessageCandidateResource) => void
296
+ }) {
297
+ const styles = useStyles()
298
+ const available = candidates.filter(c => !c.directMessagesDisabled)
299
+ const dmDisabled = candidates.filter(c => c.directMessagesDisabled)
300
+
301
+ return (
302
+ <FlatList<DirectMessageCandidateResource>
303
+ data={available}
304
+ keyExtractor={item => item.id}
305
+ renderItem={({ item }) => (
306
+ <CandidateItem
307
+ candidate={item}
308
+ badges={candidateBadges(item)}
309
+ affiliationNames={candidateAffiliationNames(item, groups, teams)}
310
+ onSelect={() => onSelect(item)}
311
+ />
312
+ )}
313
+ ListHeaderComponent={
314
+ <View style={styles.resultsHeader}>
315
+ <Text style={styles.resultsHeading}>Results</Text>
316
+ </View>
317
+ }
318
+ ListFooterComponent={
319
+ <>
320
+ {dmDisabled.length > 0 && (
321
+ <>
322
+ <View style={styles.unavailableHeader}>
323
+ <Text variant="secondary" style={styles.unavailableLabel}>
324
+ Unavailable for direct messages
325
+ </Text>
326
+ </View>
327
+ {dmDisabled.map(candidate => (
328
+ <CandidateItem
329
+ key={candidate.id}
330
+ candidate={candidate}
331
+ badges={candidateBadges(candidate)}
332
+ affiliationNames={candidateAffiliationNames(candidate, groups, teams)}
333
+ disabled
334
+ />
335
+ ))}
336
+ </>
337
+ )}
338
+ {hasTruncated && (
339
+ <View style={styles.truncatedFooter}>
340
+ <Text variant="secondary" style={styles.truncatedText}>
341
+ Keep typing to narrow your results.
342
+ </Text>
343
+ </View>
344
+ )}
345
+ </>
346
+ }
347
+ />
348
+ )
349
+ }
350
+
351
+ function CandidateItem({
352
+ candidate,
353
+ badges,
354
+ affiliationNames,
355
+ onSelect,
356
+ disabled = false,
357
+ }: {
358
+ candidate: DirectMessageCandidateResource
359
+ badges: { title: string }[]
360
+ affiliationNames: string[]
361
+ onSelect?: () => void
362
+ disabled?: boolean
363
+ }) {
364
+ const styles = useStyles()
365
+
366
+ return (
367
+ <PlatformPressable
368
+ onPress={disabled ? undefined : onSelect}
369
+ accessibilityRole="button"
370
+ accessibilityLabel={`Select ${candidate.name}`}
371
+ accessibilityState={{ disabled }}
372
+ >
373
+ <View style={[styles.row, disabled && styles.candidateDisabled]}>
374
+ <Avatar sourceUri={candidate.avatar} size="lg" showFallback />
375
+ <View style={styles.rowInfo}>
376
+ <Text style={styles.rowName}>{candidate.name}</Text>
377
+ {affiliationNames.length > 0 && (
378
+ <Text style={styles.rowAffiliations} numberOfLines={2}>
379
+ {affiliationNames.join(', ')}
380
+ </Text>
381
+ )}
382
+ {badges.length > 0 && (
383
+ <View style={styles.badgeRow}>
384
+ {badges.map(({ title }) => (
385
+ <Badge key={title} label={title} />
386
+ ))}
387
+ </View>
388
+ )}
389
+ </View>
390
+ </View>
391
+ </PlatformPressable>
392
+ )
393
+ }
394
+
20
395
  const useStyles = () => {
21
- const { bottom } = useSafeAreaInsets()
396
+ const { colors } = useTheme()
22
397
 
23
398
  return StyleSheet.create({
24
399
  container: {
25
400
  flex: 1,
26
- padding: 16,
27
- paddingBottom: 16 + bottom,
401
+ },
402
+ candidateResultsContainer: {
403
+ flex: 1,
404
+ },
405
+ toField: {
406
+ paddingHorizontal: 16,
407
+ paddingTop: 16,
408
+ paddingBottom: 8,
409
+ },
410
+ toFieldHeader: {
411
+ flexDirection: 'row',
412
+ justifyContent: 'space-between',
413
+ alignItems: 'center',
414
+ marginBottom: 8,
415
+ },
416
+ toLabel: {
417
+ fontWeight: '600',
418
+ color: colors.textColorDefaultSecondary,
419
+ },
420
+ clearButton: {
421
+ color: colors.interaction,
422
+ },
423
+ toInputArea: {
424
+ flexDirection: 'row',
425
+ flexWrap: 'wrap',
28
426
  alignItems: 'center',
29
- justifyContent: 'center',
427
+ gap: 6,
428
+ borderWidth: 1,
429
+ borderColor: colors.borderColorDefaultBase,
430
+ borderRadius: 8,
431
+ paddingHorizontal: 10,
432
+ paddingVertical: 8,
433
+ },
434
+ toTextInput: {
435
+ flex: 1,
436
+ minWidth: 80,
437
+ color: colors.textColorDefaultPrimary,
438
+ fontSize: 16,
439
+ paddingVertical: 2,
440
+ },
441
+ chip: {
442
+ flexDirection: 'row',
443
+ alignItems: 'center',
444
+ gap: 4,
445
+ borderWidth: 1,
446
+ borderColor: colors.borderColorDefaultBase,
447
+ borderRadius: 100,
448
+ paddingLeft: 4,
449
+ paddingRight: 6,
450
+ paddingVertical: 3,
451
+ },
452
+ chipName: {
453
+ fontSize: 14,
454
+ },
455
+ chipRemoveIcon: {
456
+ color: colors.iconColorDefaultSecondary,
457
+ },
458
+ errorBanner: {
459
+ margin: 16,
460
+ },
461
+ emptyState: {
462
+ alignItems: 'center',
463
+ paddingTop: 32,
464
+ paddingHorizontal: 32,
30
465
  gap: 8,
31
466
  },
467
+ emptyStateIcon: {
468
+ color: colors.iconColorDefaultSecondary,
469
+ marginBottom: 4,
470
+ },
471
+ emptyStateTitle: {
472
+ fontWeight: '600',
473
+ textAlign: 'center',
474
+ },
475
+ emptyStateSubtitle: {
476
+ textAlign: 'center',
477
+ fontSize: 14,
478
+ },
479
+ row: {
480
+ flexDirection: 'row',
481
+ alignItems: 'flex-start',
482
+ gap: 12,
483
+ paddingHorizontal: 16,
484
+ paddingVertical: 12,
485
+ borderBottomWidth: 1,
486
+ borderColor: colors.borderColorDefaultDim,
487
+ },
488
+ rowInfo: {
489
+ flex: 1,
490
+ gap: 2,
491
+ },
492
+ rowName: {
493
+ fontSize: 15,
494
+ fontWeight: '500',
495
+ },
496
+ rowAffiliations: {
497
+ fontSize: 12,
498
+ color: colors.textColorDefaultSecondary,
499
+ },
500
+ badgeRow: {
501
+ flexDirection: 'row',
502
+ flexWrap: 'wrap',
503
+ gap: 4,
504
+ marginTop: 4,
505
+ },
506
+
507
+ candidateDisabled: {
508
+ opacity: 0.5,
509
+ },
510
+ resultsHeader: {
511
+ paddingHorizontal: 16,
512
+ paddingTop: 16,
513
+ paddingBottom: 8,
514
+ },
515
+ resultsHeading: {
516
+ fontWeight: '600',
517
+ },
518
+ unavailableHeader: {
519
+ paddingHorizontal: 16,
520
+ paddingTop: 16,
521
+ paddingBottom: 8,
522
+ },
523
+ unavailableLabel: {
524
+ fontSize: 13,
525
+ },
526
+ truncatedFooter: {
527
+ paddingHorizontal: 16,
528
+ paddingTop: 12,
529
+ paddingBottom: 8,
530
+ },
531
+ truncatedText: {
532
+ fontSize: 13,
533
+ textAlign: 'center',
534
+ },
32
535
  })
33
536
  }