@ethora/chat-component-rn 26.4.1 → 26.5.1

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 (49) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -661
  3. package/README.md +259 -7
  4. package/index.js +7 -0
  5. package/package.json +47 -16
  6. package/src/components/Header/HeaderRoomList.tsx +8 -7
  7. package/src/components/InputComponents/Search.tsx +7 -4
  8. package/src/components/InputComponents/TextInput.tsx +8 -1
  9. package/src/components/MainComponents/ChatRoom.tsx +16 -4
  10. package/src/components/MainComponents/ChatWrapper.tsx +10 -14
  11. package/src/components/MainComponents/LoginWrapper.tsx +5 -2
  12. package/src/components/MainComponents/MessageList.tsx +3 -20
  13. package/src/components/MainComponents/RoomList.tsx +7 -2
  14. package/src/components/MessageBubble/Message.tsx +7 -3
  15. package/src/components/MessageBubble/MessageInteractions.tsx +67 -63
  16. package/src/components/Modals/AttachSheet/AttachSheet.tsx +195 -0
  17. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +3 -0
  18. package/src/components/Modals/FilePreviewModal/PdfView.tsx +71 -0
  19. package/src/components/Modals/Modal/Modal.tsx +2 -4
  20. package/src/components/Modals/ModalSelectMedia/ModalSelectMedia.tsx +0 -2
  21. package/src/components/styled/Button.tsx +9 -0
  22. package/src/components/styled/SendInput.tsx +191 -107
  23. package/src/components/styled/StyledInputComponents/StyledInputComponents.tsx +1 -1
  24. package/src/context/xmppProvider.tsx +51 -2
  25. package/src/helpers/constants/MODAL_COMPONENTS.tsx +35 -0
  26. package/src/helpers/constants/MODAL_TYPES.ts +10 -39
  27. package/src/helpers/historyPreloadScheduler.ts +15 -1
  28. package/src/helpers/updateMessagesTillLast.tsx +20 -17
  29. package/src/hooks/useComposing.tsx +0 -9
  30. package/src/hooks/useLogout.tsx +129 -9
  31. package/src/hooks/useSendMessage.tsx +114 -60
  32. package/src/networking/api-requests/auth.api.ts +1 -1
  33. package/src/networking/api-requests/user.api.ts +1 -0
  34. package/src/networking/apiClient.ts +80 -41
  35. package/src/networking/stanzaHandlers.ts +18 -7
  36. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +86 -0
  37. package/src/networking/xmpp/getHistory.xmpp.ts +6 -1
  38. package/src/networking/xmpp/presenceInRoom.xmpp.ts +8 -3
  39. package/src/networking/xmpp/sendMediaMessage.xmpp.ts +8 -10
  40. package/src/networking/xmpp/sendTextMessage.xmpp.ts +12 -12
  41. package/src/networking/xmppClient.ts +27 -0
  42. package/src/roomStore/Middleware/logoutMiddleware.tsx +17 -8
  43. package/src/roomStore/Middleware/unreadMidlleware.tsx +122 -28
  44. package/src/roomStore/persistence.ts +5 -2
  45. package/src/roomStore/roomsSlice.ts +115 -7
  46. package/src/services/pushSubscriptionService.ts +25 -2
  47. package/src/types/types.ts +10 -9
  48. package/src/utils/devLogger.ts +11 -0
  49. package/src/api.config.ts +0 -34
@@ -1,13 +1,17 @@
1
1
  import XmppClient from '../networking/xmppClient';
2
2
  import { store } from '../roomStore';
3
- import {
4
- getLastMessageTimestamp,
5
- insertUsers,
6
- setRoomMessages,
7
- } from '../roomStore/roomsSlice';
3
+ import { setRoomMessages } from '../roomStore/roomsSlice';
8
4
  import { IMessage, IRoom } from '../types/types';
9
5
  import { checkUniqueUsers } from './checkUniqueUsers';
10
6
 
