@repzo/widget-react-native 1.2.1 → 1.2.2

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 (117) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +31 -0
  3. package/lib/commonjs/api/client.js +47 -2
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/audio/voice.js +119 -0
  6. package/lib/commonjs/audio/voice.js.map +1 -0
  7. package/lib/commonjs/i18n.js +6 -0
  8. package/lib/commonjs/i18n.js.map +1 -1
  9. package/lib/commonjs/locales.js +4 -0
  10. package/lib/commonjs/locales.js.map +1 -1
  11. package/lib/commonjs/state/use-widget-state.js +31 -0
  12. package/lib/commonjs/state/use-widget-state.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +22 -1
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/ui/WidgetProvider.js +6 -0
  16. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  17. package/lib/commonjs/ui/components/FileCard.js +134 -0
  18. package/lib/commonjs/ui/components/FileCard.js.map +1 -0
  19. package/lib/commonjs/ui/components/Launcher.js +15 -1
  20. package/lib/commonjs/ui/components/Launcher.js.map +1 -1
  21. package/lib/commonjs/ui/components/VoiceNote.js +354 -0
  22. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
  23. package/lib/commonjs/ui/components/icons.js +57 -0
  24. package/lib/commonjs/ui/components/icons.js.map +1 -1
  25. package/lib/commonjs/ui/screens/chat/ChatView.js +33 -14
  26. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  27. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +0 -9
  28. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  29. package/lib/module/api/client.js +48 -2
  30. package/lib/module/api/client.js.map +1 -1
  31. package/lib/module/audio/voice.js +110 -0
  32. package/lib/module/audio/voice.js.map +1 -0
  33. package/lib/module/i18n.js +6 -0
  34. package/lib/module/i18n.js.map +1 -1
  35. package/lib/module/locales.js +4 -0
  36. package/lib/module/locales.js.map +1 -1
  37. package/lib/module/state/use-widget-state.js +30 -1
  38. package/lib/module/state/use-widget-state.js.map +1 -1
  39. package/lib/module/state/widget-store.js +22 -1
  40. package/lib/module/state/widget-store.js.map +1 -1
  41. package/lib/module/ui/WidgetProvider.js +6 -0
  42. package/lib/module/ui/WidgetProvider.js.map +1 -1
  43. package/lib/module/ui/components/FileCard.js +130 -0
  44. package/lib/module/ui/components/FileCard.js.map +1 -0
  45. package/lib/module/ui/components/Launcher.js +16 -2
  46. package/lib/module/ui/components/Launcher.js.map +1 -1
  47. package/lib/module/ui/components/VoiceNote.js +350 -0
  48. package/lib/module/ui/components/VoiceNote.js.map +1 -0
  49. package/lib/module/ui/components/icons.js +55 -0
  50. package/lib/module/ui/components/icons.js.map +1 -1
  51. package/lib/module/ui/screens/chat/ChatView.js +33 -14
  52. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  53. package/lib/module/ui/screens/chat/ChatView.styles.js +0 -9
  54. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  55. package/lib/typescript/commonjs/api/client.d.ts +12 -0
  56. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/audio/voice.d.ts +29 -0
  58. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/i18n.d.ts +4 -0
  60. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/state/use-widget-state.d.ts +8 -0
  63. package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/state/widget-store.d.ts +2 -0
  65. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/components/FileCard.d.ts +21 -0
  68. package/lib/typescript/commonjs/ui/components/FileCard.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +32 -0
  71. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/icons.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +0 -9
  76. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  79. package/lib/typescript/module/api/client.d.ts +12 -0
  80. package/lib/typescript/module/api/client.d.ts.map +1 -1
  81. package/lib/typescript/module/audio/voice.d.ts +29 -0
  82. package/lib/typescript/module/audio/voice.d.ts.map +1 -0
  83. package/lib/typescript/module/i18n.d.ts +4 -0
  84. package/lib/typescript/module/i18n.d.ts.map +1 -1
  85. package/lib/typescript/module/locales.d.ts.map +1 -1
  86. package/lib/typescript/module/state/use-widget-state.d.ts +8 -0
  87. package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -1
  88. package/lib/typescript/module/state/widget-store.d.ts +2 -0
  89. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/components/FileCard.d.ts +21 -0
  92. package/lib/typescript/module/ui/components/FileCard.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/components/VoiceNote.d.ts +32 -0
  95. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
  96. package/lib/typescript/module/ui/components/icons.d.ts +4 -0
  97. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +0 -9
  100. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  101. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  102. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  103. package/package.json +5 -1
  104. package/src/api/client.ts +56 -2
  105. package/src/audio/voice.ts +103 -0
  106. package/src/i18n.ts +6 -0
  107. package/src/locales.ts +4 -0
  108. package/src/state/use-widget-state.ts +30 -1
  109. package/src/state/widget-store.ts +22 -1
  110. package/src/ui/WidgetProvider.tsx +6 -0
  111. package/src/ui/components/FileCard.tsx +111 -0
  112. package/src/ui/components/Launcher.tsx +7 -2
  113. package/src/ui/components/VoiceNote.tsx +363 -0
  114. package/src/ui/components/icons.tsx +33 -0
  115. package/src/ui/screens/chat/ChatView.styles.ts +0 -9
  116. package/src/ui/screens/chat/ChatView.tsx +39 -13
  117. package/src/ui/screens/chat/ChatView.types.ts +4 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * A voice note (or any audio attachment) played inside the chat, laid out the
