@ethora/chat-component-rn 26.5.17 → 26.5.19

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 (201) hide show
  1. package/lib/commonjs/assets/EmptyChatIllustration.js +106 -0
  2. package/lib/commonjs/assets/EmptyChatIllustration.js.map +1 -0
  3. package/lib/commonjs/assets/icons.js +13 -2
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/DropdownMenu/DropdownMenu.js +2 -1
  6. package/lib/commonjs/components/DropdownMenu/DropdownMenu.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatHeader.js +4 -1
  8. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ChatRoom.js +5 -2
  10. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  11. package/lib/commonjs/components/MainComponents/ChooseChatMessage.js +9 -2
  12. package/lib/commonjs/components/MainComponents/ChooseChatMessage.js.map +1 -1
  13. package/lib/commonjs/components/MainComponents/EditWrapper.js +12 -4
  14. package/lib/commonjs/components/MainComponents/EditWrapper.js.map +1 -1
  15. package/lib/commonjs/components/MainComponents/MessageList.js +2 -1
  16. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  17. package/lib/commonjs/components/MainComponents/NoMessagesPlaceholder.js +11 -3
  18. package/lib/commonjs/components/MainComponents/NoMessagesPlaceholder.js.map +1 -1
  19. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js +12 -7
  20. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  21. package/lib/commonjs/components/MainComponents/RoomList.js +2 -1
  22. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  23. package/lib/commonjs/components/MessageBubble/Avatar.js +16 -7
  24. package/lib/commonjs/components/MessageBubble/Avatar.js.map +1 -1
  25. package/lib/commonjs/components/MessageBubble/Message.js +19 -1
  26. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  27. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +7 -5
  28. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  29. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +4 -2
  30. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  31. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  32. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  33. package/lib/commonjs/components/styled/Button.js +4 -2
  34. package/lib/commonjs/components/styled/Button.js.map +1 -1
  35. package/lib/commonjs/components/styled/DateLabel.js +12 -4
  36. package/lib/commonjs/components/styled/DateLabel.js.map +1 -1
  37. package/lib/commonjs/components/styled/SendInput.js +14 -10
  38. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  39. package/lib/commonjs/context/xmppProvider.js +8 -1
  40. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  41. package/lib/commonjs/helpers/getAvatarColor.js +33 -0
  42. package/lib/commonjs/helpers/getAvatarColor.js.map +1 -0
  43. package/lib/commonjs/helpers/getDateLabelColor.js +9 -0
  44. package/lib/commonjs/helpers/getDateLabelColor.js.map +1 -0
  45. package/lib/commonjs/helpers/getElementFont.js +29 -0
  46. package/lib/commonjs/helpers/getElementFont.js.map +1 -0
  47. package/lib/commonjs/helpers/getIconColor.js +9 -0
  48. package/lib/commonjs/helpers/getIconColor.js.map +1 -0
  49. package/lib/commonjs/helpers/getSenderNameColor.js +9 -0
  50. package/lib/commonjs/helpers/getSenderNameColor.js.map +1 -0
  51. package/lib/commonjs/hooks/useSendMessage.js +73 -23
  52. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  53. package/lib/commonjs/networking/outboundQueue.js +23 -5
  54. package/lib/commonjs/networking/outboundQueue.js.map +1 -1
  55. package/lib/commonjs/networking/xmppClient.js +50 -0
  56. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  57. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js +22 -0
  58. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  59. package/lib/commonjs/roomStore/roomsSlice.js +13 -1
  60. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  61. package/lib/module/assets/EmptyChatIllustration.js +99 -0
  62. package/lib/module/assets/EmptyChatIllustration.js.map +1 -0
  63. package/lib/module/assets/icons.js +13 -2
  64. package/lib/module/assets/icons.js.map +1 -1
  65. package/lib/module/components/DropdownMenu/DropdownMenu.js +2 -1
  66. package/lib/module/components/DropdownMenu/DropdownMenu.js.map +1 -1
  67. package/lib/module/components/MainComponents/ChatHeader.js +4 -1
  68. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  69. package/lib/module/components/MainComponents/ChatRoom.js +5 -2
  70. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  71. package/lib/module/components/MainComponents/ChooseChatMessage.js +9 -2
  72. package/lib/module/components/MainComponents/ChooseChatMessage.js.map +1 -1
  73. package/lib/module/components/MainComponents/EditWrapper.js +12 -4
  74. package/lib/module/components/MainComponents/EditWrapper.js.map +1 -1
  75. package/lib/module/components/MainComponents/MessageList.js +2 -1
  76. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  77. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js +11 -3
  78. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js.map +1 -1
  79. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +12 -7
  80. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  81. package/lib/module/components/MainComponents/RoomList.js +2 -1
  82. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  83. package/lib/module/components/MessageBubble/Avatar.js +16 -7
  84. package/lib/module/components/MessageBubble/Avatar.js.map +1 -1
  85. package/lib/module/components/MessageBubble/Message.js +19 -1
  86. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  87. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +7 -5
  88. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  89. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +4 -2
  90. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  91. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  92. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  93. package/lib/module/components/styled/Button.js +4 -2
  94. package/lib/module/components/styled/Button.js.map +1 -1
  95. package/lib/module/components/styled/DateLabel.js +12 -4
  96. package/lib/module/components/styled/DateLabel.js.map +1 -1
  97. package/lib/module/components/styled/SendInput.js +14 -10
  98. package/lib/module/components/styled/SendInput.js.map +1 -1
  99. package/lib/module/context/xmppProvider.js +8 -1
  100. package/lib/module/context/xmppProvider.js.map +1 -1
  101. package/lib/module/helpers/getAvatarColor.js +28 -0
  102. package/lib/module/helpers/getAvatarColor.js.map +1 -0
  103. package/lib/module/helpers/getDateLabelColor.js +4 -0
  104. package/lib/module/helpers/getDateLabelColor.js.map +1 -0
  105. package/lib/module/helpers/getElementFont.js +24 -0
  106. package/lib/module/helpers/getElementFont.js.map +1 -0
  107. package/lib/module/helpers/getIconColor.js +4 -0
  108. package/lib/module/helpers/getIconColor.js.map +1 -0
  109. package/lib/module/helpers/getSenderNameColor.js +4 -0
  110. package/lib/module/helpers/getSenderNameColor.js.map +1 -0
  111. package/lib/module/hooks/useSendMessage.js +73 -23
  112. package/lib/module/hooks/useSendMessage.js.map +1 -1
  113. package/lib/module/networking/outboundQueue.js +22 -5
  114. package/lib/module/networking/outboundQueue.js.map +1 -1
  115. package/lib/module/networking/xmppClient.js +50 -0
  116. package/lib/module/networking/xmppClient.js.map +1 -1
  117. package/lib/module/roomStore/Middleware/newMessageMidlleware.js +22 -0
  118. package/lib/module/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  119. package/lib/module/roomStore/roomsSlice.js +13 -1
  120. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  121. package/lib/typescript/assets/EmptyChatIllustration.d.ts +10 -0
  122. package/lib/typescript/assets/EmptyChatIllustration.d.ts.map +1 -0
  123. package/lib/typescript/assets/icons.d.ts +3 -12
  124. package/lib/typescript/assets/icons.d.ts.map +1 -1
  125. package/lib/typescript/components/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  126. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  127. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  128. package/lib/typescript/components/MainComponents/ChooseChatMessage.d.ts.map +1 -1
  129. package/lib/typescript/components/MainComponents/EditWrapper.d.ts +5 -1
  130. package/lib/typescript/components/MainComponents/EditWrapper.d.ts.map +1 -1
  131. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  132. package/lib/typescript/components/MainComponents/NoMessagesPlaceholder.d.ts.map +1 -1
  133. package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
  134. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  135. package/lib/typescript/components/MessageBubble/Avatar.d.ts.map +1 -1
  136. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  137. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts +3 -0
  138. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  139. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  140. package/lib/typescript/components/Modals/ModalWrapper/ModalWrapper.d.ts.map +1 -1
  141. package/lib/typescript/components/styled/Button.d.ts +1 -0
  142. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  143. package/lib/typescript/components/styled/DateLabel.d.ts +9 -8
  144. package/lib/typescript/components/styled/DateLabel.d.ts.map +1 -1
  145. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  146. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  147. package/lib/typescript/helpers/getAvatarColor.d.ts +7 -0
  148. package/lib/typescript/helpers/getAvatarColor.d.ts.map +1 -0
  149. package/lib/typescript/helpers/getDateLabelColor.d.ts +8 -0
  150. package/lib/typescript/helpers/getDateLabelColor.d.ts.map +1 -0
  151. package/lib/typescript/helpers/getElementFont.d.ts +13 -0
  152. package/lib/typescript/helpers/getElementFont.d.ts.map +1 -0
  153. package/lib/typescript/helpers/getIconColor.d.ts +8 -0
  154. package/lib/typescript/helpers/getIconColor.d.ts.map +1 -0
  155. package/lib/typescript/helpers/getSenderNameColor.d.ts +8 -0
  156. package/lib/typescript/helpers/getSenderNameColor.d.ts.map +1 -0
  157. package/lib/typescript/hooks/useSendMessage.d.ts +1 -1
  158. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  159. package/lib/typescript/networking/outboundQueue.d.ts +17 -2
  160. package/lib/typescript/networking/outboundQueue.d.ts.map +1 -1
  161. package/lib/typescript/networking/xmppClient.d.ts +3 -0
  162. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  163. package/lib/typescript/roomStore/Middleware/newMessageMidlleware.d.ts.map +1 -1
  164. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  165. package/lib/typescript/types/models/message.model.d.ts +1 -0
  166. package/lib/typescript/types/models/message.model.d.ts.map +1 -1
  167. package/lib/typescript/types/types.d.ts +61 -1
  168. package/lib/typescript/types/types.d.ts.map +1 -1
  169. package/package.json +1 -1
  170. package/src/assets/EmptyChatIllustration.tsx +49 -0
  171. package/src/assets/icons.tsx +64 -81
  172. package/src/components/DropdownMenu/DropdownMenu.tsx +2 -1
  173. package/src/components/MainComponents/ChatHeader.tsx +8 -2
  174. package/src/components/MainComponents/ChatRoom.tsx +5 -2
  175. package/src/components/MainComponents/ChooseChatMessage.tsx +5 -2
  176. package/src/components/MainComponents/EditWrapper.tsx +9 -6
  177. package/src/components/MainComponents/MessageList.tsx +2 -1
  178. package/src/components/MainComponents/NoMessagesPlaceholder.tsx +4 -1
  179. package/src/components/MainComponents/ProfileImagePlaceholder.tsx +14 -7
  180. package/src/components/MainComponents/RoomList.tsx +2 -1
  181. package/src/components/MessageBubble/Avatar.tsx +18 -9
  182. package/src/components/MessageBubble/Message.tsx +22 -1
  183. package/src/components/Modals/AttachSheet/AttachSheet.tsx +32 -4
  184. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +10 -2
  185. package/src/components/Modals/ModalWrapper/ModalWrapper.tsx +6 -0
  186. package/src/components/styled/Button.tsx +5 -1
  187. package/src/components/styled/DateLabel.tsx +10 -7
  188. package/src/components/styled/SendInput.tsx +18 -11
  189. package/src/context/xmppProvider.tsx +6 -1
  190. package/src/helpers/getAvatarColor.ts +31 -0
  191. package/src/helpers/getDateLabelColor.ts +3 -0
  192. package/src/helpers/getElementFont.ts +25 -0
  193. package/src/helpers/getIconColor.ts +3 -0
  194. package/src/helpers/getSenderNameColor.ts +3 -0
  195. package/src/hooks/useSendMessage.tsx +102 -59
  196. package/src/networking/outboundQueue.ts +26 -5
  197. package/src/networking/xmppClient.ts +47 -0
  198. package/src/roomStore/Middleware/newMessageMidlleware.tsx +25 -0
  199. package/src/roomStore/roomsSlice.ts +15 -1
  200. package/src/types/models/message.model.ts +3 -0
  201. package/src/types/types.ts +67 -2
