@droppii-org/chat-mobile 0.2.84 → 0.2.86
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/ThreadCard/ThreadCard.js +14 -5
- package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
- package/lib/module/core/useChatListener.js +30 -11
- package/lib/module/core/useChatListener.js.map +1 -1
- package/lib/module/hooks/groups/useLeaveGroup.js +45 -15
- package/lib/module/hooks/groups/useLeaveGroup.js.map +1 -1
- package/lib/module/hooks/message/useMessages.js +19 -3
- package/lib/module/hooks/message/useMessages.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatDetail.js +2 -1
- package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +47 -8
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js +22 -6
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
- package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +16 -6
- package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -1
- package/lib/module/screens/chat-detail/legend/LegendChatDay.js +3 -6
- package/lib/module/screens/chat-detail/legend/LegendChatDay.js.map +1 -1
- package/lib/module/services/mentionSearch.js +9 -2
- package/lib/module/services/mentionSearch.js.map +1 -1
- package/lib/module/translation/resources/i18n.js +2 -2
- package/lib/module/translation/resources/i18n.js.map +1 -1
- package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
- package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
- package/lib/typescript/src/hooks/groups/useLeaveGroup.d.ts.map +1 -1
- package/lib/typescript/src/hooks/message/useMessages.d.ts +3 -1
- package/lib/typescript/src/hooks/message/useMessages.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatDetail.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/components/ChatMessageInput.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts +1 -6
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts.map +1 -1
- package/lib/typescript/src/services/mentionSearch.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/ThreadCard/ThreadCard.tsx +20 -3
- package/src/core/useChatListener.ts +42 -10
- package/src/hooks/groups/{useLeaveGroup.ts → useLeaveGroup.tsx} +45 -22
- package/src/hooks/message/useMessages.ts +26 -2
- package/src/screens/chat-detail/ChatDetail.tsx +5 -1
- package/src/screens/chat-detail/ChatListLegend.tsx +61 -10
- package/src/screens/chat-detail/components/ChatMessageInput.tsx +43 -23
- package/src/screens/chat-detail/components/MentionSuggestionList.tsx +16 -6
- package/src/screens/chat-detail/legend/LegendChatDay.tsx +17 -27
- package/src/services/mentionSearch.ts +9 -2
- package/src/translation/resources/i18n.ts +2 -2
|
@@ -2,6 +2,7 @@ import OpenIMSDK, {
|
|
|
2
2
|
MessageReceiveOptType,
|
|
3
3
|
MessageType,
|
|
4
4
|
OpenIMEvent,
|
|
5
|
+
PeerType,
|
|
5
6
|
SessionType,
|
|
6
7
|
type ConversationItem,
|
|
7
8
|
type GroupApplicationItem,
|
|
@@ -16,7 +17,10 @@ import { useCallback, useEffect } from 'react';
|
|
|
16
17
|
import { useConversationStore } from '../store/conversation';
|
|
17
18
|
import { ToastFlashMessage } from '../utils/toastFlashMessage';
|
|
18
19
|
import { useUserStore } from '../store/user';
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
getLastMessageText,
|
|
22
|
+
GROUP_NOTIFICATION_TYPES,
|
|
23
|
+
} from '../utils/messageUtils';
|
|
20
24
|
import { useChatContext } from '../context';
|
|
21
25
|
import isEmpty from 'lodash/isEmpty';
|
|
22
26
|
import { markMessageRevoked, useMessageStore } from '../store/message';
|
|
@@ -64,8 +68,18 @@ export const useChatListener = () => {
|
|
|
64
68
|
const latestMsg: MessageItem | null = conversation.latestMsg
|
|
65
69
|
? JSON.parse(conversation.latestMsg)
|
|
66
70
|
: null;
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
console.log('[latestMsg] OnConversationChanged', latestMsg);
|
|
72
|
+
|
|
73
|
+
// Same rule as useMessages: bot-flow automation noise (isInternal) or
|
|
74
|
+
// a group-notification content type in a Bot conversation must not
|
|
75
|
+
// become the visible latest-message preview or trigger a toast.
|
|
76
|
+
const isNoiseMessage =
|
|
77
|
+
!!latestMsg?.isInternal ||
|
|
78
|
+
latestMsg?.contentType === MessageType.LogTextMessage ||
|
|
79
|
+
(conversation?.peerType === PeerType.Bot &&
|
|
80
|
+
!!latestMsg &&
|
|
81
|
+
GROUP_NOTIFICATION_TYPES.includes(latestMsg?.contentType));
|
|
82
|
+
if (isNoiseMessage) continue;
|
|
69
83
|
|
|
70
84
|
visibleConversations.push(conversation);
|
|
71
85
|
|
|
@@ -141,7 +155,7 @@ export const useChatListener = () => {
|
|
|
141
155
|
groupName: string | undefined,
|
|
142
156
|
reason: 'left' | 'dismissed' | 'kicked'
|
|
143
157
|
) => {
|
|
144
|
-
|
|
158
|
+
console.log('[Listener] removeGroupConversation', { groupID, reason });
|
|
145
159
|
|
|
146
160
|
const conversationID = getConversationID(SessionType.Group, groupID);
|
|
147
161
|
// const conversation = useConversationStore.getState().map[conversationID];
|
|
@@ -163,6 +177,24 @@ export const useChatListener = () => {
|
|
|
163
177
|
);
|
|
164
178
|
}
|
|
165
179
|
);
|
|
180
|
+
} else if (reason === 'kicked' || reason === 'left') {
|
|
181
|
+
console.log(
|
|
182
|
+
'[Listener] User was kicked from group, hiding local conversation',
|
|
183
|
+
{
|
|
184
|
+
groupID,
|
|
185
|
+
conversationID,
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
// Being kicked keeps the group intact for other members — hide the
|
|
189
|
+
// conversation from this user's local list instead of deleting it,
|
|
190
|
+
// so it doesn't resurface on the next full sync/app restart.
|
|
191
|
+
OpenIMSDK.hideConversation(conversationID).catch((error) => {
|
|
192
|
+
console.error(
|
|
193
|
+
'[Listener] Failed to hide local conversation',
|
|
194
|
+
conversationID,
|
|
195
|
+
error
|
|
196
|
+
);
|
|
197
|
+
});
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
queryClient.removeQueries({
|
|
@@ -199,7 +231,7 @@ export const useChatListener = () => {
|
|
|
199
231
|
|
|
200
232
|
const onGroupMemberDeleted = useCallback(
|
|
201
233
|
(member: GroupMemberItem) => {
|
|
202
|
-
|
|
234
|
+
console.log('[Listener] OnGroupMemberDeleted', member);
|
|
203
235
|
queryClient.invalidateQueries({
|
|
204
236
|
queryKey: conversationQueryKeys.groupInfo(member.groupID),
|
|
205
237
|
});
|
|
@@ -215,7 +247,7 @@ export const useChatListener = () => {
|
|
|
215
247
|
|
|
216
248
|
const onJoinedGroupDeleted = useCallback(
|
|
217
249
|
(group: GroupItem) => {
|
|
218
|
-
|
|
250
|
+
console.log('[Listener] OnJoinedGroupDeleted', group);
|
|
219
251
|
removeGroupConversation(group.groupID, group.groupName, 'left');
|
|
220
252
|
},
|
|
221
253
|
[removeGroupConversation]
|
|
@@ -223,10 +255,10 @@ export const useChatListener = () => {
|
|
|
223
255
|
|
|
224
256
|
const onGroupDismissed = useCallback(
|
|
225
257
|
(group: GroupItem) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
258
|
+
console.log('[Listener] OnGroupDismissed', {
|
|
259
|
+
groupID: group.groupID,
|
|
260
|
+
groupName: group.groupName,
|
|
261
|
+
});
|
|
230
262
|
removeGroupConversation(group.groupID, group.groupName, 'dismissed');
|
|
231
263
|
},
|
|
232
264
|
[removeGroupConversation]
|
|
@@ -3,6 +3,7 @@ import OpenIMSDK, {
|
|
|
3
3
|
GroupVisibility,
|
|
4
4
|
} from '@droppii/openim-rn-client-sdk';
|
|
5
5
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { KColors, KContainer, KLabel } from '@droppii/libs';
|
|
6
7
|
import { conversationQueryKeys, groupQueryKeys } from '../query-keys';
|
|
7
8
|
import { UIUtils } from '../../utils/UIUtils';
|
|
8
9
|
import { trans } from '../../translation';
|
|
@@ -21,28 +22,50 @@ const showOwnerBlockedModal = () => {
|
|
|
21
22
|
|
|
22
23
|
const confirmLeaveGroup = (visibility?: GroupVisibility) =>
|
|
23
24
|
new Promise<void>((resolve, reject) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
25
|
+
UIUtils.popup.open({
|
|
26
|
+
header: {
|
|
27
|
+
title: { text: trans('chat:leave_group_confirm_title') },
|
|
28
|
+
alignment: 'center',
|
|
29
|
+
showCloseButton: true,
|
|
30
|
+
},
|
|
31
|
+
body: {
|
|
32
|
+
renderContent: () => (
|
|
33
|
+
<KContainer.View padding={'1rem'} justifyContent alignItems>
|
|
34
|
+
<KLabel.RichText
|
|
35
|
+
typo="TextMdNormal"
|
|
36
|
+
textAlign="center"
|
|
37
|
+
richTextOptions={{
|
|
38
|
+
i18nKey: trans(
|
|
39
|
+
visibility === GroupVisibility.Public
|
|
40
|
+
? 'chat:leave_group_confirm_desc_public'
|
|
41
|
+
: 'chat:leave_group_confirm_desc_private'
|
|
42
|
+
),
|
|
43
|
+
components: {
|
|
44
|
+
bold: <KLabel.Text typo="TextMdBold" color={KColors.black} />,
|
|
45
|
+
},
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
</KContainer.View>
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
actions: {
|
|
52
|
+
buttons: [
|
|
53
|
+
{
|
|
54
|
+
label: trans('chat:leave_group_confirm_secondary'),
|
|
55
|
+
kind: 'light',
|
|
56
|
+
onPress: () => reject(),
|
|
57
|
+
weight: 'medium',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: trans('chat:leave_group_confirm_primary'),
|
|
61
|
+
kind: 'danger',
|
|
62
|
+
onPress: () => resolve(),
|
|
63
|
+
isLightSolid: true,
|
|
64
|
+
weight: 'medium',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
type: 'horizontal-button',
|
|
68
|
+
},
|
|
46
69
|
});
|
|
47
70
|
});
|
|
48
71
|
|
|
@@ -1,17 +1,41 @@
|
|
|
1
|
-
import { useCallback, useEffect } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import { PeerType } from '@droppii/openim-rn-client-sdk';
|
|
2
3
|
import { useMessageStore } from '../../store/message';
|
|
3
4
|
import { useChatContext } from '../../context';
|
|
5
|
+
import { GROUP_NOTIFICATION_TYPES } from '../../utils/messageUtils';
|
|
4
6
|
|
|
5
7
|
interface IUseMessagesProps {
|
|
6
8
|
conversationId?: string;
|
|
7
9
|
startClientMsgId?: string;
|
|
10
|
+
peerType?: PeerType;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export const useMessages = ({
|
|
11
14
|
conversationId,
|
|
12
15
|
startClientMsgId,
|
|
16
|
+
peerType,
|
|
13
17
|
}: IUseMessagesProps) => {
|
|
14
|
-
const
|
|
18
|
+
const rawMessages = useMessageStore((s) => s.messages);
|
|
19
|
+
// History fetches (getMoreOldMessages/getMoreNewMessages/searchMessages)
|
|
20
|
+
// don't filter server-internal bot-flow messages the way the live socket
|
|
21
|
+
// path (onRecvNewMessages) does — filter here instead of in the store so
|
|
22
|
+
// pagination cursors keep reading the untouched array. A group-notification
|
|
23
|
+
// content type (member joined/kicked/etc.) can never legitimately occur in
|
|
24
|
+
// a Bot conversation — when it does, it's bot-flow automation noise.
|
|
25
|
+
const messages = useMemo(
|
|
26
|
+
() =>
|
|
27
|
+
rawMessages.filter((message) => {
|
|
28
|
+
if (message.isInternal) return false;
|
|
29
|
+
if (
|
|
30
|
+
peerType === PeerType.Bot &&
|
|
31
|
+
GROUP_NOTIFICATION_TYPES.includes(message.contentType)
|
|
32
|
+
) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}),
|
|
37
|
+
[rawMessages, peerType]
|
|
38
|
+
);
|
|
15
39
|
const { applicationType } = useChatContext();
|
|
16
40
|
const getMoreOldMessages = useMessageStore((s) => s.getMoreOldMessages);
|
|
17
41
|
const getMoreNewMessages = useMessageStore((s) => s.getMoreNewMessages);
|
|
@@ -98,7 +98,11 @@ const ChatDetail = memo(
|
|
|
98
98
|
getMoreNewMessages,
|
|
99
99
|
hasMoreOld,
|
|
100
100
|
hasMoreNew,
|
|
101
|
-
} = useMessages({
|
|
101
|
+
} = useMessages({
|
|
102
|
+
conversationId,
|
|
103
|
+
startClientMsgId: targetMessageId,
|
|
104
|
+
peerType: conversation?.peerType,
|
|
105
|
+
});
|
|
102
106
|
|
|
103
107
|
const { mutate: joinGroup, isPending: isJoiningGroup } = useJoinGroup();
|
|
104
108
|
|
|
@@ -145,6 +145,10 @@ export const ChatListLegend = memo(
|
|
|
145
145
|
const isAtBottomRef = useRef(true);
|
|
146
146
|
const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
147
147
|
const rowRefsMap = useRef<Map<string, View | null>>(new Map());
|
|
148
|
+
// Flips true once LegendList's onLoad fires (initial layout + initial
|
|
149
|
+
// scroll both settled) — before that, onStartReached/onEndReached can
|
|
150
|
+
// fire purely from resting scroll position, not a real user gesture.
|
|
151
|
+
const hasSettledRef = useRef(false);
|
|
148
152
|
const [showBadge, setShowBadge] = useState(false);
|
|
149
153
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
150
154
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
@@ -226,7 +230,7 @@ export const ChatListLegend = memo(
|
|
|
226
230
|
return (
|
|
227
231
|
<>
|
|
228
232
|
{(dayStart || timeBreak) && (
|
|
229
|
-
<LegendChatDay createdAt={createdAt}
|
|
233
|
+
<LegendChatDay createdAt={createdAt} />
|
|
230
234
|
)}
|
|
231
235
|
<KContainer.View
|
|
232
236
|
ref={(node: View | null) => {
|
|
@@ -453,23 +457,51 @@ export const ChatListLegend = memo(
|
|
|
453
457
|
);
|
|
454
458
|
|
|
455
459
|
const handleStartReached = useCallback(() => {
|
|
460
|
+
const willLoad = hasMoreOld && !moreOldLoading;
|
|
456
461
|
if (__DEV__) {
|
|
457
|
-
console.log(
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
+
console.log(
|
|
463
|
+
willLoad && !hasSettledRef.current
|
|
464
|
+
? '[LegendList] ⚠️ onStartReached SPURIOUS mount-time load'
|
|
465
|
+
: '[LegendList] onStartReached triggered',
|
|
466
|
+
{
|
|
467
|
+
conversationId,
|
|
468
|
+
messagesLength: messages.length,
|
|
469
|
+
firstMessageId:
|
|
470
|
+
messages[0]?.clientMsgID || messages[0]?.serverMsgID,
|
|
471
|
+
hasMoreOld,
|
|
472
|
+
moreOldLoading,
|
|
473
|
+
willLoad,
|
|
474
|
+
duringInitialSettle: !hasSettledRef.current,
|
|
475
|
+
}
|
|
476
|
+
);
|
|
462
477
|
}
|
|
463
|
-
if (
|
|
478
|
+
if (willLoad) {
|
|
464
479
|
onLoadMoreOld?.(true);
|
|
465
480
|
}
|
|
466
|
-
}, [hasMoreOld, moreOldLoading, onLoadMoreOld]);
|
|
481
|
+
}, [conversationId, messages, hasMoreOld, moreOldLoading, onLoadMoreOld]);
|
|
467
482
|
|
|
468
483
|
const handleEndReached = useCallback(() => {
|
|
469
|
-
|
|
484
|
+
const willLoad = hasMoreNew && !moreNewLoading;
|
|
485
|
+
if (__DEV__) {
|
|
486
|
+
console.log(
|
|
487
|
+
willLoad && !hasSettledRef.current
|
|
488
|
+
? '[LegendList] ⚠️ onEndReached SPURIOUS mount-time load'
|
|
489
|
+
: '[LegendList] onEndReached triggered',
|
|
490
|
+
{
|
|
491
|
+
conversationId,
|
|
492
|
+
messagesLength: messages.length,
|
|
493
|
+
lastMessageId: messages[messages.length - 1]?.clientMsgID,
|
|
494
|
+
hasMoreNew,
|
|
495
|
+
moreNewLoading,
|
|
496
|
+
willLoad,
|
|
497
|
+
duringInitialSettle: !hasSettledRef.current,
|
|
498
|
+
}
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (willLoad) {
|
|
470
502
|
onLoadMoreNew?.(true);
|
|
471
503
|
}
|
|
472
|
-
}, [hasMoreNew, moreNewLoading, onLoadMoreNew]);
|
|
504
|
+
}, [conversationId, messages, hasMoreNew, moreNewLoading, onLoadMoreNew]);
|
|
473
505
|
|
|
474
506
|
const handleScroll = useCallback(
|
|
475
507
|
(e: any) => {
|
|
@@ -527,6 +559,14 @@ export const ChatListLegend = memo(
|
|
|
527
559
|
};
|
|
528
560
|
}, []);
|
|
529
561
|
|
|
562
|
+
// Debug-only: this component can stay mounted across a conversationId
|
|
563
|
+
// change (e.g. navigation.replace reusing the same screen instance), so
|
|
564
|
+
// hasSettledRef must reset per-conversation for the spurious-load log
|
|
565
|
+
// above to stay accurate for the newly opened conversation.
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
hasSettledRef.current = false;
|
|
568
|
+
}, [conversationId]);
|
|
569
|
+
|
|
530
570
|
useEffect(() => {
|
|
531
571
|
const bottomSubscription = DeviceEventEmitter.addListener(
|
|
532
572
|
ChatEvent.ScrollToBottom,
|
|
@@ -598,6 +638,7 @@ export const ChatListLegend = memo(
|
|
|
598
638
|
);
|
|
599
639
|
}
|
|
600
640
|
|
|
641
|
+
console.log('messages length: ', +messages.length);
|
|
601
642
|
return (
|
|
602
643
|
<KContainer.View flex background={KColors.white} style={styles.container}>
|
|
603
644
|
<ListComponent
|
|
@@ -621,6 +662,16 @@ export const ChatListLegend = memo(
|
|
|
621
662
|
ListHeaderComponent={
|
|
622
663
|
<LegendChatLoadEarlier isLoading={moreOldLoading} />
|
|
623
664
|
}
|
|
665
|
+
onLoad={(info: { elapsedTimeInMs: number }) => {
|
|
666
|
+
hasSettledRef.current = true;
|
|
667
|
+
if (__DEV__) {
|
|
668
|
+
console.log('[LegendList] onLoad (initial settle done)', {
|
|
669
|
+
conversationId,
|
|
670
|
+
messagesLength: messages.length,
|
|
671
|
+
elapsedTimeInMs: info.elapsedTimeInMs,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}}
|
|
624
675
|
onStartReached={handleStartReached}
|
|
625
676
|
onStartReachedThreshold={0.5}
|
|
626
677
|
onEndReached={handleEndReached}
|
|
@@ -105,33 +105,53 @@ export const ChatMessageInput = memo(
|
|
|
105
105
|
</KContainer.Touchable>
|
|
106
106
|
</KContainer.VisibleView>
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{hasMentions ? (
|
|
108
|
+
{hasMentions ? (
|
|
109
|
+
<TextInput
|
|
110
|
+
ref={inputRef}
|
|
111
|
+
placeholder={placeholder}
|
|
112
|
+
placeholderTextColor={KColors.palette.gray.w200}
|
|
113
|
+
onChangeText={onChangeText}
|
|
114
|
+
onFocus={onFocus}
|
|
115
|
+
onBlur={onBlur}
|
|
116
|
+
onContentSizeChange={onContentSizeChange}
|
|
117
|
+
onSelectionChange={onSelectionChange}
|
|
118
|
+
multiline
|
|
119
|
+
blurOnSubmit={false}
|
|
120
|
+
autoCorrect={false}
|
|
121
|
+
scrollEnabled
|
|
122
|
+
allowFontScaling={false}
|
|
123
|
+
underlineColorAndroid={KColors.transparent}
|
|
124
|
+
selectionColor={KColors.selector}
|
|
125
|
+
textAlignVertical={isMultilineInput ? 'top' : 'center'}
|
|
126
|
+
style={[styles.textInput]}
|
|
127
|
+
>
|
|
129
128
|
<MentionHighlightAdapter
|
|
130
129
|
text={value ?? ''}
|
|
131
130
|
mentions={mentions!}
|
|
132
131
|
/>
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
</TextInput>
|
|
133
|
+
) : (
|
|
134
|
+
<TextInput
|
|
135
|
+
ref={inputRef}
|
|
136
|
+
placeholder={placeholder}
|
|
137
|
+
placeholderTextColor={KColors.palette.gray.w200}
|
|
138
|
+
value={value}
|
|
139
|
+
onChangeText={onChangeText}
|
|
140
|
+
onFocus={onFocus}
|
|
141
|
+
onBlur={onBlur}
|
|
142
|
+
onContentSizeChange={onContentSizeChange}
|
|
143
|
+
onSelectionChange={onSelectionChange}
|
|
144
|
+
multiline
|
|
145
|
+
blurOnSubmit={false}
|
|
146
|
+
autoCorrect={false}
|
|
147
|
+
scrollEnabled
|
|
148
|
+
allowFontScaling={false}
|
|
149
|
+
underlineColorAndroid={KColors.transparent}
|
|
150
|
+
selectionColor={KColors.selector}
|
|
151
|
+
textAlignVertical={isMultilineInput ? 'top' : 'center'}
|
|
152
|
+
style={[styles.textInput]}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
135
155
|
|
|
136
156
|
{renderEmojiNode()}
|
|
137
157
|
</KContainer.View>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback } from 'react';
|
|
2
2
|
import { ActivityIndicator, StyleSheet } from 'react-native';
|
|
3
|
-
import {
|
|
3
|
+
import { FlatList } from 'react-native-gesture-handler';
|
|
4
4
|
import { KContainer, KLabel, KColors, KSpacingValue } from '@droppii/libs';
|
|
5
5
|
import { MentionSuggestionItem } from './MentionSuggestionItem';
|
|
6
6
|
import { trans } from '../../../translation';
|
|
@@ -11,8 +11,9 @@ import type { MentionSuggestion } from '../../../types/mention';
|
|
|
11
11
|
// used as the shared estimate since most rows are the single-line kind.
|
|
12
12
|
const ROW_HEIGHT = 40;
|
|
13
13
|
const ROW_GAP = 8;
|
|
14
|
-
const MAX_VISIBLE_ROWS =
|
|
15
|
-
|
|
14
|
+
const MAX_VISIBLE_ROWS = 5;
|
|
15
|
+
// Fixed, not a cap — see listWrapper's `height` below for why.
|
|
16
|
+
const PANEL_HEIGHT =
|
|
16
17
|
ROW_HEIGHT * MAX_VISIBLE_ROWS + ROW_GAP * (MAX_VISIBLE_ROWS - 1);
|
|
17
18
|
|
|
18
19
|
interface MentionSuggestionListProps {
|
|
@@ -86,11 +87,10 @@ export const MentionSuggestionList = memo(
|
|
|
86
87
|
</KContainer.View>
|
|
87
88
|
) : (
|
|
88
89
|
<KContainer.View style={styles.listWrapper} padding="0.5rem">
|
|
89
|
-
<
|
|
90
|
+
<FlatList
|
|
90
91
|
data={suggestions}
|
|
91
92
|
renderItem={renderItem}
|
|
92
93
|
keyExtractor={keyExtractor}
|
|
93
|
-
estimatedItemSize={ROW_HEIGHT}
|
|
94
94
|
keyboardShouldPersistTaps="handled"
|
|
95
95
|
onEndReached={hasMore ? onLoadMore : undefined}
|
|
96
96
|
onEndReachedThreshold={0.5}
|
|
@@ -124,8 +124,18 @@ const styles = StyleSheet.create({
|
|
|
124
124
|
borderTopRightRadius: KSpacingValue['0.75rem'],
|
|
125
125
|
overflow: 'hidden',
|
|
126
126
|
},
|
|
127
|
+
// Fixed `height`, not `maxHeight` — the panel is absolutely positioned
|
|
128
|
+
// with only `bottom` set (no `top`, see ChatComposer.tsx's mentionPanel
|
|
129
|
+
// style), so its height would otherwise have to be inferred from content
|
|
130
|
+
// through several nested Views. On Android that left the touch/hit-test
|
|
131
|
+
// bounds out of sync with what was actually painted, so drags over the
|
|
132
|
+
// list fell through to the message list rendered underneath instead of
|
|
133
|
+
// scrolling this one. A concrete `height` gives Yoga a definite box from
|
|
134
|
+
// the very first layout pass. Fixed at 5 rows regardless of result count
|
|
135
|
+
// so the scrollable area — and the load-more trigger — stays available
|
|
136
|
+
// even when fewer than 5 suggestions are loaded yet.
|
|
127
137
|
listWrapper: {
|
|
128
|
-
|
|
138
|
+
height: PANEL_HEIGHT,
|
|
129
139
|
},
|
|
130
140
|
separator: {
|
|
131
141
|
height: ROW_GAP,
|
|
@@ -43,35 +43,25 @@ const formatDayLabel = (createdAt: number): string | null => {
|
|
|
43
43
|
|
|
44
44
|
interface LegendChatDayProps {
|
|
45
45
|
createdAt: number;
|
|
46
|
-
/**
|
|
47
|
-
* Render just the clock time, without the day. Used where a cluster broke on
|
|
48
|
-
* elapsed time inside a single day — the date is already on screen above.
|
|
49
|
-
*/
|
|
50
|
-
timeOnly?: boolean;
|
|
51
46
|
}
|
|
52
47
|
|
|
53
|
-
export const LegendChatDay = memo(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<KContainer.View>
|
|
68
|
-
<KLabel.Text typo="TextXsNormal" color={CHAT_BUBBLE_COLORS.dayLabel}>
|
|
69
|
-
{dateStr}
|
|
70
|
-
</KLabel.Text>
|
|
71
|
-
</KContainer.View>
|
|
48
|
+
export const LegendChatDay = memo(({ createdAt }: LegendChatDayProps) => {
|
|
49
|
+
const dateStr = useMemo(() => {
|
|
50
|
+
if (createdAt == null) return null;
|
|
51
|
+
return formatDayLabel(createdAt);
|
|
52
|
+
}, [createdAt]);
|
|
53
|
+
|
|
54
|
+
if (!dateStr) return null;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<KContainer.View center marginV="0.5rem">
|
|
58
|
+
<KContainer.View>
|
|
59
|
+
<KLabel.Text typo="TextXsNormal" color={CHAT_BUBBLE_COLORS.dayLabel}>
|
|
60
|
+
{dateStr}
|
|
61
|
+
</KLabel.Text>
|
|
72
62
|
</KContainer.View>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
);
|
|
63
|
+
</KContainer.View>
|
|
64
|
+
);
|
|
65
|
+
});
|
|
76
66
|
|
|
77
67
|
LegendChatDay.displayName = 'LegendChatDay';
|
|
@@ -31,12 +31,19 @@ export function createOpenIMMentionSearchSource(
|
|
|
31
31
|
): MentionSearchSource {
|
|
32
32
|
return {
|
|
33
33
|
async search({ keyword, limit, offset }): Promise<MentionSearchResult> {
|
|
34
|
+
// The Android bridge does `options.getInt("offset")` unconditionally
|
|
35
|
+
// (OpenImSdkRnModule.java) — an `undefined` value here gets stripped
|
|
36
|
+
// entirely crossing the JS->native bridge (RN drops `undefined`
|
|
37
|
+
// object keys), and the native side throws ("offset") rather than
|
|
38
|
+
// treating it as missing/zero. Guard here at the boundary so this
|
|
39
|
+
// call is safe no matter what the caller passes.
|
|
40
|
+
const safeOffset = Number.isFinite(offset) ? offset : 0;
|
|
34
41
|
const members =
|
|
35
42
|
keyword === ''
|
|
36
43
|
? await OpenIMSDK.getGroupMemberList({
|
|
37
44
|
groupID,
|
|
38
45
|
filter: GroupMemberFilter.All,
|
|
39
|
-
offset,
|
|
46
|
+
offset: safeOffset,
|
|
40
47
|
count: limit,
|
|
41
48
|
})
|
|
42
49
|
: await OpenIMSDK.searchGroupMembers({
|
|
@@ -44,7 +51,7 @@ export function createOpenIMMentionSearchSource(
|
|
|
44
51
|
keywordList: [keyword],
|
|
45
52
|
isSearchUserID: false,
|
|
46
53
|
isSearchMemberNickname: true,
|
|
47
|
-
offset,
|
|
54
|
+
offset: safeOffset,
|
|
48
55
|
count: limit,
|
|
49
56
|
});
|
|
50
57
|
|
|
@@ -141,9 +141,9 @@ const i18nKey: Record<string, string> = {
|
|
|
141
141
|
'chat:info_leave_group': 'Rời nhóm',
|
|
142
142
|
'chat:leave_group_confirm_title': 'Rời nhóm?',
|
|
143
143
|
'chat:leave_group_confirm_desc_private':
|
|
144
|
-
'Sau khi rời nhóm, bạn sẽ không thể xem tin nhắn từ cuộc trò chuyện này nữa. Bạn có chắc chắn muốn rời khỏi nhóm
|
|
144
|
+
'Sau khi rời nhóm, bạn sẽ không thể xem tin nhắn từ cuộc trò chuyện này nữa. Bạn có chắc chắn muốn <bold>rời khỏi nhóm</bold>?',
|
|
145
145
|
'chat:leave_group_confirm_desc_public':
|
|
146
|
-
'Sau khi rời nhóm, bạn sẽ không thể nhắn tin hay nhận thông báo từ cuộc trò chuyện này nữa. Bạn có chắc chắn muốn rời khỏi nhóm
|
|
146
|
+
'Sau khi rời nhóm, bạn sẽ không thể nhắn tin hay nhận thông báo từ cuộc trò chuyện này nữa. Bạn có chắc chắn muốn <bold>rời khỏi nhóm</bold>?',
|
|
147
147
|
'chat:leave_group_confirm_primary': 'Rời nhóm',
|
|
148
148
|
'chat:leave_group_confirm_secondary': 'Ở lại nhóm',
|
|
149
149
|
'chat:leave_group_blocked_title': 'Không thể rời nhóm',
|