@ethora/chat-component-rn 26.5.16 → 26.5.18

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 (215) 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 +2 -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/ReduxWrapper.js +4 -0
  22. package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
  23. package/lib/commonjs/components/MainComponents/RoomList.js +2 -1
  24. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  25. package/lib/commonjs/components/MessageBubble/Avatar.js +16 -7
  26. package/lib/commonjs/components/MessageBubble/Avatar.js.map +1 -1
  27. package/lib/commonjs/components/MessageBubble/Message.js +16 -1
  28. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  29. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  30. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  31. package/lib/commonjs/components/styled/Button.js +4 -2
  32. package/lib/commonjs/components/styled/Button.js.map +1 -1
  33. package/lib/commonjs/components/styled/DateLabel.js +12 -4
  34. package/lib/commonjs/components/styled/DateLabel.js.map +1 -1
  35. package/lib/commonjs/components/styled/SendInput.js +4 -3
  36. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  37. package/lib/commonjs/context/xmppProvider.js +12 -1
  38. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  39. package/lib/commonjs/helpers/getAvatarColor.js +33 -0
  40. package/lib/commonjs/helpers/getAvatarColor.js.map +1 -0
  41. package/lib/commonjs/helpers/getDateLabelColor.js +9 -0
  42. package/lib/commonjs/helpers/getDateLabelColor.js.map +1 -0
  43. package/lib/commonjs/helpers/getIconColor.js +9 -0
  44. package/lib/commonjs/helpers/getIconColor.js.map +1 -0
  45. package/lib/commonjs/helpers/getSenderNameColor.js +9 -0
  46. package/lib/commonjs/helpers/getSenderNameColor.js.map +1 -0
  47. package/lib/commonjs/helpers/historyPreloadScheduler.js +118 -113
  48. package/lib/commonjs/helpers/historyPreloadScheduler.js.map +1 -1
  49. package/lib/commonjs/hooks/useChatFonts.js +137 -0
  50. package/lib/commonjs/hooks/useChatFonts.js.map +1 -0
  51. package/lib/commonjs/hooks/useSendMessage.js +72 -22
  52. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  53. package/lib/commonjs/hooks/useUnreadMessagesCounter.js +4 -2
  54. package/lib/commonjs/hooks/useUnreadMessagesCounter.js.map +1 -1
  55. package/lib/commonjs/main.js +7 -0
  56. package/lib/commonjs/main.js.map +1 -1
  57. package/lib/commonjs/networking/outboundQueue.js +22 -4
  58. package/lib/commonjs/networking/outboundQueue.js.map +1 -1
  59. package/lib/commonjs/networking/xmppClient.js +50 -0
  60. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  61. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js +22 -0
  62. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  63. package/lib/commonjs/roomStore/roomsSlice.js +22 -2
  64. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  65. package/lib/module/assets/EmptyChatIllustration.js +99 -0
  66. package/lib/module/assets/EmptyChatIllustration.js.map +1 -0
  67. package/lib/module/assets/icons.js +13 -2
  68. package/lib/module/assets/icons.js.map +1 -1
  69. package/lib/module/components/DropdownMenu/DropdownMenu.js +2 -1
  70. package/lib/module/components/DropdownMenu/DropdownMenu.js.map +1 -1
  71. package/lib/module/components/MainComponents/ChatHeader.js +2 -1
  72. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  73. package/lib/module/components/MainComponents/ChatRoom.js +5 -2
  74. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  75. package/lib/module/components/MainComponents/ChooseChatMessage.js +9 -2
  76. package/lib/module/components/MainComponents/ChooseChatMessage.js.map +1 -1
  77. package/lib/module/components/MainComponents/EditWrapper.js +12 -4
  78. package/lib/module/components/MainComponents/EditWrapper.js.map +1 -1
  79. package/lib/module/components/MainComponents/MessageList.js +2 -1
  80. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  81. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js +11 -3
  82. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js.map +1 -1
  83. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +12 -7
  84. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  85. package/lib/module/components/MainComponents/ReduxWrapper.js +4 -0
  86. package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
  87. package/lib/module/components/MainComponents/RoomList.js +2 -1
  88. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  89. package/lib/module/components/MessageBubble/Avatar.js +16 -7
  90. package/lib/module/components/MessageBubble/Avatar.js.map +1 -1
  91. package/lib/module/components/MessageBubble/Message.js +16 -1
  92. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  93. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  94. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  95. package/lib/module/components/styled/Button.js +4 -2
  96. package/lib/module/components/styled/Button.js.map +1 -1
  97. package/lib/module/components/styled/DateLabel.js +12 -4
  98. package/lib/module/components/styled/DateLabel.js.map +1 -1
  99. package/lib/module/components/styled/SendInput.js +4 -3
  100. package/lib/module/components/styled/SendInput.js.map +1 -1
  101. package/lib/module/context/xmppProvider.js +13 -2
  102. package/lib/module/context/xmppProvider.js.map +1 -1
  103. package/lib/module/helpers/getAvatarColor.js +28 -0
  104. package/lib/module/helpers/getAvatarColor.js.map +1 -0
  105. package/lib/module/helpers/getDateLabelColor.js +4 -0
  106. package/lib/module/helpers/getDateLabelColor.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/helpers/historyPreloadScheduler.js +119 -114
  112. package/lib/module/helpers/historyPreloadScheduler.js.map +1 -1
  113. package/lib/module/hooks/useChatFonts.js +132 -0
  114. package/lib/module/hooks/useChatFonts.js.map +1 -0
  115. package/lib/module/hooks/useSendMessage.js +72 -22
  116. package/lib/module/hooks/useSendMessage.js.map +1 -1
  117. package/lib/module/hooks/useUnreadMessagesCounter.js +4 -2
  118. package/lib/module/hooks/useUnreadMessagesCounter.js.map +1 -1
  119. package/lib/module/main.js +2 -0
  120. package/lib/module/main.js.map +1 -1
  121. package/lib/module/networking/outboundQueue.js +21 -4
  122. package/lib/module/networking/outboundQueue.js.map +1 -1
  123. package/lib/module/networking/xmppClient.js +50 -0
  124. package/lib/module/networking/xmppClient.js.map +1 -1
  125. package/lib/module/roomStore/Middleware/newMessageMidlleware.js +22 -0
  126. package/lib/module/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  127. package/lib/module/roomStore/roomsSlice.js +20 -1
  128. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  129. package/lib/typescript/assets/EmptyChatIllustration.d.ts +10 -0
  130. package/lib/typescript/assets/EmptyChatIllustration.d.ts.map +1 -0
  131. package/lib/typescript/assets/icons.d.ts +3 -12
  132. package/lib/typescript/assets/icons.d.ts.map +1 -1
  133. package/lib/typescript/components/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  134. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  135. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  136. package/lib/typescript/components/MainComponents/ChooseChatMessage.d.ts.map +1 -1
  137. package/lib/typescript/components/MainComponents/EditWrapper.d.ts +5 -1
  138. package/lib/typescript/components/MainComponents/EditWrapper.d.ts.map +1 -1
  139. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  140. package/lib/typescript/components/MainComponents/NoMessagesPlaceholder.d.ts.map +1 -1
  141. package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
  142. package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
  143. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  144. package/lib/typescript/components/MessageBubble/Avatar.d.ts.map +1 -1
  145. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  146. package/lib/typescript/components/Modals/ModalWrapper/ModalWrapper.d.ts.map +1 -1
  147. package/lib/typescript/components/styled/Button.d.ts +1 -0
  148. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  149. package/lib/typescript/components/styled/DateLabel.d.ts +9 -8
  150. package/lib/typescript/components/styled/DateLabel.d.ts.map +1 -1
  151. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  152. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  153. package/lib/typescript/helpers/getAvatarColor.d.ts +7 -0
  154. package/lib/typescript/helpers/getAvatarColor.d.ts.map +1 -0
  155. package/lib/typescript/helpers/getDateLabelColor.d.ts +8 -0
  156. package/lib/typescript/helpers/getDateLabelColor.d.ts.map +1 -0
  157. package/lib/typescript/helpers/getIconColor.d.ts +8 -0
  158. package/lib/typescript/helpers/getIconColor.d.ts.map +1 -0
  159. package/lib/typescript/helpers/getSenderNameColor.d.ts +8 -0
  160. package/lib/typescript/helpers/getSenderNameColor.d.ts.map +1 -0
  161. package/lib/typescript/helpers/historyPreloadScheduler.d.ts.map +1 -1
  162. package/lib/typescript/hooks/useChatFonts.d.ts +23 -0
  163. package/lib/typescript/hooks/useChatFonts.d.ts.map +1 -0
  164. package/lib/typescript/hooks/useSendMessage.d.ts +1 -1
  165. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  166. package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts +1 -0
  167. package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts.map +1 -1
  168. package/lib/typescript/main.d.ts +2 -1
  169. package/lib/typescript/main.d.ts.map +1 -1
  170. package/lib/typescript/networking/outboundQueue.d.ts +16 -1
  171. package/lib/typescript/networking/outboundQueue.d.ts.map +1 -1
  172. package/lib/typescript/networking/xmppClient.d.ts +3 -0
  173. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  174. package/lib/typescript/roomStore/Middleware/newMessageMidlleware.d.ts.map +1 -1
  175. package/lib/typescript/roomStore/roomsSlice.d.ts +3 -1
  176. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  177. package/lib/typescript/types/models/message.model.d.ts +1 -0
  178. package/lib/typescript/types/models/message.model.d.ts.map +1 -1
  179. package/lib/typescript/types/types.d.ts +53 -0
  180. package/lib/typescript/types/types.d.ts.map +1 -1
  181. package/package.json +5 -1
  182. package/src/assets/EmptyChatIllustration.tsx +49 -0
  183. package/src/assets/icons.tsx +64 -81
  184. package/src/components/DropdownMenu/DropdownMenu.tsx +2 -1
  185. package/src/components/MainComponents/ChatHeader.tsx +2 -1
  186. package/src/components/MainComponents/ChatRoom.tsx +5 -2
  187. package/src/components/MainComponents/ChooseChatMessage.tsx +5 -2
  188. package/src/components/MainComponents/EditWrapper.tsx +9 -6
  189. package/src/components/MainComponents/MessageList.tsx +2 -1
  190. package/src/components/MainComponents/NoMessagesPlaceholder.tsx +4 -1
  191. package/src/components/MainComponents/ProfileImagePlaceholder.tsx +14 -7
  192. package/src/components/MainComponents/ReduxWrapper.tsx +4 -0
  193. package/src/components/MainComponents/RoomList.tsx +2 -1
  194. package/src/components/MessageBubble/Avatar.tsx +18 -9
  195. package/src/components/MessageBubble/Message.tsx +16 -1
  196. package/src/components/Modals/ModalWrapper/ModalWrapper.tsx +6 -0
  197. package/src/components/styled/Button.tsx +5 -1
  198. package/src/components/styled/DateLabel.tsx +10 -7
  199. package/src/components/styled/SendInput.tsx +4 -3
  200. package/src/context/xmppProvider.tsx +11 -1
  201. package/src/helpers/getAvatarColor.ts +31 -0
  202. package/src/helpers/getDateLabelColor.ts +3 -0
  203. package/src/helpers/getIconColor.ts +3 -0
  204. package/src/helpers/getSenderNameColor.ts +3 -0
  205. package/src/helpers/historyPreloadScheduler.ts +7 -1
  206. package/src/hooks/useChatFonts.ts +153 -0
  207. package/src/hooks/useSendMessage.tsx +101 -58
  208. package/src/hooks/useUnreadMessagesCounter.ts +5 -2
  209. package/src/main.ts +4 -0
  210. package/src/networking/outboundQueue.ts +25 -4
  211. package/src/networking/xmppClient.ts +47 -0
  212. package/src/roomStore/Middleware/newMessageMidlleware.tsx +25 -0
  213. package/src/roomStore/roomsSlice.ts +26 -1
  214. package/src/types/models/message.model.ts +3 -0
  215. package/src/types/types.ts +59 -1
