@repzo/widget-react-native 1.2.2 → 1.3.0
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 +41 -0
- package/README.md +35 -29
- package/android/build.gradle +22 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
- package/expo-module.config.json +9 -0
- package/ios/RepzoVoicePlayer.swift +175 -0
- package/ios/RepzoWidgetAudio.podspec +26 -0
- package/ios/RepzoWidgetAudioModule.swift +65 -0
- package/lib/commonjs/audio/native-player.js +97 -0
- package/lib/commonjs/audio/native-player.js.map +1 -0
- package/lib/commonjs/audio/voice.js +34 -7
- package/lib/commonjs/audio/voice.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +23 -1
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +1 -3
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +17 -35
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
- package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/commonjs/ui/components/VoiceNote.js +8 -9
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +6 -4
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -7
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/audio/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +32 -6
- package/lib/module/audio/voice.js.map +1 -1
- package/lib/module/state/widget-store.js +23 -1
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +1 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +18 -36
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/MessagesSkeleton.js +124 -0
- package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/module/ui/components/VoiceNote.js +9 -10
- package/lib/module/ui/components/VoiceNote.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +6 -4
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +5 -7
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
- package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +11 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -7
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/audio/native-player.d.ts +36 -0
- package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/module/audio/voice.d.ts +17 -4
- package/lib/typescript/module/audio/voice.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +11 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -7
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/package.json +10 -3
- package/react-native.config.js +12 -0
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +30 -6
- package/src/state/widget-store.ts +24 -1
- package/src/ui/WidgetProvider.tsx +1 -3
- package/src/ui/components/ImageViewer.tsx +11 -30
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +6 -14
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/screens/chat/ChatView.styles.ts +5 -7
- package/src/ui/screens/chat/ChatView.tsx +4 -3
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An image the visitor can pinch to zoom, drag around once zoomed, and
|
|
3
|
+
* double-tap to zoom in on a spot (and again to zoom back out). Built on
|
|
4
|
+
* PanResponder and Animated, so it behaves the same on iOS and Android
|
|
5
|
+
* without a gesture library in the host app.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef, useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
Animated,
|
|
11
|
+
PanResponder,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
View,
|
|
14
|
+
type GestureResponderEvent,
|
|
15
|
+
type ImageLoadEventData,
|
|
16
|
+
type LayoutChangeEvent,
|
|
17
|
+
type NativeSyntheticEvent,
|
|
18
|
+
} from 'react-native';
|
|
19
|
+
|
|
20
|
+
import { useReduceMotion } from '../hooks/use-reduce-motion';
|
|
21
|
+
|
|
22
|
+
export const MAX_ZOOM = 4;
|
|
23
|
+
export const DOUBLE_TAP_ZOOM = 2.5;
|
|
24
|
+
const DOUBLE_TAP_MS = 300;
|
|
25
|
+
const DOUBLE_TAP_SLOP = 30;
|
|
26
|
+
const TAP_SLOP = 10;
|
|
27
|
+
|
|
28
|
+
/** Scale and offset (from the centre of the view) of the image. */
|
|
29
|
+
export interface ZoomView {
|
|
30
|
+
scale: number;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Size {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The size the image is drawn at with `contain`, before any zoom. */
|
|
41
|
+
export function fittedSize(box: Size, image: Size | null): Size {
|
|
42
|
+
if (!image || image.width <= 0 || image.height <= 0) return box;
|
|
43
|
+
const fit = Math.min(box.width / image.width, box.height / image.height);
|
|
44
|
+
return { width: image.width * fit, height: image.height * fit };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keeps the image inside the screen: zoom between 1× and `MAX_ZOOM`, and no
|
|
49
|
+
* empty space past an edge the zoomed image is wider (or taller) than.
|
|
50
|
+
*/
|
|
51
|
+
export function clampView(view: ZoomView, box: Size, content: Size): ZoomView {
|
|
52
|
+
const scale = Math.min(MAX_ZOOM, Math.max(1, view.scale));
|
|
53
|
+
const maxX = Math.max(0, (content.width * scale - box.width) / 2);
|
|
54
|
+
const maxY = Math.max(0, (content.height * scale - box.height) / 2);
|
|
55
|
+
return {
|
|
56
|
+
scale,
|
|
57
|
+
x: Math.min(maxX, Math.max(-maxX, view.x)),
|
|
58
|
+
y: Math.min(maxY, Math.max(-maxY, view.y)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The view at `scale` that keeps the spot that was under `from` (a point
|
|
64
|
+
* relative to the centre, in the `start` view) under `to`. Pinching zooms
|
|
65
|
+
* around the fingers this way, and moving them pans at the same time.
|
|
66
|
+
*/
|
|
67
|
+
export function zoomAround(start: ZoomView, scale: number, from: { x: number; y: number }, to: { x: number; y: number }): ZoomView {
|
|
68
|
+
const spotX = (from.x - start.x) / start.scale;
|
|
69
|
+
const spotY = (from.y - start.y) / start.scale;
|
|
70
|
+
return { scale, x: to.x - scale * spotX, y: to.y - scale * spotY };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Touch {
|
|
74
|
+
pageX: number;
|
|
75
|
+
pageY: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const centreOf = (touches: Touch[]) => ({
|
|
79
|
+
x: touches.reduce((sum, t) => sum + t.pageX, 0) / touches.length,
|
|
80
|
+
y: touches.reduce((sum, t) => sum + t.pageY, 0) / touches.length,
|
|
81
|
+
});
|
|
82
|
+
const spreadOf = (touches: Touch[]) =>
|
|
83
|
+
touches.length < 2 ? 0 : Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
|
|
84
|
+
|
|
85
|
+
export function ZoomableImage({
|
|
86
|
+
uri,
|
|
87
|
+
accessibilityLabel,
|
|
88
|
+
onLoad,
|
|
89
|
+
onError,
|
|
90
|
+
}: {
|
|
91
|
+
uri: string;
|
|
92
|
+
accessibilityLabel: string;
|
|
93
|
+
onLoad: () => void;
|
|
94
|
+
onError: () => void;
|
|
95
|
+
}) {
|
|
96
|
+
const reduceMotion = useReduceMotion();
|
|
97
|
+
const [scale] = useState(() => new Animated.Value(1));
|
|
98
|
+
const [x] = useState(() => new Animated.Value(0));
|
|
99
|
+
const [y] = useState(() => new Animated.Value(0));
|
|
100
|
+
|
|
101
|
+
// Gesture state, read by the (stable) PanResponder below.
|
|
102
|
+
const g = useRef({
|
|
103
|
+
view: { scale: 1, x: 0, y: 0 } as ZoomView,
|
|
104
|
+
box: { width: 0, height: 0 } as Size,
|
|
105
|
+
// Where the box sits on screen, so touches can be measured from its centre.
|
|
106
|
+
origin: { x: 0, y: 0 },
|
|
107
|
+
image: null as Size | null,
|
|
108
|
+
// The view, finger centre and finger spread when the current set of fingers went down.
|
|
109
|
+
start: { view: { scale: 1, x: 0, y: 0 } as ZoomView, centre: { x: 0, y: 0 }, spread: 0, count: 0 },
|
|
110
|
+
moved: false,
|
|
111
|
+
grantedAt: 0,
|
|
112
|
+
lastTap: null as { at: number; x: number; y: number } | null,
|
|
113
|
+
reduceMotion: false as boolean | null,
|
|
114
|
+
native: false,
|
|
115
|
+
}).current;
|
|
116
|
+
g.reduceMotion = reduceMotion;
|
|
117
|
+
|
|
118
|
+
const boxRef = useRef<View>(null);
|
|
119
|
+
|
|
120
|
+
const show = (view: ZoomView, animate: boolean) => {
|
|
121
|
+
g.view = view;
|
|
122
|
+
if (!animate || g.reduceMotion !== false) {
|
|
123
|
+
scale.setValue(view.scale);
|
|
124
|
+
x.setValue(view.x);
|
|
125
|
+
y.setValue(view.y);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const spring = (value: Animated.Value, toValue: number) =>
|
|
129
|
+
Animated.spring(value, { toValue, useNativeDriver: true, bounciness: 0, speed: 18 });
|
|
130
|
+
Animated.parallel([spring(scale, view.scale), spring(x, view.x), spring(y, view.y)]).start();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Hands the transform to the native driver as soon as the image is on
|
|
134
|
+
// screen. Until a native animation had run, Android (new architecture) didn't
|
|
135
|
+
// draw finger moves, so pinching only worked after a double tap.
|
|
136
|
+
const goNative = () => {
|
|
137
|
+
if (g.native) return;
|
|
138
|
+
g.native = true;
|
|
139
|
+
const settle = (value: Animated.Value, toValue: number) =>
|
|
140
|
+
Animated.spring(value, { toValue, useNativeDriver: true, bounciness: 0, speed: 18 });
|
|
141
|
+
Animated.parallel([settle(scale, g.view.scale), settle(x, g.view.x), settle(y, g.view.y)]).start();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const fromCentre = (point: { x: number; y: number }) => ({
|
|
145
|
+
x: point.x - g.origin.x - g.box.width / 2,
|
|
146
|
+
y: point.y - g.origin.y - g.box.height / 2,
|
|
147
|
+
});
|
|
148
|
+
const content = () => fittedSize(g.box, g.image);
|
|
149
|
+
|
|
150
|
+
const begin = (touches: Touch[]) => {
|
|
151
|
+
g.start = { view: g.view, centre: fromCentre(centreOf(touches)), spread: spreadOf(touches), count: touches.length };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const pan = useRef(
|
|
155
|
+
PanResponder.create({
|
|
156
|
+
onStartShouldSetPanResponder: () => true,
|
|
157
|
+
onMoveShouldSetPanResponder: () => true,
|
|
158
|
+
// Never hand the gesture over: nothing else in the full-screen viewer
|
|
159
|
+
// wants it, and giving it up at 1× ended a pinch as it started.
|
|
160
|
+
onPanResponderTerminationRequest: () => false,
|
|
161
|
+
onPanResponderGrant: (event: GestureResponderEvent) => {
|
|
162
|
+
g.moved = false;
|
|
163
|
+
g.grantedAt = Date.now();
|
|
164
|
+
begin(event.nativeEvent.touches);
|
|
165
|
+
},
|
|
166
|
+
onPanResponderMove: (event: GestureResponderEvent, gesture) => {
|
|
167
|
+
const touches = event.nativeEvent.touches;
|
|
168
|
+
if (touches.length === 0) return;
|
|
169
|
+
// A finger landed or lifted: carry on from where the image is now.
|
|
170
|
+
if (touches.length !== g.start.count) begin(touches);
|
|
171
|
+
if (Math.abs(gesture.dx) > TAP_SLOP || Math.abs(gesture.dy) > TAP_SLOP || touches.length > 1) {
|
|
172
|
+
g.moved = true;
|
|
173
|
+
}
|
|
174
|
+
const centre = fromCentre(centreOf(touches));
|
|
175
|
+
if (touches.length > 1 && g.start.spread > 0) {
|
|
176
|
+
// Past the limits while pinching, then springs back on release.
|
|
177
|
+
const zoom = Math.min(MAX_ZOOM * 1.25, Math.max(0.75, (g.start.view.scale * spreadOf(touches)) / g.start.spread));
|
|
178
|
+
show(zoomAround(g.start.view, zoom, g.start.centre, centre), false);
|
|
179
|
+
} else if (g.start.view.scale > 1) {
|
|
180
|
+
// One finger drags the zoomed image; at 1× there's nothing to drag.
|
|
181
|
+
const dragged = {
|
|
182
|
+
...g.start.view,
|
|
183
|
+
x: g.start.view.x + centre.x - g.start.centre.x,
|
|
184
|
+
y: g.start.view.y + centre.y - g.start.centre.y,
|
|
185
|
+
};
|
|
186
|
+
show(clampView(dragged, g.box, content()), false);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
onPanResponderRelease: (event: GestureResponderEvent) => {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const { pageX, pageY } = event.nativeEvent;
|
|
192
|
+
const isTap = !g.moved && now - g.grantedAt < DOUBLE_TAP_MS;
|
|
193
|
+
const last = g.lastTap;
|
|
194
|
+
if (
|
|
195
|
+
isTap &&
|
|
196
|
+
last &&
|
|
197
|
+
now - last.at < DOUBLE_TAP_MS &&
|
|
198
|
+
Math.hypot(pageX - last.x, pageY - last.y) < DOUBLE_TAP_SLOP
|
|
199
|
+
) {
|
|
200
|
+
g.lastTap = null;
|
|
201
|
+
// Zooming in keeps the tapped spot under the finger (as far as the edges allow).
|
|
202
|
+
const tapped = fromCentre({ x: pageX, y: pageY });
|
|
203
|
+
const target =
|
|
204
|
+
g.view.scale > 1.01
|
|
205
|
+
? { scale: 1, x: 0, y: 0 }
|
|
206
|
+
: zoomAround({ scale: 1, x: 0, y: 0 }, DOUBLE_TAP_ZOOM, tapped, tapped);
|
|
207
|
+
show(clampView(target, g.box, content()), true);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
g.lastTap = isTap ? { at: now, x: pageX, y: pageY } : null;
|
|
211
|
+
show(clampView(g.view, g.box, content()), true);
|
|
212
|
+
},
|
|
213
|
+
onPanResponderTerminate: () => show(clampView(g.view, g.box, content()), true),
|
|
214
|
+
})
|
|
215
|
+
).current;
|
|
216
|
+
|
|
217
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
218
|
+
const { width, height } = event.nativeEvent.layout;
|
|
219
|
+
g.box = { width, height };
|
|
220
|
+
goNative();
|
|
221
|
+
boxRef.current?.measureInWindow?.((left, top) => {
|
|
222
|
+
g.origin = { x: left, y: top };
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleLoad = (event: NativeSyntheticEvent<ImageLoadEventData>) => {
|
|
227
|
+
const source = event?.nativeEvent?.source;
|
|
228
|
+
if (source && source.width > 0 && source.height > 0) g.image = { width: source.width, height: source.height };
|
|
229
|
+
onLoad();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<View ref={boxRef} style={styles.box} onLayout={onLayout} {...pan.panHandlers}>
|
|
234
|
+
<Animated.Image
|
|
235
|
+
source={{ uri }}
|
|
236
|
+
style={[styles.image, { transform: [{ translateX: x }, { translateY: y }, { scale }] }]}
|
|
237
|
+
resizeMode="contain"
|
|
238
|
+
accessibilityLabel={accessibilityLabel}
|
|
239
|
+
accessibilityIgnoresInvertColors
|
|
240
|
+
onLoad={handleLoad}
|
|
241
|
+
onError={onError}
|
|
242
|
+
/>
|
|
243
|
+
</View>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const styles = StyleSheet.create({
|
|
248
|
+
box: { flex: 1, overflow: 'hidden' },
|
|
249
|
+
image: { width: '100%', height: '100%' },
|
|
250
|
+
});
|
|
@@ -84,6 +84,11 @@ export const styles = StyleSheet.create({
|
|
|
84
84
|
flexGrow: 1,
|
|
85
85
|
justifyContent: 'center',
|
|
86
86
|
},
|
|
87
|
+
// Placeholders sit at the bottom, where the newest messages will land.
|
|
88
|
+
messagesContentLoading: {
|
|
89
|
+
flexGrow: 1,
|
|
90
|
+
justifyContent: 'flex-end',
|
|
91
|
+
},
|
|
87
92
|
emptyState: {
|
|
88
93
|
alignItems: 'center',
|
|
89
94
|
paddingHorizontal: spacing.xl,
|
|
@@ -117,13 +122,6 @@ export const styles = StyleSheet.create({
|
|
|
117
122
|
fontSize: fontSize.meta,
|
|
118
123
|
fontWeight: '600',
|
|
119
124
|
},
|
|
120
|
-
stateText: {
|
|
121
|
-
textAlign: 'center',
|
|
122
|
-
paddingVertical: 40,
|
|
123
|
-
paddingHorizontal: spacing.xl,
|
|
124
|
-
color: palette.textSubtle,
|
|
125
|
-
fontSize: fontSize.meta,
|
|
126
|
-
},
|
|
127
125
|
messageRow: {
|
|
128
126
|
maxWidth: '100%',
|
|
129
127
|
},
|
|
@@ -62,6 +62,7 @@ import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
|
|
|
62
62
|
import { isPlayableAudio } from '../../../audio/voice';
|
|
63
63
|
import { VoiceNote, type VoiceNoteLabels } from '../../components/VoiceNote';
|
|
64
64
|
import { FileCard } from '../../components/FileCard';
|
|
65
|
+
import { MessagesSkeleton } from '../../components/MessagesSkeleton';
|
|
65
66
|
import { backDirection, isRTL } from '../../utils/rtl';
|
|
66
67
|
import { styles } from './ChatView.styles';
|
|
67
68
|
import type { ChatLabels, ChatViewProps } from './ChatView.types';
|
|
@@ -331,6 +332,7 @@ export function ChatView({
|
|
|
331
332
|
const headerTextColor = contrastingTextColor(color);
|
|
332
333
|
const onDarkHeader = headerTextColor === palette.white;
|
|
333
334
|
const isEmptyThread = !loading && messages.length === 0;
|
|
335
|
+
const isLoadingThread = loading && messages.length === 0;
|
|
334
336
|
const agentIsActive = agentOnline ?? isOnline;
|
|
335
337
|
// "Waiting for a teammate" until someone replies: no teammate in the header
|
|
336
338
|
// and the visitor spoke last (bot replies before that still count as waiting).
|
|
@@ -439,6 +441,7 @@ export function ChatView({
|
|
|
439
441
|
contentContainerStyle={[
|
|
440
442
|
styles.messagesContent,
|
|
441
443
|
isEmptyThread && styles.messagesContentEmpty,
|
|
444
|
+
isLoadingThread && styles.messagesContentLoading,
|
|
442
445
|
]}
|
|
443
446
|
keyboardShouldPersistTaps="handled"
|
|
444
447
|
// Interactive dismissal drags the keyboard down under the finger while
|
|
@@ -455,9 +458,7 @@ export function ChatView({
|
|
|
455
458
|
</Pressable>
|
|
456
459
|
) : null}
|
|
457
460
|
|
|
458
|
-
{
|
|
459
|
-
<Text style={styles.stateText}>{labels.loading}</Text>
|
|
460
|
-
) : null}
|
|
461
|
+
{isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
|
|
461
462
|
|
|
462
463
|
{isEmptyThread ? (
|
|
463
464
|
<View style={styles.emptyState}>
|