@planningcenter/chat-react-native 3.32.1-rc.0 → 3.33.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/build/components/conversation/message_form.d.ts.map +1 -1
  2. package/build/components/conversation/message_form.js +22 -1
  3. package/build/components/conversation/message_form.js.map +1 -1
  4. package/build/components/display/emoji_avatar.d.ts.map +1 -1
  5. package/build/components/display/emoji_avatar.js +2 -0
  6. package/build/components/display/emoji_avatar.js.map +1 -1
  7. package/build/components/display/icon_avatar.d.ts.map +1 -1
  8. package/build/components/display/icon_avatar.js +2 -0
  9. package/build/components/display/icon_avatar.js.map +1 -1
  10. package/build/components/display/utils/avatar_gradient_colors.d.ts +3 -0
  11. package/build/components/display/utils/avatar_gradient_colors.d.ts.map +1 -1
  12. package/build/components/display/utils/avatar_gradient_colors.js +8 -3
  13. package/build/components/display/utils/avatar_gradient_colors.js.map +1 -1
  14. package/build/components/page/error_boundary.d.ts.map +1 -1
  15. package/build/components/page/error_boundary.js +13 -10
  16. package/build/components/page/error_boundary.js.map +1 -1
  17. package/build/components/primitive/avatar_primitive.d.ts +3 -1
  18. package/build/components/primitive/avatar_primitive.d.ts.map +1 -1
  19. package/build/components/primitive/avatar_primitive.js +10 -2
  20. package/build/components/primitive/avatar_primitive.js.map +1 -1
  21. package/build/contexts/api_provider.d.ts.map +1 -1
  22. package/build/contexts/api_provider.js +2 -0
  23. package/build/contexts/api_provider.js.map +1 -1
  24. package/build/hooks/attachments/fallback_chat_configuration.d.ts +4 -0
  25. package/build/hooks/attachments/fallback_chat_configuration.d.ts.map +1 -0
  26. package/build/hooks/attachments/fallback_chat_configuration.js +59 -0
  27. package/build/hooks/attachments/fallback_chat_configuration.js.map +1 -0
  28. package/build/hooks/groups/use_groups_conversation_create.d.ts.map +1 -1
  29. package/build/hooks/groups/use_groups_conversation_create.js +1 -1
  30. package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
  31. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +43 -11
  32. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  33. package/build/hooks/services/use_find_or_create_services_conversation.js +5 -5
  34. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  35. package/build/hooks/use_attachment_uploader.d.ts.map +1 -1
  36. package/build/hooks/use_attachment_uploader.js +39 -14
  37. package/build/hooks/use_attachment_uploader.js.map +1 -1
  38. package/build/hooks/use_chat_configuration.d.ts +6 -0
  39. package/build/hooks/use_chat_configuration.d.ts.map +1 -0
  40. package/build/hooks/use_chat_configuration.js +41 -0
  41. package/build/hooks/use_chat_configuration.js.map +1 -0
  42. package/build/hooks/use_conversation_avatar_update.d.ts +26 -0
  43. package/build/hooks/use_conversation_avatar_update.d.ts.map +1 -0
  44. package/build/hooks/use_conversation_avatar_update.js +130 -0
  45. package/build/hooks/use_conversation_avatar_update.js.map +1 -0
  46. package/build/hooks/use_features.d.ts +1 -0
  47. package/build/hooks/use_features.d.ts.map +1 -1
  48. package/build/hooks/use_features.js +1 -0
  49. package/build/hooks/use_features.js.map +1 -1
  50. package/build/navigation/index.d.ts +16 -0
  51. package/build/navigation/index.d.ts.map +1 -1
  52. package/build/navigation/index.js +9 -0
  53. package/build/navigation/index.js.map +1 -1
  54. package/build/screens/avatar_picker/avatar_picker_screen.d.ts +12 -0
  55. package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -0
  56. package/build/screens/avatar_picker/avatar_picker_screen.js +193 -0
  57. package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -0
  58. package/build/screens/avatar_picker/avatar_picker_state.d.ts +38 -0
  59. package/build/screens/avatar_picker/avatar_picker_state.d.ts.map +1 -0
  60. package/build/screens/avatar_picker/avatar_picker_state.js +101 -0
  61. package/build/screens/avatar_picker/avatar_picker_state.js.map +1 -0
  62. package/build/screens/avatar_picker/avatar_preview.d.ts +9 -0
  63. package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -0
  64. package/build/screens/avatar_picker/avatar_preview.js +39 -0
  65. package/build/screens/avatar_picker/avatar_preview.js.map +1 -0
  66. package/build/screens/avatar_picker/color_picker.d.ts +9 -0
  67. package/build/screens/avatar_picker/color_picker.d.ts.map +1 -0
  68. package/build/screens/avatar_picker/color_picker.js +53 -0
  69. package/build/screens/avatar_picker/color_picker.js.map +1 -0
  70. package/build/screens/avatar_picker/constants.d.ts +3 -0
  71. package/build/screens/avatar_picker/constants.d.ts.map +1 -0
  72. package/build/screens/avatar_picker/constants.js +53 -0
  73. package/build/screens/avatar_picker/constants.js.map +1 -0
  74. package/build/screens/avatar_picker/emoji_tab.d.ts +7 -0
  75. package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -0
  76. package/build/screens/avatar_picker/emoji_tab.js +55 -0
  77. package/build/screens/avatar_picker/emoji_tab.js.map +1 -0
  78. package/build/screens/avatar_picker/icon_grid.d.ts +8 -0
  79. package/build/screens/avatar_picker/icon_grid.d.ts.map +1 -0
  80. package/build/screens/avatar_picker/icon_grid.js +48 -0
  81. package/build/screens/avatar_picker/icon_grid.js.map +1 -0
  82. package/build/screens/avatar_picker/upload_tab.d.ts +9 -0
  83. package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -0
  84. package/build/screens/avatar_picker/upload_tab.js +39 -0
  85. package/build/screens/avatar_picker/upload_tab.js.map +1 -0
  86. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  87. package/build/screens/conversation_details_screen.js +37 -1
  88. package/build/screens/conversation_details_screen.js.map +1 -1
  89. package/build/screens/conversation_new/components/avatar_selection_row.d.ts +12 -0
  90. package/build/screens/conversation_new/components/avatar_selection_row.d.ts.map +1 -0
  91. package/build/screens/conversation_new/components/avatar_selection_row.js +60 -0
  92. package/build/screens/conversation_new/components/avatar_selection_row.js.map +1 -0
  93. package/build/screens/conversation_new/components/gender_filter_toggle.d.ts.map +1 -1
  94. package/build/screens/conversation_new/components/gender_filter_toggle.js +3 -9
  95. package/build/screens/conversation_new/components/gender_filter_toggle.js.map +1 -1
  96. package/build/screens/conversation_new/components/groups_form.d.ts +3 -1
  97. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  98. package/build/screens/conversation_new/components/groups_form.js +22 -8
  99. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  100. package/build/screens/conversation_new/components/services_form.d.ts +3 -1
  101. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  102. package/build/screens/conversation_new/components/services_form.js +22 -8
  103. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  104. package/build/screens/conversation_new/conversation_new_screen.d.ts +2 -0
  105. package/build/screens/conversation_new/conversation_new_screen.d.ts.map +1 -1
  106. package/build/screens/conversation_new/conversation_new_screen.js +3 -3
  107. package/build/screens/conversation_new/conversation_new_screen.js.map +1 -1
  108. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  109. package/build/screens/team_conversation_screen.js +1 -1
  110. package/build/screens/team_conversation_screen.js.map +1 -1
  111. package/build/types/resources/chat_configuration_resource.d.ts +8 -0
  112. package/build/types/resources/chat_configuration_resource.d.ts.map +1 -0
  113. package/build/types/resources/chat_configuration_resource.js +2 -0
  114. package/build/types/resources/chat_configuration_resource.js.map +1 -0
  115. package/build/utils/auth_events.d.ts +7 -0
  116. package/build/utils/auth_events.d.ts.map +1 -0
  117. package/build/utils/auth_events.js +17 -0
  118. package/build/utils/auth_events.js.map +1 -0
  119. package/build/utils/native_adapters/configuration.d.ts +3 -0
  120. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  121. package/build/utils/native_adapters/configuration.js +8 -0
  122. package/build/utils/native_adapters/configuration.js.map +1 -1
  123. package/build/utils/native_adapters/document_picker.d.ts +21 -0
  124. package/build/utils/native_adapters/document_picker.d.ts.map +1 -0
  125. package/build/utils/native_adapters/document_picker.js +7 -0
  126. package/build/utils/native_adapters/document_picker.js.map +1 -0
  127. package/build/utils/native_adapters/image_picker.d.ts +7 -1
  128. package/build/utils/native_adapters/image_picker.d.ts.map +1 -1
  129. package/build/utils/native_adapters/image_picker.js.map +1 -1
  130. package/build/utils/native_adapters/index.d.ts +1 -0
  131. package/build/utils/native_adapters/index.d.ts.map +1 -1
  132. package/build/utils/native_adapters/index.js +1 -0
  133. package/build/utils/native_adapters/index.js.map +1 -1
  134. package/build/utils/request/get_chat_configuration.d.ts +10 -0
  135. package/build/utils/request/get_chat_configuration.d.ts.map +1 -0
  136. package/build/utils/request/get_chat_configuration.js +21 -0
  137. package/build/utils/request/get_chat_configuration.js.map +1 -0
  138. package/package.json +4 -3
  139. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +219 -0
  140. package/src/__tests__/hooks/use_chat_configuration.test.tsx +80 -0
  141. package/src/__tests__/utils/native_adapters/configuration.ts +25 -1
  142. package/src/components/conversation/message_form.tsx +39 -1
  143. package/src/components/display/emoji_avatar.tsx +7 -2
  144. package/src/components/display/icon_avatar.tsx +7 -2
  145. package/src/components/display/utils/avatar_gradient_colors.ts +10 -3
  146. package/src/components/page/error_boundary.tsx +16 -9
  147. package/src/components/primitive/avatar_primitive.tsx +11 -2
  148. package/src/contexts/api_provider.tsx +3 -0
  149. package/src/hooks/attachments/fallback_chat_configuration.ts +61 -0
  150. package/src/hooks/groups/use_groups_conversation_create.ts +2 -1
  151. package/src/hooks/services/use_find_or_create_services_conversation.ts +7 -7
  152. package/src/hooks/use_attachment_uploader.ts +39 -15
  153. package/src/hooks/use_chat_configuration.ts +54 -0
  154. package/src/hooks/use_conversation_avatar_update.ts +163 -0
  155. package/src/hooks/use_features.ts +1 -0
  156. package/src/navigation/index.tsx +13 -0
  157. package/src/screens/avatar_picker/__tests__/avatar_picker_state.test.ts +157 -0
  158. package/src/screens/avatar_picker/avatar_picker_screen.tsx +312 -0
  159. package/src/screens/avatar_picker/avatar_picker_state.ts +141 -0
  160. package/src/screens/avatar_picker/avatar_preview.tsx +46 -0
  161. package/src/screens/avatar_picker/color_picker.tsx +91 -0
  162. package/src/screens/avatar_picker/constants.ts +53 -0
  163. package/src/screens/avatar_picker/emoji_tab.tsx +76 -0
  164. package/src/screens/avatar_picker/icon_grid.tsx +81 -0
  165. package/src/screens/avatar_picker/upload_tab.tsx +62 -0
  166. package/src/screens/conversation_details_screen.tsx +60 -1
  167. package/src/screens/conversation_new/components/avatar_selection_row.tsx +82 -0
  168. package/src/screens/conversation_new/components/gender_filter_toggle.tsx +3 -9
  169. package/src/screens/conversation_new/components/groups_form.tsx +33 -6
  170. package/src/screens/conversation_new/components/services_form.tsx +37 -6
  171. package/src/screens/conversation_new/conversation_new_screen.tsx +17 -3
  172. package/src/screens/team_conversation_screen.tsx +2 -1
  173. package/src/types/resources/chat_configuration_resource.ts +11 -0
  174. package/src/utils/auth_events.ts +21 -0
  175. package/src/utils/native_adapters/configuration.ts +10 -0
  176. package/src/utils/native_adapters/document_picker.ts +26 -0
  177. package/src/utils/native_adapters/image_picker.ts +8 -1
  178. package/src/utils/native_adapters/index.ts +1 -0
  179. package/src/utils/request/get_chat_configuration.ts +23 -0
  180. package/build/hooks/attachments/supported_extensions.d.ts +0 -2
  181. package/build/hooks/attachments/supported_extensions.d.ts.map +0 -1
  182. package/build/hooks/attachments/supported_extensions.js +0 -48
  183. package/build/hooks/attachments/supported_extensions.js.map +0 -1
  184. package/src/hooks/attachments/supported_extensions.ts +0 -47
