@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.
- package/CHANGELOG.md +28 -0
- package/README.md +31 -0
- package/lib/commonjs/api/client.js +47 -2
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/audio/voice.js +119 -0
- package/lib/commonjs/audio/voice.js.map +1 -0
- package/lib/commonjs/i18n.js +6 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +4 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/use-widget-state.js +31 -0
- package/lib/commonjs/state/use-widget-state.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +22 -1
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +6 -0
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/FileCard.js +134 -0
- package/lib/commonjs/ui/components/FileCard.js.map +1 -0
- package/lib/commonjs/ui/components/Launcher.js +15 -1
- package/lib/commonjs/ui/components/Launcher.js.map +1 -1
- package/lib/commonjs/ui/components/VoiceNote.js +354 -0
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +57 -0
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +33 -14
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +0 -9
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.js +48 -2
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/audio/voice.js +110 -0
- package/lib/module/audio/voice.js.map +1 -0
- package/lib/module/i18n.js +6 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +4 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/use-widget-state.js +30 -1
- package/lib/module/state/use-widget-state.js.map +1 -1
- package/lib/module/state/widget-store.js +22 -1
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +6 -0
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/FileCard.js +130 -0
- package/lib/module/ui/components/FileCard.js.map +1 -0
- package/lib/module/ui/components/Launcher.js +16 -2
- package/lib/module/ui/components/Launcher.js.map +1 -1
- package/lib/module/ui/components/VoiceNote.js +350 -0
- package/lib/module/ui/components/VoiceNote.js.map +1 -0
- package/lib/module/ui/components/icons.js +55 -0
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +33 -14
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +0 -9
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +12 -0
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/audio/voice.d.ts +29 -0
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +4 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/use-widget-state.d.ts +8 -0
- package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +2 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FileCard.d.ts +21 -0
- package/lib/typescript/commonjs/ui/components/FileCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +32 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +4 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +0 -9
- 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 +4 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +12 -0
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/audio/voice.d.ts +29 -0
- package/lib/typescript/module/audio/voice.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +4 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/use-widget-state.d.ts +8 -0
- package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +2 -0
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FileCard.d.ts +21 -0
- package/lib/typescript/module/ui/components/FileCard.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +32 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +4 -0
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +0 -9
- 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 +4 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/api/client.ts +56 -2
- package/src/audio/voice.ts +103 -0
- package/src/i18n.ts +6 -0
- package/src/locales.ts +4 -0
- package/src/state/use-widget-state.ts +30 -1
- package/src/state/widget-store.ts +22 -1
- package/src/ui/WidgetProvider.tsx +6 -0
- package/src/ui/components/FileCard.tsx +111 -0
- package/src/ui/components/Launcher.tsx +7 -2
- package/src/ui/components/VoiceNote.tsx +363 -0
- package/src/ui/components/icons.tsx +33 -0
- package/src/ui/screens/chat/ChatView.styles.ts +0 -9
- package/src/ui/screens/chat/ChatView.tsx +39 -13
- 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
|
-
<
|
|
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
|
-
|
|
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}`. */
|