7
+ // BUGFIX: previously this module imported `getLastMessageTimestamp`
8
+ // + `insertUsers` from roomsSlice — neither exists in the slice, so
9
+ // any caller (useChatWrapperInit) crashed at runtime with
10
+ // "getLastMessageTimestamp is not a function" the first time the
11
+ // reducer was hit. Replaced the lookup with an inline read of
12
+ // `room.lastMessageTimestamp`; the dead `insertUsers` dispatch was
13
+ // dropped (no action exists to receive it).
14
+
11
15
  export const updateMessagesTillLast = async (
12
16
  rooms: {
13
17
  [jid: string]: IRoom;
@@ -28,14 +32,12 @@ export const updateMessagesTillLast = async (
28
32
  );
29
33
 
30
34
  const lastTimestampsByJid = currentBatch.reduce(
31
- (acc, current: string) => {
32
- acc[current] = getLastMessageTimestamp(
33
- store.getState().rooms,
34
- current
35
- );
35
+ (acc: Record<string, number>, current: string) => {
36
+ const room = (store.getState() as any).rooms?.rooms?.[current];
37
+ acc[current] = Number(room?.lastMessageTimestamp ?? 0) || 0;
36
38
  return acc;
37
39
  },
38
- {}
40
+ {} as Record<string, number>
39
41
  );
40
42
 
41
43
  await Promise.all(
@@ -75,12 +77,13 @@ export const updateMessagesTillLast = async (
75
77
  );
76
78
 
77
79
  if (!isMessageFound && !(counter <= maxFetchAttempts - 1)) {
78
- const fixedUsers = await checkUniqueUsers(
79
- currentJidNewMessages
80
- );
81
- if (fixedUsers && fixedUsers.length > 0) {
82
- store.dispatch(insertUsers({ newUsers: fixedUsers }));
83
- }
80
+ // Fire-and-forget — checkUniqueUsers resolves to an
81
+ // updated RoomMember list, but there's no slice
82
+ // action wired to receive it on RN yet (the web SDK
83
+ // has one; the port left a TODO). Keep the call so
84
+ // the API request goes out (it warms the cache for
85
+ // later renders) but stop short of dispatching.
86
+ await checkUniqueUsers(currentJidNewMessages);
84
87
 
85
88
  store.dispatch(
86
89
  setRoomMessages({
@@ -11,11 +11,6 @@ const useComposing = (config?: IConfig) => {
11
11
  const { user } = useChatSettingState();
12
12
 
13
13
  const sendStartComposing = useCallback(() => {
14
- console.log('🟣 [useComposing] start', {
15
- hasClient: !!client,
16
- room: activeRoomJID,
17
- disabled: !!config?.disableTypingIndicator,
18
- });
19
14
  if (config?.disableTypingIndicator) {
20
15
  return;
21
16
  }
@@ -29,10 +24,6 @@ const useComposing = (config?: IConfig) => {
29
24
  }, [activeRoomJID, client, config?.disableTypingIndicator, user.firstName, user.lastName]);
30
25
 
31
26
  const sendEndComposing = useCallback(() => {
32
- console.log('🟣 [useComposing] end', {
33
- hasClient: !!client,
34
- room: activeRoomJID,
35
- });
36
27
  if (config?.disableTypingIndicator) {
37
28
  return;
38
29
  }
@@ -1,28 +1,148 @@
1
1
  import { useDispatch } from 'react-redux';
2
2
  import { DeviceEventEmitter } from 'react-native';
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
3
4
  import { store } from '../roomStore';
4
5
  import { logout } from '../roomStore/chatSettingsSlice';
5
6
  import { setLogoutState } from '../roomStore/roomsSlice';
6
7
  import { useCallback } from 'react';
7
8
  import { clearHeap } from '../roomStore/roomHeapSlice';
8
9
  import { pushSubscriptionService } from '../services/pushSubscriptionService';
10
+ import { clearRoomsRestCache } from '../networking/api-requests/rooms.api';
11
+ import { clearPersistedState } from '../roomStore/persistence';
12
+ import { localStorageConstants } from '../helpers/constants/LOCAL_STORAGE';
13
+
14
+ // AsyncStorage keys the library writes but that aren't cleared by any
15
+ // slice reducer. Listed here so a single logout call wipes the full
16
+ // library footprint, not just the slices it happens to think about.
17
+ //
18
+ // `@ethora/chat-component-scope` is intentionally NOT included — it
19
+ // acts as a per-tenant fingerprint that `ensureScopedChatCache` reads
20
+ // on the next bootstrap to detect appId/baseUrl changes and force a
21
+ // re-init when needed. Wiping it would just re-stamp it on the next
22
+ // login with the same value, no-op churn.
23
+ const LIBRARY_STRAY_KEYS = [
24
+ '@ethora/chat-component-qrChatId', // pending QR join target — should not bleed across sessions
25
+ ];
9
26
 
10
27
  const logoutService = {
11
- performLogout: () => {
12
- // Clear any pending notifications
13
- DeviceEventEmitter.emit('chat:clear-notifications');
14
-
15
- pushSubscriptionService.reset();
16
- store.dispatch(logout());
17
- store.dispatch(setLogoutState());
18
- store.dispatch(clearHeap());
28
+ /**
29
+ * Tear down the active chat session end-to-end:
30
+ * 1. Fire UI-side events (notifications, push, redux slices).
31
+ * 2. The redux dispatch of `chat/logout` triggers
32
+ * `logoutMiddleware`, which emits `ethora-xmpp-logout` on
33
+ * `DeviceEventEmitter`. `XmppProvider` listens for that event,
34
+ * disconnects the XMPP transport, clears the global client
35
+ * singleton, and awaits `clearPersistedState()` to wipe the
36
+ * two persisted slice keys.
37
+ * 3. We additionally clear:
38
+ * - the 60s in-memory REST `/chats/my` cache (stale rooms
39
+ * leak across user-switch within the cache window otherwise),
40
+ * - leftover AsyncStorage keys the slices don't touch
41
+ * (pending QR join target, push subscription set),
42
+ * - the persistence keys directly (belt-and-suspenders against
43
+ * the persistence middleware's 200ms debounce silently losing
44
+ * the post-logout write if the app is killed mid-flush).
45
+ *
46
+ * Returns a Promise so callers can sequence a navigation / restart
47
+ * AFTER all the disk work has actually landed.
48
+ */
49
+ performLogout: async (): Promise<void> => {
50
+ // 0. Flush lastViewedTimestamp to the server's private store BEFORE
51
+ // we close XMPP. Only rooms with no outstanding unread (plus the
52
+ // active room) get their entries updated — rooms with unread
53
+ // keep their old marker so the next login still surfaces those
54
+ // messages as unread. Without this, an active-room view at
55
+ // logout would still appear unread on next login because we
56
+ // never persisted "I read up to now".
57
+ try {
58
+ const state = store.getState();
59
+ const client = (state.chatSettingStore as any)?.client;
60
+ const rooms = state.rooms?.rooms;
61
+ const activeRoomJID = state.rooms?.activeRoomJID || null;
62
+ if (client?.flushLastViewedToPrivateStoreStanza) {
63
+ await Promise.race([
64
+ client.flushLastViewedToPrivateStoreStanza(rooms, {
65
+ activeRoomJID,
66
+ onlyIfNoUnread: true,
67
+ }),
68
+ new Promise((res) => setTimeout(res, 2000)),
69
+ ]);
70
+ }
71
+ } catch (e) {
72
+ console.warn('logoutService: private store flush failed', e);
73
+ }
74
+
75
+ // 1. UI: clear in-app notification toast queue.
76
+ try {
77
+ DeviceEventEmitter.emit('chat:clear-notifications');
78
+ } catch {
79
+ /* non-fatal */
80
+ }
81
+
82
+ // 2. Push: clear locally-subscribed-rooms cache. Doesn't talk to the
83
+ // server — that's the host app's responsibility (it owns the FCM/
84
+ // APNs token lifecycle).
85
+ try {
86
+ await pushSubscriptionService.reset();
87
+ } catch (e) {
88
+ console.warn('logoutService: push reset failed', e);
89
+ }
90
+
91
+ // 3. REST: nuke the in-memory `/chats/my` cache so the next login
92
+ // doesn't read user A's rooms while user B is bootstrapping.
93
+ try {
94
+ clearRoomsRestCache();
95
+ } catch {
96
+ /* non-fatal */
97
+ }
98
+
99
+ // 4. Redux: dispatch the trio. Order matters — `chat/logout` is
100
+ // what triggers `logoutMiddleware → ethora-xmpp-logout` (and
101
+ // therefore XmppProvider's `client.disconnect()`), so fire it
102
+ // AFTER the slices that don't need the xmpp client.
103
+ try {
104
+ store.dispatch(setLogoutState()); // rooms slice → {} + null
105
+ store.dispatch(clearHeap()); // message heap (dedup set)
106
+ store.dispatch(logout()); // chat slice → user wiped + removes ETHORA_USER
107
+ } catch (e) {
108
+ console.warn('logoutService: redux dispatch failed', e);
109
+ }
110
+
111
+ // 5. Persisted state: belt-and-suspenders. XmppProvider's logout
112
+ // listener also calls clearPersistedState, but doing it here too
113
+ // means the disk is clean before this Promise resolves —
114
+ // regardless of how fast the event-emitter listener runs.
115
+ try {
116
+ await clearPersistedState();
117
+ } catch {
118
+ /* non-fatal */
119
+ }
120
+
121
+ // 6. Stray keys the slices don't touch.
122
+ try {
123
+ await AsyncStorage.multiRemove([
124
+ ...LIBRARY_STRAY_KEYS,
125
+ // Belt-and-suspenders: the chat slice's `logout` reducer
126
+ // schedules an async remove of ETHORA_USER fire-and-forget;
127
+ // re-issue it here so the disk is provably clean before this
128
+ // Promise resolves.
129
+ localStorageConstants.ETHORA_USER,
130
+ ]);
131
+ } catch (e) {
132
+ console.warn('logoutService: stray-key clear failed', e);
133
+ }
19
134
  },
20
135
  };
136
+
21
137
  export const useLogout = () => {
22
138
  const dispatch = useDispatch();
23
139
 
24
140
  const handleLogout = useCallback(() => {
25
- logoutService.performLogout();
141
+ // The hook fires-and-forgets — consumers that need to await the
142
+ // teardown should call `logoutService.performLogout()` directly.
143
+ logoutService.performLogout().catch((err) => {
144
+ console.warn('useLogout: performLogout rejected', err);
145
+ });
26
146
  }, []);
27
147
 
28
148
  return handleLogout;
@@ -1,12 +1,24 @@
1
1
  import { useCallback } from 'react';
2
+ import { v4 as uuidv4 } from 'uuid';
2
3
  import { useXmppClient } from '../context/xmppProvider';
3
4
  import { useDispatch, useSelector } from 'react-redux';
4
5
  import { addRoomMessage, setEditAction } from '../roomStore/roomsSlice';
6
+ import { addMessageToHeap } from '../roomStore/roomHeapSlice';
5
7
  import { uploadFile } from '../networking/api-requests/auth.api';
6
8
  import { RootState } from '../roomStore';
7
9
  import { useEventHandlers } from './useEventHandlers';
8
10
  import type { IConfig, IMessage } from '../types/types';
9
11
 
12
+ // Monotonic counter for stanza ids. Bumped on each send within a single
13
+ // process. Two rapid sends in the same millisecond used to collide on
14
+ // `send-text-message-${Date.now()}`; that collision made
15
+ // insertMessageWithDelimiter dedup the second optimistic message into
16
+ // the first by id, so 10 spam-taps visually collapsed into 1 bubble
17
+ // whose body was whatever the last dispatch wrote.
18
+ let __sendIdSeq = 0;
19
+ const nextStanzaId = (prefix: string): string =>
20
+ `${prefix}-${Date.now()}-${(__sendIdSeq = (__sendIdSeq + 1) >>> 0)}`;
21
+
10
22
  export const useSendMessage = (_configOverride?: IConfig) => {
11
23
  const { client } = useXmppClient();
12
24
  const dispatch = useDispatch();
@@ -71,7 +83,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
71
83
  // dedupes by id and flips `pending: false` → DoubleTick renders.
72
84
  // Without this, the user taps send and sees nothing for ~200ms
73
85
  // until the echo lands, which feels broken.
74
- const optimisticId = `send-text-message-${Date.now()}`;
86
+ const optimisticId = nextStanzaId('send-text-message');
75
87
  const optimisticDate = new Date().toISOString();
76
88
  const selfId =
77
89
  (user as any)?.xmppUsername || (user as any)?.walletAddress || '';
@@ -99,16 +111,6 @@ export const useSendMessage = (_configOverride?: IConfig) => {
99
111
  );
100
112
  }
101
113
 
102
- console.log('🔵 [useSendMessage] sending', {
103
- room: activeRoomJID,
104
- hasClient: !!client,
105
- first: user?.firstName,
106
- last: user?.lastName,
107
- wallet: user?.walletAddress,
108
- len: message?.length,
109
- optimisticId,
110
- });
111
-
112
114
  try {
113
115
  if (!client) {
114
116
  throw new Error('No XMPP client');
@@ -154,50 +156,82 @@ export const useSendMessage = (_configOverride?: IConfig) => {
154
156
  data: any,
155
157
  type: string,
156
158
  activeRoomJID: string,
157
- isReply?: boolean,
158
- isChecked?: boolean,
159
- mainMessage?: string
159
+ isReply: boolean = false,
160
+ isChecked: boolean = false,
161
+ mainMessage: string = ''
160
162
  ) => {
161
- const mediaData = new FormData();
162
- mediaData.append('files', data);
163
-
164
163
  client?.onCriticalSend?.(activeRoomJID);
165
164
 
166
- // Optimistic pending bubble — same pattern as text send. Render
167
- // a placeholder with `isMediafile: true` so MessageBubble shows
168
- // a media-shaped pending tile while the upload + send is in
169
- // flight; replaced in-place by the server echo via xmppId dedupe.
170
- const optimisticId = `send-media-message-${Date.now()}`;
171
- const optimisticDate = new Date().toISOString();
165
+ const id = `send-media-message:${uuidv4()}`;
166
+ const optimisticTimestamp = Date.now();
167
+ const optimisticDate = new Date(optimisticTimestamp).toISOString();
172
168
  const selfId =
173
169
  (user as any)?.xmppUsername || (user as any)?.walletAddress || '';
174
- const placeholderMessage: IMessage = {
175
- id: optimisticId,
176
- xmppId: optimisticId,
177
- body: 'media',
178
- roomJid: activeRoomJID,
179
- date: optimisticDate,
180
- pending: true,
181
- isDeleted: false,
182
- isMediafile: 'true',
183
- mimetype: type,
184
- originalName: data?.name,
185
- user: {
186
- ...(user as any),
187
- id: selfId,
188
- name: `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || selfId,
189
- } as any,
190
- } as IMessage;
170
+ const displayName =
171
+ `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || selfId;
172
+ const fileSizeStr = data?.size != null ? String(data.size) : '';
173
+
191
174
  if (!config?.disableSentLogic) {
192
175
  dispatch(
193
- addRoomMessage({ roomJID: activeRoomJID, message: placeholderMessage })
176
+ addRoomMessage({
177
+ roomJID: activeRoomJID,
178
+ message: {
179
+ id: id,
180
+ body: 'media',
181
+ roomJid: activeRoomJID,
182
+ date: optimisticDate,
183
+ messageTimestampMs: optimisticTimestamp,
184
+ user: {
185
+ ...(user as any),
186
+ id: selfId,
187
+ name: displayName,
188
+ },
189
+ pending: true,
190
+ isDeleted: false,
191
+ xmppId: id,
192
+ xmppFrom: `${activeRoomJID}/${selfId}`,
193
+ isSystemMessage: 'false',
194
+ isMediafile: 'true',
195
+ fileName: data?.name,
196
+ location: '',
197
+ locationPreview: '',
198
+ mimetype: type,
199
+ originalName: data?.name,
200
+ size: fileSizeStr,
201
+ isReply,
202
+ showInChannel: `${isChecked}`,
203
+ mainMessage,
204
+ } as any as IMessage,
205
+ })
206
+ );
207
+ dispatch(
208
+ addMessageToHeap({
209
+ id: id,
210
+ user: {
211
+ ...(user as any),
212
+ id: selfId,
213
+ name: displayName,
214
+ },
215
+ date: optimisticDate,
216
+ messageTimestampMs: optimisticTimestamp,
217
+ body: 'media',
218
+ roomJid: activeRoomJID,
219
+ xmppFrom: `${activeRoomJID}/${selfId}`,
220
+ isReply,
221
+ showInChannel: `${isChecked}` as any,
222
+ mainMessage,
223
+ } as any as IMessage)
194
224
  );
195
225
  }
196
226
 
197
227
  try {
228
+ const mediaData = new FormData();
229
+ mediaData.append('files', data);
230
+
198
231
  const response = await uploadFile(mediaData);
199
- response.data.results.forEach(async (item: any) => {
200
- const payload = {
232
+
233
+ for (const item of response.data.results) {
234
+ const messagePayload = {
201
235
  firstName: user.firstName,
202
236
  lastName: user.lastName,
203
237
  walletAddress: user.walletAddress,
@@ -217,34 +251,54 @@ export const useSendMessage = (_configOverride?: IConfig) => {
217
251
  attachmentId: item?._id,
218
252
  wrappable: true,
219
253
  roomJid: activeRoomJID,
220
- showInChannel: isChecked || false,
221
- isReply: isReply || false,
222
- mainMessage: mainMessage || '',
254
+ showInChannel: isChecked,
255
+ isReply,
256
+ mainMessage,
223
257
  isPrivate: item?.isPrivate,
224
258
  __v: item.__v,
225
259
  };
226
- // Pass optimisticId as the stanza id so the echo's outer
227
- // id matches our placeholder's xmppId and dedup'es in place.
228
- client?.sendMediaMessageStanza(activeRoomJID, payload, optimisticId);
229
- await handleMessageSent({
230
- message: item.location || '',
231
- roomJID: activeRoomJID,
232
- user,
233
- messageType: 'media',
234
- metadata: payload,
235
- });
260
+ // Stanza id == placeholder id so the MUC echo's outer
261
+ // <message id="..."> matches xmppId on the placeholder and
262
+ // insertMessageWithDelimiter merges in place + flips pending.
263
+ client?.sendMediaMessageStanza(activeRoomJID, messagePayload, id);
264
+ }
265
+
266
+ await handleMessageSent({
267
+ message: 'media',
268
+ roomJID: activeRoomJID,
269
+ user,
270
+ messageType: 'media',
271
+ metadata: {
272
+ isReply,
273
+ isChecked,
274
+ mainMessage,
275
+ fileData: data,
276
+ fileType: type,
277
+ messageId: id,
278
+ uploadResults: response.data.results,
279
+ },
280
+ });
281
+ } catch (error: any) {
282
+ // Surface the real server payload so we can see why the upload
283
+ // was rejected (axios collapses the message to "Request failed
284
+ // with status code N"; the actual reason lives in
285
+ // `error.response.data`).
286
+ console.error('upload failed', {
287
+ status: error?.response?.status,
288
+ statusText: error?.response?.statusText,
289
+ serverBody: error?.response?.data,
290
+ requestUrl: error?.config?.url,
291
+ axiosMessage: error?.message,
236
292
  });
237
- } catch (error) {
238
- console.error('Media upload failed', error);
239
293
  handleMessageFailed({
240
- message: '',
294
+ message: 'media',
241
295
  roomJID: activeRoomJID,
242
296
  error: error as Error,
243
297
  messageType: 'media',
244
298
  });
245
299
  }
246
300
  },
247
- [client, user, handleMessageSent, handleMessageFailed]
301
+ [client, config, user, dispatch, handleMessageSent, handleMessageFailed]
248
302
  );
249
303
 
250
304
  // ChatRoom/ThreadWrapper consume this as the "edit branch" of send.
@@ -134,7 +134,7 @@ export function uploadFile(formData: FormData) {
134
134
  headers: {
135
135
  Authorization: token,
136
136
  Accept: '*/*',
137
- // "Content-Type": "multipart/form-data",
137
+ 'Content-Type': 'multipart/form-data',
138
138
  },
139
139
  });
140
140
  }
@@ -56,6 +56,7 @@ export async function updateProfile(fd: FormData): Promise<{ user: User }> {
56
56
  const response = await http.put('/users', fd, {
57
57
  headers: {
58
58
  Authorization: token,
59
+ 'Content-Type': 'multipart/form-data',
59
60
  },
60
61
  });
61
62
  return response.data;
@@ -2,13 +2,16 @@ import axios from 'axios';
2
2
  import { store } from '../roomStore';
3
3
 
4
4
  import { logout, refreshTokens } from '../roomStore/chatSettingsSlice';
5
- import { appToken as defaultAppToken } from '../../api.config';
6
5
  import { installAxiosCapture } from '../utils/devLogger';
7
6
 
8
- const DEFAULT_BASE_URL = 'https://api.chat.ethora.com/v1';
7
+ // Per product-code-policy: no compiled-in Ethora endpoints or tokens.
8
+ // Consumers must call `setBaseURL(baseUrl, appToken)` (or pass
9
+ // `baseUrl` + the token-bearing user / jwt-login config via the
10
+ // top-level `<Chat>` props) before any REST call is issued.
11
+ const DEFAULT_BASE_URL = '';
9
12
 
10
13
  let currentBaseURL = DEFAULT_BASE_URL;
11
- let currentAppToken: string = defaultAppToken;
14
+ let currentAppToken: string = '';
12
15
 
13
16
  const http = axios.create({
14
17
  baseURL: currentBaseURL,
@@ -55,13 +58,22 @@ export function refresh(): Promise<{
55
58
  refreshToken: response.data.refreshToken,
56
59
  })
57
60
  );
61
+ // BUGFIX: previously the `.then` only dispatched and never
62
+ // resolved, so every `await refresh()` callsite hung forever
63
+ // on the happy path. Resolve with the axios response shape so
64
+ // the interceptor can read `tokens.data.token` to retry.
65
+ resolve(response);
58
66
  })
59
67
  .catch((error) => {
60
68
  reject(error);
61
69
  });
62
70
  } catch (error) {
71
+ // BUGFIX: previously the outer-try catch dispatched logout but
72
+ // never rejected, so a synchronous throw from `http.post` (e.g.
73
+ // a bad URL) would hang the promise. Reject explicitly.
63
74
  console.log('errr');
64
75
  store.dispatch(logout());
76
+ reject(error);
65
77
  }
66
78
  });
67
79
  }
@@ -93,11 +105,30 @@ http.interceptors.response.use(
93
105
  // BUGFIX: the original condition was `if (!enabled)` — inverted, so
94
106
  // turning `refreshTokens.enabled: true` actually DISABLED the refresh
95
107
  // path on 401. Use the correct sense.
96
- if (store.getState().chatSettingStore?.config?.refreshTokens?.enabled) {
97
- if (
98
- store.getState().chatSettingStore?.config?.refreshTokens
99
- ?.refreshFunction
100
- ) {
108
+ if (!store.getState().chatSettingStore?.config?.refreshTokens?.enabled) {
109
+ // BUGFIX: previously this branch fell off the end of the async
110
+ // function and returned `undefined`, which axios treats as a
111
+ // resolved-with-undefined response. Callers expecting an error
112
+ // got `undefined` instead, silently swallowing every failure
113
+ // when the refresh path isn't configured. Re-reject so the
114
+ // original error reaches the caller.
115
+ return Promise.reject(error);
116
+ }
117
+
118
+ const originalRequest = error.config;
119
+ const status = error.response?.status;
120
+
121
+ // Refresh paths only fire on 401. Anything else passes through.
122
+ if (!error.response || status !== 401) {
123
+ return Promise.reject(error);
124
+ }
125
+
126
+ if (
127
+ store.getState().chatSettingStore?.config?.refreshTokens?.refreshFunction
128
+ ) {
129
+ // Consumer-supplied refresh function. Call it, dispatch the new
130
+ // tokens, retry the original request.
131
+ try {
101
132
  const { refreshToken, accessToken } = store
102
133
  .getState()
103
134
  .chatSettingStore?.config?.refreshTokens?.refreshFunction();
@@ -107,42 +138,50 @@ http.interceptors.response.use(
107
138
  refreshToken: refreshToken,
108
139
  })
109
140
  );
110
- return;
111
- } else {
112
- const originalRequest = error.config;
113
-
114
- if (!error.response || error.response.status !== 401) {
115
- throw error;
116
- }
117
- if (
118
- originalRequest.url === '/users/login/refresh' ||
119
- originalRequest.url === '/users/login'
120
- ) {
121
- return Promise.reject(error);
122
- }
123
-
124
- originalRequest._retry = true;
125
-
126
- if (isRefreshing) {
127
- const retryOriginalRequest = addRequestToQueue(originalRequest);
128
-
129
- return retryOriginalRequest;
130
- } else {
131
- isRefreshing = true;
132
- try {
133
- const tokens = await refresh();
134
- console.log('tokens', tokens);
135
- isRefreshing = false;
136
- originalRequest.headers.Authorization = tokens.data.token;
137
- processQueue(tokens.data.token);
138
- return http(originalRequest);
139
- } catch (refreshErr) {
140
- isRefreshing = false;
141
- return refreshErr;
142
- }
141
+ // BUGFIX: previously this branch returned `undefined` after
142
+ // dispatching, so the original request was never retried and
143
+ // the caller resolved with undefined. Stamp the new token on
144
+ // the original config and replay.
145
+ if (originalRequest?.headers) {
146
+ originalRequest.headers.Authorization = accessToken;
143
147
  }
148
+ return http(originalRequest);
149
+ } catch (refreshErr) {
150
+ return Promise.reject(refreshErr);
144
151
  }
145
152
  }
153
+
154
+ // Built-in `/users/login/refresh` path. Skip if the failing request
155
+ // IS the refresh / login itself — otherwise we'd loop forever.
156
+ if (
157
+ originalRequest.url === '/users/login/refresh' ||
158
+ originalRequest.url === '/users/login'
159
+ ) {
160
+ return Promise.reject(error);
161
+ }
162
+
163
+ originalRequest._retry = true;
164
+
165
+ if (isRefreshing) {
166
+ const retryOriginalRequest = addRequestToQueue(originalRequest);
167
+ return retryOriginalRequest;
168
+ }
169
+
170
+ isRefreshing = true;
171
+ try {
172
+ const tokens = await refresh();
173
+ console.log('tokens', tokens);
174
+ isRefreshing = false;
175
+ originalRequest.headers.Authorization = tokens.data.token;
176
+ processQueue(tokens.data.token);
177
+ return http(originalRequest);
178
+ } catch (refreshErr) {
179
+ isRefreshing = false;
180
+ // BUGFIX: previously this returned the error (resolving the
181
+ // promise with the error as value) instead of rejecting, so
182
+ // axios callers got the error as a "successful" response body.
183
+ return Promise.reject(refreshErr);
184
+ }
146
185
  }
147
186
  );
148
187