@repzo/widget-react-native 1.0.0 → 1.0.4

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 (153) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js +3 -1
  3. package/lib/commonjs/api/client.js.map +1 -1
  4. package/lib/commonjs/api/realtime.js +2 -0
  5. package/lib/commonjs/api/realtime.js.map +1 -1
  6. package/lib/commonjs/attachments/pickers.js +493 -0
  7. package/lib/commonjs/attachments/pickers.js.map +1 -0
  8. package/lib/commonjs/i18n.js +45 -0
  9. package/lib/commonjs/i18n.js.map +1 -1
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/locales.js +21 -0
  12. package/lib/commonjs/locales.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +256 -18
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/types.js +16 -0
  16. package/lib/commonjs/types.js.map +1 -1
  17. package/lib/commonjs/ui/WidgetProvider.js +70 -10
  18. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  19. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  20. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  21. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  22. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  23. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  24. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  25. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  26. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  27. package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
  28. package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
  29. package/lib/commonjs/ui/components/icons.js +50 -0
  30. package/lib/commonjs/ui/components/icons.js.map +1 -1
  31. package/lib/commonjs/ui/screens/chat/ChatView.js +303 -24
  32. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  33. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +70 -0
  34. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  35. package/lib/module/api/client.js +3 -1
  36. package/lib/module/api/client.js.map +1 -1
  37. package/lib/module/api/realtime.js +2 -0
  38. package/lib/module/api/realtime.js.map +1 -1
  39. package/lib/module/attachments/pickers.js +479 -0
  40. package/lib/module/attachments/pickers.js.map +1 -0
  41. package/lib/module/i18n.js +44 -0
  42. package/lib/module/i18n.js.map +1 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/locales.js +21 -0
  45. package/lib/module/locales.js.map +1 -1
  46. package/lib/module/state/widget-store.js +256 -18
  47. package/lib/module/state/widget-store.js.map +1 -1
  48. package/lib/module/types.js +16 -0
  49. package/lib/module/types.js.map +1 -1
  50. package/lib/module/ui/WidgetProvider.js +71 -11
  51. package/lib/module/ui/WidgetProvider.js.map +1 -1
  52. package/lib/module/ui/components/AttachmentChips.js +131 -0
  53. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  54. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  55. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  56. package/lib/module/ui/components/ImageViewer.js +177 -0
  57. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  58. package/lib/module/ui/components/InAppNotification.js +208 -0
  59. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  60. package/lib/module/ui/components/TeamAvatars.js +59 -0
  61. package/lib/module/ui/components/TeamAvatars.js.map +1 -0
  62. package/lib/module/ui/components/icons.js +49 -0
  63. package/lib/module/ui/components/icons.js.map +1 -1
  64. package/lib/module/ui/screens/chat/ChatView.js +306 -27
  65. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  66. package/lib/module/ui/screens/chat/ChatView.styles.js +70 -0
  67. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  68. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  69. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  72. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/i18n.d.ts +23 -0
  74. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/index.d.ts +1 -1
  76. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/state/widget-store.d.ts +40 -2
  79. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/types.d.ts +88 -1
  81. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  84. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  86. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  88. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  90. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
  92. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
  94. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  96. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +69 -0
  98. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +41 -2
  100. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  101. package/lib/typescript/module/api/client.d.ts +8 -2
  102. package/lib/typescript/module/api/client.d.ts.map +1 -1
  103. package/lib/typescript/module/api/realtime.d.ts.map +1 -1
  104. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  105. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  106. package/lib/typescript/module/i18n.d.ts +23 -0
  107. package/lib/typescript/module/i18n.d.ts.map +1 -1
  108. package/lib/typescript/module/index.d.ts +1 -1
  109. package/lib/typescript/module/index.d.ts.map +1 -1
  110. package/lib/typescript/module/locales.d.ts.map +1 -1
  111. package/lib/typescript/module/state/widget-store.d.ts +40 -2
  112. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  113. package/lib/typescript/module/types.d.ts +88 -1
  114. package/lib/typescript/module/types.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  116. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  117. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  118. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  119. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  120. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  121. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  122. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  123. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  124. package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
  125. package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
  126. package/lib/typescript/module/ui/components/icons.d.ts +2 -0
  127. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  129. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +69 -0
  131. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +41 -2
  133. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  134. package/metro.js +17 -32
  135. package/package.json +18 -1
  136. package/src/api/client.ts +5 -3
  137. package/src/api/realtime.ts +2 -0
  138. package/src/attachments/pickers.ts +515 -0
  139. package/src/i18n.ts +48 -0
  140. package/src/index.tsx +4 -0
  141. package/src/locales.ts +21 -0
  142. package/src/state/widget-store.ts +256 -19
  143. package/src/types.ts +95 -0
  144. package/src/ui/WidgetProvider.tsx +90 -9
  145. package/src/ui/components/AttachmentChips.tsx +135 -0
  146. package/src/ui/components/AttachmentMenu.tsx +132 -0
  147. package/src/ui/components/ImageViewer.tsx +178 -0
  148. package/src/ui/components/InAppNotification.tsx +225 -0
  149. package/src/ui/components/TeamAvatars.tsx +58 -0
  150. package/src/ui/components/icons.tsx +37 -0
  151. package/src/ui/screens/chat/ChatView.styles.ts +70 -0
  152. package/src/ui/screens/chat/ChatView.tsx +345 -35
  153. package/src/ui/screens/chat/ChatView.types.ts +51 -2
