@planningcenter/chat-react-native 3.12.0-rc.0 → 3.12.0-rc.10

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 (54) hide show
  1. package/build/components/conversation/attachments/attachment_card.js +1 -0
  2. package/build/components/conversation/attachments/attachment_card.js.map +1 -1
  3. package/build/components/conversation/attachments/image_attachment.d.ts +3 -1
  4. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  5. package/build/components/conversation/attachments/image_attachment.js +242 -95
  6. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  7. package/build/components/conversation/message_attachments.d.ts +1 -1
  8. package/build/components/conversation/message_attachments.d.ts.map +1 -1
  9. package/build/components/conversation/message_attachments.js +7 -13
  10. package/build/components/conversation/message_attachments.js.map +1 -1
  11. package/build/components/primitive/form_sheet.d.ts +7 -6
  12. package/build/components/primitive/form_sheet.d.ts.map +1 -1
  13. package/build/components/primitive/form_sheet.js +8 -8
  14. package/build/components/primitive/form_sheet.js.map +1 -1
  15. package/build/screens/conversation/message_read_receipts_screen.js +2 -2
  16. package/build/screens/conversation/message_read_receipts_screen.js.map +1 -1
  17. package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js +3 -3
  18. package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js.map +1 -1
  19. package/build/screens/conversation_filters/components/conversation_filters.d.ts.map +1 -1
  20. package/build/screens/conversation_filters/components/conversation_filters.js +2 -1
  21. package/build/screens/conversation_filters/components/conversation_filters.js.map +1 -1
  22. package/build/screens/conversation_filters/group_filters.d.ts.map +1 -1
  23. package/build/screens/conversation_filters/group_filters.js +2 -1
  24. package/build/screens/conversation_filters/group_filters.js.map +1 -1
  25. package/build/screens/conversation_filters/team_filters.d.ts.map +1 -1
  26. package/build/screens/conversation_filters/team_filters.js +2 -1
  27. package/build/screens/conversation_filters/team_filters.js.map +1 -1
  28. package/build/screens/conversation_filters_screen.d.ts +1 -2
  29. package/build/screens/conversation_filters_screen.d.ts.map +1 -1
  30. package/build/screens/conversation_filters_screen.js +32 -75
  31. package/build/screens/conversation_filters_screen.js.map +1 -1
  32. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  33. package/build/screens/conversation_new/components/groups_form.js +11 -6
  34. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  35. package/build/screens/conversation_new/components/services_form.js +1 -1
  36. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/components/conversation/attachments/attachment_card.tsx +1 -0
  39. package/src/components/conversation/attachments/image_attachment.tsx +426 -125
  40. package/src/components/conversation/message_attachments.tsx +7 -23
  41. package/src/components/primitive/form_sheet.tsx +22 -17
  42. package/src/screens/conversation/message_read_receipts_screen.tsx +2 -2
  43. package/src/screens/conversation_filter_recipients/conversation_filter_recipients_screen.tsx +3 -3
  44. package/src/screens/conversation_filters/components/conversation_filters.tsx +2 -1
  45. package/src/screens/conversation_filters/group_filters.tsx +2 -1
  46. package/src/screens/conversation_filters/team_filters.tsx +2 -1
  47. package/src/screens/conversation_filters_screen.tsx +36 -88
  48. package/src/screens/conversation_new/components/groups_form.tsx +12 -5
  49. package/src/screens/conversation_new/components/services_form.tsx +1 -1
  50. package/build/components/conversation/attachments/image_attachment_legacy.d.ts +0 -12
  51. package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +0 -1
  52. package/build/components/conversation/attachments/image_attachment_legacy.js +0 -142
  53. package/build/components/conversation/attachments/image_attachment_legacy.js.map +0 -1
  54. package/src/components/conversation/attachments/image_attachment_legacy.tsx +0 -258
@@ -29,6 +29,7 @@ const useStyles = () => {
29
29
  title: {
30
30
  color: colors.textColorDefaultPrimary,
31
31
  fontSize: tokens.fontSizeSm,
32
+ flex: 1,
32
33
  },
33
34
  });
34
35
  };
