@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.
Files changed (126) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js.map +1 -1
  3. package/lib/commonjs/attachments/pickers.js +493 -0
  4. package/lib/commonjs/attachments/pickers.js.map +1 -0
  5. package/lib/commonjs/i18n.js +19 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/index.js.map +1 -1
  8. package/lib/commonjs/locales.js +16 -0
  9. package/lib/commonjs/locales.js.map +1 -1
  10. package/lib/commonjs/state/widget-store.js +147 -13
  11. package/lib/commonjs/state/widget-store.js.map +1 -1
  12. package/lib/commonjs/types.js +14 -0
  13. package/lib/commonjs/types.js.map +1 -1
  14. package/lib/commonjs/ui/WidgetProvider.js +53 -8
  15. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  16. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  17. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  18. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  19. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  20. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  21. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  22. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  23. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  24. package/lib/commonjs/ui/screens/chat/ChatView.js +198 -18
  25. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  26. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
  27. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  28. package/lib/module/api/client.js.map +1 -1
  29. package/lib/module/attachments/pickers.js +479 -0
  30. package/lib/module/attachments/pickers.js.map +1 -0
  31. package/lib/module/i18n.js +19 -0
  32. package/lib/module/i18n.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/locales.js +16 -0
  35. package/lib/module/locales.js.map +1 -1
  36. package/lib/module/state/widget-store.js +147 -13
  37. package/lib/module/state/widget-store.js.map +1 -1
  38. package/lib/module/types.js +14 -0
  39. package/lib/module/types.js.map +1 -1
  40. package/lib/module/ui/WidgetProvider.js +53 -8
  41. package/lib/module/ui/WidgetProvider.js.map +1 -1
  42. package/lib/module/ui/components/AttachmentChips.js +131 -0
  43. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  44. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  45. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  46. package/lib/module/ui/components/ImageViewer.js +177 -0
  47. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  48. package/lib/module/ui/components/InAppNotification.js +208 -0
  49. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  50. package/lib/module/ui/screens/chat/ChatView.js +201 -21
  51. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  52. package/lib/module/ui/screens/chat/ChatView.styles.js +19 -0
  53. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  54. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  55. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  57. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/i18n.d.ts +16 -0
  59. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/index.d.ts +1 -1
  61. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/state/widget-store.d.ts +23 -2
  64. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/types.d.ts +74 -0
  66. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  69. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  71. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  73. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  75. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  77. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +18 -0
  79. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +28 -2
  81. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  82. package/lib/typescript/module/api/client.d.ts +8 -2
  83. package/lib/typescript/module/api/client.d.ts.map +1 -1
  84. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  85. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  86. package/lib/typescript/module/i18n.d.ts +16 -0
  87. package/lib/typescript/module/i18n.d.ts.map +1 -1
  88. package/lib/typescript/module/index.d.ts +1 -1
  89. package/lib/typescript/module/index.d.ts.map +1 -1
  90. package/lib/typescript/module/locales.d.ts.map +1 -1
  91. package/lib/typescript/module/state/widget-store.d.ts +23 -2
  92. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/module/types.d.ts +74 -0
  94. package/lib/typescript/module/types.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  97. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  98. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  99. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  100. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  101. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  102. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  103. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  104. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  105. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  106. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +18 -0
  107. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  108. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +28 -2
  109. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  110. package/metro.js +17 -32
  111. package/package.json +18 -1
  112. package/src/api/client.ts +2 -2
  113. package/src/attachments/pickers.ts +515 -0
  114. package/src/i18n.ts +20 -0
  115. package/src/index.tsx +4 -0
  116. package/src/locales.ts +16 -0
  117. package/src/state/widget-store.ts +152 -16
  118. package/src/types.ts +79 -0
  119. package/src/ui/WidgetProvider.tsx +65 -6
  120. package/src/ui/components/AttachmentChips.tsx +135 -0
  121. package/src/ui/components/AttachmentMenu.tsx +132 -0
  122. package/src/ui/components/ImageViewer.tsx +178 -0
  123. package/src/ui/components/InAppNotification.tsx +225 -0
  124. package/src/ui/screens/chat/ChatView.styles.ts +19 -0
  125. package/src/ui/screens/chat/ChatView.tsx +235 -16
  126. 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,