@ethora/chat-component-rn 26.7.6 → 26.7.8

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 (87) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/lib/commonjs/assets/icons.js +2 -2
  3. package/lib/commonjs/assets/icons.js.map +1 -1
  4. package/lib/commonjs/components/MainComponents/ChatWrapper.js +2 -0
  5. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  6. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +51 -1
  7. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  8. package/lib/commonjs/context/xmppProvider.js +9 -1
  9. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  10. package/lib/commonjs/helpers/pushPayload.js +71 -0
  11. package/lib/commonjs/helpers/pushPayload.js.map +1 -0
  12. package/lib/commonjs/hooks/usePendingNotification.js +8 -11
  13. package/lib/commonjs/hooks/usePendingNotification.js.map +1 -1
  14. package/lib/commonjs/i18n/strings.js +18 -0
  15. package/lib/commonjs/i18n/strings.js.map +1 -1
  16. package/lib/commonjs/main.js +13 -0
  17. package/lib/commonjs/main.js.map +1 -1
  18. package/lib/commonjs/networking/api-requests/rooms.api.js +13 -0
  19. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  20. package/lib/commonjs/networking/xmpp/subscribeToRoomMessages.xmpp.js +5 -1
  21. package/lib/commonjs/networking/xmpp/subscribeToRoomMessages.xmpp.js.map +1 -1
  22. package/lib/commonjs/networking/xmppClient.js +55 -1
  23. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  24. package/lib/commonjs/services/pushSubscriptionService.js +15 -13
  25. package/lib/commonjs/services/pushSubscriptionService.js.map +1 -1
  26. package/lib/module/assets/icons.js +1 -1
  27. package/lib/module/assets/icons.js.map +1 -1
  28. package/lib/module/components/MainComponents/ChatWrapper.js +2 -0
  29. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  30. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +53 -3
  31. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  32. package/lib/module/context/xmppProvider.js +9 -1
  33. package/lib/module/context/xmppProvider.js.map +1 -1
  34. package/lib/module/helpers/pushPayload.js +63 -0
  35. package/lib/module/helpers/pushPayload.js.map +1 -0
  36. package/lib/module/hooks/usePendingNotification.js +8 -11
  37. package/lib/module/hooks/usePendingNotification.js.map +1 -1
  38. package/lib/module/i18n/strings.js +18 -0
  39. package/lib/module/i18n/strings.js.map +1 -1
  40. package/lib/module/main.js +1 -0
  41. package/lib/module/main.js.map +1 -1
  42. package/lib/module/networking/api-requests/rooms.api.js +12 -0
  43. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  44. package/lib/module/networking/xmpp/subscribeToRoomMessages.xmpp.js +5 -1
  45. package/lib/module/networking/xmpp/subscribeToRoomMessages.xmpp.js.map +1 -1
  46. package/lib/module/networking/xmppClient.js +55 -1
  47. package/lib/module/networking/xmppClient.js.map +1 -1
  48. package/lib/module/services/pushSubscriptionService.js +15 -13
  49. package/lib/module/services/pushSubscriptionService.js.map +1 -1
  50. package/lib/typescript/assets/icons.d.ts +8 -0
  51. package/lib/typescript/assets/icons.d.ts.map +1 -1
  52. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  53. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  54. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  55. package/lib/typescript/helpers/getAvatarColor.d.ts +1 -1
  56. package/lib/typescript/helpers/pushPayload.d.ts +7 -0
  57. package/lib/typescript/helpers/pushPayload.d.ts.map +1 -0
  58. package/lib/typescript/hooks/usePendingNotification.d.ts.map +1 -1
  59. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  60. package/lib/typescript/main.d.ts +2 -0
  61. package/lib/typescript/main.d.ts.map +1 -1
  62. package/lib/typescript/networking/api-requests/rooms.api.d.ts +1 -0
  63. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  64. package/lib/typescript/networking/xmpp/subscribeToRoomMessages.xmpp.d.ts.map +1 -1
  65. package/lib/typescript/networking/xmppClient.d.ts +7 -0
  66. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  67. package/lib/typescript/services/pushSubscriptionService.d.ts +1 -1
  68. package/lib/typescript/services/pushSubscriptionService.d.ts.map +1 -1
  69. package/lib/typescript/types/models/room.model.d.ts +1 -0
  70. package/lib/typescript/types/models/room.model.d.ts.map +1 -1
  71. package/lib/typescript/types/types.d.ts +2 -0
  72. package/lib/typescript/types/types.d.ts.map +1 -1
  73. package/package.json +1 -1
  74. package/src/assets/icons.tsx +24 -0
  75. package/src/components/MainComponents/ChatWrapper.tsx +3 -0
  76. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +36 -2
  77. package/src/context/xmppProvider.tsx +9 -1
  78. package/src/helpers/pushPayload.ts +62 -0
  79. package/src/hooks/usePendingNotification.ts +5 -11
  80. package/src/i18n/strings.ts +18 -0
  81. package/src/main.ts +3 -0
  82. package/src/networking/api-requests/rooms.api.ts +9 -0
  83. package/src/networking/xmpp/subscribeToRoomMessages.xmpp.ts +12 -1
  84. package/src/networking/xmppClient.ts +58 -1
  85. package/src/services/pushSubscriptionService.ts +18 -13
  86. package/src/types/models/room.model.ts +1 -0
  87. package/src/types/types.ts +2 -0
