@planningcenter/chat-react-native 3.32.1-rc.1 → 3.33.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 (171) 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/primitive/avatar_primitive.d.ts +3 -1
  15. package/build/components/primitive/avatar_primitive.d.ts.map +1 -1
  16. package/build/components/primitive/avatar_primitive.js +10 -2
  17. package/build/components/primitive/avatar_primitive.js.map +1 -1
  18. package/build/hooks/attachments/fallback_chat_configuration.d.ts +4 -0
  19. package/build/hooks/attachments/fallback_chat_configuration.d.ts.map +1 -0
  20. package/build/hooks/attachments/fallback_chat_configuration.js +59 -0
  21. package/build/hooks/attachments/fallback_chat_configuration.js.map +1 -0
  22. package/build/hooks/groups/use_groups_conversation_create.d.ts.map +1 -1
  23. package/build/hooks/groups/use_groups_conversation_create.js +1 -1
  24. package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
  25. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +43 -11
  26. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  27. package/build/hooks/services/use_find_or_create_services_conversation.js +5 -5
  28. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  29. package/build/hooks/use_attachment_uploader.d.ts.map +1 -1
  30. package/build/hooks/use_attachment_uploader.js +39 -14
  31. package/build/hooks/use_attachment_uploader.js.map +1 -1
  32. package/build/hooks/use_chat_configuration.d.ts +6 -0
  33. package/build/hooks/use_chat_configuration.d.ts.map +1 -0
  34. package/build/hooks/use_chat_configuration.js +41 -0
  35. package/build/hooks/use_chat_configuration.js.map +1 -0
  36. package/build/hooks/use_conversation_avatar_update.d.ts +26 -0
  37. package/build/hooks/use_conversation_avatar_update.d.ts.map +1 -0
  38. package/build/hooks/use_conversation_avatar_update.js +130 -0
  39. package/build/hooks/use_conversation_avatar_update.js.map +1 -0
  40. package/build/hooks/use_features.d.ts +1 -0
  41. package/build/hooks/use_features.d.ts.map +1 -1
  42. package/build/hooks/use_features.js +1 -0
  43. package/build/hooks/use_features.js.map +1 -1
  44. package/build/navigation/index.d.ts +16 -0
  45. package/build/navigation/index.d.ts.map +1 -1
  46. package/build/navigation/index.js +9 -0
  47. package/build/navigation/index.js.map +1 -1
  48. package/build/screens/avatar_picker/avatar_picker_screen.d.ts +12 -0
  49. package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -0
  50. package/build/screens/avatar_picker/avatar_picker_screen.js +193 -0
  51. package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -0
  52. package/build/screens/avatar_picker/avatar_picker_state.d.ts +38 -0
  53. package/build/screens/avatar_picker/avatar_picker_state.d.ts.map +1 -0
  54. package/build/screens/avatar_picker/avatar_picker_state.js +101 -0
  55. package/build/screens/avatar_picker/avatar_picker_state.js.map +1 -0
  56. package/build/screens/avatar_picker/avatar_preview.d.ts +9 -0
  57. package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -0
  58. package/build/screens/avatar_picker/avatar_preview.js +39 -0
  59. package/build/screens/avatar_picker/avatar_preview.js.map +1 -0
  60. package/build/screens/avatar_picker/color_picker.d.ts +9 -0
  61. package/build/screens/avatar_picker/color_picker.d.ts.map +1 -0
  62. package/build/screens/avatar_picker/color_picker.js +53 -0
  63. package/build/screens/avatar_picker/color_picker.js.map +1 -0
  64. package/build/screens/avatar_picker/constants.d.ts +3 -0
  65. package/build/screens/avatar_picker/constants.d.ts.map +1 -0
  66. package/build/screens/avatar_picker/constants.js +53 -0
  67. package/build/screens/avatar_picker/constants.js.map +1 -0
  68. package/build/screens/avatar_picker/emoji_tab.d.ts +7 -0
  69. package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -0
  70. package/build/screens/avatar_picker/emoji_tab.js +55 -0
  71. package/build/screens/avatar_picker/emoji_tab.js.map +1 -0
  72. package/build/screens/avatar_picker/icon_grid.d.ts +8 -0
  73. package/build/screens/avatar_picker/icon_grid.d.ts.map +1 -0
  74. package/build/screens/avatar_picker/icon_grid.js +48 -0
  75. package/build/screens/avatar_picker/icon_grid.js.map +1 -0
  76. package/build/screens/avatar_picker/upload_tab.d.ts +9 -0
  77. package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -0
  78. package/build/screens/avatar_picker/upload_tab.js +39 -0
  79. package/build/screens/avatar_picker/upload_tab.js.map +1 -0
  80. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  81. package/build/screens/conversation_details_screen.js +37 -1
  82. package/build/screens/conversation_details_screen.js.map +1 -1
  83. package/build/screens/conversation_new/components/avatar_selection_row.d.ts +12 -0
  84. package/build/screens/conversation_new/components/avatar_selection_row.d.ts.map +1 -0
  85. package/build/screens/conversation_new/components/avatar_selection_row.js +60 -0
  86. package/build/screens/conversation_new/components/avatar_selection_row.js.map +1 -0
  87. package/build/screens/conversation_new/components/gender_filter_toggle.d.ts.map +1 -1
  88. package/build/screens/conversation_new/components/gender_filter_toggle.js +3 -9
  89. package/build/screens/conversation_new/components/gender_filter_toggle.js.map +1 -1
  90. package/build/screens/conversation_new/components/groups_form.d.ts +3 -1
  91. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  92. package/build/screens/conversation_new/components/groups_form.js +22 -8
  93. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  94. package/build/screens/conversation_new/components/services_form.d.ts +3 -1
  95. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  96. package/build/screens/conversation_new/components/services_form.js +22 -8
  97. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  98. package/build/screens/conversation_new/conversation_new_screen.d.ts +2 -0
  99. package/build/screens/conversation_new/conversation_new_screen.d.ts.map +1 -1
  100. package/build/screens/conversation_new/conversation_new_screen.js +3 -3
  101. package/build/screens/conversation_new/conversation_new_screen.js.map +1 -1
  102. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  103. package/build/screens/team_conversation_screen.js +1 -1
  104. package/build/screens/team_conversation_screen.js.map +1 -1
  105. package/build/types/resources/chat_configuration_resource.d.ts +8 -0
  106. package/build/types/resources/chat_configuration_resource.d.ts.map +1 -0
  107. package/build/types/resources/chat_configuration_resource.js +2 -0
  108. package/build/types/resources/chat_configuration_resource.js.map +1 -0
  109. package/build/utils/native_adapters/configuration.d.ts +3 -0
  110. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  111. package/build/utils/native_adapters/configuration.js +8 -0
  112. package/build/utils/native_adapters/configuration.js.map +1 -1
  113. package/build/utils/native_adapters/document_picker.d.ts +21 -0
  114. package/build/utils/native_adapters/document_picker.d.ts.map +1 -0
  115. package/build/utils/native_adapters/document_picker.js +7 -0
  116. package/build/utils/native_adapters/document_picker.js.map +1 -0
  117. package/build/utils/native_adapters/image_picker.d.ts +7 -1
  118. package/build/utils/native_adapters/image_picker.d.ts.map +1 -1
  119. package/build/utils/native_adapters/image_picker.js.map +1 -1
  120. package/build/utils/native_adapters/index.d.ts +1 -0
  121. package/build/utils/native_adapters/index.d.ts.map +1 -1
  122. package/build/utils/native_adapters/index.js +1 -0
  123. package/build/utils/native_adapters/index.js.map +1 -1
  124. package/build/utils/request/get_chat_configuration.d.ts +10 -0
  125. package/build/utils/request/get_chat_configuration.d.ts.map +1 -0
  126. package/build/utils/request/get_chat_configuration.js +21 -0
  127. package/build/utils/request/get_chat_configuration.js.map +1 -0
  128. package/package.json +4 -3
  129. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +219 -0
  130. package/src/__tests__/hooks/use_chat_configuration.test.tsx +80 -0
  131. package/src/__tests__/utils/native_adapters/configuration.ts +25 -1
  132. package/src/components/conversation/message_form.tsx +39 -1
  133. package/src/components/display/emoji_avatar.tsx +7 -2
  134. package/src/components/display/icon_avatar.tsx +7 -2
  135. package/src/components/display/utils/avatar_gradient_colors.ts +10 -3
  136. package/src/components/primitive/avatar_primitive.tsx +11 -2
  137. package/src/hooks/attachments/fallback_chat_configuration.ts +61 -0
  138. package/src/hooks/groups/use_groups_conversation_create.ts +2 -1
  139. package/src/hooks/services/use_find_or_create_services_conversation.ts +7 -7
  140. package/src/hooks/use_attachment_uploader.ts +39 -15
  141. package/src/hooks/use_chat_configuration.ts +54 -0
  142. package/src/hooks/use_conversation_avatar_update.ts +163 -0
  143. package/src/hooks/use_features.ts +1 -0
  144. package/src/navigation/index.tsx +13 -0
  145. package/src/screens/avatar_picker/__tests__/avatar_picker_state.test.ts +157 -0
  146. package/src/screens/avatar_picker/avatar_picker_screen.tsx +312 -0
  147. package/src/screens/avatar_picker/avatar_picker_state.ts +141 -0
  148. package/src/screens/avatar_picker/avatar_preview.tsx +46 -0
  149. package/src/screens/avatar_picker/color_picker.tsx +91 -0
  150. package/src/screens/avatar_picker/constants.ts +53 -0
  151. package/src/screens/avatar_picker/emoji_tab.tsx +76 -0
  152. package/src/screens/avatar_picker/icon_grid.tsx +81 -0
  153. package/src/screens/avatar_picker/upload_tab.tsx +62 -0
  154. package/src/screens/conversation_details_screen.tsx +60 -1
  155. package/src/screens/conversation_new/components/avatar_selection_row.tsx +82 -0
  156. package/src/screens/conversation_new/components/gender_filter_toggle.tsx +3 -9
  157. package/src/screens/conversation_new/components/groups_form.tsx +33 -6
  158. package/src/screens/conversation_new/components/services_form.tsx +37 -6
  159. package/src/screens/conversation_new/conversation_new_screen.tsx +17 -3
  160. package/src/screens/team_conversation_screen.tsx +2 -1
  161. package/src/types/resources/chat_configuration_resource.ts +11 -0
  162. package/src/utils/native_adapters/configuration.ts +10 -0
  163. package/src/utils/native_adapters/document_picker.ts +26 -0
  164. package/src/utils/native_adapters/image_picker.ts +8 -1
  165. package/src/utils/native_adapters/index.ts +1 -0
  166. package/src/utils/request/get_chat_configuration.ts +23 -0
  167. package/build/hooks/attachments/supported_extensions.d.ts +0 -2
  168. package/build/hooks/attachments/supported_extensions.d.ts.map +0 -1
  169. package/build/hooks/attachments/supported_extensions.js +0 -48
  170. package/build/hooks/attachments/supported_extensions.js.map +0 -1
  171. package/src/hooks/attachments/supported_extensions.ts +0 -47
