@planningcenter/chat-react-native 3.10.0 → 3.10.1-qa-291.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 (35) hide show
  1. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/image_attachment.js +294 -34
  3. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment_legacy.d.ts +12 -0
  5. package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +1 -0
  6. package/build/components/conversation/attachments/image_attachment_legacy.js +142 -0
  7. package/build/components/conversation/attachments/image_attachment_legacy.js.map +1 -0
  8. package/build/components/conversation/message_attachments.d.ts +1 -1
  9. package/build/components/conversation/message_attachments.d.ts.map +1 -1
  10. package/build/components/conversation/message_attachments.js +17 -5
  11. package/build/components/conversation/message_attachments.js.map +1 -1
  12. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +2 -0
  13. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  14. package/build/hooks/services/use_find_or_create_services_conversation.js +20 -19
  15. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  16. package/build/navigation/index.d.ts +9 -0
  17. package/build/navigation/index.d.ts.map +1 -1
  18. package/build/navigation/index.js +10 -0
  19. package/build/navigation/index.js.map +1 -1
  20. package/build/screens/send_giphy_screen.d.ts +1 -1
  21. package/build/screens/send_giphy_screen.d.ts.map +1 -1
  22. package/build/screens/send_giphy_screen.js +14 -2
  23. package/build/screens/send_giphy_screen.js.map +1 -1
  24. package/build/screens/team_conversation_screen.d.ts +8 -0
  25. package/build/screens/team_conversation_screen.d.ts.map +1 -0
  26. package/build/screens/team_conversation_screen.js +28 -0
  27. package/build/screens/team_conversation_screen.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/components/conversation/attachments/image_attachment.tsx +375 -65
  30. package/src/components/conversation/attachments/image_attachment_legacy.tsx +258 -0
  31. package/src/components/conversation/message_attachments.tsx +35 -6
  32. package/src/hooks/services/use_find_or_create_services_conversation.ts +27 -21
  33. package/src/navigation/index.tsx +10 -0
  34. package/src/screens/send_giphy_screen.tsx +23 -2
  35. package/src/screens/team_conversation_screen.tsx +46 -0
