@droppii-org/chat-mobile 0.2.85 → 0.2.87

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 (51) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +14 -5
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/core/useChatListener.js +13 -3
  4. package/lib/module/core/useChatListener.js.map +1 -1
  5. package/lib/module/hooks/groups/useDismissGroup.js +6 -0
  6. package/lib/module/hooks/groups/useDismissGroup.js.map +1 -1
  7. package/lib/module/hooks/groups/useGroupAccessGuard.js +6 -0
  8. package/lib/module/hooks/groups/useGroupAccessGuard.js.map +1 -1
  9. package/lib/module/hooks/groups/useLeaveGroup.js +54 -17
  10. package/lib/module/hooks/groups/useLeaveGroup.js.map +1 -1
  11. package/lib/module/hooks/message/useMessages.js +19 -3
  12. package/lib/module/hooks/message/useMessages.js.map +1 -1
  13. package/lib/module/screens/chat-detail/ChatDetail.js +2 -1
  14. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  15. package/lib/module/screens/chat-detail/ChatListLegend.js +47 -8
  16. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  17. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +16 -6
  18. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -1
  19. package/lib/module/screens/chat-detail/legend/LegendChatDay.js +3 -6
  20. package/lib/module/screens/chat-detail/legend/LegendChatDay.js.map +1 -1
  21. package/lib/module/translation/resources/i18n.js +2 -2
  22. package/lib/module/translation/resources/i18n.js.map +1 -1
  23. package/lib/module/utils/groupExit.js +25 -0
  24. package/lib/module/utils/groupExit.js.map +1 -0
  25. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  26. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  27. package/lib/typescript/src/hooks/groups/useDismissGroup.d.ts.map +1 -1
  28. package/lib/typescript/src/hooks/groups/useGroupAccessGuard.d.ts.map +1 -1
  29. package/lib/typescript/src/hooks/groups/useLeaveGroup.d.ts.map +1 -1
  30. package/lib/typescript/src/hooks/message/useMessages.d.ts +3 -1
  31. package/lib/typescript/src/hooks/message/useMessages.d.ts.map +1 -1
  32. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  33. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  34. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -1
  35. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts +1 -6
  36. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts.map +1 -1
  37. package/lib/typescript/src/utils/groupExit.d.ts +4 -0
  38. package/lib/typescript/src/utils/groupExit.d.ts.map +1 -0
  39. package/package.json +2 -2
  40. package/src/components/ThreadCard/ThreadCard.tsx +20 -3
  41. package/src/core/useChatListener.ts +21 -3
  42. package/src/hooks/groups/useDismissGroup.tsx +8 -0
  43. package/src/hooks/groups/useGroupAccessGuard.ts +6 -0
  44. package/src/hooks/groups/{useLeaveGroup.ts → useLeaveGroup.tsx} +57 -24
  45. package/src/hooks/message/useMessages.ts +26 -2
  46. package/src/screens/chat-detail/ChatDetail.tsx +5 -1
  47. package/src/screens/chat-detail/ChatListLegend.tsx +61 -10
  48. package/src/screens/chat-detail/components/MentionSuggestionList.tsx +16 -6
  49. package/src/screens/chat-detail/legend/LegendChatDay.tsx +17 -27
  50. package/src/translation/resources/i18n.ts +2 -2
  51. package/src/utils/groupExit.ts +26 -0
@@ -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,13 +17,17 @@ 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 { getLastMessageText } from '../utils/messageUtils';
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';
23
27
  import { syncPinnedMessagesOnEvent } from '../hooks/message/usePinMessage';
24
28
  import { belongsToConversation } from '../utils/message';
25
29
  import { getConversationID } from '../utils/conversation';
30
+ import { clearSelfExitedGroup } from '../utils/groupExit';
26
31
  import { DeviceEventEmitter } from 'react-native';
27
32
  import { ChatEvent } from '../types/events';
28
33
  import { conversationQueryKeys, groupQueryKeys } from '../hooks/query-keys';