@@ -1 +1 @@
1
- {"version":3,"file":"attachment_card.js","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/attachment_card.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoC,MAAM,OAAO,CAAA;AACxD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAA;AACpC,OAAO,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAA;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAIzC,MAAM,UAAU,cAAc,CAAC,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAuB;IACxE,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,OAAO,CACL,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,CAClC;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,IAAI,CAAC,CACR,CAAA;AACH,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,EAAE,QAAQ,EAA2B;IACvE,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,OAAO,CACL,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,CAC7D;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,IAAI,CAAC,CACR,CAAA;AACH,CAAC;AAED,MAAM,SAAS,GAAG,GAAG,EAAE;IACrB,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,EAAE,CAAA;IAE7B,OAAO,UAAU,CAAC,MAAM,CAAC;QACvB,IAAI,EAAE;YACJ,OAAO,EAAE,CAAC;YACV,eAAe,EAAE,MAAM,CAAC,eAAe;YACvC,YAAY,EAAE,CAAC;YACf,QAAQ,EAAE,MAAM;YAChB,SAAS,EAAE,EAAE;YACb,cAAc,EAAE,QAAQ;SACzB;QACD,KAAK,EAAE;YACL,KAAK,EAAE,MAAM,CAAC,uBAAuB;YACrC,QAAQ,EAAE,MAAM,CAAC,UAAU;SAC5B;KACF,CAAC,CAAA;AACJ,CAAC,CAAA","sourcesContent":["import React, { ComponentProps, ReactNode } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Text } from '../../display'\nimport { tokens } from '../../../vendor/tapestry/tokens'\nimport { useTheme } from '../../../hooks'\n\ntype AttachmentCardProps = ComponentProps<typeof View>\n\nexport function AttachmentCard({ children, ...props }: AttachmentCardProps) {\n const styles = useStyles()\n\n return (\n <View style={styles.card} {...props}>\n {children}\n </View>\n )\n}\n\nexport function AttachmentCardTitle({ children }: { children: ReactNode }) {\n const styles = useStyles()\n\n return (\n <Text style={styles.title} numberOfLines={1} selectable={false}>\n {children}\n </Text>\n )\n}\n\nconst useStyles = () => {\n const { colors } = useTheme()\n\n return StyleSheet.create({\n card: {\n padding: 8,\n backgroundColor: colors.surfaceColor100,\n borderRadius: 8,\n minWidth: '100%',\n minHeight: 60,\n justifyContent: 'center',\n },\n title: {\n color: colors.textColorDefaultPrimary,\n fontSize: tokens.fontSizeSm,\n },\n })\n}\n"]}
1
+ {"version":3,"file":"attachment_card.js","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/attachment_card.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoC,MAAM,OAAO,CAAA;AACxD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAA;AACpC,OAAO,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAA;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAIzC,MAAM,UAAU,cAAc,CAAC,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAuB;IACxE,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,OAAO,CACL,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,CAClC;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,IAAI,CAAC,CACR,CAAA;AACH,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,EAAE,QAAQ,EAA2B;IACvE,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,OAAO,CACL,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,CAC7D;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,IAAI,CAAC,CACR,CAAA;AACH,CAAC;AAED,MAAM,SAAS,GAAG,GAAG,EAAE;IACrB,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,EAAE,CAAA;IAE7B,OAAO,UAAU,CAAC,MAAM,CAAC;QACvB,IAAI,EAAE;YACJ,OAAO,EAAE,CAAC;YACV,eAAe,EAAE,MAAM,CAAC,eAAe;YACvC,YAAY,EAAE,CAAC;YACf,QAAQ,EAAE,MAAM;YAChB,SAAS,EAAE,EAAE;YACb,cAAc,EAAE,QAAQ;SACzB;QACD,KAAK,EAAE;YACL,KAAK,EAAE,MAAM,CAAC,uBAAuB;YACrC,QAAQ,EAAE,MAAM,CAAC,UAAU;YAC3B,IAAI,EAAE,CAAC;SACR;KACF,CAAC,CAAA;AACJ,CAAC,CAAA","sourcesContent":["import React, { ComponentProps, ReactNode } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Text } from '../../display'\nimport { tokens } from '../../../vendor/tapestry/tokens'\nimport { useTheme } from '../../../hooks'\n\ntype AttachmentCardProps = ComponentProps<typeof View>\n\nexport function AttachmentCard({ children, ...props }: AttachmentCardProps) {\n const styles = useStyles()\n\n return (\n <View style={styles.card} {...props}>\n {children}\n </View>\n )\n}\n\nexport function AttachmentCardTitle({ children }: { children: ReactNode }) {\n const styles = useStyles()\n\n return (\n <Text style={styles.title} numberOfLines={1} selectable={false}>\n {children}\n </Text>\n )\n}\n\nconst useStyles = () => {\n const { colors } = useTheme()\n\n return StyleSheet.create({\n card: {\n padding: 8,\n backgroundColor: colors.surfaceColor100,\n borderRadius: 8,\n minWidth: '100%',\n minHeight: 60,\n justifyContent: 'center',\n },\n title: {\n color: colors.textColorDefaultPrimary,\n fontSize: tokens.fontSizeSm,\n flex: 1,\n },\n })\n}\n"]}
@@ -4,8 +4,10 @@ export type MetaProps = {
4
4
  authorName: string;
5
5
  createdAt: string;
6
6
  };
