@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.
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +22 -1
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/display/emoji_avatar.d.ts.map +1 -1
- package/build/components/display/emoji_avatar.js +2 -0
- package/build/components/display/emoji_avatar.js.map +1 -1
- package/build/components/display/icon_avatar.d.ts.map +1 -1
- package/build/components/display/icon_avatar.js +2 -0
- package/build/components/display/icon_avatar.js.map +1 -1
- package/build/components/display/utils/avatar_gradient_colors.d.ts +3 -0
- package/build/components/display/utils/avatar_gradient_colors.d.ts.map +1 -1
- package/build/components/display/utils/avatar_gradient_colors.js +8 -3
- package/build/components/display/utils/avatar_gradient_colors.js.map +1 -1
- package/build/components/page/error_boundary.d.ts.map +1 -1
- package/build/components/page/error_boundary.js +13 -10
- package/build/components/page/error_boundary.js.map +1 -1
- package/build/components/primitive/avatar_primitive.d.ts +3 -1
- package/build/components/primitive/avatar_primitive.d.ts.map +1 -1
- package/build/components/primitive/avatar_primitive.js +10 -2
- package/build/components/primitive/avatar_primitive.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +2 -0
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/attachments/fallback_chat_configuration.d.ts +4 -0
- package/build/hooks/attachments/fallback_chat_configuration.d.ts.map +1 -0
- package/build/hooks/attachments/fallback_chat_configuration.js +59 -0
- package/build/hooks/attachments/fallback_chat_configuration.js.map +1 -0
- package/build/hooks/groups/use_groups_conversation_create.d.ts.map +1 -1
- package/build/hooks/groups/use_groups_conversation_create.js +1 -1
- package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
- package/build/hooks/services/use_find_or_create_services_conversation.d.ts +43 -11
- package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
- package/build/hooks/services/use_find_or_create_services_conversation.js +5 -5
- package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
- package/build/hooks/use_attachment_uploader.d.ts.map +1 -1
- package/build/hooks/use_attachment_uploader.js +39 -14
- package/build/hooks/use_attachment_uploader.js.map +1 -1
- package/build/hooks/use_chat_configuration.d.ts +6 -0
- package/build/hooks/use_chat_configuration.d.ts.map +1 -0
- package/build/hooks/use_chat_configuration.js +41 -0
- package/build/hooks/use_chat_configuration.js.map +1 -0
- package/build/hooks/use_conversation_avatar_update.d.ts +26 -0
- package/build/hooks/use_conversation_avatar_update.d.ts.map +1 -0
- package/build/hooks/use_conversation_avatar_update.js +130 -0
- package/build/hooks/use_conversation_avatar_update.js.map +1 -0
- package/build/hooks/use_features.d.ts +1 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +1 -0
- package/build/hooks/use_features.js.map +1 -1
- package/build/navigation/index.d.ts +16 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/navigation/index.js +9 -0
- package/build/navigation/index.js.map +1 -1
- package/build/screens/avatar_picker/avatar_picker_screen.d.ts +12 -0
- package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -0
- package/build/screens/avatar_picker/avatar_picker_screen.js +193 -0
- package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -0
- package/build/screens/avatar_picker/avatar_picker_state.d.ts +38 -0
- package/build/screens/avatar_picker/avatar_picker_state.d.ts.map +1 -0
- package/build/screens/avatar_picker/avatar_picker_state.js +101 -0
- package/build/screens/avatar_picker/avatar_picker_state.js.map +1 -0
- package/build/screens/avatar_picker/avatar_preview.d.ts +9 -0
- package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -0
- package/build/screens/avatar_picker/avatar_preview.js +39 -0
- package/build/screens/avatar_picker/avatar_preview.js.map +1 -0
- package/build/screens/avatar_picker/color_picker.d.ts +9 -0
- package/build/screens/avatar_picker/color_picker.d.ts.map +1 -0
- package/build/screens/avatar_picker/color_picker.js +53 -0
- package/build/screens/avatar_picker/color_picker.js.map +1 -0
- package/build/screens/avatar_picker/constants.d.ts +3 -0
- package/build/screens/avatar_picker/constants.d.ts.map +1 -0
- package/build/screens/avatar_picker/constants.js +53 -0
- package/build/screens/avatar_picker/constants.js.map +1 -0
- package/build/screens/avatar_picker/emoji_tab.d.ts +7 -0
- package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -0
- package/build/screens/avatar_picker/emoji_tab.js +55 -0
- package/build/screens/avatar_picker/emoji_tab.js.map +1 -0
- package/build/screens/avatar_picker/icon_grid.d.ts +8 -0
- package/build/screens/avatar_picker/icon_grid.d.ts.map +1 -0
- package/build/screens/avatar_picker/icon_grid.js +48 -0
- package/build/screens/avatar_picker/icon_grid.js.map +1 -0
- package/build/screens/avatar_picker/upload_tab.d.ts +9 -0
- package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -0
- package/build/screens/avatar_picker/upload_tab.js +39 -0
- package/build/screens/avatar_picker/upload_tab.js.map +1 -0
- package/build/screens/conversation_details_screen.d.ts.map +1 -1
- package/build/screens/conversation_details_screen.js +37 -1
- package/build/screens/conversation_details_screen.js.map +1 -1
- package/build/screens/conversation_new/components/avatar_selection_row.d.ts +12 -0
- package/build/screens/conversation_new/components/avatar_selection_row.d.ts.map +1 -0
- package/build/screens/conversation_new/components/avatar_selection_row.js +60 -0
- package/build/screens/conversation_new/components/avatar_selection_row.js.map +1 -0
- package/build/screens/conversation_new/components/gender_filter_toggle.d.ts.map +1 -1
- package/build/screens/conversation_new/components/gender_filter_toggle.js +3 -9
- package/build/screens/conversation_new/components/gender_filter_toggle.js.map +1 -1
- package/build/screens/conversation_new/components/groups_form.d.ts +3 -1
- package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
- package/build/screens/conversation_new/components/groups_form.js +22 -8
- package/build/screens/conversation_new/components/groups_form.js.map +1 -1
- package/build/screens/conversation_new/components/services_form.d.ts +3 -1
- package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
- package/build/screens/conversation_new/components/services_form.js +22 -8
- package/build/screens/conversation_new/components/services_form.js.map +1 -1
- package/build/screens/conversation_new/conversation_new_screen.d.ts +2 -0
- package/build/screens/conversation_new/conversation_new_screen.d.ts.map +1 -1
- package/build/screens/conversation_new/conversation_new_screen.js +3 -3
- package/build/screens/conversation_new/conversation_new_screen.js.map +1 -1
- package/build/screens/team_conversation_screen.d.ts.map +1 -1
- package/build/screens/team_conversation_screen.js +1 -1
- package/build/screens/team_conversation_screen.js.map +1 -1
- package/build/types/resources/chat_configuration_resource.d.ts +8 -0
- package/build/types/resources/chat_configuration_resource.d.ts.map +1 -0
- package/build/types/resources/chat_configuration_resource.js +2 -0
- package/build/types/resources/chat_configuration_resource.js.map +1 -0
- package/build/utils/auth_events.d.ts +7 -0
- package/build/utils/auth_events.d.ts.map +1 -0
- package/build/utils/auth_events.js +17 -0
- package/build/utils/auth_events.js.map +1 -0
- package/build/utils/native_adapters/configuration.d.ts +3 -0
- package/build/utils/native_adapters/configuration.d.ts.map +1 -1
- package/build/utils/native_adapters/configuration.js +8 -0
- package/build/utils/native_adapters/configuration.js.map +1 -1
- package/build/utils/native_adapters/document_picker.d.ts +21 -0
- package/build/utils/native_adapters/document_picker.d.ts.map +1 -0
- package/build/utils/native_adapters/document_picker.js +7 -0
- package/build/utils/native_adapters/document_picker.js.map +1 -0
- package/build/utils/native_adapters/image_picker.d.ts +7 -1
- package/build/utils/native_adapters/image_picker.d.ts.map +1 -1
- package/build/utils/native_adapters/image_picker.js.map +1 -1
- package/build/utils/native_adapters/index.d.ts +1 -0
- package/build/utils/native_adapters/index.d.ts.map +1 -1
- package/build/utils/native_adapters/index.js +1 -0
- package/build/utils/native_adapters/index.js.map +1 -1
- package/build/utils/request/get_chat_configuration.d.ts +10 -0
- package/build/utils/request/get_chat_configuration.d.ts.map +1 -0
- package/build/utils/request/get_chat_configuration.js +21 -0
- package/build/utils/request/get_chat_configuration.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +219 -0
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +80 -0
- package/src/__tests__/utils/native_adapters/configuration.ts +25 -1
- package/src/components/conversation/message_form.tsx +39 -1
- package/src/components/display/emoji_avatar.tsx +7 -2
- package/src/components/display/icon_avatar.tsx +7 -2
- package/src/components/display/utils/avatar_gradient_colors.ts +10 -3
- package/src/components/page/error_boundary.tsx +16 -9
- package/src/components/primitive/avatar_primitive.tsx +11 -2
- package/src/contexts/api_provider.tsx +3 -0
- package/src/hooks/attachments/fallback_chat_configuration.ts +61 -0
- package/src/hooks/groups/use_groups_conversation_create.ts +2 -1
- package/src/hooks/services/use_find_or_create_services_conversation.ts +7 -7
- package/src/hooks/use_attachment_uploader.ts +39 -15
- package/src/hooks/use_chat_configuration.ts +54 -0
- package/src/hooks/use_conversation_avatar_update.ts +163 -0
- package/src/hooks/use_features.ts +1 -0
- package/src/navigation/index.tsx +13 -0
- package/src/screens/avatar_picker/__tests__/avatar_picker_state.test.ts +157 -0
- package/src/screens/avatar_picker/avatar_picker_screen.tsx +312 -0
- package/src/screens/avatar_picker/avatar_picker_state.ts +141 -0
- package/src/screens/avatar_picker/avatar_preview.tsx +46 -0
- package/src/screens/avatar_picker/color_picker.tsx +91 -0
- package/src/screens/avatar_picker/constants.ts +53 -0
- package/src/screens/avatar_picker/emoji_tab.tsx +76 -0
- package/src/screens/avatar_picker/icon_grid.tsx +81 -0
- package/src/screens/avatar_picker/upload_tab.tsx +62 -0
- package/src/screens/conversation_details_screen.tsx +60 -1
- package/src/screens/conversation_new/components/avatar_selection_row.tsx +82 -0
- package/src/screens/conversation_new/components/gender_filter_toggle.tsx +3 -9
- package/src/screens/conversation_new/components/groups_form.tsx +33 -6
- package/src/screens/conversation_new/components/services_form.tsx +37 -6
- package/src/screens/conversation_new/conversation_new_screen.tsx +17 -3
- package/src/screens/team_conversation_screen.tsx +2 -1
- package/src/types/resources/chat_configuration_resource.ts +11 -0
- package/src/utils/auth_events.ts +21 -0
- package/src/utils/native_adapters/configuration.ts +10 -0
- package/src/utils/native_adapters/document_picker.ts +26 -0
- package/src/utils/native_adapters/image_picker.ts +8 -1
- package/src/utils/native_adapters/index.ts +1 -0
- package/src/utils/request/get_chat_configuration.ts +23 -0
- package/build/hooks/attachments/supported_extensions.d.ts +0 -2
- package/build/hooks/attachments/supported_extensions.d.ts.map +0 -1
- package/build/hooks/attachments/supported_extensions.js +0 -48
- package/build/hooks/attachments/supported_extensions.js.map +0 -1
- package/src/hooks/attachments/supported_extensions.ts +0 -47
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
2
|
-
import {
|
|
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:
|
|
50
|
-
onSuccess
|
|
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 =
|
|
43
|
-
const isValidFileSize = file.size <=
|
|
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 ${
|
|
62
|
+
errorMessages.push(`File size exceeds ${maxFileSizeInMb} MB`)
|
|
64
63
|
}
|
|
65
|
-
if (numberOfAttachments + validFiles.length >
|
|
66
|
-
errorMessages.push(`You can't attach more than ${
|
|
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
|
-
|
|
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
|
-
[
|
|
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:
|
|
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> = {
|
package/src/navigation/index.tsx
CHANGED
|
@@ -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
|
+
})
|