@@ -64,8 +69,18 @@ export const useChatListener = () => {
64
69
  const latestMsg: MessageItem | null = conversation.latestMsg
65
70
  ? JSON.parse(conversation.latestMsg)
66
71
  : null;
67
-
68
- if (latestMsg?.isInternal) continue;
72
+ console.log('[latestMsg] OnConversationChanged', latestMsg);
73
+
74
+ // Same rule as useMessages: bot-flow automation noise (isInternal) or
75
+ // a group-notification content type in a Bot conversation must not
76
+ // become the visible latest-message preview or trigger a toast.
77
+ const isNoiseMessage =
78
+ !!latestMsg?.isInternal ||
79
+ latestMsg?.contentType === MessageType.LogTextMessage ||
80
+ (conversation?.peerType === PeerType.Bot &&
81
+ !!latestMsg &&
82
+ GROUP_NOTIFICATION_TYPES.includes(latestMsg?.contentType));
83
+ if (isNoiseMessage) continue;
69
84
 
70
85
  visibleConversations.push(conversation);
71
86
 
@@ -274,6 +289,9 @@ export const useChatListener = () => {
274
289
 
275
290
  const markGroupJoined = useCallback(
276
291
  (groupID: string) => {
292
+ // Back in the group: a future removal is no longer the user's own doing.
293
+ clearSelfExitedGroup(groupID);
294
+
277
295
  // Mirror of removeGroupConversation: leaving marks the conversation
278
296
  // `isNotInGroup`, which keeps it out of the inbox, so rejoining has to
279
297
  // clear that flag. This runs on the WS event rather than on joinGroup's
@@ -3,6 +3,10 @@ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
3
3
  import { KContainer, KLabel } from '@droppii/libs';
4
4
  import { UIUtils } from '../../utils/UIUtils';
5
5
  import { trans } from '../../translation';
6
+ import {
7
+ clearSelfExitedGroup,
8
+ markSelfExitedGroup,
9
+ } from '../../utils/groupExit';
6
10
 
7
11
  interface UseDismissGroupProps {
8
12
  onSuccess?: () => void;
@@ -59,6 +63,9 @@ export function useDismissGroup({ onSuccess }: UseDismissGroupProps) {
59
63
  return;
60
64
  }
61
65
 
66
+ // Marked before the call, not after: the SDK can deliver
67
+ // OnGroupDismissed the moment the API returns.
68
+ markSelfExitedGroup(groupID);
62
69
  try {
63
70
  await OpenIMSDK.dismissGroup(groupID);
64
71
  onSuccess?.();
@@ -67,6 +74,7 @@ export function useDismissGroup({ onSuccess }: UseDismissGroupProps) {
67
74
  theme: 'light',
68
75
  });
69
76
  } catch (error) {
77
+ clearSelfExitedGroup(groupID);
70
78
  console.error('dismissGroup failed', error);
71
79
  UIUtils.toast.open({
72
80
  title: trans('chat:generic_error'),
@@ -8,6 +8,7 @@ import { useQueryClient } from '@tanstack/react-query';
8
8
  import { UIUtils } from '../../utils/UIUtils';
9
9
  import { trans } from '../../translation';
10
10
  import { ChatEvent, type GroupRemovedPayload } from '../../types/events';
11
+ import { wasSelfExitedGroup } from '../../utils/groupExit';
11
12
  import { conversationQueryKeys } from '../query-keys';
12
13
 
13
14
  type GroupAccessState =
@@ -142,6 +143,11 @@ export const useGroupAccessGuard = ({ groupID, onBlocked }: Params) => {
142
143
  (payload: GroupRemovedPayload) => {
143
144
  if (payload.groupID !== activeGroupId) return;
144
145
 
146
+ // The user walked out themselves — the screen that asked for it is
147
+ // already navigating away, so a modal here would surface on whatever
148
+ // they landed on.
149
+ if (wasSelfExitedGroup(payload.groupID)) return;
150
+
145
151
  // A dismissed group is gone for everyone — no need to re-check.
146
152
  if (payload.reason === 'dismissed') {
147
153
  block();
@@ -3,9 +3,14 @@ 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';
10
+ import {
11
+ clearSelfExitedGroup,
12
+ markSelfExitedGroup,
13
+ } from '../../utils/groupExit';
9
14
 
10
15
  const showOwnerBlockedModal = () => {
11
16
  UIUtils.alert.open({
@@ -21,28 +26,50 @@ const showOwnerBlockedModal = () => {
21
26
 
22
27
  const confirmLeaveGroup = (visibility?: GroupVisibility) =>
23
28
  new Promise<void>((resolve, reject) => {
24
- console.log('confirmLeaveGroup', visibility);
25
- UIUtils.alert.open({
26
- title: trans('chat:leave_group_confirm_title'),
27
- message: trans(
28
- visibility === GroupVisibility.Public
29
- ? 'chat:leave_group_confirm_desc_public'
30
- : 'chat:leave_group_confirm_desc_private'
31
- ),
32
- primaryIndex: 1,
33
- buttonType: 'horizontal-button',
34
- buttons: [
35
- {
36
- label: trans('chat:leave_group_confirm_secondary'),
37
- kind: 'light',
38
- onPress: reject,
39
- },
40
- {
41
- label: trans('chat:leave_group_confirm_primary'),
42
- kind: 'danger',
43
- onPress: resolve,
44
- },
45
- ],
29
+ UIUtils.popup.open({
30
+ header: {
31
+ title: { text: trans('chat:leave_group_confirm_title') },
32
+ alignment: 'center',
33
+ showCloseButton: true,
34
+ },
35
+ body: {
36
+ renderContent: () => (
37
+ <KContainer.View padding={'1rem'} justifyContent alignItems>
38
+ <KLabel.RichText
39
+ typo="TextMdNormal"
40
+ textAlign="center"
41
+ richTextOptions={{
42
+ i18nKey: trans(
43
+ visibility === GroupVisibility.Public
44
+ ? 'chat:leave_group_confirm_desc_public'
45
+ : 'chat:leave_group_confirm_desc_private'
46
+ ),
47
+ components: {
48
+ bold: <KLabel.Text typo="TextMdBold" color={KColors.black} />,
49
+ },
50
+ }}
51
+ />
52
+ </KContainer.View>
53
+ ),
54
+ },
55
+ actions: {
56
+ buttons: [
57
+ {
58
+ label: trans('chat:leave_group_confirm_secondary'),
59
+ kind: 'light',
60
+ onPress: () => reject(),
61
+ weight: 'medium',
62
+ },
63
+ {
64
+ label: trans('chat:leave_group_confirm_primary'),
65
+ kind: 'danger',
66
+ onPress: () => resolve(),
67
+ isLightSolid: true,
68
+ weight: 'medium',
69
+ },
70
+ ],
71
+ type: 'horizontal-button',
72
+ },
46
73
  });
47
74
  });
48
75
 
@@ -51,13 +78,19 @@ export const useLeaveGroup = () => {
51
78
 
52
79
  const mutation = useMutation({
53
80
  mutationKey: groupQueryKeys.leave(),
54
- mutationFn: (groupID: string) => OpenIMSDK.quitGroup(groupID),
81
+ mutationFn: (groupID: string) => {
82
+ // Marked before the call, not after: the SDK can deliver
83
+ // OnJoinedGroupDeleted the moment the API returns.
84
+ markSelfExitedGroup(groupID);
85
+ return OpenIMSDK.quitGroup(groupID);
86
+ },
55
87
  onSuccess: (_data, groupID) => {
56
88
  queryClient.invalidateQueries({
57
89
  queryKey: conversationQueryKeys.joinedGroup(groupID),
58
90
  });
59
91
  },
60
- onError: () => {
92
+ onError: (_error, groupID) => {
93
+ clearSelfExitedGroup(groupID);
61
94
  UIUtils.toast.open({
62
95
  title: trans('chat:generic_error'),
63
96
  theme: 'error',
@@ -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 messages = useMessageStore((s) => s.messages);
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({ conversationId, startClientMsgId: targetMessageId });
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} timeOnly={!dayStart} />
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('[LegendList] onStartReached triggered', {
458
- hasMoreOld,
459
- moreOldLoading,
460
- willLoad: hasMoreOld && !moreOldLoading,
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 (hasMoreOld && !moreOldLoading) {
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
- if (hasMoreNew && !moreNewLoading) {
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}
@@ -1,6 +1,6 @@
1
1
  import { memo, useCallback } from 'react';
2
2
  import { ActivityIndicator, StyleSheet } from 'react-native';
3
- import { LegendList } from '@legendapp/list/react-native';
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 = 6;
15
- const MAX_PANEL_HEIGHT =
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
- <LegendList
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
- maxHeight: MAX_PANEL_HEIGHT,
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
- ({ createdAt, timeOnly = false }: LegendChatDayProps) => {
55
- const dateStr = useMemo(() => {
56
- if (createdAt == null) return null;
57
- if (!timeOnly) return formatDayLabel(createdAt);
58
-
59
- const date = new Date(createdAt);
60
- return Number.isNaN(date.getTime()) ? null : formatTime(date);
61
- }, [createdAt, timeOnly]);
62
-
63
- if (!dateStr) return null;
64
-
65
- return (
66
- <KContainer.View center marginV="0.5rem">
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';
@@ -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',
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Groups this user deliberately left or dismissed.
3
+ *
4
+ * Leaving fires the same SDK callbacks as being removed by someone else, and
5
+ * the `reason` on ChatEvent.GroupRemoved cannot tell them apart —
6
+ * OnJoinedGroupDeleted reports a self-initiated quit and a kick identically.
7
+ * The access guard needs the distinction: losing access is worth a modal, but
8
+ * walking out is not — the screen that asked for it is already navigating away,
9
+ * so a modal would land on the inbox the user just arrived at.
10
+ *
11
+ * Cleared on rejoin, so a later involuntary removal of the same group is
12
+ * treated as one. Deliberately not time-based: a timer would make correctness
13
+ * depend on how fast the SDK round-trips.
14
+ */
15
+ const selfExitedGroups = new Set<string>();
16
+
17
+ export const markSelfExitedGroup = (groupID: string): void => {
18
+ if (groupID) selfExitedGroups.add(groupID);
19
+ };
20
+
21
+ export const clearSelfExitedGroup = (groupID: string): void => {
22
+ selfExitedGroups.delete(groupID);
23
+ };
24
+
25
+ export const wasSelfExitedGroup = (groupID: string): boolean =>
26
+ selfExitedGroups.has(groupID);