@planningcenter/chat-react-native 3.32.1-rc.0 → 3.33.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/build/components/conversation/message_form.d.ts.map +1 -1
  2. package/build/components/conversation/message_form.js +22 -1
  3. package/build/components/conversation/message_form.js.map +1 -1
  4. package/build/components/display/emoji_avatar.d.ts.map +1 -1
  5. package/build/components/display/emoji_avatar.js +2 -0
  6. package/build/components/display/emoji_avatar.js.map +1 -1
  7. package/build/components/display/icon_avatar.d.ts.map +1 -1
  8. package/build/components/display/icon_avatar.js +2 -0
  9. package/build/components/display/icon_avatar.js.map +1 -1
  10. package/build/components/display/utils/avatar_gradient_colors.d.ts +3 -0
  11. package/build/components/display/utils/avatar_gradient_colors.d.ts.map +1 -1
  12. package/build/components/display/utils/avatar_gradient_colors.js +8 -3
  13. package/build/components/display/utils/avatar_gradient_colors.js.map +1 -1
  14. package/build/components/page/error_boundary.d.ts.map +1 -1
  15. package/build/components/page/error_boundary.js +13 -10
  16. package/build/components/page/error_boundary.js.map +1 -1
  17. package/build/components/primitive/avatar_primitive.d.ts +3 -1
  18. package/build/components/primitive/avatar_primitive.d.ts.map +1 -1
  19. package/build/components/primitive/avatar_primitive.js +10 -2
  20. package/build/components/primitive/avatar_primitive.js.map +1 -1
  21. package/build/contexts/api_provider.d.ts.map +1 -1
  22. package/build/contexts/api_provider.js +2 -0
  23. package/build/contexts/api_provider.js.map +1 -1
  24. package/build/hooks/attachments/fallback_chat_configuration.d.ts +4 -0
  25. package/build/hooks/attachments/fallback_chat_configuration.d.ts.map +1 -0
  26. package/build/hooks/attachments/fallback_chat_configuration.js +59 -0
  27. package/build/hooks/attachments/fallback_chat_configuration.js.map +1 -0
  28. package/build/hooks/groups/use_groups_conversation_create.d.ts.map +1 -1
  29. package/build/hooks/groups/use_groups_conversation_create.js +1 -1
  30. package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
  31. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +43 -11
  32. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  33. package/build/hooks/services/use_find_or_create_services_conversation.js +5 -5
  34. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  35. package/build/hooks/use_attachment_uploader.d.ts.map +1 -1
  36. package/build/hooks/use_attachment_uploader.js +39 -14
  37. package/build/hooks/use_attachment_uploader.js.map +1 -1
  38. package/build/hooks/use_chat_configuration.d.ts +6 -0
  39. package/build/hooks/use_chat_configuration.d.ts.map +1 -0
  40. package/build/hooks/use_chat_configuration.js +41 -0
  41. package/build/hooks/use_chat_configuration.js.map +1 -0
  42. package/build/hooks/use_conversation_avatar_update.d.ts +26 -0
  43. package/build/hooks/use_conversation_avatar_update.d.ts.map +1 -0
  44. package/build/hooks/use_conversation_avatar_update.js +130 -0
  45. package/build/hooks/use_conversation_avatar_update.js.map +1 -0
  46. package/build/hooks/use_features.d.ts +1 -0
  47. package/build/hooks/use_features.d.ts.map +1 -1
  48. package/build/hooks/use_features.js +1 -0
  49. package/build/hooks/use_features.js.map +1 -1
  50. package/build/navigation/index.d.ts +16 -0
  51. package/build/navigation/index.d.ts.map +1 -1
  52. package/build/navigation/index.js +9 -0
  53. package/build/navigation/index.js.map +1 -1
  54. package/build/screens/avatar_picker/avatar_picker_screen.d.ts +12 -0
  55. package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -0
  56. package/build/screens/avatar_picker/avatar_picker_screen.js +193 -0
  57. package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -0
  58. package/build/screens/avatar_picker/avatar_picker_state.d.ts +38 -0
  59. package/build/screens/avatar_picker/avatar_picker_state.d.ts.map +1 -0
  60. package/build/screens/avatar_picker/avatar_picker_state.js +101 -0
  61. package/build/screens/avatar_picker/avatar_picker_state.js.map +1 -0
  62. package/build/screens/avatar_picker/avatar_preview.d.ts +9 -0
  63. package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -0
  64. package/build/screens/avatar_picker/avatar_preview.js +39 -0
  65. package/build/screens/avatar_picker/avatar_preview.js.map +1 -0
  66. package/build/screens/avatar_picker/color_picker.d.ts +9 -0
  67. package/build/screens/avatar_picker/color_picker.d.ts.map +1 -0
  68. package/build/screens/avatar_picker/color_picker.js +53 -0
  69. package/build/screens/avatar_picker/color_picker.js.map +1 -0
  70. package/build/screens/avatar_picker/constants.d.ts +3 -0
  71. package/build/screens/avatar_picker/constants.d.ts.map +1 -0
  72. package/build/screens/avatar_picker/constants.js +53 -0
  73. package/build/screens/avatar_picker/constants.js.map +1 -0
  74. package/build/screens/avatar_picker/emoji_tab.d.ts +7 -0
  75. package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -0
  76. package/build/screens/avatar_picker/emoji_tab.js +55 -0
  77. package/build/screens/avatar_picker/emoji_tab.js.map +1 -0
  78. package/build/screens/avatar_picker/icon_grid.d.ts +8 -0
  79. package/build/screens/avatar_picker/icon_grid.d.ts.map +1 -0
  80. package/build/screens/avatar_picker/icon_grid.js +48 -0
  81. package/build/screens/avatar_picker/icon_grid.js.map +1 -0
  82. package/build/screens/avatar_picker/upload_tab.d.ts +9 -0
  83. package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -0
  84. package/build/screens/avatar_picker/upload_tab.js +39 -0
  85. package/build/screens/avatar_picker/upload_tab.js.map +1 -0
  86. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  87. package/build/screens/conversation_details_screen.js +37 -1
  88. package/build/screens/conversation_details_screen.js.map +1 -1
  89. package/build/screens/conversation_new/components/avatar_selection_row.d.ts +12 -0
  90. package/build/screens/conversation_new/components/avatar_selection_row.d.ts.map +1 -0
  91. package/build/screens/conversation_new/components/avatar_selection_row.js +60 -0
  92. package/build/screens/conversation_new/components/avatar_selection_row.js.map +1 -0
  93. package/build/screens/conversation_new/components/gender_filter_toggle.d.ts.map +1 -1
  94. package/build/screens/conversation_new/components/gender_filter_toggle.js +3 -9
  95. package/build/screens/conversation_new/components/gender_filter_toggle.js.map +1 -1
  96. package/build/screens/conversation_new/components/groups_form.d.ts +3 -1
  97. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  98. package/build/screens/conversation_new/components/groups_form.js +22 -8
  99. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  100. package/build/screens/conversation_new/components/services_form.d.ts +3 -1
  101. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  102. package/build/screens/conversation_new/components/services_form.js +22 -8
  103. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  104. package/build/screens/conversation_new/conversation_new_screen.d.ts +2 -0
  105. package/build/screens/conversation_new/conversation_new_screen.d.ts.map +1 -1
  106. package/build/screens/conversation_new/conversation_new_screen.js +3 -3
  107. package/build/screens/conversation_new/conversation_new_screen.js.map +1 -1
  108. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  109. package/build/screens/team_conversation_screen.js +1 -1
  110. package/build/screens/team_conversation_screen.js.map +1 -1
  111. package/build/types/resources/chat_configuration_resource.d.ts +8 -0
  112. package/build/types/resources/chat_configuration_resource.d.ts.map +1 -0
  113. package/build/types/resources/chat_configuration_resource.js +2 -0
  114. package/build/types/resources/chat_configuration_resource.js.map +1 -0
  115. package/build/utils/auth_events.d.ts +7 -0
  116. package/build/utils/auth_events.d.ts.map +1 -0
  117. package/build/utils/auth_events.js +17 -0
  118. package/build/utils/auth_events.js.map +1 -0
  119. package/build/utils/native_adapters/configuration.d.ts +3 -0
  120. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  121. package/build/utils/native_adapters/configuration.js +8 -0
  122. package/build/utils/native_adapters/configuration.js.map +1 -1
  123. package/build/utils/native_adapters/document_picker.d.ts +21 -0
  124. package/build/utils/native_adapters/document_picker.d.ts.map +1 -0
  125. package/build/utils/native_adapters/document_picker.js +7 -0
  126. package/build/utils/native_adapters/document_picker.js.map +1 -0
  127. package/build/utils/native_adapters/image_picker.d.ts +7 -1
  128. package/build/utils/native_adapters/image_picker.d.ts.map +1 -1
  129. package/build/utils/native_adapters/image_picker.js.map +1 -1
  130. package/build/utils/native_adapters/index.d.ts +1 -0
  131. package/build/utils/native_adapters/index.d.ts.map +1 -1
  132. package/build/utils/native_adapters/index.js +1 -0
  133. package/build/utils/native_adapters/index.js.map +1 -1
  134. package/build/utils/request/get_chat_configuration.d.ts +10 -0
  135. package/build/utils/request/get_chat_configuration.d.ts.map +1 -0
  136. package/build/utils/request/get_chat_configuration.js +21 -0
  137. package/build/utils/request/get_chat_configuration.js.map +1 -0
  138. package/package.json +4 -3
  139. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +219 -0
  140. package/src/__tests__/hooks/use_chat_configuration.test.tsx +80 -0
  141. package/src/__tests__/utils/native_adapters/configuration.ts +25 -1
  142. package/src/components/conversation/message_form.tsx +39 -1
  143. package/src/components/display/emoji_avatar.tsx +7 -2
  144. package/src/components/display/icon_avatar.tsx +7 -2
  145. package/src/components/display/utils/avatar_gradient_colors.ts +10 -3
  146. package/src/components/page/error_boundary.tsx +16 -9
  147. package/src/components/primitive/avatar_primitive.tsx +11 -2
  148. package/src/contexts/api_provider.tsx +3 -0
  149. package/src/hooks/attachments/fallback_chat_configuration.ts +61 -0
  150. package/src/hooks/groups/use_groups_conversation_create.ts +2 -1
  151. package/src/hooks/services/use_find_or_create_services_conversation.ts +7 -7
  152. package/src/hooks/use_attachment_uploader.ts +39 -15
  153. package/src/hooks/use_chat_configuration.ts +54 -0
  154. package/src/hooks/use_conversation_avatar_update.ts +163 -0
  155. package/src/hooks/use_features.ts +1 -0
  156. package/src/navigation/index.tsx +13 -0
  157. package/src/screens/avatar_picker/__tests__/avatar_picker_state.test.ts +157 -0
  158. package/src/screens/avatar_picker/avatar_picker_screen.tsx +312 -0
  159. package/src/screens/avatar_picker/avatar_picker_state.ts +141 -0
  160. package/src/screens/avatar_picker/avatar_preview.tsx +46 -0
  161. package/src/screens/avatar_picker/color_picker.tsx +91 -0
  162. package/src/screens/avatar_picker/constants.ts +53 -0
  163. package/src/screens/avatar_picker/emoji_tab.tsx +76 -0
  164. package/src/screens/avatar_picker/icon_grid.tsx +81 -0
  165. package/src/screens/avatar_picker/upload_tab.tsx +62 -0
  166. package/src/screens/conversation_details_screen.tsx +60 -1
  167. package/src/screens/conversation_new/components/avatar_selection_row.tsx +82 -0
  168. package/src/screens/conversation_new/components/gender_filter_toggle.tsx +3 -9
  169. package/src/screens/conversation_new/components/groups_form.tsx +33 -6
  170. package/src/screens/conversation_new/components/services_form.tsx +37 -6
  171. package/src/screens/conversation_new/conversation_new_screen.tsx +17 -3
  172. package/src/screens/team_conversation_screen.tsx +2 -1
  173. package/src/types/resources/chat_configuration_resource.ts +11 -0
  174. package/src/utils/auth_events.ts +21 -0
  175. package/src/utils/native_adapters/configuration.ts +10 -0
  176. package/src/utils/native_adapters/document_picker.ts +26 -0
  177. package/src/utils/native_adapters/image_picker.ts +8 -1
  178. package/src/utils/native_adapters/index.ts +1 -0
  179. package/src/utils/request/get_chat_configuration.ts +23 -0
  180. package/build/hooks/attachments/supported_extensions.d.ts +0 -2
  181. package/build/hooks/attachments/supported_extensions.d.ts.map +0 -1
  182. package/build/hooks/attachments/supported_extensions.js +0 -48
  183. package/build/hooks/attachments/supported_extensions.js.map +0 -1
  184. package/src/hooks/attachments/supported_extensions.ts +0 -47
