@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +35 -29
  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/audio/native-player.js +97 -0
  11. package/lib/commonjs/audio/native-player.js.map +1 -0
  12. package/lib/commonjs/audio/voice.js +34 -7
  13. package/lib/commonjs/audio/voice.js.map +1 -1
  14. package/lib/commonjs/state/widget-store.js +23 -1
  15. package/lib/commonjs/state/widget-store.js.map +1 -1
  16. package/lib/commonjs/ui/WidgetProvider.js +1 -3
  17. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  18. package/lib/commonjs/ui/components/ImageViewer.js +17 -35
  19. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  20. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  21. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  22. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  23. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  24. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  25. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  26. package/lib/commonjs/ui/screens/chat/ChatView.js +6 -4
  27. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  28. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -7
  29. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  30. package/lib/module/audio/native-player.js +93 -0
  31. package/lib/module/audio/native-player.js.map +1 -0
  32. package/lib/module/audio/voice.js +32 -6
  33. package/lib/module/audio/voice.js.map +1 -1
  34. package/lib/module/state/widget-store.js +23 -1
  35. package/lib/module/state/widget-store.js.map +1 -1
  36. package/lib/module/ui/WidgetProvider.js +1 -3
  37. package/lib/module/ui/WidgetProvider.js.map +1 -1
  38. package/lib/module/ui/components/ImageViewer.js +18 -36
  39. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  40. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  41. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  42. package/lib/module/ui/components/VoiceNote.js +9 -10
  43. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  44. package/lib/module/ui/components/ZoomableImage.js +291 -0
  45. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  46. package/lib/module/ui/screens/chat/ChatView.js +6 -4
  47. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  48. package/lib/module/ui/screens/chat/ChatView.styles.js +5 -7
  49. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  50. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  51. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  53. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/state/widget-store.d.ts +11 -0
  55. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  58. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  60. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  62. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  64. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -7
  67. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  68. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  69. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  70. package/lib/typescript/module/audio/voice.d.ts +17 -4
  71. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  72. package/lib/typescript/module/state/widget-store.d.ts +11 -0
  73. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  76. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  78. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  79. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  80. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  82. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  83. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -7
  85. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  86. package/package.json +10 -3
  87. package/react-native.config.js +12 -0
  88. package/src/audio/native-player.ts +106 -0
  89. package/src/audio/voice.ts +30 -6
  90. package/src/state/widget-store.ts +24 -1
  91. package/src/ui/WidgetProvider.tsx +1 -3
  92. package/src/ui/components/ImageViewer.tsx +11 -30
  93. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  94. package/src/ui/components/VoiceNote.tsx +6 -14
  95. package/src/ui/components/ZoomableImage.tsx +250 -0
  96. package/src/ui/screens/chat/ChatView.styles.ts +5 -7
  97. 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0ZjB,CAAC"}
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.2.2",
4
- "description": "Repzo chat widget for React Native \u2014 pure JavaScript, no native modules",
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
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Voice notes (and any audio file) play inside the chat when the host app has
3
- * `expo-audio`; without it they stay a file row that opens in the browser.
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
- /** The installed expo-audio module, or null when the app doesn't have it. */
35
- export function voicePlaybackModule(): any {
36
- return expoAudio;
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 !!expoAudio && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
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.2.2';
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-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,
@@ -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 host app has
6
- * `expo-audio` (see `src/audio/voice.ts`); without it audio stays a file row.
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 expo-audio player and plays on mount. */
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 audio = voicePlaybackModule();
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: an expo-audio that
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