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