@@ -0,0 +1,76 @@
1
+ import React, { useCallback } from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { EmojiKeyboard, type EmojiType } from 'rn-emoji-keyboard'
4
+ import { useTheme } from '../../hooks'
5
+
6
+ interface EmojiTabProps {
7
+ onEmojiSelect: (emoji: string) => void
8
+ }
9
+
10
+ export function EmojiTab({ onEmojiSelect }: EmojiTabProps) {
11
+ const styles = useStyles()
12
+ const { colors } = useTheme()
13
+
14
+ const handleEmojiSelected = useCallback(
15
+ (emojiObject: EmojiType) => {
16
+ onEmojiSelect(emojiObject.emoji)
17
+ },
18
+ [onEmojiSelect]
19
+ )
20
+
21
+ const emojiTheme = {
22
+ container: colors.fillColorNeutral100Inverted,
23
+ header: colors.textColorDefaultSecondary,
24
+ knob: colors.fillColorNeutral040,
25
+ skinTonesContainer: colors.fillColorNeutral060,
26
+ category: {
27
+ icon: colors.textColorDefaultSecondary,
28
+ iconActive: colors.interaction,
29
+ container: colors.fillColorNeutral100Inverted,
30
+ containerActive: colors.fillColorNeutral080,
31
+ },
32
+ search: {
33
+ background: colors.fillColorNeutral080,
34
+ text: colors.textColorDefaultPrimary,
35
+ placeholder: colors.textColorDefaultSecondary,
36
+ icon: colors.textColorDefaultSecondary,
37
+ },
38
+ emoji: {
39
+ selected: colors.fillColorNeutral080,
40
+ },
41
+ }
42
+
43
+ return (
44
+ <View style={styles.container}>
45
+ <EmojiKeyboard
46
+ categoryPosition="top"
47
+ onEmojiSelected={handleEmojiSelected}
48
+ enableSearchBar
49
+ enableRecentlyUsed
50
+ theme={emojiTheme}
51
+ styles={{
52
+ container: { borderRadius: 0 },
53
+ category: {
54
+ container: {
55
+ borderRadius: 0,
56
+ marginTop: -6,
57
+ borderBottomWidth: 1,
58
+ borderBottomColor: colors.borderColorDefaultDim,
59
+ },
60
+ },
61
+ }}
62
+ />
63
+ </View>
64
+ )
65
+ }
66
+
67
+ const useStyles = () => {
68
+ const { colors } = useTheme()
69
+
70
+ return StyleSheet.create({
71
+ container: {
72
+ flex: 1,
73
+ backgroundColor: colors.fillColorNeutral100Inverted,
74
+ },
75
+ })
76
+ }
@@ -0,0 +1,81 @@
1
+ import type { IconName } from '@fortawesome/fontawesome-svg-core'
2
+ import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'
3
+ import { PlatformPressable } from '@react-navigation/elements'
4
+ import React, { useCallback } from 'react'
5
+ import { FlatList, StyleSheet, useWindowDimensions } from 'react-native'
6
+ import { useTheme } from '../../hooks'
7
+ import { AVATAR_ICON_KEYS, GRID_COLUMNS } from './constants'
8
+
9
+ interface IconGridProps {
10
+ selectedIconKey: string | null
11
+ onIconSelect: (iconKey: string) => void
12
+ }
13
+
14
+ export function IconGrid({ selectedIconKey, onIconSelect }: IconGridProps) {
15
+ const styles = useStyles()
16
+
17
+ const renderItem = useCallback(
18
+ ({ item }: { item: string }) => {
19
+ const isSelected = item === selectedIconKey
20
+
21
+ return (
22
+ <PlatformPressable
23
+ onPress={() => onIconSelect(item)}
24
+ style={[styles.cell, isSelected && styles.cellSelected]}
25
+ accessibilityRole="button"
26
+ accessibilityLabel={`${item.replace(/-/g, ' ')} icon`}
27
+ accessibilityState={{ selected: isSelected }}
28
+ >
29
+ <FontAwesomeIcon icon={['fas', item as IconName]} size={20} color="white" />
30
+ </PlatformPressable>
31
+ )
32
+ },
33
+ [selectedIconKey, onIconSelect, styles.cell, styles.cellSelected]
34
+ )
35
+
36
+ return (
37
+ <FlatList
38
+ data={AVATAR_ICON_KEYS}
39
+ numColumns={GRID_COLUMNS}
40
+ keyExtractor={item => item}
41
+ renderItem={renderItem}
42
+ contentContainerStyle={styles.grid}
43
+ columnWrapperStyle={styles.row}
44
+ style={styles.list}
45
+ />
46
+ )
47
+ }
48
+
49
+ const PADDING = 16
50
+ const GAP = 8
51
+
52
+ const useStyles = () => {
53
+ const { colors } = useTheme()
54
+ const { width: screenWidth } = useWindowDimensions()
55
+ const cellSize = Math.floor((screenWidth - PADDING * 2 - (GRID_COLUMNS - 1) * GAP) / GRID_COLUMNS)
56
+
57
+ return StyleSheet.create({
58
+ list: {
59
+ flex: 1,
60
+ },
61
+ grid: {
62
+ padding: PADDING,
63
+ },
64
+ row: {
65
+ gap: GAP,
66
+ marginBottom: GAP,
67
+ },
68
+ cell: {
69
+ width: cellSize,
70
+ height: cellSize,
71
+ borderRadius: cellSize / 2,
72
+ backgroundColor: colors.fillColorNeutral040,
73
+ alignItems: 'center',
74
+ justifyContent: 'center',
75
+ },
76
+ cellSelected: {
77
+ borderWidth: 3,
78
+ borderColor: colors.interaction,
79
+ },
80
+ })
81
+ }
@@ -0,0 +1,62 @@
1
+ import React, { useCallback } from 'react'
2
+ import { Alert, StyleSheet, View } from 'react-native'
3
+ import { Button } from '../../components'
4
+ import { useTheme } from '../../hooks'
5
+ import { ImagePicker } from '../../utils/native_adapters'
6
+ import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker'
7
+
8
+ const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
9
+
10
+ interface UploadTabProps {
11
+ imagePreviewUri: string | null
12
+ onImageSelect: (asset: ImagePickerAsset) => void
13
+ }
14
+
15
+ export function UploadTab({ imagePreviewUri, onImageSelect }: UploadTabProps) {
16
+ const styles = useStyles()
17
+
18
+ const pickImage = useCallback(async () => {
19
+ const result = await ImagePicker.openImageLibraryAsync({
20
+ mediaTypes: ['images'],
21
+ allowsEditing: true,
22
+ allowsMultipleSelection: false,
23
+ })
24
+
25
+ if (result.canceled || !result.assets?.[0]) return
26
+
27
+ const asset = result.assets[0]
28
+
29
+ if (asset.fileSize && asset.fileSize > MAX_FILE_SIZE) {
30
+ Alert.alert('Image too large', 'Please choose an image under 10MB.')
31
+ return
32
+ }
33
+
34
+ onImageSelect(asset)
35
+ }, [onImageSelect])
36
+
37
+ return (
38
+ <View style={styles.container}>
39
+ <Button
40
+ title={imagePreviewUri ? 'Change photo' : 'Choose photo'}
41
+ iconNameLeft="general.image"
42
+ onPress={pickImage}
43
+ variant="outline"
44
+ appearance="interaction"
45
+ size="md"
46
+ />
47
+ </View>
48
+ )
49
+ }
50
+
51
+ const useStyles = () => {
52
+ const { colors } = useTheme()
53
+
54
+ return StyleSheet.create({
55
+ container: {
56
+ flex: 1,
57
+ alignItems: 'center',
58
+ justifyContent: 'center',
59
+ backgroundColor: colors.fillColorNeutral100Inverted,
60
+ },
61
+ })
62
+ }
@@ -26,6 +26,7 @@ import {
26
26
  } from 'react-native'