7
- export declare function ImageAttachment({ attachment, metaProps, onMessageAttachmentLongPress, }: {
7
+ export declare function ImageAttachment({ attachment, imageAttachments, currentImageIndex, metaProps, onMessageAttachmentLongPress, }: {
8
8
  attachment: DenormalizedMessageAttachmentResource;
9
+ imageAttachments: DenormalizedMessageAttachmentResource[];
10
+ currentImageIndex: number;
9
11
  metaProps: MetaProps;
10
12
  onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void;
11
13
  }): React.JSX.Element;
@@ -1 +1 @@
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;AAoB1E,OAAO,EAAE,qCAAqC,EAAE,MAAM,2DAA2D,CAAA;AAkBjH,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
+ {"version":3,"file":"image_attachment.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation/attachments/image_attachment.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmE,MAAM,OAAO,CAAA;AAkCvF,OAAO,EAAE,qCAAqC,EAAE,MAAM,2DAA2D,CAAA;AAwBjH,MAAM,MAAM,SAAS,GAAG;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,wBAAgB,eAAe,CAAC,EAC9B,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,SAAS,EACT,4BAA4B,GAC7B,EAAE;IACD,UAAU,EAAE,qCAAqC,CAAA;IACjD,gBAAgB,EAAE,qCAAqC,EAAE,CAAA;IACzD,iBAAiB,EAAE,MAAM,CAAA;IACzB,SAAS,EAAE,SAAS,CAAA;IACpB,4BAA4B,EAAE,CAAC,UAAU,EAAE,qCAAqC,KAAK,IAAI,CAAA;CAC1F,qBAyCA"}
@@ -1,17 +1,23 @@
1
- import React, { useMemo, useState } from 'react';
2
- import { StatusBar, StyleSheet, Modal, View, Linking, Dimensions } from 'react-native';
1
+ import React, { useMemo, useState, useCallback } from 'react';
2
+ import { StatusBar, StyleSheet, Modal, View, Linking, Dimensions, Platform, } from 'react-native';
3
3
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
- import { Gesture, GestureDetector, GestureHandlerRootView, Pressable, } from 'react-native-gesture-handler';
5
- import { runOnJS, useAnimatedStyle, useSharedValue, withSpring, withDecay, } from 'react-native-reanimated';
4
+ import { FlatList, Gesture, GestureDetector, GestureHandlerRootView, Pressable, } from 'react-native-gesture-handler';
5
+ import Animated, { runOnJS, useAnimatedStyle, useAnimatedReaction, useSharedValue, withSpring, withDecay, } from 'react-native-reanimated';
6
6
  import { tokens } from '../../../vendor/tapestry/tokens';
7
7
  import { IconButton, Image, Heading, Text } from '../../display';
8
8
  import colorFunction from 'color';
9
9
  import { formatDatePreview } from '../../../utils/date';
10
10
  import { PlatformPressable } from '@react-navigation/elements';
11
11
  import { useTheme } from '../../../hooks';
12
+ import { platformFontWeightMedium } from '../../../utils';
12
13
  const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window');
13
- const DISMISS_PAN_THRESHOLD = 300;
14
+ const DISMISS_PAN_THRESHOLD = 250;
15
+ const MIN_DISTANCE_FOR_PAN = 10; // Higher threshold gives pinching priority
16
+ const SINGLE_FINGER_POINTER = 1; // Single-finger panning / tapping helps to avoid conflicts with pinching
17
+ const MAX_TRAVEL_DISTANCE_FOR_DOUBLE_TAP_PLATFORM = Platform.OS === 'ios' ? 8 : 0; // Causes taps to be unreliable on Android
14
18
  const DEFAULT_OPACITY = 1;
19
+ const DEFAULT_TRANSLATE_X = 0;
20
+ const DEFAULT_TRANSLATE_Y = 0;
15
21
  const DEFAULT_SCALE = 1;
16
22
  const MIN_SCALE = 0.5;
17
23
  const MAX_SCALE = 5;
@@ -22,65 +28,104 @@ const RESET_SPRING_CONFIG = {
22
28
  damping: 20,
23
29
  stiffness: 150,
24
30
  };
25
- export function ImageAttachment({ attachment, metaProps, onMessageAttachmentLongPress, }) {
31
+ export function ImageAttachment({ attachment, imageAttachments, currentImageIndex, metaProps, onMessageAttachmentLongPress, }) {
26
32
  const { attributes } = attachment;
27
33
  const { url, urlMedium, filename, metadata = {} } = attributes;
28
34
  const { colors } = useTheme();
29
35
  const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height });
30
36
  const [visible, setVisible] = useState(false);
37
+ // Force modal to remount with fresh state
38
+ // Fixes a bug where dismissing the modal too quickly causes the Reanimated shared values (like toolbarVisible) to not reset.
39
+ const [modalKey, setModalKey] = useState(0);
31
40
  return (<>
32
- <PlatformPressable style={styles.container} onPress={() => setVisible(true)} onLongPress={() => onMessageAttachmentLongPress(attachment)} android_ripple={{ color: colors.androidRippleNeutral, foreground: true }} accessibilityHint="Long press for more options">
33
- <Image source={{ uri: urlMedium || url }} style={styles.image} wrapperStyle={styles.imageWrapper} alt={filename}/>
41
+ <PlatformPressable style={styles.container} onPress={() => {
42
+ setModalKey(prev => prev + 1);
43
+ setVisible(true);
44
+ }} onLongPress={() => onMessageAttachmentLongPress(attachment)} android_ripple={{ color: colors.androidRippleNeutral, foreground: true }} accessibilityHint="Long press for more options">
45
+ <Image source={{ uri: urlMedium || url }} style={styles.image} wrapperStyle={styles.attachmentImageWrapper} alt={filename}/>
34
46
  </PlatformPressable>
35
- <LightboxModal visible={visible} setModalVisible={setVisible} uri={urlMedium || url} metaProps={metaProps} imageWidth={metadata.width} imageHeight={metadata.height}/>
47
+ <LightboxModal key={modalKey} visible={visible} setModalVisible={setVisible} imageAttachments={imageAttachments} initialImageIndex={currentImageIndex} metaProps={metaProps}/>
36
48
  </>);
37
49
  }
38
- const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, imageHeight, }) => {
50
+ const LightboxModal = ({ visible, setModalVisible, imageAttachments, initialImageIndex, metaProps, }) => {
39
51
  const styles = useStyles();
40
52
  const insets = useSafeAreaInsets();
41
- const { authorName, createdAt } = metaProps;
53
+ const [currentImageIndex, setCurrentImageIndex] = useState(initialImageIndex);
54
+ // Get current image data
55
+ const currentImage = imageAttachments[currentImageIndex];
56
+ const { url, urlMedium, metadata = {} } = currentImage.attributes;
57
+ const imageWidth = metadata.width;
58
+ const imageHeight = metadata.height;
42
59
  // Calculate available space for image display
43
60
  const availableWindowWidth = WINDOW_WIDTH;
44
61
  const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom;
45
62
  /* ============================
46
63
  ANIMATION VALUES
47
64
  ============================ */
65
+ // Native State:
48
66
  const dismissY = useSharedValue(0); // vertical distance to dismiss modal
49
67
  const opacity = useSharedValue(DEFAULT_OPACITY); // opacity of modal
50
68
  const scale = useSharedValue(DEFAULT_SCALE); // zoom level of image
51
69
  const focalX = useSharedValue(0); // focal point of image between fingers
52
70
  const focalY = useSharedValue(0); // focal point of image between fingers
53
- const translateX = useSharedValue(0); // horizontal distance to pan image
54
- const translateY = useSharedValue(0); // vertical distance to pan image
71
+ const translateX = useSharedValue(DEFAULT_TRANSLATE_X); // horizontal distance to pan image
72
+ const translateY = useSharedValue(DEFAULT_TRANSLATE_Y); // vertical distance to pan image
55
73
  const savedScale = useSharedValue(DEFAULT_SCALE); // previous zoom level
56
- const savedTranslateX = useSharedValue(0); // previous horizontal position
57
- const savedTranslateY = useSharedValue(0); // previous vertical position
74
+ const savedTranslateX = useSharedValue(DEFAULT_TRANSLATE_X); // previous horizontal position
75
+ const savedTranslateY = useSharedValue(DEFAULT_TRANSLATE_Y); // previous vertical position
76
+ const lightboxToolbarVisible = useSharedValue(1); // toolbar visibility state
77
+ // React (JS) State:
78
+ const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
79
+ const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true);
80
+ const [panGestureEnabled, setPanGestureEnabled] = useState(false);
81
+ // Syncs toolbar useSharedValue state with React's state so that the status bar can be hidden/shown based on the toolbar's animation
82
+ useAnimatedReaction(() => lightboxToolbarVisible.value, value => {
83
+ runOnJS(setIsStatusBarHidden)(value === 0);
84
+ });
85
+ // Syncs FlatList scroll state with scale changes
86
+ // When image is at default scale, enable scroll and disable pan gesture
87
+ // When image is zoomed in, disable scroll and enable pan gesture
88
+ useAnimatedReaction(() => scale.value, value => {
89
+ const enableFlatListScroll = value === DEFAULT_SCALE;
90
+ runOnJS(setFlatListScrollEnabled)(enableFlatListScroll);
91
+ runOnJS(setPanGestureEnabled)(!enableFlatListScroll);
92
+ });
58
93
  /* ============================
59
94
  HANDLERS
60
95
  ============================ */
61
- const handleOpenInBrowser = () => {
62
- Linking.openURL(uri);
63
- };
64
- const resetDismissGestures = () => {
96
+ const handleOpenInBrowser = useCallback(() => {
97
+ Linking.openURL(urlMedium || url);
98
+ }, [urlMedium, url]);
99
+ const resetDismissGestures = useCallback(() => {
65
100
  dismissY.value = withSpring(0, RESET_SPRING_CONFIG);
66
101
  opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG);
67
- };
68
- const resetAllGestures = () => {
102
+ }, [dismissY, opacity]);
103
+ const resetAllGestures = useCallback(() => {
69
104
  resetDismissGestures();
70
105
  scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG);
71
- translateX.value = withSpring(0, RESET_SPRING_CONFIG);
72
- translateY.value = withSpring(0, RESET_SPRING_CONFIG);
106
+ translateX.value = withSpring(DEFAULT_TRANSLATE_X, RESET_SPRING_CONFIG);
107
+ translateY.value = withSpring(DEFAULT_TRANSLATE_Y, RESET_SPRING_CONFIG);
73
108
  savedScale.value = DEFAULT_SCALE;
74
- savedTranslateX.value = 0;
75
- savedTranslateY.value = 0;
76
- };
77
- const handleCloseModal = () => {
109
+ savedTranslateX.value = DEFAULT_TRANSLATE_X;
110
+ savedTranslateY.value = DEFAULT_TRANSLATE_Y;
111
+ lightboxToolbarVisible.value = withSpring(1, RESET_SPRING_CONFIG);
112
+ }, [
113
+ resetDismissGestures,
114
+ scale,
115
+ translateX,
116
+ translateY,
117
+ savedScale,
118
+ savedTranslateX,
119
+ savedTranslateY,
120
+ lightboxToolbarVisible,
121
+ ]);
122
+ const handleCloseModal = useCallback(() => {
78
123
  setModalVisible(false);
79
124
  resetAllGestures();
80
- };
125
+ }, [setModalVisible, resetAllGestures]);
81
126
  /* ============================
82
- UTILITY FUNCTIONS
83
- 'worklet' runs functions on the UI thread, instead of the JS thread for better performance
127
+ UTILITY WORKLET FUNCTIONS
128
+ 'worklet' runs functions on the UI thread, instead of the JS thread for better performance.
84
129
  ============================ */