@@ -18,12 +18,20 @@ import {
18
18
  } from 'react-native';
19
19
 
20
20
  import { isTerminalErrorCode } from '../api/client';
21
+ import {
22
+ formatBytes,
23
+ getAttachmentSources,
24
+ MAX_ATTACHMENT_BYTES,
25
+ MAX_ATTACHMENTS,
26
+ pickAttachments,
27
+ } from '../attachments/pickers';
21
28
  import { controller } from '../controller';
22
- import { buildLabels, createTranslator, formatLabel } from '../i18n';
29
+ import { buildLabels, createTranslator, formatLabel, localizeWelcomeText } from '../i18n';
23
30
  import { useWidgetState } from '../state/use-widget-state';
24
- import type { AgentUiAction, WidgetAttachment, WidgetSpaceType } from '../types';
31
+ import type { AgentUiAction, OutgoingAttachment, WidgetSpaceType } from '../types';
25
32
  import { WidgetErrorBoundary } from './components/ErrorBoundary';
26
33
  import { AlertIcon } from './components/icons';
34
+ import { InAppNotification } from './components/InAppNotification';
27
35
  import { OutboundMessageCard } from './components/OutboundMessageCard';
28
36
  import { WidgetPanel } from './components/WidgetPanel';
29
37
  import { useKeyboardOverlap } from './hooks/use-keyboard-overlap';
