@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.
Files changed (161) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +40 -3
  3. package/android/build.gradle +22 -0
  4. package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
  5. package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/RepzoVoicePlayer.swift +175 -0
  8. package/ios/RepzoWidgetAudio.podspec +26 -0
  9. package/ios/RepzoWidgetAudioModule.swift +65 -0
  10. package/lib/commonjs/api/client.js +47 -2
  11. package/lib/commonjs/api/client.js.map +1 -1
  12. package/lib/commonjs/audio/native-player.js +97 -0
  13. package/lib/commonjs/audio/native-player.js.map +1 -0
  14. package/lib/commonjs/audio/voice.js +146 -0
  15. package/lib/commonjs/audio/voice.js.map +1 -0
  16. package/lib/commonjs/i18n.js +6 -0
  17. package/lib/commonjs/i18n.js.map +1 -1
  18. package/lib/commonjs/locales.js +4 -0
  19. package/lib/commonjs/locales.js.map +1 -1
  20. package/lib/commonjs/state/use-widget-state.js +31 -0
  21. package/lib/commonjs/state/use-widget-state.js.map +1 -1
  22. package/lib/commonjs/state/widget-store.js +44 -1
  23. package/lib/commonjs/state/widget-store.js.map +1 -1
  24. package/lib/commonjs/ui/WidgetProvider.js +7 -3
  25. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  26. package/lib/commonjs/ui/components/FileCard.js +134 -0
  27. package/lib/commonjs/ui/components/FileCard.js.map +1 -0
  28. package/lib/commonjs/ui/components/ImageViewer.js +17 -35
  29. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  30. package/lib/commonjs/ui/components/Launcher.js +15 -1
  31. package/lib/commonjs/ui/components/Launcher.js.map +1 -1
  32. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  33. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  34. package/lib/commonjs/ui/components/VoiceNote.js +353 -0
  35. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
  36. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  37. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  38. package/lib/commonjs/ui/components/icons.js +57 -0
  39. package/lib/commonjs/ui/components/icons.js.map +1 -1
  40. package/lib/commonjs/ui/screens/chat/ChatView.js +39 -18
  41. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  42. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -16
  43. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  44. package/lib/module/api/client.js +48 -2
  45. package/lib/module/api/client.js.map +1 -1
  46. package/lib/module/audio/native-player.js +93 -0
  47. package/lib/module/audio/native-player.js.map +1 -0
  48. package/lib/module/audio/voice.js +136 -0
  49. package/lib/module/audio/voice.js.map +1 -0
  50. package/lib/module/i18n.js +6 -0
  51. package/lib/module/i18n.js.map +1 -1
  52. package/lib/module/locales.js +4 -0
  53. package/lib/module/locales.js.map +1 -1
  54. package/lib/module/state/use-widget-state.js +30 -1
  55. package/lib/module/state/use-widget-state.js.map +1 -1
  56. package/lib/module/state/widget-store.js +44 -1
  57. package/lib/module/state/widget-store.js.map +1 -1
  58. package/lib/module/ui/WidgetProvider.js +7 -3
  59. package/lib/module/ui/WidgetProvider.js.map +1 -1
  60. package/lib/module/ui/components/FileCard.js +130 -0
  61. package/lib/module/ui/components/FileCard.js.map +1 -0
  62. package/lib/module/ui/components/ImageViewer.js +18 -36
  63. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  64. package/lib/module/ui/components/Launcher.js +16 -2
  65. package/lib/module/ui/components/Launcher.js.map +1 -1
  66. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  67. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  68. package/lib/module/ui/components/VoiceNote.js +349 -0
  69. package/lib/module/ui/components/VoiceNote.js.map +1 -0
  70. package/lib/module/ui/components/ZoomableImage.js +291 -0
  71. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  72. package/lib/module/ui/components/icons.js +55 -0
  73. package/lib/module/ui/components/icons.js.map +1 -1
  74. package/lib/module/ui/screens/chat/ChatView.js +39 -18
  75. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  76. package/lib/module/ui/screens/chat/ChatView.styles.js +5 -16
  77. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  78. package/lib/typescript/commonjs/api/client.d.ts +12 -0
  79. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  81. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  82. package/lib/typescript/commonjs/audio/voice.d.ts +42 -0
  83. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -0
  84. package/lib/typescript/commonjs/i18n.d.ts +4 -0
  85. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/state/use-widget-state.d.ts +8 -0
  88. package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/state/widget-store.d.ts +13 -0
  90. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/components/FileCard.d.ts +21 -0
  93. package/lib/typescript/commonjs/ui/components/FileCard.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  95. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  98. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +32 -0
  100. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  102. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/ui/components/icons.d.ts +4 -0
  104. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -16
  107. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  109. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  110. package/lib/typescript/module/api/client.d.ts +12 -0
  111. package/lib/typescript/module/api/client.d.ts.map +1 -1
  112. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  113. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  114. package/lib/typescript/module/audio/voice.d.ts +42 -0
  115. package/lib/typescript/module/audio/voice.d.ts.map +1 -0
  116. package/lib/typescript/module/i18n.d.ts +4 -0
  117. package/lib/typescript/module/i18n.d.ts.map +1 -1
  118. package/lib/typescript/module/locales.d.ts.map +1 -1
  119. package/lib/typescript/module/state/use-widget-state.d.ts +8 -0
  120. package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -1
  121. package/lib/typescript/module/state/widget-store.d.ts +13 -0
  122. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/components/FileCard.d.ts +21 -0
  125. package/lib/typescript/module/ui/components/FileCard.d.ts.map +1 -0
  126. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  127. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  130. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  131. package/lib/typescript/module/ui/components/VoiceNote.d.ts +32 -0
  132. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
  133. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  134. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  135. package/lib/typescript/module/ui/components/icons.d.ts +4 -0
  136. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  137. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -16
  139. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  141. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  142. package/package.json +14 -3
  143. package/react-native.config.js +12 -0
  144. package/src/api/client.ts +56 -2
  145. package/src/audio/native-player.ts +106 -0
  146. package/src/audio/voice.ts +127 -0
  147. package/src/i18n.ts +6 -0
  148. package/src/locales.ts +4 -0
  149. package/src/state/use-widget-state.ts +30 -1
  150. package/src/state/widget-store.ts +45 -1
  151. package/src/ui/WidgetProvider.tsx +7 -3
  152. package/src/ui/components/FileCard.tsx +111 -0
  153. package/src/ui/components/ImageViewer.tsx +11 -30
  154. package/src/ui/components/Launcher.tsx +7 -2
  155. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  156. package/src/ui/components/VoiceNote.tsx +355 -0
  157. package/src/ui/components/ZoomableImage.tsx +250 -0
  158. package/src/ui/components/icons.tsx +33 -0
  159. package/src/ui/screens/chat/ChatView.styles.ts +5 -16
  160. package/src/ui/screens/chat/ChatView.tsx +43 -16
  161. 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.2.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-to-zoom
4
- * uses the native ScrollView zoom (iOS); Android shows the image fitted.
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
- <ScrollView
56
- style={styles.fill}
57
- contentContainerStyle={styles.center}
58
- maximumZoomScale={4}
59
- minimumZoomScale={1}
60
- centerContent
61
- showsHorizontalScrollIndicator={false}
62
- showsVerticalScrollIndicator={false}
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 { useWidgetState } from '../../state/use-widget-state';
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
- const state = useWidgetState();
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;