@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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -661
- package/README.md +259 -7
- package/index.js +7 -0
- package/package.json +47 -16
- package/src/components/Header/HeaderRoomList.tsx +8 -7
- package/src/components/InputComponents/Search.tsx +7 -4
- package/src/components/InputComponents/TextInput.tsx +8 -1
- package/src/components/MainComponents/ChatRoom.tsx +16 -4
- package/src/components/MainComponents/ChatWrapper.tsx +10 -14
- package/src/components/MainComponents/LoginWrapper.tsx +5 -2
- package/src/components/MainComponents/MessageList.tsx +3 -20
- package/src/components/MainComponents/RoomList.tsx +7 -2
- package/src/components/MessageBubble/Message.tsx +7 -3
- package/src/components/MessageBubble/MessageInteractions.tsx +67 -63
- package/src/components/Modals/AttachSheet/AttachSheet.tsx +195 -0
- package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +3 -0
- package/src/components/Modals/FilePreviewModal/PdfView.tsx +71 -0
- package/src/components/Modals/Modal/Modal.tsx +2 -4
- package/src/components/Modals/ModalSelectMedia/ModalSelectMedia.tsx +0 -2
- package/src/components/styled/Button.tsx +9 -0
- package/src/components/styled/SendInput.tsx +191 -107
- package/src/components/styled/StyledInputComponents/StyledInputComponents.tsx +1 -1
- package/src/context/xmppProvider.tsx +51 -2
- package/src/helpers/constants/MODAL_COMPONENTS.tsx +35 -0
- package/src/helpers/constants/MODAL_TYPES.ts +10 -39
- package/src/helpers/historyPreloadScheduler.ts +15 -1
- package/src/helpers/updateMessagesTillLast.tsx +20 -17
- package/src/hooks/useComposing.tsx +0 -9
- package/src/hooks/useLogout.tsx +129 -9
- package/src/hooks/useSendMessage.tsx +114 -60
- package/src/networking/api-requests/auth.api.ts +1 -1
- package/src/networking/api-requests/user.api.ts +1 -0
- package/src/networking/apiClient.ts +80 -41
- package/src/networking/stanzaHandlers.ts +18 -7
- package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +86 -0
- package/src/networking/xmpp/getHistory.xmpp.ts +6 -1
- package/src/networking/xmpp/presenceInRoom.xmpp.ts +8 -3
- package/src/networking/xmpp/sendMediaMessage.xmpp.ts +8 -10
- package/src/networking/xmpp/sendTextMessage.xmpp.ts +12 -12
- package/src/networking/xmppClient.ts +27 -0
- package/src/roomStore/Middleware/logoutMiddleware.tsx +17 -8
- package/src/roomStore/Middleware/unreadMidlleware.tsx +122 -28
- package/src/roomStore/persistence.ts +5 -2
- package/src/roomStore/roomsSlice.ts +115 -7
- package/src/services/pushSubscriptionService.ts +25 -2
- package/src/types/types.ts +10 -9
- package/src/utils/devLogger.ts +11 -0
- 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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
}
|
package/src/hooks/useLogout.tsx
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
158
|
-
isChecked
|
|
159
|
-
mainMessage
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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({
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
221
|
-
isReply
|
|
222
|
-
mainMessage
|
|
254
|
+
showInChannel: isChecked,
|
|
255
|
+
isReply,
|
|
256
|
+
mainMessage,
|
|
223
257
|
isPrivate: item?.isPrivate,
|
|
224
258
|
__v: item.__v,
|
|
225
259
|
};
|
|
226
|
-
//
|
|
227
|
-
// id matches
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
|