85
130
  const getImageContainedToWindowDimensions = () => {
86
131
  'worklet';
@@ -129,8 +174,8 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
129
174
  const excessWidth = scaledWidth - availableWindowWidth;
130
175
  const excessHeight = scaledHeight - availableWindowHeight;
131
176
  // How far the image can move in each direction before hitting window edges
132
- const maxTranslateX = Math.max(0, excessWidth / 2);
133
- const maxTranslateY = Math.max(0, excessHeight / 2);
177
+ const maxTranslateX = Math.max(DEFAULT_TRANSLATE_X, excessWidth / 2);
178
+ const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2);
134
179
  return { maxTranslateX, maxTranslateY };
135
180
  };
136
181
  const clampDecay = (currentScale) => {
@@ -155,17 +200,62 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
155
200
  // Calculate how much the image can exceed the window container
156
201
  const scaledHeight = containedImageHeight * currentScale;
157
202
  const excessHeight = scaledHeight - availableWindowHeight;
158
- const maxTranslateY = Math.max(0, excessHeight / 2);
203
+ const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2);
159
204
  const currentTranslateY = translateY.value;
160
205
  const panPositionTolerance = 1; // buffer to account for translateY being at a subpixel position
161
206
  const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance;
162
207
  const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance;
163
208
  return atTopBoundry || atBottomBoundry;
