@ethora/chat-component-rn 26.4.1 → 26.4.2

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 (46) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -661
  3. package/README.md +253 -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/MessageList.tsx +3 -20
  12. package/src/components/MainComponents/RoomList.tsx +7 -2
  13. package/src/components/MessageBubble/Message.tsx +7 -3
  14. package/src/components/MessageBubble/MessageInteractions.tsx +67 -63
  15. package/src/components/Modals/AttachSheet/AttachSheet.tsx +195 -0
  16. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +3 -0
  17. package/src/components/Modals/FilePreviewModal/PdfView.tsx +71 -0
  18. package/src/components/Modals/Modal/Modal.tsx +2 -4
  19. package/src/components/Modals/ModalSelectMedia/ModalSelectMedia.tsx +0 -2
  20. package/src/components/styled/Button.tsx +9 -0
  21. package/src/components/styled/SendInput.tsx +191 -107
  22. package/src/components/styled/StyledInputComponents/StyledInputComponents.tsx +1 -1
  23. package/src/context/xmppProvider.tsx +51 -2
  24. package/src/helpers/constants/MODAL_COMPONENTS.tsx +35 -0
  25. package/src/helpers/constants/MODAL_TYPES.ts +10 -39
  26. package/src/helpers/historyPreloadScheduler.ts +15 -1
  27. package/src/helpers/updateMessagesTillLast.tsx +20 -17
  28. package/src/hooks/useComposing.tsx +0 -9
  29. package/src/hooks/useLogout.tsx +129 -9
  30. package/src/hooks/useSendMessage.tsx +114 -60
  31. package/src/networking/api-requests/auth.api.ts +1 -1
  32. package/src/networking/api-requests/user.api.ts +1 -0
  33. package/src/networking/apiClient.ts +74 -38
  34. package/src/networking/stanzaHandlers.ts +18 -7
  35. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +86 -0
  36. package/src/networking/xmpp/getHistory.xmpp.ts +6 -1
  37. package/src/networking/xmpp/presenceInRoom.xmpp.ts +8 -3
  38. package/src/networking/xmpp/sendMediaMessage.xmpp.ts +8 -10
  39. package/src/networking/xmpp/sendTextMessage.xmpp.ts +12 -12
  40. package/src/networking/xmppClient.ts +27 -0
  41. package/src/roomStore/Middleware/logoutMiddleware.tsx +17 -8
  42. package/src/roomStore/Middleware/unreadMidlleware.tsx +122 -28
  43. package/src/roomStore/persistence.ts +5 -2
  44. package/src/roomStore/roomsSlice.ts +115 -7
  45. package/src/types/types.ts +10 -9
  46. package/src/utils/devLogger.ts +11 -0
@@ -2,6 +2,25 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
2
  import { EditAction, HistoryPreloadState, IMessage, IRoom } from '../types/types';
3
3
  import { insertMessageWithDelimiter } from '../helpers/insertMessageWithDelimiter';
4
4
 