@@ -507,9 +507,12 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
507
507
  if (!client) {return;}
508
508
  const id = setInterval(() => {
509
509
  if (AppState.currentState !== 'active') {return;}
510
- if (client.status !== 'online' && !client.suppressReconnect) {
510
+ if (client.suppressReconnect) {return;}
511
+ if (client.status !== 'online') {
511
512
  devPushLog('rn', `watchdog: client ${client.status} → forceReconnect`);
512
513
  client.forceReconnect();
514
+ } else {
515
+ client.ensureStreamAlive?.();
513
516
  }
514
517
  }, 30000);
515
518
  return () => clearInterval(id);
@@ -713,6 +716,8 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
713
716
  if (client.status === 'offline' || client.status === 'error') {
714
717
  devPushLog('rn', `NetInfo: reachable & client ${client.status} → forceReconnect`);
715
718
  client.forceReconnect();
719
+ } else if (client.status === 'online') {
720
+ client.ensureStreamAlive?.();
716
721
  }
717
722
  });
718
723
  return () => unsub();
@@ -0,0 +1,31 @@
1
+ import { nameToColor } from './hashcolor';
2
+
3
+ // Background for initials avatars. When `colors.avatar` is set in the
4
+ // config every avatar uses it; otherwise each user keeps their per-name
5
+ // pastel from the hash palette (the current default).
6
+ export const getAvatarColor = (
7
+ name?: string | null,
8
+ config?: { colors?: { avatar?: string } }
9
+ ) => config?.colors?.avatar || nameToColor(name || '')?.backgroundColor;
10
+
11
+ // The default pastels are all light, so initials are dark (#141414). A
12
+ // configured avatar color may be dark (e.g. brand primary) — flip the
13
+ // initials to white when the background is too dark to read on.
14
+ export const getAvatarTextColor = (bg?: string) => {
15
+ if (!bg || !bg.startsWith('#')) {
16
+ return '#141414';
17
+ }
18
+ let hex = bg.slice(1);
19
+ if (hex.length === 3) {
20
+ hex = hex
21
+ .split('')
22
+ .map((c) => c + c)
23
+ .join('');
24
+ }
25
+ const num = parseInt(hex.slice(0, 6), 16);
26
+ const r = (num >> 16) & 255;
27
+ const g = (num >> 8) & 255;
28
+ const b = num & 255;
29
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
30
+ return luminance > 150 ? '#141414' : '#FFFFFF';
31
+ };
@@ -0,0 +1,3 @@
1
+ export const getDateLabelColor = (config?: {
2
+ colors?: { primary?: string; secondary?: string; dateLabel?: string };
3
+ }) => config?.colors?.dateLabel || config?.colors?.primary || '#0052CD';
@@ -0,0 +1,25 @@
1
+ import type { TextStyle } from 'react-native';
2
+ import type { IConfig, TypographyConfig } from '../types/types';
3
+
4
+ type ElementKey = keyof NonNullable<TypographyConfig['elements']>;
5
+
6
+ /**
7
+ * Per-element font override from `config.typography.elements`, as an RN
8
+ * style object suitable for appending to a component's `style` (so it
9
+ * wins over the built-in size/weight but touches nothing else).
10
+ * Returns undefined when the element isn't configured — appending
11
+ * undefined to a style array is a no-op.
12
+ */
13
+ export const getElementFont = (
14
+ config: IConfig | undefined,
15
+ element: ElementKey
16
+ ): TextStyle | undefined => {
17
+ const override = config?.typography?.elements?.[element];
18
+ if (!override) {return undefined;}
19
+ const style: TextStyle = {};
20
+ if (override.fontSize != null) {style.fontSize = override.fontSize;}
21
+ if (override.fontWeight != null) {style.fontWeight = override.fontWeight;}
22
+ return style.fontSize != null || style.fontWeight != null
23
+ ? style
24
+ : undefined;
25
+ };
@@ -0,0 +1,3 @@
1
+ export const getIconColor = (config?: {
2
+ colors?: { primary?: string; secondary?: string; icon?: string };
3
+ }) => config?.colors?.icon || config?.colors?.primary || '#0052CD';
@@ -0,0 +1,3 @@
1
+ export const getSenderNameColor = (config?: {
2
+ colors?: { primary?: string; secondary?: string; senderName?: string };
3
+ }) => config?.colors?.senderName || config?.colors?.primary || '#0052CD';
@@ -20,10 +20,24 @@ import type { IConfig, IMessage } from '../types/types';
20
20
  // long after the optimistic dispatch, mark it failed so the bubble