3
+ * way messaging apps do it: a play button beside a waveform the visitor can
4
+ * tap or drag to jump through the note, the time under it, and a speed chip
5
+ * once the note has been started. Only rendered when the host app has
6
+ * `expo-audio` (see `src/audio/voice.ts`); without it audio stays a file row.
7
+ */
8
+
9
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
10
+ import {
11
+ ActivityIndicator,
12
+ PanResponder,
13
+ Pressable,
14
+ StyleSheet,
15
+ Text,
16
+ View,
17
+ type GestureResponderHandlers,
18
+ type LayoutChangeEvent,
19
+ } from 'react-native';
20
+
21
+ import {
22
+ audioPlayerOptions,
23
+ claimVoicePlayback,
24
+ formatVoiceClock,
25
+ voicePlaybackModule,
26
+ voiceWaveformLevels,
27
+ } from '../../audio/voice';
28
+ import { PauseIcon, PlayIcon } from './icons';
29
+ import { fontSize, palette, withAlpha } from '../styles/theme';
30
+
31
+ export interface VoiceNoteLabels {
32
+ play: string;
33
+ pause: string;
34
+ speed: string;
35
+ }
36
+
37
+ interface VoiceNoteProps {
38
+ url: string;
39
+ /** Keeps the made-up waveform the same for this note on every render. */
40
+ seed: string;
41
+ /** The workspace color. */
42
+ color: string;
43
+ /** In a visitor bubble, which is filled with `color`: the player turns white. */
44
+ onBrandBubble: boolean;
45
+ labels: VoiceNoteLabels;
46
+ /** The note can't be played (expired link, unsupported format). */
47
+ onError: () => void;
48
+ }
49
+
50
+ const BAR_COUNT = 32;
51
+ const BAR_WIDTH = 3;
52
+ const BAR_MIN_HEIGHT = 4;
53
+ const BAR_MAX_HEIGHT = 24;
54
+ const BUTTON_SIZE = 36;
55
+ const KNOB_SIZE = 12;
56
+ const SPEEDS = [1, 1.5, 2] as const;
57
+
58
+ /**
59
+ * The player is only created once the visitor taps play. The whole thread
60
+ * renders at once, so a player per note would download every note in the
61
+ * conversation as soon as the chat opened.
62
+ */
63
+ export function VoiceNote(props: VoiceNoteProps) {
64
+ const [started, setStarted] = useState(false);
65
+ if (started) return <ActiveVoiceNote {...props} />;
66
+ return (
67
+ <VoiceNoteLayout
68
+ {...props}
69
+ playing={false}
70
+ loading={false}
71
+ progress={0}
72
+ time={null}
73
+ onToggle={() => setStarted(true)}
74
+ />
75
+ );
76
+ }
77
+
78
+ /** A note that has been started: owns its expo-audio player and plays on mount. */
79
+ function ActiveVoiceNote(props: VoiceNoteProps) {
80
+ const { url, onError } = props;
81
+ const audio = voicePlaybackModule();
82
+ const player = audio.useAudioPlayer({ uri: url }, audioPlayerOptions());
83
+ const status = audio.useAudioPlayerStatus(player);
84
+ const [speedIndex, setSpeedIndex] = useState(0);
85
+ const [scrub, setScrub] = useState<number | null>(null);
86
+ const [trackWidth, setTrackWidth] = useState(0);
87
+ // Whether the visitor asked for playback, so a note still loading shows a
88
+ // spinner, and one they paused while it loaded doesn't.
89
+ const [wantsPlay, setWantsPlay] = useState(true);
90
+ // This note's hold on playback: starting it pauses any other note.
91
+ const releasePlayback = useRef<(() => void) | null>(null);
92
+
93
+ const duration = Math.max(Number(status?.duration) || 0, 0);
94
+ const currentTime = Math.max(Number(status?.currentTime) || 0, 0);
95
+ const playing = !!status?.playing;
96
+ const finished = !!status?.didJustFinish || (duration > 0 && currentTime >= duration - 0.1);
97
+ // `isLoaded` / `isBuffering` are compared strictly: an expo-audio that
98
+ // doesn't report them never gets a spinner that can't go away.
99
+ const loading = wantsPlay && !playing && (status?.isLoaded === false || status?.isBuffering === true);
100
+
101
+ const pause = () => {
102
+ setWantsPlay(false);
103
+ player.pause();
104
+ };
105
+ const start = () => {
106
+ releasePlayback.current?.();
107
+ releasePlayback.current = claimVoicePlayback(pause);
108
+ setWantsPlay(true);
109
+ player.play();
110
+ };
111
+
112
+ // The visitor tapped play on the idle note, which created this player.
113
+ // Neither callback changes for the player's lifetime.
114
+ useEffect(() => {
115
+ start();
116
+ return () => releasePlayback.current?.();
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, []);
119
+
120
+ // The error is only on the status update that reports it (the next one
121
+ // clears it), so it is acted on as it arrives.
122
+ const error = status?.error;
123
+ useEffect(() => {
124
+ if (error) onError();
125
+ }, [error, onError]);
126
+
127
+ const togglePlay = async () => {
128
+ if (playing || loading) {
129
+ pause();
130
+ return;
131
+ }
132
+ if (finished) await player.seekTo(0);
133
+ start();
134
+ };
135
+
136
+ const cycleSpeed = () => {
137
+ const next = (speedIndex + 1) % SPEEDS.length;
138
+ setSpeedIndex(next);
139
+ try {
140
+ player.setPlaybackRate(SPEEDS[next], 'high');
141
+ } catch {
142
+ // Some versions take no pitch-correction argument.
143
+ try {
144
+ player.setPlaybackRate(SPEEDS[next]);
145
+ } catch {
146
+ // Speed unsupported: the note keeps playing at 1×.
147
+ }
148
+ }
149
+ };
150
+
151
+ // The latest values for the (stable) PanResponder below.
152
+ const latest = useRef({ trackWidth, duration, player });
153
+ latest.current = { trackWidth, duration, player };
154
+ const ratioAt = (x: number) => {
155
+ const width = latest.current.trackWidth;
156
+ return width > 0 ? Math.min(1, Math.max(0, x / width)) : 0;
157
+ };
158
+ // Where the finger first touched the track. Later positions are that plus
159
+ // the drag distance: a move event's own `locationX` is measured from
160
+ // whatever view is under the finger, not from the track.
161
+ const startX = useRef(0);
162
+ const pan = useRef(
163
+ PanResponder.create({
164
+ onStartShouldSetPanResponder: () => true,
165
+ onMoveShouldSetPanResponder: () => true,
166
+ onPanResponderGrant: (event) => {
167
+ startX.current = event.nativeEvent.locationX;
168
+ setScrub(ratioAt(startX.current));
169
+ },
170
+ onPanResponderMove: (_, gesture) => setScrub(ratioAt(startX.current + gesture.dx)),
171
+ onPanResponderRelease: (_, gesture) => {
172
+ const { duration: length, player: current } = latest.current;
173
+ if (length > 0) void current.seekTo(ratioAt(startX.current + gesture.dx) * length);
174
+ setScrub(null);
175
+ },
176
+ onPanResponderTerminate: () => setScrub(null),
177
+ })
178
+ ).current;
179
+
180
+ const displayTime =
181
+ scrub != null ? scrub * duration : playing ? currentTime : finished ? duration : currentTime || duration;
182
+ // The chip joins once the note was started, so an untouched note shows only its length.
183
+ const showSpeed = speedIndex > 0 || playing || (currentTime > 0 && !finished);
184
+
185
+ return (
186
+ <VoiceNoteLayout
187
+ {...props}
188
+ playing={playing}
189
+ loading={loading}
190
+ progress={scrub ?? (duration > 0 ? Math.min(1, currentTime / duration) : 0)}
191
+ // Nothing to show until the player knows the note's length.
192
+ time={duration > 0 ? formatVoiceClock(displayTime) : null}
193
+ onToggle={() => void togglePlay()}
194
+ track={{ panHandlers: pan.panHandlers, onWidth: setTrackWidth }}
195
+ speed={showSpeed ? { value: SPEEDS[speedIndex], changed: speedIndex > 0, onPress: cycleSpeed } : null}
196
+ />
197
+ );
198
+ }
199
+
200
+ function VoiceNoteLayout({
201
+ seed,
202
+ color,
203
+ onBrandBubble,
204
+ labels,
205
+ playing,
206
+ loading,
207
+ progress,
208
+ time,
209
+ onToggle,
210
+ track,
211
+ speed,
212
+ }: VoiceNoteProps & {
213
+ playing: boolean;
214
+ loading: boolean;
215
+ /** 0–1 through the note. */
216
+ progress: number;
217
+ time: string | null;
218
+ onToggle: () => void;
219
+ /** Scrubbing; missing on a note that hasn't been started. */
220
+ track?: { panHandlers: GestureResponderHandlers; onWidth: (width: number) => void };
221
+ speed?: { value: number; changed: boolean; onPress: () => void } | null;
222
+ }) {
223
+ const accent = onBrandBubble ? palette.white : color;
224
+ const onAccent = onBrandBubble ? color : palette.white;
225
+ const trackColor = onBrandBubble ? withAlpha(palette.white, 0.4) : palette.disabled;
226
+ const subtle = onBrandBubble ? withAlpha(palette.white, 0.8) : palette.textMuted;
227
+ const levels = useMemo(() => voiceWaveformLevels(seed, BAR_COUNT), [seed]);
228
+ const [trackWidth, setTrackWidth] = useState(0);
229
+ const onTrackLayout = (event: LayoutChangeEvent) => {
230
+ setTrackWidth(event.nativeEvent.layout.width);
231
+ track?.onWidth(event.nativeEvent.layout.width);
232
+ };
233
+
234
+ // Bar i's centre sits at this share of the track; the playhead travels the same span.
235
+ const barCentre = (i: number) =>
236
+ trackWidth > BAR_WIDTH
237
+ ? (BAR_WIDTH / 2 + (i * (trackWidth - BAR_WIDTH)) / (BAR_COUNT - 1)) / trackWidth
238
+ : i / (BAR_COUNT - 1);
239
+ const knobOffset = trackWidth > 0 ? Math.round(progress * (trackWidth - KNOB_SIZE)) : 0;
240
+
241
+ let icon: ReactNode = <PlayIcon size={14} color={onAccent} />;
242
+ if (loading) icon = <ActivityIndicator size="small" color={onAccent} />;
243
+ else if (playing) icon = <PauseIcon size={14} color={onAccent} />;
244
+
245
+ return (
246
+ <View style={styles.root}>
247
+ <View style={styles.row}>
248
+ <Pressable
249
+ onPress={onToggle}
250
+ accessibilityRole="button"
251
+ accessibilityLabel={playing || loading ? labels.pause : labels.play}
252
+ accessibilityState={{ busy: loading }}
253
+ hitSlop={4}
254
+ style={({ pressed }) => [
255
+ styles.playButton,
256
+ { backgroundColor: accent, opacity: pressed ? 0.8 : 1 },
257
+ ]}>
258
+ {icon}
259
+ </Pressable>
260
+
261
+ <View
262
+ style={styles.track}
263
+ onLayout={onTrackLayout}
264
+ {...track?.panHandlers}>
265
+ {levels.map((level, i) => (
266
+ <View
267
+ key={i}
268
+ // Touches land on the track itself, so their position is measured from its edge.
269
+ pointerEvents="none"
270
+ style={[
271
+ styles.bar,
272
+ {
273
+ height: BAR_MIN_HEIGHT + level * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT),
274
+ backgroundColor: barCentre(i) <= progress ? accent : trackColor,
275
+ },
276
+ ]}
277
+ />
278
+ ))}
279
+ {trackWidth > 0 ? (
280
+ <View
281
+ pointerEvents="none"
282
+ style={[
283
+ styles.knob,
284
+ { backgroundColor: accent, transform: [{ translateX: knobOffset }] },
285
+ ]}
286
+ />
287
+ ) : null}
288
+ </View>
289
+ </View>
290
+
291
+ <View style={styles.meta}>
292
+ {time != null ? <Text style={[styles.time, { color: subtle }]}>{time}</Text> : null}
293
+ {speed ? (
294
+ <Pressable
295
+ onPress={speed.onPress}
296
+ accessibilityRole="button"
297
+ accessibilityLabel={labels.speed}
298
+ hitSlop={12}
299
+ style={({ pressed }) => [
300
+ styles.speed,
301
+ { backgroundColor: speed.changed ? accent : trackColor, opacity: pressed ? 0.6 : 1 },
302
+ ]}>
303
+ <Text
304
+ style={[
305
+ styles.speedText,
306
+ { color: speed.changed ? onAccent : onBrandBubble ? palette.white : palette.text },
307
+ ]}>
308
+ {speed.value}×
309
+ </Text>
310
+ </Pressable>
311
+ ) : null}
312
+ </View>
313
+ </View>
314
+ );
315
+ }
316
+
317
+ const styles = StyleSheet.create({
318
+ // A timeline runs left to right in every language, so RTL doesn't mirror it
319
+ // (the touch position and the playhead stay in the same coordinates).
320
+ root: { minWidth: 200, direction: 'ltr' },
321
+ row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
322
+ playButton: {
323
+ width: BUTTON_SIZE,
324
+ height: BUTTON_SIZE,
325
+ borderRadius: BUTTON_SIZE / 2,
326
+ alignItems: 'center',
327
+ justifyContent: 'center',
328
+ },
329
+ track: {
330
+ flex: 1,
331
+ minWidth: 0,
332
+ height: BUTTON_SIZE,
333
+ flexDirection: 'row',
334
+ alignItems: 'center',
335
+ justifyContent: 'space-between',
336
+ },
337
+ bar: { width: BAR_WIDTH, borderRadius: BAR_WIDTH / 2 },
338
+ knob: {
339
+ position: 'absolute',
340
+ left: 0,
341
+ width: KNOB_SIZE,
342
+ height: KNOB_SIZE,
343
+ borderRadius: KNOB_SIZE / 2,
344
+ },
345
+ meta: {
346
+ minHeight: 18,
347
+ marginTop: -2,
348
+ marginLeft: BUTTON_SIZE + 10,
349
+ flexDirection: 'row',
350
+ alignItems: 'center',
351
+ gap: 6,
352
+ },
353
+ time: { fontSize: fontSize.caption, fontWeight: '500', fontVariant: ['tabular-nums'] },
354
+ speed: {
355
+ minWidth: 30,
356
+ height: 18,
357
+ paddingHorizontal: 6,
358
+ borderRadius: 9,
359
+ alignItems: 'center',
360
+ justifyContent: 'center',
361
+ },
362
+ speedText: { fontSize: 11, fontWeight: '700', fontVariant: ['tabular-nums'] },
363
+ });
@@ -66,6 +66,39 @@ export function ChevronIcon({
66
66
  );
67
67
  }
