@ethora/chat-component-rn 26.6.11 → 26.6.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 (231) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -2
  3. package/lib/commonjs/components/AuthForms/Login.js +2 -1
  4. package/lib/commonjs/components/AuthForms/Login.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatHeader.js +4 -1
  6. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatWrapper.js +14 -13
  8. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/LoginWrapper.js +2 -1
  10. package/lib/commonjs/components/MainComponents/LoginWrapper.js.map +1 -1
  11. package/lib/commonjs/components/MainComponents/MediaMessage.js +18 -7
  12. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  13. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js +1 -1
  14. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js.map +1 -1
  15. package/lib/commonjs/components/MessageBubble/Message.js +1 -1
  16. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  17. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +1 -1
  18. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  19. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +4 -1
  20. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  21. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +15 -11
  22. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  23. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +1 -1
  24. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -1
  25. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js +2 -1
  26. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  27. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js +5 -1
  28. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js.map +1 -1
  29. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js +10 -2
  30. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  31. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js +4 -1
  32. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  33. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js +4 -1
  34. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  35. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +6 -3
  36. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  37. package/lib/commonjs/components/styled/AudioMessage.js +2 -1
  38. package/lib/commonjs/components/styled/AudioMessage.js.map +1 -1
  39. package/lib/commonjs/components/styled/MediaFilePreview.js +1 -1
  40. package/lib/commonjs/components/styled/MediaFilePreview.js.map +1 -1
  41. package/lib/commonjs/components/styled/MessageImage.js +34 -8
  42. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  43. package/lib/commonjs/components/styled/VideoMessage.js +36 -64
  44. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  45. package/lib/commonjs/context/xmppProvider.js +44 -18
  46. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  47. package/lib/commonjs/helpers/initXmppRooms.js +5 -1
  48. package/lib/commonjs/helpers/initXmppRooms.js.map +1 -1
  49. package/lib/commonjs/helpers/resolveInitBeforeLoadUser.js +126 -12
  50. package/lib/commonjs/helpers/resolveInitBeforeLoadUser.js.map +1 -1
  51. package/lib/commonjs/helpers/secureFileUrl.js +101 -0
  52. package/lib/commonjs/helpers/secureFileUrl.js.map +1 -0
  53. package/lib/commonjs/hooks/useChatWrapperInit.js +3 -3
  54. package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
  55. package/lib/commonjs/hooks/useFileToken.js +10 -0
  56. package/lib/commonjs/hooks/useFileToken.js.map +1 -0
  57. package/lib/commonjs/hooks/useSendMessage.js +23 -13
  58. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  59. package/lib/commonjs/main.js +25 -0
  60. package/lib/commonjs/main.js.map +1 -1
  61. package/lib/commonjs/networking/api-requests/auth.api.js +99 -54
  62. package/lib/commonjs/networking/api-requests/auth.api.js.map +1 -1
  63. package/lib/commonjs/networking/api-requests/roomMembers.api.js +1 -1
  64. package/lib/commonjs/networking/api-requests/roomMembers.api.js.map +1 -1
  65. package/lib/commonjs/networking/api-requests/rooms.api.js +9 -9
  66. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  67. package/lib/commonjs/networking/api-requests/user.api.js +9 -9
  68. package/lib/commonjs/networking/api-requests/user.api.js.map +1 -1
  69. package/lib/commonjs/networking/apiClient.js +62 -112
  70. package/lib/commonjs/networking/apiClient.js.map +1 -1
  71. package/lib/commonjs/networking/authRefresh.js +323 -0
  72. package/lib/commonjs/networking/authRefresh.js.map +1 -0
  73. package/lib/commonjs/networking/xmppClient.js +33 -5
  74. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  75. package/lib/commonjs/roomStore/chatSettingsSlice.js +20 -2
  76. package/lib/commonjs/roomStore/chatSettingsSlice.js.map +1 -1
  77. package/lib/module/components/AuthForms/Login.js +2 -1
  78. package/lib/module/components/AuthForms/Login.js.map +1 -1
  79. package/lib/module/components/MainComponents/ChatHeader.js +4 -1
  80. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  81. package/lib/module/components/MainComponents/ChatWrapper.js +14 -13
  82. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  83. package/lib/module/components/MainComponents/LoginWrapper.js +2 -1
  84. package/lib/module/components/MainComponents/LoginWrapper.js.map +1 -1
  85. package/lib/module/components/MainComponents/MediaMessage.js +18 -7
  86. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  87. package/lib/module/components/MessageBubble/InteractionsOverlay.js +1 -1
  88. package/lib/module/components/MessageBubble/InteractionsOverlay.js.map +1 -1
  89. package/lib/module/components/MessageBubble/Message.js +1 -1
  90. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  91. package/lib/module/components/MessageBubble/MessageInteractions.js +1 -1
  92. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  93. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +4 -1
  94. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  95. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +15 -11
  96. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  97. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +1 -1
  98. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -1
  99. package/lib/module/components/Modals/FilePreviewModal/PdfView.js +2 -1
  100. package/lib/module/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  101. package/lib/module/components/RoomComponents/ChatRoomItem.js +5 -1
  102. package/lib/module/components/RoomComponents/ChatRoomItem.js.map +1 -1
  103. package/lib/module/components/RoomComponents/styled/LastMessageFile.js +10 -2
  104. package/lib/module/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  105. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js +4 -1
  106. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  107. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js +4 -1
  108. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  109. package/lib/module/components/VideoCalls/VideoCallSession.js +6 -3
  110. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  111. package/lib/module/components/styled/AudioMessage.js +2 -1
  112. package/lib/module/components/styled/AudioMessage.js.map +1 -1
  113. package/lib/module/components/styled/MediaFilePreview.js +1 -1
  114. package/lib/module/components/styled/MediaFilePreview.js.map +1 -1
  115. package/lib/module/components/styled/MessageImage.js +34 -8
  116. package/lib/module/components/styled/MessageImage.js.map +1 -1
  117. package/lib/module/components/styled/VideoMessage.js +36 -64
  118. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  119. package/lib/module/context/xmppProvider.js +43 -18
  120. package/lib/module/context/xmppProvider.js.map +1 -1
  121. package/lib/module/helpers/initXmppRooms.js +5 -1
  122. package/lib/module/helpers/initXmppRooms.js.map +1 -1
  123. package/lib/module/helpers/resolveInitBeforeLoadUser.js +126 -11
  124. package/lib/module/helpers/resolveInitBeforeLoadUser.js.map +1 -1
  125. package/lib/module/helpers/secureFileUrl.js +91 -0
  126. package/lib/module/helpers/secureFileUrl.js.map +1 -0
  127. package/lib/module/hooks/useChatWrapperInit.js +3 -3
  128. package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
  129. package/lib/module/hooks/useFileToken.js +5 -0
  130. package/lib/module/hooks/useFileToken.js.map +1 -0
  131. package/lib/module/hooks/useSendMessage.js +24 -14
  132. package/lib/module/hooks/useSendMessage.js.map +1 -1
  133. package/lib/module/main.js +12 -1
  134. package/lib/module/main.js.map +1 -1
  135. package/lib/module/networking/api-requests/auth.api.js +96 -53
  136. package/lib/module/networking/api-requests/auth.api.js.map +1 -1
  137. package/lib/module/networking/api-requests/roomMembers.api.js +1 -1
  138. package/lib/module/networking/api-requests/roomMembers.api.js.map +1 -1
  139. package/lib/module/networking/api-requests/rooms.api.js +9 -9
  140. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  141. package/lib/module/networking/api-requests/user.api.js +8 -7
  142. package/lib/module/networking/api-requests/user.api.js.map +1 -1
  143. package/lib/module/networking/apiClient.js +61 -113
  144. package/lib/module/networking/apiClient.js.map +1 -1
  145. package/lib/module/networking/authRefresh.js +312 -0
  146. package/lib/module/networking/authRefresh.js.map +1 -0
  147. package/lib/module/networking/xmppClient.js +33 -5
  148. package/lib/module/networking/xmppClient.js.map +1 -1
  149. package/lib/module/roomStore/chatSettingsSlice.js +20 -2
  150. package/lib/module/roomStore/chatSettingsSlice.js.map +1 -1
  151. package/lib/typescript/components/AuthForms/Login.d.ts.map +1 -1
  152. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  153. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  154. package/lib/typescript/components/MainComponents/LoginWrapper.d.ts.map +1 -1
  155. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  156. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  157. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  158. package/lib/typescript/components/Modals/FilePreviewModal/PdfView.d.ts.map +1 -1
  159. package/lib/typescript/components/RoomComponents/ChatRoomItem.d.ts.map +1 -1
  160. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts.map +1 -1
  161. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts.map +1 -1
  162. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts.map +1 -1
  163. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  164. package/lib/typescript/components/styled/AudioMessage.d.ts.map +1 -1
  165. package/lib/typescript/components/styled/MessageImage.d.ts +8 -0
  166. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  167. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  168. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  169. package/lib/typescript/helpers/initXmppRooms.d.ts.map +1 -1
  170. package/lib/typescript/helpers/resolveInitBeforeLoadUser.d.ts.map +1 -1
  171. package/lib/typescript/helpers/secureFileUrl.d.ts +7 -0
  172. package/lib/typescript/helpers/secureFileUrl.d.ts.map +1 -0
  173. package/lib/typescript/hooks/useCustomTypingIndicator.d.ts +16 -4
  174. package/lib/typescript/hooks/useCustomTypingIndicator.d.ts.map +1 -1
  175. package/lib/typescript/hooks/useFileToken.d.ts +2 -0
  176. package/lib/typescript/hooks/useFileToken.d.ts.map +1 -0
  177. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  178. package/lib/typescript/main.d.ts +6 -1
  179. package/lib/typescript/main.d.ts.map +1 -1
  180. package/lib/typescript/networking/api-requests/auth.api.d.ts +3 -27
  181. package/lib/typescript/networking/api-requests/auth.api.d.ts.map +1 -1
  182. package/lib/typescript/networking/api-requests/user.api.d.ts.map +1 -1
  183. package/lib/typescript/networking/apiClient.d.ts +12 -6
  184. package/lib/typescript/networking/apiClient.d.ts.map +1 -1
  185. package/lib/typescript/networking/authRefresh.d.ts +92 -0
  186. package/lib/typescript/networking/authRefresh.d.ts.map +1 -0
  187. package/lib/typescript/networking/xmppClient.d.ts +6 -0
  188. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  189. package/lib/typescript/roomStore/chatSettingsSlice.d.ts +16 -0
  190. package/lib/typescript/roomStore/chatSettingsSlice.d.ts.map +1 -1
  191. package/lib/typescript/types/types.d.ts +17 -0
  192. package/lib/typescript/types/types.d.ts.map +1 -1
  193. package/package.json +29 -28
  194. package/src/components/AuthForms/Login.tsx +1 -0
  195. package/src/components/MainComponents/ChatHeader.tsx +4 -1
  196. package/src/components/MainComponents/ChatWrapper.tsx +14 -19
  197. package/src/components/MainComponents/LoginWrapper.tsx +1 -0
  198. package/src/components/MainComponents/MediaMessage.tsx +15 -5
  199. package/src/components/MessageBubble/InteractionsOverlay.tsx +1 -1
  200. package/src/components/MessageBubble/Message.tsx +1 -1
  201. package/src/components/MessageBubble/MessageInteractions.tsx +1 -1
  202. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +4 -1
  203. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +14 -10
  204. package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +1 -1
  205. package/src/components/Modals/FilePreviewModal/PdfView.tsx +2 -1
  206. package/src/components/RoomComponents/ChatRoomItem.tsx +9 -1
  207. package/src/components/RoomComponents/styled/LastMessageFile.tsx +14 -2
  208. package/src/components/RoomComponents/styled/LastMessagePhoto.tsx +4 -1
  209. package/src/components/RoomComponents/styled/LastMessageVideo.tsx +4 -1
  210. package/src/components/VideoCalls/VideoCallSession.tsx +8 -3
  211. package/src/components/styled/AudioMessage.tsx +2 -1
  212. package/src/components/styled/MediaFilePreview.tsx +1 -1
  213. package/src/components/styled/MessageImage.tsx +45 -6
  214. package/src/components/styled/VideoMessage.tsx +36 -60
  215. package/src/context/xmppProvider.tsx +45 -20
  216. package/src/helpers/initXmppRooms.tsx +5 -1
  217. package/src/helpers/resolveInitBeforeLoadUser.ts +123 -11
  218. package/src/helpers/secureFileUrl.ts +103 -0
  219. package/src/hooks/useChatWrapperInit.ts +3 -3
  220. package/src/hooks/useFileToken.ts +7 -0
  221. package/src/hooks/useSendMessage.tsx +28 -13
  222. package/src/main.ts +22 -1
  223. package/src/networking/api-requests/auth.api.ts +125 -52
  224. package/src/networking/api-requests/roomMembers.api.ts +1 -1
  225. package/src/networking/api-requests/rooms.api.ts +9 -9
  226. package/src/networking/api-requests/user.api.ts +8 -7
  227. package/src/networking/apiClient.ts +68 -123
  228. package/src/networking/authRefresh.ts +435 -0
  229. package/src/networking/xmppClient.ts +37 -5
  230. package/src/roomStore/chatSettingsSlice.ts +28 -3
  231. package/src/types/types.ts +17 -0