164
209
  };
210
+ /* ============================
211
+ UTILITY FLATLIST FUNCTIONS
212
+ Supports the image gallery layout and swipe functionality.
213
+ ============================ */
214
+ // Used in tandem with FlatList's initialScrollIndex to quickly calculate the position and size of each image before they load.
215
+ const getItemLayout = useCallback((_, index) => ({
216
+ length: WINDOW_WIDTH,
217
+ offset: WINDOW_WIDTH * index,
218
+ index,
219
+ }), []);
220
+ // Captures the current image's index after the FlatList finishes its scroll animation.
221
+ // Used in tandem with onViewableItemsChanged to ensure the final value for currentImageIndex is set.
222
+ const onMomentumScrollEnd = useCallback((event) => {
223
+ // Calculate the index of the image that is currently visible
224
+ const imageOffsetX = event.nativeEvent.contentOffset.x;
225
+ const newImageIndex = Math.round(imageOffsetX / WINDOW_WIDTH);
226
+ // Check if the image index has changed and the FlatList didn't scroll past the first or last image
227
+ const didImageIndexChange = newImageIndex !== currentImageIndex;
228
+ const isImageIndexWithinBounds = newImageIndex >= 0 && newImageIndex < imageAttachments.length;
229
+ if (didImageIndexChange && isImageIndexWithinBounds) {
230
+ setCurrentImageIndex(newImageIndex);
231
+ }
232
+ }, [currentImageIndex, imageAttachments.length]);
233
+ // Supplements onMomentumScrollEnd by capturing the current image's index while the FlatList is actively scrolling.
234
+ // Used in tandem with viewabilityConfig to trigger when the image is 50% visible in the window.
235
+ const onViewableItemsChanged = useCallback(({ viewableItems }) => {
236
+ if (viewableItems.length === 0)
237
+ return;
238
+ // Use the first viewable item which is enforced by the FlatList's pagingEnabled prop that allows only two images to be visible at a time when scrolling.
239
+ const firstViewableItem = viewableItems[0];
240
+ const newIndex = firstViewableItem.index;
241
+ if (newIndex !== null && newIndex !== currentImageIndex) {
242
+ setCurrentImageIndex(newIndex);
243
+ }
244
+ }, [currentImageIndex]);
165
245
  /* ============================
166
246
  GESTURES
167
247
  ============================ */
248
+ const singleTapGesture = Gesture.Tap()
249
+ .minPointers(SINGLE_FINGER_POINTER)
250
+ .numberOfTaps(1)
251
+ .onStart(() => {
252
+ lightboxToolbarVisible.value = withSpring(lightboxToolbarVisible.value > 0.5 ? 0 : 1, RESET_SPRING_CONFIG);
253
+ });
168
254
  const doubleTapGesture = Gesture.Tap()