27
27
  import {
28
28
  Badge,
29
+ Button,
29
30
  ChildNotice,
30
31
  Heading,
31
32
  Icon,
@@ -35,6 +36,7 @@ import {
35
36
  TextButton,
36
37
  type TextStyle,
37
38
  } from '../components'
39
+ import { ConversationAvatar } from '../components/display/conversation_avatar'
38
40
  import { HeaderTextButton } from '../components/display/platform_modal_header_buttons'
39
41
  import { ButtonAppearanceUnion } from '../components/display/utils/button_colors'
40
42
  import { useSuspensePaginator, useTheme } from '../hooks'
@@ -46,7 +48,7 @@ import {
46
48
  useConversationUpdate,
47
49
  } from '../hooks/use_conversation'
48
50
  import { availableFeatures, useFeatures } from '../hooks/use_features'
49
- import { MemberResource, isDefined } from '../types'
51
+ import { type ConversationResource, MemberResource, isDefined } from '../types'
50
52
  import { GroupResource } from '../types/resources/group_resource'
51
53
  import { genderDisplayLabel } from '../utils/gender_display_label'
52
54
  import { tokens } from '../vendor/tapestry/tokens'
@@ -102,6 +104,7 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
102
104
  const { mutate: deleteConversation } = useConversationDelete(route.params)
103
105
  const { featureEnabled } = useFeatures()
104
106
  const granularNotificationsEnabled = featureEnabled(availableFeatures.granular_notifications_ui)
107
+ const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
105
108
  const showGenderFilter =
106
109
  featureEnabled(availableFeatures.gender_specific_conversations) && !!conversation.genderOption
107
110
  const genderLabel = conversation.genderOption ? genderDisplayLabel(conversation.genderOption) : ''
@@ -234,6 +237,25 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
234
237
  }, [HeaderRight, HeaderTitle, navigation])
