@planningcenter/chat-react-native 3.11.0-rc.2 → 3.11.0-rc.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"image_attachment.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/image_attachment.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAyC,MAAM,OAAO,CAAA;AA2B7D,OAAO,EAAE,qCAAqC,EAAE,MAAM,2DAA2D,CAAA;AAMjH,MAAM,MAAM,SAAS,GAAG;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,wBAAgB,eAAe,CAAC,EAC9B,UAAU,EACV,SAAS,EACT,4BAA4B,GAC7B,EAAE;IACD,UAAU,EAAE,qCAAqC,CAAA;IACjD,SAAS,EAAE,SAAS,CAAA;IACpB,4BAA4B,EAAE,CAAC,UAAU,EAAE,qCAAqC,KAAK,IAAI,CAAA;CAC1F,qBA8DA"}
1
+ {"version":3,"file":"image_attachment.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/image_attachment.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsD,MAAM,OAAO,CAAA;AAS1E,OAAO,EAAE,qCAAqC,EAAE,MAAM,2DAA2D,CAAA;AAgBjH,MAAM,MAAM,SAAS,GAAG;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,wBAAgB,eAAe,CAAC,EAC9B,UAAU,EACV,SAAS,EACT,4BAA4B,GAC7B,EAAE;IACD,UAAU,EAAE,qCAAqC,CAAA;IACjD,SAAS,EAAE,SAAS,CAAA;IACpB,4BAA4B,EAAE,CAAC,UAAU,EAAE,qCAAqC,KAAK,IAAI,CAAA;CAC1F,qBAkCA"}
@@ -1,61 +1,322 @@
1
- import React, { useCallback, useMemo, useState } from 'react';
2
- import { StyleSheet, Modal, useWindowDimensions, SafeAreaView, View, Linking, } from 'react-native';
3
- import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
4
- import { runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated';
1
+ import React, { useMemo, useState } 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';
5
6
  import { tokens } from '../../../vendor/tapestry/tokens';
6
7
  import { IconButton, Image, Heading, Text } from '../../display';
7
8
  import colorFunction from 'color';
8
9
  import { formatDatePreview } from '../../../utils/date';
9
10
  import { PlatformPressable } from '@react-navigation/elements';
10
11
  import { useTheme } from '../../../hooks';
11
- const PAN_THRESHOLD_PX = 300;
12
+ const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window');
13
+ const DISMISS_PAN_THRESHOLD = 300;
14
+ const DEFAULT_OPACITY = 1;
15
+ const DEFAULT_SCALE = 1;
16
+ const MIN_SCALE = 0.5;
17
+ const MAX_SCALE = 5;
18
+ const DOUBLE_TAP_ZOOM_SCALE = 2;
19
+ const RESET_SPRING_CONFIG = {
20
+ damping: 20,
21
+ stiffness: 150,
22
+ };
12
23
  export function ImageAttachment({ attachment, metaProps, onMessageAttachmentLongPress, }) {
13
24
  const { attributes } = attachment;
14
25
  const { url, urlMedium, filename, metadata = {} } = attributes;
15
26
  const { colors } = useTheme();
16
27
  const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height });
17
28
  const [visible, setVisible] = useState(false);
18
- // shared values run on the native UI thread and prevents clogging up the JS thread
19
- const dismissY = useSharedValue(0);
20
- const opacity = useSharedValue(1);
21
- const resetAnimations = useCallback(() => {
22
- dismissY.value = withTiming(0);
23
- opacity.value = withTiming(1);
24
- }, [dismissY, opacity]);
25
- const handleCloseModal = useCallback(() => {
26
- setVisible(false);
27
- resetAnimations();
28
- }, [setVisible, resetAnimations]);
29
- const panGesture = Gesture.Pan()
30
- .onUpdate(e => {
31
- dismissY.value = e.translationY;
32
- opacity.value = 1 - Math.abs(e.translationY) / PAN_THRESHOLD_PX;
33
- })
34
- .onEnd(() => {
35
- runOnJS(handleCloseModal)(); // Ensures we can call a JS function
36
- });
37
- const animatedImageStyle = useAnimatedStyle(() => ({
38
- transform: [{ translateY: dismissY.value }],
39
- opacity: opacity.value,
40
- }));
41
29
  return (<>
42
30
  <PlatformPressable style={styles.container} onPress={() => setVisible(true)} onLongPress={() => onMessageAttachmentLongPress(attachment)} android_ripple={{ color: colors.androidRippleNeutral, foreground: true }} accessibilityHint="Long press for more options">
43
31
  <Image source={{ uri: urlMedium || url }} style={styles.image} wrapperStyle={styles.imageWrapper} alt={filename}/>
44
32
  </PlatformPressable>
45
- <LightboxModal visible={visible} handleCloseModal={handleCloseModal} uri={urlMedium || url} metaProps={metaProps} panGesture={panGesture} animatedImageStyle={animatedImageStyle}/>
33
+ <LightboxModal visible={visible} setModalVisible={setVisible} uri={urlMedium || url} metaProps={metaProps} imageWidth={metadata.width} imageHeight={metadata.height}/>
46
34
  </>);
47
35
  }