@@ -1,26 +1,8 @@
1
- import React, { useCallback, useMemo, useState } from 'react'
2
- import {
3
- StyleSheet,
4
- Modal,
5
- useWindowDimensions,
6
- SafeAreaView,
7
- View,
8
- Linking,
9
- ImageStyle,
10
- } from 'react-native'
11
- import {
12
- Gesture,
13
- GestureDetector,
14
- GestureHandlerRootView,
15
- type PanGesture,
16
- } from 'react-native-gesture-handler'
17
- import {
18
- runOnJS,
19
- useAnimatedStyle,
20
- useSharedValue,
21
- withTiming,
22
- type AnimatedStyle,
23
- } from 'react-native-reanimated'
1
+ import React, { useMemo, useState, Dispatch, SetStateAction } from 'react'
2
+ import { StyleSheet, Modal, SafeAreaView, View, Linking, Dimensions } from 'react-native'
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
4
+ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
5
+ import { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'
24
6
  import { tokens } from '../../../vendor/tapestry/tokens'
25
7
  import { IconButton, Image, Heading, Text } from '../../display'
26
8
  import colorFunction from 'color'
@@ -29,7 +11,17 @@ import { DenormalizedMessageAttachmentResource } from '../../../types/resources/
29
11
  import { PlatformPressable } from '@react-navigation/elements'
30
12
  import { useTheme } from '../../../hooks'
31
13
 
32
- const PAN_THRESHOLD_PX = 300
14
+ const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window')
15
+ const DISMISS_PAN_THRESHOLD = 300
16
+ const DEFAULT_OPACITY = 1
17
+ const DEFAULT_SCALE = 1
18
+ const MIN_SCALE = 0.5
19
+ const MAX_SCALE = 5
20
+ const DOUBLE_TAP_ZOOM_SCALE = 2
21
+ const RESET_SPRING_CONFIG = {
22
+ damping: 20,
23
+ stiffness: 150,
24
+ }
33
25
 
34
26
  export type MetaProps = {
35
27
  authorName: string
@@ -52,34 +44,6 @@ export function ImageAttachment({
52
44
  const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })
53
45
  const [visible, setVisible] = useState(false)
54
46
 
55
- // shared values run on the native UI thread and prevents clogging up the JS thread
56
- const dismissY = useSharedValue(0)
57
- const opacity = useSharedValue(1)
58
-
59
- const resetAnimations = useCallback(() => {
60
- dismissY.value = withTiming(0)
61
- opacity.value = withTiming(1)
62
- }, [dismissY, opacity])
63
-
64
- const handleCloseModal = useCallback(() => {
65
- setVisible(false)
66
- resetAnimations()
67
- }, [setVisible, resetAnimations])
68
-
69
- const panGesture = Gesture.Pan()
70
- .onUpdate(e => {
71
- dismissY.value = e.translationY
72
- opacity.value = 1 - Math.abs(e.translationY) / PAN_THRESHOLD_PX
73
- })
74
- .onEnd(() => {
75
- runOnJS(handleCloseModal)() // Ensures we can call a JS function
76
- })
77
-
78
- const animatedImageStyle = useAnimatedStyle(() => ({
79
- transform: [{ translateY: dismissY.value }],
80
- opacity: opacity.value,
81
- }))
82
-
83
47
  return (
84
48
  <>
85
49
  <PlatformPressable
@@ -98,11 +62,11 @@ export function ImageAttachment({
98
62
  </PlatformPressable>
99
63
  <LightboxModal
100
64
  visible={visible}
101
- handleCloseModal={handleCloseModal}
65
+ setModalVisible={setVisible}
102
66
  uri={urlMedium || url}
103
67
  metaProps={metaProps}
104
- panGesture={panGesture}
105
- animatedImageStyle={animatedImageStyle}
68
+ imageWidth={metadata.width}
69
+ imageHeight={metadata.height}
106
70
  />
107
71
  </>
108
72
  )
@@ -110,39 +74,386 @@ export function ImageAttachment({
110
74
 
111
75
  interface LightboxModalProps {
112
76
  visible: boolean
113
- handleCloseModal: () => void
77
+ setModalVisible: Dispatch<SetStateAction<boolean>>
114
78
  uri: string
115
79
  metaProps: MetaProps
116
- panGesture: PanGesture
117
- animatedImageStyle: AnimatedStyle<ImageStyle>
80
+ imageWidth?: number
81
+ imageHeight?: number
118
82
  }
119
83
 
120
84
  const LightboxModal = ({
121
85
  uri,
122
86
  visible,
123
- handleCloseModal,
87
+ setModalVisible,
124
88
  metaProps,
125
- panGesture,
126
- animatedImageStyle,
89
+ imageWidth,
90
+ imageHeight,
127
91
  }: LightboxModalProps) => {
128
92
  const styles = useStyles()
93
+ const insets = useSafeAreaInsets()
129
94
 
130
95
  const { authorName, createdAt } = metaProps
131
96
 
97
+ // Calculate available space for image display
98
+ const availableWindowWidth = WINDOW_WIDTH
99
+ const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom
100
+
101
+ /* ============================
102
+ ANIMATION VALUES
103
+ ============================ */
104
+ const dismissY = useSharedValue(0) // vertical distance to dismiss modal
105
+ const opacity = useSharedValue(DEFAULT_OPACITY) // opacity of modal
106
+ const scale = useSharedValue(DEFAULT_SCALE) // zoom level of image
107
+ const focalX = useSharedValue(0) // focal point of image between fingers
108
+ const focalY = useSharedValue(0) // focal point of image between fingers
109
+ const translateX = useSharedValue(0) // horizontal distance to pan image
110
+ const translateY = useSharedValue(0) // vertical distance to pan image
111
+ const savedScale = useSharedValue(DEFAULT_SCALE) // previous zoom level
112
+ const savedTranslateX = useSharedValue(0) // previous horizontal position
113
+ const savedTranslateY = useSharedValue(0) // previous vertical position
114
+
115
+ /* ============================
116
+ HANDLERS
117
+ ============================ */
132
118
  const handleOpenInBrowser = () => {
133
119
  Linking.openURL(uri)
134
120
  }
135
121
 
122
+ const resetDismissGestures = () => {
123
+ dismissY.value = withSpring(0, RESET_SPRING_CONFIG)
124
+ opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG)
125
+ }
126
+
127
+ const resetAllGestures = () => {
128
+ resetDismissGestures()
129
+ scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG)
130
+ translateX.value = withSpring(0, RESET_SPRING_CONFIG)
131
+ translateY.value = withSpring(0, RESET_SPRING_CONFIG)
132
+ savedScale.value = DEFAULT_SCALE
133
+ savedTranslateX.value = 0
134
+ savedTranslateY.value = 0
135
+ }
136
+
137
+ const handleCloseModal = () => {
138
+ setModalVisible(false)
139
+ resetAllGestures()
140
+ }
141
+
142
+ /* ============================
143
+ UTILITY FUNCTIONS
144
+ 'worklet' runs functions on the UI thread, instead of the JS thread for better performance
145
+ ============================ */
146
+ const getImageContainedToWindowDimensions = () => {
147
+ 'worklet'
148
+
149
+ if (!imageWidth || !imageHeight) {
150
+ return { width: availableWindowWidth, height: availableWindowHeight }
151
+ }
152
+
153
+ const imageAspectRatio = imageWidth / imageHeight
154
+ const windowAspectRatio = availableWindowWidth / availableWindowHeight
155
+
156
+ // Constrain image width if its wider than window
157
+ if (imageAspectRatio > windowAspectRatio) {
158
+ return {
159
+ width: availableWindowWidth,
160
+ height: availableWindowWidth / imageAspectRatio,
161
+ }
162
+ }
163
+
164
+ // Constrain image height if its taller than window
165
+ return {
166
+ width: availableWindowHeight * imageAspectRatio,
167
+ height: availableWindowHeight,
168
+ }
169
+ }
170
+
171
+ const zoomToFocalPoint = ({
172
+ focalPointX,
173
+ focalPointY,
174
+ targetScale,
175
+ currentSavedScale,
176
+ currentSavedTranslateX,
177
+ currentSavedTranslateY,
178
+ }: {
179
+ focalPointX: number
180
+ focalPointY: number
181
+ targetScale: number
182
+ currentSavedScale: number
183
+ currentSavedTranslateX: number
184
+ currentSavedTranslateY: number
185
+ }) => {
186
+ 'worklet'
187
+
188
+ // How far the focal point is from the center of the window
189
+ const windowCenterX = WINDOW_WIDTH / 2
190
+ const windowCenterY = WINDOW_HEIGHT / 2
191
+
192
+ // Position of focal point relative to current image position
193
+ const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX
194
+ const focalRelativeY = focalPointY - windowCenterY - currentSavedTranslateY
195
+
196
+ // Calculate new translation to keep focal point under fingers
197
+ const scaleRatio = targetScale / currentSavedScale
198
+ const newTranslateX = currentSavedTranslateX + focalRelativeX * (1 - scaleRatio)
199
+ const newTranslateY = currentSavedTranslateY + focalRelativeY * (1 - scaleRatio)
200
+
201
+ return {
202
+ translateX: newTranslateX,
203
+ translateY: newTranslateY,
204
+ }
205
+ }
206
+
207
+ const clampTranslation = ({
208
+ x,
209
+ y,
210
+ currentScale,
211
+ allowGestureOvershoot = false,
212
+ }: {
213
+ x: number
214
+ y: number
215
+ currentScale: number
216
+ allowGestureOvershoot?: boolean
217
+ }) => {
218
+ 'worklet'
219
+
220
+ const { width: containedImageWidth, height: containedImageHeight } =
221
+ getImageContainedToWindowDimensions()
222
+
223
+ // Image dimensions after scaling / zooming
224
+ const scaledWidth = containedImageWidth * currentScale
225
+ const scaledHeight = containedImageHeight * currentScale
226
+
227
+ // How much the scaled image exceeds the window container
228
+ const excessWidth = scaledWidth - availableWindowWidth
229
+ const excessHeight = scaledHeight - availableWindowHeight
230
+
231
+ // How far the image can move in each direction
232
+ const maxTranslateX = Math.max(0, excessWidth / 2)
233
+ const maxTranslateY = Math.max(0, excessHeight / 2)
234
+
235
+ if (allowGestureOvershoot) {
236
+ const overshoot = 20
237
+ return {
238
+ x: Math.min(maxTranslateX + overshoot, Math.max(-maxTranslateX - overshoot, x)),
239
+ y: Math.min(maxTranslateY + overshoot, Math.max(-maxTranslateY - overshoot, y)),
240
+ }
241
+ }
242
+
243
+ return {
244
+ x: Math.min(maxTranslateX, Math.max(-maxTranslateX, x)),
245
+ y: Math.min(maxTranslateY, Math.max(-maxTranslateY, y)),
246
+ }
247
+ }
248
+
249
+ const isImageAtVerticalBoundry = (currentScale: number) => {
250
+ 'worklet'
251
+
252
+ const { height: containedImageHeight } = getImageContainedToWindowDimensions()
253
+
254
+ // Calculate how much the image can exceed the window container
255
+ const scaledHeight = containedImageHeight * currentScale
256
+ const excessHeight = scaledHeight - availableWindowHeight
257
+ const maxTranslateY = Math.max(0, excessHeight / 2)
258
+
259
+ const currentTranslateY = translateY.value
260
+ const panPositionTolerance = 1 // buffer to account for translateY being at a subpixel position
261
+
262
+ const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance
263
+ const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance
264
+
265
+ return atTopBoundry || atBottomBoundry
266
+ }
267
+
268
+ /* ============================
269
+ GESTURES
270
+ ============================ */
271
+ const doubleTapGesture = Gesture.Tap()
272
+ .numberOfTaps(2)
273
+ .onStart(e => {
274
+ const isZoomedIn = scale.value > DEFAULT_SCALE
275
+ if (isZoomedIn) {
276
+ runOnJS(resetAllGestures)()
277
+ } else {
278
+ // Zoom to 2x at tap location
279
+ const newTranslation = zoomToFocalPoint({
280
+ focalPointX: e.x,
281
+ focalPointY: e.y,
282
+ targetScale: DOUBLE_TAP_ZOOM_SCALE,
283
+ currentSavedScale: savedScale.value,
284
+ currentSavedTranslateX: savedTranslateX.value,
285
+ currentSavedTranslateY: savedTranslateY.value,
286
+ })
287
+
288
+ // Apply clamping to ensure image edges remain within window boundaries
289
+ const clampedTranslate = clampTranslation({
290
+ x: newTranslation.translateX,
291
+ y: newTranslation.translateY,
292
+ currentScale: DOUBLE_TAP_ZOOM_SCALE,
293
+ })
294
+
295
+ // Animate to new scale and translation
296
+ scale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE, RESET_SPRING_CONFIG)
297
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)
298
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)
299
+
300
+ // Update saved state for next gesture
301
+ savedScale.value = DOUBLE_TAP_ZOOM_SCALE
302
+ savedTranslateX.value = clampedTranslate.x
303
+ savedTranslateY.value = clampedTranslate.y
304
+ }
305
+ })
306
+
307
+ const pinchGesture = Gesture.Pinch()
308
+ .onStart(e => {
309
+ focalX.value = e.focalX
310
+ focalY.value = e.focalY
311
+ })
312
+ .onUpdate(e => {
313
+ // Zoom image in/out within scale limits
314
+ const newScale = savedScale.value * e.scale
315
+ const newScaleClamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale))
316
+ scale.value = newScaleClamped
317
+
318
+ // Calculate new translation to keep focal point under fingers
319
+ const newTranslation = zoomToFocalPoint({
320
+ focalPointX: focalX.value,
321
+ focalPointY: focalY.value,
322
+ targetScale: newScaleClamped,
323
+ currentSavedScale: savedScale.value,
324
+ currentSavedTranslateX: savedTranslateX.value,
325
+ currentSavedTranslateY: savedTranslateY.value,
326
+ })
327
+
328
+ // Apply translation without clamping to ensure focal point doesn't jump during gesture
329
+ translateX.value = newTranslation.translateX
330
+ translateY.value = newTranslation.translateY
331
+ })
332
+ .onEnd(() => {
333
+ const currentScale = scale.value
334
+
335
+ // Dismiss modal if fully zoomed out
336
+ if (currentScale <= MIN_SCALE) {
337
+ runOnJS(handleCloseModal)()
338
+ return
339
+ }
340
+
341
+ // Reset all gestures if image is near default scale
342
+ const isNearDefaultScale = currentScale <= DEFAULT_SCALE + 0.1
343
+ if (isNearDefaultScale) {
344
+ runOnJS(resetAllGestures)()
345
+ return
346
+ }
347
+
348
+ // Ensure image edges remain within window boundaries
349
+ const clampedTranslate = clampTranslation({
350
+ x: translateX.value,
351
+ y: translateY.value,
352
+ currentScale: currentScale,
353
+ })
354
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)
355
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)
356
+
357
+ // Save state for next gesture
358
+ savedScale.value = currentScale
359
+ savedTranslateX.value = clampedTranslate.x
360
+ savedTranslateY.value = clampedTranslate.y
361
+ })
362
+
363
+ const panGesture = Gesture.Pan()
364
+ .onUpdate(e => {
365
+ // Only pan if image is zoomed in
366
+ if (scale.value <= DEFAULT_SCALE) return
367
+
368
+ const newTranslateX = savedTranslateX.value + e.translationX
369
+ const newTranslateY = savedTranslateY.value + e.translationY
370
+
371
+ // Ensure image edges remain within window boundaries
372
+ const clampedTranslate = clampTranslation({
373
+ x: newTranslateX,
374
+ y: newTranslateY,
375
+ currentScale: scale.value,
376
+ allowGestureOvershoot: true,
377
+ })
378
+ translateX.value = clampedTranslate.x
379
+ translateY.value = clampedTranslate.y
380
+ })
381
+ .onEnd(() => {
382
+ // Prevents saving pan position if image is zoomed out while panning
383
+ if (scale.value <= DEFAULT_SCALE) return
384
+
385
+ // Spring image back to window boundaries if overshot
386
+ const clampedTranslate = clampTranslation({
387
+ x: translateX.value,
388
+ y: translateY.value,
389
+ currentScale: scale.value,
390
+ })
391
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)
392
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)
393
+
394
+ // Save the current position for the next gesture
395
+ savedTranslateX.value = clampedTranslate.x
396
+ savedTranslateY.value = clampedTranslate.y
397
+ })
398
+
399
+ const panToDismissModalGesture = Gesture.Pan()
400
+ .onUpdate(e => {
401
+ const atDefaultScale = scale.value === DEFAULT_SCALE
402
+
403
+ // Calculate zoom conditions for dismissing modal
404
+ const atVerticalBoundry = isImageAtVerticalBoundry(scale.value)
405
+ const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX)
406
+ const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical
407
+
408
+ if (atDefaultScale || canDismissWhileZoomed) {
409
+ const panDistance = Math.abs(e.translationY)
410
+ const fadeProgress = panDistance / DISMISS_PAN_THRESHOLD
411
+
412
+ opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress)
413
+ dismissY.value = e.translationY
414
+ }
415
+ })
416
+ .onEnd(() => {
417
+ const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD
418
+
419
+ if (exceededDismissThreshold) {
420
+ runOnJS(handleCloseModal)()
421
+ } else {
422
+ runOnJS(resetDismissGestures)()
423
+ }
424
+ })
425
+
426
+ /* ==============================
427
+ IMPLEMENT GESTURES & ANIMATIONS
428
+ ================================= */
429
+ // Race between pinch and pan ensures only one is active at a time, preventing issues with zooming to focal point between fingers
430
+ const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture)
431
+
432
+ // Race between double-tap and pinch/pan ensures that pinch/pan can't interrupt double-tap
433
+ const transformImageGestures = Gesture.Race(doubleTapGesture, pinchOrPanGestures)
434
+
435
+ // Dismiss can work simultaneously with all gestures
436
+ const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture)
437
+
438
+ const animatedImageStyles = useAnimatedStyle(() => ({
439
+ transform: [
440
+ { translateX: translateX.value },
441
+ { translateY: translateY.value + dismissY.value },
442
+ { scale: scale.value },
443
+ ],
444
+ opacity: opacity.value,
445
+ }))
446
+
136
447
  return (
137
448
  <Modal visible={visible} transparent animationType="fade" onRequestClose={handleCloseModal}>
138
449
  <SafeAreaView style={styles.modal}>
139
450
  <GestureHandlerRootView>
140
- <GestureDetector gesture={panGesture}>
451
+ <GestureDetector gesture={composedGesture}>
141
452
  <Image
142
453
  source={{ uri }}
143
454
  loadingBackgroundStyles={styles.lightboxImageLoading}
144
455
  style={styles.lightboxImage}
145
- animatedImageStyle={animatedImageStyle}
456
+ animatedImageStyle={animatedImageStyles}
146
457
  resizeMode="contain"
147
458
  animated={true}
148
459
  alt=""
@@ -187,7 +498,6 @@ interface UseStylesProps {
187
498
  }
188
499
 
189
500
  const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {
190
- const { width: windowWidth } = useWindowDimensions()
191
501
  const backgroundColor = tokens.colorNeutral7
192
502
  const transparentBackgroundColor = useMemo(
193
503
  () => colorFunction(backgroundColor).alpha(0.8).toString(),
@@ -214,7 +524,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {})
214
524
  },
215
525
  lightboxImage: {
216
526
  height: '100%',
217
- width: windowWidth,
527
+ width: WINDOW_WIDTH,
218
528
  backgroundColor,
219
529
  },
220
530
  lightboxImageLoading: {