@droppii-org/chat-mobile 0.2.77 → 0.2.79

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 (79) hide show
  1. package/lib/module/components/messages/groupNotificationMessage/index.js +5 -3
  2. package/lib/module/components/messages/groupNotificationMessage/index.js.map +1 -1
  3. package/lib/module/core/useChatListener.js +19 -5
  4. package/lib/module/core/useChatListener.js.map +1 -1
  5. package/lib/module/hooks/groups/useGroupAccessGuard.js +145 -0
  6. package/lib/module/hooks/groups/useGroupAccessGuard.js.map +1 -0
  7. package/lib/module/hooks/groups/useLeaveGroup.js +93 -0
  8. package/lib/module/hooks/groups/useLeaveGroup.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/useConversationList.js +6 -9
  12. package/lib/module/hooks/useConversationList.js.map +1 -1
  13. package/lib/module/screens/ChatInfo/ChatInfo.js +17 -2
  14. package/lib/module/screens/ChatInfo/ChatInfo.js.map +1 -1
  15. package/lib/module/screens/ChatInfo/ChatInfoDescriptionRow.js +4 -1
  16. package/lib/module/screens/ChatInfo/ChatInfoDescriptionRow.js.map +1 -1
  17. package/lib/module/screens/chat-detail/ChatDetail.js +12 -10
  18. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  19. package/lib/module/screens/chat-detail/ChatDetailHeader.js +3 -9
  20. package/lib/module/screens/chat-detail/ChatDetailHeader.js.map +1 -1
  21. package/lib/module/screens/inbox/CommunityTab.js +5 -2
  22. package/lib/module/screens/inbox/CommunityTab.js.map +1 -1
  23. package/lib/module/screens/inbox/MessagesTab.js +2 -2
  24. package/lib/module/screens/inbox/MessagesTab.js.map +1 -1
  25. package/lib/module/store/conversation.js +18 -20
  26. package/lib/module/store/conversation.js.map +1 -1
  27. package/lib/module/translation/resources/i18n.js +24 -2
  28. package/lib/module/translation/resources/i18n.js.map +1 -1
  29. package/lib/module/utils/conversation.js +4 -1
  30. package/lib/module/utils/conversation.js.map +1 -1
  31. package/lib/module/utils/legendListMessage.js +5 -1
  32. package/lib/module/utils/legendListMessage.js.map +1 -1
  33. package/lib/module/utils/messageUtils.js +118 -9
  34. package/lib/module/utils/messageUtils.js.map +1 -1
  35. package/lib/module/utils/toastFlashMessage.js +6 -0
  36. package/lib/module/utils/toastFlashMessage.js.map +1 -1
  37. package/lib/typescript/src/components/messages/groupNotificationMessage/index.d.ts.map +1 -1
  38. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  39. package/lib/typescript/src/hooks/groups/useGroupAccessGuard.d.ts +10 -0
  40. package/lib/typescript/src/hooks/groups/useGroupAccessGuard.d.ts.map +1 -0
  41. package/lib/typescript/src/hooks/groups/useLeaveGroup.d.ts +6 -0
  42. package/lib/typescript/src/hooks/groups/useLeaveGroup.d.ts.map +1 -0
  43. package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
  44. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  45. package/lib/typescript/src/hooks/useConversationList.d.ts.map +1 -1
  46. package/lib/typescript/src/screens/ChatInfo/ChatInfo.d.ts.map +1 -1
  47. package/lib/typescript/src/screens/ChatInfo/ChatInfoDescriptionRow.d.ts.map +1 -1
  48. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  49. package/lib/typescript/src/screens/chat-detail/ChatDetailHeader.d.ts +1 -1
  50. package/lib/typescript/src/screens/chat-detail/ChatDetailHeader.d.ts.map +1 -1
  51. package/lib/typescript/src/screens/chat-detail/types.d.ts +0 -1
  52. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  53. package/lib/typescript/src/store/conversation.d.ts.map +1 -1
  54. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  55. package/lib/typescript/src/utils/conversation.d.ts.map +1 -1
  56. package/lib/typescript/src/utils/legendListMessage.d.ts.map +1 -1
  57. package/lib/typescript/src/utils/messageUtils.d.ts +6 -0
  58. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  59. package/lib/typescript/src/utils/toastFlashMessage.d.ts.map +1 -1
  60. package/package.json +5 -2
  61. package/src/components/messages/groupNotificationMessage/index.tsx +8 -4
  62. package/src/core/useChatListener.ts +29 -4
  63. package/src/hooks/groups/useGroupAccessGuard.ts +182 -0
  64. package/src/hooks/groups/useLeaveGroup.ts +104 -0
  65. package/src/hooks/query-keys.ts +1 -0
  66. package/src/hooks/useConversationList.ts +8 -17
  67. package/src/screens/ChatInfo/ChatInfo.tsx +27 -2
  68. package/src/screens/ChatInfo/ChatInfoDescriptionRow.tsx +5 -1
  69. package/src/screens/chat-detail/ChatDetail.tsx +11 -13
  70. package/src/screens/chat-detail/ChatDetailHeader.tsx +2 -6
  71. package/src/screens/chat-detail/types.ts +0 -1
  72. package/src/screens/inbox/CommunityTab.tsx +2 -2
  73. package/src/screens/inbox/MessagesTab.tsx +1 -1
  74. package/src/store/conversation.ts +14 -18
  75. package/src/translation/resources/i18n.ts +31 -1
  76. package/src/utils/conversation.ts +6 -3
  77. package/src/utils/legendListMessage.ts +5 -1
  78. package/src/utils/messageUtils.ts +128 -8
  79. package/src/utils/toastFlashMessage.ts +8 -2