255
+ .maxDeltaX(MAX_TRAVEL_DISTANCE_FOR_DOUBLE_TAP_PLATFORM)
256
+ .maxDeltaY(MAX_TRAVEL_DISTANCE_FOR_DOUBLE_TAP_PLATFORM)
257
+ .maxDistance(MAX_TRAVEL_DISTANCE_FOR_DOUBLE_TAP_PLATFORM)
258
+ .minPointers(SINGLE_FINGER_POINTER)
169
259
  .numberOfTaps(2)
170
260
  .onStart(e => {
171
261
  const isZoomedIn = scale.value > DEFAULT_SCALE;
@@ -173,6 +263,8 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
173
263
  runOnJS(resetAllGestures)();
174
264
  }
175
265
  else {
266
+ // Hide toolbar when starting to zoom
267
+ lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
176
268
  // Zoom to 2x at tap location
177
269
  const newTranslation = zoomToFocalPoint({
178
270
  focalPointX: e.x,
@@ -202,6 +294,8 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
202
294
  .onStart(e => {
203
295
  focalX.value = e.focalX;
204
296
  focalY.value = e.focalY;
297
+ // Hide toolbar when starting to zoom
298
+ lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
205
299
  // Ensure that pinch accounts for the decay animation and starts from the true current position.
206
300
  savedTranslateX.value = translateX.value;
207
301
  savedTranslateY.value = translateY.value;
@@ -264,6 +358,10 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
264
358
  savedTranslateY.value = clampedTranslate.y;
265
359
  });
266
360
  const panGesture = Gesture.Pan()
361
+ .minDistance(MIN_DISTANCE_FOR_PAN)
362
+ .minPointers(SINGLE_FINGER_POINTER)
363
+ .maxPointers(SINGLE_FINGER_POINTER)
364
+ .enabled(panGestureEnabled)
267
365
  .onStart(() => {
268
366
  // Update saved position to current animated position
269
367
  // This ensures smooth continuation if decay is running
@@ -316,12 +414,15 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
316
414
  const atVerticalBoundry = isImageAtVerticalBoundry(scale.value);
317
415
  const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX);
318
416
  const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical;
319
- if (atDefaultScale || canDismissWhileZoomed) {
320
- const panDistance = Math.abs(e.translationY);
321
- const fadeProgress = panDistance / DISMISS_PAN_THRESHOLD;
322
- opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress);
323
- dismissY.value = e.translationY;
324
- }
417
+ if (!(atDefaultScale || canDismissWhileZoomed))
418
+ return;
419
+ // Fade image if its been panned past 50% of the dismiss threshold
420
+ const panDistance = Math.abs(e.translationY);
421
+ const halfThreshold = DISMISS_PAN_THRESHOLD / 2;
422
+ const fadeDistance = Math.max(0, panDistance - halfThreshold);
423
+ const fadeProgress = fadeDistance / halfThreshold;
424
+ opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress);
425
+ dismissY.value = e.translationY;
325
426
  })
326
427
  .onEnd(() => {
327
428
  const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD;
@@ -333,45 +434,29 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
333
434
  }
334
435
  });
335
436
  /* ==============================
336
- IMPLEMENT GESTURES & ANIMATIONS
437
+ COMPOSE GESTURES
337
438
  ================================= */
338
- // Race between pinch and pan ensures only one is active at a time, preventing issues with zooming to focal point between fingers
439
+ // Race between pinch and pan ensures only one is active at a time, preserving focal point logic
339
440
  const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture);
340
- // Race between double-tap and pinch/pan ensures that pinch/pan can't interrupt double-tap
341
- const transformImageGestures = Gesture.Race(doubleTapGesture, pinchOrPanGestures);
441
+ // Exclusive race ensures single tap doesn't interfere with double tap
442
+ const tapGestures = Gesture.Exclusive(doubleTapGesture, singleTapGesture);
443
+ // Race between tap gestures and pinch/pan
444
+ const transformImageGestures = Gesture.Race(tapGestures, pinchOrPanGestures);
342
445
  // Dismiss can work simultaneously with all gestures
343
446
  const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture);
