@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
@@ -266,7 +266,11 @@ const handleComposing = async (stanza: Element, currentUser: string) => {
266
266
  };
267
267
 
268
268
  const onPresenceInRoom = (stanza: Element | any) => {
269
- if (stanza.attrs.id === 'presenceInRoom' && !stanza.getChild('error')) {
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.forEach(async (result: any) => {
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: result?.attrs?.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
- console.log('=-> error in', fixedChatJid, e);
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 === 'presenceInRoom' &&
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: 'presenceInRoom',
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().toString()}`;
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('🟢 [sendMediaMessage] client.send resolved', { id }))
83
+ .then(() => console.log('client.send resolved', { id }))
86
84
  .catch((err) =>
87
- console.error('🔴 [sendMediaMessage] client.send REJECTED', {
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('🔴 [sendMediaMessage] sync error', 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().toString()}`
44
- : `send-text-message-${Date.now().toString()}`;
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('🟢 [sendTextMessage] client.send resolved', { id }))
84
+ .then(() => console.log('client.send resolved', { id }))
85
85
  .catch((err) =>
86
- console.error('🔴 [sendTextMessage] client.send REJECTED', {
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('🔴 [sendTextMessage] sync error', 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
- if (action.type === 'chatSettingStore/logout') {
14
- try {
15
- setTimeout(() => {
16
- DeviceEventEmitter.emit('ethora-xmpp-logout');
17
- }, 0);
18
- } catch (error) {
19
- console.error('Error dispatching ethora-xmpp-logout event:', error);
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
- let previousMessagesCount: { [jid: string]: number } = {};
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.lastViewedTimestamp !== 0 && jid !== activeChatJID) {
28
- const currentMessagesLength = room.messages?.length || 0;
29
-
30
- if (previousMessagesCount[jid] !== currentMessagesLength) {
31
- previousMessagesCount[jid] = currentMessagesLength;
32
-
33
- // Mirror `countNewerMessages` in roomsSlice: ignore the
34
- // "delimiter-new" sentinel + locally-pending sends so the
35
- // two paths never disagree.
36
- const unreadMessagesCount = room.messages?.filter(
37
- (msg: IMessage) =>
38
- msg.id !== 'delimiter-new' &&
39
- !msg.pending &&
40
- new Date(msg.date).getTime() >
41
- (room.lastViewedTimestamp || 0)
42
- ).length;
43
-
44
- if (room.unreadMessages !== unreadMessagesCount) {
45
- storeAPI.dispatch(
46
- updateRoom({
47
- jid,
48
- updates: { unreadMessages: unreadMessagesCount },
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 50
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
- const MESSAGE_LIMIT = 50;
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;