@droppii-org/chat-mobile 0.2.105 → 0.2.107

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 (152) hide show
  1. package/lib/module/components/TypingDots/TypingDots.js +131 -0
  2. package/lib/module/components/TypingDots/TypingDots.js.map +1 -0
  3. package/lib/module/components/TypingDots/index.js +4 -0
  4. package/lib/module/components/TypingDots/index.js.map +1 -0
  5. package/lib/module/components/messages/ExpandableMessageText.js +112 -0
  6. package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
  7. package/lib/module/components/messages/PreviewScrollableText.js +80 -0
  8. package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
  9. package/lib/module/components/messages/linkMessage/index.js +21 -12
  10. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  11. package/lib/module/components/messages/quotedMessage/index.js +12 -2
  12. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  13. package/lib/module/components/messages/textMessage/index.js +7 -1
  14. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/types.js.map +1 -1
  16. package/lib/module/core/useChatListener.js +26 -2
  17. package/lib/module/core/useChatListener.js.map +1 -1
  18. package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
  19. package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
  20. package/lib/module/hooks/query-keys.js +2 -1
  21. package/lib/module/hooks/query-keys.js.map +1 -1
  22. package/lib/module/hooks/useExpandableText.js +120 -0
  23. package/lib/module/hooks/useExpandableText.js.map +1 -0
  24. package/lib/module/hooks/useInputState.js +38 -0
  25. package/lib/module/hooks/useInputState.js.map +1 -0
  26. package/lib/module/hooks/useInputStateBroadcast.js +53 -0
  27. package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
  28. package/lib/module/screens/chat-detail/ChatComposer.js +15 -3
  29. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  30. package/lib/module/screens/chat-detail/ChatDetail.js +11 -0
  31. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  32. package/lib/module/screens/chat-detail/ChatListLegend.js +2 -1
  33. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  34. package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
  35. package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
  36. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
  37. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  38. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +10 -3
  39. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  40. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
  41. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  42. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
  43. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
  44. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
  45. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
  46. package/lib/module/store/expandedMessage.js +28 -0
  47. package/lib/module/store/expandedMessage.js.map +1 -0
  48. package/lib/module/store/index.js +1 -0
  49. package/lib/module/store/index.js.map +1 -1
  50. package/lib/module/store/inputState.js +64 -0
  51. package/lib/module/store/inputState.js.map +1 -0
  52. package/lib/module/translation/resources/i18n.js +2 -0
  53. package/lib/module/translation/resources/i18n.js.map +1 -1
  54. package/lib/module/utils/expandableText.js +41 -0
  55. package/lib/module/utils/expandableText.js.map +1 -0
  56. package/lib/module/utils/inlineTruncation.js +51 -0
  57. package/lib/module/utils/inlineTruncation.js.map +1 -0
  58. package/lib/module/utils/inputState.js +77 -0
  59. package/lib/module/utils/inputState.js.map +1 -0
  60. package/lib/module/utils/message.js +9 -0
  61. package/lib/module/utils/message.js.map +1 -1
  62. package/lib/module/utils/truncateTextChildren.js +38 -0
  63. package/lib/module/utils/truncateTextChildren.js.map +1 -0
  64. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
  65. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
  66. package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
  67. package/lib/typescript/src/components/TypingDots/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
  69. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
  70. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
  71. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
  72. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
  73. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
  76. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/messages/types.d.ts +18 -0
  78. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  79. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  80. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
  81. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
  82. package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
  83. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  84. package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
  85. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
  86. package/lib/typescript/src/hooks/useInputState.d.ts +21 -0
  87. package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
  88. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
  89. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
  90. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  91. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  92. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  93. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
  94. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
  95. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  96. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
  97. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  98. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  99. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
  100. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
  101. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
  102. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
  103. package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
  104. package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
  105. package/lib/typescript/src/store/index.d.ts +1 -0
  106. package/lib/typescript/src/store/index.d.ts.map +1 -1
  107. package/lib/typescript/src/store/inputState.d.ts +18 -0
  108. package/lib/typescript/src/store/inputState.d.ts.map +1 -0
  109. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  110. package/lib/typescript/src/utils/expandableText.d.ts +18 -0
  111. package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
  112. package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
  113. package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
  114. package/lib/typescript/src/utils/inputState.d.ts +54 -0
  115. package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
  116. package/lib/typescript/src/utils/message.d.ts +8 -0
  117. package/lib/typescript/src/utils/message.d.ts.map +1 -1
  118. package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
  119. package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
  120. package/package.json +1 -1
  121. package/src/components/TypingDots/TypingDots.tsx +172 -0
  122. package/src/components/TypingDots/index.ts +2 -0
  123. package/src/components/messages/ExpandableMessageText.tsx +145 -0
  124. package/src/components/messages/PreviewScrollableText.tsx +98 -0
  125. package/src/components/messages/linkMessage/index.tsx +30 -21
  126. package/src/components/messages/quotedMessage/index.tsx +14 -2
  127. package/src/components/messages/textMessage/index.tsx +11 -2
  128. package/src/components/messages/types.ts +18 -0
  129. package/src/core/useChatListener.ts +35 -1
  130. package/src/hooks/groups/useGroupMemberName.ts +31 -0
  131. package/src/hooks/query-keys.ts +2 -0
  132. package/src/hooks/useExpandableText.ts +139 -0
  133. package/src/hooks/useInputState.ts +57 -0
  134. package/src/hooks/useInputStateBroadcast.ts +53 -0
  135. package/src/screens/chat-detail/ChatComposer.tsx +17 -2
  136. package/src/screens/chat-detail/ChatDetail.tsx +11 -0
  137. package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
  138. package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -0
  139. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
  140. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +22 -2
  141. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
  142. package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
  143. package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
  144. package/src/store/expandedMessage.ts +42 -0
  145. package/src/store/index.ts +1 -0
  146. package/src/store/inputState.ts +76 -0
  147. package/src/translation/resources/i18n.ts +2 -0
  148. package/src/utils/expandableText.ts +51 -0
  149. package/src/utils/inlineTruncation.ts +65 -0
  150. package/src/utils/inputState.ts +104 -0
  151. package/src/utils/message.ts +15 -0
  152. package/src/utils/truncateTextChildren.ts +46 -0