@@ -0,0 +1,104 @@
1
+ import OpenIMSDK, {
2
+ GroupMemberRole,
3
+ GroupVisibility,
4
+ } from '@droppii/openim-rn-client-sdk';
5
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
6
+ import { conversationQueryKeys, groupQueryKeys } from '../query-keys';
7
+ import { UIUtils } from '../../utils/UIUtils';
8
+ import { trans } from '../../translation';
9
+
10
+ const showOwnerBlockedModal = () => {
11
+ UIUtils.alert.open({
12
+ title: trans('chat:leave_group_blocked_title'),
13
+ message: trans('chat:leave_group_blocked_desc'),
14
+ primaryIndex: 0,
15
+ buttonType: 'horizontal-button',
16
+ buttons: [
17
+ { label: trans('chat:leave_group_blocked_button'), kind: 'primary' },
18
+ ],
19
+ });
20
+ };
21
+
22
+ const confirmLeaveGroup = (visibility?: GroupVisibility) =>
23
+ 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
+ ],
46
+ });
47
+ });
48
+
49
+ export const useLeaveGroup = () => {
50
+ const queryClient = useQueryClient();
51
+
52
+ const mutation = useMutation({
53
+ mutationKey: groupQueryKeys.leave(),
54
+ mutationFn: (groupID: string) => OpenIMSDK.quitGroup(groupID),
55
+ onSuccess: (_data, groupID) => {
56
+ queryClient.invalidateQueries({
57
+ queryKey: conversationQueryKeys.joinedGroup(groupID),
58
+ });
59
+ },
60
+ onError: () => {
61
+ UIUtils.toast.open({
62
+ title: trans('chat:generic_error'),
63
+ theme: 'error',
64
+ });
65
+ },
66
+ });
67
+
68
+ const leaveGroup = async (groupID: string, visibility?: GroupVisibility) => {
69
+ try {
70
+ // The owner must transfer ownership before leaving — block instead of
71
+ // showing the confirm modal.
72
+ const selfInfo = await OpenIMSDK.getSelfUserInfo(groupID);
73
+ if (selfInfo?.roleLevel === GroupMemberRole.Owner) {
74
+ showOwnerBlockedModal();
75
+ return false;
76
+ }
77
+ try {
78
+ await confirmLeaveGroup(visibility);
79
+ } catch {
80
+ return false;
81
+ }
82
+
83
+ // Revalidate right before submitting — role/membership may have
84
+ // changed while the confirm modal was open.
85
+ const latestSelfInfo = await OpenIMSDK.getSelfUserInfo(groupID);
86
+ if (latestSelfInfo?.roleLevel === GroupMemberRole.Owner) {
87
+ showOwnerBlockedModal();
88
+ return false;
89
+ }
90
+
91
+ await mutation.mutateAsync(groupID);
92
+ return true;
93
+ } catch (error) {
94
+ console.error('leaveGroup failed', error);
95
+ UIUtils.toast.open({
96
+ title: trans('chat:generic_error'),
97
+ theme: 'error',
98
+ });
99
+ return false;
100
+ }
101
+ };
102
+
103
+ return { leaveGroup, isLeavingGroup: mutation.isPending };
104
+ };
@@ -68,4 +68,5 @@ export const groupQueryKeys = {
68
68
  publicSearch: (keyword: string) =>
69
69
  [...groupQueryKeys.public(), keyword] as const,
70
70
  join: () => [...groupQueryKeys.all, 'join'] as const,
71
+ leave: () => [...groupQueryKeys.all, 'leave'] as const,
71
72
  };