235
238
 
236
239
  const listData = [
240
+ {
241
+ type: canUpdate && customAvatarsEnabled ? SectionTypes.view : SectionTypes.hidden,
242
+ data: {
243
+ children: (
244
+ <AvatarCard
245
+ conversation={conversation}
246
+ onPress={() => navigation.navigate('AvatarPicker', { conversation_id })}
247
+ />
248
+ ),
249
+ },
250
+ sectionOuterStyle: styles.sectionOuterAvatarCard,
251
+ sectionInnerStyle: styles.sectionInnerAvatarCard,
252
+ },
253
+ {
254
+ type: SectionTypes.header,
255
+ data: { title: 'Basic info' },
256
+ showBottomBorder: true,
257
+ sectionInnerStyle: styles.sectionInnerHeaderWithBottomBorder,
258
+ },
237
259
  {
238
260
  type: SectionTypes.view,
239
261
  data: {
@@ -595,6 +617,30 @@ function NavigableSettingRow({ title, subtitle, onPress }: NavigableSettingRowPr
595
617
  )
596
618
  }
597
619
 
620
+ function AvatarCard({
621
+ conversation,
622
+ onPress,
623
+ }: {
624
+ conversation: ConversationResource
625
+ onPress: () => void
626
+ }) {
627
+ const styles = useStyles()
628
+
629
+ return (
630
+ <View style={styles.avatarCard}>
631
+ <ConversationAvatar conversation={conversation} size="2xl" />
632
+ <Button
633
+ title="Update avatar"
634
+ iconNameLeft="general.pencil"
635
+ onPress={onPress}
636
+ variant="outline"
637
+ appearance="interaction"
638
+ size="sm"
639
+ />
640
+ </View>
641
+ )
642
+ }
643
+
598
644
  function TeamsGroup({ teams }: { teams: GroupResource[] }) {
599
645
  const styles = useStyles()
600
646
 
@@ -725,6 +771,19 @@ const useStyles = ({ isStart, isEnd }: { isStart?: boolean; isEnd?: boolean } =
725
771
  navigableSettingChevron: {
726
772
  color: colors.iconColorDefaultDisabled,
727
773
  },
774
+ avatarCard: {
775
+ alignItems: 'center',
776
+ gap: 16,
777
+ },
778
+ sectionOuterAvatarCard: {
779
+ paddingLeft: 0,
780
+ },
781
+ sectionInnerAvatarCard: {
782
+ paddingTop: 24,
783
+ paddingBottom: 24,
784
+ paddingHorizontal: 16,
785
+ alignItems: 'center',
786
+ },
728
787
  teamGroup: {
729
788
  flexDirection: 'row',
730
789
  gap: 4,
@@ -0,0 +1,82 @@
1
+ import { useNavigation, useRoute } from '@react-navigation/native'
2
+ import React from 'react'
3
+ import { Pressable, StyleSheet, View } from 'react-native'
4
+ import { Icon, Text } from '../../../components'
5
+ import { EmojiAvatar } from '../../../components/display/emoji_avatar'
6
+ import { IconAvatar } from '../../../components/display/icon_avatar'
7
+ import AvatarPrimitive from '../../../components/primitive/avatar_primitive'
8
+ import { useTheme } from '../../../hooks'
9
+ import type { AvatarUpdatePayload } from '../../../hooks/use_conversation_avatar_update'
10
+
11
+ interface AvatarSelectionRowProps {
12
+ avatarSelection?: AvatarUpdatePayload
13
+ }
14
+
15
+ export function AvatarSelectionRow({ avatarSelection }: AvatarSelectionRowProps) {
16
+ const styles = useStyles()
17
+ const navigation = useNavigation()
18
+ const route = useRoute()
19
+
20
+ return (
21
+ <Pressable
22
+ style={styles.avatarSection}
23
+ onPress={() =>
24
+ navigation.navigate('AvatarPicker', {
25
+ source_params: route.params as Record<string, unknown>,
26
+ ...(avatarSelection && { avatar_selection: avatarSelection }),
27
+ })
28
+ }
29
+ >
30
+ <Text style={styles.avatarLabel}>Customize avatar</Text>
31
+ <View style={styles.avatarSectionTrailing}>
32
+ <AvatarSelectionPreview avatarSelection={avatarSelection} />
33
+ <Icon name="general.rightChevron" size={12} />
34
+ </View>
35
+ </Pressable>
36
+ )
37
+ }
38
+
39
+ interface AvatarSelectionPreviewProps {
40
+ avatarSelection?: AvatarUpdatePayload
41
+ }
42
+
43
+ export function AvatarSelectionPreview({ avatarSelection }: AvatarSelectionPreviewProps) {
44
+ if (!avatarSelection || avatarSelection.kind === 'clear') return null
45
+
46
+ switch (avatarSelection.kind) {
47
+ case 'icon':
48
+ return <IconAvatar iconKey={avatarSelection.key} color={avatarSelection.color} size="md" />
49
+ case 'emoji':
50
+ return <EmojiAvatar emoji={avatarSelection.key} color={avatarSelection.color} size="md" />
51
+ case 'image':
52
+ return (
53
+ <AvatarPrimitive.Root size="md">
54
+ <AvatarPrimitive.Mask>
55
+ <AvatarPrimitive.Image sourceUri={avatarSelection.imageAsset.uri} />
56
+ </AvatarPrimitive.Mask>
57
+ </AvatarPrimitive.Root>
58
+ )
59
+ }
60
+ }
61
+
62
+ const useStyles = () => {
63
+ const { colors } = useTheme()
64
+
65
+ return StyleSheet.create({
66
+ avatarSection: {
67
+ padding: 16,
68
+ flexDirection: 'row',
69
+ alignItems: 'center',
70
+ justifyContent: 'space-between',
71
+ },
72
+ avatarSectionTrailing: {
73
+ flexDirection: 'row',
74
+ alignItems: 'center',
75
+ gap: 12,
76
+ },
77
+ avatarLabel: {
78
+ fontSize: 18,
79
+ color: colors.textColorDefaultPrimary,
80
+ },
81
+ })
82
+ }
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import { ActivityIndicator, StyleSheet, View } from 'react-native'
3
- import { Heading, Switch, Text, TextInlineButton } from '../../../components'
3
+ import { Heading, Switch, Text } from '../../../components'
4
4
  import { useTheme } from '../../../hooks'
5
5
  import { genderDisplayLabel } from '../../../utils/gender_display_label'
6
6
 
@@ -26,10 +26,7 @@ function FilterableGenderContent({ genderValue, enabled, onToggle }: FilterableG
26
26
  />
27
27
  </View>
28
28
  <Text style={{ color: colors.textColorDefaultSecondary }}>
29
- Filter limited to your profile's set gender.{' '}
30
- <TextInlineButton nativeID="gender-filter-learn-more" onPress={() => {}}>
31
- Learn more.
32
- </TextInlineButton>
29
+ Filter limited to your profile's set gender.
33
30
  </Text>
34
31
  </>
35
32
  )
@@ -45,10 +42,7 @@ function NoGenderContent({ isFetching }: { isFetching: boolean }) {
45
42
  <ActivityIndicator size="small" />
46
43
  ) : (
47
44
  <Text style={{ color: colors.textColorDefaultSecondary }}>
48
- Set a gender in your Church Center profile to enable gender filtering.{' '}
49
- <TextInlineButton nativeID="gender-filter-learn-more" onPress={() => {}}>
50
- Learn more.
51
- </TextInlineButton>
45
+ Set a gender in your Church Center profile to enable gender filtering.
52
46
  </Text>
53
47
  )}
54
48
  </>
@@ -4,30 +4,42 @@ import { Platform, Pressable, StyleSheet, TextInput, View } from 'react-native'
4
4
  import { Banner, ChildNotice, Heading, Text } from '../../../components'
5
5
  import { ActionButton } from '../../../components/display/action_button'
6
6
  import { KeyboardView } from '../../../components/display/keyboard_view'
7
- import { useCurrentPerson, useSuspenseGet, useTheme } from '../../../hooks'
7
+ import { useApiClient, useCurrentPerson, useSuspenseGet, useTheme } from '../../../hooks'
8
8
  import {
9
9
  GroupMembersForNewConversationResult,
10
10
  useGroupMembersForNewConversation,
11
11
  } from '../../../hooks/groups/use_group_members_for_new_conversation'
12
12
  import { useGroupsConversationCreate } from '../../../hooks/groups/use_groups_conversation_create'
13
+ import {
14
+ type AvatarUpdatePayload,
15
+ patchConversationAvatar,
16
+ } from '../../../hooks/use_conversation_avatar_update'
17
+ import { availableFeatures, useFeatures } from '../../../hooks/use_features'
13
18
  import { useMyGender } from '../../../hooks/use_my_gender'
19
+ import { useUploadClient } from '../../../hooks/use_upload_client'
14
20
  import { GroupsGroupResource } from '../../../types'
15
21
  import { GraphId } from '../../../types/resources/group_resource'
16
22
  import { pluralize } from '../../../utils'
17
23
  import { genderDisplayLabel } from '../../../utils/gender_display_label'
18
24
  import { Haptic } from '../../../utils/native_adapters'
25
+ import { AvatarSelectionRow } from './avatar_selection_row'
19
26
  import { Divider, FormList } from './form_list'
20
27
  import { GenderFilterToggle } from './gender_filter_toggle'
21
28
 
22
29
  type GroupsFormProps = {
23
30
  groupId: number
24
31
  chat_group_graph_id?: GraphId
32
+ avatarSelection?: AvatarUpdatePayload
25
33
  }
26
34
 
27
- export const GroupsForm = ({ groupId, chat_group_graph_id }: GroupsFormProps) => {
35
+ export const GroupsForm = ({ groupId, chat_group_graph_id, avatarSelection }: GroupsFormProps) => {
28
36
  const navigation = useNavigation()
37
+ const apiClient = useApiClient()
38
+ const uploadClient = useUploadClient()
29
39
  const [title, setTitle] = useState('')
30
40
  const [genderFilterEnabled, setGenderFilterEnabled] = useState(false)
41
+ const { featureEnabled } = useFeatures()
42
+ const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
31
43
  const {
32
44
  isFeatureEnabled: genderFilterAvailable,
33
45
  genderId,
@@ -67,9 +79,8 @@ export const GroupsForm = ({ groupId, chat_group_graph_id }: GroupsFormProps) =>
67
79
  }
68
80
  }
69
81
 
70
- const redirectToConversation = useCallback(
82
+ const handleCreateSuccess = useCallback(
71
83
  (conversationId: number) => {
72
- // navigate to the conversation screen
73
84
  navigation.dispatch(
74
85
  StackActions.popTo('Conversation', {
75
86
  conversation_id: conversationId,
@@ -77,15 +88,19 @@ export const GroupsForm = ({ groupId, chat_group_graph_id }: GroupsFormProps) =>
77
88
  })
78
89
  )
79
90
  Haptic.notificationSuccess()
91
+
92
+ if (avatarSelection && avatarSelection.kind !== 'clear') {
93
+ patchConversationAvatar(apiClient, uploadClient, conversationId, avatarSelection)
94
+ }
80
95
  },
81
- [chat_group_graph_id, navigation]
96
+ [apiClient, avatarSelection, chat_group_graph_id, navigation, uploadClient]
82
97
  )
83
98
 
84
99
  const { mutate: handleSave, isPending } = useGroupsConversationCreate({
85
100
  groupId,
86
101
  title,
87
102
  genderId: activeGenderId,
88
- onSuccess: redirectToConversation,
103
+ onSuccess: handleCreateSuccess,
89
104
  })
90
105
 
91
106
  return (
@@ -106,6 +121,8 @@ export const GroupsForm = ({ groupId, chat_group_graph_id }: GroupsFormProps) =>
106
121
  onGenderToggle={handleGenderToggle}
107
122
  groupMemberships={groupMemberships}
108
123
  filteredMemberCount={groupMemberships.adultMembers.length}
124
+ avatarSelection={avatarSelection}
125
+ customAvatarsEnabled={customAvatarsEnabled}
109
126
  />
110
127
  }
111
128
  />
@@ -130,6 +147,8 @@ interface FormContentProps {
130
147
  onGenderToggle: (enabled: boolean) => void
131
148
  groupMemberships: GroupMembersForNewConversationResult
132
149
  filteredMemberCount: number
150
+ avatarSelection?: AvatarUpdatePayload
151
+ customAvatarsEnabled: boolean
133
152
  }
134
153
 
135
154
  function FormContent({
@@ -143,6 +162,8 @@ function FormContent({
143
162
  onGenderToggle,
144
163
  groupMemberships,
145
164
  filteredMemberCount,
165
+ avatarSelection,
166
+ customAvatarsEnabled,
146
167
  }: FormContentProps) {
147
168
  const styles = useStyles()
148
169
  const inputRef = useRef<TextInput>(null)
@@ -185,6 +206,12 @@ function FormContent({
185
206
  />
186
207
  </Pressable>
187
208
  <Divider />
209
+ {customAvatarsEnabled && (
210
+ <>
211
+ <AvatarSelectionRow avatarSelection={avatarSelection} />
212
+ <Divider />
213
+ </>
214
+ )}
188
215
  {genderFilterAvailable && (
189
216
  <>
190
217
  <GenderFilterToggle
@@ -4,14 +4,22 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { StyleSheet, View } from 'react-native'
5
5
  import { Badge, Banner, ChildNotice, Heading, Switch, TextButton } from '../../../components'
6
6
  import { ActionButton } from '../../../components/display/action_button'
7
+ import { useApiClient } from '../../../hooks'
7
8
  import { useFindOrCreateServicesConversation } from '../../../hooks/services/use_find_or_create_services_conversation'
8
9
  import { useServicesTeams } from '../../../hooks/services/use_services_team'
9
10
  import { useTeamMembersForNewConversation } from '../../../hooks/services/use_team_members_for_new_conversation'
10
- import { ConversationResource, MemberResource } from '../../../types'
11
+ import {
12
+ type AvatarUpdatePayload,
13
+ patchConversationAvatar,
14
+ } from '../../../hooks/use_conversation_avatar_update'
15
+ import { availableFeatures, useFeatures } from '../../../hooks/use_features'
16
+ import { useUploadClient } from '../../../hooks/use_upload_client'
17
+ import { MemberResource } from '../../../types'
11
18
  import { pluralize } from '../../../utils'
12
19
  import { Haptic } from '../../../utils/native_adapters'
13
20
  import { tokens } from '../../../vendor/tapestry/tokens'
14
21
  import { TeamFilterTypes } from '../../conversation_filter_recipients/types'
22
+ import { AvatarSelectionRow } from './avatar_selection_row'
15
23
  import { FilterByPlan } from './filter_by_plan'
16
24
  import { Divider, FormList } from './form_list'
17
25
 
@@ -19,14 +27,20 @@ type ServicesFormProps = {
19
27
  initialTeamIds?: number[]
20
28
  initialPlanId?: number
21
29
  teamFilterType?: TeamFilterTypes
30
+ avatarSelection?: AvatarUpdatePayload
22
31
  }
23
32
 
24
33
  export const ServicesForm = ({
25
34
  initialTeamIds,
26
35
  initialPlanId,
27
36
  teamFilterType,
37
+ avatarSelection,
28
38
  }: ServicesFormProps) => {
29
39
  const styles = useStyles()
40
+ const apiClient = useApiClient()
41
+ const uploadClient = useUploadClient()
42
+ const { featureEnabled } = useFeatures()
43
+ const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
30
44
  const [selectedPlanId, setSelectedPlanId] = useState<number | undefined>(initialPlanId)
31
45
  const initialState = useMemo(() => uniq(initialTeamIds) || [], [initialTeamIds]) // Uniq here because services can send duplicates in the teams_i_lead response.
32
46
  const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>(initialState)
@@ -61,17 +75,19 @@ export const ServicesForm = ({
61
75
  } = useFindOrCreateServicesConversation({
62
76
  teamIds: selectedTeamIds,
63
77
  planId: filerByPlan ? selectedPlanId : undefined,
64
- onSuccess: (conversation: ConversationResource) => {
65
- // exit from the create stack
78
+ onSuccess: (conversation, { created }) => {
66
79
  navigation.getParent()?.goBack()
67
- // navigate to the conversation screen
68
80
  navigation.dispatch(
69
81
  StackActions.push('Conversation', {
70
82
  conversation_id: conversation.id,
71
83
  })
72
84
  )
73
- // Only trigger success haptic if creating a new conversation
74
- if (!selectionHasConversation) Haptic.notificationSuccess()
85
+ if (created) {
86
+ Haptic.notificationSuccess()
87
+ if (avatarSelection && avatarSelection.kind !== 'clear') {
88
+ patchConversationAvatar(apiClient, uploadClient, conversation.id, avatarSelection)
89
+ }
90
+ }
75
91
  },
76
92
  })
77
93
 
@@ -90,6 +106,9 @@ export const ServicesForm = ({
90
106
  members={members}
91
107
  isMemberError={isMemberError}
92
108
  teamFilterType={teamFilterType}
109
+ avatarSelection={avatarSelection}
110
+ selectionHasConversation={selectionHasConversation}
111
+ customAvatarsEnabled={customAvatarsEnabled}
93
112
  />
94
113
  }
95
114
  />
@@ -114,6 +133,9 @@ interface FormContentProps {
114
133
  members: MemberResource[]
115
134
  isMemberError: boolean
116
135
  teamFilterType?: TeamFilterTypes
136
+ avatarSelection?: AvatarUpdatePayload
137
+ selectionHasConversation: boolean
138
+ customAvatarsEnabled: boolean
117
139
  }
118
140
 
119
141
  function FormContent({
@@ -126,6 +148,9 @@ function FormContent({
126
148
  members,
127
149
  isMemberError,
128
150
  teamFilterType,
151
+ avatarSelection,
152
+ selectionHasConversation,
153
+ customAvatarsEnabled,
129
154
  }: FormContentProps) {
130
155
  const navigation = useNavigation()
131
156
  const servicesTeams = useServicesTeams()
@@ -205,6 +230,12 @@ function FormContent({
205
230
  )}
206
231
  </View>
207
232
  <Divider />
233
+ {!selectionHasConversation && customAvatarsEnabled && (
234
+ <>
235
+ <AvatarSelectionRow avatarSelection={avatarSelection} />
236
+ <Divider />
237
+ </>
238
+ )}
208
239
  <View style={styles.memberSection}>
209
240
  <Heading variant="h3">{pluralize(memberCount, 'member')} selected</Heading>
210
241
  {hasChildren && (