68
68
 
69
+ /** A right-pointing triangle, drawn with borders — a voice note's play button. */
70
+ export function PlayIcon({ size = 16, color = palette.white, style }: IconProps) {
71
+ return (
72
+ <View style={[{ width: size, height: size }, styles.center, style]}>
73
+ <View
74
+ style={{
75
+ width: 0,
76
+ height: 0,
77
+ // The triangle's weight sits left of its box; nudge it to look centred.
78
+ marginLeft: size * 0.12,
79
+ borderLeftWidth: size * 0.72,
80
+ borderTopWidth: size * 0.42,
81
+ borderBottomWidth: size * 0.42,
82
+ borderLeftColor: color,
83
+ borderTopColor: 'transparent',
84
+ borderBottomColor: 'transparent',
85
+ }}
86
+ />
87
+ </View>
88
+ );
89
+ }
90
+
91
+ /** Two upright bars — a playing voice note's pause button. */
92
+ export function PauseIcon({ size = 16, color = palette.white, style }: IconProps) {
93
+ const bar: ViewStyle = { width: size * 0.26, height: size * 0.8, borderRadius: 1.5, backgroundColor: color };
94
+ return (
95
+ <View style={[{ width: size, height: size, flexDirection: 'row', gap: size * 0.2 }, styles.center, style]}>
96
+ <View style={bar} />
97
+ <View style={bar} />
98
+ </View>
99
+ );
100
+ }
101
+
69
102
  /** Arrow with a shaft — used for the "start a conversation" affordance. */
