@planningcenter/chat-react-native 3.21.2-rc.4 → 3.22.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/attachments/attachment_card.js +1 -0
- package/build/components/conversation/attachments/attachment_card.js.map +1 -1
- package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts +3 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts.map +1 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.js +29 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.js.map +1 -0
- package/build/components/conversation/attachments/audio_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/audio_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/audio_attachment.js +5 -3
- package/build/components/conversation/attachments/audio_attachment.js.map +1 -1
- package/build/components/conversation/attachments/generic_file_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/generic_file_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/generic_file_attachment.js +4 -2
- package/build/components/conversation/attachments/generic_file_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +9 -4
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/attachments/video_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/video_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/video_attachment.js +5 -3
- package/build/components/conversation/attachments/video_attachment.js.map +1 -1
- package/build/components/conversation/message_attachments.d.ts.map +1 -1
- package/build/components/conversation/message_attachments.js +8 -6
- package/build/components/conversation/message_attachments.js.map +1 -1
- package/build/contexts/session_context.d.ts +40 -0
- package/build/contexts/session_context.d.ts.map +1 -0
- package/build/contexts/session_context.js +131 -0
- package/build/contexts/session_context.js.map +1 -0
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/use_deleting_ids.d.ts +4 -0
- package/build/hooks/use_deleting_ids.d.ts.map +1 -0
- package/build/hooks/use_deleting_ids.js +19 -0
- package/build/hooks/use_deleting_ids.js.map +1 -0
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +9 -2
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.d.ts.map +1 -1
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.js +1 -3
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/contexts/session_context.tsx +420 -0
- package/src/components/conversation/attachments/attachment_card.tsx +1 -0
- package/src/components/conversation/attachments/attachment_deleting_overlay.tsx +34 -0
- package/src/components/conversation/attachments/audio_attachment.tsx +7 -2
- package/src/components/conversation/attachments/generic_file_attachment.tsx +6 -1
- package/src/components/conversation/attachments/image_attachment.tsx +11 -3
- package/src/components/conversation/attachments/video_attachment.tsx +7 -2
- package/src/components/conversation/message_attachments.tsx +9 -0
- package/src/contexts/session_context.tsx +234 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_deleting_ids.ts +22 -0
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +9 -2
- package/src/screens/attachment_actions/hooks/useDeleteAttachment.tsx +1 -3
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks'
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
3
|
+
import React, { useContext, Suspense } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
SessionContext,
|
|
6
|
+
SessionContextProvider,
|
|
7
|
+
SessionContextConfig,
|
|
8
|
+
} from '../../contexts/session_context'
|
|
9
|
+
import { StorageAdapter } from '../../utils/native_adapters/storage_adapter'
|
|
10
|
+
import { OAuthToken, FailedResponse } from '../../types'
|
|
11
|
+
|
|
12
|
+
jest.useFakeTimers()
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
jest.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let mockStorage: StorageAdapter
|
|
19
|
+
let mockSecureStorage: StorageAdapter
|
|
20
|
+
let mockRefreshTokenFn: jest.Mock
|
|
21
|
+
let mockOnLogout: jest.Mock
|
|
22
|
+
|
|
23
|
+
const createMockStorage = (): StorageAdapter => {
|
|
24
|
+
const storage: Record<string, string> = {}
|
|
25
|
+
return new StorageAdapter({
|
|
26
|
+
getItem: async (key: string) => {
|
|
27
|
+
return storage[key] || null
|
|
28
|
+
},
|
|
29
|
+
setItem: async (key: string, value: string) => {
|
|
30
|
+
storage[key] = value
|
|
31
|
+
},
|
|
32
|
+
removeItem: async (key: string) => {
|
|
33
|
+
delete storage[key]
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to wait for Suspense and async operations to resolve
|
|
39
|
+
const waitForQuery = async () => {
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await Promise.resolve()
|
|
42
|
+
await Promise.resolve()
|
|
43
|
+
await Promise.resolve()
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const createWrapper = (config: SessionContextConfig) => {
|
|
48
|
+
const queryClient = new QueryClient({
|
|
49
|
+
defaultOptions: {
|
|
50
|
+
queries: {
|
|
51
|
+
retry: false,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return ({ children }: { children: React.ReactNode }) => (
|
|
57
|
+
<QueryClientProvider client={queryClient}>
|
|
58
|
+
<Suspense fallback={null}>
|
|
59
|
+
<SessionContextProvider queryClient={queryClient} config={config}>
|
|
60
|
+
{children}
|
|
61
|
+
</SessionContextProvider>
|
|
62
|
+
</Suspense>
|
|
63
|
+
</QueryClientProvider>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
mockStorage = createMockStorage()
|
|
69
|
+
mockSecureStorage = createMockStorage()
|
|
70
|
+
mockRefreshTokenFn = jest.fn()
|
|
71
|
+
mockOnLogout = jest.fn()
|
|
72
|
+
|
|
73
|
+
// Set default env
|
|
74
|
+
mockStorage.setItem('env', 'production')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
jest.restoreAllMocks()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const createConfig = (overrides?: Partial<SessionContextConfig>): SessionContextConfig => ({
|
|
82
|
+
storage: mockStorage,
|
|
83
|
+
secureStorage: mockSecureStorage,
|
|
84
|
+
refreshTokenFn: mockRefreshTokenFn,
|
|
85
|
+
onLogout: mockOnLogout,
|
|
86
|
+
...overrides,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const useSessionContext = () => {
|
|
90
|
+
return useContext(SessionContext)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('SessionContextProvider', () => {
|
|
94
|
+
describe('initialization', () => {
|
|
95
|
+
it('should initialize with default environment', async () => {
|
|
96
|
+
const config = createConfig()
|
|
97
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
98
|
+
wrapper: createWrapper(config),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await waitForQuery()
|
|
102
|
+
|
|
103
|
+
expect(result.current.env).toBe('production')
|
|
104
|
+
expect(result.current.token).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should use custom default environment', async () => {
|
|
108
|
+
await mockStorage.setItem('env', 'staging')
|
|
109
|
+
const config = createConfig({ defaultEnv: 'staging' })
|
|
110
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
111
|
+
wrapper: createWrapper(config),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await waitForQuery()
|
|
115
|
+
|
|
116
|
+
expect(result.current.env).toBe('staging')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('logout', () => {
|
|
121
|
+
it('should clear sessions and call onLogout', async () => {
|
|
122
|
+
const token: OAuthToken = {
|
|
123
|
+
access_token: 'test-token',
|
|
124
|
+
refresh_token: 'test-refresh',
|
|
125
|
+
expires_in: 3600,
|
|
126
|
+
token_type: 'Bearer',
|
|
127
|
+
scope: 'read write',
|
|
128
|
+
created_at: Date.now(),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Set up a session with a token
|
|
132
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
133
|
+
await mockSecureStorage.setItem(
|
|
134
|
+
'sessions-storage',
|
|
135
|
+
JSON.stringify({ production: sessionString })
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const config = createConfig()
|
|
139
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
140
|
+
wrapper: createWrapper(config),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await waitForQuery()
|
|
144
|
+
|
|
145
|
+
expect(result.current.token).toBeDefined()
|
|
146
|
+
|
|
147
|
+
// Logout
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.logout()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await waitForQuery()
|
|
153
|
+
|
|
154
|
+
expect(mockOnLogout).toHaveBeenCalledTimes(1)
|
|
155
|
+
|
|
156
|
+
// Verify session was cleared
|
|
157
|
+
const sessionsAfterLogout = await mockSecureStorage.getItem('sessions-storage')
|
|
158
|
+
const parsedSessions = sessionsAfterLogout ? JSON.parse(sessionsAfterLogout) : {}
|
|
159
|
+
const productionSession = parsedSessions.production
|
|
160
|
+
? JSON.parse(parsedSessions.production)
|
|
161
|
+
: null
|
|
162
|
+
|
|
163
|
+
expect(productionSession.token).toBeUndefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should clear query clients on logout', async () => {
|
|
167
|
+
const config = createConfig()
|
|
168
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
169
|
+
wrapper: createWrapper(config),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
await waitForQuery()
|
|
173
|
+
|
|
174
|
+
// Note: We can't spy on the queryClient since it's created inside createWrapper
|
|
175
|
+
// But we can verify logout was called
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await result.current.logout()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(mockOnLogout).toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('token refresh', () => {
|
|
185
|
+
it('should refresh token when handleUnauthorizedResponse is called with expired token', async () => {
|
|
186
|
+
const token: OAuthToken = {
|
|
187
|
+
access_token: 'old-token',
|
|
188
|
+
refresh_token: 'refresh-token',
|
|
189
|
+
expires_in: 3600,
|
|
190
|
+
token_type: 'Bearer',
|
|
191
|
+
scope: 'read write',
|
|
192
|
+
created_at: Date.now(),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const newToken: OAuthToken = {
|
|
196
|
+
access_token: 'new-token',
|
|
197
|
+
refresh_token: 'new-refresh-token',
|
|
198
|
+
expires_in: 3600,
|
|
199
|
+
token_type: 'Bearer',
|
|
200
|
+
scope: 'read write',
|
|
201
|
+
created_at: Date.now(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
mockRefreshTokenFn.mockResolvedValue(newToken)
|
|
205
|
+
|
|
206
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
207
|
+
await mockSecureStorage.setItem(
|
|
208
|
+
'sessions-storage',
|
|
209
|
+
JSON.stringify({ production: sessionString })
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const config = createConfig()
|
|
213
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
214
|
+
wrapper: createWrapper(config),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await waitForQuery()
|
|
218
|
+
|
|
219
|
+
expect(result.current.token).toBeDefined()
|
|
220
|
+
|
|
221
|
+
const failedResponse: FailedResponse = {
|
|
222
|
+
status: 401,
|
|
223
|
+
statusText: 'Unauthorized',
|
|
224
|
+
errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }],
|
|
225
|
+
} as FailedResponse
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await waitForQuery()
|
|
232
|
+
|
|
233
|
+
expect(mockRefreshTokenFn).toHaveBeenCalledWith({
|
|
234
|
+
refresh_token: 'refresh-token',
|
|
235
|
+
env: 'production',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await waitForQuery()
|
|
239
|
+
|
|
240
|
+
const sessions = await mockSecureStorage.getItem('sessions-storage')
|
|
241
|
+
expect(sessions).toContain('new-token')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should handle multiple unauthorized responses', async () => {
|
|
245
|
+
const token: OAuthToken = {
|
|
246
|
+
access_token: 'old-token',
|
|
247
|
+
refresh_token: 'refresh-token',
|
|
248
|
+
expires_in: 3600,
|
|
249
|
+
token_type: 'Bearer',
|
|
250
|
+
scope: 'read write',
|
|
251
|
+
created_at: Date.now(),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const newToken: OAuthToken = {
|
|
255
|
+
access_token: 'new-token',
|
|
256
|
+
refresh_token: 'new-refresh-token',
|
|
257
|
+
expires_in: 3600,
|
|
258
|
+
token_type: 'Bearer',
|
|
259
|
+
scope: 'read write',
|
|
260
|
+
created_at: Date.now(),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
mockRefreshTokenFn.mockResolvedValue(newToken)
|
|
264
|
+
|
|
265
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
266
|
+
await mockSecureStorage.setItem(
|
|
267
|
+
'sessions-storage',
|
|
268
|
+
JSON.stringify({ production: sessionString })
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const config = createConfig()
|
|
272
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
273
|
+
wrapper: createWrapper(config),
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await waitForQuery()
|
|
277
|
+
|
|
278
|
+
expect(result.current.token).toBeDefined()
|
|
279
|
+
|
|
280
|
+
const failedResponse: FailedResponse = {
|
|
281
|
+
status: 401,
|
|
282
|
+
statusText: 'Unauthorized',
|
|
283
|
+
errors: [{ detail: 'other-error', title: 'Unauthorized', status: '401' }],
|
|
284
|
+
} as FailedResponse
|
|
285
|
+
|
|
286
|
+
// Call handleUnauthorizedResponse - should trigger refresh
|
|
287
|
+
await act(async () => {
|
|
288
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await waitForQuery()
|
|
292
|
+
|
|
293
|
+
// Verify refresh was called
|
|
294
|
+
expect(mockRefreshTokenFn).toHaveBeenCalled()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should logout on forced logout error (capuchin)', async () => {
|
|
298
|
+
const token: OAuthToken = {
|
|
299
|
+
access_token: 'old-token',
|
|
300
|
+
refresh_token: 'refresh-token',
|
|
301
|
+
expires_in: 3600,
|
|
302
|
+
token_type: 'Bearer',
|
|
303
|
+
scope: 'read write',
|
|
304
|
+
created_at: Date.now(),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
308
|
+
await mockSecureStorage.setItem(
|
|
309
|
+
'sessions-storage',
|
|
310
|
+
JSON.stringify({ production: sessionString })
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
mockRefreshTokenFn.mockRejectedValue({
|
|
314
|
+
status: 401,
|
|
315
|
+
errors: [{ detail: 'capuchin', title: 'Forced Logout', status: '401' }],
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const config = createConfig()
|
|
319
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
320
|
+
wrapper: createWrapper(config),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await waitForQuery()
|
|
324
|
+
|
|
325
|
+
expect(result.current.token).toBeDefined()
|
|
326
|
+
|
|
327
|
+
// Trigger refresh that will fail with capuchin
|
|
328
|
+
const failedResponse: FailedResponse = {
|
|
329
|
+
status: 401,
|
|
330
|
+
statusText: 'Unauthorized',
|
|
331
|
+
errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }],
|
|
332
|
+
} as FailedResponse
|
|
333
|
+
|
|
334
|
+
await act(async () => {
|
|
335
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
await waitForQuery()
|
|
339
|
+
|
|
340
|
+
expect(mockRefreshTokenFn).toHaveBeenCalled()
|
|
341
|
+
|
|
342
|
+
await waitForQuery()
|
|
343
|
+
|
|
344
|
+
expect(mockOnLogout).toHaveBeenCalled()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('should ignore TRASH_PANDA errors', async () => {
|
|
348
|
+
const config = createConfig()
|
|
349
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
350
|
+
wrapper: createWrapper(config),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
await waitForQuery()
|
|
354
|
+
|
|
355
|
+
const failedResponse: FailedResponse = {
|
|
356
|
+
status: 401,
|
|
357
|
+
statusText: 'Unauthorized',
|
|
358
|
+
errors: [{ detail: 'TRASH_PANDA', title: 'Unauthorized', status: '401' }],
|
|
359
|
+
} as FailedResponse
|
|
360
|
+
|
|
361
|
+
await act(async () => {
|
|
362
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await waitForQuery()
|
|
366
|
+
|
|
367
|
+
expect(mockRefreshTokenFn).not.toHaveBeenCalled()
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('setToken', () => {
|
|
372
|
+
it('should update token in storage', async () => {
|
|
373
|
+
const config = createConfig()
|
|
374
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
375
|
+
wrapper: createWrapper(config),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await waitForQuery()
|
|
379
|
+
|
|
380
|
+
const newToken: OAuthToken = {
|
|
381
|
+
access_token: 'new-token',
|
|
382
|
+
refresh_token: 'new-refresh',
|
|
383
|
+
expires_in: 3600,
|
|
384
|
+
token_type: 'Bearer',
|
|
385
|
+
scope: 'read write',
|
|
386
|
+
created_at: Date.now(),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await act(async () => {
|
|
390
|
+
await result.current.setToken(newToken)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Verify storage was updated correctly
|
|
394
|
+
const sessions = await mockSecureStorage.getItem('sessions-storage')
|
|
395
|
+
expect(sessions).toBeTruthy()
|
|
396
|
+
const parsedSessions = JSON.parse(sessions!)
|
|
397
|
+
const productionSession = JSON.parse(parsedSessions.production)
|
|
398
|
+
expect(productionSession.token.access_token).toBe('new-token')
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe('setEnv', () => {
|
|
403
|
+
it('should change environment and clear query clients', async () => {
|
|
404
|
+
const config = createConfig()
|
|
405
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
406
|
+
wrapper: createWrapper(config),
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
await waitForQuery()
|
|
410
|
+
|
|
411
|
+
await act(async () => {
|
|
412
|
+
result.current.setEnv('staging')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// Verify storage was updated correctly (it's JSON stringified)
|
|
416
|
+
const env = await mockStorage.getItem('env')
|
|
417
|
+
expect(env).toBe('"staging"')
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, StyleSheet } from 'react-native'
|
|
3
|
+
import { Spinner } from '../../display'
|
|
4
|
+
import { useTheme } from '../../../hooks'
|
|
5
|
+
|
|
6
|
+
export function AttachmentDeletingOverlay() {
|
|
7
|
+
const styles = useStyles()
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<View style={styles.container}>
|
|
11
|
+
<View style={styles.overlay} />
|
|
12
|
+
<Spinner size={24} />
|
|
13
|
+
</View>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const useStyles = () => {
|
|
18
|
+
const { colors } = useTheme()
|
|
19
|
+
|
|
20
|
+
return StyleSheet.create({
|
|
21
|
+
container: {
|
|
22
|
+
...StyleSheet.absoluteFillObject,
|
|
23
|
+
justifyContent: 'center',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
borderRadius: 8,
|
|
26
|
+
overflow: 'hidden',
|
|
27
|
+
},
|
|
28
|
+
overlay: {
|
|
29
|
+
...StyleSheet.absoluteFillObject,
|
|
30
|
+
backgroundColor: colors.fillColorNeutral070,
|
|
31
|
+
opacity: 0.5,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -6,13 +6,16 @@ import { AttachmentCard, AttachmentCardTitle } from './attachment_card'
|
|
|
6
6
|
import { useTheme } from '../../../hooks'
|
|
7
7
|
import { Audio } from '../../../utils/native_adapters'
|
|
8
8
|
import { PlatformPressable } from '@react-navigation/elements'
|
|
9
|
+
import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
|
|
9
10
|
|
|
10
11
|
export function AudioAttachment({
|
|
11
12
|
attachment,
|
|
12
13
|
onMessageAttachmentLongPress,
|
|
14
|
+
isDeleting,
|
|
13
15
|
}: {
|
|
14
16
|
attachment: DenormalizedMessageAttachmentResource
|
|
15
17
|
onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
|
|
18
|
+
isDeleting: boolean
|
|
16
19
|
}) {
|
|
17
20
|
const styles = useStyles()
|
|
18
21
|
const { colors } = useTheme()
|
|
@@ -34,9 +37,10 @@ export function AudioAttachment({
|
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<PlatformPressable
|
|
37
|
-
onLongPress={() => onMessageAttachmentLongPress(attachment)}
|
|
40
|
+
onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)}
|
|
38
41
|
android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
|
|
39
42
|
accessibilityHint="Long press for more options"
|
|
43
|
+
disabled={isDeleting}
|
|
40
44
|
>
|
|
41
45
|
<AttachmentCard>
|
|
42
46
|
<View style={styles.container}>
|
|
@@ -46,7 +50,7 @@ export function AudioAttachment({
|
|
|
46
50
|
size="md"
|
|
47
51
|
accessibilityLabel={sound.isPlaying ? 'Pause' : 'Play'}
|
|
48
52
|
onPress={toggleAudio}
|
|
49
|
-
disabled={!sound}
|
|
53
|
+
disabled={!sound || isDeleting}
|
|
50
54
|
style={styles.button}
|
|
51
55
|
iconStyle={styles.buttonIcon}
|
|
52
56
|
/>
|
|
@@ -64,6 +68,7 @@ export function AudioAttachment({
|
|
|
64
68
|
)}
|
|
65
69
|
</View>
|
|
66
70
|
</View>
|
|
71
|
+
{isDeleting && <AttachmentDeletingOverlay />}
|
|
67
72
|
</AttachmentCard>
|
|
68
73
|
</PlatformPressable>
|
|
69
74
|
)
|
|
@@ -7,13 +7,16 @@ import { useTheme } from '../../../hooks'
|
|
|
7
7
|
import { PlatformPressable } from '@react-navigation/elements'
|
|
8
8
|
import { tokens } from '../../../vendor/tapestry/tokens'
|
|
9
9
|
import { Linking } from '../../../utils'
|
|
10
|
+
import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
|
|
10
11
|
|
|
11
12
|
export function GenericFileAttachment({
|
|
12
13
|
attachment,
|
|
13
14
|
onMessageAttachmentLongPress,
|
|
15
|
+
isDeleting,
|
|
14
16
|
}: {
|
|
15
17
|
attachment: DenormalizedMessageAttachmentResource
|
|
16
18
|
onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
|
|
19
|
+
isDeleting: boolean
|
|
17
20
|
}) {
|
|
18
21
|
const styles = useStyles()
|
|
19
22
|
const { colors } = useTheme()
|
|
@@ -32,10 +35,11 @@ export function GenericFileAttachment({
|
|
|
32
35
|
return (
|
|
33
36
|
<PlatformPressable
|
|
34
37
|
onPress={handleDownload}
|
|
35
|
-
onLongPress={() => onMessageAttachmentLongPress(attachment)}
|
|
38
|
+
onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)}
|
|
36
39
|
android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
|
|
37
40
|
accessibilityRole="link"
|
|
38
41
|
accessibilityHint={`Press to open ${fileTypeLabel} in a browser. Long press for more options`}
|
|
42
|
+
disabled={isDeleting}
|
|
39
43
|
>
|
|
40
44
|
<AttachmentCard>
|
|
41
45
|
<View style={styles.container}>
|
|
@@ -44,6 +48,7 @@ export function GenericFileAttachment({
|
|
|
44
48
|
<AttachmentCardTitle>{filename}</AttachmentCardTitle>
|
|
45
49
|
</View>
|
|
46
50
|
</View>
|
|
51
|
+
{isDeleting && <AttachmentDeletingOverlay />}
|
|
47
52
|
</AttachmentCard>
|
|
48
53
|
</PlatformPressable>
|
|
49
54
|
)
|
|
@@ -34,6 +34,7 @@ import { DenormalizedMessageAttachmentResource } from '../../../types/resources/
|
|
|
34
34
|
import { PlatformPressable } from '@react-navigation/elements'
|
|
35
35
|
import { useTheme } from '../../../hooks'
|
|
36
36
|
import { Haptic, platformFontWeightMedium } from '../../../utils'
|
|
37
|
+
import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
|
|
37
38
|
|
|
38
39
|
const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window')
|
|
39
40
|
const DISMISS_PAN_THRESHOLD = 175
|
|
@@ -64,12 +65,14 @@ export function ImageAttachment({
|
|
|
64
65
|
currentImageIndex,
|
|
65
66
|
metaProps,
|
|
66
67
|
onMessageAttachmentLongPress,
|
|
68
|
+
isDeleting,
|
|
67
69
|
}: {
|
|
68
70
|
attachment: DenormalizedMessageAttachmentResource
|
|
69
71
|
imageAttachments: DenormalizedMessageAttachmentResource[]
|
|
70
72
|
currentImageIndex: number
|
|
71
73
|
metaProps: MetaProps
|
|
72
74
|
onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
|
|
75
|
+
isDeleting: boolean
|
|
73
76
|
}) {
|
|
74
77
|
const { attributes } = attachment
|
|
75
78
|
const { url, urlMedium, filename, metadata = {} } = attributes
|
|
@@ -87,12 +90,15 @@ export function ImageAttachment({
|
|
|
87
90
|
<PlatformPressable
|
|
88
91
|
style={styles.container}
|
|
89
92
|
onPress={() => {
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
if (!isDeleting) {
|
|
94
|
+
setModalKey(prev => prev + 1)
|
|
95
|
+
setVisible(true)
|
|
96
|
+
}
|
|
92
97
|
}}
|
|
93
|
-
onLongPress={() => onMessageAttachmentLongPress(attachment)}
|
|
98
|
+
onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)}
|
|
94
99
|
android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
|
|
95
100
|
accessibilityHint="Long press for more options"
|
|
101
|
+
disabled={isDeleting}
|
|
96
102
|
>
|
|
97
103
|
<Image
|
|
98
104
|
source={{ uri: urlMedium || url }}
|
|
@@ -100,6 +106,7 @@ export function ImageAttachment({
|
|
|
100
106
|
wrapperStyle={styles.attachmentImageWrapper}
|
|
101
107
|
alt={filename}
|
|
102
108
|
/>
|
|
109
|
+
{isDeleting && <AttachmentDeletingOverlay />}
|
|
103
110
|
</PlatformPressable>
|
|
104
111
|
<LightboxModal
|
|
105
112
|
key={modalKey}
|
|
@@ -931,6 +938,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {})
|
|
|
931
938
|
return StyleSheet.create({
|
|
932
939
|
container: {
|
|
933
940
|
maxWidth: '100%',
|
|
941
|
+
position: 'relative',
|
|
934
942
|
},
|
|
935
943
|
touchEventIsolator: {
|
|
936
944
|
flex: 1,
|
|
@@ -7,13 +7,16 @@ import { Video, VideoPlayerHandle } from '../../../utils/native_adapters'
|
|
|
7
7
|
import { PlatformPressable } from '@react-navigation/elements'
|
|
8
8
|
import { useTheme } from '../../../hooks'
|
|
9
9
|
import { tokens } from '../../../vendor/tapestry/tokens'
|
|
10
|
+
import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
|
|
10
11
|
|
|
11
12
|
export function VideoAttachment({
|
|
12
13
|
attachment,
|
|
13
14
|
onMessageAttachmentLongPress,
|
|
15
|
+
isDeleting,
|
|
14
16
|
}: {
|
|
15
17
|
attachment: DenormalizedMessageAttachmentResource
|
|
16
18
|
onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
|
|
19
|
+
isDeleting: boolean
|
|
17
20
|
}) {
|
|
18
21
|
const { colors } = useTheme()
|
|
19
22
|
const styles = useStyles()
|
|
@@ -45,10 +48,11 @@ export function VideoAttachment({
|
|
|
45
48
|
<View style={styles.container} ref={viewRef}>
|
|
46
49
|
<PlatformPressable
|
|
47
50
|
onPress={openVideo}
|
|
48
|
-
onLongPress={() => onMessageAttachmentLongPress(attachment)}
|
|
51
|
+
onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)}
|
|
49
52
|
android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
|
|
50
53
|
accessibilityLabel="Play Video"
|
|
51
54
|
accessibilityHint="Press to play video. Long press for more options"
|
|
55
|
+
disabled={isDeleting}
|
|
52
56
|
>
|
|
53
57
|
<View style={styles.thumbnailOverlay} />
|
|
54
58
|
<Video.Player
|
|
@@ -58,7 +62,7 @@ export function VideoAttachment({
|
|
|
58
62
|
style={styles.video}
|
|
59
63
|
onFullscreenPlayerWillDismiss={onFullscreenPlayerWillDismiss}
|
|
60
64
|
/>
|
|
61
|
-
{!isOpen && (
|
|
65
|
+
{!isOpen && !isDeleting && (
|
|
62
66
|
<View style={styles.playContainer}>
|
|
63
67
|
<View style={styles.playCircle}>
|
|
64
68
|
<Icon
|
|
@@ -69,6 +73,7 @@ export function VideoAttachment({
|
|
|
69
73
|
</View>
|
|
70
74
|
</View>
|
|
71
75
|
)}
|
|
76
|
+
{isDeleting && <AttachmentDeletingOverlay />}
|
|
72
77
|
</PlatformPressable>
|
|
73
78
|
</View>
|
|
74
79
|
)
|