@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.
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +294 -34
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment_legacy.d.ts +12 -0
- package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +1 -0
- package/build/components/conversation/attachments/image_attachment_legacy.js +142 -0
- package/build/components/conversation/attachments/image_attachment_legacy.js.map +1 -0
- package/build/components/conversation/message_attachments.d.ts +1 -1
- package/build/components/conversation/message_attachments.d.ts.map +1 -1
- package/build/components/conversation/message_attachments.js +17 -5
- package/build/components/conversation/message_attachments.js.map +1 -1
- package/build/hooks/services/use_find_or_create_services_conversation.d.ts +2 -0
- package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
- package/build/hooks/services/use_find_or_create_services_conversation.js +20 -19
- package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
- package/build/navigation/index.d.ts +9 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/navigation/index.js +10 -0
- package/build/navigation/index.js.map +1 -1
- package/build/screens/send_giphy_screen.d.ts +1 -1
- package/build/screens/send_giphy_screen.d.ts.map +1 -1
- package/build/screens/send_giphy_screen.js +14 -2
- package/build/screens/send_giphy_screen.js.map +1 -1
- package/build/screens/team_conversation_screen.d.ts +8 -0
- package/build/screens/team_conversation_screen.d.ts.map +1 -0
- package/build/screens/team_conversation_screen.js +28 -0
- package/build/screens/team_conversation_screen.js.map +1 -0
- package/package.json +2 -2
- package/src/components/conversation/attachments/image_attachment.tsx +375 -65
- package/src/components/conversation/attachments/image_attachment_legacy.tsx +258 -0
- package/src/components/conversation/message_attachments.tsx +35 -6
- package/src/hooks/services/use_find_or_create_services_conversation.ts +27 -21
- package/src/navigation/index.tsx +10 -0
- package/src/screens/send_giphy_screen.tsx +23 -2
- package/src/screens/team_conversation_screen.tsx +46 -0
|
@@ -1,26 +1,8 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
65
|
+
setModalVisible={setVisible}
|
|
102
66
|
uri={urlMedium || url}
|
|
103
67
|
metaProps={metaProps}
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
77
|
+
setModalVisible: Dispatch<SetStateAction<boolean>>
|
|
114
78
|
uri: string
|
|
115
79
|
metaProps: MetaProps
|
|
116
|
-
|
|
117
|
-
|
|
80
|
+
imageWidth?: number
|
|
81
|
+
imageHeight?: number
|
|
118
82
|
}
|
|
119
83
|
|
|
120
84
|
const LightboxModal = ({
|
|
121
85
|
uri,
|
|
122
86
|
visible,
|
|
123
|
-
|
|
87
|
+
setModalVisible,
|
|
124
88
|
metaProps,
|
|
125
|
-
|
|
126
|
-
|
|
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={
|
|
451
|
+
<GestureDetector gesture={composedGesture}>
|
|
141
452
|
<Image
|
|
142
453
|
source={{ uri }}
|
|
143
454
|
loadingBackgroundStyles={styles.lightboxImageLoading}
|
|
144
455
|
style={styles.lightboxImage}
|
|
145
|
-
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:
|
|
527
|
+
width: WINDOW_WIDTH,
|
|
218
528
|
backgroundColor,
|
|
219
529
|
},
|
|
220
530
|
lightboxImageLoading: {
|