21
21
  // flips out of the "sending..." state. Covers the silent-failure
22
22
  // case (e.g. device offline before the message ever made it to the
23
- // XMPP socket) — bug #18. Tuned generously so a brief network blip
24
- // followed by reconnect within 30s still resolves naturally via the
25
- // server echo.
26
- const PENDING_WATCHDOG_MS = 30_000;
23
+ // XMPP socket) — bug #18.
24
+ //
25
+ // 10s window: a brief network blip that reconnects inside this window
26
+ // still resolves on its own — the offline send is buffered in the
27
+ // outbound queue (OUTBOUND_QUEUE_TTL_MS, kept in lockstep) and replayed
28
+ // the moment the stream comes back, so the server echo flips the bubble
29
+ // to delivered. Only a drop that outlasts 10s flips it to "Failed → tap
30
+ // to retry". If a slow send completes just after the window, the
31
+ // delivery clears the failed flag (see newMessageMidlleware) so it
32
+ // doesn't get stuck showing "Failed".
33
+ const PENDING_WATCHDOG_MS = 10_000;
34
+
35
+ // Media gets a longer window than text: the optimistic bubble appears before
36
+ // the file upload even starts, and a large file (up to the 50MB cap) over a
37
+ // slow link can legitimately take far longer than 10s to upload + send. Failing
38
+ // it at 10s mid-upload would flash a wrong "Failed" and let the user kick off a
39
+ // duplicate upload.
40
+ const MEDIA_PENDING_WATCHDOG_MS = 30_000;
27
41
 
