@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
@@ -0,0 +1,435 @@
1
+ import http from './apiClient';
2
+ import { store } from '../roomStore';
3
+ import { refreshTokens } from '../roomStore/chatSettingsSlice';
4
+ import { asyncLocalStorage } from '../hooks/useLocalStorage';
5
+ import { localStorageConstants } from '../helpers/constants/LOCAL_STORAGE';
6
+ import { pushLog } from '../utils/devLogger';
7
+ import { User } from '../types/types';
8
+
9
+ /**
10
+ * THE single refresh-token rotation point for the SDK.
11
+ *
12
+ * The backend now runs refresh-token ROTATION with REUSE DETECTION:
13
+ * every successful `/users/login/refresh` burns the presented refresh
14
+ * token and issues a new one. Presenting an already-rotated token is
15
+ * indistinguishable, server-side, from a stolen token — so it is
16
+ * treated as theft and (once the backend leaves monitor mode and
17
+ * enables enforcing mode) kills the whole token family.
18
+ *
19
+ * That puts three hard obligations on the client:
20
+ *
21
+ * 1. Persist the NEW refreshToken from every response, immediately,
22
+ * before any other logic can throw and drop it on the floor.
23
+ * 2. Never rotate concurrently. Two parallel refreshes both start
24
+ * from the same token; the loser presents a burned one and looks
25
+ * like an attacker. React Native is single-process, so a shared
26
+ * in-flight promise is the whole lock — no Web Locks needed (the
27
+ * web build of this component does need them, see its own
28
+ * `authRefresh.ts`).
29
+ * 3. Route EVERY refresh path through here. One "side" refresh
30
+ * elsewhere in the codebase breaks the scheme for everyone.
31
+ *
32
+ * Callers must also stop treating 401 as "log out". Two of the four
33
+ * refresh error codes are race signals, not auth failures — see
34
+ * `RefreshErrorCode` below and `RefreshFatalError`.
35
+ */
36
+
37
+ export type RefreshErrorCode =
38
+ | 'REFRESH_IN_PROGRESS'
39
+ | 'REFRESH_TOKEN_ALREADY_ROTATED'
40
+ | 'REFRESH_TOKEN_REUSE_DETECTED'
41
+ | 'REFRESH_TOKEN_NOT_FOUND';
42
+
43
+ export interface RefreshResult {
44
+ token: string;
45
+ refreshToken: string;
46
+ /**
47
+ * Present in the backend response but not currently consumed
48
+ * anywhere in the SDK. Passed through so callers can use it without
49
+ * a second round trip.
50
+ * TODO(phase-7): decide whether to persist this on `User`.
51
+ */
52
+ wsToken?: string;
53
+ xmppPassword?: string;
54
+ fileToken?: string;
55
+ }
56
+
57
+ /**
58
+ * Thrown when the session is genuinely dead and the caller must do a
59
+ * hard logout (clear storage, drop XMPP, send the user to login).
60
+ *
61
+ * Anything else this module rejects with — network errors, 5xx, an
62
+ * unrecognised 401 — is NOT fatal and must NOT trigger a logout.
63
+ */
64
+ export class RefreshFatalError extends Error {
65
+ code: RefreshErrorCode;
66
+
67
+ constructor(code: RefreshErrorCode, message?: string) {
68
+ super(message || `Refresh failed: ${code}`);
69
+ this.name = 'RefreshFatalError';
70
+ this.code = code;
71
+ // Required for `instanceof` to survive TS's ES5 class-extends-builtin
72
+ // downlevelling, which RN's babel preset still applies.
73
+ Object.setPrototypeOf(this, RefreshFatalError.prototype);
74
+ }
75
+ }
76
+
77
+ export const isRefreshFatalError = (
78
+ error: unknown
79
+ ): error is RefreshFatalError =>
80
+ error instanceof RefreshFatalError ||
81
+ (error as RefreshFatalError)?.name === 'RefreshFatalError';
82
+
83
+ const REFRESH_ENDPOINT = '/v1/users/login/refresh';
84
+
85
+ /** `REFRESH_IN_PROGRESS` retry budget, per backend guidance (~300ms x 2-3). */
86
+ const IN_PROGRESS_MAX_ATTEMPTS = 3;
87
+ const IN_PROGRESS_BASE_DELAY_MS = 300;
88
+ /**
89
+ * Jitter matters: after a network blip every client in the app retries
90
+ * on the same schedule and would hammer the rotation mutex in lockstep.
91
+ */
92
+ const IN_PROGRESS_JITTER_MS = 150;
93
+
94
+ const KNOWN_CODES: RefreshErrorCode[] = [
95
+ 'REFRESH_IN_PROGRESS',
96
+ 'REFRESH_TOKEN_ALREADY_ROTATED',
97
+ 'REFRESH_TOKEN_REUSE_DETECTED',
98
+ 'REFRESH_TOKEN_NOT_FOUND',
99
+ ];
100
+
101
+ const sleep = (ms: number) =>
102
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
103
+
104
+ const inProgressDelay = (attempt: number) =>
105
+ IN_PROGRESS_BASE_DELAY_MS * attempt +
106
+ Math.floor(Math.random() * IN_PROGRESS_JITTER_MS);
107
+
108
+ /**
109
+ * All four codes come back as HTTP 401, so the status alone tells us
110
+ * nothing — the code in the body is the only signal.
111
+ *
112
+ * TODO(backend-confirm): the exact location of `code` in the payload is
113
+ * not documented yet. Until it is, probe every shape the API uses
114
+ * elsewhere and take the first hit. Once confirmed, collapse this to
115
+ * the single real path.
116
+ */
117
+ export const parseRefreshErrorCode = (
118
+ error: unknown
119
+ ): RefreshErrorCode | null => {
120
+ const response = (error as any)?.response;
121
+ if (!response) {
122
+ return null;
123
+ }
124
+
125
+ const data = response.data;
126
+ const candidates = [
127
+ data?.code,
128
+ data?.error?.code,
129
+ data?.errors?.[0]?.code,
130
+ data?.error,
131
+ data?.message,
132
+ response.headers?.['x-error-code'],
133
+ ];
134
+
135
+ for (const candidate of candidates) {
136
+ if (typeof candidate !== 'string') {
137
+ continue;
138
+ }
139
+ const match = KNOWN_CODES.find((code) => candidate.includes(code));
140
+ if (match) {
141
+ return match;
142
+ }
143
+ }
144
+
145
+ return null;
146
+ };
147
+
148
+ const getStoreUser = (): Partial<User> =>
149
+ (store.getState().chatSettingStore?.user as Partial<User>) || {};
150
+
151
+ /**
152
+ * The refresh token as of RIGHT NOW. Read at the moment of use, never
153
+ * captured ahead of time — that is the single-process equivalent of the
154
+ * web build's "re-read inside the lock".
155
+ *
156
+ * Redux is the source of truth in RN (one process, one store);
157
+ * AsyncStorage is only consulted as a cold-start fallback, before
158
+ * `resolveInitBeforeLoadUser` has hydrated the store.
159
+ */
160
+ const readCurrentRefreshToken = async (): Promise<string> => {
161
+ const fromStore = getStoreUser().refreshToken;
162
+ if (fromStore) {
163
+ return fromStore;
164
+ }
165
+
166
+ const stored = await asyncLocalStorage<User>(
167
+ localStorageConstants.ETHORA_USER
168
+ ).get();
169
+ return stored?.refreshToken || '';
170
+ };
171
+
172
+ /**
173
+ * Persist BEFORE resolving. The dispatch updates redux (and, until
174
+ * phase 6 lands, fires its own best-effort AsyncStorage write); the
175
+ * awaited write below is the one that actually guarantees the rotated
176
+ * token survives the app being killed a moment later. Losing it means
177
+ * the next launch presents a burned token — i.e. looks like theft.
178
+ */
179
+ const persistTokens = async (result: RefreshResult): Promise<void> => {
180
+ store.dispatch(
181
+ refreshTokens({
182
+ token: result.token,
183
+ refreshToken: result.refreshToken,
184
+ xmppPassword: result.xmppPassword,
185
+ fileToken: result.fileToken,
186
+ })
187
+ );
188
+
189
+ try {
190
+ // Write the post-dispatch snapshot rather than a read-modify-write
191
+ // merge, so this can't race the reducer's own write back to a
192
+ // stale copy of the surrounding user fields.
193
+ await asyncLocalStorage<User>(localStorageConstants.ETHORA_USER).set(
194
+ store.getState().chatSettingStore.user as User
195
+ );
196
+ } catch (error) {
197
+ pushLog('warn', 'authRefresh: failed to persist rotated tokens', error);
198
+ }
199
+ };
200
+
201
+ /**
202
+ * A rotation that happened somewhere we didn't drive — currently only
203
+ * reachable via the ALREADY_ROTATED recovery path, kept separate so the
204
+ * web build and this one stay structurally identical.
205
+ */
206
+ const adoptStoredTokens = async (): Promise<RefreshResult | null> => {
207
+ const stored = await asyncLocalStorage<User>(
208
+ localStorageConstants.ETHORA_USER
209
+ ).get();
210
+ if (!stored?.refreshToken || !stored?.token) {
211
+ return null;
212
+ }
213
+
214
+ const user = getStoreUser();
215
+ if (user.refreshToken !== stored.refreshToken) {
216
+ store.dispatch(
217
+ refreshTokens({
218
+ token: stored.token,
219
+ refreshToken: stored.refreshToken,
220
+ })
221
+ );
222
+ }
223
+
224
+ return {
225
+ token: stored.token,
226
+ refreshToken: stored.refreshToken,
227
+ xmppPassword: stored.xmppPassword,
228
+ fileToken: stored.fileToken,
229
+ };
230
+ };
231
+
232
+ /** Host-supplied rotation, mirrors `IConfig['refreshTokens']['refreshFunction']`. */
233
+ type ConsumerRefreshFn = () => Promise<{
234
+ accessToken: string;
235
+ refreshToken?: string;
236
+ xmppPassword?: string;
237
+ fileToken?: string;
238
+ } | null>;
239
+
240
+ const runConsumerRefresh = async (
241
+ refreshFunction: ConsumerRefreshFn
242
+ ): Promise<RefreshResult> => {
243
+ const refreshed = await refreshFunction();
244
+
245
+ if (!refreshed?.accessToken) {
246
+ throw new Error('Custom refresh function did not return an access token');
247
+ }
248
+
249
+ if (!refreshed.refreshToken) {
250
+ // Not fatal — some hosts still run non-rotating backends — but on a
251
+ // rotating one this silently keeps a burned token around, which is
252
+ // exactly the failure the new scheme punishes.
253
+ pushLog(
254
+ 'warn',
255
+ 'authRefresh: custom refreshFunction returned no refreshToken; ' +
256
+ 'keeping the existing one. On a rotating backend this will be ' +
257
+ 'seen as token reuse.'
258
+ );
259
+ }
260
+
261
+ const result: RefreshResult = {
262
+ token: refreshed.accessToken,
263
+ refreshToken:
264
+ refreshed.refreshToken || (await readCurrentRefreshToken()) || '',
265
+ xmppPassword: refreshed.xmppPassword,
266
+ fileToken: refreshed.fileToken,
267
+ };
268
+
269
+ await persistTokens(result);
270
+ return result;
271
+ };
272
+
273
+ const requestRotation = async (
274
+ refreshToken: string
275
+ ): Promise<RefreshResult> => {
276
+ const response = await http.post(
277
+ REFRESH_ENDPOINT,
278
+ {},
279
+ { headers: { Authorization: refreshToken } }
280
+ );
281
+
282
+ const result: RefreshResult = {
283
+ token: response?.data?.token || '',
284
+ refreshToken: response?.data?.refreshToken || '',
285
+ wsToken: response?.data?.wsToken,
286
+ xmppPassword: response?.data?.xmppPassword,
287
+ fileToken: response?.data?.fileToken,
288
+ };
289
+
290
+ if (!result.token || !result.refreshToken) {
291
+ throw new Error('Refresh response did not contain both tokens');
292
+ }
293
+
294
+ // Persist first, resolve second. Every early return between here and
295
+ // the caller is a chance to lose the rotation.
296
+ await persistTokens(result);
297
+ return result;
298
+ };
299
+
300
+ const performRefresh = async (
301
+ overrideToken?: string
302
+ ): Promise<RefreshResult> => {
303
+ const consumerRefresh =
304
+ store.getState().chatSettingStore?.config?.refreshTokens?.refreshFunction;
305
+
306
+ if (consumerRefresh) {
307
+ // A configured host function wins unconditionally — including over
308
+ // an explicit bootstrap token. Hosts that embed this SDK often own
309
+ // the Ethora refresh token themselves (their own storage, their own
310
+ // rotation) and only hand us a copy; rotating it here would burn
311
+ // THEIR token behind their back and leave them holding a dead one.
312
+ //
313
+ // It still has to be serialised — it hits the same backend mutex —
314
+ // hence running it inside the shared in-flight promise.
315
+ return runConsumerRefresh(consumerRefresh);
316
+ }
317
+
318
+ for (let attempt = 1; attempt <= IN_PROGRESS_MAX_ATTEMPTS; attempt++) {
319
+ const refreshToken = overrideToken || (await readCurrentRefreshToken());
320
+
321
+ if (!refreshToken) {
322
+ // Deliberately NOT fatal: a missing token mid-bootstrap must not
323
+ // nuke a session that is still being hydrated.
324
+ throw new Error('Refresh token is missing');
325
+ }
326
+
327
+ try {
328
+ return await requestRotation(refreshToken);
329
+ } catch (error) {
330
+ const code = parseRefreshErrorCode(error);
331
+
332
+ if (code === 'REFRESH_IN_PROGRESS') {
333
+ if (attempt < IN_PROGRESS_MAX_ATTEMPTS) {
334
+ await sleep(inProgressDelay(attempt));
335
+ continue;
336
+ }
337
+ // Budget spent. Whoever holds the mutex has very likely
338
+ // finished and written a newer token — take it if so. Skipped
339
+ // for an explicit token: it may belong to a different session
340
+ // than whatever is currently in storage.
341
+ const adopted = overrideToken ? null : await adoptStoredTokens();
342
+ if (adopted && adopted.refreshToken !== refreshToken) {
343
+ return adopted;
344
+ }
345
+ throw error;
346
+ }
347
+
348
+ if (code === 'REFRESH_TOKEN_ALREADY_ROTATED') {
349
+ // Our copy is stale. If something already wrote a newer token,
350
+ // this is a benign race — use it. Otherwise the session really
351
+ // is unrecoverable.
352
+ const adopted = overrideToken ? null : await adoptStoredTokens();
353
+ if (adopted && adopted.refreshToken !== refreshToken) {
354
+ return adopted;
355
+ }
356
+ throw new RefreshFatalError(code);
357
+ }
358
+
359
+ if (
360
+ code === 'REFRESH_TOKEN_REUSE_DETECTED' ||
361
+ code === 'REFRESH_TOKEN_NOT_FOUND'
362
+ ) {
363
+ throw new RefreshFatalError(code);
364
+ }
365
+
366
+ // Network error, 5xx, plain 401 without a code — pass through
367
+ // untouched. The caller must not log out on these.
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ // Unreachable: the loop either returns or throws.
373
+ throw new Error('Refresh attempts exhausted');
374
+ };
375
+
376
+ export interface RefreshOptions {
377
+ /**
378
+ * Rotate THIS token instead of the one in the store/storage.
379
+ *
380
+ * Only for bootstrap, where the session being restored isn't in the
381
+ * store yet (a host-supplied `userLogin.user`, a persisted record).
382
+ * Everywhere else, omitting it is the correct and safer choice: the
383
+ * module then always presents the newest token it knows about.
384
+ */
385
+ refreshToken?: string;
386
+ }
387
+
388
+ let inflight: Promise<RefreshResult> | null = null;
389
+
390
+ /**
391
+ * Rotate the tokens. Concurrent callers share ONE request — this is the
392
+ * lock the new backend scheme requires.
393
+ *
394
+ * Rejects with `RefreshFatalError` when the session is dead (caller
395
+ * should hard-logout) and with a plain error otherwise (caller should
396
+ * surface the failure and leave the session alone).
397
+ */
398
+ export function refreshAuthTokens(
399
+ options?: RefreshOptions
400
+ ): Promise<RefreshResult> {
401
+ if (inflight) {
402
+ // Whatever that rotation produces is at least as fresh as anything
403
+ // this caller could have asked for.
404
+ return inflight;
405
+ }
406
+
407
+ inflight = performRefresh(options?.refreshToken).finally(() => {
408
+ inflight = null;
409
+ });
410
+
411
+ return inflight;
412
+ }
413
+
414
+ /**
415
+ * Fire-and-forget variant for call sites that only want "make sure the
416
+ * tokens are fresh" and have no meaningful error handling — chat
417
+ * bootstrap, XMPP reconnect. Never rejects.
418
+ */
419
+ export async function refreshAuthTokensQuietly(): Promise<RefreshResult | null> {
420
+ try {
421
+ return await refreshAuthTokens();
422
+ } catch (error) {
423
+ pushLog(
424
+ isRefreshFatalError(error) ? 'error' : 'warn',
425
+ 'authRefresh: background refresh failed',
426
+ error
427
+ );
428
+ return null;
429
+ }
430
+ }
431
+
432
+ /** Test seam — drops any shared in-flight promise between cases. */
433
+ export function __resetAuthRefreshStateForTests(): void {
434
+ inflight = null;
435
+ }
@@ -94,6 +94,13 @@ interface MamInFlightEntry {
94
94
  startedAt: number;
95
95
  }
96
96
 
97
+ /**
98
+ * Floor between two credential rotations driven by reconnects. Well under
99
+ * the XMPP password's one-hour life, but high enough that a link flapping
100
+ * every few seconds rotates once, not thirty times.
101
+ */
102
+ const CREDENTIALS_REFRESH_MIN_INTERVAL_MS = 60_000;
103
+
97
104
  export type XmppCredentialsProvider = () => Promise<{
98
105
  username: string;
99
106
  password: string;
@@ -137,6 +144,12 @@ export class XmppClient {
137
144
  // derived XMPP password after idle). Triggers the credentialsProvider
138
145
  // refresh path in reconnect().
139
146
  lastAuthError: 'not-authorized' | null = null;
147
+ /**
148
+ * When the credentials currently held were last (re)minted. Drives the
149
+ * throttle in `reconnect()` so a flapping connection can't rotate the
150
+ * session once per flap.
151
+ */
152
+ private credentialsRefreshedAt = 0;
140
153
  private credentialsProvider: XmppCredentialsProvider | null = null;
141
154
  private credentialsRefreshInFlight: Promise<void> | null = null;
142
155
  // Fired by the provider on every 'online' so it can re-join MUC rooms
@@ -700,14 +713,32 @@ export class XmppClient {
700
713
  console.log('Attempting to reconnect xmpp client...');
701
714
 
702
715
  try {
703
- // If the last failure was a SASL `not-authorized` (typically a stale
704
- // JWT-derived XMPP password after idle), try to fetch fresh creds
705
- // before retrying. Without this, reconnect retries with the same
706
- // stale password forever and the user has to kill the app.
707
- if (this.lastAuthError === 'not-authorized' && this.credentialsProvider) {
716
+ // Re-mint credentials BEFORE reconnecting.
717
+ //
718
+ // This deliberately no longer waits for a SASL `not-authorized`.
719
+ // The XMPP password expires after an hour, and an expiry does NOT
720
+ // drop an established connection — authentication happens once, at
721
+ // bind. So by the time anything disconnects us (a network blip, a
722
+ // sleeping device, a server restart) the password we still hold may
723
+ // well be dead, and we have no way to know. Reconnecting with it
724
+ // burns a guaranteed-failed bind plus a backoff delay before the
725
+ // old `lastAuthError` path could even start recovering.
726
+ //
727
+ // Throttled, because reconnects are not rare: a flapping link would
728
+ // otherwise rotate the whole session (access + refresh + file token
729
+ // + XMPP password) once per flap. A SASL failure bypasses the
730
+ // throttle — there the credentials are known bad.
731
+ const authFailed = this.lastAuthError === 'not-authorized';
732
+ const staleCreds =
733
+ Date.now() - this.credentialsRefreshedAt >=
734
+ CREDENTIALS_REFRESH_MIN_INTERVAL_MS;
735
+
736
+ if (this.credentialsProvider && (authFailed || staleCreds)) {
708
737
  try {
709
738
  await this.refreshCredentialsOnce();
710
739
  } catch (err) {
740
+ // Offline is the common case here — reconnect with what we
741
+ // have and let the next attempt try again.
711
742
  console.warn(
712
743
  'XMPP credential refresh failed; reconnecting with cached creds',
713
744
  err
@@ -763,6 +794,7 @@ export class XmppClient {
763
794
  // creds really are stale we simply get not-authorized again and
764
795
  // retry, which is the confirmed-good 26.5.7 behaviour.
765
796
  this.updateCredentials(fresh.username, fresh.password);
797
+ this.credentialsRefreshedAt = Date.now();
766
798
  this.lastAuthError = null;
767
799
  }
768
800
  } finally {
@@ -40,6 +40,7 @@ export const unpackAndTransform = (input?: User): User => {
40
40
  _id: input?._id || '',
41
41
  walletAddress: input?.defaultWallet?.walletAddress || '',
42
42
  xmppPassword: input?.xmppPassword || '',
43
+ fileToken: input?.fileToken || '',
43
44
  refreshToken: input?.refreshToken || '',
44
45
  firstName: input?.firstName || '',
45
46
  lastName: input?.lastName || '',
@@ -73,6 +74,7 @@ const initialState: ChatState = {
73
74
  _id: '',
74
75
  walletAddress: '',
75
76
  xmppPassword: '',
77
+ fileToken: '',
76
78
  refreshToken: '',
77
79
  firstName: '',
78
80
  lastName: '',
@@ -146,14 +148,37 @@ const reducers = {
146
148
  setSelectedUser: (state: WritableDraft<ChatState>, action: PayloadAction<IUser | undefined>) => {
147
149
  state.selectedUser = action.payload;
148
150
  },
151
+ /**
152
+ * Token rotation. Dispatched from ONE place only — `authRefresh` —
153
+ * which awaits the AsyncStorage write immediately afterwards, before
154
+ * resolving its promise.
155
+ *
156
+ * The reducer used to fire its own best-effort write here. That was
157
+ * both redundant and racy (two writers, no ordering), and a
158
+ * fire-and-forget write can be lost if the app dies right after a
159
+ * rotation — which on a backend with reuse detection means the next
160
+ * launch presents a burned token. Persistence now belongs to the
161
+ * caller, which can actually await it.
162
+ */
149
163
  refreshTokens: (
150
164
  state: WritableDraft<ChatState>,
151
- action: PayloadAction<{ token: string; refreshToken: string }>
165
+ action: PayloadAction<{
166
+ token: string;
167
+ refreshToken: string;
168
+ xmppPassword?: string;
169
+ fileToken?: string;
170
+ }>
152
171
  ) => {
153
172
  state.user.refreshToken = action.payload.refreshToken;
154
173
  state.user.token = action.payload.token;
155
- // AsyncStorage is async; fire-and-forget so the reducer stays sync.
156
- asyncLocalStorage(localStorageConstants.ETHORA_USER).set(state.user);
174
+
175
+ if (action.payload.xmppPassword) {
176
+ state.user.xmppPassword = action.payload.xmppPassword;
177
+ }
178
+
179
+ if (action.payload.fileToken) {
180
+ state.user.fileToken = action.payload.fileToken;
181
+ }
157
182
  },
158
183
  logout: (state: WritableDraft<ChatState>) => {
159
184
  state.user = unpackAndTransform();
@@ -105,6 +105,7 @@ export interface User {
105
105
  ];
106
106
  appId: string;
107
107
  xmppPassword: string;
108
+ fileToken?: string;
108
109
 
109
110
  homeScreen?: string;
110
111
  registrationChannelType?: string;
@@ -455,9 +456,25 @@ export interface IConfig {
455
456
  defaultLogin?: boolean;
456
457
  refreshTokens?: {
457
458
  enabled: boolean;
459
+ /**
460
+ * Host-owned rotation. When set, the SDK calls this INSTEAD of
461
+ * `/users/login/refresh` — always, including during bootstrap — and
462
+ * never rotates the Ethora refresh token itself. Serialised through
463
+ * the same lock as the built-in path.
464
+ *
465
+ * Return `refreshToken` whenever the host's flow actually rotates an
466
+ * Ethora refresh token: the backend now treats a re-presented one as
467
+ * theft, so omitting it leaves a burned token in storage that ends
468
+ * the session the next time it is used. It stays optional because
469
+ * some hosts re-mint an access token from a different authority
470
+ * (SuperTokens, an owner-session endpoint) and hold no Ethora
471
+ * refresh token at all.
472
+ */
458
473
  refreshFunction?: () => Promise<{
459
474
  accessToken: string;
460
475
  refreshToken?: string;
476
+ fileToken?: string;
477
+ xmppPassword?: string;
461
478
  } | null>;
462
479
  };
463
480