@@ -23,26 +23,17 @@ export function useConversationList({
23
23
  const query = useQuery({
24
24
  queryKey: [...conversationQueryKeys.list(applicationType), page],
25
25
  enabled: enabled !== false,
26
+ staleTime: 1000 * 60 * 1, // 5 minutes
26
27
  queryFn: async () => {
27
28
  const offset = (page - 1) * 500;
28
- const [conversations, joinedGroups] = await Promise.all([
29
- OpenIMSDK.getConversationListSplitApp({
30
- offset,
31
- count: 500,
32
- applicationType,
33
- }),
34
- OpenIMSDK.getJoinedGroupList(),
35
- ]);
29
+ const conversations = await OpenIMSDK.getConversationListSplitApp({
30
+ offset,
31
+ count: 500,
32
+ applicationType,
33
+ });
36
34
 
37
- const joinedGroupIds = new Set(joinedGroups.map((g) => g.groupID));
38
- const resolved = conversations.map((conversation) =>
39
- conversation.groupID && !joinedGroupIds.has(conversation.groupID)
40
- ? { ...conversation, isNotInGroup: true }
41
- : conversation
42
- );
43
-
44
- useConversationStore.getState().updateConversations(resolved);
45
- return resolved;
35
+ useConversationStore.getState().updateConversations(conversations);
36
+ return conversations;
46
37
  },
47
38
  });
48
39
 
@@ -1,7 +1,9 @@
1
- import { memo } from 'react';
1
+ import { memo, useCallback } from 'react';
2
2
  import { KContainer, KNavigation } from '@droppii/libs';
3
3
  import { useNavigation } from '@react-navigation/native';
4
4
  import { useGetConversationTarget } from '../../hooks/conversation/useGetConversationDetail';
5
+ import { useGetGroupInfo } from '../../hooks/conversation/useGetGroupInfo';
6
+ import { useLeaveGroup } from '../../hooks/groups/useLeaveGroup';
5
7
  import { useConversation } from '../../store';
6
8
  import ChatInfoProfile from './ChatInfoProfile';
7
9
  import ChatInfoMediaRow from './ChatInfoMediaRow';
@@ -31,6 +33,29 @@ const ChatInfo = memo(
31
33
  const isTargetActive = target ? target.isActive : true;
32
34
  const isGroup = useChatDetailContext().isGroupConversation;
33
35
 
36
+ const { data: groupInfo } = useGetGroupInfo(
37
+ isGroup ? conversation?.groupID : undefined
38
+ );
39
+ const { leaveGroup } = useLeaveGroup();
40
+
41
+ const handleLeaveGroup = useCallback(async () => {
42
+ if (!conversation?.groupID) return;
43
+
44
+ const didLeave = await leaveGroup(
45
+ conversation.groupID,
46
+ groupInfo?.visibility
47
+ );
48
+ if (didLeave) {
49
+ (onPressLeaveGroup ?? navigation.goBack)();
50
+ }
51
+ }, [
52
+ conversation?.groupID,
53
+ groupInfo?.visibility,
54
+ leaveGroup,
55
+ navigation.goBack,
56
+ onPressLeaveGroup,
57
+ ]);
58
+
34
59
  return (
35
60
  <KContainer.Page flex edges={['bottom']} background="#EBEDF2">
36
61
  <KNavigation.Header
@@ -61,7 +86,7 @@ const ChatInfo = memo(
61
86
  onPressOrders={onPressOrders}
62
87
  onPressCreateGroup={onPressCreateGroup}
63
88
  onPressAddToGroup={onPressAddToGroup}
64
- onPressLeaveGroup={onPressLeaveGroup}
89
+ onPressLeaveGroup={handleLeaveGroup}
65
90
  onDismissGroup={onDismissGroup}
66
91
  />
67
92
  </KContainer.ScrollView>
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useState } from 'react';
1
+ import { memo, useCallback, useEffect, useState } from 'react';
2
2
  import { StyleSheet, type TextLayoutEvent } from 'react-native';
3
3
  import { KContainer, KLabel, KColors } from '@droppii/libs';
4
4
  import { trans } from '../../translation';
@@ -47,6 +47,10 @@ const ChatInfoDescriptionRow = memo(
47
47
  paddingH: '0.75rem' as const,
48
48
  };
49
49
 
50
+ useEffect(() => {
51
+ setDisplayedText(introduction);
52
+ }, [introduction]);
53
+
50
54
  if (!introduction) {
51
55
  return null;
52
56
  }
@@ -35,9 +35,10 @@ import {
35
35
  type MessageItem,
36
36
  } from '@droppii/openim-rn-client-sdk';
37
37
  import { useMessages } from '../../hooks/message/useMessages';
38
- import { ChatEvent, type GroupRemovedPayload } from '../../types/events';
38
+ import { ChatEvent } from '../../types/events';
39
39
  import { useSetIsShowFLashMessage } from '../../context/global';
40
40
  import { useJoinGroup } from '../../hooks/groups/useJoinGroup';
41
+ import { useGroupAccessGuard } from '../../hooks/groups/useGroupAccessGuard';
41
42
 
42
43
  const ChatDetail = memo(
43
44
  ({
@@ -119,16 +120,14 @@ const ChatDetail = memo(
119
120
  };
120
121
  }, [conversationId, targetUserId, targetGroupId, setShowFLashMessage]);
121
122
 
122
- useEffect(() => {
123
- const sub = DeviceEventEmitter.addListener(
124
- ChatEvent.GroupRemoved,
125
- (payload: GroupRemovedPayload) => {
126
- if (payload.groupID !== targetGroupId) return;
127
- (onBack ?? handleBackDefault)();
128
- }
129
- );
130
- return () => sub.remove();
131
- }, [targetGroupId, onBack, handleBackDefault]);
123
+ const handleAccessBlocked = useCallback(() => {
124
+ (onBack ?? handleBackDefault)();
125
+ }, [onBack, handleBackDefault]);
126
+
127
+ const { isCheckingAccess } = useGroupAccessGuard({
128
+ groupID: conversation?.groupID || targetGroupId,
129
+ onBlocked: handleAccessBlocked,
130
+ });
132
131
 
133
132
  const resolvedShowAddMember =
134
133
  showAddMember ?? shouldShowAddMember(conversation);
@@ -197,7 +196,6 @@ const ChatDetail = memo(
197
196
  onPressAddMember={onPressAddMember}
198
197
  onPressMenu={handlePressMenu}
199
198
  onPressAvatar={handlePressMenu}
200
- targetGroupId={targetGroupId}
201
199
  />
202
200
 
203
201
  <PinnedMessageBar
@@ -210,7 +208,7 @@ const ChatDetail = memo(
210
208
  messages={messages}
211
209
  conversationId={conversationId}
212
210
  customMessageItemFactory={customMessageItemFactory}
213
- isLoading={initLoading || isLoadingConversation}
211
+ isLoading={initLoading || isLoadingConversation || isCheckingAccess}
214
212
  onLoadMoreOld={getMoreOldMessages}
215
213
  hasMoreOld={hasMoreOld}
216
214
  moreOldLoading={moreOldLoading}
@@ -11,8 +11,8 @@ import { NamePrefixIcon } from '../../components/ThreadCard/NamePrefixIcon';
11
11
  import type { ChatDetailHeaderProps } from './types';
12
12
  import { getConversationSubtitle } from './conversationHeader.utils';
13
13
  import { useGetUsersInfo } from '../../hooks/users/useGetUsersInfo';
14
- import { useGetConversationDetail } from '../../hooks/conversation/useGetConversationDetail';
15
14
  import { useChatDetailContext } from '../../context';
15
+ import { useConversation } from '../../store';
16
16
 
17
17
  const ChatDetailHeader = memo(
18
18
  ({
@@ -24,12 +24,8 @@ const ChatDetailHeader = memo(
24
24
  onPressAddMember,
25
25
  onPressMenu,
26
26
  onPressAvatar,
27
- targetGroupId,
28
27
  }: ChatDetailHeaderProps) => {
29
- const { conversationData: conversation } = useGetConversationDetail({
30
- targetGroupId,
31
- targetUserId: targetUserId,
32
- });
28
+ const conversation = useConversation(conversationId);
33
29
  const { data: usersInfo } = useGetUsersInfo(
34
30
  targetUserId ? [targetUserId] : []
35
31
  );
@@ -49,7 +49,6 @@ export interface ChatDetailHeaderProps {
49
49
  onPressAvatar?: () => void;
50
50
  conversationId: string;
51
51
  targetUserId?: string;
52
- targetGroupId?: string;
53
52
  }
54
53
 
55
54
  export interface ChatQuickActionsRenderParams {
@@ -1,4 +1,4 @@
1
- import { KContainer, KSpacingValue } from '@droppii/libs';
1
+ import { KColors, KContainer, KSpacingValue } from '@droppii/libs';
2
2
  import { memo, useCallback } from 'react';
3
3
  import { ActivityIndicator, StyleSheet } from 'react-native';
4
4
  import type { SearchPublicGroupInfo } from '@droppii/openim-rn-client-sdk';
@@ -41,7 +41,7 @@ export const CommunityTab = memo(({ onPressGroup }: CommunityTabProps) => {
41
41
  <KContainer.Page flex>
42
42
  {isFetching && !isFetchingNextPage && !isRefetching ? (
43
43
  <KContainer.View flex center>
44
- <ActivityIndicator />
44
+ <ActivityIndicator color={KColors.primary.normal} size="small" />
45
45
  </KContainer.View>
46
46
  ) : (
47
47
  <KContainer.FlatList
@@ -39,7 +39,7 @@ export const MessagesTab = memo(({ onPressThread }: MessagesTabProps) => {
39
39
 
40
40
  const LoadingComponent = (
41
41
  <KContainer.View center flex paddingV="3rem">
42
- <ActivityIndicator size="large" color={KColors.palette.primary.w400} />
42
+ <ActivityIndicator color={KColors.primary.normal} size="small" />
43
43
  </KContainer.View>
44
44
  );
45
45
 
@@ -52,23 +52,14 @@ export const useConversationStore = create<ConversationStore>()((set, get) => ({
52
52
  const existing = get().map[id];
53
53
  if (!existing) return;
54
54
 
55
- const merged = { ...existing, ...data };
56
55
  const nextMap = { ...get().map };
57
- if (merged.isNotInGroup) {
58
- delete nextMap[id];
59
- } else {
60
- nextMap[id] = merged;
61
- }
56
+ nextMap[id] = { ...existing, ...data };
62
57
  set(sortConversation(nextMap));
63
58
  },
64
59
  updateConversations(conversations) {
65
60
  const nextMap = conversations.reduce(
66
61
  (pre, curr) => {
67
- if (curr.isNotInGroup) {
68
- delete pre[curr.conversationID];
69
- } else {
70
- pre[curr.conversationID] = curr;
71
- }
62
+ pre[curr.conversationID] = curr;
72
63
  return pre;
73
64
  },
74
65
  { ...get().map }
@@ -86,14 +77,19 @@ export const useConversationStore = create<ConversationStore>()((set, get) => ({
86
77
  },
87
78
  removeGroupConversation(groupID) {
88
79
  const conversationID = getConversationID(SessionType.Group, groupID);
80
+ const existing = get().map[conversationID];
81
+ if (!existing) return;
89
82
 
90
- const newMap = { ...get().map };
91
- delete newMap[conversationID];
92
-
93
- set({
94
- map: newMap,
95
- list: get().list.filter((item) => item !== conversationID),
96
- });
83
+ // Mark rather than delete: the conversation leaves the inbox and loses its
84
+ // pin, but stays addressable so a public group is still readable. Access
85
+ // itself is decided by useGroupAccessGuard, not by the store.
86
+ const nextMap = { ...get().map };
87
+ nextMap[conversationID] = {
88
+ ...existing,
89
+ isNotInGroup: true,
90
+ isPinned: false,
91
+ };
92
+ set(sortConversation(nextMap));
97
93
  },
98
94
  reset() {
99
95
  set({
@@ -75,16 +75,31 @@ const i18nKey: Record<string, string> = {
75
75
  'chat:group_created_by': '{{sender}} đã tạo nhóm',
76
76
  'msg_type_group_name_updated': 'Đã đổi tên nhóm',
77
77
  'chat:group_name_updated_by': '{{sender}} đã đổi tên nhóm',
78
+ 'chat:group_name_updated_by_named':
79
+ '{{sender}} đã đổi tên nhóm thành {{newGroupName}}',
78
80
  'msg_type_group_info_updated': 'Đã cập nhật thông tin nhóm',
79
81
  'chat:group_info_updated_by': '{{sender}} đã cập nhật thông tin nhóm',
80
82
  'msg_type_member_quit': 'Đã rời khỏi nhóm',
81
- 'chat:member_quit_by': '{{sender}} đã rời khỏi nhóm',
83
+ 'chat:member_quit_by': '{{sender}} đã rời nhóm',
82
84
  'msg_type_group_owner_transferred': 'Đã chuyển quyền trưởng nhóm',
83
85
  'chat:group_owner_transferred_by': '{{sender}} đã chuyển quyền trưởng nhóm',
86
+ 'chat:group_owner_transferred_by_named':
87
+ '{{sender}} đã chuyển quyền trưởng nhóm cho {{memberName}}',
84
88
  'msg_type_member_kicked': 'Đã xoá thành viên khỏi nhóm',
85
89
  'chat:member_kicked_by': '{{sender}} đã xoá thành viên khỏi nhóm',
90
+ 'chat:member_kicked_by_named': '{{sender}} đã xoá {{names}} khỏi nhóm',
86
91
  'msg_type_member_invited': 'Đã thêm thành viên vào nhóm',
87
92
  'chat:member_invited_by': '{{sender}} đã thêm thành viên vào nhóm',
93
+ 'chat:member_invited_by_named': '{{sender}} đã thêm {{names}} vào nhóm',
94
+ 'msg_type_group_member_set_admin': 'Đã chỉ định quản trị viên mới',
95
+ 'chat:group_member_set_admin_by':
96
+ '{{sender}} đã đặt {{memberName}} làm quản trị viên',
97
+ 'chat:group_member_set_admin_by_generic':
98
+ '{{sender}} đã đặt một thành viên làm quản trị viên',
99
+ 'msg_type_message_pinned': 'Đã ghim một tin nhắn',
100
+ 'chat:group_message_pinned_by': '{{sender}} đã ghim một tin nhắn',
101
+ 'msg_type_message_unpinned': 'Đã bỏ ghim một tin nhắn',
102
+ 'chat:group_message_unpinned_by': '{{sender}} đã bỏ ghim một tin nhắn',
88
103
  'msg_type_member_enter': 'Đã tham gia nhóm',
89
104
  'chat:member_enter_by': '{{sender}} đã tham gia nhóm',
90
105
  'msg_type_group_dismissed': 'Nhóm đã bị giải tán',
@@ -124,6 +139,17 @@ const i18nKey: Record<string, string> = {
124
139
  'chat:info_unpin_message': 'Bỏ ghim trò chuyện',
125
140
  'chat:info_delete_history': 'Xoá lịch sử trò chuyện',
126
141
  'chat:info_leave_group': 'Rời nhóm',
142
+ 'chat:leave_group_confirm_title': 'Rời nhóm?',
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?',
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?',
147
+ 'chat:leave_group_confirm_primary': 'Rời nhóm',
148
+ 'chat:leave_group_confirm_secondary': 'Ở lại nhóm',
149
+ 'chat:leave_group_blocked_title': 'Không thể rời nhóm',
150
+ 'chat:leave_group_blocked_desc':
151
+ 'Bạn không thể rời nhóm khi đang là chủ nhóm, vui lòng chuyển quyền chủ nhóm cho thành viên khác và thử lại!',
152
+ 'chat:leave_group_blocked_button': 'Đã hiểu',
127
153
  'chat:dismiss_group': 'Giải tán nhóm',
128
154
  'chat:info_view_more': 'Xem thêm',
129
155
  'chat:group_description_title': 'Mô tả nhóm',
@@ -183,6 +209,10 @@ const i18nKey: Record<string, string> = {
183
209
  'group:dismiss_confirm_primary': 'Giải tán',
184
210
  'group:dismiss_confirm_secondary': 'Quay lại',
185
211
  'group:dismiss_toast_success': 'Đã giải tán nhóm',
212
+ 'group:access_blocked_title': 'Không thể xem cuộc trò chuyện này',
213
+ 'group:access_blocked_desc':
214
+ 'Bạn hiện không có quyền truy cập vào cuộc trò chuyện hoặc tin nhắn này.',
215
+ 'group:access_blocked_button': 'Đã hiểu',
186
216
  };
187
217
 
188
218
  export default {
@@ -38,9 +38,12 @@ export const getConversationID = (
38
38
  };
39
39
 
40
40
  export const sortConversation = (map: Record<string, ConversationItem>) => {
41
- const list = Object.keys(map).sort((a, b) =>
42
- conversationCompare(map[a]!, map[b]!)
43
- );
41
+ // `map` holds every conversation that can still be opened; `list` is the
42
+ // inbox. A group the user is no longer in stays addressable — a public group
43
+ // remains readable via deeplink or search — but must not show in the inbox.
44
+ const list = Object.keys(map)
45
+ .filter((id) => !map[id]?.isNotInGroup)
46
+ .sort((a, b) => conversationCompare(map[a]!, map[b]!));
44
47
  return { map, list };
45
48
  };
46
49
 
@@ -28,8 +28,12 @@ export const getMessageText = (message: MessageItem): string => {
28
28
  case MessageType.FaceMessage:
29
29
  return '[Sticker]';
30
30
  default:
31
+ // Never surface the raw notification payload as message text — it
32
+ // contains other users' userID/nickname/faceURL/permissions and would
33
+ // render verbatim as a normal chat bubble for any content type that
34
+ // isn't (yet) mapped in GROUP_NOTIFICATION_TEXT.
31
35
  if (message.notificationElem?.detail) {
32
- return message.notificationElem.detail;
36
+ return '';
33
37
  }
34
38
  return message.content || '';
35
39
  }
@@ -79,6 +79,120 @@ export const GROUP_NOTIFICATION_TEXT: Partial<
79
79
  previewKey: 'msg_type_group_name_updated',
80
80
  bySenderKey: 'chat:group_name_updated_by',
81
81
  },
82
+ [MessageType.GroupMemberSetToAdmin]: {
83
+ previewKey: 'msg_type_group_member_set_admin',
84
+ bySenderKey: 'chat:group_member_set_admin_by_generic',
85
+ },
86
+ // One content type, two texts (pinned vs unpinned) — the base entry here is
87
+ // just the "unpinned" fallback; resolveNotificationText() below overrides
88
+ // it to the "pinned" keys when notificationElem.detail.isPinned is true.
89
+ [MessageType.PinMsg]: {
90
+ previewKey: 'msg_type_message_unpinned',
91
+ bySenderKey: 'chat:group_message_unpinned_by',
92
+ },
93
+ };
94
+
95
+ const parseNotificationDetail = (message: MessageItem): any => {
96
+ if (!message.notificationElem?.detail) return undefined;
97
+ try {
98
+ return JSON.parse(message.notificationElem.detail);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ };
103
+
104
+ // PM-approved rule for "N members added/removed at once": 1-2 names are
105
+ // listed in full, 3+ collapse to the first two names plus a trailing count.
106
+ // Renders identically to the spec either way — e.g. 4 invites become
107
+ // "B, C và 2 người khác".
108
+ const formatMemberNames = (
109
+ members: Array<{ nickname?: string }> | undefined
110
+ ): string | undefined => {
111
+ const names = (members ?? [])
112
+ .map((member) => member?.nickname)
113
+ .filter((nickname): nickname is string => !!nickname);
114
+
115
+ if (names.length === 0) return undefined;
116
+ if (names.length <= 2) return names.join(', ');
117
+ return `${names[0]}, ${names[1]} và ${names.length - 2} người khác`;
118
+ };
119
+
120
+ export interface ResolvedNotificationText {
121
+ previewKey: string;
122
+ bySenderKey: string;
123
+ params: Record<string, string>;
124
+ }
125
+
126
+ // GROUP_NOTIFICATION_TEXT above only covers the static "no extra data needed"
127
+ // shape. This resolver layers in the per-event params (affected member name,
128
+ // new group name, pinned/unpinned) confirmed against real captured payloads —
129
+ // upgrading to a `_named`/`_by` key when the data is present, and silently
130
+ // falling back to the generic base key when it's missing or malformed so the
131
+ // UI never shows a raw `{{placeholder}}` or throws on a bad payload.
132
+ export const resolveNotificationText = (
133
+ message: MessageItem
134
+ ): ResolvedNotificationText | undefined => {
135
+ const base = GROUP_NOTIFICATION_TEXT[message.contentType];
136
+ if (!base) return undefined;
137
+
138
+ let previewKey = base.previewKey;
139
+ let bySenderKey = base.bySenderKey;
140
+ const params: Record<string, string> = {};
141
+ const detail = parseNotificationDetail(message);
142
+
143
+ switch (message.contentType) {
144
+ case MessageType.GroupNameUpdated: {
145
+ const newGroupName = detail?.group?.groupName;
146
+ if (newGroupName) {
147
+ params.newGroupName = newGroupName;
148
+ bySenderKey = 'chat:group_name_updated_by_named';
149
+ }
150
+ break;
151
+ }
152
+ case MessageType.MemberInvited: {
153
+ const names = formatMemberNames(detail?.invitedUserList);
154
+ if (names) {
155
+ params.names = names;
156
+ bySenderKey = 'chat:member_invited_by_named';
157
+ }
158
+ break;
159
+ }
160
+ case MessageType.MemberKicked: {
161
+ const names = formatMemberNames(detail?.kickedUserList);
162
+ if (names) {
163
+ params.names = names;
164
+ bySenderKey = 'chat:member_kicked_by_named';
165
+ }
166
+ break;
167
+ }
168
+ case MessageType.GroupOwnerTransferred: {
169
+ const memberName = detail?.newGroupOwner?.nickname;
170
+ if (memberName) {
171
+ params.memberName = memberName;
172
+ bySenderKey = 'chat:group_owner_transferred_by_named';
173
+ }
174
+ break;
175
+ }
176
+ case MessageType.GroupMemberSetToAdmin: {
177
+ const memberName = detail?.changedUser?.nickname;
178
+ if (memberName) {
179
+ params.memberName = memberName;
180
+ bySenderKey = 'chat:group_member_set_admin_by';
181
+ }
182
+ break;
183
+ }
184
+ case MessageType.PinMsg: {
185
+ if (detail?.isPinned === true) {
186
+ previewKey = 'msg_type_message_pinned';
187
+ bySenderKey = 'chat:group_message_pinned_by';
188
+ }
189
+ break;
190
+ }
191
+ default:
192
+ break;
193
+ }
194
+
195
+ return { previewKey, bySenderKey, params };
82
196
  };
83
197
 
84
198
  export const extractTextContent = (message: MessageItem): string => {
@@ -118,7 +232,7 @@ export const getMessagePreview = (message?: MessageItem): string => {
118
232
  case MessageType.FileMessage: {
119
233
  const fileName = message?.fileElem?.fileName;
120
234
  if (fileName) {
121
- return `[File] ${fileName}`;
235
+ return `[File] ${truncateMiddle(fileName, 20)}`;
122
236
  }
123
237
  return '[File]';
124
238
  }
@@ -177,9 +291,9 @@ export const getMessagePreview = (message?: MessageItem): string => {
177
291
  return trans('msg_type_burn_message_change');
178
292
 
179
293
  default: {
180
- const groupNotification = GROUP_NOTIFICATION_TEXT[message.contentType];
181
- if (groupNotification) {
182
- return trans(groupNotification.previewKey);
294
+ const resolved = resolveNotificationText(message);
295
+ if (resolved) {
296
+ return trans(resolved.previewKey, resolved.params);
183
297
  }
184
298
  return '[Tin nhắn không được hỗ trợ]';
185
299
  }
@@ -305,8 +419,11 @@ export const getNotificationOpUserNickname = (
305
419
  try {
306
420
  const parsed = JSON.parse(message.notificationElem.detail) as {
307
421
  opUser?: { nickname?: string };
422
+ quitUser?: { nickname?: string };
308
423
  };
309
- return parsed?.opUser?.nickname;
424
+ // MemberQuit puts the leaver in `quitUser`, not `opUser` — every other
425
+ // group-notification content type uses `opUser` for the actor.
426
+ return parsed?.opUser?.nickname ?? parsed?.quitUser?.nickname;
310
427
  } catch {
311
428
  return undefined;
312
429
  }
@@ -353,9 +470,12 @@ export const getLastMessageText = ({
353
470
  return trans('chat:revoked_by_sender', { sender });
354
471
  }
355
472
 
356
- const groupNotification = GROUP_NOTIFICATION_TEXT[message.contentType];
357
- if (groupNotification) {
358
- return trans(groupNotification.bySenderKey, { sender });
473
+ const resolvedNotification = resolveNotificationText(message);
474
+ if (resolvedNotification) {
475
+ return trans(resolvedNotification.bySenderKey, {
476
+ sender,
477
+ ...resolvedNotification.params,
478
+ });
359
479
  }
360
480
 
361
481
  const hideSenderPrefix =