@ethora/chat-component-rn 26.5.11 → 26.5.13

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 (154) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -0
  3. package/lib/commonjs/assets/icons.js +7 -1
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js +58 -22
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js +288 -5
  8. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ReduxWrapper.js +24 -15
  10. package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
  11. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +23 -3
  12. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  13. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js +5 -44
  14. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  15. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
  16. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  17. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +149 -0
  18. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  19. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js +4 -17
  20. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  21. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js +19 -33
  22. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  23. package/lib/commonjs/components/styled/AudioMessage.js +643 -66
  24. package/lib/commonjs/components/styled/AudioMessage.js.map +1 -1
  25. package/lib/commonjs/components/styled/Button.js +9 -3
  26. package/lib/commonjs/components/styled/Button.js.map +1 -1
  27. package/lib/commonjs/components/styled/SendInput.js +239 -40
  28. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  29. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  30. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  31. package/lib/commonjs/components/styled/UnsupportedType.js +47 -5
  32. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  33. package/lib/commonjs/context/xmppProvider.js +68 -2
  34. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  35. package/lib/commonjs/helpers/keyboardLayout.js +5 -3
  36. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  37. package/lib/commonjs/helpers/mimeToExtension.js +70 -3
  38. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  39. package/lib/commonjs/hooks/useEventHandlers.js +1 -1
  40. package/lib/commonjs/hooks/useEventHandlers.js.map +1 -1
  41. package/lib/commonjs/hooks/useSendMessage.js +6 -3
  42. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  43. package/lib/commonjs/networking/xmppClient.js +26 -1
  44. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  45. package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js +10 -10
  46. package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js.map +1 -1
  47. package/lib/commonjs/roomStore/roomsSlice.js +65 -4
  48. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  49. package/lib/module/assets/icons.js +7 -1
  50. package/lib/module/assets/icons.js.map +1 -1
  51. package/lib/module/components/MainComponents/ChatRoom.js +59 -23
  52. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  53. package/lib/module/components/MainComponents/MediaMessage.js +290 -7
  54. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  55. package/lib/module/components/MainComponents/ReduxWrapper.js +24 -15
  56. package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
  57. package/lib/module/components/MessageBubble/MessageInteractions.js +24 -4
  58. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  59. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js +5 -45
  60. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  61. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
  62. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  63. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +144 -0
  64. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  65. package/lib/module/components/Modals/FilePreviewModal/PdfView.js +3 -17
  66. package/lib/module/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  67. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js +20 -34
  68. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  69. package/lib/module/components/styled/AudioMessage.js +644 -67
  70. package/lib/module/components/styled/AudioMessage.js.map +1 -1
  71. package/lib/module/components/styled/Button.js +9 -3
  72. package/lib/module/components/styled/Button.js.map +1 -1
  73. package/lib/module/components/styled/SendInput.js +240 -42
  74. package/lib/module/components/styled/SendInput.js.map +1 -1
  75. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  76. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  77. package/lib/module/components/styled/UnsupportedType.js +47 -5
  78. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  79. package/lib/module/context/xmppProvider.js +70 -4
  80. package/lib/module/context/xmppProvider.js.map +1 -1
  81. package/lib/module/helpers/keyboardLayout.js +4 -2
  82. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  83. package/lib/module/helpers/mimeToExtension.js +67 -3
  84. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  85. package/lib/module/hooks/useEventHandlers.js +1 -1
  86. package/lib/module/hooks/useEventHandlers.js.map +1 -1
  87. package/lib/module/hooks/useSendMessage.js +6 -3
  88. package/lib/module/hooks/useSendMessage.js.map +1 -1
  89. package/lib/module/networking/xmppClient.js +26 -1
  90. package/lib/module/networking/xmppClient.js.map +1 -1
  91. package/lib/module/roomStore/Middleware/unreadMidlleware.js +10 -10
  92. package/lib/module/roomStore/Middleware/unreadMidlleware.js.map +1 -1
  93. package/lib/module/roomStore/roomsSlice.js +63 -3
  94. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  95. package/lib/typescript/assets/icons.d.ts.map +1 -1
  96. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  97. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  98. package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
  99. package/lib/typescript/components/MessageBubble/MessageInteractions.d.ts.map +1 -1
  100. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts +0 -7
  101. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts.map +1 -1
  102. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  103. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts +8 -0
  104. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts.map +1 -0
  105. package/lib/typescript/components/Modals/FilePreviewModal/PdfView.d.ts.map +1 -1
  106. package/lib/typescript/components/RoomComponents/styled/LastAudioMessage.d.ts.map +1 -1
  107. package/lib/typescript/components/styled/AudioMessage.d.ts +8 -2
  108. package/lib/typescript/components/styled/AudioMessage.d.ts.map +1 -1
  109. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  110. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  111. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts +5 -1
  112. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts.map +1 -1
  113. package/lib/typescript/components/styled/UnsupportedType.d.ts +5 -0
  114. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  115. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  116. package/lib/typescript/helpers/keyboardLayout.d.ts +3 -1
  117. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  118. package/lib/typescript/helpers/mimeToExtension.d.ts +31 -1
  119. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  120. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  121. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  122. package/lib/typescript/roomStore/Middleware/unreadMidlleware.d.ts.map +1 -1
  123. package/lib/typescript/roomStore/roomsSlice.d.ts +18 -1
  124. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  125. package/lib/typescript/types/models/modal.model.d.ts +3 -0
  126. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  127. package/lib/typescript/types/types.d.ts +15 -0
  128. package/lib/typescript/types/types.d.ts.map +1 -1
  129. package/package.json +5 -1
  130. package/src/assets/icons.tsx +30 -25
  131. package/src/components/MainComponents/ChatRoom.tsx +61 -23
  132. package/src/components/MainComponents/MediaMessage.tsx +295 -5
  133. package/src/components/MainComponents/ReduxWrapper.tsx +23 -9
  134. package/src/components/MessageBubble/MessageInteractions.tsx +35 -3
  135. package/src/components/Modals/FilePreviewModal/DocumentViewer.tsx +3 -40
  136. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +39 -3
  137. package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +142 -0
  138. package/src/components/Modals/FilePreviewModal/PdfView.tsx +2 -18
  139. package/src/components/RoomComponents/styled/LastAudioMessage.tsx +20 -35
  140. package/src/components/styled/AudioMessage.tsx +724 -59
  141. package/src/components/styled/Button.tsx +20 -11
  142. package/src/components/styled/SendInput.tsx +255 -46
  143. package/src/components/styled/StyledInputComponents/MediaComponents.tsx +29 -18
  144. package/src/components/styled/UnsupportedType.tsx +60 -6
  145. package/src/context/xmppProvider.tsx +61 -3
  146. package/src/helpers/keyboardLayout.ts +5 -1
  147. package/src/helpers/mimeToExtension.ts +116 -2
  148. package/src/hooks/useEventHandlers.tsx +1 -1
  149. package/src/hooks/useSendMessage.tsx +12 -6
  150. package/src/networking/xmppClient.ts +24 -1
  151. package/src/roomStore/Middleware/unreadMidlleware.tsx +11 -8
  152. package/src/roomStore/roomsSlice.ts +66 -2
  153. package/src/types/models/modal.model.ts +3 -0
  154. package/src/types/types.ts +32 -0
