@planningcenter/chat-react-native 3.12.0-rc.1 → 3.12.0-rc.11

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 (68) 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 +223 -107
  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/display/index.d.ts +1 -0
  12. package/build/components/display/index.d.ts.map +1 -1
  13. package/build/components/display/index.js +1 -0
  14. package/build/components/display/index.js.map +1 -1
  15. package/build/components/primitive/form_sheet.d.ts +7 -6
  16. package/build/components/primitive/form_sheet.d.ts.map +1 -1
  17. package/build/components/primitive/form_sheet.js +8 -8
  18. package/build/components/primitive/form_sheet.js.map +1 -1
  19. package/build/hooks/use_report_bug_action.d.ts +1 -0
  20. package/build/hooks/use_report_bug_action.d.ts.map +1 -1
  21. package/build/hooks/use_report_bug_action.js +2 -1
  22. package/build/hooks/use_report_bug_action.js.map +1 -1
  23. package/build/screens/bug_report_screen.d.ts.map +1 -1
  24. package/build/screens/bug_report_screen.js +216 -46
  25. package/build/screens/bug_report_screen.js.map +1 -1
  26. package/build/screens/conversation/message_read_receipts_screen.js +2 -2
  27. package/build/screens/conversation/message_read_receipts_screen.js.map +1 -1
  28. package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js +3 -3
  29. package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js.map +1 -1
  30. package/build/screens/conversation_filters/components/conversation_filters.d.ts.map +1 -1
  31. package/build/screens/conversation_filters/components/conversation_filters.js +2 -1
  32. package/build/screens/conversation_filters/components/conversation_filters.js.map +1 -1
  33. package/build/screens/conversation_filters/group_filters.d.ts.map +1 -1
  34. package/build/screens/conversation_filters/group_filters.js +2 -1
  35. package/build/screens/conversation_filters/group_filters.js.map +1 -1
  36. package/build/screens/conversation_filters/team_filters.d.ts.map +1 -1
  37. package/build/screens/conversation_filters/team_filters.js +2 -1
  38. package/build/screens/conversation_filters/team_filters.js.map +1 -1
  39. package/build/screens/conversation_filters_screen.d.ts +1 -2
  40. package/build/screens/conversation_filters_screen.d.ts.map +1 -1
  41. package/build/screens/conversation_filters_screen.js +32 -75
  42. package/build/screens/conversation_filters_screen.js.map +1 -1
  43. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  44. package/build/screens/conversation_new/components/groups_form.js +11 -6
  45. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  46. package/build/screens/conversation_new/components/services_form.js +1 -1
  47. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/components/conversation/attachments/attachment_card.tsx +1 -0
  50. package/src/components/conversation/attachments/image_attachment.tsx +393 -141
  51. package/src/components/conversation/message_attachments.tsx +7 -23
  52. package/src/components/display/index.ts +1 -0
  53. package/src/components/primitive/form_sheet.tsx +22 -17
  54. package/src/hooks/use_report_bug_action.ts +3 -0
  55. package/src/screens/bug_report_screen.tsx +302 -80
  56. package/src/screens/conversation/message_read_receipts_screen.tsx +2 -2
  57. package/src/screens/conversation_filter_recipients/conversation_filter_recipients_screen.tsx +3 -3
  58. package/src/screens/conversation_filters/components/conversation_filters.tsx +2 -1
  59. package/src/screens/conversation_filters/group_filters.tsx +2 -1
  60. package/src/screens/conversation_filters/team_filters.tsx +2 -1
  61. package/src/screens/conversation_filters_screen.tsx +36 -88
  62. package/src/screens/conversation_new/components/groups_form.tsx +12 -5
  63. package/src/screens/conversation_new/components/services_form.tsx +1 -1
  64. package/build/components/conversation/attachments/image_attachment_legacy.d.ts +0 -12
  65. package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +0 -1
  66. package/build/components/conversation/attachments/image_attachment_legacy.js +0 -142
  67. package/build/components/conversation/attachments/image_attachment_legacy.js.map +0 -1
  68. 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;AAqB1E,OAAO,EAAE,qCAAqC,EAAE,MAAM,2DAA2D,CAAA;AAoBjH,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,7 +1,7 @@