344
- const animatedImageStyles = useAnimatedStyle(() => ({
345
- transform: [
346
- { translateX: translateX.value },
347
- { translateY: translateY.value + dismissY.value },
348
- { scale: scale.value },
349
- ],
350
- opacity: opacity.value,
351
- }));
352
447
  return (<Modal visible={visible} transparent animationType="fade" onRequestClose={handleCloseModal}>
353
- <StatusBar barStyle="light-content"/>
354
- <View style={styles.lightboxModalSafeArea}>
355
- <GestureHandlerRootView>
356
- <GestureDetector gesture={composedGesture}>
357
- <PreventPressEventsBubbling>
358
- <Image source={{ uri }} loadingBackgroundStyles={styles.lightboxImageLoading} style={styles.lightboxImage} animatedImageStyle={animatedImageStyles} resizeMode="contain" animated={true} alt=""/>
359
- </PreventPressEventsBubbling>
360
- </GestureDetector>
361
- </GestureHandlerRootView>
362
- </View>
363
- <View style={styles.actionToolbar} accessibilityRole="toolbar">
364
- <View style={styles.actionToolbarTextMeta}>
365
- <Heading variant="h3" style={styles.actionToolbarTitle} numberOfLines={1}>
366
- {authorName}
367
- </Heading>
368
- <Text variant="tertiary" style={styles.actionToolbarSubtitle}>
369
- {formatDatePreview(createdAt)}
370
- </Text>
371
- </View>
372
- <IconButton onPress={handleOpenInBrowser} name="general.newWindow" accessibilityRole="link" accessibilityLabel="Open image in browser" accessibilityHint="Image can be downloaded and shared through the browser." style={styles.actionButton} iconStyle={styles.actionButtonIcon} size="lg"/>
373
- <IconButton onPress={handleCloseModal} name="general.x" accessibilityLabel="Close image" style={styles.actionButton} iconStyle={styles.actionButtonIcon} size="lg"/>
374
- </View>
448
+ <StatusBar barStyle="light-content" hidden={isStatusBarHidden} animated showHideTransition="slide"/>
449
+ <GestureHandlerRootView>
450
+ <GestureDetector gesture={composedGesture}>
451
+ <PreventPressEventsBubbling>
452
+ <FlatList data={imageAttachments} renderItem={({ item, index }) => (<GestureImage item={item} gesturesEnabled={index === currentImageIndex} scale={scale} translateX={translateX} translateY={translateY} dismissY={dismissY} opacity={opacity}/>)} keyExtractor={(item, index) => `${item.id}-${index}`} horizontal pagingEnabled scrollEnabled={flatListScrollEnabled} showsHorizontalScrollIndicator={false} initialScrollIndex={initialImageIndex} getItemLayout={getItemLayout} onMomentumScrollEnd={onMomentumScrollEnd} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={{
453
+ itemVisiblePercentThreshold: 50, // 50% of the image must be visible in the window to be considered viewable
454
+ }} style={styles.gallery} contentContainerStyle={styles.galleryContentContainer}/>
455
+ </PreventPressEventsBubbling>
456
+ </GestureDetector>
457
+ </GestureHandlerRootView>
458
+ <LightboxToolbarHeader metaProps={metaProps} lightboxToolbarVisible={lightboxToolbarVisible} isStatusBarHidden={isStatusBarHidden} handleOpenInBrowser={handleOpenInBrowser} handleCloseModal={handleCloseModal}/>
459
+ {imageAttachments.length > 1 && (<LightboxToolbarFooter lightboxToolbarVisible={lightboxToolbarVisible} totalImages={imageAttachments.length} currentImageIndex={currentImageIndex}/>)}
375
460
  </Modal>);
376
461
  };
377
462
  const PreventPressEventsBubbling = ({ children }) => {
@@ -379,6 +464,57 @@ const PreventPressEventsBubbling = ({ children }) => {
379
464
  {children}
380
465
  </Pressable>);
381
466
  };
467
+ const GestureImage = ({ item, gesturesEnabled, scale, translateX, translateY, dismissY, opacity, }) => {
468
+ const styles = useStyles();
469
+ const { url: itemUrl, urlMedium: itemUrlMedium } = item.attributes;
470
+ const animatedImageStyles = useAnimatedStyle(() => {
471
+ return {
472
+ transform: [
473
+ { translateX: gesturesEnabled ? translateX.value : DEFAULT_TRANSLATE_X },
474
+ { translateY: gesturesEnabled ? translateY.value + dismissY.value : DEFAULT_TRANSLATE_Y },
475
+ { scale: gesturesEnabled ? scale.value : DEFAULT_SCALE },
476
+ ],
477
+ opacity: opacity.value,
478
+ };
479
+ });
480
+ return (<Image source={{ uri: itemUrlMedium || itemUrl }} style={styles.gestureImage} animatedImageStyle={animatedImageStyles} loadingBackgroundStyles={styles.gestureImageLoading} resizeMode="contain" alt=""/>);
481
+ };
482
+ const LightboxToolbarHeader = ({ metaProps, lightboxToolbarVisible, isStatusBarHidden, handleOpenInBrowser, handleCloseModal, }) => {
483
+ const styles = useStyles();
484
+ const { authorName, createdAt } = metaProps;
485
+ const animatedHeaderStyles = useAnimatedStyle(() => ({
486
+ opacity: lightboxToolbarVisible.value,
487
+ transform: [
488
+ { translateY: (1 - lightboxToolbarVisible.value) * -20 }, // slide up when hiding
489
+ ],
490
+ }));
491
+ return (<Animated.View style={[styles.lightboxToolbar, styles.lightboxToolbarHeader, animatedHeaderStyles]} accessibilityRole="toolbar">
492
+ <View style={styles.lightboxToolbarHeaderMetaContainer}>
493
+ <Heading variant="h3" style={styles.lightboxToolbarHeaderTitle} numberOfLines={1}>
494
+ {authorName}
495
+ </Heading>
496
+ <Text variant="tertiary" style={styles.lightboxToolbarHeaderSubtitle}>
497
+ {formatDatePreview(createdAt)}
498
+ </Text>
499
+ </View>
500
+ <IconButton onPress={handleOpenInBrowser} disabled={isStatusBarHidden} name="general.newWindow" accessibilityRole="link" accessibilityLabel="Open image in browser" accessibilityHint="Image can be downloaded and shared through the browser." style={styles.lightboxToolbarButton} iconStyle={styles.lightboxToolbarButtonIcon} size="lg"/>
501
+ <IconButton onPress={handleCloseModal} disabled={isStatusBarHidden} name="general.x" accessibilityLabel="Close image" style={styles.lightboxToolbarButton} iconStyle={styles.lightboxToolbarButtonIcon} size="lg"/>
502
+ </Animated.View>);
503
+ };
504
+ const LightboxToolbarFooter = ({ lightboxToolbarVisible, totalImages, currentImageIndex, }) => {
505
+ const styles = useStyles();
506
+ const animatedFooterStyles = useAnimatedStyle(() => ({
507
+ opacity: lightboxToolbarVisible.value,
508
+ transform: [
509
+ { translateY: (1 - lightboxToolbarVisible.value) * 20 }, // slide down when showing
510
+ ],
511
+ }));
512
+ return (<Animated.View style={[styles.lightboxToolbar, styles.lightboxToolbarFooter, animatedFooterStyles]} accessibilityRole="toolbar">
513
+ <Text style={styles.lightboxToolbarFooterText}>
514
+ {currentImageIndex + 1} of {totalImages}
515
+ </Text>
516
+ </Animated.View>);
517
+ };
382
518
  const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