@@ -5,88 +5,731 @@ import {
5
5
  Text,
6
6
  StyleSheet,
7
7
  ActivityIndicator,
8
+ Platform,
8
9
  } from 'react-native';
9
10
  import { Audio, AVPlaybackStatus } from 'expo-av';
11
+ import * as FileSystem from 'expo-file-system/legacy';
12
+ import { WebView, WebViewMessageEvent } from 'react-native-webview';
13
+ import { PauseIcon, PlayIcon } from '../../assets/icons';
14
+ import { useChatSettingState } from '../../hooks/useChatSettingState';
15
+ import { getIosAudioPlaybackCacheExtension } from '../../helpers/mimeToExtension';
16
+ import { pushLog as devPushLog } from '../../utils/devLogger';
10
17
 
11
18
  const formatTime = (millis: number) => {
12
- const totalSec = Math.floor(millis / 1000);
19
+ const safe = Number.isFinite(millis) && millis > 0 ? millis : 0;
20
+ const totalSec = Math.floor(safe / 1000);
13
21
  const min = Math.floor(totalSec / 60);
14
22
  const sec = totalSec % 60;
15
23
  return `${min}:${sec.toString().padStart(2, '0')}`;
16
24
  };
17
25
 
18
- const AudioMessage = ({ src }: { src: string }) => {
26
+ type AudioMessageProps = {
27
+ src: string;
28
+ mimeType?: string;
29
+ fileName?: string;
30
+ originalName?: string;
31
+ duration?: number | string;
32
+ waveForm?: string;
33
+ };
34
+
35
+ type AudioContainer =
36
+ | 'webm'
37
+ | 'ogg'
38
+ | 'wav'
39
+ | 'mp4'
40
+ | 'mp3'
41
+ | 'aac'
42
+ | 'unknown';
43
+
44
+ type PreparedSource = {
45
+ localUri: string;
46
+ container: AudioContainer;
47
+ base64?: string;
48
+ };
49
+
50
+ type WebDecoderMessage =
51
+ | { type: 'bridge_ready' }
52
+ | { type: 'decoded'; durationMillis: number; wavBase64: string }
53
+ | { type: 'error'; message?: string };
54
+
55
+ // Largest payload we will pull into JS for decoding (input opus) and the
56
+ // largest WAV we will ship back over the bridge (output PCM). Voice
57
+ // messages are a few dozen KB / a couple hundred KB of WAV; these only
58
+ // guard against a pathological multi-minute attachment.
59
+ const MAX_DECODE_BYTES = 25 * 1024 * 1024;
60
+
61
+ const hashString = (value: string) => {
62
+ let hash = 0;
63
+ for (let i = 0; i < value.length; i += 1) {
64
+ hash = (hash * 31 + value.charCodeAt(i)) | 0;
65
+ }
66
+ return Math.abs(hash).toString(36);
67
+ };
68
+
69
+ const withTimeout = async <T,>(
70
+ promise: Promise<T>,
71
+ ms: number,
72
+ label: string
73
+ ) => {
74
+ let timer: ReturnType<typeof setTimeout> | null = null;
75
+ try {
76
+ return await Promise.race([
77
+ promise,
78
+ new Promise<T>((_, reject) => {
79
+ timer = setTimeout(() => reject(new Error(label)), ms);
80
+ }),
81
+ ]);
82
+ } finally {
83
+ if (timer) {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+ };
88
+
89
+ const logAudioDebug = (message: string, details?: Record<string, unknown>) => {
90
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
91
+ devPushLog('rn', message, details);
92
+ console.log(`[AudioDebug] ${message}`, details || {});
93
+ }
94
+ };
95
+
96
+ const B64_ALPHABET =
97
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
98
+
99
+ // Decode just the leading bytes of a base64 string into a byte array. Used
100
+ // for magic-number container sniffing — we only need the first ~16 bytes,
101
+ // so this avoids pulling a base64 lib in for a handful of bytes.
102
+ const decodeBase64Head = (b64: string, maxBytes: number): number[] => {
103
+ const out: number[] = [];
104
+ let buffer = 0;
105
+ let bits = 0;
106
+ for (let i = 0; i < b64.length && out.length < maxBytes; i += 1) {
107
+ const ch = b64[i]!;
108
+ if (ch === '=') {
109
+ break;
110
+ }
111
+ const idx = B64_ALPHABET.indexOf(ch);
112
+ if (idx === -1) {
113
+ continue;
114
+ }
115
+ buffer = (buffer << 6) | idx;
116
+ bits += 6;
117
+ if (bits >= 8) {
118
+ bits -= 8;
119
+ out.push((buffer >> bits) & 0xff);
120
+ }
121
+ }
122
+ return out;
123
+ };
124
+
125
+ // Sniff the real container from magic bytes. This is the source of truth —
126
+ // the HTTP mime is frequently `application/octet-stream` and the filename a
127
+ // meaningless `.blob`/`.bin`, so neither can be trusted. Byte signatures:
128
+ // EBML 1A45DFA3 → webm/matroska, OggS → ogg, RIFF…WAVE → wav,
129
+ // ….ftyp → mp4/m4a, ID3 / FFEx → mp3, FFF1/FFF9 → aac(ADTS).
130
+ const detectContainer = (bytes: number[]): AudioContainer => {
131
+ if (bytes.length < 4) {
132
+ return 'unknown';
133
+ }
134
+ if (
135
+ bytes[0] === 0x1a &&
136
+ bytes[1] === 0x45 &&
137
+ bytes[2] === 0xdf &&
138
+ bytes[3] === 0xa3
139
+ ) {
140
+ return 'webm';
141
+ }
142
+ if (
143
+ bytes[0] === 0x4f &&
144
+ bytes[1] === 0x67 &&
145
+ bytes[2] === 0x67 &&
146
+ bytes[3] === 0x53
147
+ ) {
148
+ return 'ogg';
149
+ }
150
+ if (
151
+ bytes[0] === 0x52 &&
152
+ bytes[1] === 0x49 &&
153
+ bytes[2] === 0x46 &&
154
+ bytes[3] === 0x46 &&
155
+ bytes[8] === 0x57 &&
156
+ bytes[9] === 0x41 &&
157
+ bytes[10] === 0x56 &&
158
+ bytes[11] === 0x45
159
+ ) {
160
+ return 'wav';
161
+ }
162
+ if (
163
+ bytes[4] === 0x66 &&
164
+ bytes[5] === 0x74 &&
165
+ bytes[6] === 0x79 &&
166
+ bytes[7] === 0x70
167
+ ) {
168
+ return 'mp4';
169
+ }
170
+ if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
171
+ return 'mp3';
172
+ }
173
+ if (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) {
174
+ // MPEG audio frame sync (mp3) or ADTS AAC — both decode natively.
175
+ return bytes[1] === 0xf1 || bytes[1] === 0xf9 ? 'aac' : 'mp3';
176
+ }
177
+ return 'unknown';
178
+ };
179
+
180
+ const getContainerExtension = (container: AudioContainer) => {
181
+ switch (container) {
182
+ case 'webm':
183
+ return '.webm';
184
+ case 'ogg':
185
+ return '.ogg';
186
+ case 'wav':
187
+ return '.wav';
188
+ case 'mp4':
189
+ return '.m4a';
190
+ case 'mp3':
191
+ return '.mp3';
192
+ case 'aac':
193
+ return '.aac';
194
+ default:
195
+ return '.bin';
196
+ }
197
+ };
198
+
199
+ // AVFoundation (expo-av) and the iOS WKWebView <audio> element cannot
200
+ // demux/decode WebM or Ogg Opus — only `AudioContext.decodeAudioData` can
201
+ // on iOS. So on iOS those two containers go through the WebView decoder.
202
+ // Everything else (mp3/m4a/aac/wav), and ALL of Android (ExoPlayer handles
203
+ // Opus natively), plays straight through expo-av.
204
+ const requiresWebAudioDecode = (container: AudioContainer) =>
205
+ Platform.OS === 'ios' && (container === 'webm' || container === 'ogg');
206
+
207
+ // The WebView is used ONLY as a decoder, never as a player. Two iOS facts
208
+ // force this shape:
209
+ // • `decodeAudioData` is the only iOS API that understands WebM/Ogg Opus,
210
+ // and it works on a suspended AudioContext (decoding needs no gesture).
211
+ // • Playing audio OUT of a WebView — via AudioContext OR an <audio>
212
+ // element — is gated behind an in-page user gesture that an RN-side tap
213
+ // cannot supply, so both hang silently.
214
+ // Therefore the page decodes Opus → PCM, re-encodes to a WAV blob, and ships
215
+ // the bytes back to RN as base64. RN writes the WAV to cache and plays it
216
+ // through expo-av (native, no gesture limits, real play/pause/seek).
217
+ const WEB_DECODER_HTML = `<!doctype html>
218
+ <html>
219
+ <head>
220
+ <meta charset="utf-8" />
221
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
222
+ <style>html,body{margin:0;padding:0;width:1px;height:1px;overflow:hidden;background:transparent}</style>
223
+ </head>
224
+ <body>
225
+ <script>
226
+ (function () {
227
+ var MAX_WAV = ${MAX_DECODE_BYTES};
228
+ var post = function (payload) {
229
+ if (window.ReactNativeWebView) {
230
+ window.ReactNativeWebView.postMessage(JSON.stringify(payload));
231
+ }
232
+ };
233
+ var ctx = null;
234
+ var encodeWav = function (buffer) {
235
+ var numCh = buffer.numberOfChannels;
236
+ var frames = buffer.length;
237
+ var sr = buffer.sampleRate;
238
+ var bytesLen = 44 + frames * numCh * 2;
239
+ var ab = new ArrayBuffer(bytesLen);
240
+ var view = new DataView(ab);
241
+ var off = 0;
242
+ var ws = function (s) { for (var i = 0; i < s.length; i += 1) { view.setUint8(off++, s.charCodeAt(i)); } };
243
+ var u32 = function (d) { view.setUint32(off, d, true); off += 4; };
244
+ var u16 = function (d) { view.setUint16(off, d, true); off += 2; };
245
+ ws('RIFF'); u32(bytesLen - 8); ws('WAVE');
246
+ ws('fmt '); u32(16); u16(1); u16(numCh); u32(sr); u32(sr * numCh * 2); u16(numCh * 2); u16(16);
247
+ ws('data'); u32(frames * numCh * 2);
248
+ var chans = [];
249
+ for (var c = 0; c < numCh; c += 1) { chans.push(buffer.getChannelData(c)); }
250
+ for (var f = 0; f < frames; f += 1) {
251
+ for (var c2 = 0; c2 < numCh; c2 += 1) {
252
+ var s = Math.max(-1, Math.min(1, chans[c2][f]));
253
+ view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
254
+ off += 2;
255
+ }
256
+ }
257
+ return ab;
258
+ };
259
+ var abToBase64 = function (ab) {
260
+ var bytes = new Uint8Array(ab);
261
+ var binary = '';
262
+ var chunk = 0x8000;
263
+ for (var i = 0; i < bytes.length; i += chunk) {
264
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
265
+ }
266
+ return window.btoa(binary);
267
+ };
268
+ window.__decodeAudio = function (base64) {
269
+ try {
270
+ if (!ctx) {
271
+ var Ctx = window.AudioContext || window.webkitAudioContext;
272
+ ctx = new Ctx();
273
+ }
274
+ var bin = window.atob(base64);
275
+ var len = bin.length;
276
+ var bytes = new Uint8Array(len);
277
+ for (var i = 0; i < len; i += 1) { bytes[i] = bin.charCodeAt(i); }
278
+ ctx.decodeAudioData(
279
+ bytes.buffer,
280
+ function (decoded) {
281
+ try {
282
+ var wav = encodeWav(decoded);
283
+ if (wav.byteLength > MAX_WAV) {
284
+ post({ type: 'error', message: 'audio_too_long' });
285
+ return;
286
+ }
287
+ post({
288
+ type: 'decoded',
289
+ durationMillis: Math.floor(decoded.duration * 1000),
290
+ wavBase64: abToBase64(wav),
291
+ });
292
+ } catch (e) {
293
+ post({ type: 'error', message: 'encode_failed:' + (e && e.message ? e.message : e) });
294
+ }
295
+ },
296
+ function (err) {
297
+ post({ type: 'error', message: 'decode_failed:' + (err && err.message ? err.message : err) });
298
+ }
299
+ );
300
+ } catch (e) {
301
+ post({ type: 'error', message: 'load_failed:' + (e && e.message ? e.message : e) });
302
+ }
303
+ };
304
+ post({ type: 'bridge_ready' });
305
+ })();
306
+ </script>
307
+ </body>
308
+ </html>`;
309
+
310
+ const AudioMessage = ({
311
+ src,
312
+ mimeType,
313
+ fileName,
314
+ originalName,
315
+ }: AudioMessageProps) => {
316
+ const { config } = useChatSettingState();
19
317
  const soundRef = useRef<Audio.Sound | null>(null);
318
+ const webViewRef = useRef<WebView | null>(null);
319
+ const preparedRef = useRef<PreparedSource | null>(null);
320
+ const bridgeReadyRef = useRef(false);
321
+ const injectedRef = useRef(false);
322
+ const didFinishRef = useRef(false);
323
+ const unmountedRef = useRef(false);
324
+ const loadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
325
+
20
326
  const [isPlaying, setIsPlaying] = useState(false);
21
327
  const [isLoading, setIsLoading] = useState(false);
328
+ const [playbackError, setPlaybackError] = useState(false);
22
329
  const [position, setPosition] = useState(0);
23
330
  const [duration, setDuration] = useState(0);
331
+ const [webViewEnabled, setWebViewEnabled] = useState(false);
332
+ const primaryColor = config?.colors?.primary || '#0A84FF';
333
+
334
+ const clearLoadingGuard = () => {
335
+ if (loadingTimerRef.current) {
336
+ clearTimeout(loadingTimerRef.current);
337
+ loadingTimerRef.current = null;
338
+ }
339
+ };
340
+
341
+ const fail = (reason: unknown) => {
342
+ clearLoadingGuard();
343
+ if (unmountedRef.current) {
344
+ return;
345
+ }
346
+ logAudioDebug('audio playback failed', {
347
+ src,
348
+ mimeType,
349
+ fileName,
350
+ originalName,
351
+ container: preparedRef.current?.container,
352
+ error:
353
+ reason instanceof Error
354
+ ? { name: reason.name, message: reason.message }
355
+ : String(reason),
356
+ });
357
+ setIsLoading(false);
358
+ setIsPlaying(false);
359
+ setPlaybackError(true);
360
+ };
361
+
362
+ // Hard ceiling on the loading spinner — whatever stalls (a hung network
363
+ // request, a WebView that never decodes, expo-av wedging on a bad file)
364
+ // the control resolves to an error state instead of spinning forever
365
+ // (the customer-reported "stuck spinner").
366
+ const armLoadingGuard = () => {
367
+ clearLoadingGuard();
368
+ loadingTimerRef.current = setTimeout(() => {
369
+ if (!unmountedRef.current && !soundRef.current) {
370
+ fail(new Error('audio_timeout'));
371
+ }
372
+ }, 15000);
373
+ };
374
+
375
+ const prepareSource = async (): Promise<PreparedSource> => {
376
+ if (preparedRef.current) {
377
+ return preparedRef.current;
378
+ }
379
+ if (!/^https?:\/\//i.test(src)) {
380
+ const prepared: PreparedSource = { localUri: src, container: 'unknown' };
381
+ preparedRef.current = prepared;
382
+ return prepared;
383
+ }
384
+ const cacheDirectory = FileSystem.cacheDirectory;
385
+ if (!cacheDirectory) {
386
+ const prepared: PreparedSource = { localUri: src, container: 'unknown' };
387
+ preparedRef.current = prepared;
388
+ return prepared;
389
+ }
390
+
391
+ const base = `${cacheDirectory}ethora-audio-${hashString(src)}`;
392
+ const part = `${base}.part`;
393
+ const partInfo = await FileSystem.getInfoAsync(part);
394
+ if (!partInfo.exists) {
395
+ const download = await withTimeout(
396
+ FileSystem.downloadAsync(src, part),
397
+ 12000,
398
+ 'audio_download_timeout'
399
+ );
400
+ logAudioDebug('audio downloaded', {
401
+ src,
402
+ uri: download.status === 200 ? download.uri : undefined,
403
+ status: download.status,
404
+ });
405
+ if (download.status !== 200) {
406
+ throw new Error(`audio_download_status_${download.status}`);
407
+ }
408
+ }
409
+
410
+ const head = await FileSystem.readAsStringAsync(part, {
411
+ encoding: FileSystem.EncodingType.Base64,
412
+ position: 0,
413
+ length: 32,
414
+ });
415
+ const container = detectContainer(decodeBase64Head(head, 16));
416
+
417
+ let base64: string | undefined;
418
+ let localUri = part;
419
+ if (requiresWebAudioDecode(container)) {
420
+ // WebView-decode path: extension is irrelevant, we hand over the bytes.
421
+ const info = await FileSystem.getInfoAsync(part);
422
+ const size = 'size' in info ? info.size ?? 0 : 0;
423
+ if (size > MAX_DECODE_BYTES) {
424
+ throw new Error('audio_too_large_to_decode');
425
+ }
426
+ base64 = await FileSystem.readAsStringAsync(part, {
427
+ encoding: FileSystem.EncodingType.Base64,
428
+ });
429
+ } else {
430
+ // Native path: give the cached file a real extension so AVFoundation
431
+ // / ExoPlayer pick the right demuxer instead of choking on an opaque
432
+ // `.blob` (the original `audio_timeout` cause).
433
+ const ext =
434
+ container !== 'unknown'
435
+ ? getContainerExtension(container)
436
+ : getIosAudioPlaybackCacheExtension({
437
+ mime: mimeType,
438
+ fileName,
439
+ originalName,
440
+ url: src,
441
+ });
442
+ const finalUri = `${base}${ext}`;
443
+ const finalInfo = await FileSystem.getInfoAsync(finalUri);
444
+ if (finalInfo.exists) {
445
+ await FileSystem.deleteAsync(part, { idempotent: true }).catch(
446
+ () => {}
447
+ );
448
+ } else {
449
+ await FileSystem.moveAsync({ from: part, to: finalUri });
450
+ }
451
+ localUri = finalUri;
452
+ }
453
+
454
+ const prepared: PreparedSource = { localUri, container, base64 };
455
+ preparedRef.current = prepared;
456
+ logAudioDebug('audio prepared', {
457
+ src,
458
+ mimeType,
459
+ fileName,
460
+ originalName,
461
+ container,
462
+ platform: Platform.OS,
463
+ decode: requiresWebAudioDecode(container) ? 'web-audio' : 'native',
464
+ });
465
+ return prepared;
466
+ };
467
+
468
+ const maybeInjectDecode = () => {
469
+ const base64 = preparedRef.current?.base64;
470
+ if (
471
+ bridgeReadyRef.current &&
472
+ base64 &&
473
+ !injectedRef.current &&
474
+ webViewRef.current
475
+ ) {
476
+ injectedRef.current = true;
477
+ webViewRef.current.injectJavaScript(
478
+ `window.__decodeAudio(${JSON.stringify(base64)}); true;`
479
+ );
480
+ }
481
+ };
24
482
 
25
483
  const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
26
- if (!status.isLoaded) return;
484
+ if (!status.isLoaded) {
485
+ if (status.error) {
486
+ logAudioDebug('playback status error', { src, error: status.error });
487
+ }
488
+ return;
489
+ }
27
490
  setPosition(status.positionMillis);
28
491
  setDuration(status.durationMillis ?? 0);
29
492
  setIsPlaying(status.isPlaying);
30
493
  if (status.didJustFinish) {
494
+ didFinishRef.current = true;
31
495
  setIsPlaying(false);
32
- soundRef.current?.setPositionAsync(0);
496
+ setPosition(0);
497
+ void soundRef.current?.setStatusAsync({
498
+ shouldPlay: false,
499
+ positionMillis: 0,
500
+ });
33
501
  }
34
502
  };
35
503
 
36
- const togglePlayback = async () => {
504
+ const startNativePlayback = async (localUri: string) => {
37
505
  try {
38
- if (!soundRef.current) {
39
- setIsLoading(true);
40
- const { sound } = await Audio.Sound.createAsync(
41
- { uri: src },
42
- { shouldPlay: true },
43
- onPlaybackStatusUpdate
44
- );
45
- soundRef.current = sound;
46
- setIsLoading(false);
47
- setIsPlaying(true);
506
+ await Audio.setAudioModeAsync({ playsInSilentModeIOS: true });
507
+ } catch {
508
+ /* non-fatal — still plays through the ringer channel */
509
+ }
510
+ const { sound } = await withTimeout<{
511
+ sound: Audio.Sound;
512
+ status: AVPlaybackStatus;
513
+ }>(
514
+ Audio.Sound.createAsync(
515
+ { uri: localUri },
516
+ { shouldPlay: true },
517
+ onPlaybackStatusUpdate
518
+ ),
519
+ 12000,
520
+ 'audio_create_timeout'
521
+ );
522
+ if (unmountedRef.current) {
523
+ void sound.unloadAsync();
524
+ return;
525
+ }
526
+ soundRef.current = sound;
527
+ clearLoadingGuard();
528
+ setIsLoading(false);
529
+ setPlaybackError(false);
530
+ setIsPlaying(true);
531
+ logAudioDebug('native audio playback started', {
532
+ src,
533
+ container: preparedRef.current?.container,
534
+ localUri,
535
+ });
536
+ };
537
+
538
+ // WebView finished decoding Opus → WAV: persist the WAV and play it
539
+ // natively. From here it is indistinguishable from any other native clip.
540
+ const onDecoded = async (durationMillis: number, wavBase64: string) => {
541
+ try {
542
+ const cacheDirectory = FileSystem.cacheDirectory || '';
543
+ const wavUri = `${cacheDirectory}ethora-audio-${hashString(src)}.wav`;
544
+ await FileSystem.writeAsStringAsync(wavUri, wavBase64, {
545
+ encoding: FileSystem.EncodingType.Base64,
546
+ });
547
+ if (unmountedRef.current) {
48
548
  return;
49
549
  }
550
+ preparedRef.current = {
551
+ localUri: wavUri,
552
+ container: preparedRef.current?.container ?? 'webm',
553
+ };
554
+ if (durationMillis > 0) {
555
+ setDuration(durationMillis);
556
+ }
557
+ setWebViewEnabled(false); // decoder no longer needed
558
+ await startNativePlayback(wavUri);
559
+ } catch (error) {
560
+ fail(error);
561
+ }
562
+ };
50
563
 
51
- if (isPlaying) {
52
- await soundRef.current.pauseAsync();
53
- } else {
54
- await soundRef.current.playAsync();
564
+ const handleWebMessage = (event: WebViewMessageEvent) => {
565
+ let payload: WebDecoderMessage;
566
+ try {
567
+ payload = JSON.parse(event.nativeEvent.data) as WebDecoderMessage;
568
+ } catch {
569
+ return;
570
+ }
571
+ if (payload.type === 'bridge_ready') {
572
+ bridgeReadyRef.current = true;
573
+ maybeInjectDecode();
574
+ return;
575
+ }
576
+ if (payload.type === 'decoded') {
577
+ logAudioDebug('web audio decoded', {
578
+ src,
579
+ durationMillis: payload.durationMillis,
580
+ wavBytes: payload.wavBase64 ? payload.wavBase64.length : 0,
581
+ });
582
+ void onDecoded(payload.durationMillis, payload.wavBase64);
583
+ return;
584
+ }
585
+ // type === 'error'
586
+ fail(new Error(payload.message || 'web_audio_error'));
587
+ };
588
+
589
+ const togglePlayback = async () => {
590
+ if (!src) {
591
+ return;
592
+ }
593
+
594
+ // Already-loaded native sound (covers both native formats AND the
595
+ // decoded-WAV from the WebView path) → plain toggle.
596
+ if (soundRef.current) {
597
+ try {
598
+ if (isPlaying) {
599
+ await soundRef.current.pauseAsync();
600
+ } else {
601
+ if (didFinishRef.current) {
602
+ await soundRef.current.setPositionAsync(0);
603
+ didFinishRef.current = false;
604
+ }
605
+ await soundRef.current.playAsync();
606
+ }
607
+ } catch (error) {
608
+ fail(error);
55
609
  }
610
+ return;
611
+ }
612
+
613
+ // First tap → prepare (download + sniff) then route to the right engine.
614
+ setPlaybackError(false);
615
+ setIsLoading(true);
616
+ armLoadingGuard();
617
+ try {
618
+ const prepared = await withTimeout(
619
+ prepareSource(),
620
+ 14000,
621
+ 'audio_prepare_timeout'
622
+ );
623
+ if (unmountedRef.current) {
624
+ return;
625
+ }
626
+ if (requiresWebAudioDecode(prepared.container)) {
627
+ // Mount the decoder WebView; decoding + playback continue when the
628
+ // 'decoded' message arrives (→ onDecoded → startNativePlayback).
629
+ if (!webViewEnabled) {
630
+ setWebViewEnabled(true);
631
+ }
632
+ maybeInjectDecode();
633
+ return;
634
+ }
635
+ await startNativePlayback(prepared.localUri);
56
636
  } catch (error) {
57
- console.error('Audio playback error:', error);
58
- setIsLoading(false);
637
+ fail(error);
59
638
  }
60
639
  };
61
640
 
62
641
  useEffect(() => {
642
+ unmountedRef.current = false;
63
643
  return () => {
644
+ unmountedRef.current = true;
645
+ clearLoadingGuard();
64
646
  soundRef.current?.unloadAsync();
65
647
  };
648
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
649
  }, []);
67
650
 
68
- const progress = duration > 0 ? position / duration : 0;
651
+ // Full reset whenever the source changes (component reused for a new
652
+ // message / re-render with a different src).
653
+ useEffect(() => {
654
+ soundRef.current?.unloadAsync();
655
+ soundRef.current = null;
656
+ preparedRef.current = null;
657
+ bridgeReadyRef.current = false;
658
+ injectedRef.current = false;
659
+ didFinishRef.current = false;
660
+ clearLoadingGuard();
661
+ setIsPlaying(false);
662
+ setIsLoading(false);
663
+ setPlaybackError(false);
664
+ setPosition(0);
665
+ setDuration(0);
666
+ setWebViewEnabled(false);
667
+ // eslint-disable-next-line react-hooks/exhaustive-deps
668
+ }, [src]);
669
+
670
+ const progress = duration > 0 ? Math.min(1, position / duration) : 0;
69
671
 
70
672
  return (
71
- <View style={styles.container}>
72
- <TouchableOpacity
73
- style={styles.playButton}
74
- onPress={togglePlayback}
75
- disabled={isLoading}
76
- >
77
- {isLoading ? (
78
- <ActivityIndicator size="small" color="#fff" />
79
- ) : (
80
- <Text style={styles.playIcon}>{isPlaying ? '⏸' : '▶'}</Text>
81
- )}
82
- </TouchableOpacity>
83
- <View style={styles.progressContainer}>
84
- <View style={styles.progressTrack}>
85
- <View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
673
+ <View style={styles.root}>
674
+ {/* Decoder WebView lives in a zero-size, absolutely-positioned host
675
+ OUTSIDE the row so mounting/unmounting it during the loading phase
676
+ can never shift the button or progress line (was the flicker). */}
677
+ {webViewEnabled ? (
678
+ <View style={styles.decoderHost} pointerEvents="none">
679
+ <WebView
680
+ ref={webViewRef}
681
+ source={{ html: WEB_DECODER_HTML }}
682
+ originWhitelist={['*']}
683
+ onMessage={handleWebMessage}
684
+ javaScriptEnabled
685
+ domStorageEnabled={false}
686
+ scrollEnabled={false}
687
+ pointerEvents="none"
688
+ showsHorizontalScrollIndicator={false}
689
+ showsVerticalScrollIndicator={false}
690
+ style={styles.hiddenWebView}
691
+ />
692
+ </View>
693
+ ) : null}
694
+ <View style={styles.container}>
695
+ <TouchableOpacity
696
+ style={[styles.playButton, { backgroundColor: primaryColor }]}
697
+ onPress={togglePlayback}
698
+ disabled={isLoading}
699
+ activeOpacity={0.85}
700
+ >
701
+ {isLoading ? (
702
+ <ActivityIndicator size="small" color="#fff" />
703
+ ) : (
704
+ <View style={styles.iconWrap}>
705
+ {isPlaying ? (
706
+ <PauseIcon width={18} height={18} color="#fff" />
707
+ ) : (
708
+ <PlayIcon width={18} height={18} color="#fff" />
709
+ )}
710
+ </View>
711
+ )}
712
+ </TouchableOpacity>
713
+ <View style={styles.progressContainer}>
714
+ <View style={styles.progressTrack}>
715
+ <View
716
+ style={[
717
+ styles.progressFill,
718
+ { width: `${progress * 100}%`, backgroundColor: primaryColor },
719
+ ]}
720
+ />
721
+ </View>
722
+ <View style={styles.progressRow}>
723
+ <Text style={styles.time}>
724
+ {playbackError ? 'Audio unavailable' : formatTime(position)}
725
+ </Text>
726
+ {/* Always render the right slot (empty until known) so the row
727
+ never reflows when the duration appears after decoding. */}
728
+ <Text style={styles.time}>
729
+ {!playbackError && duration > 0 ? formatTime(duration) : ''}
730
+ </Text>
731
+ </View>
86
732
  </View>
87
- <Text style={styles.time}>
88
- {formatTime(position)} / {formatTime(duration)}
89
- </Text>
90
733
  </View>
91
734
  </View>
92
735
  );
@@ -95,43 +738,65 @@ const AudioMessage = ({ src }: { src: string }) => {
95
738
  export default AudioMessage;
96
739
 
97
740
  const styles = StyleSheet.create({
741
+ root: {
742
+ position: 'relative',
743
+ },
744
+ decoderHost: {
745
+ position: 'absolute',
746
+ top: 0,
747
+ left: 0,
748
+ width: 0,
749
+ height: 0,
750
+ overflow: 'hidden',
751
+ opacity: 0,
752
+ },
98
753
  container: {
99
754
  flexDirection: 'row',
100
755
  alignItems: 'center',
101
- paddingHorizontal: 8,
102
- paddingVertical: 6,
103
- minWidth: 200,
756
+ gap: 12,
757
+ paddingHorizontal: 12,
758
+ paddingVertical: 10,
759
+ width: 260,
760
+ maxWidth: '100%',
104
761
  },
105
762
  playButton: {
106
- width: 36,
107
- height: 36,
108
- borderRadius: 18,
109
- backgroundColor: '#007AFF',
763
+ width: 44,
764
+ height: 44,
765
+ borderRadius: 22,
110
766
  alignItems: 'center',
111
767
  justifyContent: 'center',
112
- marginRight: 10,
113
768
  },
114
- playIcon: {
115
- color: '#fff',
116
- fontSize: 14,
769
+ iconWrap: {
770
+ marginLeft: 1,
117
771
  },
118
772
  progressContainer: {
119
773
  flex: 1,
774
+ gap: 6,
775
+ },
776
+ progressRow: {
777
+ flexDirection: 'row',
778
+ justifyContent: 'space-between',
779
+ alignItems: 'center',
120
780
  },
121
781
  progressTrack: {
122
- height: 4,
123
- backgroundColor: '#E0E0E0',
124
- borderRadius: 2,
782
+ height: 6,
783
+ backgroundColor: '#D0D7E6',
784
+ borderRadius: 999,
125
785
  overflow: 'hidden',
126
786
  },
787
+ hiddenWebView: {
788
+ position: 'absolute',
789
+ width: 1,
790
+ height: 1,
791
+ opacity: 0,
792
+ },
127
793
  progressFill: {
128
794
  height: '100%',
129
- backgroundColor: '#007AFF',
130
- borderRadius: 2,
795
+ borderRadius: 999,
131
796
  },
132
797
  time: {
133
- fontSize: 11,
134
- color: '#888',
135
- marginTop: 4,
798
+ fontSize: 12,
799
+ color: '#667085',
800
+ fontWeight: '500',
136
801
  },
137
802
  });