@droppii-org/chat-mobile 0.2.104 → 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.
- package/lib/module/components/TypingDots/TypingDots.js +131 -0
- package/lib/module/components/TypingDots/TypingDots.js.map +1 -0
- package/lib/module/components/TypingDots/index.js +4 -0
- package/lib/module/components/TypingDots/index.js.map +1 -0
- package/lib/module/core/useChatListener.js +26 -2
- package/lib/module/core/useChatListener.js.map +1 -1
- package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
- package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
- package/lib/module/hooks/query-keys.js +2 -1
- package/lib/module/hooks/query-keys.js.map +1 -1
- package/lib/module/hooks/useInputState.js +38 -0
- package/lib/module/hooks/useInputState.js.map +1 -0
- package/lib/module/hooks/useInputStateBroadcast.js +53 -0
- package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
- package/lib/module/screens/chat-detail/ChatComposer.js +17 -4
- package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +2 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js +14 -10
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
- package/lib/module/store/index.js +1 -0
- package/lib/module/store/index.js.map +1 -1
- package/lib/module/store/inputState.js +64 -0
- package/lib/module/store/inputState.js.map +1 -0
- package/lib/module/translation/resources/i18n.js +2 -0
- package/lib/module/translation/resources/i18n.js.map +1 -1
- package/lib/module/utils/inputState.js +77 -0
- package/lib/module/utils/inputState.js.map +1 -0
- package/lib/module/utils/message.js +9 -0
- package/lib/module/utils/message.js.map +1 -1
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts.map +1 -0
- package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
- package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
- package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useInputState.d.ts +21 -0
- package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +3 -1
- package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
- package/lib/typescript/src/store/index.d.ts +1 -0
- package/lib/typescript/src/store/index.d.ts.map +1 -1
- package/lib/typescript/src/store/inputState.d.ts +18 -0
- package/lib/typescript/src/store/inputState.d.ts.map +1 -0
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- package/lib/typescript/src/utils/inputState.d.ts +54 -0
- package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
- package/lib/typescript/src/utils/message.d.ts +8 -0
- package/lib/typescript/src/utils/message.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/TypingDots/TypingDots.tsx +172 -0
- package/src/components/TypingDots/index.ts +2 -0
- package/src/core/useChatListener.ts +35 -1
- package/src/hooks/groups/useGroupMemberName.ts +31 -0
- package/src/hooks/query-keys.ts +2 -0
- package/src/hooks/useInputState.ts +57 -0
- package/src/hooks/useInputStateBroadcast.ts +53 -0
- package/src/screens/chat-detail/ChatComposer.tsx +18 -2
- package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
- package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -0
- package/src/screens/chat-detail/components/ChatMessageInput.tsx +10 -7
- package/src/store/index.ts +1 -0
- package/src/store/inputState.ts +76 -0
- package/src/translation/resources/i18n.ts +2 -0
- package/src/utils/inputState.ts +104 -0
- package/src/utils/message.ts +15 -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 {
|
|
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
|
+
};
|
package/src/hooks/query-keys.ts
CHANGED
|
@@ -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,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
|
-
|
|
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 =
|
|
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 } } }) => {
|
|
@@ -675,6 +690,7 @@ export const ChatComposer = memo(
|
|
|
675
690
|
onSuggestPress={
|
|
676
691
|
hasBotMenuOptions ? handleOpenBotMenu : undefined
|
|
677
692
|
}
|
|
693
|
+
activePanelType={activePanelType}
|
|
678
694
|
/>
|
|
679
695
|
|
|
680
696
|
<KContainer.Touchable
|
|
@@ -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
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { KContainer, KImage, KColors, KSpacingValue } from '@droppii/libs';
|
|
11
11
|
import { MentionHighlightAdapter } from './MentionHighlightAdapter';
|
|
12
12
|
import type { Mention } from '../../../types/mention';
|
|
13
|
+
import type { ActivePanelType } from '../hooks/useChatComposerState';
|
|
13
14
|
|
|
14
15
|
const INPUT_FONT_SIZE = 16;
|
|
15
16
|
const INPUT_LINE_HEIGHT = 22.4;
|
|
@@ -39,6 +40,7 @@ export interface ChatMessageInputProps {
|
|
|
39
40
|
onEmojiPress?: () => void;
|
|
40
41
|
onSuggestPress?: () => void;
|
|
41
42
|
inputRef: React.RefObject<TextInput>;
|
|
43
|
+
activePanelType: ActivePanelType;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export const ChatMessageInput = memo(
|
|
@@ -56,6 +58,7 @@ export const ChatMessageInput = memo(
|
|
|
56
58
|
onEmojiPress,
|
|
57
59
|
onSuggestPress,
|
|
58
60
|
inputRef,
|
|
61
|
+
activePanelType,
|
|
59
62
|
}: ChatMessageInputProps) => {
|
|
60
63
|
// Re-enabled: the earlier flicker/"Maximum update depth exceeded" loop
|
|
61
64
|
// was root-caused to two bugs in the composer wiring (unstable callback
|
|
@@ -67,18 +70,18 @@ export const ChatMessageInput = memo(
|
|
|
67
70
|
// ChatComposer.tsx. If a highlight-specific issue turns up on device,
|
|
68
71
|
// set this back to `false` — it's a one-line, fully reversible gate.
|
|
69
72
|
const hasMentions = !!mentions && mentions.length > 0;
|
|
70
|
-
const renderEmojiNode = useCallback(
|
|
71
|
-
|
|
73
|
+
const renderEmojiNode = useCallback(() => {
|
|
74
|
+
const isActive = activePanelType === 'sticker';
|
|
75
|
+
return (
|
|
72
76
|
<KContainer.Touchable marginR="0.5rem" onPress={onEmojiPress}>
|
|
73
77
|
<KImage.VectorIcons
|
|
74
|
-
name=
|
|
78
|
+
name={isActive ? 'sticker-b' : 'sticker-o'}
|
|
75
79
|
size={24}
|
|
76
|
-
color={KColors.gray.normal}
|
|
80
|
+
color={isActive ? KColors.primary.normal : KColors.gray.normal}
|
|
77
81
|
/>
|
|
78
82
|
</KContainer.Touchable>
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
);
|
|
83
|
+
);
|
|
84
|
+
}, [onEmojiPress, activePanelType]);
|
|
82
85
|
|
|
83
86
|
return (
|
|
84
87
|
<KContainer.View flex marginL="0.75rem" style={styles.inputWrapper}>
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/utils/message.ts
CHANGED
|
@@ -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,
|