@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.
Files changed (57) hide show
  1. package/build/components/conversation/attachments/attachment_card.js +1 -0
  2. package/build/components/conversation/attachments/attachment_card.js.map +1 -1
  3. package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts +3 -0
  4. package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts.map +1 -0
  5. package/build/components/conversation/attachments/attachment_deleting_overlay.js +29 -0
  6. package/build/components/conversation/attachments/attachment_deleting_overlay.js.map +1 -0
  7. package/build/components/conversation/attachments/audio_attachment.d.ts +2 -1
  8. package/build/components/conversation/attachments/audio_attachment.d.ts.map +1 -1
  9. package/build/components/conversation/attachments/audio_attachment.js +5 -3
  10. package/build/components/conversation/attachments/audio_attachment.js.map +1 -1
  11. package/build/components/conversation/attachments/generic_file_attachment.d.ts +2 -1
  12. package/build/components/conversation/attachments/generic_file_attachment.d.ts.map +1 -1
  13. package/build/components/conversation/attachments/generic_file_attachment.js +4 -2
  14. package/build/components/conversation/attachments/generic_file_attachment.js.map +1 -1
  15. package/build/components/conversation/attachments/image_attachment.d.ts +2 -1
  16. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  17. package/build/components/conversation/attachments/image_attachment.js +9 -4
  18. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  19. package/build/components/conversation/attachments/video_attachment.d.ts +2 -1
  20. package/build/components/conversation/attachments/video_attachment.d.ts.map +1 -1
  21. package/build/components/conversation/attachments/video_attachment.js +5 -3
  22. package/build/components/conversation/attachments/video_attachment.js.map +1 -1
  23. package/build/components/conversation/message_attachments.d.ts.map +1 -1
  24. package/build/components/conversation/message_attachments.js +8 -6
  25. package/build/components/conversation/message_attachments.js.map +1 -1
  26. package/build/contexts/session_context.d.ts +40 -0
  27. package/build/contexts/session_context.d.ts.map +1 -0
  28. package/build/contexts/session_context.js +131 -0
  29. package/build/contexts/session_context.js.map +1 -0
  30. package/build/hooks/index.d.ts +1 -0
  31. package/build/hooks/index.d.ts.map +1 -1
  32. package/build/hooks/index.js +1 -0
  33. package/build/hooks/index.js.map +1 -1
  34. package/build/hooks/use_deleting_ids.d.ts +4 -0
  35. package/build/hooks/use_deleting_ids.d.ts.map +1 -0
  36. package/build/hooks/use_deleting_ids.js +19 -0
  37. package/build/hooks/use_deleting_ids.js.map +1 -0
  38. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  39. package/build/screens/attachment_actions/attachment_actions_screen.js +9 -2
  40. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  41. package/build/screens/attachment_actions/hooks/useDeleteAttachment.d.ts.map +1 -1
  42. package/build/screens/attachment_actions/hooks/useDeleteAttachment.js +1 -3
  43. package/build/screens/attachment_actions/hooks/useDeleteAttachment.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/__tests__/contexts/session_context.tsx +420 -0
  46. package/src/components/conversation/attachments/attachment_card.tsx +1 -0
  47. package/src/components/conversation/attachments/attachment_deleting_overlay.tsx +34 -0
  48. package/src/components/conversation/attachments/audio_attachment.tsx +7 -2
  49. package/src/components/conversation/attachments/generic_file_attachment.tsx +6 -1
  50. package/src/components/conversation/attachments/image_attachment.tsx +11 -3
  51. package/src/components/conversation/attachments/video_attachment.tsx +7 -2
  52. package/src/components/conversation/message_attachments.tsx +9 -0
  53. package/src/contexts/session_context.tsx +234 -0
  54. package/src/hooks/index.ts +1 -0
  55. package/src/hooks/use_deleting_ids.ts +22 -0
  56. package/src/screens/attachment_actions/attachment_actions_screen.tsx +9 -2
  57. 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
+ })
@@ -37,6 +37,7 @@ const useStyles = () => {
37
37
  minWidth: '100%',
38
38
  minHeight: 60,
39
39
  justifyContent: 'center',
40
+ position: 'relative',
40
41
  },
41
42
  title: {
42
43
  color: colors.textColorDefaultPrimary,
@@ -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
- setModalKey(prev => prev + 1)
91
- setVisible(true)
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
  )