@@ -0,0 +1,157 @@
1
+ import { DEFAULT_AVATAR_COLOR_KEY } from '../../../components/display/utils/avatar_gradient_colors'
2
+ import type { AvatarUpdatePayload } from '../../../hooks/use_conversation_avatar_update'
3
+ import type { ConversationResource } from '../../../types'
4
+ import {
5
+ avatarPickerReducer,
6
+ initAvatarPickerState,
7
+ initAvatarPickerStateFromPayload,
8
+ initEmptyAvatarPickerState,
9
+ } from '../avatar_picker_state'
10
+
11
+ const emptyState = initEmptyAvatarPickerState()
12
+
13
+ describe('initEmptyAvatarPickerState', () => {
14
+ it('starts clean', () => {
15
+ expect(emptyState).toMatchObject({
16
+ activeTab: 'icon',
17
+ selectedType: null,
18
+ selectedKey: null,
19
+ selectedColor: DEFAULT_AVATAR_COLOR_KEY,
20
+ imagePreviewUri: null,
21
+ imageAsset: null,
22
+ isDirty: false,
23
+ })
24
+ })
25
+ })
26
+
27
+ describe('initAvatarPickerState', () => {
28
+ it('seeds from icon avatar', () => {
29
+ const conversation = {
30
+ customAvatarType: 'icon' as const,
31
+ customAvatarKey: 'guitar',
32
+ customAvatarColor: 'cosmic',
33
+ customAvatarImageUrl: null,
34
+ } as Pick<
35
+ ConversationResource,
36
+ 'customAvatarType' | 'customAvatarKey' | 'customAvatarColor' | 'customAvatarImageUrl'
37
+ >
38
+
39
+ const state = initAvatarPickerState(conversation)
40
+ expect(state).toMatchObject({
41
+ activeTab: 'icon',
42
+ selectedType: 'icon',
43
+ selectedKey: 'guitar',
44
+ selectedColor: 'cosmic',
45
+ isDirty: false,
46
+ })
47
+ })
48
+
49
+ it('seeds from image avatar — active tab is upload', () => {
50
+ const conversation = {
51
+ customAvatarType: 'image' as const,
52
+ customAvatarKey: null,
53
+ customAvatarColor: 'rose-gold',
54
+ customAvatarImageUrl: 'https://example.com/avatar.jpg',
55
+ } as Pick<
56
+ ConversationResource,
57
+ 'customAvatarType' | 'customAvatarKey' | 'customAvatarColor' | 'customAvatarImageUrl'
58
+ >
59
+
60
+ const state = initAvatarPickerState(conversation)
61
+ expect(state).toMatchObject({
62
+ activeTab: 'upload',
63
+ selectedType: 'image',
64
+ imagePreviewUri: 'https://example.com/avatar.jpg',
65
+ imageAsset: null,
66
+ isDirty: false,
67
+ })
68
+ })
69
+
70
+ it('falls back to icon tab and default color when no avatar', () => {
71
+ const conversation = {
72
+ customAvatarType: null,
73
+ customAvatarKey: null,
74
+ customAvatarColor: null,
75
+ customAvatarImageUrl: null,
76
+ } as Pick<
77
+ ConversationResource,
78
+ 'customAvatarType' | 'customAvatarKey' | 'customAvatarColor' | 'customAvatarImageUrl'
79
+ >
80
+
81
+ const state = initAvatarPickerState(conversation)
82
+ expect(state).toMatchObject({
83
+ activeTab: 'icon',
84
+ selectedType: null,
85
+ selectedColor: DEFAULT_AVATAR_COLOR_KEY,
86
+ isDirty: false,
87
+ })
88
+ })
89
+ })
90
+
91
+ describe('initAvatarPickerStateFromPayload', () => {
92
+ it('restores icon payload', () => {
93
+ const payload: AvatarUpdatePayload = { kind: 'icon', key: 'dove', color: 'twilight' }
94
+ const state = initAvatarPickerStateFromPayload(payload)
95
+ expect(state).toMatchObject({
96
+ activeTab: 'icon',
97
+ selectedType: 'icon',
98
+ selectedKey: 'dove',
99
+ selectedColor: 'twilight',
100
+ isDirty: false,
101
+ })
102
+ })
103
+
104
+ it('restores emoji payload', () => {
105
+ const payload: AvatarUpdatePayload = { kind: 'emoji', key: '🎸', color: 'garden' }
106
+ const state = initAvatarPickerStateFromPayload(payload)
107
+ expect(state).toMatchObject({
108
+ activeTab: 'emoji',
109
+ selectedType: 'emoji',
110
+ selectedKey: '🎸',
111
+ isDirty: false,
112
+ })
113
+ })
114
+
115
+ it('falls back to empty state for clear payload', () => {
116
+ const payload: AvatarUpdatePayload = { kind: 'clear' }
117
+ const state = initAvatarPickerStateFromPayload(payload)
118
+ expect(state).toMatchObject(emptyState)
119
+ })
120
+ })
121
+
122
+ describe('avatarPickerReducer', () => {
123
+ it('SELECT_ICON sets type, key, isDirty', () => {
124
+ const state = avatarPickerReducer(emptyState, { type: 'SELECT_ICON', payload: 'guitar' })
125
+ expect(state).toMatchObject({ selectedType: 'icon', selectedKey: 'guitar', isDirty: true })
126
+ })
127
+
128
+ it('SELECT_EMOJI sets type, key, isDirty', () => {
129
+ const state = avatarPickerReducer(emptyState, { type: 'SELECT_EMOJI', payload: '🎵' })
130
+ expect(state).toMatchObject({ selectedType: 'emoji', selectedKey: '🎵', isDirty: true })
131
+ })
132
+
133
+ it('SELECT_COLOR updates color and sets isDirty', () => {
134
+ const state = avatarPickerReducer(emptyState, { type: 'SELECT_COLOR', payload: 'cosmic' })
135
+ expect(state).toMatchObject({ selectedColor: 'cosmic', isDirty: true })
136
+ })
137
+
138
+ it('SELECT_TAB switches tab without setting isDirty', () => {
139
+ const state = avatarPickerReducer(emptyState, { type: 'SELECT_TAB', payload: 'emoji' })
140
+ expect(state.activeTab).toBe('emoji')
141
+ expect(state.isDirty).toBe(false)
142
+ })
143
+
144
+ it('CLEAR resets selection, color, and sets isDirty', () => {
145
+ const withIcon = avatarPickerReducer(emptyState, { type: 'SELECT_ICON', payload: 'dove' })
146
+ const withColor = avatarPickerReducer(withIcon, { type: 'SELECT_COLOR', payload: 'cosmic' })
147
+ const cleared = avatarPickerReducer(withColor, { type: 'CLEAR' })
148
+ expect(cleared).toMatchObject({
149
+ selectedType: null,
150
+ selectedKey: null,
151
+ selectedColor: DEFAULT_AVATAR_COLOR_KEY,
152
+ imagePreviewUri: null,
153
+ imageAsset: null,
154
+ isDirty: true,
155
+ })
156
+ })
157
+ })
@@ -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
+ })