@planningcenter/chat-react-native 3.39.0-rc.1 → 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 (174) 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_candidates.d.ts.map +1 -1
  11. package/build/hooks/direct_messages/use_direct_message_candidates.js +1 -0
  12. package/build/hooks/direct_messages/use_direct_message_candidates.js.map +1 -1
  13. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -1
  14. package/build/hooks/groups/use_group_chat_conversation_payload.js +2 -1
  15. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -1
  16. package/build/hooks/index.d.ts +1 -0
  17. package/build/hooks/index.d.ts.map +1 -1
  18. package/build/hooks/index.js +1 -0
  19. package/build/hooks/index.js.map +1 -1
  20. package/build/hooks/services/use_services_chat_conversation_payload.d.ts.map +1 -1
  21. package/build/hooks/services/use_services_chat_conversation_payload.js +2 -1
  22. package/build/hooks/services/use_services_chat_conversation_payload.js.map +1 -1
  23. package/build/hooks/use_api.d.ts +5 -1
  24. package/build/hooks/use_api.d.ts.map +1 -1
  25. package/build/hooks/use_api.js +2 -1
  26. package/build/hooks/use_api.js.map +1 -1
  27. package/build/hooks/use_chat_configuration.d.ts.map +1 -1
  28. package/build/hooks/use_chat_configuration.js +2 -1
  29. package/build/hooks/use_chat_configuration.js.map +1 -1
  30. package/build/hooks/use_chat_permissions.d.ts.map +1 -1
  31. package/build/hooks/use_chat_permissions.js +4 -1
  32. package/build/hooks/use_chat_permissions.js.map +1 -1
  33. package/build/hooks/use_conversation.d.ts.map +1 -1
  34. package/build/hooks/use_conversation.js +1 -0
  35. package/build/hooks/use_conversation.js.map +1 -1
  36. package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -1
  37. package/build/hooks/use_conversation_jolt_events.js +2 -2
  38. package/build/hooks/use_conversation_jolt_events.js.map +1 -1
  39. package/build/hooks/use_current_person.d.ts.map +1 -1
  40. package/build/hooks/use_current_person.js +4 -2
  41. package/build/hooks/use_current_person.js.map +1 -1
  42. package/build/hooks/use_features.d.ts.map +1 -1
  43. package/build/hooks/use_features.js +2 -1
  44. package/build/hooks/use_features.js.map +1 -1
  45. package/build/hooks/use_groups.d.ts.map +1 -1
  46. package/build/hooks/use_groups.js +2 -1
  47. package/build/hooks/use_groups.js.map +1 -1
  48. package/build/hooks/use_groups_groups.d.ts.map +1 -1
  49. package/build/hooks/use_groups_groups.js +2 -1
  50. package/build/hooks/use_groups_groups.js.map +1 -1
  51. package/build/hooks/use_has_direct_messages.d.ts +2 -0
  52. package/build/hooks/use_has_direct_messages.d.ts.map +1 -0
  53. package/build/hooks/use_has_direct_messages.js +17 -0
  54. package/build/hooks/use_has_direct_messages.js.map +1 -0
  55. package/build/hooks/use_jolt.d.ts.map +1 -1
  56. package/build/hooks/use_jolt.js +2 -1
  57. package/build/hooks/use_jolt.js.map +1 -1
  58. package/build/hooks/use_organization.d.ts.map +1 -1
  59. package/build/hooks/use_organization.js +4 -1
  60. package/build/hooks/use_organization.js.map +1 -1
  61. package/build/hooks/use_person_settings.d.ts +6 -0
  62. package/build/hooks/use_person_settings.d.ts.map +1 -0
  63. package/build/hooks/use_person_settings.js +26 -0
  64. package/build/hooks/use_person_settings.js.map +1 -0
  65. package/build/navigation/index.d.ts +13 -0
  66. package/build/navigation/index.d.ts.map +1 -1
  67. package/build/navigation/index.js +9 -0
  68. package/build/navigation/index.js.map +1 -1
  69. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  70. package/build/screens/conversation_details_screen.js +2 -1
  71. package/build/screens/conversation_details_screen.js.map +1 -1
  72. package/build/screens/conversation_filters/components/conversation_filters.d.ts.map +1 -1
  73. package/build/screens/conversation_filters/components/conversation_filters.js +20 -5
  74. package/build/screens/conversation_filters/components/conversation_filters.js.map +1 -1
  75. package/build/screens/conversation_filters/components/rows.d.ts +2 -1
  76. package/build/screens/conversation_filters/components/rows.d.ts.map +1 -1
  77. package/build/screens/conversation_filters/components/rows.js +2 -2
  78. package/build/screens/conversation_filters/components/rows.js.map +1 -1
  79. package/build/screens/conversation_filters/context/conversation_filter_context.d.ts.map +1 -1
  80. package/build/screens/conversation_filters/context/conversation_filter_context.js +4 -0
  81. package/build/screens/conversation_filters/context/conversation_filter_context.js.map +1 -1
  82. package/build/screens/conversation_filters/filter_types.d.ts +1 -0
  83. package/build/screens/conversation_filters/filter_types.d.ts.map +1 -1
  84. package/build/screens/conversation_filters/filter_types.js +1 -0
  85. package/build/screens/conversation_filters/filter_types.js.map +1 -1
  86. package/build/screens/conversation_filters/screen_props.d.ts +1 -0
  87. package/build/screens/conversation_filters/screen_props.d.ts.map +1 -1
  88. package/build/screens/conversation_filters/screen_props.js.map +1 -1
  89. package/build/screens/conversation_screen.d.ts.map +1 -1
  90. package/build/screens/conversation_screen.js +1 -1
  91. package/build/screens/conversation_screen.js.map +1 -1
  92. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts +1 -2
  93. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  94. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +319 -10
  95. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  96. package/build/screens/conversations/components/list_header_component.d.ts.map +1 -1
  97. package/build/screens/conversations/components/list_header_component.js +35 -17
  98. package/build/screens/conversations/components/list_header_component.js.map +1 -1
  99. package/build/screens/conversations/conversations_screen.d.ts +1 -0
  100. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  101. package/build/screens/conversations/conversations_screen.js.map +1 -1
  102. package/build/screens/get_help_screen.d.ts.map +1 -1
  103. package/build/screens/get_help_screen.js +2 -18
  104. package/build/screens/get_help_screen.js.map +1 -1
  105. package/build/screens/index.d.ts +1 -0
  106. package/build/screens/index.d.ts.map +1 -1
  107. package/build/screens/index.js +1 -0
  108. package/build/screens/index.js.map +1 -1
  109. package/build/screens/settings_screen.d.ts +5 -0
  110. package/build/screens/settings_screen.d.ts.map +1 -0
  111. package/build/screens/settings_screen.js +56 -0
  112. package/build/screens/settings_screen.js.map +1 -0
  113. package/build/types/jolt_events/conversation_events.d.ts +2 -0
  114. package/build/types/jolt_events/conversation_events.d.ts.map +1 -1
  115. package/build/types/jolt_events/conversation_events.js.map +1 -1
  116. package/build/types/resources/conversation.d.ts +2 -0
  117. package/build/types/resources/conversation.d.ts.map +1 -1
  118. package/build/types/resources/conversation.js.map +1 -1
  119. package/build/types/resources/organization.d.ts +1 -0
  120. package/build/types/resources/organization.d.ts.map +1 -1
  121. package/build/types/resources/organization.js.map +1 -1
  122. package/build/types/resources/person.d.ts +1 -0
  123. package/build/types/resources/person.d.ts.map +1 -1
  124. package/build/types/resources/person.js.map +1 -1
  125. package/build/utils/request/conversation.d.ts +2 -1
  126. package/build/utils/request/conversation.d.ts.map +1 -1
  127. package/build/utils/request/conversation.js +15 -6
  128. package/build/utils/request/conversation.js.map +1 -1
  129. package/build/utils/time.d.ts +8 -0
  130. package/build/utils/time.d.ts.map +1 -0
  131. package/build/utils/time.js +15 -0
  132. package/build/utils/time.js.map +1 -0
  133. package/package.json +2 -2
  134. package/src/components/conversation/messages_disabled_banners.tsx +16 -2
  135. package/src/contexts/api_provider.tsx +4 -1
  136. package/src/contexts/conversations_context.tsx +6 -2
  137. package/src/hooks/direct_messages/use_direct_message_candidates.ts +1 -0
  138. package/src/hooks/groups/use_group_chat_conversation_payload.ts +2 -1
  139. package/src/hooks/index.ts +1 -0
  140. package/src/hooks/services/use_services_chat_conversation_payload.ts +2 -1
  141. package/src/hooks/use_api.ts +10 -1
  142. package/src/hooks/use_chat_configuration.ts +2 -1
  143. package/src/hooks/use_chat_permissions.ts +4 -1
  144. package/src/hooks/use_conversation.ts +1 -0
  145. package/src/hooks/use_conversation_jolt_events.ts +3 -2
  146. package/src/hooks/use_current_person.ts +4 -2
  147. package/src/hooks/use_features.ts +2 -1
  148. package/src/hooks/use_groups.ts +16 -12
  149. package/src/hooks/use_groups_groups.ts +19 -15
  150. package/src/hooks/use_has_direct_messages.ts +21 -0
  151. package/src/hooks/use_jolt.ts +2 -1
  152. package/src/hooks/use_organization.ts +4 -1
  153. package/src/hooks/use_person_settings.ts +29 -0
  154. package/src/navigation/index.tsx +11 -0
  155. package/src/screens/conversation_details_screen.tsx +2 -1
  156. package/src/screens/conversation_filters/components/conversation_filters.tsx +25 -5
  157. package/src/screens/conversation_filters/components/rows.tsx +16 -3
  158. package/src/screens/conversation_filters/context/conversation_filter_context.tsx +4 -0
  159. package/src/screens/conversation_filters/filter_types.ts +1 -0
  160. package/src/screens/conversation_filters/screen_props.ts +1 -0
  161. package/src/screens/conversation_screen.tsx +2 -1
  162. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +513 -10
  163. package/src/screens/conversations/components/list_header_component.tsx +84 -34
  164. package/src/screens/conversations/conversations_screen.tsx +1 -0
  165. package/src/screens/get_help_screen.tsx +2 -29
  166. package/src/screens/index.ts +1 -0
  167. package/src/screens/settings_screen.tsx +67 -0
  168. package/src/types/jolt_events/conversation_events.ts +2 -0
  169. package/src/types/resources/conversation.ts +3 -0
  170. package/src/types/resources/organization.ts +1 -0
  171. package/src/types/resources/person.ts +1 -0
  172. package/src/utils/__tests__/conversation.test.ts +14 -0
  173. package/src/utils/request/conversation.ts +13 -5
  174. 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
  }