@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
|
@@ -78,6 +78,10 @@ export declare const styles: {
|
|
|
78
78
|
flexGrow: number;
|
|
79
79
|
justifyContent: "center";
|
|
80
80
|
};
|
|
81
|
+
messagesContentLoading: {
|
|
82
|
+
flexGrow: number;
|
|
83
|
+
justifyContent: "flex-end";
|
|
84
|
+
};
|
|
81
85
|
emptyState: {
|
|
82
86
|
alignItems: "center";
|
|
83
87
|
paddingHorizontal: 20;
|
|
@@ -111,13 +115,6 @@ export declare const styles: {
|
|
|
111
115
|
fontSize: 13;
|
|
112
116
|
fontWeight: "600";
|
|
113
117
|
};
|
|
114
|
-
stateText: {
|
|
115
|
-
textAlign: "center";
|
|
116
|
-
paddingVertical: number;
|
|
117
|
-
paddingHorizontal: 20;
|
|
118
|
-
color: "#9ca3af";
|
|
119
|
-
fontSize: 13;
|
|
120
|
-
};
|
|
121
118
|
messageRow: {
|
|
122
119
|
maxWidth: "100%";
|
|
123
120
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatView.styles.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/screens/chat/ChatView.styles.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAOlF,eAAO,MAAM,MAAM
|
|
1
|
+
{"version":3,"file":"ChatView.styles.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/screens/chat/ChatView.styles.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAOlF,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwZjB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repzo/widget-react-native",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Repzo chat widget for React Native
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Repzo chat widget for React Native",
|
|
5
5
|
"source": "./src/index.tsx",
|
|
6
6
|
"main": "./lib/commonjs/index.js",
|
|
7
7
|
"module": "./lib/module/index.js",
|
|
@@ -30,7 +30,14 @@
|
|
|
30
30
|
"!**/__mocks__",
|
|
31
31
|
"!**/.*",
|
|
32
32
|
"metro.js",
|
|
33
|
-
"CHANGELOG.md"
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"android",
|
|
35
|
+
"!android/build",
|
|
36
|
+
"!android/.gradle",
|
|
37
|
+
"!android/.cxx",
|
|
38
|
+
"ios",
|
|
39
|
+
"expo-module.config.json",
|
|
40
|
+
"react-native.config.js"
|
|
34
41
|
],
|
|
35
42
|
"scripts": {
|
|
36
43
|
"test": "bob build && jest",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The native voice player is an Expo module, linked through
|
|
2
|
+
// expo-module.config.json. This keeps the React Native CLI from also trying
|
|
3
|
+
// to link it as a classic native module, which apps without Expo modules
|
|
4
|
+
// would fail to build. Without Expo modules, voice notes stay a file row.
|
|
5
|
+
module.exports = {
|
|
6
|
+
dependency: {
|
|
7
|
+
platforms: {
|
|
8
|
+
android: null,
|
|
9
|
+
ios: null,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's own voice-note player (`ios/` and `android/`), on the system
|
|
3
|
+
* players: AVPlayer on iOS, MediaPlayer on Android. It's an Expo module, so it
|
|
4
|
+
* exists in apps with Expo modules after a native build; in Expo Go, bare
|
|
5
|
+
* React Native apps and an over-the-air update to an app built with an older
|
|
6
|
+
* SDK it's missing, and `src/audio/voice.ts` falls back to `expo-audio` or the
|
|
7
|
+
* file row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
interface NativeAudioModule {
|
|
13
|
+
create(id: string, url: string): Promise<void>;
|
|
14
|
+
play(id: string): Promise<void>;
|
|
15
|
+
pause(id: string): Promise<void>;
|
|
16
|
+
seekTo(id: string, seconds: number): Promise<void>;
|
|
17
|
+
setRate(id: string, rate: number): Promise<void>;
|
|
18
|
+
release(id: string): Promise<void>;
|
|
19
|
+
addListener?(event: 'onStatus', listener: (status: NativeVoiceStatus) => void): { remove(): void };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the player reports; the same fields `expo-audio`'s status has. */
|
|
23
|
+
export interface NativeVoiceStatus {
|
|
24
|
+
id: string;
|
|
25
|
+
currentTime: number;
|
|
26
|
+
duration: number;
|
|
27
|
+
playing: boolean;
|
|
28
|
+
isLoaded: boolean;
|
|
29
|
+
isBuffering: boolean;
|
|
30
|
+
didJustFinish: boolean;
|
|
31
|
+
error?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadNativeAudio(): NativeAudioModule | null {
|
|
35
|
+
let mod: any = null;
|
|
36
|
+
try {
|
|
37
|
+
mod = require('expo-modules-core').requireOptionalNativeModule('RepzoWidgetAudio');
|
|
38
|
+
} catch {
|
|
39
|
+
mod = null;
|
|
40
|
+
}
|
|
41
|
+
// Without the JS package (an unusual setup), the module can still be installed.
|
|
42
|
+
mod = mod ?? (globalThis as any).expo?.modules?.RepzoWidgetAudio ?? null;
|
|
43
|
+
return mod && typeof mod.create === 'function' && typeof mod.play === 'function' ? mod : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nativeAudio = loadNativeAudio();
|
|
47
|
+
|
|
48
|
+
/** Subscribes to every player's status; null when events can't be received. */
|
|
49
|
+
function addStatusListener(listener: (status: NativeVoiceStatus) => void): { remove(): void } | null {
|
|
50
|
+
if (!nativeAudio) return null;
|
|
51
|
+
try {
|
|
52
|
+
if (typeof nativeAudio.addListener === 'function') return nativeAudio.addListener('onStatus', listener);
|
|
53
|
+
// Expo SDK 51 and older: native modules aren't event emitters themselves.
|
|
54
|
+
const { EventEmitter } = require('expo-modules-core');
|
|
55
|
+
return new EventEmitter(nativeAudio).addListener('onStatus', listener);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the app has the SDK's own player. */
|
|
62
|
+
export function hasNativeVoicePlayer(): boolean {
|
|
63
|
+
return nativeAudio != null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let nextPlayerId = 0;
|
|
67
|
+
|
|
68
|
+
const ignore = () => {};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A player for one note, released when the component goes away. Shaped like
|
|
72
|
+
* `expo-audio`'s `useAudioPlayer` + `useAudioPlayerStatus`, so the voice note
|
|
73
|
+
* drives either the same way.
|
|
74
|
+
*/
|
|
75
|
+
export function useNativeVoicePlayer(url: string) {
|
|
76
|
+
const id = useMemo(() => `repzo-voice-${(nextPlayerId += 1)}`, []);
|
|
77
|
+
const [status, setStatus] = useState<Partial<NativeVoiceStatus>>({ isLoaded: false });
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const audio = nativeAudio!;
|
|
81
|
+
const subscription = addStatusListener((next) => {
|
|
82
|
+
if (next?.id === id) setStatus(next);
|
|
83
|
+
});
|
|
84
|
+
if (!subscription) {
|
|
85
|
+
setStatus({ isLoaded: false, error: 'Voice notes need a newer Expo SDK' });
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
audio.create(id, url).catch((error: unknown) => setStatus({ isLoaded: false, error: String(error) }));
|
|
89
|
+
return () => {
|
|
90
|
+
subscription.remove();
|
|
91
|
+
audio.release(id).catch(ignore);
|
|
92
|
+
};
|
|
93
|
+
}, [id, url]);
|
|
94
|
+
|
|
95
|
+
const player = useMemo(
|
|
96
|
+
() => ({
|
|
97
|
+
play: () => void nativeAudio!.play(id).catch(ignore),
|
|
98
|
+
pause: () => void nativeAudio!.pause(id).catch(ignore),
|
|
99
|
+
seekTo: (seconds: number) => nativeAudio!.seekTo(id, seconds).catch(ignore),
|
|
100
|
+
setPlaybackRate: (rate: number) => void nativeAudio!.setRate(id, rate).catch(ignore),
|
|
101
|
+
}),
|
|
102
|
+
[id]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return { player, status };
|
|
106
|
+
}
|
package/src/audio/voice.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Voice notes (and any audio file) play inside the chat
|
|
3
|
-
* `
|
|
2
|
+
* Voice notes (and any audio file) play inside the chat with the SDK's own
|
|
3
|
+
* native player (`src/audio/native-player.ts`). Where that isn't built into
|
|
4
|
+
* the app (Expo Go, bare React Native, an app built with SDK 1.2.2 or older),
|
|
5
|
+
* they play with the host app's `expo-audio` if it has one; otherwise they
|
|
6
|
+
* stay a file row that opens in the browser.
|
|
4
7
|
*
|
|
5
8
|
* Resolved once at module load, like the attachment pickers: each `require` is
|
|
6
9
|
* static so Metro can see it, and with `allowOptionalDependencies` (see
|
|
@@ -8,6 +11,8 @@
|
|
|
8
11
|
* `try` around it catches.
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
import { hasNativeVoicePlayer, useNativeVoicePlayer } from './native-player';
|
|
15
|
+
|
|
11
16
|
let expoAudio: any = null;
|
|
12
17
|
try {
|
|
13
18
|
const mod = require('expo-audio');
|
|
@@ -31,9 +36,11 @@ try {
|
|
|
31
36
|
/** How often the playhead moves while a note plays. */
|
|
32
37
|
const UPDATE_INTERVAL_MS = 100;
|
|
33
38
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
/** Which player voice notes use in this app, or null when audio stays a file row. */
|
|
40
|
+
const backend: 'native' | 'expo-audio' | null = hasNativeVoicePlayer() ? 'native' : expoAudio ? 'expo-audio' : null;
|
|
41
|
+
|
|
42
|
+
export function voicePlaybackBackend(): 'native' | 'expo-audio' | null {
|
|
43
|
+
return backend;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/** The second argument to `useAudioPlayer` for the installed expo-audio. */
|
|
@@ -41,9 +48,26 @@ export function audioPlayerOptions(): unknown {
|
|
|
41
48
|
return takesOptionsObject ? { updateInterval: UPDATE_INTERVAL_MS } : UPDATE_INTERVAL_MS;
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
/** expo-audio's player for a note, on the same terms as the native one. */
|
|
52
|
+
function useExpoAudioPlayer(url: string): { player: any; status: any } {
|
|
53
|
+
const player = expoAudio.useAudioPlayer({ uri: url }, audioPlayerOptions());
|
|
54
|
+
const status = expoAudio.useAudioPlayerStatus(player);
|
|
55
|
+
return { player, status };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The player for one note: `play()`, `pause()`, `seekTo(seconds)`,
|
|
60
|
+
* `setPlaybackRate(rate)`, and its status (`currentTime`, `duration`,
|
|
61
|
+
* `playing`, `isLoaded`, `isBuffering`, `didJustFinish`, `error`). Only use it
|
|
62
|
+
* when `voicePlaybackBackend()` isn't null. Picked once, since the backend
|
|
63
|
+
* never changes while the app runs.
|
|
64
|
+
*/
|
|
65
|
+
export const useVoicePlayer: (url: string) => { player: any; status: any } =
|
|
66
|
+
backend === 'native' ? useNativeVoicePlayer : useExpoAudioPlayer;
|
|
67
|
+
|
|
44
68
|
/** Audio the chat can play in place: an `audio/*` attachment with a web address. */
|
|
45
69
|
export function isPlayableAudio(contentType: string | null | undefined, url: string | null | undefined): boolean {
|
|
46
|
-
return
|
|
70
|
+
return backend != null && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
// ─── One note at a time ─────────────────────────────────────────────────────
|
|
@@ -40,7 +40,7 @@ import type {
|
|
|
40
40
|
import { setOpenUrlHandler } from '../ui/utils/safe-url';
|
|
41
41
|
|
|
42
42
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
43
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
43
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.3.0';
|
|
44
44
|
|
|
45
45
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
46
46
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -216,6 +216,13 @@ export class WidgetStore {
|
|
|
216
216
|
|
|
217
217
|
private pendingConversationId: string | null = null;
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The screen a thread was opened from, so its back button returns there.
|
|
221
|
+
* Null when it was opened with the messenger closed (a push, a notification
|
|
222
|
+
* card, `presentConversation`): there is nothing to go back to.
|
|
223
|
+
*/
|
|
224
|
+
private chatOpenedFrom: 'home' | 'conversations' | null = null;
|
|
225
|
+
|
|
219
226
|
private appStateSubscription: { remove(): void } | null = null;
|
|
220
227
|
|
|
221
228
|
/** When the app last went to the background (0: hasn't this session). */
|
|
@@ -321,6 +328,14 @@ export class WidgetStore {
|
|
|
321
328
|
}
|
|
322
329
|
this.state = next;
|
|
323
330
|
|
|
331
|
+
// Recorded here because a thread opens from many places (home cards, the
|
|
332
|
+
// list, the Messages tab, posts) and each would otherwise have to say so.
|
|
333
|
+
const intoThread = next.view === 'chat' || next.view === 'prechat';
|
|
334
|
+
if (intoThread && previous.view !== 'chat' && previous.view !== 'prechat') {
|
|
335
|
+
this.chatOpenedFrom =
|
|
336
|
+
previous.isOpen && (previous.view === 'home' || previous.view === 'conversations') ? previous.view : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
324
339
|
if (next.unreadCount !== previous.unreadCount) {
|
|
325
340
|
this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, { count: next.unreadCount });
|
|
326
341
|
}
|
|
@@ -1524,6 +1539,14 @@ export class WidgetStore {
|
|
|
1524
1539
|
});
|
|
1525
1540
|
}
|
|
1526
1541
|
|
|
1542
|
+
/**
|
|
1543
|
+
* The thread's back button: the screen it was opened from, or the
|
|
1544
|
+
* conversation list when it was opened from outside the messenger.
|
|
1545
|
+
*/
|
|
1546
|
+
leaveConversation(): void {
|
|
1547
|
+
this.setView(this.chatOpenedFrom ?? (this.state.activeTab === 'messages' ? 'conversations' : 'home'));
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1527
1550
|
setActiveTab(tab: WidgetSpaceType): void {
|
|
1528
1551
|
if (tab === 'home') {
|
|
1529
1552
|
this.setState({ activeTab: 'home', view: 'home' });
|
|
@@ -332,9 +332,7 @@ export function RepzoWidgetProvider({
|
|
|
332
332
|
[store]
|
|
333
333
|
);
|
|
334
334
|
|
|
335
|
-
const handleBack = useCallback(() =>
|
|
336
|
-
store.setView(state.activeTab === 'messages' ? 'conversations' : 'home');
|
|
337
|
-
}, [state.activeTab, store]);
|
|
335
|
+
const handleBack = useCallback(() => store.leaveConversation(), [store]);
|
|
338
336
|
|
|
339
337
|
const handleStartConversation = useCallback(() => {
|
|
340
338
|
if (!theme.allowNewConversations) return;
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
4
|
-
*
|
|
3
|
+
* stays inside the messenger instead of bouncing to the browser. Pinch, drag
|
|
4
|
+
* and double-tap zoom work on iOS and Android (see ZoomableImage).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useState } from 'react';
|
|
8
8
|
import {
|
|
9
9
|
ActivityIndicator,
|
|
10
|
-
Image,
|
|
11
10
|
Modal,
|
|
12
11
|
Platform,
|
|
13
12
|
Pressable,
|
|
14
|
-
ScrollView,
|
|
15
13
|
StyleSheet,
|
|
16
14
|
Text,
|
|
17
15
|
View,
|
|
@@ -20,6 +18,7 @@ import {
|
|
|
20
18
|
import { useWidgetInsets } from '../hooks/use-widget-insets';
|
|
21
19
|
import { fontSize, palette, spacing } from '../styles/theme';
|
|
22
20
|
import { CloseIcon } from './icons';
|
|
21
|
+
import { ZoomableImage } from './ZoomableImage';
|
|
23
22
|
|
|
24
23
|
export interface ImageViewerLabels {
|
|
25
24
|
close: string;
|
|
@@ -52,25 +51,14 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
|
|
|
52
51
|
onRequestClose={onClose}>
|
|
53
52
|
<View style={styles.backdrop} accessibilityViewIsModal>
|
|
54
53
|
{image ? (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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>
|
|
54
|
+
// Keyed by URL so the next image opens at 1×, not at the last one's zoom.
|
|
55
|
+
<ZoomableImage
|
|
56
|
+
key={image.url}
|
|
57
|
+
uri={image.url}
|
|
58
|
+
accessibilityLabel={image.name}
|
|
59
|
+
onLoad={() => setStatus({ url: image.url, state: 'ready' })}
|
|
60
|
+
onError={() => setStatus({ url: image.url, state: 'failed' })}
|
|
61
|
+
/>
|
|
74
62
|
) : null}
|
|
75
63
|
|
|
76
64
|
{image && state === 'loading' ? (
|
|
@@ -112,18 +100,11 @@ const styles = StyleSheet.create({
|
|
|
112
100
|
flex: 1,
|
|
113
101
|
backgroundColor: '#000000',
|
|
114
102
|
},
|
|
115
|
-
fill: {
|
|
116
|
-
flex: 1,
|
|
117
|
-
},
|
|
118
103
|
center: {
|
|
119
104
|
flexGrow: 1,
|
|
120
105
|
alignItems: 'center',
|
|
121
106
|
justifyContent: 'center',
|
|
122
107
|
},
|
|
123
|
-
image: {
|
|
124
|
-
width: '100%',
|
|
125
|
-
height: '100%',
|
|
126
|
-
},
|
|
127
108
|
header: {
|
|
128
109
|
position: 'absolute',
|
|
129
110
|
top: 0,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder bubbles while a conversation's messages load: the shape of a
|
|
3
|
+
* thread (team on one side, visitor on the other) that gently pulses, so the
|
|
4
|
+
* screen reads as "messages are coming" instead of a line of text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { Animated, Easing, StyleSheet, View } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { useReduceMotion } from '../hooks/use-reduce-motion';
|
|
11
|
+
import { palette, radius, spacing, withAlpha } from '../styles/theme';
|
|
12
|
+
import { isRTL } from '../utils/rtl';
|
|
13
|
+
|
|
14
|
+
/** Who "sent" each placeholder, its width, and how many lines it holds. */
|
|
15
|
+
const ROWS: { visitor: boolean; width: `${number}%`; lines: number }[] = [
|
|
16
|
+
{ visitor: false, width: '62%', lines: 2 },
|
|
17
|
+
{ visitor: false, width: '40%', lines: 1 },
|
|
18
|
+
{ visitor: true, width: '48%', lines: 1 },
|
|
19
|
+
{ visitor: false, width: '70%', lines: 3 },
|
|
20
|
+
{ visitor: true, width: '56%', lines: 2 },
|
|
21
|
+
{ visitor: false, width: '34%', lines: 1 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const LINE_HEIGHT = 20;
|
|
25
|
+
const PULSE_MS = 900;
|
|
26
|
+
|
|
27
|
+
export function MessagesSkeleton({ color, label }: { color: string; label: string }) {
|
|
28
|
+
const reduceMotion = useReduceMotion();
|
|
29
|
+
const [opacity] = useState(() => new Animated.Value(1));
|
|
30
|
+
const rtl = isRTL();
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// `null` (not known yet) holds still too, so the bubbles never jump.
|
|
34
|
+
if (reduceMotion !== false) return undefined;
|
|
35
|
+
const pulse = Animated.loop(
|
|
36
|
+
Animated.sequence([
|
|
37
|
+
Animated.timing(opacity, {
|
|
38
|
+
toValue: 0.45,
|
|
39
|
+
duration: PULSE_MS,
|
|
40
|
+
easing: Easing.inOut(Easing.ease),
|
|
41
|
+
useNativeDriver: true,
|
|
42
|
+
}),
|
|
43
|
+
Animated.timing(opacity, {
|
|
44
|
+
toValue: 1,
|
|
45
|
+
duration: PULSE_MS,
|
|
46
|
+
easing: Easing.inOut(Easing.ease),
|
|
47
|
+
useNativeDriver: true,
|
|
48
|
+
}),
|
|
49
|
+
])
|
|
50
|
+
);
|
|
51
|
+
pulse.start();
|
|
52
|
+
return () => pulse.stop();
|
|
53
|
+
}, [opacity, reduceMotion]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Animated.View
|
|
57
|
+
style={[styles.root, { opacity }]}
|
|
58
|
+
accessible
|
|
59
|
+
accessibilityRole="progressbar"
|
|
60
|
+
accessibilityLabel={label}
|
|
61
|
+
accessibilityState={{ busy: true }}>
|
|
62
|
+
{ROWS.map((row, i) => (
|
|
63
|
+
<View key={i} style={row.visitor ? styles.rowVisitor : styles.rowAgent}>
|
|
64
|
+
<View
|
|
65
|
+
style={[
|
|
66
|
+
styles.bubble,
|
|
67
|
+
row.visitor
|
|
68
|
+
? [styles.bubbleVisitor, rtl && styles.bubbleVisitorRTL, { backgroundColor: withAlpha(color, 0.16) }]
|
|
69
|
+
: [styles.bubbleAgent, rtl && styles.bubbleAgentRTL],
|
|
70
|
+
{ width: row.width, height: row.lines * LINE_HEIGHT + spacing.sm * 2 + 4 },
|
|
71
|
+
]}
|
|
72
|
+
/>
|
|
73
|
+
</View>
|
|
74
|
+
))}
|
|
75
|
+
</Animated.View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The same corners as real bubbles, so the thread doesn't reshape when it loads.
|
|
80
|
+
const BUBBLE_RADIUS = radius.lg + 4;
|
|
81
|
+
const TAIL_RADIUS = 4;
|
|
82
|
+
|
|
83
|
+
const styles = StyleSheet.create({
|
|
84
|
+
root: { gap: spacing.sm },
|
|
85
|
+
rowAgent: { alignItems: 'flex-start' },
|
|
86
|
+
rowVisitor: { alignItems: 'flex-end' },
|
|
87
|
+
bubble: { borderRadius: BUBBLE_RADIUS },
|
|
88
|
+
bubbleAgent: { backgroundColor: palette.surfaceAlt, borderBottomLeftRadius: TAIL_RADIUS },
|
|
89
|
+
bubbleAgentRTL: { borderBottomLeftRadius: BUBBLE_RADIUS, borderBottomRightRadius: TAIL_RADIUS },
|
|
90
|
+
bubbleVisitor: { borderBottomRightRadius: TAIL_RADIUS },
|
|
91
|
+
bubbleVisitorRTL: { borderBottomRightRadius: BUBBLE_RADIUS, borderBottomLeftRadius: TAIL_RADIUS },
|
|
92
|
+
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* A voice note (or any audio attachment) played inside the chat, laid out the
|
|
3
3
|
* way messaging apps do it: a play button beside a waveform the visitor can
|
|
4
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
|
|
6
|
-
*
|
|
5
|
+
* once the note has been started. Only rendered when the app can play audio
|
|
6
|
+
* (see `src/audio/voice.ts`); otherwise audio stays a file row.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
@@ -18,13 +18,7 @@ import {
|
|
|
18
18
|
type LayoutChangeEvent,
|
|
19
19
|
} from 'react-native';
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
audioPlayerOptions,
|
|
23
|
-
claimVoicePlayback,
|
|
24
|
-
formatVoiceClock,
|
|
25
|
-
voicePlaybackModule,
|
|
26
|
-
voiceWaveformLevels,
|
|
27
|
-
} from '../../audio/voice';
|
|
21
|
+
import { claimVoicePlayback, formatVoiceClock, useVoicePlayer, voiceWaveformLevels } from '../../audio/voice';
|
|
28
22
|
import { PauseIcon, PlayIcon } from './icons';
|
|
29
23
|
import { fontSize, palette, withAlpha } from '../styles/theme';
|
|
30
24
|
|
|
@@ -75,12 +69,10 @@ export function VoiceNote(props: VoiceNoteProps) {
|
|
|
75
69
|
);
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
/** A note that has been started: owns its
|
|
72
|
+
/** A note that has been started: owns its player and plays on mount. */
|
|
79
73
|
function ActiveVoiceNote(props: VoiceNoteProps) {
|
|
80
74
|
const { url, onError } = props;
|
|
81
|
-
const
|
|
82
|
-
const player = audio.useAudioPlayer({ uri: url }, audioPlayerOptions());
|
|
83
|
-
const status = audio.useAudioPlayerStatus(player);
|
|
75
|
+
const { player, status } = useVoicePlayer(url);
|
|
84
76
|
const [speedIndex, setSpeedIndex] = useState(0);
|
|
85
77
|
const [scrub, setScrub] = useState<number | null>(null);
|
|
86
78
|
const [trackWidth, setTrackWidth] = useState(0);
|
|
@@ -94,7 +86,7 @@ function ActiveVoiceNote(props: VoiceNoteProps) {
|
|
|
94
86
|
const currentTime = Math.max(Number(status?.currentTime) || 0, 0);
|
|
95
87
|
const playing = !!status?.playing;
|
|
96
88
|
const finished = !!status?.didJustFinish || (duration > 0 && currentTime >= duration - 0.1);
|
|
97
|
-
// `isLoaded` / `isBuffering` are compared strictly:
|
|
89
|
+
// `isLoaded` / `isBuffering` are compared strictly: a player that
|
|
98
90
|
// doesn't report them never gets a spinner that can't go away.
|
|
99
91
|
const loading = wantsPlay && !playing && (status?.isLoaded === false || status?.isBuffering === true);
|
|
100
92
|
|