5
+ // Per-room runtime message cap. Mirrors the persistence layer's
6
+ // MESSAGE_LIMIT so what's in memory matches what's on disk; otherwise
7
+ // long sessions grow the array unbounded (memory leak) and reload
8
+ // shows fewer messages than the user saw last in the session.
9
+ //
10
+ // The cap fires on the APPEND path (new messages arriving) — paginated
11
+ // older history added via unshift/splice is exempt, so the user can
12
+ // page backward arbitrarily far without instantly losing what they
13
+ // just fetched. After cap eviction we drop from the head (oldest).
14
+ const RUNTIME_MESSAGE_LIMIT = 100;
15
+
16
+ const enforceMessageCap = (messages: IMessage[]): void => {
17
+ // Trim oldest until we're at/under the limit. Mutates in place
18
+ // (immer-compatible inside reducers).
19
+ while (messages.length > RUNTIME_MESSAGE_LIMIT) {
20
+ messages.shift();
21
+ }
22
+ };
23
+
5
24
  export interface RoomPreloadPatch {
6
25
  jid: string;
7
26
  messages?: IMessage[];
@@ -37,7 +56,18 @@ export const roomsStore = createSlice({
37
56
  reducers: {
38
57
  addRoom(state, action: PayloadAction<{ roomData: IRoom }>) {
39
58
  const { roomData } = action.payload;
40
- state.rooms[roomData.jid] = roomData;
59
+ const existing = state.rooms[roomData.jid];
60
+ // Preserve a pre-existing lastViewedTimestamp (the private-store
61
+ // pull may have landed before /chats/my finished). If neither
62
+ // source set one, stamp `Date.now()` so initial REST history
63
+ // counts as "seen" — without this default, every back-history
64
+ // message read by the unread middleware satisfies `>0` and the
65
+ // badge shows the entire room population on first paint.
66
+ const lastViewed =
67
+ roomData.lastViewedTimestamp ??
68
+ existing?.lastViewedTimestamp ??
69
+ Date.now();
70
+ state.rooms[roomData.jid] = { ...roomData, lastViewedTimestamp: lastViewed };
41
71
  },
42
72
  deleteRoom(state, action: PayloadAction<{ jid: string }>) {
43
73
  const { jid } = action.payload;
@@ -65,7 +95,14 @@ export const roomsStore = createSlice({
65
95
  ) {
66
96
  const { roomJID, messages } = action.payload;
67
97
  if (state.rooms[roomJID]) {
68
- state.rooms[roomJID].messages = messages;
98
+ // Cap to the runtime limit on replace too — guards against a
99
+ // single MAM page returning more than the limit (would balloon
100
+ // the array on its own).
101
+ const capped =
102
+ messages.length > RUNTIME_MESSAGE_LIMIT
103
+ ? messages.slice(-RUNTIME_MESSAGE_LIMIT)
104
+ : messages;
105
+ state.rooms[roomJID].messages = capped;
69
106
  }
70
107
  },
71
108
  deleteRoomMessage(
@@ -120,11 +157,22 @@ export const roomsStore = createSlice({
120
157
  ) {
121
158
  const { roomJID, message, start } = action.payload;
122
159
 
123
- if (!state.rooms[roomJID]?.messages) {
160
+ // Guard against the "stanza arrived before /chats/my completed"
161
+ // race — without this, the optional-chain `?.messages` falls
162
+ // back to undefined, and the assignment below tries to set
163
+ // `.messages` on undefined → TypeError. Caller can safely
164
+ // ignore the message; the room will get its messages array on
165
+ // `addRoom` when the API response lands.
166
+ if (!state.rooms[roomJID]) {
167
+ return;
168
+ }
169
+
170
+ if (!state.rooms[roomJID].messages) {
124
171
  state.rooms[roomJID].messages = [];
125
172
  }
126
173
 
127
174
  const roomMessages = state.rooms[roomJID].messages;
175
+ const lengthBefore = roomMessages.length;
128
176
 
129
177
  if (roomMessages.length === 0 || start) {
130
178
  roomMessages.unshift(message);
@@ -135,6 +183,14 @@ export const roomsStore = createSlice({
135
183
 
136
184
  insertMessageWithDelimiter(roomMessages, message, lastViewedTimestamp);
137
185
  }
186
+
187
+ // Apply the in-memory cap only when the array actually GREW
188
+ // (i.e. this wasn't a dedupe/merge that left length unchanged).
189
+ // Otherwise repeated echoes of the same message would chip away
190
+ // at the oldest history for no reason.
191
+ if (roomMessages.length > lengthBefore) {
192
+ enforceMessageCap(roomMessages);
193
+ }
138
194
  },
139
195
  deleteAllRooms(state) {
140
196
  state.rooms = {};
@@ -309,17 +365,69 @@ export const roomsStore = createSlice({
309
365
  });
310
366
 
311
367
  // Count messages strictly newer than the given millisecond timestamp.
312
- // Uses `msg.date` (canonical ISO/Date) so it matches the unread
313
- // middleware. Excludes the "delimiter-new" sentinel and pending sends.
368
+ // Uses `msg.id` (server-authoritative microsecond timestamp prefixed by
369
+ // 13-digit millis — see helpers/dateComparison `getHighResolutionTimestamp`)
370
+ // because `msg.date` can be derived client-side (createMessageFromXml
371
+ // falls back to `Date.now()` for realtime stanzas without a `date`
372
+ // attr), which makes the comparison drift vs what the server assigned.
373
+ // Excludes the "delimiter-new" sentinel, pending sends, and the current
374
+ // user's own messages (parity with unreadMiddleware's isOwnMessage
375
+ // filter — without this, the reducer and the middleware disagree about
376
+ // the count and we get a flicker as the badge gets written twice with
377
+ // different values on every message).
378
+ export const msgSortableMs = (msg: any): number => {
379
+ const id = String(msg?.id || '');
380
+ const m = /^(\d{13})/.exec(id);
381
+ if (m) {return Number(m[1]);}
382
+ if (msg?.date) {
383
+ const t = new Date(msg.date as any).getTime();
384
+ if (Number.isFinite(t)) {return t;}
385
+ }
386
+ return 0;
387
+ };
388
+
389
+ const norm = (s: any): string => {
390
+ if (s == null) {return '';}
391
+ let v = String(s).toLowerCase();
392
+ v = v.split('/')[0];
393
+ v = v.split('@')[0];
394
+ return v.replace(/_/g, '');
395
+ };
396
+
397
+ const isOwn = (
398
+ msg: IMessage,
399
+ selfXmpp: string,
400
+ selfWallet: string
401
+ ): boolean => {
402
+ if (!selfXmpp && !selfWallet) {return false;}
403
+ const candidates = [
404
+ norm((msg as any)?.user?.id),
405
+ norm((msg as any)?.user?.userJID),
406
+ norm((msg as any)?.user?.xmppUsername),
407
+ norm((msg as any)?.xmppFrom),
408
+ ].filter(Boolean);
409
+ if (candidates.length === 0) {return false;}
410
+ const self = new Set(
411
+ [norm(selfXmpp), norm(selfWallet)].filter(Boolean)
412
+ );
413
+ for (const c of candidates) {
414
+ if (self.has(c)) {return true;}
415
+ }
416
+ return false;
417
+ };
418
+
314
419
  const countNewerMessages = (
315
420
  messages: IMessage[],
316
- timestamp: number
421
+ timestamp: number,
422
+ selfXmpp: string = '',
423
+ selfWallet: string = ''
317
424
  ): number => {
318
425
  if (!messages?.length || !timestamp) {return 0;}
319
426
  let count = 0;
320
427
  for (const message of messages) {
321
428
  if (!message || message.id === 'delimiter-new' || message.pending) {continue;}
322
- const ms = new Date(message.date as any).getTime();
429
+ if (isOwn(message, selfXmpp, selfWallet)) {continue;}
430
+ const ms = msgSortableMs(message);
323
431
  if (Number.isFinite(ms) && ms > timestamp) {count += 1;}
324
432
  }
325
433
  return count;
@@ -60,6 +60,11 @@ export interface IRoom {
60
60
  createdAt?: string | number | Date;
61
61
  updatedAt?: string | number | Date;
62
62
 
63
+ // REST-side metadata populated by `/chats/my` hydration. Surfaced
64
+ // in ChatProfileModal under Description / Chat type fields.
65
+ description?: string;
66
+ type?: string;
67
+
63
68
  roomMembers?: RoomMember[];
64
69
 
65
70
  // QoS / preload state
@@ -349,6 +354,8 @@ export interface IConfig {
349
354
  roomListStyles?: ViewStyle;
350
355
  chatRoomStyles?: ViewStyle;
351
356
 
357
+ keyboardVerticalOffset?: number;
358
+
352
359
  // ----- interactions / messages -----
353
360
  disableInteractions?: boolean;
354
361
  disableReactions?: boolean;
@@ -356,15 +363,9 @@ export interface IConfig {
356
363
  disableUserCount?: boolean;
357
364
  disableSentLogic?: boolean;
358
365
  disableTypingIndicator?: boolean;
359
- // Chat-profile modal granular gates (matches web's
360
- // `disableChatInfo`).
361
- disableChatInfo?: {
362
- disableHeader?: boolean;
363
- disableDescription?: boolean;
364
- disableType?: boolean;
365
- hideMembers?: boolean;
366
- disableMembers?: boolean;
367
- };
366
+ // disableChatInfo already declared above (line ~338) — granular gates
367
+ // (disableHeader / disableDescription / disableType / hideMembers /
368
+ // disableMembers / disableChatHeaderMenu). Don't redeclare here.
368
369
  botMessageAutoScroll?: boolean;
369
370
  blockMessageSendingWhenProcessing?:
370
371
  | boolean
@@ -106,6 +106,17 @@ function safeStringify(value: any): string {
106
106
  let consolePatched = false;
107
107
 
108
108
  export function installConsoleCapture() {
109
+ // Under Jest, patching `console.*` to fan out through `pushLog` causes
110
+ // a render-time feedback loop: React's dev build emits `console.error`
111
+ // during act() for various reasons (deprecated APIs, act-warnings),
112
+ // each call schedules a microtask notify → `useSyncExternalStore`
113
+ // subscribers re-render → next render may log again → "Maximum
114
+ // update depth exceeded" → the suite hangs. Skipping the patch in
115
+ // test environments eliminates the loop without changing dev/prod
116
+ // behaviour. Mirrors how the web build gates DEV-only diagnostics.
117
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') {
118
+ return;
119
+ }
109
120
  if (consolePatched) {return;}
110
121
  consolePatched = true;
111
122
  (['log', 'info', 'warn', 'error'] as const).forEach((level) => {