@repzo/widget-react-native 1.0.0 → 1.0.1
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/README.md +167 -74
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +493 -0
- package/lib/commonjs/attachments/pickers.js.map +1 -0
- package/lib/commonjs/i18n.js +19 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +16 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +147 -13
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +14 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +53 -8
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
- package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/commonjs/ui/components/ImageViewer.js +181 -0
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
- package/lib/commonjs/ui/components/InAppNotification.js +210 -0
- package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +198 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +479 -0
- package/lib/module/attachments/pickers.js.map +1 -0
- package/lib/module/i18n.js +19 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +16 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +147 -13
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +14 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +53 -8
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AttachmentChips.js +131 -0
- package/lib/module/ui/components/AttachmentChips.js.map +1 -0
- package/lib/module/ui/components/AttachmentMenu.js +120 -0
- package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/module/ui/components/ImageViewer.js +177 -0
- package/lib/module/ui/components/ImageViewer.js.map +1 -0
- package/lib/module/ui/components/InAppNotification.js +208 -0
- package/lib/module/ui/components/InAppNotification.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +201 -21
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +19 -0
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +8 -2
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +16 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +23 -2
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +74 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +18 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +28 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +8 -2
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +81 -0
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +16 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +23 -2
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +74 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +18 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +28 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/metro.js +17 -32
- package/package.json +18 -1
- package/src/api/client.ts +2 -2
- package/src/attachments/pickers.ts +515 -0
- package/src/i18n.ts +20 -0
- package/src/index.tsx +4 -0
- package/src/locales.ts +16 -0
- package/src/state/widget-store.ts +152 -16
- package/src/types.ts +79 -0
- package/src/ui/WidgetProvider.tsx +65 -6
- package/src/ui/components/AttachmentChips.tsx +135 -0
- package/src/ui/components/AttachmentMenu.tsx +132 -0
- package/src/ui/components/ImageViewer.tsx +178 -0
- package/src/ui/components/InAppNotification.tsx +225 -0
- package/src/ui/screens/chat/ChatView.styles.ts +19 -0
- package/src/ui/screens/chat/ChatView.tsx +235 -16
- package/src/ui/screens/chat/ChatView.types.ts +38 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attach button's choice of source.
|
|
3
|
+
*
|
|
4
|
+
* iOS gets the native action sheet. Android has no equivalent (`Alert` caps at
|
|
5
|
+
* three buttons, and three sources plus Cancel is four), so it gets a small
|
|
6
|
+
* bottom sheet in its own transparent modal — which also lets the hardware
|
|
7
|
+
* back button close the sheet rather than the whole widget.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ActionSheetIOS, Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { useWidgetInsets } from '../hooks/use-widget-insets';
|
|
13
|
+
import { fontSize, palette, radius, spacing } from '../styles/theme';
|
|
14
|
+
import { START_ALIGN } from '../utils/rtl';
|
|
15
|
+
|
|
16
|
+
export interface AttachmentMenuOption<K extends string> {
|
|
17
|
+
key: K;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* iOS only: shows the native sheet and calls `onSelect` with the chosen key
|
|
23
|
+
* (nothing on cancel). Returns false elsewhere, so the caller shows
|
|
24
|
+
* `AttachmentMenu` instead.
|
|
25
|
+
*/
|
|
26
|
+
export function showNativeAttachmentMenu<K extends string>(
|
|
27
|
+
options: AttachmentMenuOption<K>[],
|
|
28
|
+
cancelLabel: string,
|
|
29
|
+
onSelect: (key: K) => void
|
|
30
|
+
): boolean {
|
|
31
|
+
if (Platform.OS !== 'ios') return false;
|
|
32
|
+
ActionSheetIOS.showActionSheetWithOptions(
|
|
33
|
+
{ options: [...options.map((option) => option.label), cancelLabel], cancelButtonIndex: options.length },
|
|
34
|
+
(index) => {
|
|
35
|
+
const chosen = options[index];
|
|
36
|
+
if (chosen) onSelect(chosen.key);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AttachmentMenuProps<K extends string> {
|
|
43
|
+
visible: boolean;
|
|
44
|
+
options: AttachmentMenuOption<K>[];
|
|
45
|
+
cancelLabel: string;
|
|
46
|
+
onSelect: (key: K) => void;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function AttachmentMenu<K extends string>({
|
|
51
|
+
visible,
|
|
52
|
+
options,
|
|
53
|
+
cancelLabel,
|
|
54
|
+
onSelect,
|
|
55
|
+
onClose,
|
|
56
|
+
}: AttachmentMenuProps<K>) {
|
|
57
|
+
const insets = useWidgetInsets();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Modal
|
|
61
|
+
visible={visible}
|
|
62
|
+
transparent
|
|
63
|
+
animationType="fade"
|
|
64
|
+
statusBarTranslucent
|
|
65
|
+
onRequestClose={onClose}>
|
|
66
|
+
<Pressable
|
|
67
|
+
style={styles.backdrop}
|
|
68
|
+
onPress={onClose}
|
|
69
|
+
accessibilityRole="button"
|
|
70
|
+
accessibilityLabel={cancelLabel}
|
|
71
|
+
/>
|
|
72
|
+
<View
|
|
73
|
+
style={[styles.sheet, { paddingBottom: insets.bottom + spacing.sm }]}
|
|
74
|
+
accessibilityViewIsModal>
|
|
75
|
+
{options.map((option) => (
|
|
76
|
+
<Pressable
|
|
77
|
+
key={option.key}
|
|
78
|
+
onPress={() => onSelect(option.key)}
|
|
79
|
+
accessibilityRole="button"
|
|
80
|
+
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}>
|
|
81
|
+
<Text style={styles.rowLabel}>{option.label}</Text>
|
|
82
|
+
</Pressable>
|
|
83
|
+
))}
|
|
84
|
+
<View style={styles.divider} />
|
|
85
|
+
<Pressable
|
|
86
|
+
onPress={onClose}
|
|
87
|
+
accessibilityRole="button"
|
|
88
|
+
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}>
|
|
89
|
+
<Text style={[styles.rowLabel, styles.cancelLabel]}>{cancelLabel}</Text>
|
|
90
|
+
</Pressable>
|
|
91
|
+
</View>
|
|
92
|
+
</Modal>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
backdrop: {
|
|
98
|
+
...StyleSheet.absoluteFillObject,
|
|
99
|
+
backgroundColor: 'rgba(0, 0, 0, 0.32)',
|
|
100
|
+
},
|
|
101
|
+
sheet: {
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
start: 0,
|
|
104
|
+
end: 0,
|
|
105
|
+
bottom: 0,
|
|
106
|
+
backgroundColor: palette.white,
|
|
107
|
+
borderTopLeftRadius: radius.xl + 4,
|
|
108
|
+
borderTopRightRadius: radius.xl + 4,
|
|
109
|
+
paddingTop: spacing.sm,
|
|
110
|
+
},
|
|
111
|
+
row: {
|
|
112
|
+
minHeight: 52,
|
|
113
|
+
justifyContent: 'center',
|
|
114
|
+
paddingHorizontal: spacing.xl,
|
|
115
|
+
},
|
|
116
|
+
rowPressed: {
|
|
117
|
+
backgroundColor: palette.surfaceAlt,
|
|
118
|
+
},
|
|
119
|
+
rowLabel: {
|
|
120
|
+
textAlign: START_ALIGN,
|
|
121
|
+
fontSize: fontSize.body,
|
|
122
|
+
color: palette.ink,
|
|
123
|
+
},
|
|
124
|
+
cancelLabel: {
|
|
125
|
+
color: palette.textMuted,
|
|
126
|
+
},
|
|
127
|
+
divider: {
|
|
128
|
+
height: StyleSheet.hairlineWidth,
|
|
129
|
+
backgroundColor: palette.border,
|
|
130
|
+
marginVertical: spacing.xs,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-screen viewer for image attachments, so a tap on a photo the agent sent
|
|
3
|
+
* stays inside the messenger instead of bouncing to the browser. Pinch-to-zoom
|
|
4
|
+
* uses the native ScrollView zoom (iOS); Android shows the image fitted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
ActivityIndicator,
|
|
10
|
+
Image,
|
|
11
|
+
Modal,
|
|
12
|
+
Platform,
|
|
13
|
+
Pressable,
|
|
14
|
+
ScrollView,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
View,
|
|
18
|
+
} from 'react-native';
|
|
19
|
+
|
|
20
|
+
import { useWidgetInsets } from '../hooks/use-widget-insets';
|
|
21
|
+
import { fontSize, palette, spacing } from '../styles/theme';
|
|
22
|
+
import { CloseIcon } from './icons';
|
|
23
|
+
|
|
24
|
+
export interface ImageViewerLabels {
|
|
25
|
+
close: string;
|
|
26
|
+
loadFailed: string;
|
|
27
|
+
openExternally: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ImageViewerProps {
|
|
31
|
+
image: { url: string; name: string } | null;
|
|
32
|
+
labels: ImageViewerLabels;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
/** Escape hatch when the image can't render here (e.g. unsupported format). */
|
|
35
|
+
onOpenExternally: (url: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageViewerProps) {
|
|
39
|
+
const insets = useWidgetInsets();
|
|
40
|
+
// Keyed by URL so opening another image starts from a clean loading state.
|
|
41
|
+
const [status, setStatus] = useState<{ url: string | null; state: 'loading' | 'ready' | 'failed' }>(
|
|
42
|
+
{ url: null, state: 'loading' }
|
|
43
|
+
);
|
|
44
|
+
const state = status.url === image?.url ? status.state : 'loading';
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Modal
|
|
48
|
+
visible={!!image}
|
|
49
|
+
transparent
|
|
50
|
+
animationType="fade"
|
|
51
|
+
statusBarTranslucent
|
|
52
|
+
onRequestClose={onClose}>
|
|
53
|
+
<View style={styles.backdrop} accessibilityViewIsModal>
|
|
54
|
+
{image ? (
|
|
55
|
+
<ScrollView
|
|
56
|
+
style={styles.fill}
|
|
57
|
+
contentContainerStyle={styles.center}
|
|
58
|
+
maximumZoomScale={4}
|
|
59
|
+
minimumZoomScale={1}
|
|
60
|
+
centerContent
|
|
61
|
+
showsHorizontalScrollIndicator={false}
|
|
62
|
+
showsVerticalScrollIndicator={false}
|
|
63
|
+
bouncesZoom>
|
|
64
|
+
<Image
|
|
65
|
+
source={{ uri: image.url }}
|
|
66
|
+
style={styles.image}
|
|
67
|
+
resizeMode="contain"
|
|
68
|
+
accessibilityLabel={image.name}
|
|
69
|
+
accessibilityIgnoresInvertColors
|
|
70
|
+
onLoad={() => setStatus({ url: image.url, state: 'ready' })}
|
|
71
|
+
onError={() => setStatus({ url: image.url, state: 'failed' })}
|
|
72
|
+
/>
|
|
73
|
+
</ScrollView>
|
|
74
|
+
) : null}
|
|
75
|
+
|
|
76
|
+
{image && state === 'loading' ? (
|
|
77
|
+
<ActivityIndicator style={StyleSheet.absoluteFill} color={palette.white} />
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{image && state === 'failed' ? (
|
|
81
|
+
<View style={[StyleSheet.absoluteFill, styles.center, styles.failed]}>
|
|
82
|
+
<Text style={styles.failedText}>{labels.loadFailed}</Text>
|
|
83
|
+
<Pressable
|
|
84
|
+
onPress={() => onOpenExternally(image.url)}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
|
|
87
|
+
<Text style={styles.externalLabel}>{labels.openExternally}</Text>
|
|
88
|
+
</Pressable>
|
|
89
|
+
</View>
|
|
90
|
+
) : null}
|
|
91
|
+
|
|
92
|
+
<View style={[styles.header, { paddingTop: insets.top + spacing.sm }]} pointerEvents="box-none">
|
|
93
|
+
<Text style={styles.title} numberOfLines={1}>
|
|
94
|
+
{image?.name ?? ''}
|
|
95
|
+
</Text>
|
|
96
|
+
<Pressable
|
|
97
|
+
onPress={onClose}
|
|
98
|
+
accessibilityRole="button"
|
|
99
|
+
accessibilityLabel={labels.close}
|
|
100
|
+
hitSlop={12}
|
|
101
|
+
style={({ pressed }) => [styles.closeButton, pressed && styles.pressed]}>
|
|
102
|
+
<CloseIcon size={20} color={palette.white} />
|
|
103
|
+
</Pressable>
|
|
104
|
+
</View>
|
|
105
|
+
</View>
|
|
106
|
+
</Modal>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const styles = StyleSheet.create({
|
|
111
|
+
backdrop: {
|
|
112
|
+
flex: 1,
|
|
113
|
+
backgroundColor: '#000000',
|
|
114
|
+
},
|
|
115
|
+
fill: {
|
|
116
|
+
flex: 1,
|
|
117
|
+
},
|
|
118
|
+
center: {
|
|
119
|
+
flexGrow: 1,
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
justifyContent: 'center',
|
|
122
|
+
},
|
|
123
|
+
image: {
|
|
124
|
+
width: '100%',
|
|
125
|
+
height: '100%',
|
|
126
|
+
},
|
|
127
|
+
header: {
|
|
128
|
+
position: 'absolute',
|
|
129
|
+
top: 0,
|
|
130
|
+
left: 0,
|
|
131
|
+
right: 0,
|
|
132
|
+
flexDirection: 'row',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
gap: spacing.md,
|
|
135
|
+
paddingHorizontal: spacing.lg,
|
|
136
|
+
paddingBottom: spacing.sm,
|
|
137
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
138
|
+
},
|
|
139
|
+
title: {
|
|
140
|
+
flex: 1,
|
|
141
|
+
minWidth: 0,
|
|
142
|
+
color: palette.white,
|
|
143
|
+
fontSize: fontSize.body,
|
|
144
|
+
fontWeight: '600',
|
|
145
|
+
},
|
|
146
|
+
closeButton: {
|
|
147
|
+
width: 36,
|
|
148
|
+
height: 36,
|
|
149
|
+
borderRadius: 18,
|
|
150
|
+
alignItems: 'center',
|
|
151
|
+
justifyContent: 'center',
|
|
152
|
+
backgroundColor: 'rgba(255,255,255,0.14)',
|
|
153
|
+
},
|
|
154
|
+
pressed: {
|
|
155
|
+
opacity: 0.7,
|
|
156
|
+
},
|
|
157
|
+
failed: {
|
|
158
|
+
gap: spacing.md,
|
|
159
|
+
paddingHorizontal: spacing.xxl,
|
|
160
|
+
},
|
|
161
|
+
failedText: {
|
|
162
|
+
color: palette.white,
|
|
163
|
+
fontSize: fontSize.body,
|
|
164
|
+
textAlign: 'center',
|
|
165
|
+
},
|
|
166
|
+
externalButton: {
|
|
167
|
+
paddingHorizontal: spacing.lg,
|
|
168
|
+
paddingVertical: spacing.sm,
|
|
169
|
+
borderRadius: 999,
|
|
170
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
171
|
+
borderColor: 'rgba(255,255,255,0.5)',
|
|
172
|
+
},
|
|
173
|
+
externalLabel: {
|
|
174
|
+
color: palette.white,
|
|
175
|
+
fontSize: fontSize.meta,
|
|
176
|
+
fontWeight: Platform.OS === 'ios' ? '600' : 'bold',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card that floats over the host app when a team reply arrives while the
|
|
3
|
+
* messenger is closed — Intercom's in-app notification. It never times out:
|
|
4
|
+
* tapping it opens the conversation, swiping it left or right dismisses it
|
|
5
|
+
* (the thread stays unread). The store takes it down on its own once the
|
|
6
|
+
* messenger opens or the thread is read.
|
|
7
|
+
*
|
|
8
|
+
* Pure JS gestures (PanResponder), so hosts need no gesture library.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
AccessibilityInfo,
|
|
14
|
+
Animated,
|
|
15
|
+
PanResponder,
|
|
16
|
+
Pressable,
|
|
17
|
+
StyleSheet,
|
|
18
|
+
Text,
|
|
19
|
+
View,
|
|
20
|
+
useWindowDimensions,
|
|
21
|
+
type AccessibilityActionEvent,
|
|
22
|
+
} from 'react-native';
|
|
23
|
+
|
|
24
|
+
import { formatLabel } from '../../i18n';
|
|
25
|
+
import type { InAppNotification as InAppNotificationData } from '../../types';
|
|
26
|
+
import { useReduceMotion } from '../hooks/use-reduce-motion';
|
|
27
|
+
import { fontSize, palette, radius, shadows, spacing, withAlpha } from '../styles/theme';
|
|
28
|
+
import { START_ALIGN } from '../utils/rtl';
|
|
29
|
+
import { Avatar } from './Avatar';
|
|
30
|
+
|
|
31
|
+
/** Share of the card's width a drag must travel to count as a dismiss. */
|
|
32
|
+
const DISMISS_DISTANCE_RATIO = 0.35;
|
|
33
|
+
/** A quick flick dismisses even when it travels less than that. */
|
|
34
|
+
const DISMISS_VELOCITY = 0.6;
|
|
35
|
+
|
|
36
|
+
interface InAppNotificationProps {
|
|
37
|
+
notification: InAppNotificationData;
|
|
38
|
+
/** Brand color — the avatar's background when the agent has no photo. */
|
|
39
|
+
color: string;
|
|
40
|
+
bottomInset: number;
|
|
41
|
+
labels: {
|
|
42
|
+
replyFrom: string;
|
|
43
|
+
newReply: string;
|
|
44
|
+
attachment: string;
|
|
45
|
+
openHint: string;
|
|
46
|
+
dismiss: string;
|
|
47
|
+
};
|
|
48
|
+
onOpen: (conversationId: string) => void;
|
|
49
|
+
onDismiss: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function InAppNotification({
|
|
53
|
+
notification,
|
|
54
|
+
color,
|
|
55
|
+
bottomInset,
|
|
56
|
+
labels,
|
|
57
|
+
onOpen,
|
|
58
|
+
onDismiss,
|
|
59
|
+
}: InAppNotificationProps) {
|
|
60
|
+
const { width } = useWindowDimensions();
|
|
61
|
+
const reduceMotion = useReduceMotion();
|
|
62
|
+
// Read as "hold still" until the setting is known (see useReduceMotion).
|
|
63
|
+
const animate = reduceMotion === false;
|
|
64
|
+
|
|
65
|
+
const entry = useRef(new Animated.Value(animate ? 0 : 1)).current;
|
|
66
|
+
const dragX = useRef(new Animated.Value(0)).current;
|
|
67
|
+
|
|
68
|
+
const preview = notification.preview || (notification.hasAttachments ? labels.attachment : '');
|
|
69
|
+
const sender = notification.senderName
|
|
70
|
+
? formatLabel(labels.replyFrom, { name: notification.senderName })
|
|
71
|
+
: labels.newReply;
|
|
72
|
+
const accessibilityLabel = [preview, sender].filter(Boolean).join(', ');
|
|
73
|
+
|
|
74
|
+
// Mounted once per reply (the provider keys it by message id), so this runs
|
|
75
|
+
// for every new card: slide it in and tell screen-reader users it arrived.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (animate) {
|
|
78
|
+
Animated.spring(entry, {
|
|
79
|
+
toValue: 1,
|
|
80
|
+
useNativeDriver: true,
|
|
81
|
+
speed: 16,
|
|
82
|
+
bounciness: 4,
|
|
83
|
+
}).start();
|
|
84
|
+
}
|
|
85
|
+
AccessibilityInfo.announceForAccessibility(accessibilityLabel);
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const panResponder = useMemo(() => {
|
|
90
|
+
const settle = () =>
|
|
91
|
+
Animated.spring(dragX, { toValue: 0, useNativeDriver: true, bounciness: 6 }).start();
|
|
92
|
+
|
|
93
|
+
return PanResponder.create({
|
|
94
|
+
// Capture so a horizontal drag wins over the Pressable underneath; taps
|
|
95
|
+
// and vertical movement still reach it.
|
|
96
|
+
onMoveShouldSetPanResponderCapture: (_, gesture) =>
|
|
97
|
+
Math.abs(gesture.dx) > 8 && Math.abs(gesture.dx) > Math.abs(gesture.dy) * 1.5,
|
|
98
|
+
onPanResponderMove: (_, gesture) => dragX.setValue(gesture.dx),
|
|
99
|
+
onPanResponderTerminationRequest: () => false,
|
|
100
|
+
onPanResponderRelease: (_, gesture) => {
|
|
101
|
+
const farEnough = Math.abs(gesture.dx) > width * DISMISS_DISTANCE_RATIO;
|
|
102
|
+
const fastEnough = Math.abs(gesture.vx) > DISMISS_VELOCITY;
|
|
103
|
+
if (!farEnough && !fastEnough) {
|
|
104
|
+
settle();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const direction = (gesture.dx || gesture.vx) < 0 ? -1 : 1;
|
|
108
|
+
Animated.timing(dragX, {
|
|
109
|
+
toValue: direction * width,
|
|
110
|
+
duration: animate ? 180 : 0,
|
|
111
|
+
useNativeDriver: true,
|
|
112
|
+
}).start(() => onDismiss());
|
|
113
|
+
},
|
|
114
|
+
onPanResponderTerminate: settle,
|
|
115
|
+
});
|
|
116
|
+
}, [animate, dragX, onDismiss, width]);
|
|
117
|
+
|
|
118
|
+
const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
|
|
119
|
+
if (event.nativeEvent.actionName === 'dismiss') onDismiss();
|
|
120
|
+
if (event.nativeEvent.actionName === 'activate') onOpen(notification.conversationId);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const opacity = Animated.multiply(
|
|
124
|
+
entry,
|
|
125
|
+
dragX.interpolate({
|
|
126
|
+
inputRange: [-width, 0, width],
|
|
127
|
+
outputRange: [0, 1, 0],
|
|
128
|
+
extrapolate: 'clamp',
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
const translateY = entry.interpolate({ inputRange: [0, 1], outputRange: [spacing.lg, 0] });
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<View
|
|
135
|
+
pointerEvents="box-none"
|
|
136
|
+
style={[styles.wrapper, { bottom: bottomInset + spacing.md }]}>
|
|
137
|
+
<Animated.View
|
|
138
|
+
{...panResponder.panHandlers}
|
|
139
|
+
style={[styles.card, { opacity, transform: [{ translateX: dragX }, { translateY }] }]}>
|
|
140
|
+
<Pressable
|
|
141
|
+
onPress={() => onOpen(notification.conversationId)}
|
|
142
|
+
accessibilityRole="button"
|
|
143
|
+
accessibilityLabel={accessibilityLabel}
|
|
144
|
+
accessibilityHint={labels.openHint}
|
|
145
|
+
accessibilityActions={[
|
|
146
|
+
{ name: 'activate' },
|
|
147
|
+
{ name: 'dismiss', label: labels.dismiss },
|
|
148
|
+
]}
|
|
149
|
+
onAccessibilityAction={handleAccessibilityAction}
|
|
150
|
+
style={({ pressed }) => [styles.row, pressed && styles.pressed]}>
|
|
151
|
+
<Avatar
|
|
152
|
+
avatar={notification.senderAvatar}
|
|
153
|
+
name={notification.senderName}
|
|
154
|
+
color={color}
|
|
155
|
+
size={40}
|
|
156
|
+
style={styles.avatar}
|
|
157
|
+
/>
|
|
158
|
+
<View style={styles.text}>
|
|
159
|
+
{preview ? (
|
|
160
|
+
<Text numberOfLines={2} style={styles.preview}>
|
|
161
|
+
{preview}
|
|
162
|
+
</Text>
|
|
163
|
+
) : null}
|
|
164
|
+
<Text numberOfLines={1} style={styles.sender}>
|
|
165
|
+
{sender}
|
|
166
|
+
</Text>
|
|
167
|
+
</View>
|
|
168
|
+
</Pressable>
|
|
169
|
+
</Animated.View>
|
|
170
|
+
</View>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const styles = StyleSheet.create({
|
|
175
|
+
wrapper: {
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
left: 0,
|
|
178
|
+
right: 0,
|
|
179
|
+
paddingHorizontal: spacing.lg,
|
|
180
|
+
alignItems: 'center',
|
|
181
|
+
// Above the host's navigator even when the provider is mounted before it.
|
|
182
|
+
zIndex: 1000,
|
|
183
|
+
elevation: 1000,
|
|
184
|
+
},
|
|
185
|
+
card: {
|
|
186
|
+
width: '100%',
|
|
187
|
+
maxWidth: 480,
|
|
188
|
+
borderRadius: radius.xl,
|
|
189
|
+
backgroundColor: palette.text,
|
|
190
|
+
...shadows.floating,
|
|
191
|
+
},
|
|
192
|
+
row: {
|
|
193
|
+
flexDirection: 'row',
|
|
194
|
+
alignItems: 'center',
|
|
195
|
+
gap: spacing.md,
|
|
196
|
+
minHeight: 64,
|
|
197
|
+
paddingVertical: spacing.md,
|
|
198
|
+
paddingHorizontal: spacing.lg,
|
|
199
|
+
borderRadius: radius.xl,
|
|
200
|
+
},
|
|
201
|
+
pressed: {
|
|
202
|
+
opacity: 0.85,
|
|
203
|
+
},
|
|
204
|
+
avatar: {
|
|
205
|
+
borderWidth: 1,
|
|
206
|
+
borderColor: withAlpha(palette.white, 0.1),
|
|
207
|
+
},
|
|
208
|
+
text: {
|
|
209
|
+
flex: 1,
|
|
210
|
+
minWidth: 0,
|
|
211
|
+
gap: 2,
|
|
212
|
+
},
|
|
213
|
+
preview: {
|
|
214
|
+
color: palette.white,
|
|
215
|
+
fontSize: fontSize.body,
|
|
216
|
+
fontWeight: '600',
|
|
217
|
+
lineHeight: 20,
|
|
218
|
+
textAlign: START_ALIGN,
|
|
219
|
+
},
|
|
220
|
+
sender: {
|
|
221
|
+
color: withAlpha(palette.white, 0.64),
|
|
222
|
+
fontSize: fontSize.meta,
|
|
223
|
+
textAlign: START_ALIGN,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
@@ -261,9 +261,28 @@ export const styles = StyleSheet.create({
|
|
|
261
261
|
paddingRight: spacing.xs + 2,
|
|
262
262
|
paddingVertical: spacing.xs + 2,
|
|
263
263
|
},
|
|
264
|
+
// The paperclip sits where the text inset was; both ends match the send side.
|
|
265
|
+
composerWithAttach: {
|
|
266
|
+
paddingLeft: spacing.xs + 2,
|
|
267
|
+
},
|
|
264
268
|
composerFocused: {
|
|
265
269
|
backgroundColor: palette.white,
|
|
266
270
|
},
|
|
271
|
+
attachButton: {
|
|
272
|
+
width: 36,
|
|
273
|
+
height: 36,
|
|
274
|
+
borderRadius: 18,
|
|
275
|
+
alignItems: 'center',
|
|
276
|
+
justifyContent: 'center',
|
|
277
|
+
},
|
|
278
|
+
composerNotice: {
|
|
279
|
+
textAlign: START_ALIGN,
|
|
280
|
+
fontSize: fontSize.caption,
|
|
281
|
+
lineHeight: fontSize.caption * 1.4,
|
|
282
|
+
color: palette.danger,
|
|
283
|
+
marginBottom: spacing.sm,
|
|
284
|
+
paddingHorizontal: spacing.xs,
|
|
285
|
+
},
|
|
267
286
|
input: {
|
|
268
287
|
textAlign: START_ALIGN,
|
|
269
288
|
flex: 1,
|