@@ -30,6 +30,7 @@ import Modal from '../Modals/Modal/Modal';
30
30
  import ThreadWrapper from '../Thread/ThreadWrapper';
31
31
  import {ModalWrapper} from '../Modals/ModalWrapper/ModalWrapper';
32
32
  import {useChatSettingState} from '../../hooks/useChatSettingState';
33
+ import { usePendingNotification } from '../../hooks/usePendingNotification';
33
34
  import {DeviceEventEmitter, Pressable, Text, View} from 'react-native';
34
35
  import {pushLog as devPushLog} from '../../utils/devLogger';
35
36
  import {normalizeRoomJid} from '../../helpers/normalizeRoomJid';
@@ -61,6 +62,8 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
61
62
  client: storedClient,
62
63
  } = useChatSettingState();
63
64
 
65
+ usePendingNotification();
66
+
64
67
  const [isInited, setInited] = useState(false);
65
68
  const [showModal, setShowModal] = useState(false);
66
69
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
@@ -26,6 +26,8 @@ import { deleteRoom, updateRoom } from '../../../roomStore/roomsSlice';
26
26
  import Loader from '../../styled/Loader';
27
27
  import {
28
28
  AddNewIcon,
29
+ BellIcon,
30
+ BellOffIcon,
29
31
  DeleteIcon,
30
32
  EditIcon,
31
33
  LeaveIcon,
@@ -36,7 +38,7 @@ import { useChatSettingState } from '../../../hooks/useChatSettingState';
36
38
  import { chatTextStyle } from '../../../helpers/typography';
37
39
  import { getElementFont } from '../../../helpers/getElementFont';
38
40
  import { getIconColor } from '../../../helpers/getIconColor';
39
- import { deleteRoomMember } from '../../../networking/api-requests/rooms.api';
41
+ import { deleteRoomMember, setRoomMuted } from '../../../networking/api-requests/rooms.api';
40
42
  import { RoomMember } from '../../../types/models/room.model';
41
43
  import { setActiveModal, setSelectedUser } from '../../../roomStore/chatSettingsSlice';
42
44
  import { MODAL_TYPES } from '../../../helpers/constants/MODAL_TYPES';
@@ -355,8 +357,40 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
355
357
  return () => sub.remove();
356
358
  }, [isSearchOpen]); // eslint-disable-line react-hooks/exhaustive-deps
357
359
 