1
- import React, { useMemo, useState } from 'react';
2
- import { StatusBar, StyleSheet, Modal, View, Linking, Dimensions, Platform } 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';
4
+ import { FlatList, Gesture, GestureDetector, GestureHandlerRootView, Pressable, } from 'react-native-gesture-handler';
5
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';
@@ -9,11 +9,15 @@ 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 MIN_DISTANCE_PAN_FOR_PLATFORM = Platform.OS === 'android' ? 5 : 0; // Android requires a higher threshold to give pinching priority
15
- const POINTER_FOR_SINGLE_FINGER_PAN = 1; // Single-finger panning helps to avoid conflicts with pinching
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
16
18
  const DEFAULT_OPACITY = 1;
19
+ const DEFAULT_TRANSLATE_X = 0;
20
+ const DEFAULT_TRANSLATE_Y = 0;
17
21
  const DEFAULT_SCALE = 1;
18
22
  const MIN_SCALE = 0.5;
19
23
  const MAX_SCALE = 5;
@@ -24,23 +28,34 @@ const RESET_SPRING_CONFIG = {
24
28
  damping: 20,
25
29
  stiffness: 150,
26
30
  };
27
- export function ImageAttachment({ attachment, metaProps, onMessageAttachmentLongPress, }) {
31
+ export function ImageAttachment({ attachment, imageAttachments, currentImageIndex, metaProps, onMessageAttachmentLongPress, }) {
28
32
  const { attributes } = attachment;
29
33
  const { url, urlMedium, filename, metadata = {} } = attributes;
30
34
  const { colors } = useTheme();
31
35
  const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height });
32
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);
33
40
  return (<>
34
- <PlatformPressable style={styles.container} onPress={() => setVisible(true)} onLongPress={() => onMessageAttachmentLongPress(attachment)} android_ripple={{ color: colors.androidRippleNeutral, foreground: true }} accessibilityHint="Long press for more options">
35
- <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}/>
36
46
  </PlatformPressable>
37
- <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}/>
38
48
  </>);
39
49
  }