70
103
  export function ArrowRightIcon({ size = 16, color = palette.textMuted, style }: IconProps) {
71
104
  const thickness = Math.max(2, size * 0.12);
@@ -214,15 +214,6 @@ export const styles = StyleSheet.create({
214
214
  height: 140,
215
215
  borderRadius: radius.md,
216
216
  },
217
- attachmentFile: {
218
- borderWidth: StyleSheet.hairlineWidth,
219
- borderRadius: radius.md,
220
- paddingHorizontal: spacing.sm + 1,
221
- paddingVertical: spacing.sm - 1,
222
- },
223
- attachmentName: {
224
- fontSize: fontSize.meta,
225
- },
226
217
  failedText: {
227
218
  fontSize: fontSize.caption,
228
219
  marginTop: spacing.xs,
@@ -59,6 +59,9 @@ import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from '../..
59
59
  import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
60
60
  import { stripHtml } from '../../utils/html';
61
61
  import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
62
+ import { isPlayableAudio } from '../../../audio/voice';
63
+ import { VoiceNote, type VoiceNoteLabels } from '../../components/VoiceNote';
64
+ import { FileCard } from '../../components/FileCard';
62
65
  import { backDirection, isRTL } from '../../utils/rtl';
63
66
  import { styles } from './ChatView.styles';
64
67
  import type { ChatLabels, ChatViewProps } from './ChatView.types';
@@ -759,6 +762,9 @@ const MessageBubble = memo(function MessageBubble({
759
762
  key={attachment.id}
760
763
  attachment={attachment}
761
764
  isVisitor={isVisitor}
765
+ color={color}
766
+ voiceLabels={labels.voice}
767
+ openLabel={labels.openAttachment}
762
768
  onOpenImage={onOpenImage}
763
769
  />
764
770
  ))}
@@ -818,16 +824,24 @@ function receiptLabel(message: Message, labels: ChatLabels): string | undefined
818
824
  function AttachmentPreview({
819
825
  attachment,
820
826
  isVisitor,
827
+ color,
828
+ voiceLabels,
829
+ openLabel,
821
830
  onOpenImage,
822
831
  }: {
823
832
  attachment: WidgetAttachment;
824
833
  isVisitor: boolean;
834
+ color: string;
835
+ voiceLabels: VoiceNoteLabels;
836
+ /** Keeps `{name}`. */
837
+ openLabel: string;
825
838
  onOpenImage: (image: { url: string; name: string }) => void;
826
839
  }) {
827
840
  // `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
828
841
  // An unsent attachment shows its local file (or data URL): it renders, but
829
842
  // there is nothing to open until the server copy arrives.
830
843
  const [imageFailed, setImageFailed] = useState(false);
844
+ const [audioFailed, setAudioFailed] = useState(false);
831
845
  const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
832
846
  const openable = isHttpUrl(attachment.url);
833
847
  // Images open in the in-app viewer; other files (PDF, sheets) need the OS.
@@ -837,6 +851,23 @@ function AttachmentPreview({
837
851
  ? () => onOpenImage({ url: attachment.url, name: attachment.name })
838
852
  : () => void openExternalUrl(attachment.url);
839
853
 
854
+ // Voice notes from the team (and any other audio) play in place, like the
855
+ // web Messenger — when the app has expo-audio; otherwise the file row below.
856
+ // One the player can't load (expired link, a format the OS can't decode)
857
+ // falls back to that row, which opens it in the browser.
858
+ if (isPlayableAudio(attachment.contentType, attachment.url) && !audioFailed) {
859
+ return (
860
+ <VoiceNote
861
+ url={attachment.url}
862
+ seed={attachment.id}
863
+ color={color}
864
+ onBrandBubble={isVisitor}
865
+ labels={voiceLabels}
866
+ onError={() => setAudioFailed(true)}
867
+ />
868
+ );
869
+ }
870
+
840
871
  if (isImage) {
841
872
  return (
842
873
  <Pressable
@@ -858,20 +889,15 @@ function AttachmentPreview({
858
889
  }
859
890
 
860
891
  return (
861
- <Pressable
892
+ <FileCard
893
+ name={attachment.name}
894
+ contentType={attachment.contentType}
895
+ size={attachment.size}
896
+ color={color}
897
+ onBrandBubble={isVisitor}
898
+ openLabel={openLabel.replace('{name}', attachment.name)}
862
899
  onPress={open}
863
- disabled={!openable}
864
- accessibilityRole="button"
865
- style={[
866
- styles.attachmentFile,
867
- { borderColor: isVisitor ? withAlpha(palette.white, 0.35) : palette.border },
868
- ]}>
869
- <Text
870
- style={[styles.attachmentName, { color: isVisitor ? palette.white : palette.ink }]}
871
- numberOfLines={1}>
872
- {attachment.name}
873
- </Text>
874
- </Pressable>
900
+ />
875
901
  );
876
902
  }
877
903
 
@@ -12,6 +12,7 @@ import type {
12
12
  import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
13
13
  import type { ImageViewerLabels } from '../../components/ImageViewer';
14
14
  import type { RatingCardLabels } from '../../components/RatingCard';
15
+ import type { VoiceNoteLabels } from '../../components/VoiceNote';
15
16
 
16
17
  export interface ChatLabels {
17
18
  back: string;
@@ -49,6 +50,9 @@ export interface ChatLabels {
49
50
  rating: RatingCardLabels;
50
51
  attach: AttachLabels;
51
52
  viewer: ImageViewerLabels;
53
+ voice: VoiceNoteLabels;
54
+ /** Screen-reader label for a file card; keeps `{name}`. */
55
+ openAttachment: string;
52
56
  }
53
57
 
54
58
  /** Composer attachment copy. Limits are already filled in; `remove` keeps `{name}`. */