@droppii-org/chat-mobile 0.2.105 → 0.2.106

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 (72) 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/core/useChatListener.js +26 -2
  6. package/lib/module/core/useChatListener.js.map +1 -1
  7. package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
  8. package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
  9. package/lib/module/hooks/query-keys.js +2 -1
  10. package/lib/module/hooks/query-keys.js.map +1 -1
  11. package/lib/module/hooks/useInputState.js +38 -0
  12. package/lib/module/hooks/useInputState.js.map +1 -0
  13. package/lib/module/hooks/useInputStateBroadcast.js +53 -0
  14. package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
  15. package/lib/module/screens/chat-detail/ChatComposer.js +15 -3
  16. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  17. package/lib/module/screens/chat-detail/ChatListLegend.js +2 -1
  18. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  19. package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
  20. package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
  21. package/lib/module/store/index.js +1 -0
  22. package/lib/module/store/index.js.map +1 -1
  23. package/lib/module/store/inputState.js +64 -0
  24. package/lib/module/store/inputState.js.map +1 -0
  25. package/lib/module/translation/resources/i18n.js +2 -0
  26. package/lib/module/translation/resources/i18n.js.map +1 -1
  27. package/lib/module/utils/inputState.js +77 -0
  28. package/lib/module/utils/inputState.js.map +1 -0
  29. package/lib/module/utils/message.js +9 -0
  30. package/lib/module/utils/message.js.map +1 -1
  31. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
  32. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
  33. package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
  34. package/lib/typescript/src/components/TypingDots/index.d.ts.map +1 -0
  35. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
  37. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
  38. package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
  39. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  40. package/lib/typescript/src/hooks/useInputState.d.ts +21 -0
  41. package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
  42. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
  43. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
  44. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  45. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  46. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
  47. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
  48. package/lib/typescript/src/store/index.d.ts +1 -0
  49. package/lib/typescript/src/store/index.d.ts.map +1 -1
  50. package/lib/typescript/src/store/inputState.d.ts +18 -0
  51. package/lib/typescript/src/store/inputState.d.ts.map +1 -0
  52. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  53. package/lib/typescript/src/utils/inputState.d.ts +54 -0
  54. package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
  55. package/lib/typescript/src/utils/message.d.ts +8 -0
  56. package/lib/typescript/src/utils/message.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/components/TypingDots/TypingDots.tsx +172 -0
  59. package/src/components/TypingDots/index.ts +2 -0
  60. package/src/core/useChatListener.ts +35 -1
  61. package/src/hooks/groups/useGroupMemberName.ts +31 -0
  62. package/src/hooks/query-keys.ts +2 -0
  63. package/src/hooks/useInputState.ts +57 -0
  64. package/src/hooks/useInputStateBroadcast.ts +53 -0
  65. package/src/screens/chat-detail/ChatComposer.tsx +17 -2
  66. package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
  67. package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -0
  68. package/src/store/index.ts +1 -0
  69. package/src/store/inputState.ts +76 -0
  70. package/src/translation/resources/i18n.ts +2 -0
  71. package/src/utils/inputState.ts +104 -0
  72. package/src/utils/message.ts +15 -0
@@ -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 } } }) => {
@@ -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
+ });
@@ -1,3 +1,4 @@
1
1
  export * from './storeConfig';
2
2
  export * from './user';
3
3
  export * from './conversation';