360
+ const muteInFlightRef = useRef(false);
361
+ const toggleMuted = async () => {
362
+ if (!activeRoom?.jid || !activeRoom?.name || muteInFlightRef.current) {return;}
363
+ const next = !activeRoom.muted;
364
+ muteInFlightRef.current = true;
365
+ dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: next } }));
366
+ try {
367
+ const confirmed = await setRoomMuted(activeRoom.name, next);
368
+ if (confirmed !== next) {
369
+ dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: confirmed } }));
370
+ }
371
+ } catch (error) {
372
+ dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: !next } }));
373
+ console.warn('[ethora-rn] mute toggle failed', error);
374
+ showToast({
375
+ id: Date.now().toString(),
376
+ title: 'Error',
377
+ message: t('toast.muteFailed'),
378
+ type: 'error',
379
+ });
380
+ } finally {
381
+ muteInFlightRef.current = false;
382
+ }
383
+ };
384
+
358
385
  const heroActions: HeroAction[] = useMemo(
359
386
  () => [
387
+ {
388
+ key: 'mute',
389
+ label: activeRoom?.muted ? t('action.muted') : t('action.unmuted'),
390
+ icon: (color: string) =>
391
+ activeRoom?.muted ? <BellOffIcon color={color} /> : <BellIcon color={color} />,
392
+ onPress: toggleMuted,
393
+ },
360
394
  {
361
395
  key: 'search',
362
396
  label: t('action.search'),
@@ -376,7 +410,7 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
376
410
  onPress: () => setIsReportOpen(true),
377
411
  },
378
412
  ],
379
- [t, activeRoom?.jid] // eslint-disable-line react-hooks/exhaustive-deps
413
+ [t, activeRoom?.jid, activeRoom?.muted] // eslint-disable-line react-hooks/exhaustive-deps
380
414
  );
381
415
 
382
416
  // "…" menu. Edit is the chat picture — the only chat detail this SDK can
@@ -594,9 +594,14 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
594
594
  // be re-marked visible on return. Lives in the effect closure (one per
595
595
  // client) — persists across the listener's many invocations.
596
596
  let visibleBeforeBackground: string | null = null;
597
+ const suspendInBackground = !config?.xmppSettings?.keepAliveInBackground;
597
598
  const sub = AppState.addEventListener('change', (next) => {
598
599
  const c = client;
599
600
  if (next === 'active') {
601
+ if (c?.isSuspended) {
602
+ devPushLog('rn', 'AppState active: resuming suspended XMPP client');
603
+ c.resume();
604
+ }
600
605
  // Returned to foreground. If the socket died while backgrounded
601
606
  // (iOS suspends the WebSocket; long background kills it), the
602
607
  // status may still read 'online' until xmpp.js notices the dead
@@ -624,6 +629,9 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
624
629
  }
625
630
  // Going to background/inactive (sleep / app switcher / tray):
626
631
  if (!c) {return;}
632
+ if (next === 'background' && suspendInBackground) {
633
+ c.suspend().catch(() => undefined);
634
+ }
627
635
  const state = store.getState();
628
636
  const rooms = state.rooms?.rooms;
629
637
  const visibleRoomJID = state.rooms?.visibleRoomJID || null;
@@ -655,7 +663,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
655
663
  );
656
664
  });
657
665
  return () => sub.remove();
658
- }, [client]);
666
+ }, [client, config?.xmppSettings?.keepAliveInBackground]);
659
667
 
660
668
  // -----------------------------------------------------------
661
669
  // Consumer visibility signal (`isVisible` prop). For hosts that keep