@@ -7,6 +7,7 @@ import OpenIMSDK, {
7
7
  type ConversationItem,
8
8
  type GroupApplicationItem,
9
9
  type GroupItem,
10
+ type ConversationInputStatus,
10
11
  type GroupMemberItem,
11
12
  type MessageItem,
12
13
  type MessagePinned,
@@ -25,7 +26,11 @@ import { useChatContext } from '../context';
25
26
  import isEmpty from 'lodash/isEmpty';
26
27
  import { markMessageRevoked, useMessageStore } from '../store/message';
27
28
  import { syncPinnedMessagesOnEvent } from '../hooks/message/usePinMessage';
28
- import { belongsToConversation } from '../utils/message';
29
+ import {
30
+ belongsToConversation,
31
+ conversationIdsOfMessage,
32
+ } from '../utils/message';
33
+ import { useInputStateStore } from '../store/inputState';
29
34
  import { getConversationID } from '../utils/conversation';
30
35
  import { clearSelfExitedGroup } from '../utils/groupExit';
31
36
  import { DeviceEventEmitter } from 'react-native';
@@ -371,8 +376,34 @@ export const useChatListener = () => {
371
376
  [updateOneMessage]
372
377
  );
373
378
 
379
+ const onInputStatusChanged = useCallback(
380
+ (status: ConversationInputStatus) => {
381
+ // Never reflect the viewer's own composing back at them.
382
+ if (!status?.userID || status.userID === currentUserId) return;
383
+
384
+ // Stored for every conversation, not just the open one: the thread list
385
+ // shows typing for threads the user is not currently inside, and the
386
+ // store is keyed by conversation so each reader takes only its own.
387
+ useInputStateStore.getState().applyInputStatus(status);
388
+ },
389
+ [currentUserId]
390
+ );
391
+
374
392
  const onRecvNewMessages = useCallback((messages: MessageItem[]) => {
375
393
  // console.log('[Listener] OnRecvNewMessages', messages);
394
+ // A sender whose message just arrived has stopped composing. Runs over
395
+ // every message and above the guards below, because typing is tracked for
396
+ // all conversations rather than only the open one — and those guards bail
397
+ // outright while the user is sitting on the inbox.
398
+ const { removeTyper } = useInputStateStore.getState();
399
+ for (const message of messages) {
400
+ const senderId = message.sendID;
401
+ if (!senderId) continue;
402
+ conversationIdsOfMessage(message).forEach((id) =>
403
+ removeTyper(id, senderId)
404
+ );
405
+ }
406
+
376
407
  const conversationId =
377
408
  useConversationStore.getState().currentConversationId;
378
409
  const targetUserId = useConversationStore.getState().currentUserId;
@@ -415,6 +446,7 @@ export const useChatListener = () => {
415
446
  OpenIMSDK.on(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
416
447
  OpenIMSDK.on(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
417
448
  OpenIMSDK.on(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
449
+ OpenIMSDK.on(OpenIMEvent.OnInputStatusChanged, onInputStatusChanged);
418
450
  OpenIMSDK.on(
419
451
  OpenIMEvent.OnRecvOfflineNewMessages,
420
452
  onRecvOfflineNewMessages
@@ -444,6 +476,7 @@ export const useChatListener = () => {
444
476
  // Message status
445
477
  OpenIMSDK.off(OpenIMEvent.SendMessageProgress, onSendMessageProgress);
446
478
  OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
479
+ OpenIMSDK.off(OpenIMEvent.OnInputStatusChanged, onInputStatusChanged);
447
480
  OpenIMSDK.off(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
448
481
  OpenIMSDK.off(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
449
482
  OpenIMSDK.off(
@@ -461,6 +494,7 @@ export const useChatListener = () => {
461
494
  onNewConversation,
462
495
  onSendMessageProgress,
463
496
  onRecvNewMessages,
497
+ onInputStatusChanged,
464
498
  onRecvOfflineNewMessages,
465
499
  OnGroupMemberInfoChanged,
466
500
  onJoinedGroupDeleted,
@@ -0,0 +1,31 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
3
+ import { groupQueryKeys } from '../query-keys';
4
+
5
+ /**
6
+ * Display name of one group member.
7
+ *
8
+ * Fetches a single member rather than the whole roster: the only place this is
9
+ * needed is the typing sentence, which names at most one person — beyond that
10
+ * the copy collapses to "Nhiều người". Cached by react-query so a dot flickering
11
+ * on and off does not re-hit the SDK.
12
+ */
13
+ export const useGroupMemberName = (
14
+ groupID?: string,
15
+ userID?: string
16
+ ): string | undefined => {
17
+ const { data } = useQuery({
18
+ queryKey: groupQueryKeys.member(groupID, userID),
19
+ enabled: !!groupID && !!userID,
20
+ staleTime: 5 * 60_000,
21
+ queryFn: async () => {
22
+ const members = await OpenIMSDK.getSpecifiedGroupMembersInfo({
23
+ groupID: groupID!,
24
+ userIDList: [userID!],
25
+ });
26
+ return members?.[0]?.nickname ?? null;
27
+ },
28
+ });
29
+
30
+ return data ?? undefined;
31
+ };
@@ -83,4 +83,6 @@ export const groupQueryKeys = {
83
83
  leave: () => [...groupQueryKeys.all, 'leave'] as const,
84
84
  inviteInfo: (code: string) =>
85
85
  [...groupQueryKeys.all, 'inviteInfo', code] as const,
86
+ member: (groupID?: string, userID?: string) =>
87
+ [...groupQueryKeys.all, 'member', groupID, userID] as const,
86
88
  };
@@ -0,0 +1,139 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { useWindowDimensions, type TextLayoutEvent } from 'react-native';
3
+ import { useExpandedMessageStore } from '../store/expandedMessage';
4
+ import {
5
+ COLLAPSED_LINES,
6
+ expandedKey,
7
+ isMeasurementCandidate,
8
+ type MessageSurface,
9
+ } from '../utils/expandableText';
10
+ import { initialCut, joinLines, shrinkCut } from '../utils/inlineTruncation';
11
+
12
+ export { COLLAPSED_LINES };
13
+
14
+ type Verdict = { truncated: false } | { truncated: true; cut: number };
15
+
16
+ // Each pass is one extra layout; in practice the suffix fits by the second.
17
+ // Past this the suffix may sit on a sixth line — still visible and tappable.
18
+ const MAX_FIT_PASSES = 4;
19
+
20
+ /**
21
+ * Truncation is a pure function of (text, available width, typography), so it
22
+ * belongs to the message — not to the list row that happens to render it.
23
+ * `recycleItems` reuses row instances, so a verdict held in component state
24
+ * bleeds onto the next message in that slot. Keyed here, a recycled row reads
25
+ * the right answer during render and pays no measurement for a message seen.
26
+ */
27
+ const verdictCache = new Map<string, Verdict>();
28
+
29
+ export const clearExpandableTextCache = () => verdictCache.clear();
30
+
31
+ // Window width, not the bubble's: the bubble shrinks to its content, and the
32
+ // content changes when the suffix is appended, so keying on it would re-measure
33
+ // in a loop. Surface is in the key because the pin sheet is a different width.
34
+ // Text length makes a body that mutates in place (TextMessage re-derives its
35
+ // runs when `ex` arrives) miss the cache rather than reuse a stale cut.
36
+ const verdictKey = (
37
+ surface: MessageSurface,
38
+ messageId: string,
39
+ windowWidth: number,
40
+ text: string
41
+ ) => `${surface}:${messageId}:${Math.round(windowWidth)}:${text.length}`;
42
+
43
+ interface Fit {
44
+ key: string | null;
45
+ full?: string;
46
+ cut?: number;
47
+ passes: number;
48
+ verdict?: Verdict;
49
+ }
50
+
51
+ export type ExpandableTextMode =
52
+ | 'plain'
53
+ | 'measuring'
54
+ | 'fitting'
55
+ | 'collapsed';
56
+
57
+ export function useExpandableText(
58
+ messageId: string,
59
+ text: string,
60
+ surface: MessageSurface
61
+ ) {
62
+ const expanded = useExpandedMessageStore(
63
+ (s) => !!s.expanded[expandedKey(surface, messageId)]
64
+ );
65
+ const expandInStore = useExpandedMessageStore((s) => s.expand);
66
+ const { width: windowWidth } = useWindowDimensions();
67
+
68
+ const key = messageId
69
+ ? verdictKey(surface, messageId, windowWidth, text)
70
+ : null;
71
+
72
+ // Reset during render rather than in an effect: an effect lands after paint,
73
+ // so a recycled row would show one frame with the previous message's cut.
74
+ const [fit, setFit] = useState<Fit>({ key, passes: 0 });
75
+ const current = fit.key === key ? fit : { key, passes: 0 };
76
+ if (fit.key !== key) setFit(current);
77
+
78
+ // Without an id there is nowhere to record an expansion, so the control
79
+ // would do nothing. Never collapse those.
80
+ const isCandidate = !!messageId && !expanded && isMeasurementCandidate(text);
81
+ const verdict = (key && verdictCache.get(key)) || current.verdict;
82
+
83
+ let mode: ExpandableTextMode = 'plain';
84
+ if (isCandidate) {
85
+ if (verdict) mode = verdict.truncated ? 'collapsed' : 'plain';
86
+ else mode = current.cut == null ? 'measuring' : 'fitting';
87
+ }
88
+ const cut = verdict?.truncated ? verdict.cut : (current.cut ?? 0);
89
+
90
+ const settle = useCallback(
91
+ (next: Verdict) => {
92
+ if (key) verdictCache.set(key, next);
93
+ setFit((prev) => (prev.key === key ? { ...prev, verdict: next } : prev));
94
+ },
95
+ [key]
96
+ );
97
+
98
+ // Full, unclamped copy: how many lines, and where line N ends.
99
+ const onMeasureTextLayout = useCallback(
100
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
101
+ if (lines.length <= COLLAPSED_LINES) {
102
+ settle({ truncated: false });
103
+ return;
104
+ }
105
+ const full = joinLines(lines);
106
+ const first = initialCut(lines, COLLAPSED_LINES);
107
+ setFit((prev) =>
108
+ prev.key === key ? { ...prev, full, cut: first, passes: 0 } : prev
109
+ );
110
+ },
111
+ [key, settle]
112
+ );
113
+
114
+ // The visible copy with the suffix appended: accept once it fits in N lines.
115
+ const onFitTextLayout = useCallback(
116
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
117
+ if (fit.key !== key || fit.full == null || fit.cut == null) return;
118
+ if (lines.length <= COLLAPSED_LINES || fit.passes >= MAX_FIT_PASSES) {
119
+ settle({ truncated: true, cut: fit.cut });
120
+ return;
121
+ }
122
+ const next = shrinkCut(fit.full, fit.cut, lines, COLLAPSED_LINES);
123
+ setFit({ ...fit, cut: next, passes: fit.passes + 1 });
124
+ },
125
+ [fit, key, settle]
126
+ );
127
+
128
+ const expand = useCallback(() => {
129
+ expandInStore(surface, messageId);
130
+ }, [expandInStore, surface, messageId]);
131
+
132
+ return {
133
+ mode,
134
+ cut,
135
+ expand,
136
+ onMeasureTextLayout,
137
+ onFitTextLayout,
138
+ };
139
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useInputStateStore } from '../store';
3
+ import {
4
+ getActiveTypers,
5
+ getNextExpiryAt,
6
+ type InputStateRecord,
7
+ } from '../utils/inputState';
8
+
9
+ export interface InputStateResult {
10
+ /**
11
+ * Whether the indicator belongs on screen. Unlike the sentence this needs no
12
+ * name, so it can be answered here.
13
+ */
14
+ isTyping: boolean;
15
+ /** Everyone still inside the window, oldest signal first. */
16
+ typers: readonly InputStateRecord[];
17
+ }
18
+
19
+ /**
20
+ * Typing state for one conversation.
21
+ *
22
+ * Expiry is read from each record's timestamp rather than tracked with a timer
23
+ * per user, and the hook schedules a single wake-up at the moment the next
24
+ * typer falls out of the window. So with A typing at t=0 and B at t=2s the
25
+ * sentence moves from A, to both, to B alone at t=3s, and disappears at t=5s —
26
+ * without polling between those points.
27
+ */
28
+ export const useInputState = (conversationID?: string): InputStateResult => {
29
+ const state = useInputStateStore((store) =>
30
+ conversationID ? store.byConversation[conversationID] : undefined
31
+ );
32
+ // Bumped by the expiry timer below. Records go stale on a clock rather than
33
+ // on a store write, so something has to ask the question again.
34
+ const [tick, setTick] = useState(0);
35
+
36
+ const typers = useMemo(
37
+ // `tick` is not read here — it is a dep so that the expiry timer below can
38
+ // force a recount. Records fall out of the window on a clock, so `state`
39
+ // alone would leave an expired typer on screen until the next store write.
40
+ () => getActiveTypers(state, Date.now()),
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ [state, tick]
43
+ );
44
+
45
+ useEffect(() => {
46
+ const expiresAt = getNextExpiryAt(state, Date.now());
47
+ if (expiresAt === null) return;
48
+
49
+ const timer = setTimeout(
50
+ () => setTick((value) => value + 1),
51
+ Math.max(0, expiresAt - Date.now())
52
+ );
53
+ return () => clearTimeout(timer);
54
+ }, [state, typers]);
55
+
56
+ return { isTyping: typers.length > 0, typers };
57
+ };
@@ -0,0 +1,53 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
3
+
4
+ /**
5
+ * Broadcasts this device's composing state for a conversation.
6
+ *
7
+ * The SDK throttles the outgoing frames itself, so `notifyTyping` is safe to
8
+ * call on every keystroke — no debounce here would only duplicate that. What
9
+ * this hook does own is the start/stop rule: an empty draft counts as stopping,
10
+ * not as typing, and leaving the screen has to stop too or the peer is left
11
+ * watching an indicator for a composer nobody is in front of.
12
+ */
13
+ export const useInputStateBroadcast = (conversationID?: string) => {
14
+ // Mirrors what we last told the SDK, so a cleared draft or a blur does not
15
+ // fire a stop frame for a conversation we never announced typing in.
16
+ const isTypingRef = useRef(false);
17
+
18
+ const send = useCallback(
19
+ (focus: boolean) => {
20
+ if (!conversationID) return;
21
+ // Only a repeated *stop* is redundant. A repeated start is the heartbeat
22
+ // that keeps the peer's TTL window open — suppressing it would let the
23
+ // indicator expire while the person is still typing.
24
+ if (!focus && !isTypingRef.current) return;
25
+
26
+ isTypingRef.current = focus;
27
+ OpenIMSDK.changeInputStates({ conversationID, focus }).catch(() => {
28
+ // Typing state is cosmetic — a failed frame must not surface to the
29
+ // person typing, and the peer's own 3s window cleans up after it.
30
+ isTypingRef.current = !focus;
31
+ });
32
+ },
33
+ [conversationID]
34
+ );
35
+
36
+ /** Any draft change: text present means typing, empty means stopped. */
37
+ const notifyTyping = useCallback(
38
+ (text?: string) => send(!!text && text.trim().length > 0),
39
+ [send]
40
+ );
41
+
42
+ /** Explicit stop — message sent, input blurred, screen left. */
43
+ const notifyStoppedTyping = useCallback(() => send(false), [send]);
44
+
45
+ // Switching conversations, or unmounting, stops the previous one.
46
+ useEffect(() => {
47
+ return () => {
48
+ notifyStoppedTyping();
49
+ };
50
+ }, [notifyStoppedTyping]);
51
+
52
+ return { notifyTyping, notifyStoppedTyping };
53
+ };
@@ -59,6 +59,7 @@ import { DFlowState } from '../../types/chat';
59
59
  import { useAttachmentMapping } from './hooks/useAttachmentMapping';
60
60
  import type { AttachmentType } from '../../types/attachment';
61
61
  import { useChatCompose } from '../../hooks/useChatCompose';
62
+ import { useInputStateBroadcast } from '../../hooks/useInputStateBroadcast';
62
63
  import { ChatCurrentQuotedMessage } from './ChatCurrentQuotedMessage';
63
64
  import { useSendMessage } from '../../hooks/message/useSendMessage';
64
65
  import { openBotMenuSheet } from './BotMenuSheet';
@@ -111,6 +112,9 @@ export const ChatComposer = memo(
111
112
  const { mutateAsync: closeSupportSession } = useCloseSupportSession();
112
113
  const { sendMessage } = useSendMessage();
113
114
  const compose = useChatCompose();
115
+ const { notifyTyping, notifyStoppedTyping } = useInputStateBroadcast(
116
+ conversation?.conversationID
117
+ );
114
118
  const { allowed: isSendMessageAllowed, reason: sendDeniedReason } =
115
119
  useCanSendMessage();
116
120
 
@@ -226,6 +230,9 @@ export const ChatComposer = memo(
226
230
  // "ghost" mention anchored past the end of an empty string). See
227
231
  // useMentionComposer.clear()'s doc comment.
228
232
  mentionComposer.clear();
233
+ // Stop before awaiting the send: the peer should lose the indicator as
234
+ // the message goes out, not once it round-trips.
235
+ notifyStoppedTyping();
229
236
  try {
230
237
  await compose.send(mentionsToSend);
231
238
  } catch (error) {
@@ -248,6 +255,7 @@ export const ChatComposer = memo(
248
255
  sendDeniedReason,
249
256
  mentionComposer.mentions,
250
257
  mentionComposer.clear,
258
+ notifyStoppedTyping,
251
259
  inputRef,
252
260
  ]);
253
261
 
@@ -324,12 +332,19 @@ export const ChatComposer = memo(
324
332
 
325
333
  const handleInputBlur = useCallback(() => {
326
334
  setIsInputFocused(false);
327
- }, [setIsInputFocused]);
335
+ notifyStoppedTyping();
336
+ }, [setIsInputFocused, notifyStoppedTyping]);
328
337
 
329
338
  // Routes through mentionComposer instead of compose.setText directly —
330
339
  // it owns the atomic-delete/reconcile pipeline and calls compose.setText
331
340
  // itself once the corrected text is known (see useMentionComposer).
332
- const handleChangeText = mentionComposer.handleChangeText;
341
+ const handleChangeText = useCallback(
342
+ (text?: string) => {
343
+ notifyTyping(text);
344
+ mentionComposer.handleChangeText(text);
345
+ },
346
+ [notifyTyping, mentionComposer]
347
+ );
333
348
 
334
349
  const handleInputContentSizeChange = useCallback(
335
350
  (event: { nativeEvent: { contentSize: { height: number } } }) => {
@@ -18,6 +18,8 @@ import {
18
18
  useConversationStore,
19
19
  useUserStore,
20
20
  } from '../../store';
21
+ import { useExpandedMessageStore } from '../../store/expandedMessage';
22
+ import { clearExpandableTextCache } from '../../hooks/useExpandableText';
21
23
  import ChatDetailHeader from './ChatDetailHeader';
22
24
  import { ChatListLegend } from './ChatListLegend';
23
25
  import { ChatComposer } from './ChatComposer';
@@ -81,6 +83,15 @@ const ChatDetail = memo(
81
83
 
82
84
  const conversation = useConversation(conversationId);
83
85
 
86
+ // Keyed on the conversation, not on unmount: this screen is mounted by the
87
+ // consumer's navigator, so whether it remounts per conversation or is
88
+ // reused with a new id is not knowable here. Reopening a conversation must
89
+ // start every message shortened again.
90
+ useEffect(() => {
91
+ useExpandedMessageStore.getState().clearSurface('timeline');
92
+ clearExpandableTextCache();
93
+ }, [conversationId]);
94
+
84
95
  const { data: target } = useGetConversationTarget(conversationId);
85
96
  const isComposeDisabled = !!target && !target.isActive;
86
97
 
@@ -68,6 +68,7 @@ import { resolveImageDisplayDims } from '../../components/messages/imageMessage'
68
68
  import ChatImageDimens, {
69
69
  CHAT_IMAGE_VIEW_SOURCE,
70
70
  } from '../../utils/chatImageDimens';
71
+ import { ChatTypingIndicator } from './ChatTypingIndicator';
71
72
 
72
73
  // Cluster spacing: a wide gap after the message that closes a cluster, a tight
73
74
  // one between messages inside it. These override the flat margin on the row
@@ -1020,6 +1021,7 @@ export const ChatListLegend = memo(
1020
1021
  {showBadge && <KContainer.View style={styles.badge} />}
1021
1022
  </KContainer.Touchable>
1022
1023
  </Animated.View>
1024
+ <ChatTypingIndicator />
1023
1025
  </KContainer.View>
1024
1026
  );
1025
1027
  }
@@ -0,0 +1,89 @@
1
+ import { memo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { KColors, KContainer, KLabel, KSpacingValue } from '@droppii/libs';
4
+ import { PeerType } from '@droppii/openim-rn-client-sdk';
5
+ import { TypingDots } from '../../components/TypingDots';
6
+ import { useChatDetailContext } from '../../context/ChatDetailContext';
7
+ import { useGroupMemberName } from '../../hooks/groups/useGroupMemberName';
8
+ import { useInputState } from '../../hooks/useInputState';
9
+ import { resolveTypingText } from '../../utils/inputState';
10
+ import { trans } from '../../translation';
11
+
12
+ /**
13
+ * "<name> đang nhập..." above the composer, with the bouncing dots.
14
+ *
15
+ * Renders nothing while nobody is typing — an empty row would reserve height
16
+ * and nudge the message list every time somebody started or stopped.
17
+ */
18
+ export const ChatTypingIndicator = memo(() => {
19
+ const { conversation } = useChatDetailContext();
20
+ const isGroup = conversation?.peerType === PeerType.Group;
21
+
22
+ const { isTyping, typers } = useInputState(conversation?.conversationID);
23
+
24
+ // Only the first typer is ever named — past one person the copy collapses to
25
+ // "Nhiều người", so a single lookup covers every case.
26
+ const firstTyperId = typers[0]?.userID;
27
+ const memberName = useGroupMemberName(
28
+ isGroup ? conversation?.groupID : undefined,
29
+ isGroup ? firstTyperId : undefined
30
+ );
31
+
32
+ // A 1:1 or bot thread is named after its only other participant, so the
33
+ // conversation's own name is the answer and no lookup is needed.
34
+ // A key plus its values, not a finished string: the name renders bold while
35
+ // the rest does not, so RichText needs the pieces apart.
36
+ const copy = resolveTypingText(
37
+ typers.length,
38
+ isGroup ? memberName : conversation?.showName
39
+ );
40
+
41
+ if (!isTyping) return null;
42
+
43
+ // Dots go up the moment somebody types; the sentence follows once the name
44
+ // resolves. Waiting for the name would hold the whole indicator back on a
45
+ // network round-trip the dots do not depend on.
46
+ return (
47
+ <KContainer.View row style={styles.row}>
48
+ {!!copy && (
49
+ <KLabel.RichText
50
+ typo="TextXsNormal"
51
+ color={KColors.gray.light}
52
+ numberOfLines={1}
53
+ richTextOptions={{
54
+ i18nKey: trans(copy.key),
55
+ values: copy.values,
56
+ components: {
57
+ bold: (
58
+ <KLabel.Text typo="TextXsBold" color={KColors.gray.light} />
59
+ ),
60
+ },
61
+ }}
62
+ />
63
+ )}
64
+ <TypingDots
65
+ fill={KColors.gray.light}
66
+ width={13}
67
+ height={5.91}
68
+ style={styles.dot}
69
+ />
70
+ </KContainer.View>
71
+ );
72
+ });
73
+
74
+ ChatTypingIndicator.displayName = 'ChatTypingIndicator';
75
+
76
+ const styles = StyleSheet.create({
77
+ row: {
78
+ paddingHorizontal: KSpacingValue['0.5rem'],
79
+ paddingVertical: 2,
80
+ gap: 2,
81
+ alignItems: 'flex-end',
82
+ position: 'absolute',
83
+ bottom: 0,
84
+ left: 0,
85
+ },
86
+ dot: {
87
+ marginBottom: 3,
88
+ },
89
+ });
@@ -12,6 +12,7 @@ import {
12
12
  import { UIUtils } from '../../utils/UIUtils';
13
13
  import { trans } from '../../translation';
14
14
  import { usePinnedMessageStore } from '../../store/pinnedMessage';
15
+ import { useExpandedMessageStore } from '../../store/expandedMessage';
15
16
  import { LegendChatMessage } from './legend/LegendChatMessage';
16
17
  import { formatMessageTime } from './legend/messageTypes';
17
18
 
@@ -122,6 +123,7 @@ const PinnedMessagesSheetContent = memo(
122
123
  <LegendChatMessage
123
124
  message={message}
124
125
  messageId={messageId}
126
+ surface="pin"
125
127
  isOutgoing={isOutgoing}
126
128
  createdAtTime={createdAtTime}
127
129
  onPress={handlePressMessage}
@@ -198,6 +200,12 @@ export const openPinnedMessagesSheet = ({
198
200
  customMessageItemFactory,
199
201
  onPressMessage,
200
202
  }: OpenPinnedMessagesSheetParams) => {
203
+ // Reset on open, not on close. `UIUtils.bottomSheet` is an adapter the host
204
+ // app registers, so whether dismissing unmounts this content — or keeps it
205
+ // mounted and hidden — is not ours to know. Clearing here satisfies
206
+ // "reopened starts shortened" whatever the host does with the sheet.
207
+ useExpandedMessageStore.getState().clearSurface('pin');
208
+
201
209
  UIUtils.bottomSheet.open({
202
210
  header: {
203
211
  title: { text: trans('chat:pinned_messages_title') },
@@ -28,6 +28,7 @@ import QuotedMessage from '../../../components/messages/quotedMessage';
28
28
  import { StickerMessage } from '../../../components/messages/stickerMessage';
29
29
  import { GROUP_NOTIFICATION_TYPES } from '../../../utils/messageUtils';
30
30
  import type { GroupPosition } from '../../../utils/legendListMessage';
31
+ import type { MessageSurface } from '../../../store/expandedMessage';
31
32
  import { Avatar } from '../../../components/Avatar';
32
33
  import { TAIL_WIDTH } from '../../../components/messages/BubbleTail';
33
34
 
@@ -72,6 +73,15 @@ interface LegendChatMessageProps {
72
73
  showSenderGutter?: boolean;
73
74
  /** Position within its same-sender cluster — drives the bubble tail. */
74
75
  groupPosition?: GroupPosition;
76
+ /**
77
+ * Which surface is rendering this message. Expansion state is scoped by it
78
+ * (BR-003), so Pin Management must pass `'pin'` explicitly — the default
79
+ * keeps the timeline and the toolbox overlay sharing one scope, which is
80
+ * what makes the overlay clone mirror the row it lifted from.
81
+ */
82
+ surface?: MessageSurface;
83
+ /** Toolbox overlay clone — see `BaseLegendMessageProps.isPreview`. */
84
+ isPreview?: boolean;
75
85
  }
76
86
 
77
87
  const messageComponentMap: Partial<
@@ -129,6 +139,8 @@ export const LegendChatMessage = memo(
129
139
  showAvatar = false,
130
140
  showSenderGutter = false,
131
141
  groupPosition,
142
+ surface = 'timeline',
143
+ isPreview,
132
144
  }: LegendChatMessageProps) => {
133
145
  const handlePress = useCallback(() => {
134
146
  onPress?.(messageId);
@@ -151,6 +163,8 @@ export const LegendChatMessage = memo(
151
163
  <KContainer.View center style={styles.systemMessageWrapper}>
152
164
  <SystemMessageComponent
153
165
  message={message}
166
+ messageId={messageId}
167
+ surface={surface}
154
168
  isOutgoing={isOutgoing}
155
169
  createdAtTime={createdAtTime}
156
170
  />
@@ -210,12 +224,18 @@ export const LegendChatMessage = memo(
210
224
  {message.senderNickname}
211
225
  </KLabel.Text>
212
226
  )}
227
+ {/* No gestures on the overlay clone: a Tap here would race
228
+ `Xem thêm` for the same touch, and the overlay has its own
229
+ tap-to-dismiss around the clone. */}
213
230
  <LongPressWrapper
214
- onPress={shouldHandlePress ? handlePress : undefined}
215
- onLongPress={handleLongPress}
231
+ onPress={shouldHandlePress && !isPreview ? handlePress : undefined}
232
+ onLongPress={isPreview ? undefined : handleLongPress}
216
233
  >
217
234
  <MessageComponent
218
235
  message={message}
236
+ messageId={messageId}
237
+ surface={surface}
238
+ isPreview={isPreview}
219
239
  isOutgoing={isOutgoing}
220
240
  createdAtTime={createdAtTime}
221
241
  onMediaPress={hasMediaPress ? effectiveOnMediaPress : undefined}