@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
@@ -60,7 +60,7 @@ const ANGLE_160: Pick<AvatarGradientProps, 'start' | 'end'> = {
60
60
  end: { x: 0.35, y: 1 },
61
61
  }
62
62
 
63
- const AVATAR_GRADIENT_MAP: Record<CustomAvatarColorKey, AvatarGradientProps> = {
63
+ export const AVATAR_GRADIENT_MAP: Record<CustomAvatarColorKey, AvatarGradientProps> = {
64
64
  'warm-sunset': { colors: ['#F6D06F', '#FB7946', '#B13825'], ...ANGLE_135 },
65
65
  peach: { colors: ['#FCD983', '#FC9369'], ...ANGLE_90 },
66
66
  'rose-gold': { colors: ['#ED78BE', '#E19084', '#EDB32C'], ...ANGLE_135 },
@@ -81,7 +81,14 @@ const AVATAR_GRADIENT_MAP: Record<CustomAvatarColorKey, AvatarGradientProps> = {
81
81
  rainbow: { colors: ['#E76958', '#CCA32C', '#3FA05A', '#8B52D0'], ...ANGLE_135 },
82
82
  }
83
83
 
84
+ export const COLOR_KEYS = Object.keys(AVATAR_GRADIENT_MAP) as CustomAvatarColorKey[]
85
+
86
+ export function coerceColorKey(value: string | null | undefined): CustomAvatarColorKey {
87
+ return value && value in AVATAR_GRADIENT_MAP
88
+ ? (value as CustomAvatarColorKey)
89
+ : DEFAULT_AVATAR_COLOR_KEY
90
+ }
91
+
84
92
  export function getAvatarGradientProps(colorKey?: string | null): AvatarGradientProps {
85
- const key = (colorKey as CustomAvatarColorKey) || DEFAULT_AVATAR_COLOR_KEY
86
- return AVATAR_GRADIENT_MAP[key] || AVATAR_GRADIENT_MAP[DEFAULT_AVATAR_COLOR_KEY]
93
+ return AVATAR_GRADIENT_MAP[coerceColorKey(colorKey)]
87
94
  }
@@ -38,6 +38,7 @@ export type {
38
38
  AvatarGroupProps,
39
39
  AvatarMaskProps,
40
40
  AvatarRootProps,
41
+ AvatarSize,
41
42
  }
42
43
 
43
44
  // =================================
@@ -49,6 +50,8 @@ const AVATAR_SIZES = {
49
50
  sm: 'sm',
50
51
  md: 'md',
51
52
  lg: 'lg',
53
+ xl: 'xl',
54
+ '2xl': '2xl',
52
55
  } as const
53
56
 
54
57
  const AVATAR_PRESENCE_TYPES = {
@@ -65,6 +68,8 @@ const AVATAR_PX: Record<AvatarSize, number> = {
65
68
  [AVATAR_SIZES.sm]: 24,
66
69
  [AVATAR_SIZES.md]: 32,
67
70
  [AVATAR_SIZES.lg]: 40,
71
+ [AVATAR_SIZES.xl]: 56,
72
+ [AVATAR_SIZES['2xl']]: 80,
68
73
  }
69
74
 
70
75
  const AVATAR_PRESENCE_PX: Record<AvatarSize, number> = {
@@ -72,6 +77,8 @@ const AVATAR_PRESENCE_PX: Record<AvatarSize, number> = {
72
77
  [AVATAR_SIZES.sm]: 10,
73
78
  [AVATAR_SIZES.md]: 12,
74
79
  [AVATAR_SIZES.lg]: 14,
80
+ [AVATAR_SIZES.xl]: 16,
81
+ [AVATAR_SIZES['2xl']]: 20,
75
82
  }
76
83
 
77
84
  const AVATAR_FALLBACK_ICON_PX: Record<AvatarSize, number> = {
@@ -79,6 +86,8 @@ const AVATAR_FALLBACK_ICON_PX: Record<AvatarSize, number> = {
79
86
  [AVATAR_SIZES.sm]: 12,
80
87
  [AVATAR_SIZES.md]: 16,
81
88
  [AVATAR_SIZES.lg]: 20,
89
+ [AVATAR_SIZES.xl]: 28,
90
+ [AVATAR_SIZES['2xl']]: 40,
82
91
  }
83
92
 
84
93
  // =================================
@@ -149,8 +158,8 @@ AvatarRoot.displayName = 'Avatar.Root'
149
158
  type AvatarMaskProps = ViewProps
150
159
 
151
160
  function AvatarMask({ children, ...props }: AvatarMaskProps) {
152
- const { maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext()
153
- const styles = useStyles({ maxFontSizeMultiplier, minFontSizeMultiplier })
161
+ const { size, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext()
162
+ const styles = useStyles({ size, maxFontSizeMultiplier, minFontSizeMultiplier })
154
163
 
155
164
  return (
156
165
  <View style={styles.mask} {...props}>
@@ -0,0 +1,61 @@
1
+ // Fallback values for use_chat_configuration used when the API request fails.
2
+ // Keeping a static copy here means an API outage doesn't block attachment
3
+ // uploads entirely — users retain the behavior they had before the server
4
+ // was the source of truth.
5
+ //
6
+ // The server is authoritative: if these values drift from the server's
7
+ // ChatConfiguration, the server will still reject uploads that exceed its
8
+ // own rules. These exist only to give the client something reasonable to
9
+ // validate against up-front.
10
+
11
+ export const FALLBACK_ALLOWED_FILE_EXTENSIONS = [
12
+ '.3ga',
13
+ '.3gp',
14
+ '.aac',
15
+ '.amr',
16
+ '.avi',
17
+ '.bmp',
18
+ '.doc',
19
+ '.docx',
20
+ '.gif',
21
+ '.h263',
22
+ '.h264',
23
+ '.heic',
24
+ '.heif',
25
+ '.jpeg',
26
+ '.jpg',
27
+ '.key',
28
+ '.m4a',
29
+ '.m4b',
30
+ '.m4p',
31
+ '.m4r',
32
+ '.m4v',
33
+ '.mkv',
34
+ '.mov',
35
+ '.mp3',
36
+ '.mp4',
37
+ '.mp4-latm',
38
+ '.mpeg',
39
+ '.mpeg4',
40
+ '.mpg',
41
+ '.numbers',
42
+ '.ogg',
43
+ '.pages',
44
+ '.pdf',
45
+ '.png',
46
+ '.ppt',
47
+ '.pptx',
48
+ '.rtf',
49
+ '.txt',
50
+ '.vcf',
51
+ '.wav',
52
+ '.webm',
53
+ '.webp',
54
+ '.wmv',
55
+ '.xls',
56
+ '.xlsx',
57
+ ]
58
+
59
+ export const FALLBACK_MAX_FILE_SIZE_IN_BYTES = 50 * 1024 * 1024
60
+
61
+ export const FALLBACK_MAX_ATTACHMENTS_PER_MESSAGE = 10
@@ -12,10 +12,11 @@ interface Props {
12
12
 
13
13
  export function useGroupsConversationCreate({ groupId, title, genderId, onSuccess }: Props) {
14
14
  const apiClient = useApiClient()
15
+
15
16
  return useMutation({
16
17
  throwOnError: true,
17
18
  onSuccess: result => {
18
- onSuccess && onSuccess(result.data.id)
19
+ onSuccess(result.data.id)
19
20
  },
20
21
  mutationFn: () =>
21
22
  apiClient.groups
@@ -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 }) => ({