@@ -0,0 +1,219 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook, act } from '@testing-library/react-hooks'
3
+ import React from 'react'
4
+ import { useApiClient } from '../../hooks/use_api_client'
5
+ import { useAttachmentUploader } from '../../hooks/use_attachment_uploader'
6
+ import { useChatConfiguration } from '../../hooks/use_chat_configuration'
7
+ import { useUploadClient } from '../../hooks/use_upload_client'
8
+ import { FileAttachment } from '../../types/resources/denormalized_attachment_resource_for_create'
9
+
10
+ jest.mock('../../hooks/use_api_client')
11
+ jest.mock('../../hooks/use_upload_client')
12
+ jest.mock('../../hooks/use_chat_configuration')
13
+
14
+ const mockedUseApiClient = useApiClient as jest.MockedFunction<typeof useApiClient>
15
+ const mockedUseUploadClient = useUploadClient as jest.MockedFunction<typeof useUploadClient>
16
+ const mockedUseChatConfiguration = useChatConfiguration as jest.MockedFunction<
17
+ typeof useChatConfiguration
18
+ >
19
+
20
+ const createWrapper = () => {
21
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22
+ return ({ children }: { children: React.ReactNode }) => (
23
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
24
+ )
25
+ }
26
+
27
+ const setChatConfiguration = (overrides: Partial<ReturnType<typeof useChatConfiguration>> = {}) => {
28
+ mockedUseChatConfiguration.mockReturnValue({
29
+ allowedFileExtensions: ['.pdf', '.jpg'],
30
+ maxFileSizeInBytes: 50 * 1024 * 1024,
31
+ maxAttachmentsPerMessage: 10,
32
+ ...overrides,
33
+ })
34
+ }
35
+
36
+ const renderUploader = (opts?: { draftAttachments?: FileAttachment[] }) =>
37
+ renderHook(() => useAttachmentUploader({ conversationId: 1, ...opts }), {
38
+ wrapper: createWrapper(),
39
+ })
40
+
41
+ const makeFile = (
42
+ overrides: Partial<
43
+ Parameters<ReturnType<typeof useAttachmentUploader>['handleFilesAttached']>[0][number]
44
+ > = {}
45
+ ) => ({
46
+ uri: 'file:///tmp/example.pdf',
47
+ name: 'example.pdf',
48
+ type: 'application/pdf',
49
+ size: 1024,
50
+ ...overrides,
51
+ })
52
+
53
+ const draftAttachment = (name: string): FileAttachment => ({
54
+ file: { uri: `file:///tmp/${name}`, name, type: 'application/pdf', size: 1024 },
55
+ status: 'success',
56
+ uploadedAt: 0,
57
+ id: `att-${name}`,
58
+ })
59
+
60
+ beforeEach(() => {
61
+ setChatConfiguration()
62
+ mockedUseApiClient.mockReturnValue({
63
+ chat: { post: jest.fn().mockResolvedValue({ data: { id: 'msg-attachment-1' } }) },
64
+ } as any)
65
+ mockedUseUploadClient.mockReturnValue({
66
+ uploadFile: jest.fn().mockResolvedValue({ id: 'uploaded-1' }),
67
+ } as any)
68
+ })
69
+
70
+ afterEach(() => {
71
+ jest.clearAllMocks()
72
+ })
73
+
74
+ describe('useAttachmentUploader', () => {
75
+ describe('extension validation', () => {
76
+ it('rejects a disallowed extension and lists it in the error', () => {
77
+ const { result } = renderUploader()
78
+
79
+ act(() => {
80
+ result.current.handleFilesAttached([makeFile({ name: 'evil.exe' })])
81
+ })
82
+
83
+ expect(result.current.errorMessage).toContain(
84
+ 'The following file types are not supported: exe'
85
+ )
86
+ expect(result.current.attachments).toHaveLength(0)
87
+ })
88
+
89
+ it('accepts a matching extension regardless of casing', async () => {
90
+ const { result } = renderUploader()
91
+
92
+ await act(async () => {
93
+ result.current.handleFilesAttached([makeFile({ name: 'Shouting.PDF' })])
94
+ })
95
+
96
+ expect(result.current.errorMessage).toBeNull()
97
+ expect(result.current.attachments).toHaveLength(1)
98
+ })
99
+ })
100
+
101
+ describe('file size validation', () => {
102
+ it('rejects files that exceed maxFileSizeInBytes', () => {
103
+ setChatConfiguration({ maxFileSizeInBytes: 50 * 1024 * 1024 })
104
+ const { result } = renderUploader()
105
+
106
+ act(() => {
107
+ result.current.handleFilesAttached([makeFile({ size: 50 * 1024 * 1024 + 1 })])
108
+ })
109
+
110
+ expect(result.current.errorMessage).toBe('File size exceeds 50 MB')
111
+ expect(result.current.attachments).toHaveLength(0)
112
+ })
113
+
114
+ it('formats an integer MB limit without a trailing decimal', () => {
115
+ setChatConfiguration({ maxFileSizeInBytes: 25 * 1024 * 1024 })
116
+ const { result } = renderUploader()
117
+
118
+ act(() => {
119
+ result.current.handleFilesAttached([makeFile({ size: 25 * 1024 * 1024 + 1 })])
120
+ })
121
+
122
+ expect(result.current.errorMessage).toBe('File size exceeds 25 MB')
123
+ })
124
+
125
+ it('formats a fractional MB limit to one decimal', () => {
126
+ // 50.5 MB — server could in principle return a non-multiple-of-MB cap.
127
+ setChatConfiguration({ maxFileSizeInBytes: Math.round(50.5 * 1024 * 1024) })
128
+ const { result } = renderUploader()
129
+
130
+ act(() => {
131
+ result.current.handleFilesAttached([makeFile({ size: 51 * 1024 * 1024 })])
132
+ })
133
+
134
+ expect(result.current.errorMessage).toBe('File size exceeds 50.5 MB')
135
+ })
136
+ })
137
+
138
+ describe('attachment count validation', () => {
139
+ it('rejects a batch that would push past maxAttachmentsPerMessage', () => {
140
+ setChatConfiguration({ maxAttachmentsPerMessage: 2 })
141
+ const { result } = renderUploader()
142
+
143
+ act(() => {
144
+ result.current.handleFilesAttached([
145
+ makeFile({ uri: 'file:///a.pdf', name: 'a.pdf' }),
146
+ makeFile({ uri: 'file:///b.pdf', name: 'b.pdf' }),
147
+ makeFile({ uri: 'file:///c.pdf', name: 'c.pdf' }),
148
+ ])
149
+ })
150
+
151
+ expect(result.current.errorMessage).toBe("You can't attach more than 2 files at once.")
152
+ expect(result.current.attachments).toHaveLength(0)
153
+ })
154
+ })
155
+
156
+ describe('remainingAttachable', () => {
157
+ it('reports how many more files can be attached', () => {
158
+ setChatConfiguration({ maxAttachmentsPerMessage: 5 })
159
+ const { result } = renderUploader({
160
+ draftAttachments: [draftAttachment('a.pdf'), draftAttachment('b.pdf')],
161
+ })
162
+
163
+ expect(result.current.remainingAttachable).toBe(3)
164
+ })
165
+
166
+ it('clamps to 0 when the draft already exceeds the server limit', () => {
167
+ // Simulates a saved draft whose attachments outnumber a since-lowered
168
+ // server cap — the value must never go negative.
169
+ setChatConfiguration({ maxAttachmentsPerMessage: 1 })
170
+ const { result } = renderUploader({
171
+ draftAttachments: [draftAttachment('a.pdf'), draftAttachment('b.pdf')],
172
+ })
173
+
174
+ expect(result.current.remainingAttachable).toBe(0)
175
+ })
176
+ })
177
+
178
+ describe('upload errors', () => {
179
+ const rejectingResponse = (body: unknown, status = 500) => {
180
+ const response = { status, clone: () => response, json: async () => body }
181
+ return response as unknown as Response
182
+ }
183
+
184
+ it('shows a generic error message when the upload fails', async () => {
185
+ mockedUseApiClient.mockReturnValue({
186
+ chat: { post: jest.fn().mockRejectedValue(rejectingResponse({})) },
187
+ } as any)
188
+
189
+ const { result } = renderUploader()
190
+
191
+ await act(async () => {
192
+ result.current.handleFilesAttached([makeFile()])
193
+ })
194
+ await act(async () => {
195
+ await Promise.resolve()
196
+ })
197
+
198
+ expect(result.current.errorMessage).toBe('This file could not be uploaded.')
199
+ })
200
+
201
+ it('does not overwrite the error message when the upload is flagged', async () => {
202
+ mockedUseUploadClient.mockReturnValue({
203
+ uploadFile: jest.fn().mockRejectedValue({ code: 'image_flagged' }),
204
+ } as any)
205
+
206
+ const { result } = renderUploader()
207
+
208
+ await act(async () => {
209
+ result.current.handleFilesAttached([makeFile()])
210
+ })
211
+ await act(async () => {
212
+ await Promise.resolve()
213
+ })
214
+
215
+ expect(result.current.errorMessage).toBeNull()
216
+ expect(result.current.flaggedAttachmentCount).toBe(1)
217
+ })
218
+ })
219
+ })
@@ -0,0 +1,80 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook, act } from '@testing-library/react-hooks'
3
+ import React, { Suspense } from 'react'
4
+ import {
5
+ FALLBACK_ALLOWED_FILE_EXTENSIONS,
6
+ FALLBACK_MAX_ATTACHMENTS_PER_MESSAGE,
7
+ FALLBACK_MAX_FILE_SIZE_IN_BYTES,
8
+ } from '../../hooks/attachments/fallback_chat_configuration'
9
+ import * as useApiClientModule from '../../hooks/use_api_client'
10
+ import { useChatConfiguration } from '../../hooks/use_chat_configuration'
11
+
12
+ const createWrapper = () => {
13
+ const queryClient = new QueryClient({
14
+ defaultOptions: { queries: { retry: false } },
15
+ })
16
+
17
+ return ({ children }: { children: React.ReactNode }) => (
18
+ <QueryClientProvider client={queryClient}>
19
+ <Suspense fallback={null}>{children}</Suspense>
20
+ </QueryClientProvider>
21
+ )
22
+ }
23
+
24
+ const waitForQuery = async () => {
25
+ await act(async () => {
26
+ await Promise.resolve()
27
+ await Promise.resolve()
28
+ await Promise.resolve()
29
+ })
30
+ }
31
+
32
+ const mockApiClient = (getImpl: () => Promise<unknown>) => {
33
+ jest.spyOn(useApiClientModule, 'useApiClient').mockReturnValue({
34
+ chat: { get: jest.fn(getImpl) },
35
+ } as any)
36
+ }
37
+
38
+ describe('useChatConfiguration', () => {
39
+ afterEach(() => {
40
+ jest.restoreAllMocks()
41
+ })
42
+
43
+ it('returns server-provided values when the API succeeds', async () => {
44
+ mockApiClient(() =>
45
+ Promise.resolve({
46
+ data: {
47
+ type: 'ChatConfiguration',
48
+ id: 'current',
49
+ allowedFileExtensions: ['.pdf', '.jpg'],
50
+ maxFileSizeInBytes: 1000,
51
+ maxAttachmentsPerMessage: 3,
52
+ },
53
+ links: {},
54
+ meta: {},
55
+ })
56
+ )
57
+
58
+ const { result } = renderHook(() => useChatConfiguration(), { wrapper: createWrapper() })
59
+ await waitForQuery()
60
+
61
+ expect(result.current).toEqual({
62
+ allowedFileExtensions: ['.pdf', '.jpg'],
63
+ maxFileSizeInBytes: 1000,
64
+ maxAttachmentsPerMessage: 3,
65
+ })
66
+ })
67
+
68
+ it('returns fallback values when the API rejects', async () => {
69
+ mockApiClient(() => Promise.reject(new Error('boom')))
70
+
71
+ const { result } = renderHook(() => useChatConfiguration(), { wrapper: createWrapper() })
72
+ await waitForQuery()
73
+
74
+ expect(result.current).toEqual({
75
+ allowedFileExtensions: FALLBACK_ALLOWED_FILE_EXTENSIONS,
76
+ maxFileSizeInBytes: FALLBACK_MAX_FILE_SIZE_IN_BYTES,
77
+ maxAttachmentsPerMessage: FALLBACK_MAX_ATTACHMENTS_PER_MESSAGE,
78
+ })
79
+ })
80
+ })
@@ -4,11 +4,17 @@ import {
4
4
  AudioAdapter,
5
5
  Clipboard,
6
6
  ClipboardAdapter,
7
+ DocumentPickerAdapter,
7
8
  ImagePickerAdapter,
8
9
  LinkingAdapter,
9
10
  VideoAdapter,
10
11
  } from '../../../utils/native_adapters'