@@ -61,7 +69,20 @@ export function RepzoWidgetProvider({
61
69
  // not, so they own the top inset.
62
70
  const topInset = Platform.OS === 'ios' && presentationStyle === 'pageSheet' ? 0 : insets.top;
63
71
 
64
- const theme = useMemo(() => buildTheme(state.config), [state.config]);
72
+ const localizedConfig = useMemo(
73
+ () => localizeWelcomeText(state.config, state.locale),
74
+ [state.config, state.locale]
75
+ );
76
+ const theme = useMemo(() => buildTheme(localizedConfig), [localizedConfig]);
77
+ // Teammate photos can be server-relative media paths.
78
+ const teamAvatars = useMemo(
79
+ () =>
80
+ (state.config?.teamAvatars ?? []).map((avatar) => ({
81
+ ...avatar,
82
+ avatarUrl: store.client.resolveUrl(avatar.avatarUrl),
83
+ })),
84
+ [state.config, store]
85
+ );
65
86
  const labels = useMemo(
66
87
  () => buildLabels(createTranslator(state.config, state.locale)),
67
88
  [state.config, state.locale]
@@ -108,16 +129,40 @@ export function RepzoWidgetProvider({
108
129
  [labels]
109
130
  );
110
131
 
132
+ const attachLabels = useMemo(
133
+ () => ({
134
+ button: labels.attachFile,
135
+ photoLibrary: labels.attachPhotoLibrary,
136
+ takePhoto: labels.attachTakePhoto,
137
+ file: labels.attachChooseFile,
138
+ cancel: labels.cancel,
139
+ remove: labels.removeAttachment,
140
+ tooLarge: formatLabel(labels.attachmentTooLarge, { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
141
+ tooMany: formatLabel(labels.attachmentTooMany, { count: MAX_ATTACHMENTS }),
142
+ tooLargeTogether: labels.attachmentsTooLargeTogether,
143
+ failed: labels.attachmentFailed,
144
+ cameraDenied: labels.cameraPermissionDenied,
145
+ }),
146
+ [labels]
147
+ );
148
+
149
+ // Read per render: `boot({ attachments })` sets it outside React state.
150
+ const attachmentSources = theme.attachmentsEnabled ? getAttachmentSources() : [];
151
+
111
152
  const handleClose = useCallback(() => store.hide(), [store]);
112
153
 
154
+ const handleOpenNotification = useCallback(
155
+ (conversationId: string) => void store.openConversation(conversationId),
156
+ [store]
157
+ );
158
+ const handleDismissNotification = useCallback(() => store.dismissInAppNotification(), [store]);
159
+
113
160
  const handleTabChange = useCallback((tab: WidgetSpaceType) => store.setActiveTab(tab), [store]);
114
161
 
162
+ // Rejections reach ChatView, which keeps unsent attachments for a retry.
115
163
  const handleSendMessage = useCallback(
116
- (body: string, attachments?: WidgetAttachment[], action?: AgentUiAction) => {
117
- store.sendMessage(body, attachments ?? [], action).catch(() => {
118
- // The failed bubble already surfaces this to the visitor.
119
- });
120
- },
164
+ (body: string, attachments?: OutgoingAttachment[], action?: AgentUiAction) =>
165
+ store.sendMessage(body, attachments ?? [], action),
121
166
  [store]
122
167
  );
123
168
 
@@ -211,6 +256,7 @@ export function RepzoWidgetProvider({
211
256
  if (state.view === 'chat') {
212
257
  return (
213
258
  <ChatView
259
+ key={store.getThreadGeneration()}
214
260
  messages={state.messages}
215
261
  loading={state.messagesLoading}
216
262
  hasMoreMessages={state.hasMoreMessages}
@@ -218,7 +264,11 @@ export function RepzoWidgetProvider({
218
264
  color={theme.primaryColor}
219
265
  agentName={activeConversation?.agentName}
220
266
  agentAvatar={activeConversation?.agentAvatar}
267
+ agentOnline={activeConversation?.agentOnline}
221
268
  isOnline={theme.isOnline}
269
+ teamName={state.config?.workspaceName}
270
+ teamAvatars={teamAvatars}
271
+ replyTimeLabel={theme.replyTimeLabel}
222
272
  locale={state.locale}
223
273
  prefillText={state.prefillText}
224
274
  offlineMessage={theme.offlineMessage}
@@ -237,10 +287,23 @@ export function RepzoWidgetProvider({
237
287
  today: labels.today,
238
288
  yesterday: labels.yesterday,
239
289
  conversationUpdated: labels.conversationUpdated,
290
+ messageDeleted: labels.messageDeleted,
291
+ messageSent: labels.messageSent,
292
+ messageSeen: labels.messageSeen,
293
+ waitingForTeammate: labels.waitingForTeammate,
294
+ joinedConversation: labels.joinedConversation,
240
295
  procedureSend: labels.procedureSend,
241
296
  procedureSent: labels.procedureSent,
242
297
  blocks: blockLabels,
298
+ attach: attachLabels,
299
+ viewer: {
300
+ close: labels.close,
301
+ loadFailed: labels.imageLoadFailed,
302
+ openExternally: labels.openImageExternally,
303
+ },
243
304
  }}
305
+ attachmentSources={attachmentSources}
306
+ onPickAttachments={pickAttachments}
244
307
  onSendMessage={handleSendMessage}
245
308
  onIdentifyVisitor={(data) => store.identifyVisitor(data)}
246
309
  onLoadMore={() => store.loadMoreMessages()}
@@ -254,7 +317,7 @@ export function RepzoWidgetProvider({
254
317
  if (isHomeSurface) {
255
318
  return (
256
319
  <HomeView
257
- config={state.config}
320
+ config={localizedConfig}
258
321
  color={theme.primaryColor}
259
322
  visitorName={state.visitorProfile.name}
260
323
  conversationCount={state.conversations.length}
@@ -319,6 +382,24 @@ export function RepzoWidgetProvider({
319
382
  return (
320
383
  <>
321
384
  {children}
385
+ {state.inAppNotification ? (
386
+ <InAppNotification
387
+ // A new reply remounts the card, so it slides in fresh.
388
+ key={state.inAppNotification.messageId}
389
+ notification={state.inAppNotification}
390
+ color={theme.primaryColor}
391
+ bottomInset={insets.bottom}
392
+ labels={{
393
+ replyFrom: labels.notificationReplyFrom,
394
+ newReply: labels.notificationNewReply,
395
+ attachment: labels.notificationAttachment,
396
+ openHint: labels.notificationOpenHint,
397
+ dismiss: labels.dismiss,
398
+ }}
399
+ onOpen={handleOpenNotification}
400
+ onDismiss={handleDismissNotification}
401
+ />
402
+ ) : null}
322
403
  <Modal
323
404
  visible={state.isOpen}
324
405
  animationType="slide"
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Picked-but-unsent attachments above the composer: a thumbnail for images, a
3
+ * name + size card for everything else, each with its own remove button.
4
+ */
5
+
6
+ import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
7
+
8
+ import {
9
+ formatBytes,
10
+ isImageAttachment,
11
+ type DraftAttachment,
12
+ } from '../../attachments/pickers';
13
+ import { formatLabel } from '../../i18n';
14
+ import { fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
15
+ import { START_ALIGN } from '../utils/rtl';
16
+ import { CloseIcon } from './icons';
17
+
18
+ interface AttachmentChipsProps {
19
+ attachments: DraftAttachment[];
20
+ /** While a send is in flight the chips are dimmed and can't be removed. */
21
+ disabled?: boolean;
22
+ /** Keeps its `{name}` placeholder; filled per chip. */
23
+ removeLabel: string;
24
+ onRemove: (id: string) => void;
25
+ }
26
+
27
+ export function AttachmentChips({
28
+ attachments,
29
+ disabled,
30
+ removeLabel,
31
+ onRemove,
32
+ }: AttachmentChipsProps) {
33
+ return (
34
+ <ScrollView
35
+ horizontal
36
+ showsHorizontalScrollIndicator={false}
37
+ keyboardShouldPersistTaps="handled"
38
+ style={styles.scroller}
39
+ contentContainerStyle={styles.row}>
40
+ {attachments.map((attachment) => (
41
+ <View key={attachment.id} style={[styles.chip, disabled && styles.chipDisabled]}>
42
+ {isImageAttachment(attachment.contentType) ? (
43
+ <Image
44
+ source={{ uri: attachment.previewUri }}
45
+ style={styles.thumbnail}
46
+ resizeMode="cover"
47
+ accessible
48
+ accessibilityLabel={attachment.name}
49
+ accessibilityIgnoresInvertColors
50
+ />
51
+ ) : (
52
+ <View style={styles.file}>
53
+ <Text style={styles.fileName} numberOfLines={1}>
54
+ {attachment.name}
55
+ </Text>
56
+ <Text style={styles.fileSize}>{formatBytes(attachment.size)}</Text>
57
+ </View>
58
+ )}
59
+ <Pressable
60
+ onPress={() => onRemove(attachment.id)}
61
+ disabled={disabled}
62
+ accessibilityRole="button"
63
+ accessibilityLabel={formatLabel(removeLabel, { name: attachment.name })}
64
+ accessibilityState={{ disabled: !!disabled }}
65
+ // 22pt badge + 11pt on every side = a 44pt target.
66
+ hitSlop={11}
67
+ style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}>
68
+ <CloseIcon size={10} color={palette.white} />
69
+ </Pressable>
70
+ </View>
71
+ ))}
72
+ </ScrollView>
73
+ );
74
+ }
75
+
76
+ const CHIP_HEIGHT = 64;
77
+
78
+ const styles = StyleSheet.create({
79
+ scroller: {
80
+ flexGrow: 0,
81
+ marginBottom: spacing.sm,
82
+ },
83
+ row: {
84
+ gap: spacing.sm,
85
+ },
86
+ chip: {
87
+ height: CHIP_HEIGHT,
88
+ borderRadius: radius.md,
89
+ overflow: 'hidden',
90
+ backgroundColor: palette.surfaceAlt,
91
+ borderWidth: StyleSheet.hairlineWidth,
92
+ borderColor: palette.border,
93
+ },
94
+ chipDisabled: {
95
+ opacity: 0.5,
96
+ },
97
+ thumbnail: {
98
+ width: CHIP_HEIGHT,
99
+ height: CHIP_HEIGHT,
100
+ },
101
+ file: {
102
+ width: 156,
103
+ height: CHIP_HEIGHT,
104
+ justifyContent: 'center',
105
+ paddingStart: spacing.md,
106
+ // Clear of the remove badge in the top corner.
107
+ paddingEnd: spacing.xl + spacing.sm,
108
+ },
109
+ fileName: {
110
+ textAlign: START_ALIGN,
111
+ fontSize: fontSize.meta,
112
+ fontWeight: '600',
113
+ color: palette.ink,
114
+ },
115
+ fileSize: {
116
+ textAlign: START_ALIGN,
117
+ fontSize: fontSize.caption,
118
+ color: palette.textMuted,
119
+ marginTop: 2,
120
+ },
121
+ remove: {
122
+ position: 'absolute',
123
+ top: spacing.xs,
124
+ end: spacing.xs,
125
+ width: 22,
126
+ height: 22,
127
+ borderRadius: 11,
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ backgroundColor: withAlpha(palette.ink, 0.72),
131
+ },
132
+ removePressed: {
133
+ opacity: 0.7,
134
+ },
135
+ });
@@ -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
+ });