@@ -0,0 +1,62 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import { store } from '../roomStore';
3
+ import {
4
+ clearPendingNotificationJid,
5
+ setCurrentRoom,
6
+ setPendingNotificationJid,
7
+ } from '../roomStore/roomsSlice';
8
+ import { handleCallPush } from './incomingCallFromPush';
9
+ import { normalizeRoomJid } from './normalizeRoomJid';
10
+
11
+ export type PushPayloadOutcome = 'call' | 'room' | 'pending' | 'unknown';
12
+
13
+ export const PENDING_NOTIFICATION_JID_KEY = 'ethora_pending_notification_jid';
14
+ export const PENDING_NOTIFICATION_TTL_MS = 5 * 60 * 1000;
15
+
16
+ export const readPendingJid = async (): Promise<string | null> => {
17
+ try {
18
+ const raw = await AsyncStorage.getItem(PENDING_NOTIFICATION_JID_KEY);
19
+ if (!raw) {return null;}
20
+ if (raw.startsWith('{')) {
21
+ const { jid, at } = JSON.parse(raw) as { jid?: string; at?: number };
22
+ if (!jid || !at || Date.now() - at > PENDING_NOTIFICATION_TTL_MS) {
23
+ await AsyncStorage.removeItem(PENDING_NOTIFICATION_JID_KEY);
24
+ return null;
25
+ }
26
+ return jid;
27
+ }
28
+ return raw;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ export const openRoomFromPush = (jid: string): 'room' | 'pending' => {
35
+ const state = store.getState();
36
+ const conference = state.chatSettingStore?.config?.xmppSettings?.conference;
37
+ const full = normalizeRoomJid(jid, conference);
38
+
39
+ AsyncStorage.setItem(
40
+ PENDING_NOTIFICATION_JID_KEY,
41
+ JSON.stringify({ jid: full, at: Date.now() })
42
+ ).catch(() => undefined);
43
+ if (state.rooms?.rooms?.[full]) {
44
+ store.dispatch(clearPendingNotificationJid());
45
+ store.dispatch(setCurrentRoom({ roomJID: full }));
46
+ return 'room';
47
+ }
48
+ store.dispatch(setPendingNotificationJid(full));
49
+ return 'pending';
50
+ };
51
+
52
+ export const handlePushPayload = (
53
+ data: Record<string, unknown> | null | undefined
54
+ ): PushPayloadOutcome => {
55
+ if (!data) {return 'unknown';}
56
+ if (handleCallPush(data as Record<string, any>)) {return 'call';}
57
+ const jid = data.jid ?? data.chatJid ?? data.roomJid ?? data.roomJID;
58
+ if (typeof jid === 'string' && jid) {
59
+ return openRoomFromPush(jid);
60
+ }
61
+ return 'unknown';
62
+ };
@@ -4,7 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
4
4
  import { RootState } from '../roomStore';
5
5
  import { setCurrentRoom, clearPendingNotificationJid } from '../roomStore/roomsSlice';
6
6
 
7
- const PENDING_NOTIFICATION_JID_KEY = 'ethora_pending_notification_jid';
7
+ import { PENDING_NOTIFICATION_JID_KEY, readPendingJid } from '../helpers/pushPayload';
8
8
 
9
9
  export function usePendingNotification() {
10
10
  const dispatch = useDispatch();
@@ -21,21 +21,15 @@ export function usePendingNotification() {
21
21
  let jidToOpen = pendingNotificationJid;
22
22
 
23
23
  if (!jidToOpen) {
24
- try {
25
- const storedJid = await AsyncStorage.getItem(PENDING_NOTIFICATION_JID_KEY);
26
- if (storedJid) {
27
- jidToOpen = storedJid;
28
- await AsyncStorage.removeItem(PENDING_NOTIFICATION_JID_KEY);
29
- }
30
- } catch (error) {
31
- console.error('Error reading from AsyncStorage:', error);
32
- }
24
+ jidToOpen = await readPendingJid();
33
25
  }
34
26
 
35
27
  if (jidToOpen) {
36
28
  const room = rooms[jidToOpen];
37
29
  if (room) {
38
- return;
30
+ dispatch(clearPendingNotificationJid());
31
+ AsyncStorage.removeItem(PENDING_NOTIFICATION_JID_KEY).catch(() => undefined);
32
+ dispatch(setCurrentRoom({ roomJID: jidToOpen }));
39
33
  }
40
34
  }
41
35
  };
@@ -81,6 +81,9 @@ const en: UiStringTable = {
81
81
  'action.save': 'Save',
82
82
  'action.delete': 'Delete',
83
83
  'action.leave': 'Leave',
84
+ 'action.muted': 'Muted',
85
+ 'action.unmuted': 'Unmuted',
86
+ 'toast.muteFailed': 'Could not update notifications for this chat',
84
87
  'action.create': 'Create',
85
88
  'action.submit': 'Submit',
86
89
  'action.translate': 'Translate',
@@ -309,6 +312,9 @@ const fr: UiStringTable = {
309
312
  'action.save': 'Enregistrer',
310
313
  'action.delete': 'Supprimer',
311
314
  'action.leave': 'Quitter',
315
+ 'action.muted': 'En sourdine',
316
+ 'action.unmuted': 'Activé',
317
+ 'toast.muteFailed': 'Impossible de modifier les notifications de ce chat',
312
318
  'action.create': 'Créer',
313
319
  'action.submit': 'Soumettre',
314
320
  'action.translate': 'Traduire',
@@ -537,6 +543,9 @@ const es: UiStringTable = {
537
543
  'action.save': 'Guardar',
538
544
  'action.delete': 'Eliminar',
539
545
  'action.leave': 'Salir',
546
+ 'action.muted': 'Silenciado',
547
+ 'action.unmuted': 'Activado',
548
+ 'toast.muteFailed': 'No se pudieron cambiar las notificaciones de este chat',
540
549
  'action.create': 'Crear',
541
550
  'action.submit': 'Enviar',
542
551
  'action.translate': 'Traducir',
@@ -765,6 +774,9 @@ const pt: UiStringTable = {
765
774
  'action.save': 'Guardar',
766
775
  'action.delete': 'Eliminar',
767
776
  'action.leave': 'Sair',
777
+ 'action.muted': 'Muted',
778
+ 'action.unmuted': 'Unmuted',
779
+ 'toast.muteFailed': 'Could not update notifications for this chat',
768
780
  'action.create': 'Criar',
769
781
  'action.submit': 'Enviar',
770
782
  'action.translate': 'Traduzir',
@@ -993,6 +1005,9 @@ const ht: UiStringTable = {
993
1005
  'action.save': 'Anrejistre',
994
1006
  'action.delete': 'Efase',
995
1007
  'action.leave': 'Kite',
1008
+ 'action.muted': 'Muted',
1009
+ 'action.unmuted': 'Unmuted',
1010
+ 'toast.muteFailed': 'Could not update notifications for this chat',
996
1011
  'action.create': 'Kreye',
997
1012
  'action.submit': 'Soumèt',
998
1013
  'action.translate': 'Tradui',
@@ -1221,6 +1236,9 @@ const zh: UiStringTable = {
1221
1236
  'action.save': '保存',
1222
1237
  'action.delete': '删除',
1223
1238
  'action.leave': '退出',
1239
+ 'action.muted': 'Muted',
1240
+ 'action.unmuted': 'Unmuted',
1241
+ 'toast.muteFailed': 'Could not update notifications for this chat',
1224
1242
  'action.create': '创建',
1225
1243
  'action.submit': '提交',
1226
1244
  'action.translate': '翻译',
package/src/main.ts CHANGED
@@ -31,6 +31,9 @@ export { XmppProvider } from './context/xmppProvider';
31
31
 
32
32
  // Hooks that consumers usually need (badge counts, logout, push, etc.).
33
33
  export { useUnread } from './hooks/useUnreadMessagesCounter';
34
+
35
+ export { handlePushPayload, openRoomFromPush } from './helpers/pushPayload';
36
+ export type { PushPayloadOutcome } from './helpers/pushPayload';
34
37
  export { useChatRoomFocus } from './hooks/useChatRoomFocus';
35
38
  export { logoutService, useLogout } from './hooks/useLogout';
36
39
  export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
@@ -130,6 +130,7 @@ function dispatchRoomsFromRestItems(items: ApiRoom[]): void {
130
130
  icon: item.picture || item.icon,
131
131
  createdAt: item.createdAt,
132
132
  updatedAt: item.updatedAt,
133
+ muted: item.muted === true,
133
134
  description: (item as any).description,
134
135
  type: (item as any).type,
135
136
  // ChatProfileModal renders this list under the description/type
@@ -254,6 +255,14 @@ export async function postPrivateRoom(
254
255
  }
255
256
  }
256
257
 
258
+ export async function setRoomMuted(chatName: string, muted: boolean): Promise<boolean> {
259
+ const token = store.getState().chatSettingStore.user.token || '';
260
+ const url = `/v1/chats/my/${encodeURIComponent(chatName)}/mute`;
261
+ const config = { headers: { Authorization: token } };
262
+ const response = muted ? await http.put(url, {}, config) : await http.delete(url, config);
263
+ return response.data?.result?.muted ?? muted;
264
+ }
265
+
257
266
  export async function postReportRoom(data: PostReportRoom) {
258
267
  const { chatName, category, text } = data;
259
268
  const token = store.getState().chatSettingStore.user.token || '';
@@ -2,12 +2,14 @@ import { Client, xml } from '@xmpp/client';
2
2
  import { Element } from '@xmpp/xml';
3
3
  import { createTimeoutPromise } from './createTimeoutPromise.xmpp';
4
4
 
5
+ let subscribeIdCounter = 0;
6
+
5
7
  export async function subscribeToRoomMessages(
6
8
  client: Client,
7
9
  roomJID: string,
8
10
  userNick?: string
9
11
  ): Promise<boolean> {
10
- const id = `newSubscription:${Date.now().toString()}`;
12
+ const id = `newSubscription:${Date.now().toString(36)}-${(++subscribeIdCounter).toString(36)}`;
11
13
  let stanzaHandler: (stanza: Element) => void;
12
14
 
13
15
  const unsubscribe = () => {
@@ -33,6 +35,15 @@ export async function subscribeToRoomMessages(
33
35
  if (stanza.attrs.type === 'result') {
34
36
  finish(resolve, true);
35
37
  } else if (stanza.attrs.type === 'error') {
38
+ const errorEl = typeof stanza.getChild === 'function' ? stanza.getChild('error') : null;
39
+ const condition = errorEl?.children?.find(
40
+ (c: any) => c?.name && c.name !== 'text'
41
+ ) as Element | undefined;
42
+ console.warn(
43
+ '[PushService] MucSub subscribe error for',
44
+ roomJID,
45
+ condition?.name ?? 'unknown'
46
+ );
36
47
  finish(resolve, false);
37
48
  }
38
49
  }
@@ -342,6 +342,7 @@ export class XmppClient {
342
342
  }
343
343
 
344
344
  initializeClient() {
345
+ this.clientAlreadyStopped = false;
345
346
  try {
346
347
  const devServer = this.devServer || DEFAULT_DEV_SERVER;
347
348
  const url = `wss://${devServer}/ws`;
@@ -752,7 +753,10 @@ export class XmppClient {
752
753
  // `this.client` still points at the old client — i.e. before
753
754
  // initializeClient() reassigns it.
754
755
  const old = this.client;
755
- if (old) {
756
+ if (old && this.clientAlreadyStopped) {
757
+ this.detachEventListeners();
758
+ this.clientAlreadyStopped = false;
759
+ } else if (old) {
756
760
  this.detachEventListeners();
757
761
  // NEVER await a bare stop(): on a half-dead socket (the exact case
758
762
  // we're reconnecting for) @xmpp/client's stop() can hang forever
@@ -767,6 +771,9 @@ export class XmppClient {
767
771
  await withTimeout(old.stop(), STOP_TIMEOUT_MS);
768
772
  }
769
773
  this.initializeClient();
774
+ if (this.suspendedForBackground) {
775
+ await this.stopClientQuietly();
776
+ }
770
777
  } finally {
771
778
  this.reconnecting = false;
772
779
  }
@@ -804,6 +811,56 @@ export class XmppClient {
804
811
  return this.credentialsRefreshInFlight;
805
812
  }
806
813
 
814
+ private suspendedForBackground = false;
815
+ private clientAlreadyStopped = false;
816
+
817
+ private async stopClientQuietly(): Promise<void> {
818
+ const c: any = this.client;
819
+ if (c) {
820
+ const sock = c.socket;
821
+ if (sock && typeof sock.end === 'function') {
822
+ try {
823
+ sock.end();
824
+ } catch {}
825
+ try {
826
+ if (c.status !== 'offline') c._status?.('offline');
827
+ } catch {}
828
+ } else {
829
+ await withTimeout(
830
+ Promise.resolve(c.stop()).catch(() => undefined),
831
+ STOP_TIMEOUT_MS
832
+ );
833
+ }
834
+ this.clientAlreadyStopped = true;
835
+ }
836
+ this.status = 'offline';
837
+ this.presencesReady = false;
838
+ }
839
+
840
+ async suspend(): Promise<void> {
841
+ if (this.suspendedForBackground) {return;}
842
+ this.suspendedForBackground = true;
843
+ this.suppressReconnect = true;
844
+ if (this.reconnectTimer) {
845
+ clearTimeout(this.reconnectTimer);
846
+ this.reconnectTimer = null;
847
+ }
848
+ await this.stopClientQuietly();
849
+ console.log('XMPP suspended (background).');
850
+ }
851
+
852
+ /** Back in the foreground: allow reconnects again and reconnect at once. */
853
+ resume(): void {
854
+ if (!this.suspendedForBackground) {return;}
855
+ this.suspendedForBackground = false;
856
+ this.suppressReconnect = false;
857
+ this.forceReconnect();
858
+ }
859
+
860
+ get isSuspended(): boolean {
861
+ return this.suspendedForBackground;
862
+ }
863
+
807
864
  async disconnect(options?: { suppressReconnect?: boolean }): Promise<void> {
808
865
  if (options?.suppressReconnect) {this.suppressReconnect = true;}
809
866
  return this.close();
@@ -81,7 +81,9 @@ export class PushSubscriptionService {
81
81
  this.subscribedRooms.add(roomJID);
82
82
  await this.saveSubscribedRoomsToStorage();
83
83
  }
84
- console.log('Successfully Subscribed to room', roomJID);
84
+ if (!result) {
85
+ console.warn('[PushService] MucSub subscribe refused for', roomJID);
86
+ }
85
87
  return result;
86
88
  } catch (error) {
87
89
  console.error(`[PushService] Failed to subscribe to room ${roomJID}:`, error);
@@ -92,30 +94,33 @@ export class PushSubscriptionService {
92
94
  async subscribeToRooms(
93
95
  client: Client,
94
96
  roomJIDs: string[],
95
- userNick?: string
97
+ userNick?: string,
98
+ concurrency = 8
96
99
  ): Promise<void> {
97
100
  let successful = 0;
98
101
  let failed = 0;
99
102
 
100
- console.log('test roomJIDs', roomJIDs);
103
+ await this.loadSubscribedRoomsFromStorage();
104
+ const pending = roomJIDs.filter((jid) => !this.subscribedRooms.has(jid));
105
+ if (!pending.length) {
106
+ return;
107
+ }
101
108
 
102
- for (const roomJID of roomJIDs) {
103
- try {
104
- const result = await this.subscribeToRoom(client, roomJID, userNick);
105
- console.log('test roomJID-1', result);
106
- if (result) {
109
+ for (let i = 0; i < pending.length; i += concurrency) {
110
+ const batch = pending.slice(i, i + concurrency);
111
+ const results = await Promise.allSettled(
112
+ batch.map((roomJID) => this.subscribeToRoom(client, roomJID, userNick))
113
+ );
114
+ for (const r of results) {
115
+ if (r.status === 'fulfilled' && r.value) {
107
116
  successful++;
108
117
  } else {
109
118
  failed++;
110
119
  }
111
- await new Promise(resolve => setTimeout(resolve, 100));
112
- } catch (error) {
113
- failed++;
114
- console.error(`Failed to subscribe to room ${roomJID}:`, error);
115
120
  }
116
121
  }
117
122
 
118
- console.log(`Subscribed to ${successful} rooms, ${failed} failed`);
123
+ console.log(`[PushService] MucSub: ${successful} rooms subscribed, ${failed} failed`);
119
124
  }
120
125
 
121
126
  async reset(): Promise<void> {
@@ -79,6 +79,7 @@ export interface ApiRoom {
79
79
  jid?: string;
80
80
  participants?: number;
81
81
  icon?: string;
82
+ muted?: boolean;
82
83
  }
83
84
 
84
85
  export interface PostRoom {
@@ -34,6 +34,7 @@ export interface IRoom {
34
34
  lastViewedTimestamp?: number;
35
35
  unreadMessages?: number;
36
36
  noMessages?: boolean;
37
+ muted?: boolean;
37
38
  role?: string;
38
39
  createdAt?: string | number | Date;
39
40
  updatedAt?: string | number | Date;
@@ -151,6 +152,7 @@ export interface xmppSettingsInterface {
151
152
  disableLastRead?: boolean;
152
153
  xmppPingOnSendEnabled?: boolean;
153
154
  historyQoS?: HistoryQoSConfig;
155
+ keepAliveInBackground?: boolean;
154
156
  }
155
157
 
156
158
  export interface InAppNotificationConfig {