28
42
  // Monotonic counter for stanza ids. Bumped on each send within a single
29
43
  // process. Two rapid sends in the same millisecond used to collide on
@@ -76,7 +90,14 @@ export const useSendMessage = (_configOverride?: IConfig) => {
76
90
  activeRoomJID: string,
77
91
  isReply?: boolean,
78
92
  isChecked?: boolean,
79
- mainMessage?: string
93
+ mainMessage?: string,
94
+ // Set by retryMessage to the failed message's own id. When present we
95
+ // re-use that id (and its existing optimistic bubble) instead of minting
96
+ // a fresh one — mirroring sendMedia's `existingId`. Without this, a text
97
+ // retry created a second bubble while the original stayed pending
98
+ // forever, so a failed message that the user retried showed as two
99
+ // bubbles, one stuck on "sending...".
100
+ existingId?: string
80
101
  ) => {
81
102
  if (editAction?.isEdit) {
82
103
  try {
@@ -113,7 +134,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
113
134
  // dedupes by id and flips `pending: false` → DoubleTick renders.
114
135
  // Without this, the user taps send and sees nothing for ~200ms
115
136
  // until the echo lands, which feels broken.
116
- const optimisticId = nextStanzaId('send-text-message');
137
+ const optimisticId = existingId || nextStanzaId('send-text-message');
117
138
  const optimisticDate = new Date().toISOString();
118
139
  const selfId =
119
140
  (user as any)?.xmppUsername || (user as any)?.walletAddress || '';
@@ -135,7 +156,11 @@ export const useSendMessage = (_configOverride?: IConfig) => {
135
156
  name: `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || selfId,
136
157
  } as any,
137
158
  } as IMessage;
138
- if (!config?.disableSentLogic) {
159
+ // On retry (`existingId`) the original optimistic bubble is still in
160
+ // the room (pending:true) — clearMessageFailure just flipped it back out
161
+ // of the "failed" state — so re-adding would duplicate it. Only push a
162
+ // fresh bubble for a brand-new send.
163
+ if (!config?.disableSentLogic && !existingId) {
139
164
  dispatch(
140
165
  addRoomMessage({ roomJID: activeRoomJID, message: optimisticMessage })
141
166
  );
@@ -177,15 +202,54 @@ export const useSendMessage = (_configOverride?: IConfig) => {
177
202
  }, PENDING_WATCHDOG_MS);
178
203
 
179
204
  try {
180
-
181
- const effectiveClient: any =
182
- client ||
183
- [
184
- getGlobalXmppClient(),
185
- store.getState()?.chatSettingStore?.client,
186
- ].find((c: any) => c && c.status === 'online');
205
+ // The single send closure, used both for the immediate send and for
206
+ // the buffered replay. Passing the optimistic id as the stanza id
207
+ // means the server echoes it back with the same value, so the
208
+ // reducer's dedupe matches and the bubble flips pending → delivered
209
+ // in-place (rather than rendering a second copy).
210
+ const replaySend = (c: any) =>
211
+ c.sendMessage(
212
+ activeRoomJID,
213
+ user.firstName,
214
+ user.lastName,
215
+ '',
216
+ user.walletAddress,
217
+ message,
218
+ '',
219
+ isReply || false,
220
+ isChecked || false,
221
+ mainMessage || '',
222
+ optimisticId
223
+ );
224
+
225
+ // Always buffer a copy BEFORE attempting the live send. The WebSocket
226
+ // can still read `status: 'online'` for a beat after the network
227
+ // actually drops (the WS error lands a moment later), so a stanza sent
228
+ // "while online" can vanish into a dying socket and never get echoed.
229
+ // Buffering every send means the onOnline reconnect flush replays
230
+ // anything that wasn't confirmed within the window. The server echo
231
+ // removes the entry (see newMessageMidlleware), so a healthy online
232
+ // send never actually replays — only genuinely-lost ones do.
233
+ enqueueOutboundSend({
234
+ optimisticId,
235
+ roomJID: activeRoomJID,
236
+ enqueuedAt: Date.now(),
237
+ send: replaySend,
238
+ });
239
+
240
+ // Only treat a client as usable if its underlying stream is actually
241
+ // `online` — the instance survives a network drop with a stale
242
+ // 'connecting'/'offline' status, and sending through it would just
243
+ // lose the stanza.
244
+ const effectiveClient: any = [
245
+ client,
246
+ getGlobalXmppClient(),
247
+ store.getState()?.chatSettingStore?.client,
248
+ ].find((c: any) => c && c.status === 'online');
187
249
 
188
250
  if (!effectiveClient) {
251
+ // No live stream — kick a reconnect; the buffered copy above is
252
+ // replayed by the onOnline flush once the stream is back.
189
253
  const anyClient: any =
190
254
  client ||
191
255
  getGlobalXmppClient() ||
@@ -193,45 +257,13 @@ export const useSendMessage = (_configOverride?: IConfig) => {
193
257
  try {
194
258
  anyClient?.forceReconnect?.();
195
259
  } catch {}
196
- enqueueOutboundSend({
197
- optimisticId,
198
- roomJID: activeRoomJID,
199
- enqueuedAt: Date.now(),
200
- send: (c) =>
201
- c.sendMessage(
202
- activeRoomJID,
203
- user.firstName,
204
- user.lastName,
205
- '',
206
- user.walletAddress,
207
- message,
208
- '',
209
- isReply || false,
210
- isChecked || false,
211
- mainMessage || '',
212
- optimisticId
213
- ),
214
- });
215
260
  return;
216
261
  }
217
- // Pass the optimistic id as the stanza id so the server echoes
218
- // it back with the same value — the reducer's dedupe lookup
219
- // matches and the bubble flips from pending → delivered in-place
220
- // (instead of rendering two copies, which is what happens when
221
- // ids don't match).
222
- effectiveClient.sendMessage(
223
- activeRoomJID,
224
- user.firstName,
225
- user.lastName,
226
- '',
227
- user.walletAddress,
228
- message,
229
- '',
230
- isReply || false,
231
- isChecked || false,
232
- mainMessage || '',
233
- optimisticId
234
- );
262
+
263
+ replaySend(effectiveClient);
264
+ try {
265
+ effectiveClient.ensureStreamAlive?.();
266
+ } catch {}
235
267
  await handleMessageSent({
236
268
  message,
237
269
  roomJID: activeRoomJID,
@@ -405,7 +437,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
405
437
  messageType: 'media',
406
438
  });
407
439
  }
408
- }, PENDING_WATCHDOG_MS);
440
+ }, MEDIA_PENDING_WATCHDOG_MS);
409
441
 
410
442
  // Help RN's FormData polyfill build a proper multipart/file part
411
443
  // (instead of serialising the JS object as JSON). On iOS the
@@ -487,12 +519,14 @@ export const useSendMessage = (_configOverride?: IConfig) => {
487
519
  // Stanza id == placeholder id so the MUC echo's outer
488
520
  // <message id="..."> matches xmppId on the placeholder and
489
521
  // insertMessageWithDelimiter merges in place + flips pending.
490
- const effectiveMediaClient: any =
491
- client ||
492
- [
493
- getGlobalXmppClient(),
494
- store.getState()?.chatSettingStore?.client,
495
- ].find((c: any) => c && c.status === 'online');
522
+ // Same online-only guard as the text path: a surviving-but-offline
523
+ // client must NOT be used (the stanza would vanish into a dead
524
+ // socket) — fall through to enqueue so the reconnect flush replays.
525
+ const effectiveMediaClient: any = [
526
+ client,
527
+ getGlobalXmppClient(),
528
+ store.getState()?.chatSettingStore?.client,
529
+ ].find((c: any) => c && c.status === 'online');
496
530
  if (effectiveMediaClient) {
497
531
  effectiveMediaClient.setActiveRoomJid?.(activeRoomJID);
498
532
  await Promise.resolve(
@@ -503,6 +537,9 @@ export const useSendMessage = (_configOverride?: IConfig) => {
503
537
  messagePayload,
504
538
  id
505
539
  );
540
+ try {
541
+ effectiveMediaClient.ensureStreamAlive?.();
542
+ } catch {}
506
543
  } else {
507
544
  const anyMediaClient: any =
508
545
  client ||
@@ -515,6 +552,9 @@ export const useSendMessage = (_configOverride?: IConfig) => {
515
552
  optimisticId: id,
516
553
  roomJID: activeRoomJID,
517
554
  enqueuedAt: Date.now(),
555
+ // Match the media watchdog so a reconnect within the (longer)
556
+ // media window still auto-replays the buffered stanza.
557
+ ttlMs: MEDIA_PENDING_WATCHDOG_MS,
518
558
  send: (c) =>
519
559
  c.sendMediaMessageStanza(activeRoomJID, messagePayload, id),
520
560
  });
@@ -605,7 +645,10 @@ export const useSendMessage = (_configOverride?: IConfig) => {
605
645
  payload.roomJID,
606
646
  payload.isReply,
607
647
  payload.isChecked,
608
- payload.mainMessage
648
+ payload.mainMessage,
649
+ // Re-use the failed message's id so the retry resends in place
650
+ // instead of spawning a duplicate bubble.
651
+ failedId
609
652
  );
610
653
  } else {
611
654
  await sendMedia(
@@ -19,7 +19,7 @@
19
19
  * Each queued item carries a `send(client)` closure that captures the
20
20
  * original args (text or media), so flush is type-agnostic.
21
21
  *
22
- * TTL is deliberately aligned with the 30s pending-send watchdog in
22
+ * TTL is deliberately aligned with the pending-send watchdog in
23
23
  * useSendMessage (PENDING_WATCHDOG_MS): a queued item older than that has
24
24
  * already been — or is about to be — flipped to "Failed → tap to retry" by
25
25
  * the watchdog, so replaying it on a late reconnect would duplicate the
@@ -37,12 +37,21 @@ export interface QueuedSend {
37
37
  roomJID: string;
38
38
  /** Wall-clock ms when first queued; drives the TTL drop on flush. */
39
39
  enqueuedAt: number;
40
+ /**
41
+ * Per-item replay window. Kept in lockstep with the matching send
42
+ * watchdog so a reconnect inside the window replays the buffered send,
43
+ * while a longer drop lets the watchdog own the failure (no duplicate).
44
+ * Text uses 10s, media 30s (large uploads need longer). Falls back to
45
+ * OUTBOUND_QUEUE_TTL_MS when omitted.
46
+ */
47
+ ttlMs?: number;
40
48
  /** Replays the original send against a live, online client. */
41
49
  send: (client: OutboundQueueClient) => void;
42
50
  }
43
51
 
44
- // Kept in lockstep with PENDING_WATCHDOG_MS in useSendMessage.
45
- export const OUTBOUND_QUEUE_TTL_MS = 30_000;
52
+ // Default replay window (text). Mirrors PENDING_WATCHDOG_MS in useSendMessage.
53
+ // Media enqueues a longer per-item ttlMs (see QueuedSend.ttlMs).
54
+ export const OUTBOUND_QUEUE_TTL_MS = 10_000;
46
55
 
47
56
  let queue: QueuedSend[] = [];
48
57
 
@@ -74,8 +83,8 @@ export function flushOutboundSends(
74
83
  const pending = queue;
75
84
  queue = [];
76
85
  for (const item of pending) {
77
- if (now - item.enqueuedAt > OUTBOUND_QUEUE_TTL_MS) {
78
- // Stale — the 30s watchdog owns the failure; don't duplicate.
86
+ if (now - item.enqueuedAt > (item.ttlMs ?? OUTBOUND_QUEUE_TTL_MS)) {
87
+ // Stale — the send watchdog owns the failure; don't duplicate.
79
88
  continue;
80
89
  }
81
90
  try {
@@ -87,6 +96,18 @@ export function flushOutboundSends(
87
96
  }
88
97
  }
89
98
 
99
+ /**
100
+ * Drop a single buffered send by its optimistic/stanza id. Called when a
101
+ * server-confirmed copy of the message arrives (see newMessageMidlleware):
102
+ * the send went through, so the buffered replay is no longer needed and must
103
+ * not fire on a later reconnect. No-op if the id isn't queued.
104
+ */
105
+ export function removeOutboundSend(optimisticId: string): void {
106
+ if (!optimisticId) {return;}
107
+ const idx = queue.findIndex((q) => q.optimisticId === optimisticId);
108
+ if (idx >= 0) {queue.splice(idx, 1);}
109
+ }
110
+
90
111
  /** Drop everything — called on permanent teardown (logout / close). */
91
112
  export function clearOutboundSends(): void {
92
113
  queue = [];
@@ -7,6 +7,7 @@ import { getChatsPrivateStoreRequest } from './xmpp/getChatsPrivateStoreRequest.
7
7
  import { actionSetTimestampToPrivateStore } from './xmpp/actionSetTimestampToPrivateStore.xmpp';
8
8
  import { flushLastViewedToPrivateStore } from './xmpp/flushLastViewedToPrivateStore';
9
9
  import { sendTypingRequest } from './xmpp/sendTypingRequest.xmpp';
10
+ import { sendPing } from './xmpp/sendPing.xmpp';
10
11
  import { getHistory } from './xmpp/getHistory.xmpp';
11
12
  import { sendTextMessage } from './xmpp/sendTextMessage.xmpp';
12
13
  import { deleteMessage } from './xmpp/deleteMessage.xmpp';
@@ -614,6 +615,52 @@ export class XmppClient {
614
615
  this.reconnect();
615
616
  }
616
617
 
618
+ private streamAliveProbe: Promise<boolean> | null = null;
619
+
620
+ verifyStreamAlive(timeoutMs: number = 4000): Promise<boolean> {
621
+ if (this.status !== 'online' || !this.client) {
622
+ return Promise.resolve(false);
623
+ }
624
+ if (this.streamAliveProbe) {return this.streamAliveProbe;}
625
+ const underlying: any = this.client;
626
+ this.streamAliveProbe = new Promise<boolean>((resolve) => {
627
+ let settled = false;
628
+ const done = (alive: boolean) => {
629
+ if (settled) {return;}
630
+ settled = true;
631
+ clearTimeout(timer);
632
+ try {
633
+ underlying.removeListener?.('stanza', onStanza);
634
+ } catch {}
635
+ resolve(alive);
636
+ };
637
+ const onStanza = () => done(true);
638
+ const timer = setTimeout(() => done(false), timeoutMs);
639
+ try {
640
+ underlying.on('stanza', onStanza);
641
+ sendPing(underlying, this.host, `alive-${Date.now()}`);
642
+ } catch {
643
+ // Couldn't even write the probe — treat as dead.
644
+ done(false);
645
+ }
646
+ }).finally(() => {
647
+ this.streamAliveProbe = null;
648
+ }) as Promise<boolean>;
649
+ return this.streamAliveProbe;
650
+ }
651
+
652
+ async ensureStreamAlive(timeoutMs: number = 4000): Promise<void> {
653
+ if (this.suppressReconnect || this.status !== 'online') {return;}
654
+ const alive = await this.verifyStreamAlive(timeoutMs);
655
+ // Re-check state: a reconnect may have started meanwhile.
656
+ if (!alive && this.status === 'online' && !this.suppressReconnect) {
657
+ try {
658
+ devPushLog('xmpp', 'stream is zombie (ping unanswered) → forceReconnect');
659
+ } catch {}
660
+ this.forceReconnect();
661
+ }
662
+ }
663
+
617
664
  async reconnect() {
618
665
  if (this.suppressReconnect) {return;}
619
666
  // Single-flight: ignore overlapping triggers (AppState + NetInfo +
@@ -1,5 +1,7 @@
1
1
  import { Middleware } from '@reduxjs/toolkit';
2
2
  import { updateRoom } from '../roomsSlice';
3
+ import { clearMessageFailure } from '../roomHeapSlice';
4
+ import { removeOutboundSend } from '../../networking/outboundQueue';
3
5
  import { IRoom } from '../../types/types';
4
6
 
5
7
  export const newMessageMidlleware: Middleware =
@@ -24,6 +26,29 @@ export const newMessageMidlleware: Middleware =
24
26
 
25
27
  const { roomJID, message } = action.payload;
26
28
 
29
+ // Un-fail on confirmed delivery. A server-confirmed copy (echo or MAM
30
+ // history, never `pending`) arriving for a message we'd flagged failed
31
+ // means the send actually went through — e.g. a slow send that completed
32
+ // just after the 5s watchdog window, or a queued auto-resend landing on
33
+ // reconnect. The failed flag is keyed by our optimistic/stanza id, which
34
+ // the echo carries back as `id`/`xmppId`, so clear it here and let the
35
+ // bubble flip to delivered instead of staying stuck on "Failed".
36
+ if (message && !message.pending) {
37
+ // The confirmed copy carries our optimistic/stanza id back as id/xmppId.
38
+ // Drop any still-buffered replay for it so a later reconnect flush can't
39
+ // re-send an already-delivered message.
40
+ removeOutboundSend(message.id);
41
+ removeOutboundSend(message.xmppId);
42
+
43
+ const failedMap = state.roomHeapSlice?.failedMessages || {};
44
+ const failedKey = [message.id, message.xmppId].find(
45
+ (k: string | undefined) => k && failedMap[k]
46
+ );
47
+ if (failedKey) {
48
+ storeAPI.dispatch(clearMessageFailure(failedKey as string));
49
+ }
50
+ }
51
+
27
52
  if ((rooms[roomJID]?.lastMessageTimestamp ?? 0) <= Number(message.id)) {
28
53
  storeAPI.dispatch(
29
54
  updateRoom({
@@ -76,7 +76,17 @@ function mergeHistoryIntoCache(
76
76
  // the freshest server state (reactions / edits / deletions).
77
77
  const byId = new Map<string, IMessage>();
78
78
  for (const m of realExisting) {byId.set(String(m.id), m);}
79
- for (const m of realFetched) {byId.set(String(m.id), m);}
79
+ for (const m of realFetched) {
80
+ // Fetched wins on body/reactions/etc., but MAM doesn't carry our
81
+ // client-side "edited" flag (the archived stanza just has the corrected
82
+ // body). Preserve `isEdited` from the cached copy so a message edited
83
+ // before reload keeps its marker instead of losing it on history sync.
84
+ const prev = byId.get(String(m.id));
85
+ byId.set(
86
+ String(m.id),
87
+ prev?.isEdited && !m.isEdited ? { ...m, isEdited: true } : m
88
+ );
89
+ }
80
90
  const merged = Array.from(byId.values()).sort(byMs);
81
91
  return [...capTail(merged), ...pending];
82
92
  }
@@ -307,6 +317,10 @@ const reducers = {
307
317
  state.rooms[roomJID].messages.map((message) => {
308
318
  if (message.id === messageId) {
309
319
  message.body = text;
320
+ // Flag the correction so the bubble can render an "edited" marker.
321
+ // Covers both the author's own edit and edits from other users —
322
+ // every <replace> echo flows through here.
323
+ message.isEdited = true;
310
324
  }
311
325
  });
312
326
  }
@@ -33,6 +33,9 @@ export interface IMessage {
33
33
  xmppId?: string;
34
34
  xmppFrom?: string;
35
35
  deleted?: boolean;
36
+ // Set once a message has been corrected via XEP-0308 (<replace>), so the
37
+ // bubble can show a small "edited" marker (Slack/WhatsApp style).
38
+ isEdited?: boolean;
36
39
  }
37
40
 
38
41
  export interface ReactionMessage {
@@ -1,4 +1,4 @@
1
- import type { ViewStyle, ImageSourcePropType } from 'react-native';
1
+ import type { ViewStyle, TextStyle, ImageSourcePropType } from 'react-native';
2
2
  import type { Iso639_1Codes } from './models/language.model';
3
3
  import type { IMessage, IReply } from './models/message.model';
4
4
  import type { RoomMember } from './models/room.model';
@@ -260,6 +260,52 @@ export interface TypographyConfig {
260
260
  };
261
261
  /** Fonts for the SDK to load with expo-font before applying `fontFamily`. */
262
262
  fonts?: RNFontSource[];
263
+ /** Per-element size / weight overrides. Each entry replaces only the
264
+ * properties it sets; everything else keeps the built-in style. */
265
+ elements?: {
266
+ /** Message body text inside bubbles (default 16). */
267
+ messageText?: ElementFontStyle;
268
+ /** Sender's name above incoming bubbles (default 14 / 500). */
269
+ senderName?: ElementFontStyle;
270
+ /** Room title in the chat header (default 16 / 600). */
271
+ chatHeaderTitle?: ElementFontStyle;
272
+ /** Typed text AND the "Type message" placeholder (default 16).
273
+ * Pair larger sizes with `inputLayout.minHeight` so glyphs aren't
274
+ * clipped vertically. */
275
+ input?: ElementFontStyle;
276
+ /** Room name on the chat-profile screen (default 24 / 400). */
277
+ chatProfileTitle?: ElementFontStyle;
278
+ /** Member names in the chat-profile list (default 16 / 600). */
279
+ chatProfileMemberName?: ElementFontStyle;
280
+ /** "ATTACH" caption of the attachment sheet (default 13 / 600). */
281
+ attachSheetTitle?: ElementFontStyle;
282
+ /** Row labels in the attachment sheet — "Take photo", "Photo or
283
+ * video", "Document" (default 16 / 500). */
284
+ attachSheetRowLabel?: ElementFontStyle;
285
+ /** Row hints under each label (default 12). */
286
+ attachSheetRowHint?: ElementFontStyle;
287
+ /** "Cancel" button of the attachment sheet (default 16 / 600). */
288
+ attachSheetCancel?: ElementFontStyle;
289
+ };
290
+ }
291
+
292
+ /** Size/weight override for one chat-UI text element. */
293
+ export interface ElementFontStyle {
294
+ fontSize?: number;
295
+ fontWeight?: TextStyle['fontWeight'];
296
+ }
297
+
298
+ /**
299
+ * Geometry of the message-input row. Raise `minHeight` together with
300
+ * `typography.elements.input.fontSize` so larger text isn't clipped.
301
+ */
302
+ export interface InputLayoutConfig {
303
+ /** Min height of the text field, also its single-line height (default 40). */
304
+ minHeight?: number;
305
+ /** Height at which the field stops growing and starts scrolling (default 120). */
306
+ maxHeight?: number;
307
+ /** Diameter of the round send / mic button (default 40). */
308
+ sendButtonSize?: number;
263
309
  }
264
310
 
265
311
  export interface IConfig {
@@ -270,9 +316,28 @@ export interface IConfig {
270
316
  projectName?: string;
271
317
 
272
318
  // ----- theming -----
273
- colors?: { primary: string; secondary: string };
319
+ colors?: {
320
+ primary: string;
321
+ secondary: string;
322
+ /** Tint for chrome icons (attach, mic/send, burger menu, edit banner,
323
+ * scroll-to-bottom FAB). Falls back to `primary`, then `#0052CD`. */
324
+ icon?: string;
325
+ /** Color of the sender's name above incoming message bubbles.
326
+ * Falls back to `primary`, then `#0052CD`. */
327
+ senderName?: string;
328
+ /** Background of initials avatars (message bubbles, chat header,
329
+ * profile modals). When omitted, each user keeps their per-name
330
+ * pastel color from the hash palette (current default). */
331
+ avatar?: string;
332
+ /** Text color of the day-separator pill ("Today", "June 8") in the
333
+ * message list; the pill background is a light tint of it. Falls
334
+ * back to `primary`, then `#0052CD`. */
335
+ dateLabel?: string;
336
+ };
274
337
  /** Configurable font family / weights for the chat UI. See TypographyConfig. */
275
338
  typography?: TypographyConfig;
339
+ /** Input-row geometry (field height, send-button size). See InputLayoutConfig. */
340
+ inputLayout?: InputLayoutConfig;
276
341
  messageColor?: {
277
342
  backgroundMessage: string;
278
343
  backgroundMessageUser: string;