4
+ export * from './inputState';
@@ -0,0 +1,76 @@
1
+ import { create } from 'zustand';
2
+ import type { ConversationInputStatus } from '@droppii/openim-rn-client-sdk';
3
+ import type {
4
+ ConversationInputState,
5
+ InputStateMap,
6
+ } from '../utils/inputState';
7
+
8
+ export interface InputStateStore {
9
+ /** conversationID -> userID -> record. */
10
+ byConversation: InputStateMap;
11
+ /**
12
+ * Apply one OnInputStatusChanged payload.
13
+ *
14
+ * A non-empty `platformIDs` means that user is composing; an empty list is
15
+ * how the SDK reports that they stopped, so it removes them outright rather
16
+ * than storing an inactive record.
17
+ */
18
+ applyInputStatus: (status: ConversationInputStatus) => void;
19
+ /** Remove one user now — their message arrived, or they went idle. */
20
+ removeTyper: (conversationID: string, userID: string) => void;
21
+ }
22
+
23
+ const withoutUser = (
24
+ state: ConversationInputState,
25
+ userID: string
26
+ ): ConversationInputState | undefined => {
27
+ const next = { ...state };
28
+ delete next[userID];
29
+ // Undefined rather than an empty object, so a leftover record can never read
30
+ // as "somebody is typing".
31
+ return Object.keys(next).length > 0 ? next : undefined;
32
+ };
33
+
34
+ export const useInputStateStore = create<InputStateStore>()((set) => ({
35
+ byConversation: {},
36
+
37
+ applyInputStatus({ conversationID, userID, platformIDs }) {
38
+ if (!conversationID || !userID) return;
39
+
40
+ set((state) => {
41
+ const current = state.byConversation[conversationID];
42
+
43
+ if (!platformIDs || platformIDs.length === 0) {
44
+ if (!current?.[userID]) return state;
45
+ const remaining = withoutUser(current, userID);
46
+ const byConversation = { ...state.byConversation };
47
+ if (remaining) byConversation[conversationID] = remaining;
48
+ else delete byConversation[conversationID];
49
+ return { byConversation };
50
+ }
51
+
52
+ return {
53
+ byConversation: {
54
+ ...state.byConversation,
55
+ [conversationID]: {
56
+ ...current,
57
+ [userID]: { userID, platformIDs, updatedAt: Date.now() },
58
+ },
59
+ },
60
+ };
61
+ });
62
+ },
63
+
64
+ removeTyper(conversationID, userID) {
65
+ set((state) => {
66
+ const current = state.byConversation[conversationID];
67
+ if (!current?.[userID]) return state;
68
+
69
+ const remaining = withoutUser(current, userID);
70
+ const byConversation = { ...state.byConversation };
71
+ if (remaining) byConversation[conversationID] = remaining;
72
+ else delete byConversation[conversationID];
73
+ return { byConversation };
74
+ });
75
+ },
76
+ }));
@@ -1,6 +1,8 @@
1
1
  const i18nKey: Record<string, string> = {
2
2
  'thread_card_bot_crm_name': 'Droppii Hỗ Trợ',
3
3
  'thread_card_fallback_name': 'Người dùng',
4
+ 'chat:typing_single': '<bold>{{name}}</bold> đang nhập',
5
+ 'chat:typing_multiple': 'Nhiều người đang nhập',
4
6
  'chat:file_too_large':
5
7
  'Tệp tải lên không hợp lệ. Kích thước tệp không được vượt quá {{maxSize}}MB',
6
8
  'chat:file_max_count': 'Chỉ có thể đính kèm tối đa 3 tệp',
@@ -0,0 +1,104 @@
1
+ import type { Platform } from '@droppii/openim-rn-client-sdk';
2
+
3
+ /** A typer drops out this long after their last signal — BR-002. */
4
+ export const INPUT_STATE_TTL = 3_000;
5
+
6
+ /** A single name longer than this is truncated before it goes in the sentence. */
7
+ const TYPING_NAME_MAX_LENGTH = 30;
8
+
9
+ export interface InputStateRecord {
10
+ userID: string;
11
+ /** Devices that user is composing from. An empty list means they stopped. */
12
+ platformIDs: Platform[];
13
+ /** When their most recent signal arrived — the clock the TTL runs against. */
14
+ updatedAt: number;
15
+ }
16
+
17
+ /** userID -> record, for one conversation. */
18
+ export type ConversationInputState = Readonly<Record<string, InputStateRecord>>;
19
+
20
+ /** conversationID -> that conversation's typers. */
21
+ export type InputStateMap = Readonly<Record<string, ConversationInputState>>;
22
+
23
+ const EMPTY_TYPERS: readonly InputStateRecord[] = [];
24
+
25
+ /**
26
+ * Typers still inside the TTL window, oldest signal first.
27
+ *
28
+ * Expiry is derived from `updatedAt` rather than driven by one timer per user:
29
+ * the whole point is that each typer runs on their own clock. With A typing at
30
+ * t=0 and B at t=2s, A has to drop at t=3s while B stays until t=5s — a single
31
+ * shared timer reset by the newest signal would keep both alive to t=5s.
32
+ */
33
+ export const getActiveTypers = (
34
+ state: ConversationInputState | undefined,
35
+ now: number
36
+ ): readonly InputStateRecord[] => {
37
+ if (!state) return EMPTY_TYPERS;
38
+
39
+ const active = Object.values(state).filter(
40
+ (record) =>
41
+ record.platformIDs.length > 0 && now - record.updatedAt < INPUT_STATE_TTL
42
+ );
43
+
44
+ if (active.length === 0) return EMPTY_TYPERS;
45
+ return active.sort((a, b) => a.updatedAt - b.updatedAt);
46
+ };
47
+
48
+ /**
49
+ * When the next active typer falls out of the window, or null if none will.
50
+ *
51
+ * The caller uses this to wake up exactly once per expiry instead of polling.
52
+ */
53
+ export const getNextExpiryAt = (
54
+ state: ConversationInputState | undefined,
55
+ now: number
56
+ ): number | null => {
57
+ const active = getActiveTypers(state, now);
58
+ if (active.length === 0) return null;
59
+
60
+ // Sorted oldest first, so the first entry expires first.
61
+ return active[0]!.updatedAt + INPUT_STATE_TTL;
62
+ };
63
+
64
+ export const truncateTypingName = (name: string): string =>
65
+ name.length > TYPING_NAME_MAX_LENGTH
66
+ ? `${name.slice(0, TYPING_NAME_MAX_LENGTH)}...`
67
+ : name;
68
+
69
+ /**
70
+ * Which copy to render, as a key plus its values.
71
+ *
72
+ * Deliberately not a finished string: the name is styled differently from the
73
+ * rest of the sentence, so the caller needs the pieces separately to hand them
74
+ * to a rich-text renderer. Keeping translation out of here also leaves this
75
+ * module free of any dependency on the i18n layer.
76
+ */
77
+ export interface TypingTextDescriptor {
78
+ key: string;
79
+ values?: Record<string, string>;
80
+ }
81
+
82
+ export const TYPING_TEXT_KEYS = {
83
+ single: 'chat:typing_single',
84
+ multiple: 'chat:typing_multiple',
85
+ } as const;
86
+
87
+ /**
88
+ * Copy for a set of typers, or null when the indicator should be hidden.
89
+ *
90
+ * Only one typer is ever named. Beyond that the sentence collapses, so the
91
+ * caller never has to resolve a second name.
92
+ */
93
+ export const resolveTypingText = (
94
+ typerCount: number,
95
+ singleName?: string
96
+ ): TypingTextDescriptor | null => {
97
+ if (typerCount === 0) return null;
98
+ if (typerCount > 1) return { key: TYPING_TEXT_KEYS.multiple };
99
+ if (!singleName) return null;
100
+ return {
101
+ key: TYPING_TEXT_KEYS.single,
102
+ values: { name: truncateTypingName(singleName) },
103
+ };
104
+ };
@@ -119,6 +119,21 @@ export const hasNewHistoryMessages = (
119
119
  });
120
120
  };
121
121
 
122
+ /**
123
+ * Conversation ids a message could be filed under.
124
+ *
125
+ * A 1:1 id is built from both participants and their order is not recoverable
126
+ * from the message alone, so both spellings come back — `belongsToConversation`
127
+ * compares against both for exactly the same reason.
128
+ */
129
+ export const conversationIdsOfMessage = (message: MessageItem): string[] =>
130
+ message.sessionType === SessionType.Group
131
+ ? [getConversationID(SessionType.Group, message.groupID)]
132
+ : [
133
+ getConversationID(SessionType.Single, message.sendID, message.recvID),
134
+ getConversationID(SessionType.Single, message.recvID, message.sendID),
135
+ ];
136
+
122
137
  export const belongsToConversation = (
123
138
  message: MessageItem,
124
139
  conversationId?: string,