@repzo/widget-react-native 1.2.1 → 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 +69 -0
- package/README.md +40 -3
- 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/api/client.js +47 -2
- package/lib/commonjs/api/client.js.map +1 -1
- 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 +146 -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 +44 -1
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +7 -3
- 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/ImageViewer.js +17 -35
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/Launcher.js +15 -1
- package/lib/commonjs/ui/components/Launcher.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 +353 -0
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.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 +39 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -16
- 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/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +136 -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 +44 -1
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +7 -3
- 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/ImageViewer.js +18 -36
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/Launcher.js +16 -2
- package/lib/module/ui/components/Launcher.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 +349 -0
- package/lib/module/ui/components/VoiceNote.js.map +1 -0
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.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 +39 -18
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +5 -16
- 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/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 +42 -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 +13 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Launcher.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 +32 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
- 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/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 +4 -16
- 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/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 +42 -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 +13 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Launcher.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 +32 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
- 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/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 +4 -16
- 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 +14 -3
- package/react-native.config.js +12 -0
- package/src/api/client.ts +56 -2
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +127 -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 +45 -1
- package/src/ui/WidgetProvider.tsx +7 -3
- package/src/ui/components/FileCard.tsx +111 -0
- package/src/ui/components/ImageViewer.tsx +11 -30
- package/src/ui/components/Launcher.tsx +7 -2
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +355 -0
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/components/icons.tsx +33 -0
- package/src/ui/screens/chat/ChatView.styles.ts +5 -16
- package/src/ui/screens/chat/ChatView.tsx +43 -16
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Resolved once at module load, like the attachment pickers: each `require` is
|
|
9
|
+
* static so Metro can see it, and with `allowOptionalDependencies` (see
|
|
10
|
+
* metro.js) an uninstalled one bundles as a require that throws, which the
|
|
11
|
+
* `try` around it catches.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { hasNativeVoicePlayer, useNativeVoicePlayer } from './native-player';
|
|
15
|
+
|
|
16
|
+
let expoAudio: any = null;
|
|
17
|
+
try {
|
|
18
|
+
const mod = require('expo-audio');
|
|
19
|
+
if (typeof mod?.useAudioPlayer === 'function' && typeof mod?.useAudioPlayerStatus === 'function') {
|
|
20
|
+
expoAudio = mod;
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
expoAudio = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// expo-audio 1.x (Expo SDK 54+) takes `{ updateInterval }`; 0.x (SDK 53) took
|
|
27
|
+
// the interval itself. Passing the wrong shape would break the player.
|
|
28
|
+
let takesOptionsObject = true;
|
|
29
|
+
try {
|
|
30
|
+
const version: unknown = require('expo-audio/package.json')?.version;
|
|
31
|
+
if (typeof version === 'string') takesOptionsObject = Number(version.split('.')[0]) >= 1;
|
|
32
|
+
} catch {
|
|
33
|
+
takesOptionsObject = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How often the playhead moves while a note plays. */
|
|
37
|
+
const UPDATE_INTERVAL_MS = 100;
|
|
38
|
+
|
|
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;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The second argument to `useAudioPlayer` for the installed expo-audio. */
|
|
47
|
+
export function audioPlayerOptions(): unknown {
|
|
48
|
+
return takesOptionsObject ? { updateInterval: UPDATE_INTERVAL_MS } : UPDATE_INTERVAL_MS;
|
|
49
|
+
}
|
|
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
|
+
|
|
68
|
+
/** Audio the chat can play in place: an `audio/*` attachment with a web address. */
|
|
69
|
+
export function isPlayableAudio(contentType: string | null | undefined, url: string | null | undefined): boolean {
|
|
70
|
+
return backend != null && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── One note at a time ─────────────────────────────────────────────────────
|
|
74
|
+
// Each bubble owns its own player, so starting a second note would otherwise
|
|
75
|
+
// talk over the first.
|
|
76
|
+
|
|
77
|
+
let playing: { pause: () => void } | null = null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Called as a note starts: pauses whichever note was playing and returns a
|
|
81
|
+
* release for this one's claim (call it when the note goes away).
|
|
82
|
+
*/
|
|
83
|
+
export function claimVoicePlayback(pause: () => void): () => void {
|
|
84
|
+
const claim = { pause };
|
|
85
|
+
const previous = playing;
|
|
86
|
+
playing = claim;
|
|
87
|
+
if (previous) {
|
|
88
|
+
try {
|
|
89
|
+
previous.pause();
|
|
90
|
+
} catch {
|
|
91
|
+
// Its player was released with its bubble: nothing is playing.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return () => {
|
|
95
|
+
if (playing === claim) playing = null;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Waveform ───────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Bar heights (0.15–1) for a note's waveform. Workstation doesn't send the
|
|
103
|
+
* recorded loudness to visitors, so the pattern is made up — seeded from the
|
|
104
|
+
* attachment, so the same note always draws the same bars.
|
|
105
|
+
*/
|
|
106
|
+
export function voiceWaveformLevels(seed: string, count: number): number[] {
|
|
107
|
+
let h = 2166136261;
|
|
108
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
109
|
+
h ^= seed.charCodeAt(i);
|
|
110
|
+
h = Math.imul(h, 16777619);
|
|
111
|
+
}
|
|
112
|
+
const levels: number[] = [];
|
|
113
|
+
for (let i = 0; i < count; i += 1) {
|
|
114
|
+
h ^= h << 13;
|
|
115
|
+
h ^= h >>> 17;
|
|
116
|
+
h ^= h << 5;
|
|
117
|
+
const n = ((h >>> 0) % 1000) / 1000;
|
|
118
|
+
levels.push(0.15 + Math.pow(n, 0.85) * 0.85);
|
|
119
|
+
}
|
|
120
|
+
return levels;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `m:ss` for a playback position or length. */
|
|
124
|
+
export function formatVoiceClock(seconds: number): string {
|
|
125
|
+
const s = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
|
|
126
|
+
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
|
127
|
+
}
|
package/src/i18n.ts
CHANGED
|
@@ -70,6 +70,12 @@ export const DEFAULT_LABELS = {
|
|
|
70
70
|
attachmentsTooLargeTogether:
|
|
71
71
|
'Attachments are too large to send together — send some in another message',
|
|
72
72
|
attachmentFailed: "Couldn't attach that file",
|
|
73
|
+
// A file in a message; tapping opens it. `{name}` is the file name.
|
|
74
|
+
openAttachment: 'Open {name}',
|
|
75
|
+
// Voice notes played in the chat (with expo-audio).
|
|
76
|
+
voicePlay: 'Play voice message',
|
|
77
|
+
voicePause: 'Pause voice message',
|
|
78
|
+
voiceSpeed: 'Playback speed',
|
|
73
79
|
// Full-screen image viewer.
|
|
74
80
|
imageLoadFailed: "Couldn't load this image",
|
|
75
81
|
openImageExternally: 'Open image',
|
package/src/locales.ts
CHANGED
|
@@ -29,6 +29,10 @@ const ar: LabelMap = {
|
|
|
29
29
|
attachmentTooMany: 'يمكنك إرفاق {count} ملفات كحد أقصى',
|
|
30
30
|
attachmentsTooLargeTogether: 'المرفقات كبيرة جدًا لإرسالها معًا — أرسل بعضها في رسالة أخرى',
|
|
31
31
|
attachmentFailed: 'تعذر إرفاق هذا الملف',
|
|
32
|
+
openAttachment: 'فتح {name}',
|
|
33
|
+
voicePlay: 'تشغيل الرسالة الصوتية',
|
|
34
|
+
voicePause: 'إيقاف الرسالة الصوتية مؤقتًا',
|
|
35
|
+
voiceSpeed: 'سرعة التشغيل',
|
|
32
36
|
imageLoadFailed: 'تعذر تحميل هذه الصورة',
|
|
33
37
|
openImageExternally: 'فتح الصورة',
|
|
34
38
|
cameraPermissionDenied: 'اسمح بالوصول إلى الكاميرا من الإعدادات لالتقاط صورة',
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* component is mounted; this hook only subscribes to it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useSyncExternalStore } from 'react';
|
|
7
|
+
import { useRef, useSyncExternalStore } from 'react';
|
|
8
8
|
|
|
9
9
|
import { controller } from '../controller';
|
|
10
10
|
import type { WidgetState } from '../types';
|
|
@@ -18,3 +18,32 @@ export function useWidgetState(): WidgetState {
|
|
|
18
18
|
export function useWidgetStore(): WidgetStore {
|
|
19
19
|
return controller.store;
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A slice of the state that re-renders only when the slice changes (by
|
|
24
|
+
* `isEqual`) — for UI that lives on the host's own screens, like the launcher,
|
|
25
|
+
* which shouldn't redraw on every message or typing event. Internal.
|
|
26
|
+
*/
|
|
27
|
+
export function useWidgetSelector<T>(
|
|
28
|
+
select: (state: WidgetState) => T,
|
|
29
|
+
isEqual: (previous: T, next: T) => boolean
|
|
30
|
+
): T {
|
|
31
|
+
const store = controller.store;
|
|
32
|
+
const last = useRef<{ state: WidgetState; value: T } | null>(null);
|
|
33
|
+
const getSnapshot = () => {
|
|
34
|
+
const state = store.getState();
|
|
35
|
+
const previous = last.current;
|
|
36
|
+
if (previous && previous.state === state) return previous.value;
|
|
37
|
+
const value = select(state);
|
|
38
|
+
// Same slice from a newer state: keep the old value so React bails out.
|
|
39
|
+
last.current = { state, value: previous && isEqual(previous.value, value) ? previous.value : value };
|
|
40
|
+
return last.current.value;
|
|
41
|
+
};
|
|
42
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Same keys, same values (by identity). */
|
|
46
|
+
export function shallowEqual<T extends Record<string, unknown>>(previous: T, next: T): boolean {
|
|
47
|
+
const keys = Object.keys(previous);
|
|
48
|
+
return keys.length === Object.keys(next).length && keys.every((key) => Object.is(previous[key], next[key]));
|
|
49
|
+
}
|
|
@@ -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' {
|
|
@@ -67,6 +67,13 @@ const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
|
67
67
|
const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
|
|
68
68
|
/** Live replies in the open thread advance the server read cursor, batched. */
|
|
69
69
|
const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
70
|
+
/**
|
|
71
|
+
* Back from the background sooner than this, availability and the post are
|
|
72
|
+
* still current enough to skip re-asking. Reps hop to maps or the camera and
|
|
73
|
+
* back all day; the list and the open thread still catch up every time, since
|
|
74
|
+
* the stream was closed meanwhile.
|
|
75
|
+
*/
|
|
76
|
+
const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
|
|
70
77
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
71
78
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
72
79
|
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
@@ -209,8 +216,18 @@ export class WidgetStore {
|
|
|
209
216
|
|
|
210
217
|
private pendingConversationId: string | null = null;
|
|
211
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
|
+
|
|
212
226
|
private appStateSubscription: { remove(): void } | null = null;
|
|
213
227
|
|
|
228
|
+
/** When the app last went to the background (0: hasn't this session). */
|
|
229
|
+
private backgroundedAt = 0;
|
|
230
|
+
|
|
214
231
|
private lastTypingPingAt = 0;
|
|
215
232
|
|
|
216
233
|
private visitorTypingActive = false;
|
|
@@ -311,6 +328,14 @@ export class WidgetStore {
|
|
|
311
328
|
}
|
|
312
329
|
this.state = next;
|
|
313
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
|
+
|
|
314
339
|
if (next.unreadCount !== previous.unreadCount) {
|
|
315
340
|
this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, { count: next.unreadCount });
|
|
316
341
|
}
|
|
@@ -914,10 +939,14 @@ export class WidgetStore {
|
|
|
914
939
|
|
|
915
940
|
private watchAppState() {
|
|
916
941
|
if (this.appStateSubscription) return;
|
|
942
|
+
// Booted while already in the background (a push woke the app): coming
|
|
943
|
+
// to the foreground is a return from the background too.
|
|
944
|
+
if (AppState.currentState === 'background') this.backgroundedAt = Date.now();
|
|
917
945
|
this.appStateSubscription = AppState.addEventListener('change', (status: AppStateStatus) => {
|
|
918
946
|
if (status === 'active') {
|
|
919
947
|
void this.resumeFromBackground();
|
|
920
948
|
} else if (status === 'background') {
|
|
949
|
+
this.backgroundedAt = Date.now();
|
|
921
950
|
this.realtime.close();
|
|
922
951
|
}
|
|
923
952
|
});
|
|
@@ -931,6 +960,12 @@ export class WidgetStore {
|
|
|
931
960
|
if (this.state.error) await this.retry();
|
|
932
961
|
return;
|
|
933
962
|
}
|
|
963
|
+
// Only the background closes the stream. Back from merely inactive (a
|
|
964
|
+
// system sheet, Face ID, the notification shade) it never stopped, so
|
|
965
|
+
// there is nothing to catch up on.
|
|
966
|
+
if (!this.backgroundedAt) return;
|
|
967
|
+
const awayMs = Date.now() - this.backgroundedAt;
|
|
968
|
+
this.backgroundedAt = 0;
|
|
934
969
|
const epoch = this.epoch;
|
|
935
970
|
const token = await this.client.getToken();
|
|
936
971
|
if (!token || this.isStale(epoch)) return;
|
|
@@ -938,6 +973,7 @@ export class WidgetStore {
|
|
|
938
973
|
// The stream had a gap: refresh the list so unread/snippets are correct,
|
|
939
974
|
// and the thread on screen so replies sent meanwhile show up.
|
|
940
975
|
await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
|
|
976
|
+
if (awayMs < FOREGROUND_REFRESH_AFTER_MS) return;
|
|
941
977
|
await this.refreshConfig();
|
|
942
978
|
// A post may have gone live (or ended) while the app was away.
|
|
943
979
|
if (!this.isStale(epoch)) this.loadPost();
|
|
@@ -1503,6 +1539,14 @@ export class WidgetStore {
|
|
|
1503
1539
|
});
|
|
1504
1540
|
}
|
|
1505
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
|
+
|
|
1506
1550
|
setActiveTab(tab: WidgetSpaceType): void {
|
|
1507
1551
|
if (tab === 'home') {
|
|
1508
1552
|
this.setState({ activeTab: 'home', view: 'home' });
|
|
@@ -182,6 +182,7 @@ export function RepzoWidgetProvider({
|
|
|
182
182
|
emptyConversation: labels.emptyConversation,
|
|
183
183
|
messagePlaceholder: labels.messagePlaceholder,
|
|
184
184
|
failedToSend: labels.failedToSend,
|
|
185
|
+
openAttachment: labels.openAttachment,
|
|
185
186
|
thinking: labels.thinking,
|
|
186
187
|
team: labels.team,
|
|
187
188
|
statusOnline: labels.homeStatusOnline,
|
|
@@ -224,6 +225,11 @@ export function RepzoWidgetProvider({
|
|
|
224
225
|
loadFailed: labels.imageLoadFailed,
|
|
225
226
|
openExternally: labels.openImageExternally,
|
|
226
227
|
},
|
|
228
|
+
voice: {
|
|
229
|
+
play: labels.voicePlay,
|
|
230
|
+
pause: labels.voicePause,
|
|
231
|
+
speed: labels.voiceSpeed,
|
|
232
|
+
},
|
|
227
233
|
}),
|
|
228
234
|
[labels, blockLabels, attachLabels]
|
|
229
235
|
);
|
|
@@ -326,9 +332,7 @@ export function RepzoWidgetProvider({
|
|
|
326
332
|
[store]
|
|
327
333
|
);
|
|
328
334
|
|
|
329
|
-
const handleBack = useCallback(() =>
|
|
330
|
-
store.setView(state.activeTab === 'messages' ? 'conversations' : 'home');
|
|
331
|
-
}, [state.activeTab, store]);
|
|
335
|
+
const handleBack = useCallback(() => store.leaveConversation(), [store]);
|
|
332
336
|
|
|
333
337
|
const handleStartConversation = useCallback(() => {
|
|
334
338
|
if (!theme.allowNewConversations) return;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An attachment that opens outside the chat (PDF, video, sheet…), drawn as a
|
|
3
|
+
* card that reads as tappable: a tile with the file's type, its name, the
|
|
4
|
+
* type and size under it, and an arrow — instead of a bare name in a box.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
8
|
+
|
|
9
|
+
import { formatBytes } from '../../attachments/pickers';
|
|
10
|
+
import { ChevronIcon, PlayIcon } from './icons';
|
|
11
|
+
import { fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
|
|
12
|
+
import { forwardDirection, START_ALIGN } from '../utils/rtl';
|
|
13
|
+
|
|
14
|
+
/** `PDF`, `MP4`, `XLSX` — from the name, else the MIME subtype; at most 4 letters. */
|
|
15
|
+
export function fileTypeLabel(name: string, contentType: string | null | undefined): string {
|
|
16
|
+
const fromName = /\.([a-z0-9]{1,8})$/i.exec(name.trim())?.[1];
|
|
17
|
+
const fromType = (contentType ?? '').split('/')[1]?.split(/[;+.-]/)[0];
|
|
18
|
+
const label = (fromName || fromType || 'file').toUpperCase();
|
|
19
|
+
return label.length > 4 ? label.slice(0, 4) : label;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function FileCard({
|
|
23
|
+
name,
|
|
24
|
+
contentType,
|
|
25
|
+
size,
|
|
26
|
+
color,
|
|
27
|
+
onBrandBubble,
|
|
28
|
+
openLabel,
|
|
29
|
+
onPress,
|
|
30
|
+
}: {
|
|
31
|
+
name: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
size: number;
|
|
34
|
+
/** The workspace color. */
|
|
35
|
+
color: string;
|
|
36
|
+
/** In a visitor bubble, which is filled with `color`: the card turns white-on-color. */
|
|
37
|
+
onBrandBubble: boolean;
|
|
38
|
+
/** Screen-reader label, e.g. "Open report.pdf". */
|
|
39
|
+
openLabel: string;
|
|
40
|
+
/** Missing while there's nothing to open yet (an unsent file). */
|
|
41
|
+
onPress?: () => void;
|
|
42
|
+
}) {
|
|
43
|
+
const type = fileTypeLabel(name, contentType);
|
|
44
|
+
const isVideo = (contentType ?? '').toLowerCase().startsWith('video/');
|
|
45
|
+
const meta = size > 0 ? `${type} · ${formatBytes(size)}` : type;
|
|
46
|
+
const accent = onBrandBubble ? palette.white : color;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Pressable
|
|
50
|
+
onPress={onPress}
|
|
51
|
+
disabled={!onPress}
|
|
52
|
+
accessibilityRole="button"
|
|
53
|
+
accessibilityLabel={openLabel}
|
|
54
|
+
style={({ pressed }) => [
|
|
55
|
+
styles.card,
|
|
56
|
+
onBrandBubble
|
|
57
|
+
? { backgroundColor: withAlpha(palette.white, 0.16), borderColor: withAlpha(palette.white, 0.35) }
|
|
58
|
+
: { backgroundColor: palette.white, borderColor: palette.border },
|
|
59
|
+
!onPress && styles.unavailable,
|
|
60
|
+
pressed && styles.pressed,
|
|
61
|
+
]}>
|
|
62
|
+
<View style={[styles.tile, { backgroundColor: onBrandBubble ? palette.white : withAlpha(color, 0.12) }]}>
|
|
63
|
+
{isVideo ? (
|
|
64
|
+
<PlayIcon size={16} color={color} />
|
|
65
|
+
) : (
|
|
66
|
+
<Text style={[styles.tileLabel, { color }]} numberOfLines={1}>
|
|
67
|
+
{type}
|
|
68
|
+
</Text>
|
|
69
|
+
)}
|
|
70
|
+
</View>
|
|
71
|
+
|
|
72
|
+
<View style={styles.text}>
|
|
73
|
+
<Text style={[styles.name, { color: onBrandBubble ? palette.white : palette.ink }]} numberOfLines={2}>
|
|
74
|
+
{name}
|
|
75
|
+
</Text>
|
|
76
|
+
<Text style={[styles.meta, { color: onBrandBubble ? withAlpha(palette.white, 0.8) : palette.textMuted }]}>
|
|
77
|
+
{meta}
|
|
78
|
+
</Text>
|
|
79
|
+
</View>
|
|
80
|
+
|
|
81
|
+
{onPress ? <ChevronIcon size={14} color={accent} direction={forwardDirection()} /> : null}
|
|
82
|
+
</Pressable>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const styles = StyleSheet.create({
|
|
87
|
+
card: {
|
|
88
|
+
flexDirection: 'row',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
gap: spacing.sm + 2,
|
|
91
|
+
minWidth: 220,
|
|
92
|
+
paddingVertical: spacing.sm + 1,
|
|
93
|
+
paddingHorizontal: spacing.sm + 2,
|
|
94
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
95
|
+
borderRadius: radius.lg,
|
|
96
|
+
},
|
|
97
|
+
pressed: { opacity: 0.7 },
|
|
98
|
+
unavailable: { opacity: 0.6 },
|
|
99
|
+
tile: {
|
|
100
|
+
width: 40,
|
|
101
|
+
height: 40,
|
|
102
|
+
borderRadius: radius.sm,
|
|
103
|
+
alignItems: 'center',
|
|
104
|
+
justifyContent: 'center',
|
|
105
|
+
},
|
|
106
|
+
tileLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.3 },
|
|
107
|
+
text: { flex: 1, minWidth: 0, gap: 2 },
|
|
108
|
+
// A Latin file name in an Arabic chat still lines up with the card's start.
|
|
109
|
+
name: { fontSize: fontSize.meta + 1, fontWeight: '600', textAlign: START_ALIGN },
|
|
110
|
+
meta: { fontSize: fontSize.caption, textAlign: START_ALIGN },
|
|
111
|
+
});
|
|
@@ -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,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
11
11
|
|
|
12
12
|
import { createTranslator, DEFAULT_LABELS } from '../../i18n';
|
|
13
|
-
import {
|
|
13
|
+
import { shallowEqual, useWidgetSelector } from '../../state/use-widget-state';
|
|
14
14
|
import { controller } from '../../controller';
|
|
15
15
|
import { ChatIcon, ChevronIcon } from './icons';
|
|
16
16
|
import {
|
|
@@ -37,7 +37,12 @@ export function Launcher({
|
|
|
37
37
|
horizontalPadding,
|
|
38
38
|
hidden = false,
|
|
39
39
|
}: LauncherProps) {
|
|
40
|
-
|
|
40
|
+
// Only what the launcher draws: it sits on the host's screens and shouldn't
|
|
41
|
+
// redraw on every message or typing event.
|
|
42
|
+
const state = useWidgetSelector(
|
|
43
|
+
({ booted, config, isOpen, locale, unreadCount }) => ({ booted, config, isOpen, locale, unreadCount }),
|
|
44
|
+
shallowEqual
|
|
45
|
+
);
|
|
41
46
|
const config = state.config;
|
|
42
47
|
|
|
43
48
|
if (hidden || !state.booted || config?.showLauncher === false) return null;
|