@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.
- package/build/components/conversation/attachments/attachment_card.js +1 -0
- package/build/components/conversation/attachments/attachment_card.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts +3 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +242 -95
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/message_attachments.d.ts +1 -1
- package/build/components/conversation/message_attachments.d.ts.map +1 -1
- package/build/components/conversation/message_attachments.js +7 -13
- package/build/components/conversation/message_attachments.js.map +1 -1
- package/build/components/primitive/form_sheet.d.ts +7 -6
- package/build/components/primitive/form_sheet.d.ts.map +1 -1
- package/build/components/primitive/form_sheet.js +8 -8
- package/build/components/primitive/form_sheet.js.map +1 -1
- package/build/screens/conversation/message_read_receipts_screen.js +2 -2
- package/build/screens/conversation/message_read_receipts_screen.js.map +1 -1
- package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js +3 -3
- package/build/screens/conversation_filter_recipients/conversation_filter_recipients_screen.js.map +1 -1
- package/build/screens/conversation_filters/components/conversation_filters.d.ts.map +1 -1
- package/build/screens/conversation_filters/components/conversation_filters.js +2 -1
- package/build/screens/conversation_filters/components/conversation_filters.js.map +1 -1
- package/build/screens/conversation_filters/group_filters.d.ts.map +1 -1
- package/build/screens/conversation_filters/group_filters.js +2 -1
- package/build/screens/conversation_filters/group_filters.js.map +1 -1
- package/build/screens/conversation_filters/team_filters.d.ts.map +1 -1
- package/build/screens/conversation_filters/team_filters.js +2 -1
- package/build/screens/conversation_filters/team_filters.js.map +1 -1
- package/build/screens/conversation_filters_screen.d.ts +1 -2
- package/build/screens/conversation_filters_screen.d.ts.map +1 -1
- package/build/screens/conversation_filters_screen.js +32 -75
- package/build/screens/conversation_filters_screen.js.map +1 -1
- package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
- package/build/screens/conversation_new/components/groups_form.js +11 -6
- package/build/screens/conversation_new/components/groups_form.js.map +1 -1
- package/build/screens/conversation_new/components/services_form.js +1 -1
- package/build/screens/conversation_new/components/services_form.js.map +1 -1
- package/package.json +2 -2
- package/src/components/conversation/attachments/attachment_card.tsx +1 -0
- package/src/components/conversation/attachments/image_attachment.tsx +426 -125
- package/src/components/conversation/message_attachments.tsx +7 -23
- package/src/components/primitive/form_sheet.tsx +22 -17
- package/src/screens/conversation/message_read_receipts_screen.tsx +2 -2
- package/src/screens/conversation_filter_recipients/conversation_filter_recipients_screen.tsx +3 -3
- package/src/screens/conversation_filters/components/conversation_filters.tsx +2 -1
- package/src/screens/conversation_filters/group_filters.tsx +2 -1
- package/src/screens/conversation_filters/team_filters.tsx +2 -1
- package/src/screens/conversation_filters_screen.tsx +36 -88
- package/src/screens/conversation_new/components/groups_form.tsx +12 -5
- package/src/screens/conversation_new/components/services_form.tsx +1 -1
- package/build/components/conversation/attachments/image_attachment_legacy.d.ts +0 -12
- package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +0 -1
- package/build/components/conversation/attachments/image_attachment_legacy.js +0 -142
- package/build/components/conversation/attachments/image_attachment_legacy.js.map +0 -1
- package/src/components/conversation/attachments/image_attachment_legacy.tsx +0 -258
|
@@ -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;
|
|
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,
|
|
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 =
|
|
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={() =>
|
|
33
|
-
|
|
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}
|
|
47
|
+
<LightboxModal key={modalKey} visible={visible} setModalVisible={setVisible} imageAttachments={imageAttachments} initialImageIndex={currentImageIndex} metaProps={metaProps}/>
|
|
36
48
|
</>);
|
|
37
49
|
}
|
|
38
|
-
const LightboxModal = ({
|
|
50
|
+
const LightboxModal = ({ visible, setModalVisible, imageAttachments, initialImageIndex, metaProps, }) => {
|
|
39
51
|
const styles = useStyles();
|
|
40
52
|
const insets = useSafeAreaInsets();
|
|
41
|
-
const
|
|
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(
|
|
54
|
-
const translateY = useSharedValue(
|
|
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(
|
|
57
|
-
const savedTranslateY = useSharedValue(
|
|
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(
|
|
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(
|
|
72
|
-
translateY.value = withSpring(
|
|
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 =
|
|
75
|
-
savedTranslateY.value =
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
133
|
-
const maxTranslateY = Math.max(
|
|
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(
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
437
|
+
COMPOSE GESTURES
|
|
337
438
|
================================= */
|
|
338
|
-
// Race between pinch and pan ensures only one is active at a time,
|
|
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
|
-
//
|
|
341
|
-
const
|
|
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
|
-
<
|
|
355
|
-
<
|
|
356
|
-
<
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
</
|
|
361
|
-
</
|
|
362
|
-
</
|
|
363
|
-
<
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
flex: 1,
|
|
534
|
+
gallery: {
|
|
400
535
|
backgroundColor,
|
|
401
|
-
|
|
402
|
-
|
|
536
|
+
},
|
|
537
|
+
galleryContentContainer: {
|
|
403
538
|
paddingTop: top,
|
|
404
539
|
paddingBottom: bottom,
|
|
405
540
|
},
|
|
406
|
-
|
|
541
|
+
gestureImage: {
|
|
407
542
|
height: '100%',
|
|
408
543
|
width: WINDOW_WIDTH,
|
|
409
|
-
backgroundColor,
|
|
544
|
+
backgroundColor: 'transparent',
|
|
410
545
|
},
|
|
411
|
-
|
|
546
|
+
gestureImageLoading: {
|
|
412
547
|
backgroundColor,
|
|
413
548
|
},
|
|
414
|
-
|
|
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
|
-
|
|
574
|
+
lightboxToolbarHeaderMetaContainer: {
|
|
427
575
|
flex: 1,
|
|
428
576
|
},
|
|
429
|
-
|
|
577
|
+
lightboxToolbarHeaderTitle: {
|
|
430
578
|
marginRight: 'auto',
|
|
431
579
|
flexShrink: 1,
|
|
432
580
|
color: tokens.colorNeutral88,
|
|
433
581
|
},
|
|
434
|
-
|
|
582
|
+
lightboxToolbarHeaderSubtitle: {
|
|
435
583
|
color: tokens.colorNeutral68,
|
|
436
584
|
},
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
591
|
+
lightboxToolbarFooterText: {
|
|
446
592
|
color: tokens.colorNeutral88,
|
|
593
|
+
fontWeight: platformFontWeightMedium,
|
|
447
594
|
},
|
|
448
595
|
});
|
|
449
596
|
};
|