11
- import { ChatAdapters, Linking, Haptic } from '../../../utils/native_adapters/configuration'
12
+ import {
13
+ ChatAdapters,
14
+ DocumentPicker,
15
+ Linking,
16
+ Haptic,
17
+ } from '../../../utils/native_adapters/configuration'
12
18
  import { HapticAdapter } from '../../../utils/native_adapters/haptic'
13
19
  import { VideoPlayerHandle, VideoPlayerProps } from '../../../utils/native_adapters/video'
14
20
 
@@ -104,6 +110,24 @@ describe('ChatAdapters', () => {
104
110
  })
105
111
  })
106
112
 
113
+ describe('document picker adapter', () => {
114
+ it('uses the configured adapter when provided', () => {
115
+ const documentPicker = new DocumentPickerAdapter({
116
+ openAsync: jest.fn(async () => ({ canceled: true, assets: null })),
117
+ })
118
+
119
+ ChatAdapters.configure({
120
+ clipboard,
121
+ audio,
122
+ video,
123
+ imagePicker,
124
+ documentPicker,
125
+ })
126
+
127
+ expect(DocumentPicker).toEqual(documentPicker)
128
+ })
129
+ })
130
+
107
131
  describe('haptic adapter', () => {
108
132
  it('should configure the haptic adapter', () => {
109
133
  ChatAdapters.configure({
@@ -36,7 +36,13 @@ import {
36
36
  platformFontWeightMedium,
37
37
  platformPressedOpacityStyle,
38
38
  } from '../../utils'
39
- import { Haptic, ImagePicker, ImagePickerResult } from '../../utils/native_adapters'
39
+ import {
40
+ DocumentPicker,
41
+ DocumentPickerResult,
42
+ Haptic,
43
+ ImagePicker,
44
+ ImagePickerResult,
45
+ } from '../../utils/native_adapters'
40
46
  import { tokens } from '../../vendor/tapestry/tokens'
41
47
  import { Button } from '../display/button'
42
48
  import BannerPrimitive from '../primitive/banner_primitive'
@@ -522,6 +528,21 @@ function MessageFormAttachmentPicker() {
522
528
  attachmentUploader?.handleFilesAttached(filteredAssets)
523
529
  }
524
530
 
531
+ function uploadDocumentPickerResult(result: DocumentPickerResult) {
532
+ if (result.canceled) return
533
+
534
+ const filteredAssets = result.assets
535
+ .filter(asset => asset.size != null && asset.uri)
536
+ .map(asset => ({
537
+ uri: asset.uri,
538
+ name: asset.name,
539
+ type: asset.mimeType || 'application/octet-stream',
540
+ size: asset.size as number,
541
+ }))
542
+
543
+ attachmentUploader?.handleFilesAttached(filteredAssets)
544
+ }
545
+
525
546
  const openCamera = async () => {
526
547
  setIsOpen(false)
527
548
  let result = await ImagePicker.openCameraAsync()
@@ -538,6 +559,14 @@ function MessageFormAttachmentPicker() {
538
559
  }
539
560
  }
540
561
 
562
+ const pickFile = async () => {
563
+ setIsOpen(false)
564
+ let result = await DocumentPicker.openAsync()
565
+ if (!result.canceled) {
566
+ uploadDocumentPickerResult(result)
567
+ }
568
+ }
569
+
541
570
  if (usingGiphy || currentlyEditingMessage) {
542
571
  return null
543
572
  }
@@ -564,6 +593,15 @@ function MessageFormAttachmentPicker() {
564
593
  onPress={pickImage}
565
594
  style={styles.attachmentPickerButton}
566
595
  />
596
+ <IconButton
597
+ accessibilityLabel="Attach a file"
598
+ accessibilityHint="Opens your files to attach documents"
599
+ size="lg"
600
+ appearance="neutral"
601
+ name="general.blankFile"
602
+ onPress={pickFile}
603
+ style={styles.attachmentPickerButton}
604
+ />
567
605
  </View>
568
606
  )}
569
607
  <IconButton
@@ -1,14 +1,19 @@
1
1
  import React from 'react'
2
2
  import { StyleSheet, Text, View } from 'react-native'
3
3
  import LinearGradient from 'react-native-linear-gradient'
4
- import AvatarPrimitive, { type AvatarRootProps } from '../primitive/avatar_primitive'
4
+ import AvatarPrimitive, {
5
+ type AvatarRootProps,
6
+ type AvatarSize,
7
+ } from '../primitive/avatar_primitive'
5
8
  import { getAvatarGradientProps } from './utils/avatar_gradient_colors'
6
9
 
7
- const EMOJI_SIZE: Record<string, number> = {
10
+ const EMOJI_SIZE: Record<AvatarSize, number> = {
8
11
  xs: 8,
9
12
  sm: 10,
10
13
  md: 14,
11
14
  lg: 20,
15
+ xl: 28,
16
+ '2xl': 40,
12
17
  }
13
18
 
14
19
  interface EmojiAvatarProps {
@@ -7,14 +7,19 @@ import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'
7
7
  import React from 'react'
8
8
  import { StyleSheet, Text, View } from 'react-native'
9
9
  import LinearGradient from 'react-native-linear-gradient'
10
- import AvatarPrimitive, { type AvatarRootProps } from '../primitive/avatar_primitive'
10
+ import AvatarPrimitive, {
11
+ type AvatarRootProps,
12
+ type AvatarSize,
13
+ } from '../primitive/avatar_primitive'
11
14
  import { getAvatarGradientProps } from './utils/avatar_gradient_colors'
12
15
 
13
- const ICON_SIZE: Record<string, number> = {
16
+ const ICON_SIZE: Record<AvatarSize, number> = {
14
17
  xs: 10,
15
18
  sm: 12,
16
19
  md: 16,
17
20
  lg: 20,
21
+ xl: 28,
22
+ '2xl': 40,
18
23
  }
19
24
 
20
25
  interface IconAvatarProps {
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import { useQueryErrorResetBoundary } from '@tanstack/react-query'
3
- import React, { PropsWithChildren, useEffect, useMemo } from 'react'
3
+ import React, { PropsWithChildren, useCallback, useEffect, useMemo } from 'react'
4
+ import { onAuthRefresh } from '../../utils/auth_events'
4
5
  import { ResponseError } from '../../utils/response_error'
5
6
  import BlankState from '../primitive/blank_state_primitive'
6
7
 
@@ -39,17 +40,16 @@ class ErrorBoundary extends React.Component<PropsWithChildren<{ onReset?: () =>
39
40
 
40
41
  function ErrorView({ error, onReset }: { error: Error | ResponseError; onReset: () => void }) {
41
42
  const { reset } = useQueryErrorResetBoundary()
42
- useEffect(() => {
43
- if (!reset) return
44
43
 
45
- return () => {
46
- onReset()
47
- reset()
48
- }
44
+ const handleReset = useCallback(() => {
45
+ reset()
46
+ onReset()
49
47
  }, [reset, onReset])
50
48
 
49
+ useEffect(() => handleReset, [handleReset])
50
+
51
51
  if (error instanceof ResponseError) {
52
- return <ResponseErrorView response={error.response} onReset={onReset} />
52
+ return <ResponseErrorView response={error.response} onReset={handleReset} />
53
53
  }
54
54
 
55
55
  if (isNetworkError(error)) {
@@ -77,8 +77,9 @@ function isNetworkError(error: ResponseError | Error | TypeError | null) {
77
77
  return new RegExp(networkFailedMessages.join('|'), 'i').test(error.message)
78
78
  }
79
79
 
80
- function ResponseErrorView({ response }: { response: Response; onReset: () => void }) {
80
+ function ResponseErrorView({ response, onReset }: { response: Response; onReset: () => void }) {
81
81
  const { status } = response
82
+
82
83
  const heading = useMemo(() => {
83
84
  switch (status) {
84
85
  case 403:
@@ -101,6 +102,12 @@ function ResponseErrorView({ response }: { response: Response; onReset: () => vo
101
102
  }
102
103
  }, [status])
103
104
 
105
+ useEffect(() => {
106
+ if (status !== 401) return
107
+
108
+ return onAuthRefresh(onReset)
109
+ }, [status, onReset])
110
+
104
111
  return <ErrorContent heading={heading} body={body} />
105
112
  }
106
113
 
@@ -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}>
@@ -11,6 +11,7 @@ import { ApiClient, useApiClient } from '../hooks/use_api_client'
11
11
  import { useAppState } from '../hooks/use_app_state'
12
12
  import { appGrantsRequestArgs } from '../hooks/use_chat_permissions'
13
13
  import { getRequestQueryKey, RequestQueryKey } from '../hooks/use_suspense_api'
14
+ import { emitAuthRefresh } from '../utils/auth_events'
14
15
  import { ChatContext, ChatContextValue } from './chat_context'
15
16
 
16
17
  let apiClient: ApiClient | undefined
@@ -43,6 +44,8 @@ export function ApiProvider({ children }: ViewProps) {
43
44
  useEffect(() => {
44
45
  if (!sessionChanged) return
45
46
 
47
+ emitAuthRefresh()
48
+
46
49
  if (chatQueryClient.isFetching()) {
47
50
  chatQueryClient.invalidateQueries()
48
51
  return
@@ -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