@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
|
@@ -266,7 +266,11 @@ const handleComposing = async (stanza: Element, currentUser: string) => {
|
|
|
266
266
|
};
|
|
267
267
|
|
|
268
268
|
const onPresenceInRoom = (stanza: Element | any) => {
|
|
269
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
typeof stanza.attrs.id === 'string' &&
|
|
271
|
+
stanza.attrs.id.startsWith('presenceInRoom') &&
|
|
272
|
+
!stanza.getChild('error')
|
|
273
|
+
) {
|
|
270
274
|
const roomJID: string = stanza.attrs.from.split('/')[0];
|
|
271
275
|
const role: string = stanza?.children[1]?.children[0]?.attrs.role;
|
|
272
276
|
store.dispatch(setRoomRole({ chatJID: roomJID, role: role }));
|
|
@@ -346,17 +350,20 @@ const onGetChatRooms = (stanza: Element, xmpp: any) => {
|
|
|
346
350
|
stanza.attrs.id === 'getUserRooms' &&
|
|
347
351
|
Array.isArray(stanza.getChild('query')?.children)
|
|
348
352
|
) {
|
|
349
|
-
stanza.getChild('query')?.children
|
|
353
|
+
const children = stanza.getChild('query')?.children || [];
|
|
354
|
+
children.forEach(async (result: any) => {
|
|
350
355
|
const currentChatRooms = store.getState().rooms.rooms;
|
|
351
356
|
|
|
352
357
|
const isRoomAlreadyAdded = Object.values(currentChatRooms).some(
|
|
353
358
|
(element) => element.jid === result?.attrs?.jid
|
|
354
359
|
);
|
|
355
360
|
|
|
361
|
+
const jid = result?.attrs?.jid;
|
|
362
|
+
|
|
356
363
|
if (!isRoomAlreadyAdded) {
|
|
357
364
|
try {
|
|
358
365
|
const roomData: IRoom = {
|
|
359
|
-
jid:
|
|
366
|
+
jid: jid || '',
|
|
360
367
|
name: result?.attrs?.name || '',
|
|
361
368
|
id: '',
|
|
362
369
|
title: result?.attrs?.name || '',
|
|
@@ -380,12 +387,16 @@ const onGetChatRooms = (stanza: Element, xmpp: any) => {
|
|
|
380
387
|
if (!store.getState().rooms.activeRoomJID) {
|
|
381
388
|
store.dispatch(setCurrentRoom({ roomJID: roomData.jid }));
|
|
382
389
|
}
|
|
383
|
-
|
|
384
|
-
if (roomData.jid) {
|
|
385
|
-
xmpp.presenceInRoomStanza(roomData.jid);
|
|
386
|
-
}
|
|
387
390
|
} catch (error) {}
|
|
388
391
|
}
|
|
392
|
+
|
|
393
|
+
if (jid) {
|
|
394
|
+
try {
|
|
395
|
+
xmpp.presenceInRoomStanza(jid);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
console.warn('presenceInRoomStanza failed', jid, e);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
389
400
|
});
|
|
390
401
|
}
|
|
391
402
|
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getChatsPrivateStoreRequest } from './getChatsPrivateStoreRequest.xmpp';
|
|
2
|
+
import { setChatsPrivateStoreRequest } from './setChatsPrivateStoreRequest.xmpp';
|
|
3
|
+
|
|
4
|
+
interface RoomLike {
|
|
5
|
+
jid: string;
|
|
6
|
+
lastViewedTimestamp?: number;
|
|
7
|
+
unreadMessages?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FlushOpts {
|
|
11
|
+
// When activeRoomJID is set, treat it as "user just viewed everything
|
|
12
|
+
// up to now" and stamp it with Date.now() — even if its in-memory
|
|
13
|
+
// lastViewedTimestamp is 0 (which means "actively viewing" in
|
|
14
|
+
// roomsSlice).
|
|
15
|
+
activeRoomJID?: string | null;
|
|
16
|
+
// If true, only write entries for rooms where unreadMessages === 0
|
|
17
|
+
// (or the active room). Used at logout time: rooms with outstanding
|
|
18
|
+
// unread keep their old marker so the next login still surfaces them.
|
|
19
|
+
onlyIfNoUnread?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Merge in-memory lastViewedTimestamp values into the server-side
|
|
24
|
+
* private store. Reads current state, overlays per-room updates, writes
|
|
25
|
+
* back. Single round-trip so concurrent open clients don't trample
|
|
26
|
+
* each other's entries.
|
|
27
|
+
*
|
|
28
|
+
* Called on: tab change away from chat, AppState background/inactive,
|
|
29
|
+
* and (with onlyIfNoUnread=true) logout. ChatRoom's own unmount path
|
|
30
|
+
* still uses actionSetTimestampToPrivateStore for the single-room case.
|
|
31
|
+
*/
|
|
32
|
+
export async function flushLastViewedToPrivateStore(
|
|
33
|
+
client: any,
|
|
34
|
+
rooms: Record<string, RoomLike> | null | undefined,
|
|
35
|
+
opts: FlushOpts = {}
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
if (!client?.client) {return false;}
|
|
38
|
+
const { activeRoomJID, onlyIfNoUnread } = opts;
|
|
39
|
+
const roomList = Object.values(rooms || {});
|
|
40
|
+
if (roomList.length === 0) {return false;}
|
|
41
|
+
|
|
42
|
+
let storeObj: any = null;
|
|
43
|
+
try {
|
|
44
|
+
storeObj = await getChatsPrivateStoreRequest(client.client);
|
|
45
|
+
} catch {
|
|
46
|
+
storeObj = null;
|
|
47
|
+
}
|
|
48
|
+
if (!storeObj || typeof storeObj !== 'object') {storeObj = {};}
|
|
49
|
+
|
|
50
|
+
const nowMs = Date.now();
|
|
51
|
+
let dirty = false;
|
|
52
|
+
|
|
53
|
+
for (const room of roomList) {
|
|
54
|
+
if (!room?.jid) {continue;}
|
|
55
|
+
const isActive = !!activeRoomJID && room.jid === activeRoomJID;
|
|
56
|
+
const hasUnread = Number(room.unreadMessages || 0) > 0;
|
|
57
|
+
|
|
58
|
+
// For the active room, persist "now" — the in-memory value is 0
|
|
59
|
+
// (sentinel for "user is here") and writing 0 would mark all
|
|
60
|
+
// history unread on next login.
|
|
61
|
+
let ts: number | undefined;
|
|
62
|
+
if (isActive) {
|
|
63
|
+
ts = nowMs;
|
|
64
|
+
} else if (room.lastViewedTimestamp) {
|
|
65
|
+
ts = room.lastViewedTimestamp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!ts) {continue;}
|
|
69
|
+
if (onlyIfNoUnread && hasUnread && !isActive) {continue;}
|
|
70
|
+
|
|
71
|
+
const prev = storeObj[room.jid];
|
|
72
|
+
const prevNum = prev != null ? Number(prev) : 0;
|
|
73
|
+
if (Number.isFinite(prevNum) && prevNum >= ts) {continue;}
|
|
74
|
+
storeObj[room.jid] = String(ts);
|
|
75
|
+
dirty = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!dirty) {return false;}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await setChatsPrivateStoreRequest(client.client, JSON.stringify(storeObj));
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -132,7 +132,12 @@ export const getHistory = async (
|
|
|
132
132
|
]);
|
|
133
133
|
return res === null ? [] : res;
|
|
134
134
|
} catch (e) {
|
|
135
|
-
|
|
135
|
+
// MAM rejects with no value when an iq error returns; the timeout
|
|
136
|
+
// path resolves with null instead of throwing. Either is expected
|
|
137
|
+
// for a room with no history yet — only log if `e` is meaningful.
|
|
138
|
+
if (e !== undefined) {
|
|
139
|
+
console.log('=-> error in', fixedChatJid, e);
|
|
140
|
+
}
|
|
136
141
|
return [];
|
|
137
142
|
} finally {
|
|
138
143
|
unsubscribe();
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Client, xml } from '@xmpp/client';
|
|
2
2
|
import { createTimeoutPromise } from './createTimeoutPromise.xmpp';
|
|
3
3
|
import { Element } from '@xmpp/xml';
|
|
4
|
+
|
|
5
|
+
let presenceIdCounter = 0;
|
|
6
|
+
const nextPresenceId = () =>
|
|
7
|
+
`presenceInRoom-${Date.now().toString(36)}-${(++presenceIdCounter).toString(36)}`;
|
|
8
|
+
|
|
4
9
|
export const presenceInRoom = async (
|
|
5
10
|
client: Client,
|
|
6
11
|
roomJID: string,
|
|
7
12
|
delay = 2000
|
|
8
13
|
): Promise<Element> => {
|
|
9
14
|
let stanzaHandler: (stanza: Element) => void;
|
|
10
|
-
|
|
11
15
|
const unsubscribe = () => client.off('stanza', stanzaHandler);
|
|
16
|
+
const stanzaId = nextPresenceId();
|
|
12
17
|
|
|
13
18
|
return new Promise(async (resolve, reject) => {
|
|
14
19
|
let settled = false;
|
|
@@ -26,7 +31,7 @@ export const presenceInRoom = async (
|
|
|
26
31
|
stanzaHandler = (stanza) => {
|
|
27
32
|
if (
|
|
28
33
|
stanza.is('presence') &&
|
|
29
|
-
stanza.attrs.id ===
|
|
34
|
+
stanza.attrs.id === stanzaId &&
|
|
30
35
|
stanza.attrs.from?.startsWith(roomJID)
|
|
31
36
|
) {
|
|
32
37
|
finish(resolve, stanza);
|
|
@@ -40,7 +45,7 @@ export const presenceInRoom = async (
|
|
|
40
45
|
{
|
|
41
46
|
from: client.jid?.toString(),
|
|
42
47
|
to: `${roomJID}/${client.jid?.getLocal()}`,
|
|
43
|
-
id:
|
|
48
|
+
id: stanzaId,
|
|
44
49
|
},
|
|
45
50
|
xml('x', { xmlns: 'http://jabber.org/protocol/muc' })
|
|
46
51
|
);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Client, xml } from '@xmpp/client';
|
|
2
2
|
|
|
3
|
+
// Monotonic counter — see sendTextMessage for the rationale (avoids
|
|
4
|
+
// Date.now() id collisions on <1ms-apart sends).
|
|
5
|
+
let _sendMediaSeq = 0;
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Same xmlns derivation as sendTextMessage — the server registers app
|
|
5
9
|
* messages under the WSS service URL (`wss://<host>/ws`), and stanzas
|
|
@@ -23,7 +27,7 @@ export function sendMediaMessage(
|
|
|
23
27
|
devServer?: string
|
|
24
28
|
) {
|
|
25
29
|
const id =
|
|
26
|
-
customId || `send-media-message-${Date.now()
|
|
30
|
+
customId || `send-media-message-${Date.now()}-${(_sendMediaSeq = (_sendMediaSeq + 1) >>> 0)}`;
|
|
27
31
|
|
|
28
32
|
const dataToSend = {
|
|
29
33
|
xmlns: toServiceXmlns(devServer, client),
|
|
@@ -72,26 +76,20 @@ export function sendMediaMessage(
|
|
|
72
76
|
xml('data', dataToSend)
|
|
73
77
|
);
|
|
74
78
|
|
|
75
|
-
console.log('🟢 [sendMediaMessage] sending stanza', {
|
|
76
|
-
id,
|
|
77
|
-
to: roomJID,
|
|
78
|
-
mime: data?.mimetype,
|
|
79
|
-
name: data?.fileName,
|
|
80
|
-
});
|
|
81
79
|
try {
|
|
82
80
|
const sendResult = client.send(message);
|
|
83
81
|
if (sendResult && typeof (sendResult as any).then === 'function') {
|
|
84
82
|
(sendResult as Promise<unknown>)
|
|
85
|
-
.then(() => console.log('
|
|
83
|
+
.then(() => console.log('client.send resolved', { id }))
|
|
86
84
|
.catch((err) =>
|
|
87
|
-
console.error('
|
|
85
|
+
console.error('client.send REJECTED', {
|
|
88
86
|
id,
|
|
89
87
|
message: (err as any)?.message,
|
|
90
88
|
})
|
|
91
89
|
);
|
|
92
90
|
}
|
|
93
91
|
} catch (error) {
|
|
94
|
-
console.error(
|
|
92
|
+
console.error(error);
|
|
95
93
|
}
|
|
96
94
|
return id;
|
|
97
95
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Client, xml } from '@xmpp/client';
|
|
2
2
|
|
|
3
|
+
// Local monotonic counter — see comment in id construction below.
|
|
4
|
+
let _sendTextSeq = 0;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Normalize devServer to the WSS service URL that the server uses as
|
|
5
8
|
* the data-element xmlns. The user-facing `xmppDevServer` config
|
|
@@ -37,11 +40,15 @@ export const sendTextMessage = (
|
|
|
37
40
|
devServer?: string,
|
|
38
41
|
customId?: string
|
|
39
42
|
) => {
|
|
43
|
+
// Same monotonic-counter pattern as useSendMessage.nextStanzaId, to
|
|
44
|
+
// protect direct callers that don't supply a customId from
|
|
45
|
+
// Date.now() collisions when sends happen <1ms apart.
|
|
46
|
+
if (!_sendTextSeq) {/* noop guarded init */}
|
|
40
47
|
const id = customId
|
|
41
48
|
? customId
|
|
42
49
|
: isReply
|
|
43
|
-
? `send-reply-message-${Date.now()
|
|
44
|
-
: `send-text-message-${Date.now()
|
|
50
|
+
? `send-reply-message-${Date.now()}-${(_sendTextSeq = (_sendTextSeq + 1) >>> 0)}`
|
|
51
|
+
: `send-text-message-${Date.now()}-${(_sendTextSeq = (_sendTextSeq + 1) >>> 0)}`;
|
|
45
52
|
|
|
46
53
|
try {
|
|
47
54
|
const message = xml(
|
|
@@ -71,19 +78,12 @@ export const sendTextMessage = (
|
|
|
71
78
|
}),
|
|
72
79
|
xml('body', {}, userMessage)
|
|
73
80
|
);
|
|
74
|
-
console.log('🟢 [sendTextMessage] sending stanza', {
|
|
75
|
-
id,
|
|
76
|
-
to: roomJID,
|
|
77
|
-
bodyLen: userMessage?.length,
|
|
78
|
-
clientJid: client.jid?.toString(),
|
|
79
|
-
xmlns: toServiceXmlns(devServer, client),
|
|
80
|
-
});
|
|
81
81
|
const sendResult = client.send(message);
|
|
82
82
|
if (sendResult && typeof (sendResult as any).then === 'function') {
|
|
83
83
|
(sendResult as Promise<unknown>)
|
|
84
|
-
.then(() => console.log('
|
|
84
|
+
.then(() => console.log('client.send resolved', { id }))
|
|
85
85
|
.catch((err) =>
|
|
86
|
-
console.error('
|
|
86
|
+
console.error('client.send REJECTED', {
|
|
87
87
|
id,
|
|
88
88
|
message: (err as any)?.message,
|
|
89
89
|
name: (err as any)?.name,
|
|
@@ -94,6 +94,6 @@ export const sendTextMessage = (
|
|
|
94
94
|
console.log('🟡 [sendTextMessage] client.send returned non-promise', { id, type: typeof sendResult });
|
|
95
95
|
}
|
|
96
96
|
} catch (error) {
|
|
97
|
-
console.error(
|
|
97
|
+
console.error(error);
|
|
98
98
|
}
|
|
99
99
|
};
|
|
@@ -5,6 +5,7 @@ import { xmppSettingsInterface } from '../types/types';
|
|
|
5
5
|
import { sendMediaMessage } from './xmpp/sendMediaMessage.xmpp';
|
|
6
6
|
import { getChatsPrivateStoreRequest } from './xmpp/getChatsPrivateStoreRequest.xmpp';
|
|
7
7
|
import { actionSetTimestampToPrivateStore } from './xmpp/actionSetTimestampToPrivateStore.xmpp';
|
|
8
|
+
import { flushLastViewedToPrivateStore } from './xmpp/flushLastViewedToPrivateStore';
|
|
8
9
|
import { sendTypingRequest } from './xmpp/sendTypingRequest.xmpp';
|
|
9
10
|
import { getHistory } from './xmpp/getHistory.xmpp';
|
|
10
11
|
import { sendTextMessage } from './xmpp/sendTextMessage.xmpp';
|
|
@@ -233,6 +234,17 @@ export class XmppClient {
|
|
|
233
234
|
password: this.password,
|
|
234
235
|
});
|
|
235
236
|
|
|
237
|
+
// Concurrent room preloads each register their own short-lived
|
|
238
|
+
// 'stanza' handler (getHistory, getRooms, getChatsPrivateStore,
|
|
239
|
+
// ...). With 5+ rooms preloading in parallel, Node's default
|
|
240
|
+
// EventEmitter cap of 10 trips the "possible memory leak" warning
|
|
241
|
+
// even though each handler is correctly unsubscribed in `finally`.
|
|
242
|
+
// Raising the cap silences the warning without masking real leaks
|
|
243
|
+
// — true leaks would still grow unboundedly past this number.
|
|
244
|
+
try {
|
|
245
|
+
(this.client as any)?.setMaxListeners?.(50);
|
|
246
|
+
} catch {}
|
|
247
|
+
|
|
236
248
|
// Wrap `send` so the dev logger sees outgoing stanzas too.
|
|
237
249
|
// Guarded: some @xmpp/client builds define `send` as a getter or
|
|
238
250
|
// non-writable property, and overwriting would throw silently and
|
|
@@ -531,6 +543,21 @@ export class XmppClient {
|
|
|
531
543
|
} catch (error) {}
|
|
532
544
|
}
|
|
533
545
|
|
|
546
|
+
// Flush lastViewedTimestamp for every room into the server's private
|
|
547
|
+
// store. Used by AppState/background and tab-change handlers — see
|
|
548
|
+
// src/networking/xmpp/flushLastViewedToPrivateStore.ts for details.
|
|
549
|
+
async flushLastViewedToPrivateStoreStanza(
|
|
550
|
+
rooms: Record<string, any> | null | undefined,
|
|
551
|
+
opts: { activeRoomJID?: string | null; onlyIfNoUnread?: boolean } = {}
|
|
552
|
+
) {
|
|
553
|
+
if (this.disableLastRead) {return false;}
|
|
554
|
+
try {
|
|
555
|
+
return await flushLastViewedToPrivateStore(this, rooms, opts);
|
|
556
|
+
} catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
534
561
|
sendMediaMessageStanza(roomJID: string, data: any, customId?: string) {
|
|
535
562
|
return sendMediaMessage(
|
|
536
563
|
this.client,
|
|
@@ -10,14 +10,23 @@ export const logoutMiddleware: Middleware =
|
|
|
10
10
|
|
|
11
11
|
const result = next(action);
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
// Action type uses the slice's `name`, not the store key —
|
|
14
|
+
// chatSettingsSlice is registered with `name: 'chat'`, so the
|
|
15
|
+
// logout action type is `chat/logout`. Filtering on
|
|
16
|
+
// `chatSettingStore/logout` (the store key) silently never fires,
|
|
17
|
+
// which means the XMPP transport never sees the logout signal.
|
|
18
|
+
if (action.type === 'chat/logout') {
|
|
19
|
+
// Capture the emitter reference outside the timer body so a Jest
|
|
20
|
+
// teardown that nulls out the lazy `react-native` exports between
|
|
21
|
+
// dispatch and the 0-delay timer firing can't crash the timer.
|
|
22
|
+
const emitter = DeviceEventEmitter;
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
try {
|
|
25
|
+
emitter?.emit?.('ethora-xmpp-logout');
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error dispatching ethora-xmpp-logout event:', error);
|
|
28
|
+
}
|
|
29
|
+
}, 0);
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
return result;
|
|
@@ -1,8 +1,73 @@
|
|
|
1
1
|
import { Middleware } from '@reduxjs/toolkit';
|
|
2
|
-
import { updateRoom } from '../roomsSlice';
|
|
2
|
+
import { updateRoom, msgSortableMs } from '../roomsSlice';
|
|
3
3
|
import { IMessage } from '../../types/types';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Per-room cache so we only recompute when something that affects the
|
|
6
|
+
// count actually changed. The fingerprint is `<messages.length>|<lastViewed>`
|
|
7
|
+
// — switching just the lastViewedTimestamp (e.g. user re-opens a room)
|
|
8
|
+
// must also retrigger, not just the message count.
|
|
9
|
+
//
|
|
10
|
+
// Kept at module scope so the middleware doesn't re-allocate it on
|
|
11
|
+
// every store; cleared on `chat/logout` (see the action gate below) so
|
|
12
|
+
// a re-login as a different user doesn't inherit the previous user's
|
|
13
|
+
// suppression cache.
|
|
14
|
+
let triggerCache: { [jid: string]: string } = {};
|
|
15
|
+
|
|
16
|
+
// Normalise an identity candidate (bare JID local, xmpp username, wallet)
|
|
17
|
+
// for case-insensitive comparison. Drops the domain part of any JID and
|
|
18
|
+
// strips XMPP resource separators ('/' for resource, '_' that the server
|
|
19
|
+
// inserts inside the local part for some prefix formats).
|
|
20
|
+
const norm = (s: any): string => {
|
|
21
|
+
if (s == null) {return '';}
|
|
22
|
+
let v = String(s).toLowerCase();
|
|
23
|
+
v = v.split('/')[0]; // strip XMPP resource
|
|
24
|
+
v = v.split('@')[0]; // strip JID domain
|
|
25
|
+
return v.replace(/_/g, '');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Mirrors Android `isOwnMessage` (chat-core RoomStore.kt). The sender id
|
|
29
|
+
// arrives in either `user.id` (Ethora user id from optimistic local sends)
|
|
30
|
+
// or `user.userJID` / `xmppFrom` (XMPP local for server echoes), so we
|
|
31
|
+
// match across all three against the current user's candidates.
|
|
32
|
+
// Uses exact-match on the normalised bare-local (no substring containment)
|
|
33
|
+
// to avoid false positives like norm('someone')='someone' matching
|
|
34
|
+
// norm('me')='me' via 'someone'.includes('me').
|
|
35
|
+
const isOwnMessage = (
|
|
36
|
+
msg: IMessage,
|
|
37
|
+
selfXmpp: string,
|
|
38
|
+
selfWallet: string
|
|
39
|
+
): boolean => {
|
|
40
|
+
if (!selfXmpp && !selfWallet) {return false;}
|
|
41
|
+
const candidates = [
|
|
42
|
+
norm((msg as any)?.user?.id),
|
|
43
|
+
norm((msg as any)?.user?.userJID),
|
|
44
|
+
norm((msg as any)?.user?.xmppUsername),
|
|
45
|
+
norm((msg as any)?.xmppFrom),
|
|
46
|
+
].filter(Boolean);
|
|
47
|
+
if (candidates.length === 0) {return false;}
|
|
48
|
+
const self = new Set(
|
|
49
|
+
[norm(selfXmpp), norm(selfWallet)].filter(Boolean)
|
|
50
|
+
);
|
|
51
|
+
for (const c of candidates) {
|
|
52
|
+
if (self.has(c)) {return true;}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Action types that can affect any room's unread count. Without this
|
|
58
|
+
// gate the middleware ran on EVERY dispatch — modal toggles, scroll
|
|
59
|
+
// events, typing-indicator setComposing, etc. — and walked every room's
|
|
60
|
+
// messages array looking for a count delta that almost never came. For
|
|
61
|
+
// 50 rooms × 100 messages that's 5000 Date() parses per modal click.
|
|
62
|
+
const TRIGGER_ACTIONS = new Set([
|
|
63
|
+
'roomMessages/addRoomMessage',
|
|
64
|
+
'roomMessages/setRoomMessages',
|
|
65
|
+
'roomMessages/editRoomMessage',
|
|
66
|
+
'roomMessages/setLastViewedTimestamp',
|
|
67
|
+
'roomMessages/setCurrentRoom',
|
|
68
|
+
'roomMessages/addRoom',
|
|
69
|
+
'roomMessages/updateRoom',
|
|
70
|
+
]);
|
|
6
71
|
|
|
7
72
|
export const unreadMiddleware: Middleware =
|
|
8
73
|
(storeAPI) => (next) => (action: any) => {
|
|
@@ -11,45 +76,74 @@ export const unreadMiddleware: Middleware =
|
|
|
11
76
|
return next(action);
|
|
12
77
|
}
|
|
13
78
|
|
|
79
|
+
// Reset the per-user suppression cache on logout so the next
|
|
80
|
+
// signed-in user doesn't inherit the previous user's "saw this
|
|
81
|
+
// count, skip" entries.
|
|
82
|
+
if (action.type === 'chat/logout') {
|
|
83
|
+
triggerCache = {};
|
|
84
|
+
return next(action);
|
|
85
|
+
}
|
|
86
|
+
|
|
14
87
|
if (action?.type === 'roomMessages/deleteRoomMessage') {
|
|
15
88
|
return next(action);
|
|
16
89
|
}
|
|
17
90
|
|
|
18
91
|
const result = next(action);
|
|
19
92
|
|
|
93
|
+
// Hot-path bail-out: most actions don't touch unread state.
|
|
94
|
+
if (!TRIGGER_ACTIONS.has(action.type)) {return result;}
|
|
95
|
+
|
|
20
96
|
const state = storeAPI.getState();
|
|
21
97
|
const rooms = state.rooms.rooms;
|
|
22
98
|
const activeChatJID = state.rooms.activeRoomJID;
|
|
99
|
+
const selfUser = state.chatSettingStore?.user;
|
|
100
|
+
const selfXmpp = selfUser?.xmppUsername || '';
|
|
101
|
+
const selfWallet = selfUser?.walletAddress || '';
|
|
23
102
|
|
|
24
103
|
if (rooms && Object.keys(rooms).length > 0) {
|
|
25
104
|
Object.keys(rooms).forEach((jid) => {
|
|
26
105
|
const room = rooms[jid];
|
|
27
|
-
if (room
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
106
|
+
if (!room) {return;}
|
|
107
|
+
// Skip rooms the user is currently viewing — `setLastViewedTimestamp(0)`
|
|
108
|
+
// clears their unread directly. Skip rooms with no reference point
|
|
109
|
+
// (covers undefined / null / 0) — after logout→login, hydrated rooms
|
|
110
|
+
// come back without lastViewedTimestamp, and treating that as 0
|
|
111
|
+
// made every history message satisfy `date > 0` and incorrectly
|
|
112
|
+
// bumped unread for already-seen content.
|
|
113
|
+
if (!room.lastViewedTimestamp || jid === activeChatJID) {return;}
|
|
114
|
+
|
|
115
|
+
// Fingerprint = "what would affect the answer". If neither the
|
|
116
|
+
// message count nor the lastViewedTimestamp changed since last
|
|
117
|
+
// time we computed, the answer is stable; skip the walk. (The
|
|
118
|
+
// previous version tracked only message count, so changing
|
|
119
|
+
// lastViewedTimestamp without a new message dropped the
|
|
120
|
+
// recompute silently — the unread badge could go stale.)
|
|
121
|
+
const currentMessagesLength = room.messages?.length || 0;
|
|
122
|
+
const fingerprint = `${currentMessagesLength}|${room.lastViewedTimestamp || 0}`;
|
|
123
|
+
if (triggerCache[jid] === fingerprint) {return;}
|
|
124
|
+
triggerCache[jid] = fingerprint;
|
|
125
|
+
|
|
126
|
+
// Ignore the "delimiter-new" sentinel + locally-pending sends
|
|
127
|
+
// so the two unread-counting paths (this middleware + the
|
|
128
|
+
// reducer's countNewerMessages) never disagree. Also exclude
|
|
129
|
+
// own messages so MAM-replayed sends on re-login don't bump
|
|
130
|
+
// the user's own badge.
|
|
131
|
+
const unreadMessagesCount = room.messages?.filter(
|
|
132
|
+
(msg: IMessage) =>
|
|
133
|
+
msg.id !== 'delimiter-new' &&
|
|
134
|
+
!msg.pending &&
|
|
135
|
+
!isOwnMessage(msg, selfXmpp, selfWallet) &&
|
|
136
|
+
msgSortableMs(msg) >
|
|
137
|
+
(room.lastViewedTimestamp || 0)
|
|
138
|
+
).length;
|
|
139
|
+
|
|
140
|
+
if (room.unreadMessages !== unreadMessagesCount) {
|
|
141
|
+
storeAPI.dispatch(
|
|
142
|
+
updateRoom({
|
|
143
|
+
jid,
|
|
144
|
+
updates: { unreadMessages: unreadMessagesCount },
|
|
145
|
+
})
|
|
146
|
+
);
|
|
53
147
|
}
|
|
54
148
|
});
|
|
55
149
|
}
|
|
@@ -8,7 +8,7 @@ import { IMessage, IRoom, User } from '../types/types';
|
|
|
8
8
|
//
|
|
9
9
|
// - persist chatSettingStore.user (sanitized) and rooms state
|
|
10
10
|
// - blacklist transient fields (modals, activeRoomJID, etc.)
|
|
11
|
-
// - cap each room's messages to the most recent
|
|
11
|
+
// - cap each room's messages to the most recent MESSAGE_LIMIT (100)
|
|
12
12
|
// - debounced writes (200ms) on relevant action types
|
|
13
13
|
//
|
|
14
14
|
// Not encrypted. We rely on AsyncStorage's per-app sandbox.
|
|
@@ -17,7 +17,10 @@ import { IMessage, IRoom, User } from '../types/types';
|
|
|
17
17
|
const KEY_CHAT = '@ethora/persist:chatSettingStore';
|
|
18
18
|
const KEY_ROOMS = '@ethora/persist:rooms';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Per-room message cap on disk. Mirror this value in `roomsSlice`'s
|
|
21
|
+
// in-memory cap (see `enforceMessageCap`) so the runtime and persisted
|
|
22
|
+
// shapes stay aligned.
|
|
23
|
+
export const MESSAGE_LIMIT = 100;
|
|
21
24
|
|
|
22
25
|
interface PersistedChatState {
|
|
23
26
|
user: User;
|