40
- const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, imageHeight, }) => {
50
+ const LightboxModal = ({ visible, setModalVisible, imageAttachments, initialImageIndex, metaProps, }) => {
41
51
  const styles = useStyles();
42
52
  const insets = useSafeAreaInsets();
43
- 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;
44
59
  // Calculate available space for image display
45
60
  const availableWindowWidth = WINDOW_WIDTH;
46
61
  const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom;
@@ -53,45 +68,64 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
53
68
  const scale = useSharedValue(DEFAULT_SCALE); // zoom level of image
54
69
  const focalX = useSharedValue(0); // focal point of image between fingers
55
70
  const focalY = useSharedValue(0); // focal point of image between fingers
56
- const translateX = useSharedValue(0); // horizontal distance to pan image
57
- 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
58
73
  const savedScale = useSharedValue(DEFAULT_SCALE); // previous zoom level
59
- const savedTranslateX = useSharedValue(0); // previous horizontal position
60
- const savedTranslateY = useSharedValue(0); // previous vertical position
61
- const toolbarVisible = useSharedValue(1); // toolbar visibility state
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
62
77
  // React (JS) State:
63
78
  const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
79
+ const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true);
80
+ const [panGestureEnabled, setPanGestureEnabled] = useState(false);
64
81
  // Syncs toolbar useSharedValue state with React's state so that the status bar can be hidden/shown based on the toolbar's animation
65
- useAnimatedReaction(() => toolbarVisible.value, value => {
82
+ useAnimatedReaction(() => lightboxToolbarVisible.value, value => {
66
83
  runOnJS(setIsStatusBarHidden)(value === 0);
67
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
+ });
68
93
  /* ============================
69
94
  HANDLERS
70
95
  ============================ */
71
- const handleOpenInBrowser = () => {
72
- Linking.openURL(uri);
73
- };
74
- const resetDismissGestures = () => {
96
+ const handleOpenInBrowser = useCallback(() => {
97
+ Linking.openURL(urlMedium || url);
98
+ }, [urlMedium, url]);
99
+ const resetDismissGestures = useCallback(() => {
75
100
  dismissY.value = withSpring(0, RESET_SPRING_CONFIG);
76
101
  opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG);
77
- };
78
- const resetAllGestures = () => {
102
+ }, [dismissY, opacity]);
103
+ const resetAllGestures = useCallback(() => {
79
104
  resetDismissGestures();
80
105
  scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG);
81
- translateX.value = withSpring(0, RESET_SPRING_CONFIG);
82
- 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);
83
108
  savedScale.value = DEFAULT_SCALE;
84
- savedTranslateX.value = 0;
85
- savedTranslateY.value = 0;
86
- toolbarVisible.value = withSpring(1, RESET_SPRING_CONFIG);
87
- };
88
- 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(() => {
89
123
  setModalVisible(false);
90
124
  resetAllGestures();
91
- };
125
+ }, [setModalVisible, resetAllGestures]);
92
126
  /* ============================
93
- UTILITY FUNCTIONS
94
- '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.
95
129
  ============================ */
96
130
  const getImageContainedToWindowDimensions = () => {
97
131
  'worklet';
@@ -140,8 +174,8 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
140
174
  const excessWidth = scaledWidth - availableWindowWidth;
141
175
  const excessHeight = scaledHeight - availableWindowHeight;
142
176
  // How far the image can move in each direction before hitting window edges
143
- const maxTranslateX = Math.max(0, excessWidth / 2);
144
- 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);
145
179
  return { maxTranslateX, maxTranslateY };
146
180
  };
147
181
  const clampDecay = (currentScale) => {
@@ -166,22 +200,62 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
166
200
  // Calculate how much the image can exceed the window container
167
201
  const scaledHeight = containedImageHeight * currentScale;
168
202
  const excessHeight = scaledHeight - availableWindowHeight;
169
- const maxTranslateY = Math.max(0, excessHeight / 2);
203
+ const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2);
170
204
  const currentTranslateY = translateY.value;
171
205
  const panPositionTolerance = 1; // buffer to account for translateY being at a subpixel position
172
206
  const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance;
173
207
  const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance;
174
208
  return atTopBoundry || atBottomBoundry;
175
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]);
176
245
  /* ============================
177
246
  GESTURES
178
247
  ============================ */
179
248
  const singleTapGesture = Gesture.Tap()
249
+ .minPointers(SINGLE_FINGER_POINTER)
180
250
  .numberOfTaps(1)
181
251
  .onStart(() => {
182
- toolbarVisible.value = withSpring(toolbarVisible.value > 0.5 ? 0 : 1, RESET_SPRING_CONFIG);
252
+ lightboxToolbarVisible.value = withSpring(lightboxToolbarVisible.value > 0.5 ? 0 : 1, RESET_SPRING_CONFIG);
183
253
  });
184
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)
185
259
  .numberOfTaps(2)