48
- const LightboxModal = ({ uri, visible, handleCloseModal, metaProps, panGesture, animatedImageStyle, }) => {
36
+ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, imageHeight, }) => {
49
37
  const styles = useStyles();
38
+ const insets = useSafeAreaInsets();
50
39
  const { authorName, createdAt } = metaProps;
40
+ // Calculate available space for image display
41
+ const availableWindowWidth = WINDOW_WIDTH;
42
+ const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom;
43
+ /* ============================
44
+ ANIMATION VALUES
45
+ ============================ */
46
+ const dismissY = useSharedValue(0); // vertical distance to dismiss modal
47
+ const opacity = useSharedValue(DEFAULT_OPACITY); // opacity of modal
48
+ const scale = useSharedValue(DEFAULT_SCALE); // zoom level of image
49
+ const focalX = useSharedValue(0); // focal point of image between fingers
50
+ const focalY = useSharedValue(0); // focal point of image between fingers
51
+ const translateX = useSharedValue(0); // horizontal distance to pan image
52
+ const translateY = useSharedValue(0); // vertical distance to pan image
53
+ const savedScale = useSharedValue(DEFAULT_SCALE); // previous zoom level
54
+ const savedTranslateX = useSharedValue(0); // previous horizontal position
55
+ const savedTranslateY = useSharedValue(0); // previous vertical position
56
+ /* ============================
57
+ HANDLERS
58
+ ============================ */
51
59
  const handleOpenInBrowser = () => {
52
60
  Linking.openURL(uri);
53
61
  };
62
+ const resetDismissGestures = () => {
63
+ dismissY.value = withSpring(0, RESET_SPRING_CONFIG);
64
+ opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG);
65
+ };
66
+ const resetAllGestures = () => {
67
+ resetDismissGestures();
68
+ scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG);
69
+ translateX.value = withSpring(0, RESET_SPRING_CONFIG);
70
+ translateY.value = withSpring(0, RESET_SPRING_CONFIG);
71
+ savedScale.value = DEFAULT_SCALE;
72
+ savedTranslateX.value = 0;
73
+ savedTranslateY.value = 0;
74
+ };
75
+ const handleCloseModal = () => {
76
+ setModalVisible(false);
77
+ resetAllGestures();
78
+ };
79
+ /* ============================
80
+ UTILITY FUNCTIONS
81
+ 'worklet' runs functions on the UI thread, instead of the JS thread for better performance
82
+ ============================ */
83
+ const getImageContainedToWindowDimensions = () => {
84
+ 'worklet';
85
+ if (!imageWidth || !imageHeight) {
86
+ return { width: availableWindowWidth, height: availableWindowHeight };
87
+ }
88
+ const imageAspectRatio = imageWidth / imageHeight;
89
+ const windowAspectRatio = availableWindowWidth / availableWindowHeight;
90
+ // Constrain image width if its wider than window
91
+ if (imageAspectRatio > windowAspectRatio) {
92
+ return {
93
+ width: availableWindowWidth,
94
+ height: availableWindowWidth / imageAspectRatio,
95
+ };
96
+ }
97
+ // Constrain image height if its taller than window
98
+ return {
99
+ width: availableWindowHeight * imageAspectRatio,
100
+ height: availableWindowHeight,
101
+ };
102
+ };
103
+ const zoomToFocalPoint = ({ focalPointX, focalPointY, targetScale, currentSavedScale, currentSavedTranslateX, currentSavedTranslateY, }) => {
104
+ 'worklet';
105
+ // How far the focal point is from the center of the window
106
+ const windowCenterX = WINDOW_WIDTH / 2;
107
+ const windowCenterY = WINDOW_HEIGHT / 2;
108
+ // Position of focal point relative to current image position
109
+ const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX;
110
+ const focalRelativeY = focalPointY - windowCenterY - currentSavedTranslateY;
111
+ // Calculate new translation to keep focal point under fingers
112
+ const scaleRatio = targetScale / currentSavedScale;
113
+ const newTranslateX = currentSavedTranslateX + focalRelativeX * (1 - scaleRatio);
114
+ const newTranslateY = currentSavedTranslateY + focalRelativeY * (1 - scaleRatio);
115
+ return {
116
+ translateX: newTranslateX,
117
+ translateY: newTranslateY,
118
+ };
119
+ };
120
+ const clampTranslation = ({ x, y, currentScale, allowGestureOvershoot = false, }) => {
121
+ 'worklet';
122
+ const { width: containedImageWidth, height: containedImageHeight } = getImageContainedToWindowDimensions();
123
+ // Image dimensions after scaling / zooming
124
+ const scaledWidth = containedImageWidth * currentScale;
125
+ const scaledHeight = containedImageHeight * currentScale;
126
+ // How much the scaled image exceeds the window container
127
+ const excessWidth = scaledWidth - availableWindowWidth;
128
+ const excessHeight = scaledHeight - availableWindowHeight;
129
+ // How far the image can move in each direction
130
+ const maxTranslateX = Math.max(0, excessWidth / 2);
131
+ const maxTranslateY = Math.max(0, excessHeight / 2);
132
+ if (allowGestureOvershoot) {
133
+ const overshoot = 20;
134
+ return {
135
+ x: Math.min(maxTranslateX + overshoot, Math.max(-maxTranslateX - overshoot, x)),
136
+ y: Math.min(maxTranslateY + overshoot, Math.max(-maxTranslateY - overshoot, y)),
137
+ };
138
+ }
139
+ return {
140
+ x: Math.min(maxTranslateX, Math.max(-maxTranslateX, x)),
141
+ y: Math.min(maxTranslateY, Math.max(-maxTranslateY, y)),
142
+ };
143
+ };
144
+ const isImageAtVerticalBoundry = (currentScale) => {
145
+ 'worklet';
146
+ const { height: containedImageHeight } = getImageContainedToWindowDimensions();
147
+ // Calculate how much the image can exceed the window container
148
+ const scaledHeight = containedImageHeight * currentScale;
149
+ const excessHeight = scaledHeight - availableWindowHeight;
150
+ const maxTranslateY = Math.max(0, excessHeight / 2);
151
+ const currentTranslateY = translateY.value;
152
+ const panPositionTolerance = 1; // buffer to account for translateY being at a subpixel position
153
+ const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance;
154
+ const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance;
155
+ return atTopBoundry || atBottomBoundry;
156
+ };
157
+ /* ============================
158
+ GESTURES
159
+ ============================ */
160
+ const doubleTapGesture = Gesture.Tap()
161
+ .numberOfTaps(2)
162
+ .onStart(e => {
163
+ const isZoomedIn = scale.value > DEFAULT_SCALE;
164
+ if (isZoomedIn) {
165
+ runOnJS(resetAllGestures)();
166
+ }
167
+ else {
168
+ // Zoom to 2x at tap location
169
+ const newTranslation = zoomToFocalPoint({
170
+ focalPointX: e.x,
171
+ focalPointY: e.y,
172
+ targetScale: DOUBLE_TAP_ZOOM_SCALE,
173
+ currentSavedScale: savedScale.value,
174
+ currentSavedTranslateX: savedTranslateX.value,
175
+ currentSavedTranslateY: savedTranslateY.value,
176
+ });
177
+ // Apply clamping to ensure image edges remain within window boundaries
178
+ const clampedTranslate = clampTranslation({
179
+ x: newTranslation.translateX,
180
+ y: newTranslation.translateY,
181
+ currentScale: DOUBLE_TAP_ZOOM_SCALE,
182
+ });
183
+ // Animate to new scale and translation
184
+ scale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE, RESET_SPRING_CONFIG);
185
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG);
186
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG);
187
+ // Update saved state for next gesture
188
+ savedScale.value = DOUBLE_TAP_ZOOM_SCALE;
189
+ savedTranslateX.value = clampedTranslate.x;
190
+ savedTranslateY.value = clampedTranslate.y;
191
+ }
192
+ });
193
+ const pinchGesture = Gesture.Pinch()
194
+ .onStart(e => {
195
+ focalX.value = e.focalX;
196
+ focalY.value = e.focalY;
197
+ })
198
+ .onUpdate(e => {
199
+ // Zoom image in/out within scale limits
200
+ const newScale = savedScale.value * e.scale;
201
+ const newScaleClamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
202
+ scale.value = newScaleClamped;
203
+ // Calculate new translation to keep focal point under fingers
204
+ const newTranslation = zoomToFocalPoint({
205
+ focalPointX: focalX.value,
206
+ focalPointY: focalY.value,
207
+ targetScale: newScaleClamped,
208
+ currentSavedScale: savedScale.value,
209
+ currentSavedTranslateX: savedTranslateX.value,
210
+ currentSavedTranslateY: savedTranslateY.value,
211
+ });
212
+ // Apply translation without clamping to ensure focal point doesn't jump during gesture
213
+ translateX.value = newTranslation.translateX;
214
+ translateY.value = newTranslation.translateY;
215
+ })
216
+ .onEnd(() => {
217
+ const currentScale = scale.value;
218
+ // Dismiss modal if fully zoomed out
219
+ if (currentScale <= MIN_SCALE) {
220
+ runOnJS(handleCloseModal)();
221
+ return;
222
+ }
223
+ // Reset all gestures if image is near default scale
224
+ const isNearDefaultScale = currentScale <= DEFAULT_SCALE + 0.1;
225
+ if (isNearDefaultScale) {
226
+ runOnJS(resetAllGestures)();
227
+ return;
228
+ }
229
+ // Ensure image edges remain within window boundaries
230
+ const clampedTranslate = clampTranslation({
231
+ x: translateX.value,
232
+ y: translateY.value,
233
+ currentScale: currentScale,
234
+ });
235
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG);
236
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG);
237
+ // Save state for next gesture
238
+ savedScale.value = currentScale;
239
+ savedTranslateX.value = clampedTranslate.x;
240
+ savedTranslateY.value = clampedTranslate.y;
241
+ });
242
+ const panGesture = Gesture.Pan()
243
+ .onUpdate(e => {
244
+ // Only pan if image is zoomed in
245
+ if (scale.value <= DEFAULT_SCALE)
246
+ return;
247
+ const newTranslateX = savedTranslateX.value + e.translationX;
248
+ const newTranslateY = savedTranslateY.value + e.translationY;
249
+ // Ensure image edges remain within window boundaries
250
+ const clampedTranslate = clampTranslation({
251
+ x: newTranslateX,
252
+ y: newTranslateY,
253
+ currentScale: scale.value,
254
+ allowGestureOvershoot: true,
255
+ });
256
+ translateX.value = clampedTranslate.x;
257
+ translateY.value = clampedTranslate.y;
258
+ })
259
+ .onEnd(() => {
260
+ // Prevents saving pan position if image is zoomed out while panning
261
+ if (scale.value <= DEFAULT_SCALE)
262
+ return;
263
+ // Spring image back to window boundaries if overshot
264
+ const clampedTranslate = clampTranslation({
265
+ x: translateX.value,
266
+ y: translateY.value,
267
+ currentScale: scale.value,
268
+ });
269
+ translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG);
270
+ translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG);
271
+ // Save the current position for the next gesture
272
+ savedTranslateX.value = clampedTranslate.x;
273
+ savedTranslateY.value = clampedTranslate.y;
274
+ });
275
+ const panToDismissModalGesture = Gesture.Pan()
276
+ .onUpdate(e => {
277
+ const atDefaultScale = scale.value === DEFAULT_SCALE;
278
+ // Calculate zoom conditions for dismissing modal
279
+ const atVerticalBoundry = isImageAtVerticalBoundry(scale.value);
280
+ const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX);
281
+ const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical;
282
+ if (atDefaultScale || canDismissWhileZoomed) {
283
+ const panDistance = Math.abs(e.translationY);
284
+ const fadeProgress = panDistance / DISMISS_PAN_THRESHOLD;
285
+ opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress);
286
+ dismissY.value = e.translationY;
287
+ }
288
+ })
289
+ .onEnd(() => {
290
+ const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD;
291
+ if (exceededDismissThreshold) {
292
+ runOnJS(handleCloseModal)();
293
+ }
294
+ else {
295
+ runOnJS(resetDismissGestures)();
296
+ }
297
+ });
298
+ /* ==============================
299
+ IMPLEMENT GESTURES & ANIMATIONS
300
+ ================================= */
301
+ // Race between pinch and pan ensures only one is active at a time, preventing issues with zooming to focal point between fingers
302
+ const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture);
303
+ // Race between double-tap and pinch/pan ensures that pinch/pan can't interrupt double-tap
304
+ const transformImageGestures = Gesture.Race(doubleTapGesture, pinchOrPanGestures);
305
+ // Dismiss can work simultaneously with all gestures
306
+ const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture);
307
+ const animatedImageStyles = useAnimatedStyle(() => ({
308
+ transform: [
309
+ { translateX: translateX.value },
310
+ { translateY: translateY.value + dismissY.value },
311
+ { scale: scale.value },
312
+ ],
313
+ opacity: opacity.value,
314
+ }));
54
315
  return (<Modal visible={visible} transparent animationType="fade" onRequestClose={handleCloseModal}>
55
316
  <SafeAreaView style={styles.modal}>
56
317
  <GestureHandlerRootView>
57
- <GestureDetector gesture={panGesture}>
58
- <Image source={{ uri }} loadingBackgroundStyles={styles.lightboxImageLoading} style={styles.lightboxImage} animatedImageStyle={animatedImageStyle} resizeMode="contain" animated={true} alt=""/>
318
+ <GestureDetector gesture={composedGesture}>
319
+ <Image source={{ uri }} loadingBackgroundStyles={styles.lightboxImageLoading} style={styles.lightboxImage} animatedImageStyle={animatedImageStyles} resizeMode="contain" animated={true} alt=""/>
59
320
  </GestureDetector>
60
321
  <View style={styles.actionToolbar} accessibilityRole="toolbar">
61
322
  <View style={styles.actionToolbarTextMeta}>
@@ -74,7 +335,6 @@ const LightboxModal = ({ uri, visible, handleCloseModal, metaProps, panGesture,
74
335
  </Modal>);
75
336
  };
76
337
  const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
77
- const { width: windowWidth } = useWindowDimensions();
78
338
  const backgroundColor = tokens.colorNeutral7;
79
339
  const transparentBackgroundColor = useMemo(() => colorFunction(backgroundColor).alpha(0.8).toString(), [backgroundColor]);
80
340
  return StyleSheet.create({
@@ -97,7 +357,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
97
357
  },
98
358
  lightboxImage: {
99
359
  height: '100%',
100
- width: windowWidth,
360
+ width: WINDOW_WIDTH,
101
361
  backgroundColor,
102
362
  },
103
363
  lightboxImageLoading: {
@@ -1 +1 @@
1
- {"version":3,"file":"image_attachment.js","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/image_attachment.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { useCallback, useMemo, useState } from 'react'\nimport {\n StyleSheet,\n Modal,\n useWindowDimensions,\n SafeAreaView,\n View,\n Linking,\n ImageStyle,\n} from 'react-native'\nimport {\n Gesture,\n GestureDetector,\n GestureHandlerRootView,\n type PanGesture,\n} from 'react-native-gesture-handler'\nimport {\n runOnJS,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n type AnimatedStyle,\n} from 'react-native-reanimated'\nimport { tokens } from '../../../vendor/tapestry/tokens'\nimport { IconButton, Image, Heading, Text } from '../../display'\nimport colorFunction from 'color'\nimport { formatDatePreview } from '../../../utils/date'\nimport { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'\nimport { PlatformPressable } from '@react-navigation/elements'\nimport { useTheme } from '../../../hooks'\n\nconst PAN_THRESHOLD_PX = 300\n\nexport type MetaProps = {\n authorName: string\n createdAt: string\n}\n\nexport function ImageAttachment({\n attachment,\n metaProps,\n onMessageAttachmentLongPress,\n}: {\n attachment: DenormalizedMessageAttachmentResource\n metaProps: MetaProps\n onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void\n}) {\n const { attributes } = attachment\n const { url, urlMedium, filename, metadata = {} } = attributes\n const { colors } = useTheme()\n\n const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })\n const [visible, setVisible] = useState(false)\n\n // shared values run on the native UI thread and prevents clogging up the JS thread\n const dismissY = useSharedValue(0)\n const opacity = useSharedValue(1)\n\n const resetAnimations = useCallback(() => {\n dismissY.value = withTiming(0)\n opacity.value = withTiming(1)\n }, [dismissY, opacity])\n\n const handleCloseModal = useCallback(() => {\n setVisible(false)\n resetAnimations()\n }, [setVisible, resetAnimations])\n\n const panGesture = Gesture.Pan()\n .onUpdate(e => {\n dismissY.value = e.translationY\n opacity.value = 1 - Math.abs(e.translationY) / PAN_THRESHOLD_PX\n })\n .onEnd(() => {\n runOnJS(handleCloseModal)() // Ensures we can call a JS function\n })\n\n const animatedImageStyle = useAnimatedStyle(() => ({\n transform: [{ translateY: dismissY.value }],\n opacity: opacity.value,\n }))\n\n return (\n <>\n <PlatformPressable\n style={styles.container}\n onPress={() => setVisible(true)}\n onLongPress={() => onMessageAttachmentLongPress(attachment)}\n android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}\n accessibilityHint=\"Long press for more options\"\n >\n <Image\n source={{ uri: urlMedium || url }}\n style={styles.image}\n wrapperStyle={styles.imageWrapper}\n alt={filename}\n />\n </PlatformPressable>\n <LightboxModal\n visible={visible}\n handleCloseModal={handleCloseModal}\n uri={urlMedium || url}\n metaProps={metaProps}\n panGesture={panGesture}\n animatedImageStyle={animatedImageStyle}\n />\n </>\n )\n}\n\ninterface LightboxModalProps {\n visible: boolean\n handleCloseModal: () => void\n uri: string\n metaProps: MetaProps\n panGesture: PanGesture\n animatedImageStyle: AnimatedStyle<ImageStyle>\n}\n\nconst LightboxModal = ({\n uri,\n visible,\n handleCloseModal,\n metaProps,\n panGesture,\n animatedImageStyle,\n}: LightboxModalProps) => {\n const styles = useStyles()\n\n const { authorName, createdAt } = metaProps\n\n const handleOpenInBrowser = () => {\n Linking.openURL(uri)\n }\n\n return (\n <Modal visible={visible} transparent animationType=\"fade\" onRequestClose={handleCloseModal}>\n <SafeAreaView style={styles.modal}>\n <GestureHandlerRootView>\n <GestureDetector gesture={panGesture}>\n <Image\n source={{ uri }}\n loadingBackgroundStyles={styles.lightboxImageLoading}\n style={styles.lightboxImage}\n animatedImageStyle={animatedImageStyle}\n resizeMode=\"contain\"\n animated={true}\n alt=\"\"\n />\n </GestureDetector>\n <View style={styles.actionToolbar} accessibilityRole=\"toolbar\">\n <View style={styles.actionToolbarTextMeta}>\n <Heading variant=\"h3\" style={styles.actionToolbarTitle} numberOfLines={1}>\n {authorName}\n </Heading>\n <Text variant=\"tertiary\" style={styles.actionToolbarSubtitle}>\n {formatDatePreview(createdAt)}\n </Text>\n </View>\n <IconButton\n onPress={handleOpenInBrowser}\n name=\"general.newWindow\"\n accessibilityRole=\"link\"\n accessibilityLabel=\"Open image in browser\"\n accessibilityHint=\"Image can be downloaded and shared through the browser.\"\n style={styles.actionButton}\n iconStyle={styles.actionButtonIcon}\n size=\"lg\"\n />\n <IconButton\n onPress={handleCloseModal}\n name=\"general.x\"\n accessibilityLabel=\"Close image\"\n style={styles.actionButton}\n iconStyle={styles.actionButtonIcon}\n />\n </View>\n </GestureHandlerRootView>\n </SafeAreaView>\n </Modal>\n )\n}\n\ninterface UseStylesProps {\n imageWidth?: number\n imageHeight?: number\n}\n\nconst useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {\n const { width: windowWidth } = useWindowDimensions()\n const backgroundColor = tokens.colorNeutral7\n const transparentBackgroundColor = useMemo(\n () => colorFunction(backgroundColor).alpha(0.8).toString(),\n [backgroundColor]\n )\n\n return StyleSheet.create({\n container: {\n maxWidth: '100%',\n },\n imageWrapper: {\n width: '100%',\n minWidth: 200,\n aspectRatio: imageWidth / imageHeight,\n },\n image: {\n borderRadius: 8,\n },\n modal: {\n flex: 1,\n backgroundColor,\n justifyContent: 'center',\n alignItems: 'center',\n },\n lightboxImage: {\n height: '100%',\n width: windowWidth,\n backgroundColor,\n },\n lightboxImageLoading: {\n backgroundColor,\n },\n actionToolbar: {\n width: '100%',\n position: 'absolute',\n top: 0,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 20,\n paddingHorizontal: 16,\n paddingTop: 16,\n paddingBottom: 8,\n backgroundColor: transparentBackgroundColor,\n },\n actionToolbarTextMeta: {\n flex: 1,\n },\n actionToolbarTitle: {\n marginRight: 'auto',\n flexShrink: 1,\n color: tokens.colorNeutral88,\n },\n actionToolbarSubtitle: {\n color: tokens.colorNeutral68,\n },\n actionButton: {\n backgroundColor,\n height: 40,\n width: 40,\n borderRadius: 50,\n borderWidth: 1,\n borderColor: tokens.colorNeutral24,\n },\n actionButtonIcon: {\n color: tokens.colorNeutral88,\n },\n })\n}\n"]}
1
+ {"version":3,"file":"image_attachment.js","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/image_attachment.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { useMemo, useState, Dispatch, SetStateAction } from 'react'\nimport { StyleSheet, Modal, SafeAreaView, View, Linking, Dimensions } from 'react-native'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\nimport { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'\nimport { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'\nimport { tokens } from '../../../vendor/tapestry/tokens'\nimport { IconButton, Image, Heading, Text } from '../../display'\nimport colorFunction from 'color'\nimport { formatDatePreview } from '../../../utils/date'\nimport { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'\nimport { PlatformPressable } from '@react-navigation/elements'\nimport { useTheme } from '../../../hooks'\n\nconst { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window')\nconst DISMISS_PAN_THRESHOLD = 300\nconst DEFAULT_OPACITY = 1\nconst DEFAULT_SCALE = 1\nconst MIN_SCALE = 0.5\nconst MAX_SCALE = 5\nconst DOUBLE_TAP_ZOOM_SCALE = 2\nconst RESET_SPRING_CONFIG = {\n damping: 20,\n stiffness: 150,\n}\n\nexport type MetaProps = {\n authorName: string\n createdAt: string\n}\n\nexport function ImageAttachment({\n attachment,\n metaProps,\n onMessageAttachmentLongPress,\n}: {\n attachment: DenormalizedMessageAttachmentResource\n metaProps: MetaProps\n onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void\n}) {\n const { attributes } = attachment\n const { url, urlMedium, filename, metadata = {} } = attributes\n const { colors } = useTheme()\n\n const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })\n const [visible, setVisible] = useState(false)\n\n return (\n <>\n <PlatformPressable\n style={styles.container}\n onPress={() => setVisible(true)}\n onLongPress={() => onMessageAttachmentLongPress(attachment)}\n android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}\n accessibilityHint=\"Long press for more options\"\n >\n <Image\n source={{ uri: urlMedium || url }}\n style={styles.image}\n wrapperStyle={styles.imageWrapper}\n alt={filename}\n />\n </PlatformPressable>\n <LightboxModal\n visible={visible}\n setModalVisible={setVisible}\n uri={urlMedium || url}\n metaProps={metaProps}\n imageWidth={metadata.width}\n imageHeight={metadata.height}\n />\n </>\n )\n}\n\ninterface LightboxModalProps {\n visible: boolean\n setModalVisible: Dispatch<SetStateAction<boolean>>\n uri: string\n metaProps: MetaProps\n imageWidth?: number\n imageHeight?: number\n}\n\nconst LightboxModal = ({\n uri,\n visible,\n setModalVisible,\n metaProps,\n imageWidth,\n imageHeight,\n}: LightboxModalProps) => {\n const styles = useStyles()\n const insets = useSafeAreaInsets()\n\n const { authorName, createdAt } = metaProps\n\n // Calculate available space for image display\n const availableWindowWidth = WINDOW_WIDTH\n const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom\n\n /* ============================\n ANIMATION VALUES\n ============================ */\n const dismissY = useSharedValue(0) // vertical distance to dismiss modal\n const opacity = useSharedValue(DEFAULT_OPACITY) // opacity of modal\n const scale = useSharedValue(DEFAULT_SCALE) // zoom level of image\n const focalX = useSharedValue(0) // focal point of image between fingers\n const focalY = useSharedValue(0) // focal point of image between fingers\n const translateX = useSharedValue(0) // horizontal distance to pan image\n const translateY = useSharedValue(0) // vertical distance to pan image\n const savedScale = useSharedValue(DEFAULT_SCALE) // previous zoom level\n const savedTranslateX = useSharedValue(0) // previous horizontal position\n const savedTranslateY = useSharedValue(0) // previous vertical position\n\n /* ============================\n HANDLERS\n ============================ */\n const handleOpenInBrowser = () => {\n Linking.openURL(uri)\n }\n\n const resetDismissGestures = () => {\n dismissY.value = withSpring(0, RESET_SPRING_CONFIG)\n opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG)\n }\n\n const resetAllGestures = () => {\n resetDismissGestures()\n scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG)\n translateX.value = withSpring(0, RESET_SPRING_CONFIG)\n translateY.value = withSpring(0, RESET_SPRING_CONFIG)\n savedScale.value = DEFAULT_SCALE\n savedTranslateX.value = 0\n savedTranslateY.value = 0\n }\n\n const handleCloseModal = () => {\n setModalVisible(false)\n resetAllGestures()\n }\n\n /* ============================\n UTILITY FUNCTIONS\n 'worklet' runs functions on the UI thread, instead of the JS thread for better performance\n ============================ */\n const getImageContainedToWindowDimensions = () => {\n 'worklet'\n\n if (!imageWidth || !imageHeight) {\n return { width: availableWindowWidth, height: availableWindowHeight }\n }\n\n const imageAspectRatio = imageWidth / imageHeight\n const windowAspectRatio = availableWindowWidth / availableWindowHeight\n\n // Constrain image width if its wider than window\n if (imageAspectRatio > windowAspectRatio) {\n return {\n width: availableWindowWidth,\n height: availableWindowWidth / imageAspectRatio,\n }\n }\n\n // Constrain image height if its taller than window\n return {\n width: availableWindowHeight * imageAspectRatio,\n height: availableWindowHeight,\n }\n }\n\n const zoomToFocalPoint = ({\n focalPointX,\n focalPointY,\n targetScale,\n currentSavedScale,\n currentSavedTranslateX,\n currentSavedTranslateY,\n }: {\n focalPointX: number\n focalPointY: number\n targetScale: number\n currentSavedScale: number\n currentSavedTranslateX: number\n currentSavedTranslateY: number\n }) => {\n 'worklet'\n\n // How far the focal point is from the center of the window\n const windowCenterX = WINDOW_WIDTH / 2\n const windowCenterY = WINDOW_HEIGHT / 2\n\n // Position of focal point relative to current image position\n const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX\n const focalRelativeY = focalPointY - windowCenterY - currentSavedTranslateY\n\n // Calculate new translation to keep focal point under fingers\n const scaleRatio = targetScale / currentSavedScale\n const newTranslateX = currentSavedTranslateX + focalRelativeX * (1 - scaleRatio)\n const newTranslateY = currentSavedTranslateY + focalRelativeY * (1 - scaleRatio)\n\n return {\n translateX: newTranslateX,\n translateY: newTranslateY,\n }\n }\n\n const clampTranslation = ({\n x,\n y,\n currentScale,\n allowGestureOvershoot = false,\n }: {\n x: number\n y: number\n currentScale: number\n allowGestureOvershoot?: boolean\n }) => {\n 'worklet'\n\n const { width: containedImageWidth, height: containedImageHeight } =\n getImageContainedToWindowDimensions()\n\n // Image dimensions after scaling / zooming\n const scaledWidth = containedImageWidth * currentScale\n const scaledHeight = containedImageHeight * currentScale\n\n // How much the scaled image exceeds the window container\n const excessWidth = scaledWidth - availableWindowWidth\n const excessHeight = scaledHeight - availableWindowHeight\n\n // How far the image can move in each direction\n const maxTranslateX = Math.max(0, excessWidth / 2)\n const maxTranslateY = Math.max(0, excessHeight / 2)\n\n if (allowGestureOvershoot) {\n const overshoot = 20\n return {\n x: Math.min(maxTranslateX + overshoot, Math.max(-maxTranslateX - overshoot, x)),\n y: Math.min(maxTranslateY + overshoot, Math.max(-maxTranslateY - overshoot, y)),\n }\n }\n\n return {\n x: Math.min(maxTranslateX, Math.max(-maxTranslateX, x)),\n y: Math.min(maxTranslateY, Math.max(-maxTranslateY, y)),\n }\n }\n\n const isImageAtVerticalBoundry = (currentScale: number) => {\n 'worklet'\n\n const { height: containedImageHeight } = getImageContainedToWindowDimensions()\n\n // Calculate how much the image can exceed the window container\n const scaledHeight = containedImageHeight * currentScale\n const excessHeight = scaledHeight - availableWindowHeight\n const maxTranslateY = Math.max(0, excessHeight / 2)\n\n const currentTranslateY = translateY.value\n const panPositionTolerance = 1 // buffer to account for translateY being at a subpixel position\n\n const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance\n const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance\n\n return atTopBoundry || atBottomBoundry\n }\n\n /* ============================\n GESTURES\n ============================ */\n const doubleTapGesture = Gesture.Tap()\n .numberOfTaps(2)\n .onStart(e => {\n const isZoomedIn = scale.value > DEFAULT_SCALE\n if (isZoomedIn) {\n runOnJS(resetAllGestures)()\n } else {\n // Zoom to 2x at tap location\n const newTranslation = zoomToFocalPoint({\n focalPointX: e.x,\n focalPointY: e.y,\n targetScale: DOUBLE_TAP_ZOOM_SCALE,\n currentSavedScale: savedScale.value,\n currentSavedTranslateX: savedTranslateX.value,\n currentSavedTranslateY: savedTranslateY.value,\n })\n\n // Apply clamping to ensure image edges remain within window boundaries\n const clampedTranslate = clampTranslation({\n x: newTranslation.translateX,\n y: newTranslation.translateY,\n currentScale: DOUBLE_TAP_ZOOM_SCALE,\n })\n\n // Animate to new scale and translation\n scale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE, RESET_SPRING_CONFIG)\n translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)\n translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)\n\n // Update saved state for next gesture\n savedScale.value = DOUBLE_TAP_ZOOM_SCALE\n savedTranslateX.value = clampedTranslate.x\n savedTranslateY.value = clampedTranslate.y\n }\n })\n\n const pinchGesture = Gesture.Pinch()\n .onStart(e => {\n focalX.value = e.focalX\n focalY.value = e.focalY\n })\n .onUpdate(e => {\n // Zoom image in/out within scale limits\n const newScale = savedScale.value * e.scale\n const newScaleClamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale))\n scale.value = newScaleClamped\n\n // Calculate new translation to keep focal point under fingers\n const newTranslation = zoomToFocalPoint({\n focalPointX: focalX.value,\n focalPointY: focalY.value,\n targetScale: newScaleClamped,\n currentSavedScale: savedScale.value,\n currentSavedTranslateX: savedTranslateX.value,\n currentSavedTranslateY: savedTranslateY.value,\n })\n\n // Apply translation without clamping to ensure focal point doesn't jump during gesture\n translateX.value = newTranslation.translateX\n translateY.value = newTranslation.translateY\n })\n .onEnd(() => {\n const currentScale = scale.value\n\n // Dismiss modal if fully zoomed out\n if (currentScale <= MIN_SCALE) {\n runOnJS(handleCloseModal)()\n return\n }\n\n // Reset all gestures if image is near default scale\n const isNearDefaultScale = currentScale <= DEFAULT_SCALE + 0.1\n if (isNearDefaultScale) {\n runOnJS(resetAllGestures)()\n return\n }\n\n // Ensure image edges remain within window boundaries\n const clampedTranslate = clampTranslation({\n x: translateX.value,\n y: translateY.value,\n currentScale: currentScale,\n })\n translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)\n translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)\n\n // Save state for next gesture\n savedScale.value = currentScale\n savedTranslateX.value = clampedTranslate.x\n savedTranslateY.value = clampedTranslate.y\n })\n\n const panGesture = Gesture.Pan()\n .onUpdate(e => {\n // Only pan if image is zoomed in\n if (scale.value <= DEFAULT_SCALE) return\n\n const newTranslateX = savedTranslateX.value + e.translationX\n const newTranslateY = savedTranslateY.value + e.translationY\n\n // Ensure image edges remain within window boundaries\n const clampedTranslate = clampTranslation({\n x: newTranslateX,\n y: newTranslateY,\n currentScale: scale.value,\n allowGestureOvershoot: true,\n })\n translateX.value = clampedTranslate.x\n translateY.value = clampedTranslate.y\n })\n .onEnd(() => {\n // Prevents saving pan position if image is zoomed out while panning\n if (scale.value <= DEFAULT_SCALE) return\n\n // Spring image back to window boundaries if overshot\n const clampedTranslate = clampTranslation({\n x: translateX.value,\n y: translateY.value,\n currentScale: scale.value,\n })\n translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)\n translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)\n\n // Save the current position for the next gesture\n savedTranslateX.value = clampedTranslate.x\n savedTranslateY.value = clampedTranslate.y\n })\n\n const panToDismissModalGesture = Gesture.Pan()\n .onUpdate(e => {\n const atDefaultScale = scale.value === DEFAULT_SCALE\n\n // Calculate zoom conditions for dismissing modal\n const atVerticalBoundry = isImageAtVerticalBoundry(scale.value)\n const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX)\n const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical\n\n if (atDefaultScale || canDismissWhileZoomed) {\n const panDistance = Math.abs(e.translationY)\n const fadeProgress = panDistance / DISMISS_PAN_THRESHOLD\n\n opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress)\n dismissY.value = e.translationY\n }\n })\n .onEnd(() => {\n const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD\n\n if (exceededDismissThreshold) {\n runOnJS(handleCloseModal)()\n } else {\n runOnJS(resetDismissGestures)()\n }\n })\n\n /* ==============================\n IMPLEMENT GESTURES & ANIMATIONS\n ================================= */\n // Race between pinch and pan ensures only one is active at a time, preventing issues with zooming to focal point between fingers\n const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture)\n\n // Race between double-tap and pinch/pan ensures that pinch/pan can't interrupt double-tap\n const transformImageGestures = Gesture.Race(doubleTapGesture, pinchOrPanGestures)\n\n // Dismiss can work simultaneously with all gestures\n const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture)\n\n const animatedImageStyles = useAnimatedStyle(() => ({\n transform: [\n { translateX: translateX.value },\n { translateY: translateY.value + dismissY.value },\n { scale: scale.value },\n ],\n opacity: opacity.value,\n }))\n\n return (\n <Modal visible={visible} transparent animationType=\"fade\" onRequestClose={handleCloseModal}>\n <SafeAreaView style={styles.modal}>\n <GestureHandlerRootView>\n <GestureDetector gesture={composedGesture}>\n <Image\n source={{ uri }}\n loadingBackgroundStyles={styles.lightboxImageLoading}\n style={styles.lightboxImage}\n animatedImageStyle={animatedImageStyles}\n resizeMode=\"contain\"\n animated={true}\n alt=\"\"\n />\n </GestureDetector>\n <View style={styles.actionToolbar} accessibilityRole=\"toolbar\">\n <View style={styles.actionToolbarTextMeta}>\n <Heading variant=\"h3\" style={styles.actionToolbarTitle} numberOfLines={1}>\n {authorName}\n </Heading>\n <Text variant=\"tertiary\" style={styles.actionToolbarSubtitle}>\n {formatDatePreview(createdAt)}\n </Text>\n </View>\n <IconButton\n onPress={handleOpenInBrowser}\n name=\"general.newWindow\"\n accessibilityRole=\"link\"\n accessibilityLabel=\"Open image in browser\"\n accessibilityHint=\"Image can be downloaded and shared through the browser.\"\n style={styles.actionButton}\n iconStyle={styles.actionButtonIcon}\n size=\"lg\"\n />\n <IconButton\n onPress={handleCloseModal}\n name=\"general.x\"\n accessibilityLabel=\"Close image\"\n style={styles.actionButton}\n iconStyle={styles.actionButtonIcon}\n />\n </View>\n </GestureHandlerRootView>\n </SafeAreaView>\n </Modal>\n )\n}\n\ninterface UseStylesProps {\n imageWidth?: number\n imageHeight?: number\n}\n\nconst useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {\n const backgroundColor = tokens.colorNeutral7\n const transparentBackgroundColor = useMemo(\n () => colorFunction(backgroundColor).alpha(0.8).toString(),\n [backgroundColor]\n )\n\n return StyleSheet.create({\n container: {\n maxWidth: '100%',\n },\n imageWrapper: {\n width: '100%',\n minWidth: 200,\n aspectRatio: imageWidth / imageHeight,\n },\n image: {\n borderRadius: 8,\n },\n modal: {\n flex: 1,\n backgroundColor,\n justifyContent: 'center',\n alignItems: 'center',\n },\n lightboxImage: {\n height: '100%',\n width: WINDOW_WIDTH,\n backgroundColor,\n },\n lightboxImageLoading: {\n backgroundColor,\n },\n actionToolbar: {\n width: '100%',\n position: 'absolute',\n top: 0,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 20,\n paddingHorizontal: 16,\n paddingTop: 16,\n paddingBottom: 8,\n backgroundColor: transparentBackgroundColor,\n },\n actionToolbarTextMeta: {\n flex: 1,\n },\n actionToolbarTitle: {\n marginRight: 'auto',\n flexShrink: 1,\n color: tokens.colorNeutral88,\n },\n actionToolbarSubtitle: {\n color: tokens.colorNeutral68,\n },\n actionButton: {\n backgroundColor,\n height: 40,\n width: 40,\n borderRadius: 50,\n borderWidth: 1,\n borderColor: tokens.colorNeutral24,\n },\n actionButtonIcon: {\n color: tokens.colorNeutral88,\n },\n })\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "3.11.0-rc.2",
3
+ "version": "3.11.0-rc.4",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -55,5 +55,5 @@
55
55
  "prettier": "^3.4.2",
56
56
  "typescript": "<5.6.0"
57
57
  },
58
- "gitHead": "e3035cb2dfb6434b91ecc562f115158a5c2ed051"
58
+ "gitHead": "070a5b47b238e467d37ee3721fcf618e68bc3301"
59
59
  }
@@ -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: {