@@ -345,9 +345,14 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
345
345
  if (!runtime) {return;}
346
346
  try {
347
347
  if (Platform.OS === 'android') {
348
- await runtime.AudioSession.setAndroidAudioConfiguration?.({
349
- preferredOutputList: next ? ['speaker'] : ['earpiece'],
350
- });
348
+ // NB: not `setAndroidAudioConfiguration` — that method has never
349
+ // existed on AudioSession (the old `?.` call silently no-oped, so
350
+ // the speaker icon toggled while audio stayed on the earpiece).
351
+ // `selectAudioOutput` routes directly; on Android its device ids
352
+ // are the literal strings 'speaker' / 'earpiece'.
353
+ await runtime.AudioSession.selectAudioOutput(
354
+ next ? 'speaker' : 'earpiece'
355
+ );
351
356
  } else {
352
357
  await runtime.AudioSession.setAppleAudioConfiguration?.({
353
358
  audioCategory: 'playAndRecord',
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
+ import { withFileToken } from '../../helpers/secureFileUrl';
2
3
  import {
3
4
  View,
4
5
  TouchableOpacity,
@@ -425,7 +426,7 @@ const AudioMessage = ({
425
426
  const partInfo = await FileSystem.getInfoAsync(part);
426
427
  if (!partInfo.exists) {
427
428
  const download = await withTimeout(
428
- FileSystem.downloadAsync(src, part),
429
+ FileSystem.downloadAsync(withFileToken(src), part),
429
430
  12000,
430
431
  'audio_download_timeout'
431
432
  );
@@ -110,7 +110,7 @@ const styles = StyleSheet.create({
110
110
  backgroundColor: '#1a1a1a',
111
111
  },
112
112
  playOverlay: {
113
- ...StyleSheet.absoluteFillObject,
113
+ ...StyleSheet.absoluteFill,
114
114
  justifyContent: 'center',
115
115
  alignItems: 'center',
116
116
  backgroundColor: 'rgba(0,0,0,0.3)',
@@ -1,4 +1,9 @@
1
1
  import React, { useEffect, useState } from 'react';
2
+ import {
3
+ isSecureFileUrl,
4
+ requestFileTokenRecovery,
5
+ } from '../../helpers/secureFileUrl';
6
+ import { pushLog } from '../../utils/devLogger';
2
7
  import { Container } from './StyledInputComponents/MediaComponents';
3
8
  import { useDispatch } from 'react-redux';
4
9
  import {
@@ -14,11 +19,23 @@ import {
14
19
  } from '../../helpers/mediaDimensions';
15
20
 
16
21
  interface CustomMessageImageProps {
22
+ /** Full-size file. Opened in the preview modal, NOT rendered inline. */
17
23
  fileURL: string;
18
24
  fileName: string;
19
25
  mimetype: string;
26
+ /**
27
+ * Server-generated thumbnail — what the bubble actually renders, same
28
+ * as the web client. The full-size original can be many megabytes and,
29
+ * for secure uploads, is served from a different (membership-gated)
30
+ * route; rendering it inline is what left the bubbles blank.
31
+ */
32
+ locationPreview?: string;
20
33
  }
21
34
 
35
+ /** Keep the personal file token out of the logs. */
36
+ const maskToken = (url?: string) =>
37
+ (url || '').replace(/([?&]ft=)[^&]*/i, '$1<redacted>');
38
+
22
39
  const FALLBACK_IMAGE =
23
40
  'https://as2.ftcdn.net/v2/jpg/02/51/95/53/1000_F_251955356_FAQH0U1y1TZw3ZcdPGybwUkH90a3VAhb.jpg';
24
41
 
@@ -26,7 +43,12 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
26
43
  fileURL,
27
44
  fileName,
28
45
  mimetype,
46
+ locationPreview,
29
47
  }) => {
48
+ // Preview first, original only as a fallback — mirrors the web client,
49
+ // where the <img> src is the preview and `fileURL` is reserved for the
50
+ // modal.
51
+ const displayURL = locationPreview || fileURL;
30
52
  const [loading, setLoading] = useState(true);
31
53
  // Start with a neutral box; swap to the real aspect ratio once the
32
54
  // image's natural dimensions are known so it's shown scaled, not cropped.
@@ -35,25 +57,31 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
35
57
  const dispatch = useDispatch();
36
58
 
37
59
  useEffect(() => {
38
- if (!fileURL) {
60
+ if (!displayURL) {
39
61
  return;
40
62
  }
41
63
  let active = true;
42
64
  Image.getSize(
43
- fileURL,
65
+ displayURL,
44
66
  (w, h) => {
45
67
  if (active) {
46
68
  setDims(fitMediaDimensions(w, h));
47
69
  }
48
70
  },
49
- () => {
50
- /* keep the default box if the size probe fails */
71
+ (error) => {
72
+ // Keep the default box — but say WHICH url failed. A blank
73
+ // bubble is otherwise indistinguishable between "bad url",
74
+ // "403 without a token" and "host unreachable".
75
+ pushLog('warn', 'image: getSize failed', {
76
+ url: maskToken(displayURL),
77
+ error: (error as any)?.message || String(error),
78
+ });
51
79
  }
52
80
  );
53
81
  return () => {
54
82
  active = false;
55
83
  };
56
- }, [fileURL]);
84
+ }, [displayURL]);
57
85
 
58
86
  const handleOpen = () => {
59
87
  dispatch(setActiveFile({ fileName, fileURL, mimetype }));
@@ -78,7 +106,7 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
78
106
  </View>
79
107
  )}
80
108
  <Image
81
- source={{ uri: fileURL || FALLBACK_IMAGE }}
109
+ source={{ uri: displayURL || FALLBACK_IMAGE }}
82
110
  style={{
83
111
  width: dims.width,
84
112
  height: dims.height,
@@ -86,6 +114,17 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
86
114
  backgroundColor: 'rgba(0, 0, 0, 0)',
87
115
  }}
88
116
  onLoadEnd={() => setLoading(false)}
117
+ onError={(event) => {
118
+ setLoading(false);
119
+ pushLog('warn', 'image: load failed', {
120
+ url: maskToken(displayURL),
121
+ usingPreview: !!locationPreview,
122
+ error: (event as any)?.nativeEvent?.error,
123
+ });
124
+ if (isSecureFileUrl(displayURL)) {
125
+ requestFileTokenRecovery();
126
+ }
127
+ }}
89
128
  // The box already matches the natural aspect ratio, so 'cover'
90
129
  // fills it edge-to-edge with no actual cropping or letterboxing.
91
130
  resizeMode="cover"
@@ -1,6 +1,9 @@
1
1
  import React, { useEffect, useState } from 'react';
2
+ import {
3
+ isSecureFileUrl,
4
+ requestFileTokenRecovery,
5
+ } from '../../helpers/secureFileUrl';
2
6
  import { Image, Pressable, StyleSheet, View } from 'react-native';
3
- import { useVideoPlayer, VideoView } from 'expo-video';
4
7
  import { useDispatch } from 'react-redux';
5
8
  import {
6
9
  setActiveFile,
@@ -37,12 +40,6 @@ const PlayBadge: React.FC = () => (
37
40
  * playback happens in the full-screen preview. Drawing that still with a
38
41
  * plain <Image> keeps the whole bubble inside the RN view hierarchy, so
39
42
  * the play badge is guaranteed to composite on top of it.
40
- *
41
- * The VideoView path below cannot make that promise: expo-video draws
42
- * into a native surface, and depending on platform, expo-video version
43
- * and surfaceType that surface can end up above its sibling RN views —
44
- * swallowing the badge and leaving the video looking like a plain photo.
45
- * It is also cheaper: no video player instance per row in a long list.
46
43
  */
47
44
  const PosterVideo: React.FC<{
48
45
  previewURL: string;
@@ -85,8 +82,8 @@ const PosterVideo: React.FC<{
85
82
  resizeMode="cover"
86
83
  // Not every backend returns a real still — some set
87
84
  // locationPreview to the video URL itself, which decodes to
88
- // nothing. Rather than show an empty box, hand the row back to
89
- // the player so it can paint a first frame.
85
+ // nothing. Rather than show an empty box, fall back to the
86
+ // static placeholder branch.
90
87
  onError={onPosterError}
91
88
  />
92
89
  <PlayBadge />
@@ -95,61 +92,35 @@ const PosterVideo: React.FC<{
95
92
  };
96
93
 
97
94
  /**
98
- * Fallback for messages with no usable poster frame: mount the player
99
- * purely to paint its first frame.
95
+ * Fallback for messages with no usable poster frame: a static dark
96
+ * placeholder behind the play badge.
97
+ *
98
+ * This branch used to mount an expo-video player purely to paint the
99
+ * first frame (`VideoView` + `surfaceType="textureView"`). That never
100
+ * composited reliably: on Android (Pixel, Android 16, New Architecture)
101
+ * the native video surface draws ABOVE sibling RN views, swallowing the
102
+ * play badge — the video looked like a plain photo. There is no
103
+ * surfaceType/zIndex combination that guarantees ordering across SDK 54
104
+ * and 57, so we don't fight it: no live surface in the bubble at all.
105
+ * The badge is plain RN views over a plain RN background, which cannot
106
+ * be occluded, and we drop the per-row player instance as a bonus.
107
+ * Actual playback (and the real first frame) happens in the full-screen
108
+ * preview modal.
100
109
  */
101
- const VideoSurfaceVideo: React.FC<{
102
- fileURL: string;
110
+ const PlaceholderVideo: React.FC<{
103
111
  onOpen: () => void;
104
- }> = ({ fileURL, onOpen }) => {
105
- const [dims, setDims] = useState<MediaDims>(defaultMediaDims());
106
-
107
- const player = useVideoPlayer(fileURL, (p) => {
108
- p.muted = true;
109
- });
110
-
111
- useEffect(() => {
112
- const sub = player.addListener('statusChange', () => {
113
- const size = player.videoTrack?.size;
114
- if (size?.width && size?.height) {
115
- setDims(fitMediaDimensions(size.width, size.height));
116
- }
117
- });
118
- return () => sub.remove();
119
- }, [player]);
112
+ }> = ({ onOpen }) => {
113
+ const dims = defaultMediaDims();
120
114
 
121
115
  return (
122
- // Capture-overlay pattern: VideoView sits underneath without ANY
123
- // touch participation; a transparent absolute-fill <Pressable> on
124
- // top owns the tap. Previously we wrapped VideoView in a
125
- // TouchableOpacity and set `pointerEvents="none"` on the inner
126
- // VideoView — but on some expo-video versions / iOS the native
127
- // VideoView still intercepted gestures despite that hint, so
128
- // tapping the poster did nothing. Customer-reported #9 (video
129
- // preview unresponsive). The Pressable now guarantees the tap
130
- // reaches `onOpen`.
131
- <View
116
+ <Pressable
117
+ onPress={onOpen}
132
118
  style={[styles.wrapper, { width: dims.width, height: dims.height }]}
119
+ accessibilityRole="button"
120
+ accessibilityLabel="Play video"
133
121
  >
134
- <VideoView
135
- player={player}
136
- style={styles.video}
137
- contentFit="cover"
138
- nativeControls={false}
139
- surfaceType="textureView"
140
- pointerEvents="none"
141
- />
142
122
  <PlayBadge />
143
- <Pressable
144
- onPress={onOpen}
145
- // Sits ON TOP of VideoView + the play-button overlay; transparent
146
- // so the poster + play icon show through. Captures the tap and
147
- // routes to onOpen → setActiveModal(FILE_PREVIEW).
148
- style={StyleSheet.absoluteFill}
149
- accessibilityRole="button"
150
- accessibilityLabel="Play video"
151
- />
152
- </View>
123
+ </Pressable>
153
124
  );
154
125
  };
155
126
 
@@ -172,12 +143,17 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
172
143
  <PosterVideo
173
144
  previewURL={previewURL}
174
145
  onOpen={handleOpen}
175
- onPosterError={() => setPosterFailed(true)}
146
+ onPosterError={() => {
147
+ if (isSecureFileUrl(previewURL)) {
148
+ requestFileTokenRecovery();
149
+ }
150
+ setPosterFailed(true);
151
+ }}
176
152
  />
177
153
  );
178
154
  }
179
155
 
180
- return <VideoSurfaceVideo fileURL={fileURL} onOpen={handleOpen} />;
156
+ return <PlaceholderVideo onOpen={handleOpen} />;
181
157
  };
182
158
 
183
159
  export default CustomMessageVideo;
@@ -193,7 +169,7 @@ const styles = StyleSheet.create({
193
169
  height: '100%',
194
170
  },
195
171
  overlay: {
196
- ...StyleSheet.absoluteFillObject,
172
+ ...StyleSheet.absoluteFill,
197
173
  justifyContent: 'center',
198
174
  alignItems: 'center',
199
175
  },
@@ -14,7 +14,7 @@ import { VideoCallOverlay } from '../components/VideoCalls/VideoCallOverlay';
14
14
  import XmppClient, {
15
15
  XmppCredentialsProvider,
16
16
  } from '../networking/xmppClient';
17
- import { refreshTokens } from '../roomStore/chatSettingsSlice';
17
+ import { refreshAuthTokensQuietly } from '../networking/authRefresh';
18
18
  import {
19
19
  IConfig,
20
20
  ProviderBootstrapStatus,
@@ -107,23 +107,25 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
107
107
  // the XmppClient at least doesn't crash on `undefined.password`.
108
108
  const credentialsProvider = useMemo<XmppCredentialsProvider>(() => {
109
109
  return async () => {
110
- // 1. Consumer-supplied REST refresh (when wired). This runs
111
- // before the SDK's own refresh chain so non-Ethora backends
112
- // can keep auth state in sync with their own token endpoint.
113
- const customRefresh = config?.refreshTokens?.refreshFunction;
114
- if (customRefresh) {
115
- try {
116
- const result = await customRefresh();
117
- if (result?.accessToken) {
118
- store.dispatch(
119
- refreshTokens({
120
- token: result.accessToken,
121
- refreshToken: result.refreshToken || '',
122
- })
123
- );
124
- }
125
- } catch (err) {
126
- devPushLog('warn', 'XMPP creds refresh: customRefresh failed', err);
110
+ // 1. Make sure the REST tokens are fresh before re-minting XMPP
111
+ // creds. This used to call `config.refreshTokens.refreshFunction`
112
+ // directly and then fall into step 2, which refreshes again —
113
+ // two rotations back to back, which the new backend scheme
114
+ // reads as a race at best and token reuse at worst. One call to
115
+ // the shared rotation point covers both the consumer-supplied
116
+ // function and the built-in endpoint.
117
+ if (config?.refreshTokens?.enabled) {
118
+ const rotated = await refreshAuthTokensQuietly();
119
+
120
+ if (rotated?.xmppPassword) {
121
+ const rotatedUser = store.getState().chatSettingStore.user;
122
+ return {
123
+ username:
124
+ rotatedUser?.xmppUsername ||
125
+ rotatedUser?.defaultWallet?.walletAddress ||
126
+ '',
127
+ password: rotated.xmppPassword,
128
+ };
127
129
  }
128
130
  }
129
131
 
@@ -163,7 +165,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
163
165
  }, [
164
166
  config?.jwtLogin?.enabled,
165
167
  config?.jwtLogin?.token,
166
- config?.refreshTokens?.refreshFunction,
168
+ config?.refreshTokens?.enabled,
167
169
  config?.userLogin?.enabled,
168
170
  config?.customLogin?.enabled,
169
171
  config?.initBeforeLoadAuth?.myEndpoint,
@@ -353,6 +355,29 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
353
355
  store.dispatch(setConfig(config));
354
356
  }
355
357
 
358
+ // Rotate BEFORE the first XMPP connect. On the rotating backend
359
+ // the xmpp password is a short-lived JWT that only the newest
360
+ // rotation knows, and each rotation invalidates its predecessors
361
+ // — so a snapshot's password (a host's cached `userLogin.user`,
362
+ // a persisted session) is usually already dead at boot. Without
363
+ // this, the first SASL attempt fails `not-authorized`, all three
364
+ // bootstrap retries re-present the same dead password, and the
365
+ // user dead-ends on the Connection error modal even though one
366
+ // rotation away a perfectly good session existed. The rotation
367
+ // also lands the fileToken pre-render, so secure media never
368
+ // paints blank first. Quiet + deduped; when it can't rotate
369
+ // (no refresh material, non-rotating backend) we just connect
370
+ // with what the snapshot has, exactly as before.
371
+ // jwtLogin is skipped: /users/client just minted fresh creds.
372
+ let connectPassword = resolved.xmppPassword;
373
+ if (config?.refreshTokens?.enabled && !config?.jwtLogin?.enabled) {
374
+ const rotated = await refreshAuthTokensQuietly();
375
+ if (cancelled) {return;}
376
+ if (rotated?.xmppPassword) {
377
+ connectPassword = rotated.xmppPassword;
378
+ }
379
+ }
380
+
356
381
  // Kick off REST /chats/my; allRoomPresences below needs the
357
382
  // room list to be in redux, otherwise it joins 0 MUCs and the
358
383
  // user receives zero realtime messages until they manually tap
@@ -362,7 +387,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
362
387
 
363
388
  const c = await initializeClient(
364
389
  resolved.xmppUsername!,
365
- resolved.xmppPassword,
390
+ connectPassword,
366
391
  config?.xmppSettings
367
392
  );
368
393
  if (cancelled) {return;}
@@ -4,6 +4,7 @@ import { initRoomsPresence } from './initRoomsPresence';
4
4
  import XmppClient from '../networking/xmppClient';
5
5
  import { IConfig, IRoom, User } from '../types/types';
6
6
  import { store } from '../roomStore';
7
+ import { refreshAuthTokensQuietly } from '../networking/authRefresh';
7
8
 
8
9
  const initXmppRooms = async (
9
10
  user: User,
@@ -59,7 +60,10 @@ const initXmppRooms = async (
59
60
  }
60
61
 
61
62
  if (config?.refreshTokens?.enabled) {
62
- config?.refreshTokens?.refreshFunction?.();
63
+ // Was a bare call to the consumer-supplied refreshFunction, i.e. a
64
+ // rotation completely outside the SDK's lock. Routed through the
65
+ // one rotation point, which calls that same function internally.
66
+ refreshAuthTokensQuietly();
63
67
  }
64
68
  } catch (error) {
65
69
  console.error(error);
@@ -1,4 +1,5 @@
1
- import http, { setBaseURL } from '../networking/apiClient';
1
+ import { setBaseURL } from '../networking/apiClient';
2
+ import { refreshAuthTokens, isRefreshFatalError } from '../networking/authRefresh';
2
3
  import { loginViaJwt } from '../networking/api-requests/auth.api';
3
4
  import { getMyUser } from '../networking/api-requests/user.api';
4
5
  import { IConfig, User } from '../types/types';
@@ -46,15 +47,109 @@ const normalizeUserForXmpp = (user?: User | null): User | null => {
46
47
  return { ...user, xmppUsername: normalizedXmppUsername };
47
48
  };
48
49
 
50
+ // Hermes has no atob/Buffer; hand-decode just enough base64url to read a
51
+ // JWT's `iat`. Claims are ASCII so the byte-per-char string is fine.
52
+ const B64_ALPHABET =
53
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
54
+
55
+ const readJwtIat = (token?: string | null): number => {
56
+ if (!token || typeof token !== 'string') {return 0;}
57
+ const part = token.split('.')[1];
58
+ if (!part) {return 0;}
59
+ try {
60
+ const clean = part.replace(/-/g, '+').replace(/_/g, '/');
61
+ let out = '';
62
+ let buffer = 0;
63
+ let bits = 0;
64
+ for (let i = 0; i < clean.length; i++) {
65
+ const idx = B64_ALPHABET.indexOf(clean[i]);
66
+ if (idx === -1) {continue;}
67
+ buffer = (buffer << 6) | idx;
68
+ bits += 6;
69
+ if (bits >= 8) {
70
+ bits -= 8;
71
+ out += String.fromCharCode((buffer >> bits) & 0xff);
72
+ }
73
+ }
74
+ const iat = JSON.parse(out)?.iat;
75
+ return typeof iat === 'number' ? iat : 0;
76
+ } catch {
77
+ return 0;
78
+ }
79
+ };
80
+
81
+ /**
82
+ * A `userLogin.user` is typically a snapshot the host captured at ITS
83
+ * login time and re-presents verbatim on every mount, while this SDK
84
+ * keeps rotating the session and persisting the newest pair to
85
+ * `ETHORA_USER`. Taking the snapshot as-is therefore discards the live
86
+ * session in favour of an ever-aging copy: media goes blank the moment
87
+ * the snapshot has no `fileToken` (secure-files URLs 401 without it),
88
+ * and once the snapshot's refresh token ages out entirely the account
89
+ * looks dead even though the SDK held a working session all along.
90
+ *
91
+ * So, for the SAME account only, prefer whichever refresh pair was
92
+ * minted later (JWT `iat` decides — a host that genuinely re-logged-in
93
+ * hands us newer tokens and wins), and fill in a missing `fileToken`
94
+ * from the persisted copy either way.
95
+ */
96
+ const adoptFresherPersistedSession = async (candidate: User): Promise<User> => {
97
+ try {
98
+ const stored = await asyncLocalStorage<User>(
99
+ localStorageConstants.ETHORA_USER
100
+ ).get();
101
+ if (!stored) {return candidate;}
102
+
103
+ const sameUser =
104
+ (candidate._id && stored._id && candidate._id === stored._id) ||
105
+ (candidate.xmppUsername &&
106
+ stored.xmppUsername &&
107
+ candidate.xmppUsername === stored.xmppUsername);
108
+ if (!sameUser) {return candidate;}
109
+
110
+ const merged = { ...candidate };
111
+
112
+ if (
113
+ stored.refreshToken &&
114
+ readJwtIat(stored.refreshToken) > readJwtIat(candidate.refreshToken)
115
+ ) {
116
+ merged.refreshToken = stored.refreshToken;
117
+ if (stored.token) {merged.token = stored.token;}
118
+ if (stored.fileToken) {merged.fileToken = stored.fileToken;}
119
+ }
120
+
121
+ if (!merged.fileToken && stored.fileToken) {
122
+ merged.fileToken = stored.fileToken;
123
+ }
124
+
125
+ return merged;
126
+ } catch {
127
+ return candidate;
128
+ }
129
+ };
130
+
131
+ /**
132
+ * Bootstrap rotation.
133
+ *
134
+ * Was a second, independent `/users/login/refresh` caller that bypassed
135
+ * every lock in the SDK — and, worse, only wrote the rotated token to
136
+ * the store on the happy path, so several of the early-return branches
137
+ * below used to drop it. Under the backend's reuse detection a dropped
138
+ * rotation means the next launch presents a burned token and the
139
+ * session is killed.
140
+ *
141
+ * `refreshAuthTokens` persists the new pair before it resolves, so by
142
+ * the time this returns the rotation is safe no matter which branch the
143
+ * caller takes afterwards. The explicit token is required here: during
144
+ * bootstrap the candidate session isn't in the store yet.
145
+ */
49
146
  const refreshWithToken = async (refreshToken: string) => {
50
- const response = await http.post(
51
- '/users/login/refresh',
52
- {},
53
- { headers: { Authorization: refreshToken } }
54
- );
147
+ const result = await refreshAuthTokens({ refreshToken });
55
148
  return {
56
- token: response?.data?.token || '',
57
- refreshToken: response?.data?.refreshToken || refreshToken,
149
+ token: result.token,
150
+ refreshToken: result.refreshToken || refreshToken,
151
+ xmppPassword: result.xmppPassword || '',
152
+ fileToken: result.fileToken || '',
58
153
  };
59
154
  };
60
155
 
@@ -87,11 +182,15 @@ const tryHydrateViaMy = async (
87
182
 
88
183
  let workingToken = candidate?.token || '';
89
184
  let workingRefresh = candidate?.refreshToken || '';
185
+ let rotatedXmppPassword = '';
186
+ let workingFileToken = candidate?.fileToken || '';
90
187
 
91
188
  const candidateWithCurrentTokens = (): User => ({
92
189
  ...candidate,
93
190
  token: workingToken || candidate.token,
94
191
  refreshToken: workingRefresh || candidate.refreshToken,
192
+ xmppPassword: rotatedXmppPassword || candidate.xmppPassword,
193
+ fileToken: workingFileToken || candidate.fileToken,
95
194
  });
96
195
 
97
196
  const fallbackWithCreds = (): User | null => {
@@ -107,6 +206,8 @@ const tryHydrateViaMy = async (
107
206
  if (merged) {
108
207
  merged.token = workingToken || merged.token;
109
208
  merged.refreshToken = workingRefresh || merged.refreshToken;
209
+ merged.xmppPassword = rotatedXmppPassword || merged.xmppPassword;
210
+ merged.fileToken = workingFileToken || merged.fileToken;
110
211
  }
111
212
  return merged;
112
213
  } catch (error) {
@@ -127,6 +228,8 @@ const tryHydrateViaMy = async (
127
228
  const refreshed = await refreshWithToken(workingRefresh);
128
229
  workingToken = refreshed.token;
129
230
  workingRefresh = refreshed.refreshToken;
231
+ rotatedXmppPassword = refreshed.xmppPassword || rotatedXmppPassword;
232
+ workingFileToken = refreshed.fileToken || workingFileToken;
130
233
 
131
234
  try {
132
235
  const myUser = await getMyUser({ token: workingToken, endpoint: myEndpoint });
@@ -136,6 +239,8 @@ const tryHydrateViaMy = async (
136
239
  if (merged) {
137
240
  merged.token = workingToken || merged.token;
138
241
  merged.refreshToken = workingRefresh || merged.refreshToken;
242
+ merged.xmppPassword = rotatedXmppPassword || merged.xmppPassword;
243
+ merged.fileToken = workingFileToken || merged.fileToken;
139
244
  }
140
245
  return merged;
141
246
  } catch (myError) {
@@ -147,6 +252,11 @@ const tryHydrateViaMy = async (
147
252
  }
148
253
  } catch (error) {
149
254
  if (signal?.aborted || isAbortError(error)) {return null;}
255
+ // A fatal refresh verdict (reuse detected / token not found / stale
256
+ // with nothing newer around) carries no HTTP response, so it would
257
+ // otherwise fall through to `throw` — the session is simply dead,
258
+ // which for bootstrap means "nothing to restore".
259
+ if (isRefreshFatalError(error)) {return null;}
150
260
  if (isAuthError(error)) {return null;}
151
261
  throw error;
152
262
  }
@@ -162,13 +272,15 @@ export const resolveInitBeforeLoadUser = async (
162
272
  setBaseURL(config.baseUrl, config.customAppToken);
163
273
  }
164
274
 
165
- const myEndpoint = config?.initBeforeLoadAuth?.myEndpoint || '/users/my';
275
+ const myEndpoint = config?.initBeforeLoadAuth?.myEndpoint || '/v1/users/my';
166
276
 
167
277
  // Priority 1: explicit userLogin
168
278
  const explicitUser = config?.userLogin?.enabled ? config?.userLogin?.user : null;
169
279
  if (explicitUser) {
170
280
  const candidate = normalizeUserForXmpp(explicitUser);
171
- if (candidate && hasXmppCredentials(candidate)) {return candidate;}
281
+ if (candidate && hasXmppCredentials(candidate)) {
282
+ return await adoptFresherPersistedSession(candidate);
283
+ }
172
284
 
173
285
  const hydrated = await tryHydrateViaMy(explicitUser, myEndpoint, signal).catch(
174
286
  () => null
@@ -275,7 +387,7 @@ export const refreshUserCredentialsForXmpp = async (
275
387
  }
276
388
  }
277
389
 
278
- const myEndpoint = config?.initBeforeLoadAuth?.myEndpoint || '/users/my';
390
+ const myEndpoint = config?.initBeforeLoadAuth?.myEndpoint || '/v1/users/my';
279
391
 
280
392
  // Priority 2: redux store user (covers userLogin + customLogin +
281
393
  // anything previously persisted into chatSettingStore.user). The