186
260
  .onStart(e => {
187
261
  const isZoomedIn = scale.value > DEFAULT_SCALE;
@@ -190,7 +264,7 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
190
264
  }
191
265
  else {
192
266
  // Hide toolbar when starting to zoom
193
- toolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
267
+ lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
194
268
  // Zoom to 2x at tap location
195
269
  const newTranslation = zoomToFocalPoint({
196
270
  focalPointX: e.x,
@@ -221,7 +295,7 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
221
295
  focalX.value = e.focalX;
222
296
  focalY.value = e.focalY;
223
297
  // Hide toolbar when starting to zoom
224
- toolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
298
+ lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG);
225
299
  // Ensure that pinch accounts for the decay animation and starts from the true current position.
226
300
  savedTranslateX.value = translateX.value;
227
301
  savedTranslateY.value = translateY.value;
@@ -284,9 +358,10 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
284
358
  savedTranslateY.value = clampedTranslate.y;
285
359
  });
286
360
  const panGesture = Gesture.Pan()
287
- .minDistance(MIN_DISTANCE_PAN_FOR_PLATFORM)
288
- .minPointers(POINTER_FOR_SINGLE_FINGER_PAN)
289
- .maxPointers(POINTER_FOR_SINGLE_FINGER_PAN)
361
+ .minDistance(MIN_DISTANCE_FOR_PAN)
362
+ .minPointers(SINGLE_FINGER_POINTER)
363
+ .maxPointers(SINGLE_FINGER_POINTER)
364
+ .enabled(panGestureEnabled)
290
365
  .onStart(() => {
291
366
  // Update saved position to current animated position
292
367
  // This ensures smooth continuation if decay is running
@@ -339,12 +414,15 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
339
414
  const atVerticalBoundry = isImageAtVerticalBoundry(scale.value);
340
415
  const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX);
341
416
  const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical;
342
- if (atDefaultScale || canDismissWhileZoomed) {
343
- const panDistance = Math.abs(e.translationY);
344
- const fadeProgress = panDistance / DISMISS_PAN_THRESHOLD;
345
- opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress);
346
- dismissY.value = e.translationY;
347
- }
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;
348
426
  })
349
427
  .onEnd(() => {
350
428
  const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD;
@@ -356,7 +434,7 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
356
434
  }
357
435
  });
358
436
  /* ==============================
359
- IMPLEMENT GESTURES & ANIMATIONS
437
+ COMPOSE GESTURES
360
438
  ================================= */
361
439
  // Race between pinch and pan ensures only one is active at a time, preserving focal point logic
362
440
  const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture);
@@ -366,43 +444,19 @@ const LightboxModal = ({ uri, visible, setModalVisible, metaProps, imageWidth, i
366
444
  const transformImageGestures = Gesture.Race(tapGestures, pinchOrPanGestures);
367
445
  // Dismiss can work simultaneously with all gestures
368
446
  const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture);
369
- const animatedImageStyles = useAnimatedStyle(() => ({
370
- transform: [
371
- { translateX: translateX.value },
372
- { translateY: translateY.value + dismissY.value },
373
- { scale: scale.value },
374
- ],
375
- opacity: opacity.value,
376
- }));
377
- const animatedToolbarStyles = useAnimatedStyle(() => ({
378
- opacity: toolbarVisible.value,
379
- transform: [
380
- { translateY: (1 - toolbarVisible.value) * -20 }, // slide up when hiding
381
- ],
382
- }));
383
447
  return (<Modal visible={visible} transparent animationType="fade" onRequestClose={handleCloseModal}>
384
448
  <StatusBar barStyle="light-content" hidden={isStatusBarHidden} animated showHideTransition="slide"/>
385
- <View style={styles.lightboxModalSafeArea}>
386
- <GestureHandlerRootView>
387
- <GestureDetector gesture={composedGesture}>
388
- <PreventPressEventsBubbling>
389
- <Image source={{ uri }} loadingBackgroundStyles={styles.lightboxImageLoading} style={styles.lightboxImage} animatedImageStyle={animatedImageStyles} resizeMode="contain" animated={true} alt=""/>
390
- </PreventPressEventsBubbling>
391
- </GestureDetector>
392
- </GestureHandlerRootView>
393
- </View>
394
- <Animated.View style={[styles.actionToolbar, animatedToolbarStyles]} accessibilityRole="toolbar">
395
- <View style={styles.actionToolbarTextMeta}>
396
- <Heading variant="h3" style={styles.actionToolbarTitle} numberOfLines={1}>
397
- {authorName}
398
- </Heading>
399
- <Text variant="tertiary" style={styles.actionToolbarSubtitle}>
400
- {formatDatePreview(createdAt)}
401
- </Text>
402
- </View>
403
- <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"/>
404
- <IconButton onPress={handleCloseModal} name="general.x" accessibilityLabel="Close image" style={styles.actionButton} iconStyle={styles.actionButtonIcon} size="lg"/>
405
- </Animated.View>
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}/>)}
406
460
  </Modal>);
