@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
@@ -1,5 +1,5 @@
1
1
  import { useMutation, useQuery } from '@tanstack/react-query'
2
- import { omitBy, isNil } from 'lodash'
2
+ import { isNil, omitBy } from 'lodash'
3
3
  import { useMemo } from 'react'
4
4
  import {
5
5
  ApiCollection,
@@ -14,7 +14,7 @@ import { ApiClient, useApiClient } from '../use_api_client'
14
14
  interface Props {
15
15
  teamIds?: number[]
16
16
  planId?: number
17
- onSuccess?: (conversation: ConversationResource) => void
17
+ onSuccess?: (conversation: ConversationResource, meta: { created: boolean }) => void
18
18
  }
19
19
 
20
20
  interface TeamAndPlanParams {
@@ -46,8 +46,8 @@ export const useFindOrCreateServicesConversation = ({ teamIds, planId, onSuccess
46
46
 
47
47
  const mutation = useMutation({
48
48
  throwOnError: true,
49
- onSuccess: result => {
50
- onSuccess && onSuccess(result)
49
+ onSuccess: ({ conversation, created }) => {
50
+ onSuccess?.(conversation, { created })
51
51
  },
52
52
  mutationFn: async () => findOrCreateServicesConversation(apiClient, teamAndPlanParams),
53
53
  })
@@ -58,7 +58,7 @@ export const useFindOrCreateServicesConversation = ({ teamIds, planId, onSuccess
58
58
  export const findOrCreateServicesConversation = async (
59
59
  apiClient: ApiClient,
60
60
  teamAndPlanParams: TeamAndPlanParams
61
- ) => {
61
+ ): Promise<{ conversation: ConversationResource; created: boolean }> => {
62
62
  const foundConversations = await getGroupIdsFromServices(apiClient, teamAndPlanParams)
63
63
  .then(res => res.data.groupIdentifiers)
64
64
  .then(groupIdentifiers => findConversationWithExactTeams(apiClient, groupIdentifiers))
@@ -66,13 +66,13 @@ export const findOrCreateServicesConversation = async (
66
66
  const foundConversation = foundConversations?.data[0]
67
67
 
68
68
  if (foundConversation?.id) {
69
- return foundConversation
69
+ return { conversation: foundConversation, created: false }
70
70
  }
71
71
 
72
72
  return fetchServicesPayload(apiClient, teamAndPlanParams)
73
73
  .then(res => res.data.payload)
74
74
  .then(payload => createConversation(apiClient, payload))
75
- .then(res => res.data)
75
+ .then(res => ({ conversation: res.data, created: true }))
76
76
  .catch(throwResponseError)
77
77
  }
78
78
 
@@ -5,8 +5,8 @@ import {
5
5
  FileUploadState,
6
6
  NativeAttachmentFile,
7
7
  } from '../types/resources/denormalized_attachment_resource_for_create'
8
- import { SUPPORTED_EXTENSIONS } from './attachments/supported_extensions'
9
8
  import { useApiClient } from './use_api_client'
9
+ import { useChatConfiguration } from './use_chat_configuration'
10
10
  import { useUploadClient } from './use_upload_client'
11
11
 
12
12
  export interface FileError {
@@ -14,10 +14,6 @@ export interface FileError {
14
14
  file_size?: boolean
15
15
  }
16
16
 
17
- const MAX_FILE_SIZE_IN_MB = 50
18
- const MAX_FILE_SIZE_IN_BYTES = MAX_FILE_SIZE_IN_MB * 1024 * 1024
19
- const MAX_NUMBER_OF_ATTACHMENTS = 10
20
-
21
17
  export function useAttachmentUploader({
22
18
  conversationId,
23
19
  draftAttachments,
@@ -27,6 +23,9 @@ export function useAttachmentUploader({
27
23
  }) {
28
24
  const apiClient = useApiClient()
29
25
  const uploadApi = useUploadClient()
26
+ const { allowedFileExtensions, maxFileSizeInBytes, maxAttachmentsPerMessage } =
27
+ useChatConfiguration()
28
+ const maxFileSizeInMb = Number((maxFileSizeInBytes / (1024 * 1024)).toFixed(1))
30
29
  const [attachments, setAttachments] = useState<FileAttachment[]>(() => draftAttachments || [])
31
30
  const uploadState = useRef<FileUploadState>({})
32
31
  const [lastUploadId, setLastUploadId] = useState<string>()
@@ -38,9 +37,9 @@ export function useAttachmentUploader({
38
37
  const fileErrors = {} as FileError
39
38
 
40
39
  const validFiles = files.filter(file => {
41
- const extension = file.name.split('.').pop() as string
42
- const isValidExtension = SUPPORTED_EXTENSIONS.includes(`.${extension}`)
43
- const isValidFileSize = file.size <= MAX_FILE_SIZE_IN_BYTES
40
+ const extension = file.name.toLowerCase().split('.').pop() as string
41
+ const isValidExtension = allowedFileExtensions.includes(`.${extension}`)
42
+ const isValidFileSize = file.size <= maxFileSizeInBytes
44
43
 
45
44
  if (!isValidExtension) {
46
45
  fileErrors.file_type ||= []
@@ -60,10 +59,10 @@ export function useAttachmentUploader({
60
59
  )
61
60
  }
62
61
  if (fileErrors.file_size) {
63
- errorMessages.push(`File size exceeds ${MAX_FILE_SIZE_IN_MB} MB`)
62
+ errorMessages.push(`File size exceeds ${maxFileSizeInMb} MB`)
64
63
  }
65
- if (numberOfAttachments + validFiles.length > MAX_NUMBER_OF_ATTACHMENTS) {
66
- errorMessages.push(`You can't attach more than ${MAX_NUMBER_OF_ATTACHMENTS} files at once.`)
64
+ if (numberOfAttachments + validFiles.length > maxAttachmentsPerMessage) {
65
+ errorMessages.push(`You can't attach more than ${maxAttachmentsPerMessage} files at once.`)
67
66
  }
68
67
  if (errorMessages.length > 0) {
69
68
  setErrorMessage(errorMessages.join('\n'))
@@ -100,21 +99,34 @@ export function useAttachmentUploader({
100
99
  }
101
100
  setLastUploadId(messageAttachmentId)
102
101
  })
103
- .catch(err => {
102
+ .catch(async err => {
104
103
  const isFlagged = err?.code === 'image_flagged'
105
104
  uploadState.current[attachment.file.name] = {
106
105
  status: 'error',
107
106
  flagged: isFlagged,
108
107
  }
109
108
  if (!isFlagged) {
110
- setErrorMessage('This file could not be uploaded.')
109
+ // Dev builds surface the raw server detail to shorten the
110
+ // feedback loop on backend errors (e.g. AWS SSO re-auth).
111
+ // Production users always see the generic message.
112
+ const serverDetail = __DEV__ ? await extractDevOnlyServerErrorDetail(err) : null
113
+ setErrorMessage(serverDetail ?? 'This file could not be uploaded.')
111
114
  }
112
115
  setLastUploadId(attachment.file.name)
113
116
  })
114
117
  })
115
118
  }
116
119
  },
117
- [numberOfAttachments, uploadApi, apiClient.chat, conversationId]
120
+ [
121
+ numberOfAttachments,
122
+ uploadApi,
123
+ apiClient.chat,
124
+ conversationId,
125
+ allowedFileExtensions,
126
+ maxFileSizeInBytes,
127
+ maxFileSizeInMb,
128
+ maxAttachmentsPerMessage,
129
+ ]
118
130
  )
119
131
 
120
132
  useEffect(() => {
@@ -168,7 +180,19 @@ export function useAttachmentUploader({
168
180
  pendingUploads,
169
181
  errorMessage,
170
182
  flaggedAttachmentCount,
171
- remainingAttachable: MAX_NUMBER_OF_ATTACHMENTS - numberOfAttachments,
183
+ remainingAttachable: Math.max(0, maxAttachmentsPerMessage - numberOfAttachments),
184
+ }
185
+ }
186
+
187
+ async function extractDevOnlyServerErrorDetail(err: unknown): Promise<string | null> {
188
+ const response = err as Response | null
189
+ if (!response?.clone) return null
190
+ try {
191
+ const body = await response.clone().json()
192
+ const detail = body?.errors?.[0]?.detail ?? body?.detail
193
+ return typeof detail === 'string' ? detail : null
194
+ } catch {
195
+ return null
172
196
  }
173
197
  }
174
198
 
@@ -0,0 +1,54 @@
1
+ import { useSuspenseQuery } from '@tanstack/react-query'
2
+ import { ApiResource } from '../types'
3
+ import type { ChatConfigurationResource } from '../types/resources/chat_configuration_resource'
4
+ import {
5
+ getChatConfigurationRequestArgs,
6
+ getChatConfigurationQueryKey,
7
+ } from '../utils/request/get_chat_configuration'
8
+ import {
9
+ FALLBACK_ALLOWED_FILE_EXTENSIONS,
10
+ FALLBACK_MAX_ATTACHMENTS_PER_MESSAGE,
11
+ FALLBACK_MAX_FILE_SIZE_IN_BYTES,
12
+ } from './attachments/fallback_chat_configuration'
13
+ import { useApiClient } from './use_api_client'
14
+
15
+ // Client-side mirror of ChatConfiguration (server is source of truth).
16
+ // Returns fallback values if the API request fails so that the attachment
17
+ // UX keeps working during transient server issues. The server still
18
+ // enforces its own limits — this hook only drives pre-upload UX.
19
+ export function useChatConfiguration() {
20
+ const apiClient = useApiClient()
21
+ const requestArgs = getChatConfigurationRequestArgs()
22
+
23
+ const { data } = useSuspenseQuery({
24
+ queryKey: getChatConfigurationQueryKey(),
25
+ queryFn: () => {
26
+ return apiClient.chat
27
+ .get<ApiResource<ChatConfigurationResource>>(requestArgs)
28
+ .catch(() => stableFallbackConfiguration)
29
+ },
30
+ staleTime: 1000 * 60 * 60, // 1 hour — this rarely changes
31
+ })
32
+
33
+ const attrs = data.data
34
+
35
+ return {
36
+ allowedFileExtensions: attrs.allowedFileExtensions,
37
+ maxFileSizeInBytes: attrs.maxFileSizeInBytes,
38
+ maxAttachmentsPerMessage: attrs.maxAttachmentsPerMessage,
39
+ }
40
+ }
41
+
42
+ // Shape matches what consumers see after transform_response flattens
43
+ // attributes and camelCases keys.
44
+ const stableFallbackConfiguration: ApiResource<ChatConfigurationResource> = {
45
+ data: {
46
+ type: 'ChatConfiguration',
47
+ id: 'current',
48
+ allowedFileExtensions: FALLBACK_ALLOWED_FILE_EXTENSIONS,
49
+ maxFileSizeInBytes: FALLBACK_MAX_FILE_SIZE_IN_BYTES,
50
+ maxAttachmentsPerMessage: FALLBACK_MAX_ATTACHMENTS_PER_MESSAGE,
51
+ },
52
+ links: {},
53
+ meta: {},
54
+ }
@@ -0,0 +1,163 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import { Alert } from 'react-native'
3
+ import type { ConversationResource } from '../types'
4
+ import type { ApiResource } from '../types'
5
+ import { transformGetToPost } from '../utils/client/request_helpers'
6
+ import type { ImagePickerAsset } from '../utils/native_adapters/image_picker'
7
+ import { ApiClient, useApiClient } from './use_api_client'
8
+ import { getConversationRequestArgs } from './use_conversation'
9
+ import { getRequestQueryKey } from './use_suspense_api'
10
+ import { useUploadClient } from './use_upload_client'
11
+
12
+ export type AvatarType = 'icon' | 'emoji' | 'image'
13
+
14
+ export type AvatarUpdatePayload =
15
+ | { kind: Extract<AvatarType, 'icon' | 'emoji'>; key: string; color: string }
16
+ | { kind: Extract<AvatarType, 'image'>; imageAsset: ImagePickerAsset; color: string }
17
+ | { kind: 'clear' }
18
+
19
+ export const useConversationAvatarUpdate = ({ conversationId }: { conversationId: number }) => {
20
+ const apiClient = useApiClient()
21
+ const uploadClient = useUploadClient()
22
+ const queryClient = useQueryClient()
23
+ const requestArgs = getConversationRequestArgs({ conversation_id: conversationId })
24
+ const queryKey = getRequestQueryKey(requestArgs)
25
+
26
+ return useMutation({
27
+ mutationKey: ['updateConversationAvatar', conversationId],
28
+ mutationFn: async (payload: AvatarUpdatePayload) => {
29
+ const postArgs = transformGetToPost(requestArgs).data
30
+ const attributes = await buildAttributes(payload, uploadClient)
31
+
32
+ return apiClient.chat.patch<ApiResource<ConversationResource>>({
33
+ url: `/me/conversations/${conversationId}/`,
34
+ data: { data: { type: '', attributes }, ...postArgs },
35
+ })
36
+ },
37
+ onMutate: async (payload: AvatarUpdatePayload) => {
38
+ await queryClient.cancelQueries({ queryKey })
39
+
40
+ const previous = queryClient.getQueryData<ApiResource<ConversationResource>>(queryKey)
41
+
42
+ queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, prev => {
43
+ if (!prev?.data) return prev
44
+ return { ...prev, data: buildOptimisticData(prev.data, payload) }
45
+ })
46
+
47
+ return { previous }
48
+ },
49
+ onSuccess: (response, _payload, _context) => {
50
+ queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, () => response)
51
+ queryClient.invalidateQueries({ queryKey: ['/me/conversations'] })
52
+ },
53
+ onError: (_error, _payload, context) => {
54
+ if (context?.previous) {
55
+ queryClient.setQueryData(queryKey, context.previous)
56
+ }
57
+
58
+ Alert.alert('Error', 'Failed to update conversation avatar. Please try again.')
59
+ },
60
+ })
61
+ }
62
+
63
+ export async function patchConversationAvatar(
64
+ apiClient: ApiClient,
65
+ uploadClient: ReturnType<typeof useUploadClient>,
66
+ conversationId: number,
67
+ payload: Exclude<AvatarUpdatePayload, { kind: 'clear' }>
68
+ ) {
69
+ const requestArgs = getConversationRequestArgs({ conversation_id: conversationId })
70
+ const postArgs = transformGetToPost(requestArgs).data
71
+
72
+ try {
73
+ const attributes = await buildAttributes(payload, uploadClient)
74
+ await apiClient.chat.patch({
75
+ url: `/me/conversations/${conversationId}/`,
76
+ data: { data: { type: '', attributes }, ...postArgs },
77
+ })
78
+ } catch {
79
+ Alert.alert(
80
+ 'Avatar not saved',
81
+ 'The conversation was created, but the avatar could not be saved. You can update it from the conversation details.'
82
+ )
83
+ }
84
+ }
85
+
86
+ function buildOptimisticData(
87
+ data: ConversationResource,
88
+ payload: AvatarUpdatePayload
89
+ ): ConversationResource {
90
+ switch (payload.kind) {
91
+ case 'icon':
92
+ return {
93
+ ...data,
94
+ customAvatarType: 'icon',
95
+ customAvatarKey: payload.key,
96
+ customAvatarColor: payload.color,
97
+ customAvatarImageUrl: null,
98
+ }
99
+ case 'emoji':
100
+ return {
101
+ ...data,
102
+ customAvatarType: 'emoji',
103
+ customAvatarKey: payload.key,
104
+ customAvatarColor: payload.color,
105
+ customAvatarImageUrl: null,
106
+ }
107
+ case 'image':
108
+ return {
109
+ ...data,
110
+ customAvatarType: 'image',
111
+ customAvatarKey: null,
112
+ customAvatarColor: payload.color,
113
+ customAvatarImageUrl: payload.imageAsset.uri,
114
+ }
115
+ case 'clear':
116
+ return {
117
+ ...data,
118
+ customAvatarType: null,
119
+ customAvatarKey: null,
120
+ customAvatarColor: null,
121
+ customAvatarImageUrl: null,
122
+ }
123
+ }
124
+ }
125
+
126
+ async function buildAttributes(
127
+ payload: AvatarUpdatePayload,
128
+ uploadClient: ReturnType<typeof useUploadClient>
129
+ ) {
130
+ switch (payload.kind) {
131
+ case 'icon':
132
+ return {
133
+ custom_avatar_type: 'icon',
134
+ custom_avatar_key: payload.key,
135
+ custom_avatar_color: payload.color,
136
+ }
137
+ case 'emoji':
138
+ return {
139
+ custom_avatar_type: 'emoji',
140
+ custom_avatar_key: payload.key,
141
+ custom_avatar_color: payload.color,
142
+ }
143
+ case 'image': {
144
+ const uploaded = await uploadClient.uploadFile({
145
+ uri: payload.imageAsset.uri,
146
+ name: payload.imageAsset.fileName || 'avatar.jpg',
147
+ type: payload.imageAsset.mimeType || 'image/jpeg',
148
+ })
149
+
150
+ return {
151
+ custom_avatar_type: 'image',
152
+ custom_avatar_color: payload.color,
153
+ avatar_uploaded_file_id: uploaded.id,
154
+ }
155
+ }
156
+ case 'clear':
157
+ return {
158
+ custom_avatar_type: null,
159
+ custom_avatar_key: null,
160
+ custom_avatar_color: null,
161
+ }
162
+ }
163
+ }
@@ -37,6 +37,7 @@ export const availableFeatures = {
37
37
  gender_specific_conversations: 'ROLLOUT_gender_specific_conversations',
38
38
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
39
39
  granular_notifications_ui: 'ROLLOUT_granular_notification_preferences_ui',
40
+ custom_conversation_avatars: 'ROLLOUT_custom_conversation_avatars',
40
41
  }
41
42
 
42
43
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -17,6 +17,11 @@ import {
17
17
  AttachmentActionsScreen,
18
18
  AttachmentActionsScreenOptions,
19
19
  } from '../screens/attachment_actions/attachment_actions_screen'
20
+ import {
21
+ AvatarPickerScreen,
22
+ AvatarPickerScreenOptions,
23
+ AvatarPickerCreateScreenOptions,
24
+ } from '../screens/avatar_picker/avatar_picker_screen'
20
25
  import { BugReportScreen, BugReportScreenOptions } from '../screens/bug_report_screen'
21
26
  import {
22
27
  MessageReadReceiptsScreen,
@@ -148,6 +153,10 @@ export const NewConversationStack = createNativeStackNavigator({
148
153
  ),
149
154
  }),
150
155
  },
156
+ AvatarPicker: {
157
+ screen: AvatarPickerScreen,
158
+ options: AvatarPickerCreateScreenOptions,
159
+ },
151
160
  },
152
161
  })
153
162
 
@@ -252,6 +261,10 @@ export const ChatStack = createNativeStackNavigator({
252
261
  ),
253
262
  }),
254
263
  },
264
+ AvatarPicker: {
265
+ screen: AvatarPickerScreen,
266
+ options: AvatarPickerScreenOptions,
267
+ },
255
268
  NotificationSettings: {
256
269
  screen: NotificationSettingsScreen,
257
270
  options: ({ navigation }) => ({
@@ -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
+ })