@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,312 @@
1
+ import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
2
+ import React, { useCallback, useReducer } from 'react'
3
+ import { Platform, StyleSheet, View } from 'react-native'
4
+ import { ConversationAvatar } from '../../components/display/conversation_avatar'
5
+ import { Tabs } from '../../components/display/tabs'
6
+ import { Text } from '../../components/display/text'
7
+ import { coerceColorKey } from '../../components/display/utils/avatar_gradient_colors'
8
+ import FormSheet, { getFormSheetScreenOptions } from '../../components/primitive/form_sheet'
9
+ import { useTheme } from '../../hooks'
10
+ import { useConversation } from '../../hooks/use_conversation'
11
+ import {
12
+ useConversationAvatarUpdate,
13
+ type AvatarUpdatePayload,
14
+ } from '../../hooks/use_conversation_avatar_update'
15
+ import { useFontScale } from '../../hooks/use_font_scale'
16
+ import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker'
17
+ import {
18
+ avatarPickerReducer,
19
+ initAvatarPickerState,
20
+ initAvatarPickerStateFromPayload,
21
+ initEmptyAvatarPickerState,
22
+ type AvatarPickerAction,
23
+ type AvatarPickerState,
24
+ type AvatarTab,
25
+ } from './avatar_picker_state'
26
+ import { AvatarPreview } from './avatar_preview'
27
+ import { ColorPicker } from './color_picker'
28
+ import { EmojiTab } from './emoji_tab'
29
+ import { IconGrid } from './icon_grid'
30
+ import { UploadTab } from './upload_tab'
31
+
32
+ export const AvatarPickerScreenOptions = getFormSheetScreenOptions({
33
+ headerTitle: 'Update avatar',
34
+ sheetAllowedDetents: Platform.select({
35
+ android: [0.94],
36
+ default: [1],
37
+ }),
38
+ })
39
+
40
+ export const AvatarPickerCreateScreenOptions = getFormSheetScreenOptions({
41
+ headerTitle: 'Choose avatar',
42
+ sheetAllowedDetents: Platform.select({
43
+ android: [0.94],
44
+ default: [1],
45
+ }),
46
+ })
47
+
48
+ export type AvatarPickerScreenProps = StaticScreenProps<{
49
+ conversation_id?: number
50
+ source_params?: Record<string, unknown>
51
+ avatar_selection?: AvatarUpdatePayload
52
+ }>
53
+
54
+ const TABS: AvatarTab[] = ['icon', 'emoji', 'upload']
55
+
56
+ const TAB_LABELS: Record<AvatarTab, string> = {
57
+ icon: 'Icon',
58
+ emoji: 'Emoji',
59
+ upload: 'Upload',
60
+ }
61
+
62
+ export function AvatarPickerScreen({ route }: AvatarPickerScreenProps) {
63
+ const { conversation_id, source_params, avatar_selection } = route.params
64
+ if (conversation_id) {
65
+ return <EditModeContent conversationId={conversation_id} />
66
+ }
67
+ return <CreateModeContent sourceParams={source_params} existingSelection={avatar_selection} />
68
+ }
69
+
70
+ function EditModeContent({ conversationId }: { conversationId: number }) {
71
+ const navigation = useNavigation()
72
+ const { data: conversation } = useConversation({ conversation_id: conversationId })
73
+ const mutation = useConversationAvatarUpdate({ conversationId })
74
+
75
+ const [state, dispatch] = useReducer(avatarPickerReducer, conversation, initAvatarPickerState)
76
+
77
+ const hasAvatar = state.selectedType !== null
78
+
79
+ const handleDone = useCallback(() => {
80
+ if (!state.isDirty) {
81
+ navigation.goBack()
82
+ return
83
+ }
84
+
85
+ const payload = buildPayload(state)
86
+ if (payload) {
87
+ mutation.mutate(payload)
88
+ }
89
+ navigation.goBack()
90
+ }, [state, mutation, navigation])
91
+
92
+ const handleRemove = useCallback(() => {
93
+ mutation.mutate({ kind: 'clear' }, { onSuccess: () => dispatch({ type: 'CLEAR' }) })
94
+ }, [mutation, dispatch])
95
+
96
+ return (
97
+ <AvatarPickerFormSheet
98
+ state={state}
99
+ dispatch={dispatch}
100
+ headerTitle="Update avatar"
101
+ headerActions={
102
+ <>
103
+ {hasAvatar && (
104
+ <FormSheet.HeaderTextButton onPress={handleRemove} appearance="danger">
105
+ Remove
106
+ </FormSheet.HeaderTextButton>
107
+ )}
108
+ <FormSheet.HeaderTextButton onPress={handleDone} disabled={mutation.isPending}>
109
+ Done
110
+ </FormSheet.HeaderTextButton>
111
+ </>
112
+ }
113
+ fallbackPreview={<ConversationAvatar conversation={conversation} size="2xl" />}
114
+ />
115
+ )
116
+ }
117
+
118
+ interface CreateModeContentProps {
119
+ sourceParams?: Record<string, unknown>
120
+ existingSelection?: AvatarUpdatePayload
121
+ }
122
+
123
+ function CreateModeContent({ sourceParams, existingSelection }: CreateModeContentProps) {
124
+ const navigation = useNavigation()
125
+
126
+ const [state, dispatch] = useReducer(avatarPickerReducer, existingSelection, selection =>
127
+ selection ? initAvatarPickerStateFromPayload(selection) : initEmptyAvatarPickerState()
128
+ )
129
+
130
+ const handleDone = useCallback(() => {
131
+ if (!state.isDirty) {
132
+ navigation.goBack()
133
+ return
134
+ }
135
+
136
+ const payload = buildPayload(state)
137
+ if (payload) {
138
+ navigation.dispatch(
139
+ StackActions.popTo('ConversationNew', { ...sourceParams, avatar_selection: payload })
140
+ )
141
+ } else {
142
+ navigation.goBack()
143
+ }
144
+ }, [state, navigation, sourceParams])
145
+
146
+ return (
147
+ <AvatarPickerFormSheet
148
+ state={state}
149
+ dispatch={dispatch}
150
+ headerTitle="Choose avatar"
151
+ headerActions={
152
+ <FormSheet.HeaderTextButton onPress={handleDone}>Done</FormSheet.HeaderTextButton>
153
+ }
154
+ fallbackPreview={<EmptyAvatarPlaceholder />}
155
+ />
156
+ )
157
+ }
158
+
159
+ interface AvatarPickerFormSheetProps {
160
+ state: AvatarPickerState
161
+ dispatch: React.Dispatch<AvatarPickerAction>
162
+ headerTitle: string
163
+ headerActions: React.ReactNode
164
+ fallbackPreview: React.ReactNode
165
+ }
166
+
167
+ function AvatarPickerFormSheet({
168
+ state,
169
+ dispatch,
170
+ headerTitle,
171
+ headerActions,
172
+ fallbackPreview,
173
+ }: AvatarPickerFormSheetProps) {
174
+ const styles = useStyles()
175
+
176
+ return (
177
+ <FormSheet.Root style={styles.formSheetRoot} contentStyle={styles.formSheetContent}>
178
+ <FormSheet.Header>
179
+ <FormSheet.HeaderTitle>{headerTitle}</FormSheet.HeaderTitle>
180
+ <FormSheet.HeaderActions>{headerActions}</FormSheet.HeaderActions>
181
+ </FormSheet.Header>
182
+
183
+ <AvatarPreview state={state} fallback={fallbackPreview} />
184
+
185
+ <Tabs
186
+ data={TABS}
187
+ activeTab={state.activeTab}
188
+ onTabPress={tab => dispatch({ type: 'SELECT_TAB', payload: tab })}
189
+ renderItem={({ item }) => (
190
+ <Text style={[styles.tabLabel, item === state.activeTab && styles.tabLabelActive]}>
191
+ {TAB_LABELS[item]}
192
+ </Text>
193
+ )}
194
+ style={styles.tabs}
195
+ />
196
+
197
+ <View style={styles.body}>
198
+ {state.activeTab === 'icon' && (
199
+ <IconGrid
200
+ selectedIconKey={state.selectedType === 'icon' ? state.selectedKey : null}
201
+ onIconSelect={iconKey => dispatch({ type: 'SELECT_ICON', payload: iconKey })}
202
+ />
203
+ )}
204
+ {state.activeTab === 'emoji' && (
205
+ <EmojiTab onEmojiSelect={emoji => dispatch({ type: 'SELECT_EMOJI', payload: emoji })} />
206
+ )}
207
+ {state.activeTab === 'upload' && (
208
+ <UploadTab
209
+ imagePreviewUri={state.imagePreviewUri}
210
+ onImageSelect={asset => dispatch({ type: 'SET_IMAGE', payload: asset })}
211
+ />
212
+ )}
213
+ </View>
214
+
215
+ {state.activeTab !== 'upload' && (
216
+ <View style={styles.colorPickerWrapper}>
217
+ <ColorPicker
218
+ selectedColor={state.selectedColor}
219
+ onColorSelect={color => dispatch({ type: 'SELECT_COLOR', payload: color })}
220
+ />
221
+ </View>
222
+ )}
223
+ </FormSheet.Root>
224
+ )
225
+ }
226
+
227
+ function EmptyAvatarPlaceholder() {
228
+ const styles = useStyles()
229
+ return <View style={styles.emptyAvatarPlaceholder} />
230
+ }
231
+
232
+ type ValidAvatarPickerState = AvatarPickerState &
233
+ (
234
+ | { selectedType: 'icon' | 'emoji'; selectedKey: string }
235
+ | { selectedType: 'image'; imageAsset: ImagePickerAsset }
236
+ )
237
+
238
+ function hasValidSelection(state: AvatarPickerState): state is ValidAvatarPickerState {
239
+ switch (state.selectedType) {
240
+ case 'icon':
241
+ case 'emoji':
242
+ return state.selectedKey !== null
243
+ case 'image':
244
+ return state.imageAsset !== null
245
+ case null:
246
+ return false
247
+ }
248
+ }
249
+
250
+ function buildPayload(state: AvatarPickerState): AvatarUpdatePayload | null {
251
+ if (!hasValidSelection(state)) return null
252
+
253
+ const color = coerceColorKey(state.selectedColor)
254
+
255
+ switch (state.selectedType) {
256
+ case 'icon':
257
+ return { kind: 'icon', key: state.selectedKey, color }
258
+ case 'emoji':
259
+ return { kind: 'emoji', key: state.selectedKey, color }
260
+ case 'image':
261
+ return { kind: 'image', imageAsset: state.imageAsset, color }
262
+ }
263
+ }
264
+
265
+ const useStyles = () => {
266
+ const { colors } = useTheme()
267
+ const fontScale = useFontScale({ maxFontSizeMultiplier: 1.3 })
268
+ const uncappedFontScale = useFontScale()
269
+ const emptyAvatarDiameter = 80 * uncappedFontScale
270
+
271
+ return StyleSheet.create({
272
+ formSheetRoot: {
273
+ flex: 1,
274
+ },
275
+ formSheetContent: {
276
+ flex: 1,
277
+ },
278
+ tabs: {
279
+ minHeight: 52 * fontScale,
280
+ borderBottomWidth: 1,
281
+ borderBottomColor: colors.borderColorDefaultBase,
282
+ backgroundColor: colors.fillColorNeutral100Inverted,
283
+ zIndex: 1,
284
+ },
285
+ tabLabel: {
286
+ fontSize: 14,
287
+ color: colors.textColorDefaultSecondary,
288
+ paddingVertical: 14 * fontScale,
289
+ minWidth: 56 * fontScale,
290
+ textAlign: 'center',
291
+ },
292
+ tabLabelActive: {
293
+ color: colors.textColorDefaultPrimary,
294
+ },
295
+ body: {
296
+ flex: 1,
297
+ overflow: 'hidden',
298
+ },
299
+ colorPickerWrapper: {
300
+ borderTopWidth: 1,
301
+ borderTopColor: colors.borderColorDefaultBase,
302
+ },
303
+ emptyAvatarPlaceholder: {
304
+ width: emptyAvatarDiameter,
305
+ height: emptyAvatarDiameter,
306
+ borderRadius: emptyAvatarDiameter / 2,
307
+ borderWidth: 2,
308
+ borderStyle: 'dashed',
309
+ borderColor: colors.borderColorDefaultBase,
310
+ },
311
+ })
312
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ CustomAvatarColorKey,
3
+ DEFAULT_AVATAR_COLOR_KEY,
4
+ } from '../../components/display/utils/avatar_gradient_colors'
5
+ import type { AvatarType, AvatarUpdatePayload } from '../../hooks/use_conversation_avatar_update'
6
+ import type { ConversationResource } from '../../types'
7
+ import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker'
8
+
9
+ export type { AvatarType }
10
+ export type AvatarTab = 'icon' | 'emoji' | 'upload'
11
+
12
+ export interface AvatarPickerState {
13
+ activeTab: AvatarTab
14
+ selectedType: AvatarType | null
15
+ selectedKey: string | null
16
+ selectedColor: string
17
+ imagePreviewUri: string | null
18
+ imageAsset: ImagePickerAsset | null
19
+ isDirty: boolean
20
+ }
21
+
22
+ export type AvatarPickerAction =
23
+ | { type: 'SELECT_TAB'; payload: AvatarTab }
24
+ | { type: 'SELECT_ICON'; payload: string }
25
+ | { type: 'SELECT_EMOJI'; payload: string }
26
+ | { type: 'SELECT_COLOR'; payload: CustomAvatarColorKey }
27
+ | { type: 'SET_IMAGE'; payload: ImagePickerAsset }
28
+ | { type: 'CLEAR' }
29
+
30
+ export function avatarPickerReducer(
31
+ state: AvatarPickerState,
32
+ action: AvatarPickerAction
33
+ ): AvatarPickerState {
34
+ switch (action.type) {
35
+ case 'SELECT_TAB':
36
+ return { ...state, activeTab: action.payload }
37
+ case 'SELECT_ICON':
38
+ return {
39
+ ...state,
40
+ selectedType: 'icon',
41
+ selectedKey: action.payload,
42
+ isDirty: true,
43
+ }
44
+ case 'SELECT_EMOJI':
45
+ return {
46
+ ...state,
47
+ selectedType: 'emoji',
48
+ selectedKey: action.payload,
49
+ isDirty: true,
50
+ }
51
+ case 'SELECT_COLOR':
52
+ return {
53
+ ...state,
54
+ selectedColor: action.payload,
55
+ isDirty: true,
56
+ }
57
+ case 'SET_IMAGE':
58
+ return {
59
+ ...state,
60
+ selectedType: 'image',
61
+ selectedKey: null,
62
+ imagePreviewUri: action.payload.uri,
63
+ imageAsset: action.payload,
64
+ isDirty: true,
65
+ }
66
+ case 'CLEAR':
67
+ return {
68
+ ...state,
69
+ selectedType: null,
70
+ selectedKey: null,
71
+ selectedColor: DEFAULT_AVATAR_COLOR_KEY,
72
+ imagePreviewUri: null,
73
+ imageAsset: null,
74
+ isDirty: true,
75
+ }
76
+ }
77
+ }
78
+
79
+ export function initEmptyAvatarPickerState(): AvatarPickerState {
80
+ return {
81
+ activeTab: 'icon',
82
+ selectedType: null,
83
+ selectedKey: null,
84
+ selectedColor: DEFAULT_AVATAR_COLOR_KEY,
85
+ imagePreviewUri: null,
86
+ imageAsset: null,
87
+ isDirty: false,
88
+ }
89
+ }
90
+
91
+ export function initAvatarPickerState(
92
+ conversation: Pick<
93
+ ConversationResource,
94
+ 'customAvatarType' | 'customAvatarKey' | 'customAvatarColor' | 'customAvatarImageUrl'
95
+ >
96
+ ): AvatarPickerState {
97
+ const tabFromType: Record<AvatarType, AvatarTab> = {
98
+ icon: 'icon',
99
+ emoji: 'emoji',
100
+ image: 'upload',
101
+ }
102
+
103
+ return {
104
+ activeTab:
105
+ (conversation.customAvatarType && tabFromType[conversation.customAvatarType]) ?? 'icon',
106
+ selectedType: conversation.customAvatarType ?? null,
107
+ selectedKey: conversation.customAvatarKey ?? null,
108
+ selectedColor: conversation.customAvatarColor || DEFAULT_AVATAR_COLOR_KEY,
109
+ imagePreviewUri: conversation.customAvatarImageUrl ?? null,
110
+ imageAsset: null,
111
+ isDirty: false,
112
+ }
113
+ }
114
+
115
+ export function initAvatarPickerStateFromPayload(payload: AvatarUpdatePayload): AvatarPickerState {
116
+ switch (payload.kind) {
117
+ case 'icon':
118
+ case 'emoji':
119
+ return {
120
+ activeTab: payload.kind,
121
+ selectedType: payload.kind,
122
+ selectedKey: payload.key,
123
+ selectedColor: payload.color || DEFAULT_AVATAR_COLOR_KEY,
124
+ imagePreviewUri: null,
125
+ imageAsset: null,
126
+ isDirty: false,
127
+ }
128
+ case 'image':
129
+ return {
130
+ activeTab: 'upload',
131
+ selectedType: 'image',
132
+ selectedKey: null,
133
+ selectedColor: payload.color || DEFAULT_AVATAR_COLOR_KEY,
134
+ imagePreviewUri: payload.imageAsset.uri,
135
+ imageAsset: payload.imageAsset,
136
+ isDirty: false,
137
+ }
138
+ default:
139
+ return initEmptyAvatarPickerState()
140
+ }
141
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { EmojiAvatar } from '../../components/display/emoji_avatar'
4
+ import { IconAvatar } from '../../components/display/icon_avatar'
5
+ import AvatarPrimitive from '../../components/primitive/avatar_primitive'
6
+ import type { AvatarPickerState } from './avatar_picker_state'
7
+
8
+ interface AvatarPreviewProps {
9
+ state: AvatarPickerState
10
+ fallback: React.ReactNode
11
+ }
12
+
13
+ function getPreviewNode(state: AvatarPickerState): React.ReactNode {
14
+ switch (state.selectedType) {
15
+ case 'icon':
16
+ if (!state.selectedKey) return null
17
+ return <IconAvatar iconKey={state.selectedKey} color={state.selectedColor} size="2xl" />
18
+ case 'emoji':
19
+ if (!state.selectedKey) return null
20
+ return <EmojiAvatar emoji={state.selectedKey} color={state.selectedColor} size="2xl" />
21
+ case 'image':
22
+ if (!state.imagePreviewUri) return null
23
+ return (
24
+ <AvatarPrimitive.Root size="2xl">
25
+ <AvatarPrimitive.Mask>
26
+ <AvatarPrimitive.Image sourceUri={state.imagePreviewUri} />
27
+ </AvatarPrimitive.Mask>
28
+ </AvatarPrimitive.Root>
29
+ )
30
+ default:
31
+ return null
32
+ }
33
+ }
34
+
35
+ export function AvatarPreview({ state, fallback }: AvatarPreviewProps) {
36
+ const preview = getPreviewNode(state)
37
+ return <View style={styles.container}>{preview ?? fallback}</View>
38
+ }
39
+
40
+ const styles = StyleSheet.create({
41
+ container: {
42
+ alignItems: 'center',
43
+ paddingTop: 24,
44
+ paddingBottom: 16,
45
+ },
46
+ })
@@ -0,0 +1,91 @@
1
+ import { PlatformPressable } from '@react-navigation/elements'
2
+ import React, { useCallback } from 'react'
3
+ import { FlatList, StyleSheet, useWindowDimensions } from 'react-native'
4
+ import LinearGradient from 'react-native-linear-gradient'
5
+ import {
6
+ COLOR_KEYS,
7
+ type CustomAvatarColorKey,
8
+ getAvatarGradientProps,
9
+ } from '../../components/display/utils/avatar_gradient_colors'
10
+ import { useTheme } from '../../hooks'
11
+ import { GRID_COLUMNS } from './constants'
12
+
13
+ interface ColorPickerProps {
14
+ selectedColor: string
15
+ onColorSelect: (color: CustomAvatarColorKey) => void
16
+ }
17
+
18
+ export function ColorPicker({ selectedColor, onColorSelect }: ColorPickerProps) {
19
+ const styles = useStyles()
20
+
21
+ const renderItem = useCallback(
22
+ ({ item }: { item: CustomAvatarColorKey }) => {
23
+ const gradientProps = getAvatarGradientProps(item)
24
+ const isSelected = item === selectedColor
25
+
26
+ return (
27
+ <PlatformPressable
28
+ onPress={() => onColorSelect(item)}
29
+ style={[styles.swatchOuter, isSelected && styles.swatchSelected]}
30
+ accessibilityRole="button"
31
+ accessibilityLabel={`${item.replace(/-/g, ' ')} color`}
32
+ accessibilityState={{ selected: isSelected }}
33
+ >
34
+ <LinearGradient {...gradientProps} style={styles.swatch} />
35
+ </PlatformPressable>
36
+ )
37
+ },
38
+ [selectedColor, onColorSelect, styles.swatch, styles.swatchOuter, styles.swatchSelected]
39
+ )
40
+
41
+ return (
42
+ <FlatList
43
+ data={COLOR_KEYS}
44
+ numColumns={GRID_COLUMNS}
45
+ keyExtractor={item => item}
46
+ renderItem={renderItem}
47
+ contentContainerStyle={styles.grid}
48
+ columnWrapperStyle={styles.row}
49
+ scrollEnabled={false}
50
+ />
51
+ )
52
+ }
53
+
54
+ const PADDING = 16
55
+ const GAP = 8
56
+
57
+ const useStyles = () => {
58
+ const { colors } = useTheme()
59
+ const { width: screenWidth } = useWindowDimensions()
60
+ const swatchSize = Math.floor(
61
+ (screenWidth - PADDING * 2 - (GRID_COLUMNS - 1) * GAP) / GRID_COLUMNS
62
+ )
63
+ const innerSize = swatchSize - 6
64
+
65
+ return StyleSheet.create({
66
+ grid: {
67
+ paddingHorizontal: PADDING,
68
+ paddingVertical: 12,
69
+ },
70
+ row: {
71
+ gap: GAP,
72
+ marginBottom: GAP,
73
+ },
74
+ swatchOuter: {
75
+ width: swatchSize,
76
+ height: swatchSize,
77
+ borderRadius: swatchSize / 2,
78
+ alignItems: 'center',
79
+ justifyContent: 'center',
80
+ },
81
+ swatchSelected: {
82
+ borderWidth: 3,
83
+ borderColor: colors.interaction,
84
+ },
85
+ swatch: {
86
+ width: innerSize,
87
+ height: innerSize,
88
+ borderRadius: innerSize / 2,
89
+ },
90
+ })
91
+ }
@@ -0,0 +1,53 @@
1
+ export const AVATAR_ICON_KEYS: string[] = [
2
+ // Church
3
+ 'book-bible',
4
+ 'church',
5
+ 'cross',
6
+ 'dove',
7
+ 'hands-heart',
8
+ 'person-rays',
9
+ 'praying-hands',
10
+ 'podium',
11
+ 'droplet',
12
+ 'person-drowning',
13
+ 'lighthouse',
14
+ // Services
15
+ 'guitar',
16
+ 'guitar-electric',
17
+ 'violin',
18
+ 'calendar-heart',
19
+ 'hand-heart',
20
+ 'hand-wave',
21
+ 'baby',
22
+ 'children',
23
+ 'mug-hot',
24
+ 'video-camera',
25
+ 'music',
26
+ 'piano-keyboard',
27
+ 'drum',
28
+ 'user-graduate',
29
+ 'comments-question',
30
+ 'presentation',
31
+ 'microphone-stand',
32
+ 'id-badge',
33
+ 'book-user',
34
+ 'hand-holding-heart',
35
+ 'guitars',
36
+ 'amp-guitar',
37
+ 'school-flag',
38
+ 'shield-check',
39
+ 'projector',
40
+ // Groups
41
+ 'people',
42
+ 'people-group',
43
+ 'person-dress',
44
+ 'person',
45
+ 'seedling',
46
+ 'comments-alt',
47
+ 'globe',
48
+ 'book-open-reader',
49
+ 'running',
50
+ 'leaf',
51
+ ]
52
+
53
+ export const GRID_COLUMNS = 6