407
461
  };
408
462
  const PreventPressEventsBubbling = ({ children }) => {
@@ -410,6 +464,57 @@ const PreventPressEventsBubbling = ({ children }) => {
410
464
  {children}
411
465
  </Pressable>);
412
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
+ };
413
518
  const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
414
519
  const { top, bottom } = useSafeAreaInsets();
415
520
  const backgroundColor = tokens.colorNeutral7;
@@ -418,7 +523,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
418
523
  container: {
419
524
  maxWidth: '100%',
420
525
  },
421
- imageWrapper: {
526
+ attachmentImageWrapper: {
422
527
  width: '100%',
423
528
  minWidth: 200,
424
529
  aspectRatio: imageWidth / imageHeight,
@@ -426,55 +531,66 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 } = {}) => {
426
531
  image: {
427
532
  borderRadius: 8,
428
533
  },
429
- lightboxModalSafeArea: {
430
- flex: 1,
534
+ gallery: {
431
535
  backgroundColor,
432
- justifyContent: 'center',
433
- alignItems: 'center',
536
+ },
537
+ galleryContentContainer: {
434
538
  paddingTop: top,
435
539
  paddingBottom: bottom,
436
540
  },
437
- lightboxImage: {
541
+ gestureImage: {
438
542
  height: '100%',
439
543
  width: WINDOW_WIDTH,
440
- backgroundColor,
544
+ backgroundColor: 'transparent',
441
545
  },
442
- lightboxImageLoading: {
546
+ gestureImageLoading: {
443
547
  backgroundColor,
444
548
  },
445
- actionToolbar: {
549
+ lightboxToolbar: {
446
550
  width: '100%',
447
551
  position: 'absolute',
448
- top: 0,
449
552
  flexDirection: 'row',
450
553
  alignItems: 'center',
451
554
  gap: 20,
452
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,
453
571
  paddingTop: top + 16,
454
572
  paddingBottom: 8,
455
- backgroundColor: transparentBackgroundColor,
456
573
  },
457
- actionToolbarTextMeta: {
574
+ lightboxToolbarHeaderMetaContainer: {
458
575
  flex: 1,
459
576
  },
460
- actionToolbarTitle: {
577
+ lightboxToolbarHeaderTitle: {
461
578
  marginRight: 'auto',
462
579
  flexShrink: 1,
463
580
  color: tokens.colorNeutral88,
464
581
  },
465
- actionToolbarSubtitle: {
582
+ lightboxToolbarHeaderSubtitle: {
466
583
  color: tokens.colorNeutral68,
467
584
  },
468
- actionButton: {
469
- backgroundColor,
470
- height: 40,
471
- width: 40,
472
- borderRadius: 50,
473
- borderWidth: 1,
474
- borderColor: tokens.colorNeutral24,
585
+ lightboxToolbarFooter: {
586
+ justifyContent: 'center',
587
+ bottom: 0,
588
+ paddingTop: 8,
589
+ paddingBottom: bottom + 16,
475
590
  },
476
- actionButtonIcon: {
591
+ lightboxToolbarFooterText: {
477
592
  color: tokens.colorNeutral88,
593
+ fontWeight: platformFontWeightMedium,
478
594
  },
479
595
  });
480
596
  };