@@ -0,0 +1,153 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Text, TextInput } from 'react-native';
3
+ import type { TypographyConfig } from '../types/types';
4
+
5
+ /**
6
+ * Loads and applies the host-provided font configuration for the chat UI.
7
+ *
8
+ * React Native has no web-style runtime `@font-face`, so a custom font must be
9
+ * registered with `expo-font` before it can be used. This hook:
10
+ * 1. loads every entry in `typography.fonts` via `expo-font` (remote URL or
11
+ * bundled `require(...)`), then
12
+ * 2. sets the resolved `fontFamily` as the default for all `<Text>` and
13
+ * `<TextInput>` rendered by the chat — mirroring how the web SDK sets a
14
+ * CSS variable. Individual components can still override per-style.
15
+ *
16
+ * `expo-font` is an optional peer dependency: if the host hasn't installed it,
17
+ * the hook degrades gracefully (font simply isn't loaded) instead of throwing.
18
+ *
19
+ * No-op when `typography` is undefined — the system font is kept, so existing
20
+ * integrations are unaffected.
21
+ *
22
+ * @returns `true` once fonts are ready (or when there's nothing to load), so
23
+ * callers can gate first paint to avoid a font "flash" if desired.
24
+ */
25
+ export function useChatFonts(typography?: TypographyConfig): boolean {
26
+ const [ready, setReady] = useState(!typography?.fonts?.length);
27
+
28
+ applyDefaultFont(typography);
29
+
30
+ useEffect(() => {
31
+ let cancelled = false;
32
+
33
+ async function load() {
34
+ // Apply the default family immediately so already-available fonts
35
+ // (system, or host-loaded) take effect without waiting on a download.
36
+ applyDefaultFont(typography);
37
+
38
+ if (!typography?.fonts?.length) {
39
+ setReady(true);
40
+ return;
41
+ }
42
+
43
+ try {
44
+ // Optional dependency — resolve lazily so apps that don't use custom
45
+ // fonts don't need expo-font installed.
46
+ const ExpoFont = await import('expo-font').catch(() => null);
47
+ if (!ExpoFont) {
48
+ if (!cancelled) setReady(true);
49
+ return;
50
+ }
51
+ const map: Record<string, string | number> = {};
52
+ for (const f of typography.fonts) map[f.family] = f.source as never;
53
+ await ExpoFont.loadAsync(map);
54
+ if (!cancelled) {
55
+ applyDefaultFont(typography);
56
+ setReady(true);
57
+ }
58
+ } catch {
59
+ // Loading failed (e.g. bad URL) — keep the fallback font, don't block UI.
60
+ if (!cancelled) setReady(true);
61
+ }
62
+ }
63
+
64
+ load();
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [
70
+ typography?.fontFamily,
71
+ JSON.stringify(typography?.fonts ?? []),
72
+ JSON.stringify(typography?.weightFamilies ?? {}),
73
+ ]);
74
+
75
+ return ready;
76
+ }
77
+
78
+ /**
79
+ * The family currently applied as the chat-wide default. Read at render time
80
+ * by the patched components below, so updating it (then triggering a re-render)
81
+ * restyles the whole tree.
82
+ */
83
+ let activeFamily: string | undefined;
84
+
85
+
86
+ function wrapFactory(orig: any): any {
87
+ if (typeof orig !== 'function') return orig;
88
+ if (orig.__ethoraFontWrapped) return orig;
89
+ const wrapped = function (this: any, type: any, props: any, ...rest: any[]) {
90
+ if (
91
+ activeFamily &&
92
+ props &&
93
+ (type === Text || type === TextInput)
94
+ ) {
95
+ props = { ...props, style: [{ fontFamily: activeFamily }, props.style] };
96
+ }
97
+ return orig.call(this, type, props, ...rest);
98
+ };
99
+ wrapped.__ethoraFontWrapped = true;
100
+ return wrapped;
101
+ }
102
+
103
+ let factoriesPatched = false;
104
+
105
+ function ensurePatched(): void {
106
+ if (factoriesPatched) return;
107
+
108
+ let any = false;
109
+
110
+ const patchMod = (mod: any): void => {
111
+ if (!mod) return;
112
+ for (const fn of ['jsx', 'jsxs', 'jsxDEV']) {
113
+ if (typeof mod[fn] === 'function' && !mod[fn].__ethoraFontWrapped) {
114
+ mod[fn] = wrapFactory(mod[fn]);
115
+ any = true;
116
+ }
117
+ }
118
+ };
119
+
120
+ try {
121
+ patchMod(require('react/jsx-runtime'));
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ try {
126
+ patchMod(require('react/jsx-dev-runtime'));
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+
131
+ try {
132
+ const React = require('react');
133
+ if (
134
+ typeof React.createElement === 'function' &&
135
+ !React.createElement.__ethoraFontWrapped
136
+ ) {
137
+ React.createElement = wrapFactory(React.createElement);
138
+ any = true;
139
+ }
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+
144
+ if (any) factoriesPatched = true;
145
+ }
146
+
147
+ /**
148
+ * Set (or clear) the chat-wide default font family.
149
+ */
150
+ function applyDefaultFont(typography?: TypographyConfig): void {
151
+ activeFamily = typography?.fontFamily;
152
+ if (activeFamily) ensurePatched();
153
+ }
@@ -20,11 +20,25 @@ 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.
23
+ // XMPP socket) — bug #18.
24
+ //
25
+ // 5s 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 5s 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".
26
33
  const PENDING_WATCHDOG_MS = 30_000;
27
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 5s to upload + send. Failing
38
+ // it at 5s mid-upload would flash a wrong "Failed" and let the user kick off a
39
+ // duplicate upload. The text watchdog stays at 5s per product spec.
40
+ const MEDIA_PENDING_WATCHDOG_MS = 30_000;
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
30
44
  // `send-text-message-${Date.now()}`; that collision made
@@ -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(
@@ -10,6 +10,7 @@ interface UnreadMessagesStats {
10
10
  hasUnread: boolean;
11
11
  totalCount: number;
12
12
  unreadByRoom: UnreadMessagesMap;
13
+ isLoading: boolean;
13
14
  }
14
15
 
15
16
  export const useUnreadMessagesCounter = (): UnreadMessagesStats => {
@@ -26,11 +27,12 @@ export const useUnreadMessagesCounter = (): UnreadMessagesStats => {
26
27
  // so non-rooms dispatches still don't trigger a re-render.)
27
28
  const subscribe = (callback: () => void) => store.subscribe(callback);
28
29
 
29
- const rooms = useSyncExternalStore(
30
+ const roomsState = useSyncExternalStore(
30
31
  subscribe,
31
- () => store.getState().rooms.rooms
32
+ () => store.getState().rooms
32
33
  );
33
34
 
35
+ const rooms = roomsState.rooms;
34
36
  const unreadByRoom: UnreadMessagesMap = {};
35
37
  let totalCount = 0;
36
38
 
@@ -46,6 +48,7 @@ export const useUnreadMessagesCounter = (): UnreadMessagesStats => {
46
48
  hasUnread: totalCount > 0,
47
49
  totalCount,
48
50
  unreadByRoom,
51
+ isLoading: !!roomsState.isUnreadSyncing || !!roomsState.isLoading,
49
52
  };
50
53
  };
51
54
 
package/src/main.ts CHANGED
@@ -32,6 +32,8 @@ export { useChatRoomFocus } from './hooks/useChatRoomFocus';
32
32
  export { logoutService } from './hooks/useLogout';
33
33
  export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
34
34
  export { resendMessage } from './utils/resendMessage';
35
+ // Font loader — exposed so hosts can also load the font outside <Chat> if needed.
36
+ export { useChatFonts } from './hooks/useChatFonts';
35
37
 
36
38
  // Public types — needed so consumers can type their `config` prop and
37
39
  // any event handlers they pass in.
@@ -43,4 +45,6 @@ export type {
43
45
  User,
44
46
  ConfigUser,
45
47
  MessageProps,
48
+ TypographyConfig,
49
+ RNFontSource,
46
50
  } from './types/types';
@@ -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,11 +37,20 @@ 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 5s, 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.
52
+ // Default replay window (text). Mirrors PENDING_WATCHDOG_MS in useSendMessage.
53
+ // Media enqueues a longer per-item ttlMs (see QueuedSend.ttlMs).
45
54
  export const OUTBOUND_QUEUE_TTL_MS = 30_000;
46
55
 
47
56
  let queue: QueuedSend[] = [];
@@ -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
  }
@@ -96,6 +106,7 @@ export interface RoomMessagesState {
96
106
  editAction?: EditAction;
97
107
  isLoading: boolean;
98
108
  loadingText?: string;
109
+ isUnreadSyncing?: boolean;
99
110
  usersSet?: Record<string, any>;
100
111
  pendingNotificationJid?: string | null;
101
112
  // Server-side read markers fetched from the XMPP private store
@@ -111,6 +122,7 @@ const initialState: RoomMessagesState = {
111
122
  activeRoomJID: null,
112
123
  visibleRoomJID: null,
113
124
  isLoading: false,
125
+ isUnreadSyncing: false,
114
126
  editAction: {
115
127
  isEdit: false,
116
128
  roomJid: '',
@@ -305,6 +317,10 @@ const reducers = {
305
317
  state.rooms[roomJID].messages.map((message) => {
306
318
  if (message.id === messageId) {
307
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;
308
324
  }
309
325
  });
310
326
  }
@@ -362,6 +378,7 @@ const reducers = {
362
378
  state.rooms = {};
363
379
  state.visibleRoomJID = null;
364
380
  state.privateStoreMarkers = {};
381
+ state.isUnreadSyncing = false;
365
382
  },
366
383
  setComposing(
367
384
  state: WritableDraft<RoomMessagesState>,
@@ -386,6 +403,12 @@ const reducers = {
386
403
  state.isLoading = loading;
387
404
  state.loadingText = loadingText;
388
405
  },
406
+ setUnreadSyncing: (
407
+ state: WritableDraft<RoomMessagesState>,
408
+ action: PayloadAction<boolean>
409
+ ) => {
410
+ state.isUnreadSyncing = action.payload;
411
+ },
389
412
  setLastViewedTimestamp: (
390
413
  state: WritableDraft<RoomMessagesState>,
391
414
  action: PayloadAction<{ chatJID: string; timestamp: number }>
@@ -538,6 +561,7 @@ const reducers = {
538
561
  state.activeRoomJID = null;
539
562
  state.visibleRoomJID = null;
540
563
  state.isLoading = false;
564
+ state.isUnreadSyncing = false;
541
565
  state.privateStoreMarkers = {};
542
566
  },
543
567
  setActiveMessage: (
@@ -729,6 +753,7 @@ export const {
729
753
  editRoomMessage,
730
754
  setComposing,
731
755
  setIsLoading,
756
+ setUnreadSyncing,
732
757
  setLastViewedTimestamp,
733
758
  applyPrivateStoreMarkers,
734
759
  setRoomNoMessages,