383
519
  const { top, bottom } = useSafeAreaInsets();
384
520
  const backgroundColor = tokens.colorNeutral7;
@@ -387,7 +523,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
387
523
  container: {
388
524
  maxWidth: '100%',
389
525
  },
390
- imageWrapper: {
526
+ attachmentImageWrapper: {
391
527
  width: '100%',
392
528
  minWidth: 200,
393
529
  aspectRatio: imageWidth / imageHeight,
@@ -395,55 +531,66 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
395
531
  image: {
396
532
  borderRadius: 8,
397
533
  },
398
- lightboxModalSafeArea: {
399
- flex: 1,
534
+ gallery: {
400
535
  backgroundColor,
401
- justifyContent: 'center',
402
- alignItems: 'center',
536
+ },
537
+ galleryContentContainer: {
403
538
  paddingTop: top,
404
539
  paddingBottom: bottom,
405
540
  },
406
- lightboxImage: {
541
+ gestureImage: {
407
542
  height: '100%',
408
543
  width: WINDOW_WIDTH,
409
- backgroundColor,
544
+ backgroundColor: 'transparent',
410
545
  },
411
- lightboxImageLoading: {
546
+ gestureImageLoading: {
412
547
  backgroundColor,
413
548
  },
414
- actionToolbar: {
549
+ lightboxToolbar: {
415
550
  width: '100%',
416
551
  position: 'absolute',
417
- top: 0,
418
552
  flexDirection: 'row',
419
553
  alignItems: 'center',
420
554
  gap: 20,
421
555
  paddingHorizontal: 16,
556
+ backgroundColor: transparentBackgroundColor,
557
+ },
558
+ lightboxToolbarButton: {
559
+ backgroundColor,
560
+ height: 40,
561
+ width: 40,
562
+ borderRadius: 50,
563
+ borderWidth: 1,
564
+ borderColor: tokens.colorNeutral24,
565
+ },
566
+ lightboxToolbarButtonIcon: {
567
+ color: tokens.colorNeutral88,
568
+ },
569
+ lightboxToolbarHeader: {
570
+ top: 0,
422
571
  paddingTop: top + 16,
423
572
  paddingBottom: 8,
424
- backgroundColor: transparentBackgroundColor,
425
573
  },
426
- actionToolbarTextMeta: {
574
+ lightboxToolbarHeaderMetaContainer: {
427
575
  flex: 1,
428
576
  },
429
- actionToolbarTitle: {
577
+ lightboxToolbarHeaderTitle: {
430
578
  marginRight: 'auto',
431
579
  flexShrink: 1,
432
580
  color: tokens.colorNeutral88,
433
581
  },
434
- actionToolbarSubtitle: {
582
+ lightboxToolbarHeaderSubtitle: {
435
583
  color: tokens.colorNeutral68,
436
584
  },
437
- actionButton: {
438
- backgroundColor,
439
- height: 40,
440
- width: 40,
441
- borderRadius: 50,
442
- borderWidth: 1,
443
- borderColor: tokens.colorNeutral24,
585
+ lightboxToolbarFooter: {
586
+ justifyContent: 'center',
587
+ bottom: 0,
588
+ paddingTop: 8,
589
+ paddingBottom: bottom + 16,
444
590
  },
445
- actionButtonIcon: {
591
+ lightboxToolbarFooterText: {
446
592
  color: tokens.colorNeutral88,
593
+ fontWeight: platformFontWeightMedium,
447
594
  },
448
595
  });
449
596
  };