@droppii-org/chat-sdk 0.7.13 → 0.7.14

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 (44) hide show
  1. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageResultItem.d.ts +10 -0
  2. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageResultItem.d.ts.map +1 -0
  3. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageResultItem.js +30 -0
  4. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageSection.d.ts +12 -0
  5. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageSection.d.ts.map +1 -0
  6. package/dist/components/conversation-inbox/contact-search/ContactSearchMessageSection.js +14 -0
  7. package/dist/components/conversation-inbox/contact-search/ContactSearchMessagesTab.d.ts +11 -0
  8. package/dist/components/conversation-inbox/contact-search/ContactSearchMessagesTab.d.ts.map +1 -0
  9. package/dist/components/conversation-inbox/contact-search/ContactSearchMessagesTab.js +31 -0
  10. package/dist/components/conversation-inbox/contact-search/ContactSearchPanel.d.ts +5 -1
  11. package/dist/components/conversation-inbox/contact-search/ContactSearchPanel.d.ts.map +1 -1
  12. package/dist/components/conversation-inbox/contact-search/ContactSearchPanel.js +54 -9
  13. package/dist/components/conversation-inbox/contact-search/constants.js +2 -2
  14. package/dist/components/message/GroupMembersDrawer.d.ts.map +1 -1
  15. package/dist/components/message/GroupMembersDrawer.js +35 -6
  16. package/dist/components/message/GroupMembersPanelCrm.d.ts +5 -1
  17. package/dist/components/message/GroupMembersPanelCrm.d.ts.map +1 -1
  18. package/dist/components/message/GroupMembersPanelCrm.js +7 -3
  19. package/dist/components/message/footer/mention/useMentionOptions.d.ts.map +1 -1
  20. package/dist/components/message/footer/mention/useMentionOptions.js +26 -5
  21. package/dist/hooks/group/useGroupMembers.d.ts +8 -1
  22. package/dist/hooks/group/useGroupMembers.d.ts.map +1 -1
  23. package/dist/hooks/group/useGroupMembers.js +83 -15
  24. package/dist/hooks/group/useSearchGroupMembers.d.ts.map +1 -1
  25. package/dist/hooks/group/useSearchGroupMembers.js +11 -2
  26. package/dist/hooks/search/useAppMessageSearch.d.ts +317 -0
  27. package/dist/hooks/search/useAppMessageSearch.d.ts.map +1 -0
  28. package/dist/hooks/search/useAppMessageSearch.js +53 -0
  29. package/dist/hooks/search/useSearchGlobalMessages.d.ts +306 -0
  30. package/dist/hooks/search/useSearchGlobalMessages.d.ts.map +1 -0
  31. package/dist/hooks/search/useSearchGlobalMessages.js +15 -0
  32. package/dist/hooks/search/useSearchThreadMessages.d.ts +115 -36
  33. package/dist/hooks/search/useSearchThreadMessages.d.ts.map +1 -1
  34. package/dist/hooks/search/useSearchThreadMessages.js +5 -52
  35. package/dist/screens/conversation-inbox/index.d.ts +2 -0
  36. package/dist/screens/conversation-inbox/index.d.ts.map +1 -1
  37. package/dist/screens/conversation-inbox/index.js +2 -2
  38. package/dist/services/query.d.ts +1 -0
  39. package/dist/services/query.d.ts.map +1 -1
  40. package/dist/services/query.js +1 -0
  41. package/dist/styles/global.css +1 -1
  42. package/dist/types/dto.d.ts +15 -2
  43. package/dist/types/dto.d.ts.map +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,10 @@
1
+ import type { AppSearchMessageItem } from "../../../types/dto";
2
+ interface ContactSearchMessageResultItemProps {
3
+ item: AppSearchMessageItem;
4
+ selfUserID: string;
5
+ searchTerm: string;
6
+ onSelect: (item: AppSearchMessageItem) => void;
7
+ }
8
+ declare const ContactSearchMessageResultItem: ({ item, selfUserID, searchTerm, onSelect, }: ContactSearchMessageResultItemProps) => import("react/jsx-runtime").JSX.Element;
9
+ export default ContactSearchMessageResultItem;
10
+ //# sourceMappingURL=ContactSearchMessageResultItem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContactSearchMessageResultItem.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation-inbox/contact-search/ContactSearchMessageResultItem.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAKxD,UAAU,mCAAmC;IAC3C,IAAI,EAAE,oBAAoB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,oBAAoB,KAAK,IAAI,CAAC;CAChD;AAID,QAAA,MAAM,8BAA8B,GAAI,6CAKrC,mCAAmC,4CA+ErC,CAAC;AAEF,eAAe,8BAA8B,CAAC"}
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useTranslation } from "react-i18next";
4
+ import { Icon } from "../../../components/icon";
5
+ import ConversationPeerAvatar from "../../../components/conversation-inbox/ConversationPeerAvatar";
6
+ import { getSearchHitText } from "../../../components/conversation-detail/thread-search/utils";
7
+ import { PeerType } from "../../../types/conversation";
8
+ import { formatTimestamp, splitHighlight } from "../../../utils/common";
9
+ import { resolvePeerBadgeLabel } from "../../../utils/conversation";
10
+ import { isGroupSession } from "../../../utils/imCommon";
11
+ // One message hit: the thread it belongs to on top, the matched text below,
12
+ // prefixed with its sender the way biz-mobile's global search does.
13
+ const ContactSearchMessageResultItem = ({ item, selfUserID, searchTerm, onSelect, }) => {
14
+ var _a;
15
+ const { t } = useTranslation("conversation-inbox");
16
+ const { chatLog, conversation } = item;
17
+ const peerType = (_a = conversation === null || conversation === void 0 ? void 0 : conversation.peerType) !== null && _a !== void 0 ? _a : (isGroupSession(chatLog.sessionType) ? PeerType.GROUP : PeerType.USER);
18
+ const isGroup = peerType === PeerType.GROUP;
19
+ const name = (conversation === null || conversation === void 0 ? void 0 : conversation.showName) || "";
20
+ // 1-1 and bot threads already name the other side in the title.
21
+ const sender = !!selfUserID && chatLog.sendID === selfUserID
22
+ ? t("thread_detail.search.you")
23
+ : isGroup
24
+ ? chatLog.senderNickname || ""
25
+ : "";
26
+ const text = getSearchHitText(chatLog);
27
+ const parts = splitHighlight(text, searchTerm.trim());
28
+ return (_jsxs("button", { type: "button", "data-cp-name": "ContactSearchMessageResultItem", "data-testid": "contact-search-message-result", className: "flex w-full items-center gap-3 border-b border-gray-25 px-3 py-2.5 text-left hover:bg-gray-10", onClick: () => onSelect(item), children: [_jsx(ConversationPeerAvatar, { peerType: peerType, displayName: name, avatarUrl: (conversation === null || conversation === void 0 ? void 0 : conversation.faceURL) || null, badgeLabel: resolvePeerBadgeLabel(peerType, t) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-1", children: [isGroup && (_jsx(Icon, { icon: "user-three-b", size: 16, className: "shrink-0 text-gray-900", "aria-hidden": true })), _jsx("span", { className: "min-w-0 flex-1 truncate text-[16px] font-medium leading-[1.6] text-black", children: name }), _jsx("span", { className: "shrink-0 pl-2 text-[12px] leading-[1.6] text-gray-400", children: formatTimestamp(chatLog.sendTime, { hasTime: true }) })] }), _jsxs("span", { "data-testid": "contact-search-message-preview", className: "flex min-w-0 text-[14px] leading-[1.6] tracking-[0.14px] text-gray-600", children: [sender && (_jsxs(_Fragment, { children: [_jsx("span", { className: "max-w-[50%] shrink-0 truncate", children: sender }), _jsx("span", { className: "shrink-0 whitespace-pre", children: ": " })] })), _jsx("span", { className: "min-w-0 truncate", children: parts ? (_jsxs(_Fragment, { children: [parts.before, _jsx("span", { className: "text-[var(--dc-color-primary)]", children: parts.match }), parts.after] })) : (text) })] })] })] }));
29
+ };
30
+ export default ContactSearchMessageResultItem;
@@ -0,0 +1,12 @@
1
+ import type { AppSearchMessageItem } from "../../../types/dto";
2
+ interface ContactSearchMessageSectionProps {
3
+ items: AppSearchMessageItem[];
4
+ hasMore: boolean;
5
+ searchTerm: string;
6
+ selfUserID: string;
7
+ onSelect: (item: AppSearchMessageItem) => void;
8
+ onViewAll: () => void;
9
+ }
10
+ declare const ContactSearchMessageSection: ({ items, hasMore, searchTerm, selfUserID, onSelect, onViewAll, }: ContactSearchMessageSectionProps) => import("react/jsx-runtime").JSX.Element;
11
+ export default ContactSearchMessageSection;
12
+ //# sourceMappingURL=ContactSearchMessageSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContactSearchMessageSection.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation-inbox/contact-search/ContactSearchMessageSection.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAMxD,UAAU,gCAAgC;IACxC,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAID,QAAA,MAAM,2BAA2B,GAAI,kEAOlC,gCAAgC,4CAqBlC,CAAC;AAEF,eAAe,2BAA2B,CAAC"}
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useTranslation } from "react-i18next";
4
+ import ContactSearchMessageResultItem from "./ContactSearchMessageResultItem";
5
+ import ContactSearchSectionHeader from "./ContactSearchSectionHeader";
6
+ import ContactSearchViewMoreBar from "./ContactSearchViewMoreBar";
7
+ import { CONTACT_SEARCH_PREVIEW_LIMIT } from "./constants";
8
+ // `Tất cả` preview of the `Tin nhắn` tab. No count: the search API always
9
+ // reports a total of 0.
10
+ const ContactSearchMessageSection = ({ items, hasMore, searchTerm, selfUserID, onSelect, onViewAll, }) => {
11
+ const { t } = useTranslation("conversation-inbox");
12
+ return (_jsxs("section", { "data-cp-name": "ContactSearchMessageSection", children: [_jsx(ContactSearchSectionHeader, { title: t("contact_search.tabs.messages"), onSelect: onViewAll }), items.slice(0, CONTACT_SEARCH_PREVIEW_LIMIT).map((item) => (_jsx(ContactSearchMessageResultItem, { item: item, selfUserID: selfUserID, searchTerm: searchTerm, onSelect: onSelect }, item.chatLog.clientMsgID))), hasMore && _jsx(ContactSearchViewMoreBar, { onSelect: onViewAll })] }));
13
+ };
14
+ export default ContactSearchMessageSection;
@@ -0,0 +1,11 @@
1
+ import type { useSearchGlobalMessages } from "../../../hooks/search/useSearchGlobalMessages";
2
+ import type { AppSearchMessageItem } from "../../../types/dto";
3
+ interface ContactSearchMessagesTabProps {
4
+ searchTerm: string;
5
+ search: ReturnType<typeof useSearchGlobalMessages>;
6
+ selfUserID: string;
7
+ onSelect: (item: AppSearchMessageItem) => void;
8
+ }
9
+ declare const ContactSearchMessagesTab: ({ searchTerm, search, selfUserID, onSelect, }: ContactSearchMessagesTabProps) => import("react/jsx-runtime").JSX.Element;
10
+ export default ContactSearchMessagesTab;
11
+ //# sourceMappingURL=ContactSearchMessagesTab.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContactSearchMessagesTab.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation-inbox/contact-search/ContactSearchMessagesTab.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,wCAAwC,CAAC;AACtF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAMxD,UAAU,6BAA6B;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAC;IACnD,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,oBAAoB,KAAK,IAAI,CAAC;CAChD;AAID,QAAA,MAAM,wBAAwB,GAAI,+CAK/B,6BAA6B,4CA6D/B,CAAC;AAEF,eAAe,wBAAwB,CAAC"}
@@ -0,0 +1,31 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Spin } from "antd";
4
+ import InfiniteScroll from "react-infinite-scroll-component";
5
+ import { useTranslation } from "react-i18next";
6
+ import ContactSearchMessageResultItem from "./ContactSearchMessageResultItem";
7
+ import ContactSearchStateMessage from "./ContactSearchStateMessage";
8
+ const SCROLL_TARGET_ID = "contact-search-messages-scroll";
9
+ // Full `Tin nhắn` list. The query lives in the panel so `Tất cả` and this
10
+ // tab share one cache entry.
11
+ const ContactSearchMessagesTab = ({ searchTerm, search, selfUserID, onSelect, }) => {
12
+ const { t } = useTranslation("conversation-inbox");
13
+ const { results, isLoading, isError, hasNextPage, isFetchNextPageError } = search;
14
+ if (!searchTerm) {
15
+ return (_jsx(ContactSearchStateMessage, { testId: "contact-search-messages-initial", children: t("contact_search.initial") }));
16
+ }
17
+ if (isLoading) {
18
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center py-12", children: _jsx(Spin, {}) }));
19
+ }
20
+ if (isError && results.length === 0) {
21
+ return (_jsx(ContactSearchStateMessage, { testId: "contact-search-messages-failed", children: t("contact_search.failed") }));
22
+ }
23
+ // The message search spec dropped the helper line under the title.
24
+ if (results.length === 0) {
25
+ return (_jsx(ContactSearchStateMessage, { testId: "contact-search-messages-no-result", children: t("contact_search.no_result_title") }));
26
+ }
27
+ return (_jsx("div", { id: SCROLL_TARGET_ID, className: "flex-1 overflow-y-auto", children: _jsx(InfiniteScroll, { dataLength: results.length, next: search.fetchNextPage,
28
+ // A failed page would otherwise leave the loader spinning for good.
29
+ hasMore: !!hasNextPage && !isFetchNextPageError, scrollableTarget: SCROLL_TARGET_ID, loader: _jsx("div", { className: "flex items-center justify-center py-3", children: _jsx(Spin, { size: "small" }) }), children: results.map((item) => (_jsx(ContactSearchMessageResultItem, { item: item, selfUserID: selfUserID, searchTerm: searchTerm, onSelect: onSelect }, item.chatLog.clientMsgID))) }) }));
30
+ };
31
+ export default ContactSearchMessagesTab;
@@ -1,7 +1,11 @@
1
1
  interface ContactSearchPanelProps {
2
2
  open: boolean;
3
3
  onClose: () => void;
4
+ /** Searches messages in `Tất cả` and the `Tin nhắn` tab; off keeps the tab's placeholder. */
5
+ showMessageSearch?: boolean;
6
+ /** Host toast for a revoked message hit; the SDK toast shows otherwise. */
7
+ onSearchMessageRevoked?: () => void;
4
8
  }
5
- declare const ContactSearchPanel: ({ open, onClose }: ContactSearchPanelProps) => import("react/jsx-runtime").JSX.Element;
9
+ declare const ContactSearchPanel: ({ open, onClose, showMessageSearch, onSearchMessageRevoked, }: ContactSearchPanelProps) => import("react/jsx-runtime").JSX.Element;
6
10
  export default ContactSearchPanel;
7
11
  //# sourceMappingURL=ContactSearchPanel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ContactSearchPanel.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation-inbox/contact-search/ContactSearchPanel.tsx"],"names":[],"mappings":"AAgCA,UAAU,uBAAuB;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,QAAA,MAAM,kBAAkB,GAAI,mBAAmB,uBAAuB,4CA2TrE,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"ContactSearchPanel.d.ts","sourceRoot":"","sources":["../../../../src/components/conversation-inbox/contact-search/ContactSearchPanel.tsx"],"names":[],"mappings":"AAgDA,UAAU,uBAAuB;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,2EAA2E;IAC3E,sBAAsB,CAAC,EAAE,MAAM,IAAI,CAAC;CACrC;AAED,QAAA,MAAM,kBAAkB,GAAI,+DAKzB,uBAAuB,4CA6XzB,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
@@ -1,17 +1,23 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from "react";
4
- import { Drawer, Input, Spin } from "antd";
4
+ import { Drawer, Input, Spin, message as toastMessage, } from "antd";
5
5
  import { useDebounce } from "ahooks";
6
6
  import InfiniteScroll from "react-infinite-scroll-component";
7
7
  import { useTranslation } from "react-i18next";
8
+ import { MessageType } from "@droppii-org/wasm-client-sdk";
8
9
  import { Icon } from "../../../components/icon";
9
10
  import { useChatNavigate } from "../../../hooks/conversation/useChatNavigate";
10
11
  import { useSearchContacts } from "../../../hooks/search/useSearchContacts";
12
+ import { useSearchGlobalMessages } from "../../../hooks/search/useSearchGlobalMessages";
11
13
  import { useSearchPublicGroups } from "../../../hooks/search/useSearchPublicGroups";
14
+ import useAuthStore from "../../../store/auth";
12
15
  import useConversationStore from "../../../store/conversation";
16
+ import { getConversationIdFromMessage } from "../../../utils/conversation";
13
17
  import ContactSearchCommunitySection from "./ContactSearchCommunitySection";
14
18
  import ContactSearchFilters from "./ContactSearchFilters";
19
+ import ContactSearchMessageSection from "./ContactSearchMessageSection";
20
+ import ContactSearchMessagesTab from "./ContactSearchMessagesTab";
15
21
  import ContactSearchResultItem from "./ContactSearchResultItem";
16
22
  import ContactSearchSectionHeader from "./ContactSearchSectionHeader";
17
23
  import ContactSearchStateMessage from "./ContactSearchStateMessage";
@@ -22,10 +28,13 @@ import { CONTACT_SEARCH_DEBOUNCE_MS, CONTACT_SEARCH_ICON_SIZE, CONTACT_SEARCH_PR
22
28
  import { buildContactSearchParams, resolveContactSearchTarget } from "./utils";
23
29
  const SCROLL_TARGET_ID = "contact-search-scroll";
24
30
  const COMMUNITY_SCROLL_TARGET_ID = "contact-search-community-scroll";
25
- const ContactSearchPanel = ({ open, onClose }) => {
31
+ const ContactSearchPanel = ({ open, onClose, showMessageSearch = false, onSearchMessageRevoked, }) => {
26
32
  const { t } = useTranslation("conversation-inbox");
33
+ const { t: tCommon } = useTranslation();
27
34
  const onNavigate = useChatNavigate();
35
+ const selfUserID = useAuthStore((state) => state.userID);
28
36
  const setPendingResolveTarget = useConversationStore((state) => state.setPendingResolveTarget);
37
+ const setSelectedConversationId = useConversationStore((state) => state.setSelectedConversationId);
29
38
  const [keyword, setKeyword] = useState("");
30
39
  const [activeType, setActiveType] = useState();
31
40
  const [activeTab, setActiveTab] = useState(ContactSearchTab.All);
@@ -54,6 +63,14 @@ const ContactSearchPanel = ({ open, onClose }) => {
54
63
  (activeTab === ContactSearchTab.All ||
55
64
  activeTab === ContactSearchTab.Community),
56
65
  });
66
+ // Same sharing between `Tất cả` and `Tin nhắn`.
67
+ const messages = useSearchGlobalMessages({
68
+ searchTerm: trimmedKeyword,
69
+ enabled: open &&
70
+ showMessageSearch &&
71
+ (activeTab === ContactSearchTab.All ||
72
+ activeTab === ContactSearchTab.Messages),
73
+ });
57
74
  const handleOpenGroup = (groupID) => {
58
75
  const target = { groupId: groupID };
59
76
  setPendingResolveTarget(target);
@@ -68,6 +85,26 @@ const ContactSearchPanel = ({ open, onClose }) => {
68
85
  onClose();
69
86
  onNavigate(`${window.location.pathname}?${params.toString()}`);
70
87
  };
88
+ // A hit opens like a copied message link: the thread from the URL, then a
89
+ // jump to the message.
90
+ const handleSelectMessage = ({ chatLog, conversation }) => {
91
+ if (chatLog.isRevoked || chatLog.contentType === MessageType.RevokeMessage) {
92
+ if (onSearchMessageRevoked)
93
+ onSearchMessageRevoked();
94
+ else
95
+ toastMessage.info(tCommon("message_revoked_or_deleted"));
96
+ return;
97
+ }
98
+ const threadId = (conversation === null || conversation === void 0 ? void 0 : conversation.conversationID) || getConversationIdFromMessage(chatLog);
99
+ if (!threadId)
100
+ return;
101
+ const params = buildContactSearchParams({});
102
+ params.set("threadId", threadId);
103
+ params.set("messageId", chatLog.clientMsgID);
104
+ setSelectedConversationId(threadId);
105
+ onClose();
106
+ onNavigate(`${window.location.pathname}?${params.toString()}`);
107
+ };
71
108
  // Filters belong to the `Liên hệ` tab only, so leaving it drops the active
72
109
  // type — `Tất cả` always previews every contact type.
73
110
  const handleChangeTab = (tab) => {
@@ -93,23 +130,31 @@ const ContactSearchPanel = ({ open, onClose }) => {
93
130
  return renderNoResultState();
94
131
  return null;
95
132
  };
96
- // `Tất cả` previews contacts, then public groups; the message section
97
- // belongs to the message-search ticket and goes between the two.
133
+ // `Tất cả` previews contacts, then messages, then public groups.
98
134
  const renderAllTab = () => {
99
135
  if (!trimmedKeyword)
100
136
  return renderInitialState();
101
- if (isLoading || publicGroups.isLoading)
137
+ if (isLoading || publicGroups.isLoading || messages.isLoading) {
102
138
  return renderLoadingState();
103
- if (isError && publicGroups.isError)
139
+ }
140
+ // A failed next page keeps the hits already loaded.
141
+ const messagesFailed = !showMessageSearch ||
142
+ (messages.isError && messages.results.length === 0);
143
+ if (isError && publicGroups.isError && messagesFailed) {
104
144
  return renderFailedState();
145
+ }
105
146
  // A source that failed drops its own section instead of the whole tab.
106
147
  const showContacts = !isError && contacts.length > 0;
148
+ const showMessages = !messagesFailed && messages.results.length > 0;
107
149
  const showGroups = !publicGroups.isError && publicGroups.groups.length > 0;
108
- if (!showContacts && !showGroups)
150
+ if (!showContacts && !showMessages && !showGroups) {
109
151
  return renderNoResultState();
152
+ }
110
153
  const hasMoreContacts = contacts.length > CONTACT_SEARCH_PREVIEW_LIMIT || !!hasNextPage;
111
154
  const openContactsTab = () => handleChangeTab(ContactSearchTab.Contacts);
112
- return (_jsxs("div", { className: "flex-1 overflow-y-auto", children: [showContacts && (_jsxs("section", { children: [_jsx(ContactSearchSectionHeader, { title: t("contact_search.tabs.contacts"), count: totalElements || undefined, onSelect: openContactsTab }), renderContactItems(contacts.slice(0, CONTACT_SEARCH_PREVIEW_LIMIT)), hasMoreContacts && (_jsx(ContactSearchViewMoreBar, { onSelect: openContactsTab }))] })), showGroups && (_jsx(ContactSearchCommunitySection, { groups: publicGroups.groups, total: publicGroups.total, onOpenGroup: handleOpenGroup, onViewAll: () => handleChangeTab(ContactSearchTab.Community) }))] }));
155
+ // The design separates the sections with a 4px gray-5 band.
156
+ return (_jsxs("div", { className: "flex-1 overflow-y-auto [&>section+section]:border-t-4 [&>section+section]:border-gray-5", children: [showContacts && (_jsxs("section", { children: [_jsx(ContactSearchSectionHeader, { title: t("contact_search.tabs.contacts"), count: totalElements || undefined, onSelect: openContactsTab }), renderContactItems(contacts.slice(0, CONTACT_SEARCH_PREVIEW_LIMIT)), hasMoreContacts && (_jsx(ContactSearchViewMoreBar, { onSelect: openContactsTab }))] })), showMessages && (_jsx(ContactSearchMessageSection, { items: messages.results, hasMore: messages.results.length > CONTACT_SEARCH_PREVIEW_LIMIT ||
157
+ !!messages.hasNextPage, searchTerm: trimmedKeyword, selfUserID: selfUserID, onSelect: handleSelectMessage, onViewAll: () => handleChangeTab(ContactSearchTab.Messages) })), showGroups && (_jsx(ContactSearchCommunitySection, { groups: publicGroups.groups, total: publicGroups.total, onOpenGroup: handleOpenGroup, onViewAll: () => handleChangeTab(ContactSearchTab.Community) }))] }));
113
158
  };
114
159
  const renderContactList = () => (_jsx("div", { id: SCROLL_TARGET_ID, className: "flex-1 overflow-y-auto", children: _jsx(InfiniteScroll, { dataLength: contacts.length, next: fetchNextPage, hasMore: !!hasNextPage, scrollableTarget: SCROLL_TARGET_ID, loader: isFetchingNextPage ? (_jsx("div", { className: "flex items-center justify-center py-3", children: _jsx(Spin, { size: "small" }) })) : null, children: renderContactItems(contacts) }) }));
115
160
  const renderCommunityTab = () => {
@@ -145,7 +190,7 @@ const ContactSearchPanel = ({ open, onClose }) => {
145
190
  {
146
191
  key: ContactSearchTab.Messages,
147
192
  label: t("contact_search.tabs.messages"),
148
- children: (_jsx(ContactSearchStateMessage, { testId: "contact-search-messages", children: t("contact_search.messages_not_available") })),
193
+ children: showMessageSearch ? (_jsx(ContactSearchMessagesTab, { searchTerm: trimmedKeyword, search: messages, selfUserID: selfUserID, onSelect: handleSelectMessage })) : (_jsx(ContactSearchStateMessage, { testId: "contact-search-messages", children: t("contact_search.messages_not_available") })),
149
194
  },
150
195
  {
151
196
  key: ContactSearchTab.Community,
@@ -2,8 +2,8 @@ import { ContactSearchType } from "../../../types/dto";
2
2
  export const CONTACT_SEARCH_DEBOUNCE_MS = 300;
3
3
  export const CONTACT_SEARCH_ICON_SIZE = 18;
4
4
  export const CONTACT_SEARCH_AVATAR_SIZE = 48;
5
- // `Tất cả` caps each section's preview (contacts, public groups) before handing
6
- // the seller over to the full `Liên hệ` / `Cộng đồng` tab.
5
+ // `Tất cả` caps each section's preview (contacts, messages, public groups)
6
+ // before handing the seller over to the full tab.
7
7
  export const CONTACT_SEARCH_PREVIEW_LIMIT = 5;
8
8
  export var ContactSearchTab;
9
9
  (function (ContactSearchTab) {
@@ -1 +1 @@
1
- {"version":3,"file":"GroupMembersDrawer.d.ts","sourceRoot":"","sources":["../../../src/components/message/GroupMembersDrawer.tsx"],"names":[],"mappings":"AAYA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAwB/C,UAAU,uBAAuB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA6BD,QAAA,MAAM,kBAAkB,GAAI,wDAKzB,uBAAuB,4CA4oBzB,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"GroupMembersDrawer.d.ts","sourceRoot":"","sources":["../../../src/components/message/GroupMembersDrawer.tsx"],"names":[],"mappings":"AAaA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AA6B/C,UAAU,uBAAuB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA6BD,QAAA,MAAM,kBAAkB,GAAI,wDAKzB,uBAAuB,4CA6rBzB,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
9
9
  import { useChatNavigate } from "../../hooks/conversation/useChatNavigate";
10
10
  import useAuthStore from "../../store/auth";
11
11
  import useConversationStore from "../../store/conversation";
12
+ import { PeerType } from "../../types/conversation";
12
13
  import { useGroupMembers } from "../../hooks/group/useGroupMembers";
13
14
  import { useSearchGroupMembers } from "../../hooks/group/useSearchGroupMembers";
14
15
  import { useLeaveConversation } from "../../hooks/session/useLeaveConversation";
@@ -25,6 +26,9 @@ import useDeskPanelHostStore from "../../store/deskPanelHost";
25
26
  import DeskSidePanel from "../../components/common/DeskSidePanel";
26
27
  import GroupMembersPanelCrm from "./GroupMembersPanelCrm";
27
28
  import { buildCrmMemberMenu } from "./groupMemberMenuCrm";
29
+ // A group's list loads page by page as the reader scrolls.
30
+ const MEMBER_PAGE_SIZE = 50;
31
+ const LOAD_MORE_THRESHOLD_PX = 80;
28
32
  const getMemberDisplayName = (member) => {
29
33
  return member.nickname || member.userID || "";
30
34
  };
@@ -61,7 +65,14 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
61
65
  // relation to who may manage "members", so those actions are hidden
62
66
  // entirely rather than shown disabled.
63
67
  const isRealGroup = isGroupConversation(conversationData !== null && conversationData !== void 0 ? conversationData : undefined);
64
- const { members, isLoading, error, refetch, patchMemberRole, patchMemberPermissions } = useGroupMembers(groupID);
68
+ // Same split as the header count: a group pages its list and takes its
69
+ // head-count from group info; other threads load and count the whole list.
70
+ const isPagedList = (conversationData === null || conversationData === void 0 ? void 0 : conversationData.peerType) === PeerType.GROUP;
71
+ const groupMemberCount = useConversationStore((state) => { var _a; return groupID ? (_a = state.currentGroupInfo.get(groupID)) === null || _a === void 0 ? void 0 : _a.memberCount : undefined; });
72
+ const { members, isLoading, isLoadingMore, hasMore, loadMore, error, refetch, patchMemberRole, patchMemberPermissions, } = useGroupMembers(groupID, {
73
+ pageSize: isPagedList ? MEMBER_PAGE_SIZE : undefined,
74
+ });
75
+ const totalCount = isPagedList ? groupMemberCount !== null && groupMemberCount !== void 0 ? groupMemberCount : 0 : members.length;
65
76
  const { results: searchResults, isSearching } = useSearchGroupMembers(groupID, debouncedSearch);
66
77
  const { mutateAsync: leaveConversation, isPending: isLeaving } = useLeaveConversation();
67
78
  const permissions = useGroupPermission(groupID);
@@ -69,7 +80,8 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
69
80
  // granularity — BE still rejects an Admin removing another Admin/Owner
70
81
  // (role hierarchy), so the FE must gate per-target here too, or the user
71
82
  // reaches the confirm flow and only then sees a rejection toast.
72
- const actingMemberRoleLevel = useConversationStore((state) => { var _a; return groupID ? (_a = state.currentMemberInGroup.get(groupID)) === null || _a === void 0 ? void 0 : _a.roleLevel : undefined; });
83
+ const actingMember = useConversationStore((state) => groupID ? state.currentMemberInGroup.get(groupID) : undefined);
84
+ const actingMemberRoleLevel = actingMember === null || actingMember === void 0 ? void 0 : actingMember.roleLevel;
73
85
  const actingIsOwner = actingMemberRoleLevel === GroupMemberRole.Owner;
74
86
  const { mutateAsync: setMemberPermissions } = useSetGroupMemberPermissions();
75
87
  const { mutateAsync: kickMember } = useKickGroupMember();
@@ -85,7 +97,13 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
85
97
  console.error("Error parsing conversation extra info:", error);
86
98
  }
87
99
  const isSearchActive = debouncedSearch.trim().length > 0;
88
- const sourceMembers = isSearchActive ? searchResults : members;
100
+ // "Me" leads its role group even before paging reaches my row.
101
+ const listedMembers = useMemo(() => isPagedList &&
102
+ actingMember &&
103
+ !members.some((member) => member.userID === actingMember.userID)
104
+ ? [actingMember, ...members]
105
+ : members, [isPagedList, actingMember, members]);
106
+ const sourceMembers = isSearchActive ? searchResults : listedMembers;
89
107
  const sortedMembers = useMemo(() => {
90
108
  return [...sourceMembers]
91
109
  .map((member) => {
@@ -123,11 +141,22 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
123
141
  if (memberA.isCustomer !== memberB.isCustomer) {
124
142
  return memberA.isCustomer ? -1 : 1;
125
143
  }
144
+ // Paged rows keep core's join order so a new page only appends below.
145
+ if (isPagedList && !isSearchActive)
146
+ return 0;
126
147
  return memberA.nickname.localeCompare(memberB.nickname, "vi", {
127
148
  sensitivity: "base",
128
149
  });
129
150
  });
130
- }, [authUserID, sourceMembers, customerUserID, t]);
151
+ }, [authUserID, sourceMembers, customerUserID, t, isPagedList, isSearchActive]);
152
+ const handleListScroll = (event) => {
153
+ if (isSearchActive || !hasMore || isLoadingMore)
154
+ return;
155
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
156
+ if (scrollHeight - scrollTop - clientHeight < LOAD_MORE_THRESHOLD_PX) {
157
+ void loadMore();
158
+ }
159
+ };
131
160
  const handleOpenDrawer = () => {
132
161
  if (!groupID || disabled) {
133
162
  return;
@@ -279,7 +308,7 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
279
308
  useEffect(() => {
280
309
  closePanel();
281
310
  }, [groupID]);
282
- const renderCrmPanel = (closeOnEscape) => (_jsx(GroupMembersPanelCrm, { totalCount: members.length, members: sortedMembers.map((member) => isRealGroup ? member : Object.assign(Object.assign({}, member), { roleLabel: "" })), isRealGroup: isRealGroup, isLoading: isLoading || (isSearchActive && isSearching), error: error, isSearchActive: isSearchActive, search: search, onSearchChange: setSearch, canAddMember: canAddMember, onAddMember: () => setShowAddMember(true), onClose: handleCloseDrawer, closeOnEscape: closeOnEscape, buildMenu: (member) => buildCrmMemberMenu({
311
+ const renderCrmPanel = (closeOnEscape) => (_jsx(GroupMembersPanelCrm, { totalCount: totalCount, members: sortedMembers.map((member) => isRealGroup ? member : Object.assign(Object.assign({}, member), { roleLabel: "" })), isRealGroup: isRealGroup, isLoading: isLoading || (isSearchActive && isSearching), isLoadingMore: isLoadingMore && !isSearchActive, onListScroll: handleListScroll, error: error, isSearchActive: isSearchActive, search: search, onSearchChange: setSearch, canAddMember: canAddMember, onAddMember: () => setShowAddMember(true), onClose: handleCloseDrawer, closeOnEscape: closeOnEscape, buildMenu: (member) => buildCrmMemberMenu({
283
312
  member,
284
313
  isRealGroup,
285
314
  permissions,
@@ -409,7 +438,7 @@ const GroupMembersDrawer = ({ groupID, conversationId, currentSession, disabled
409
438
  padding: 0,
410
439
  height: "100%",
411
440
  },
412
- }, getContainer: false, destroyOnHidden: true, width: 360, children: _jsxs("div", { className: "flex flex-col h-full bg-white", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-4 border-b border-gray-100", children: [_jsx("span", { className: "text-lg font-medium", children: t("members") }), _jsx(Button, { type: "text", shape: "default", className: "text-gray-500 w-8 h-8 p-0", onClick: handleCloseDrawer, "aria-label": t("close"), children: _jsx(Icon, { icon: "close-b", size: 22 }) })] }), _jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [_jsx(Input, { value: search, onChange: (event) => setSearch(event.target.value), placeholder: t("search_member_placeholder"), prefix: _jsx(Icon, { icon: "search-o", size: 16, className: "text-gray-400" }), className: "h-10" }), canAddMember && (_jsx(Button, { type: "primary", shape: "default", className: "w-10 h-10 p-0 shrink-0 flex items-center justify-center rounded-lg", onClick: () => setShowAddMember(true), children: _jsx(Icon, { icon: "user-add-b", size: 18 }) }))] }), _jsx("div", { className: "flex-1 overflow-y-auto", children: renderContent() })] }) })), groupID && (_jsx(AddMemberModal, { open: showAddMember, groupID: groupID, onClose: () => setShowAddMember(false) })), groupID && (_jsx(AdminPermissionModal, { open: Boolean(adminPermissionMember), groupID: groupID, onClose: () => setAdminPermissionMember(null), member: adminPermissionMember
441
+ }, getContainer: false, destroyOnHidden: true, width: 360, children: _jsxs("div", { className: "flex flex-col h-full bg-white", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-4 border-b border-gray-100", children: [_jsx("span", { className: "text-lg font-medium", children: t("members") }), _jsx(Button, { type: "text", shape: "default", className: "text-gray-500 w-8 h-8 p-0", onClick: handleCloseDrawer, "aria-label": t("close"), children: _jsx(Icon, { icon: "close-b", size: 22 }) })] }), _jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [_jsx(Input, { value: search, onChange: (event) => setSearch(event.target.value), placeholder: t("search_member_placeholder"), prefix: _jsx(Icon, { icon: "search-o", size: 16, className: "text-gray-400" }), className: "h-10" }), canAddMember && (_jsx(Button, { type: "primary", shape: "default", className: "w-10 h-10 p-0 shrink-0 flex items-center justify-center rounded-lg", onClick: () => setShowAddMember(true), children: _jsx(Icon, { icon: "user-add-b", size: 18 }) }))] }), _jsxs("div", { className: "flex-1 overflow-y-auto", onScroll: handleListScroll, children: [renderContent(), isLoadingMore && !isSearchActive && (_jsx("div", { className: "flex justify-center py-3", children: _jsx(Spin, { size: "small" }) }))] })] }) })), groupID && (_jsx(AddMemberModal, { open: showAddMember, groupID: groupID, onClose: () => setShowAddMember(false) })), groupID && (_jsx(AdminPermissionModal, { open: Boolean(adminPermissionMember), groupID: groupID, onClose: () => setAdminPermissionMember(null), member: adminPermissionMember
413
442
  ? {
414
443
  userID: adminPermissionMember.userID,
415
444
  nickname: adminPermissionMember.nickname,
@@ -1,11 +1,15 @@
1
+ import type { UIEvent } from "react";
1
2
  import type { MenuProps } from "antd";
2
3
  import type { MemberDisplayItem } from "./groupMemberTypes";
3
4
  interface GroupMembersPanelCrmProps {
5
+ /** Whole-thread head-count; `members` may be only the pages loaded so far. */
4
6
  totalCount: number;
5
7
  members: MemberDisplayItem[];
6
8
  /** Real groups split owners/admins from members; support threads show one list. */
7
9
  isRealGroup: boolean;
8
10
  isLoading: boolean;
11
+ isLoadingMore: boolean;
12
+ onListScroll: (event: UIEvent<HTMLDivElement>) => void;
9
13
  error?: string;
10
14
  isSearchActive: boolean;
11
15
  search: string;
@@ -18,6 +22,6 @@ interface GroupMembersPanelCrmProps {
18
22
  buildMenu: (member: MemberDisplayItem) => MenuProps["items"];
19
23
  }
20
24
  /** CRM members panel: search + add, owners/admins and members lists, per-member action menu. */
21
- declare const GroupMembersPanelCrm: ({ totalCount, members, isRealGroup, isLoading, error, isSearchActive, search, onSearchChange, canAddMember, onAddMember, onClose, closeOnEscape, buildMenu, }: GroupMembersPanelCrmProps) => import("react/jsx-runtime").JSX.Element;
25
+ declare const GroupMembersPanelCrm: ({ totalCount, members, isRealGroup, isLoading, isLoadingMore, onListScroll, error, isSearchActive, search, onSearchChange, canAddMember, onAddMember, onClose, closeOnEscape, buildMenu, }: GroupMembersPanelCrmProps) => import("react/jsx-runtime").JSX.Element;
22
26
  export default GroupMembersPanelCrm;
23
27
  //# sourceMappingURL=GroupMembersPanelCrm.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"GroupMembersPanelCrm.d.ts","sourceRoot":"","sources":["../../../src/components/message/GroupMembersPanelCrm.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,MAAM,CAAC;AAMtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,UAAU,yBAAyB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B,mFAAmF;IACnF,WAAW,EAAE,OAAO,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,SAAS,CAAC,OAAO,CAAC,CAAC;CAC9D;AAQD,gGAAgG;AAChG,QAAA,MAAM,oBAAoB,GAAI,+JAc3B,yBAAyB,4CA0H3B,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
1
+ {"version":3,"file":"GroupMembersPanelCrm.d.ts","sourceRoot":"","sources":["../../../src/components/message/GroupMembersPanelCrm.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA4B,OAAO,EAAE,MAAM,OAAO,CAAC;AAE/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,MAAM,CAAC;AAMtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,UAAU,yBAAyB;IACjC,8EAA8E;IAC9E,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B,mFAAmF;IACnF,WAAW,EAAE,OAAO,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,SAAS,CAAC,OAAO,CAAC,CAAC;CAC9D;AAQD,gGAAgG;AAChG,QAAA,MAAM,oBAAoB,GAAI,4LAgB3B,yBAAyB,4CAiI3B,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
@@ -10,7 +10,7 @@ import { FOCUS_RING_CLASS } from "../../utils/a11yClickable";
10
10
  const CHIP = "inline-flex h-[22px] shrink-0 items-center gap-1 whitespace-nowrap rounded-full px-2 text-xs font-medium";
11
11
  const SectionTitle = ({ children }) => (_jsx("h3", { className: "m-0 px-2 pb-1 pt-3 text-xs font-medium text-gray-600", children: children }));
12
12
  /** CRM members panel: search + add, owners/admins and members lists, per-member action menu. */
13
- const GroupMembersPanelCrm = ({ totalCount, members, isRealGroup, isLoading, error, isSearchActive, search, onSearchChange, canAddMember, onAddMember, onClose, closeOnEscape, buildMenu, }) => {
13
+ const GroupMembersPanelCrm = ({ totalCount, members, isRealGroup, isLoading, isLoadingMore, onListScroll, error, isSearchActive, search, onSearchChange, canAddMember, onAddMember, onClose, closeOnEscape, buildMenu, }) => {
14
14
  const { t } = useTranslation();
15
15
  const rootRef = useRef(null);
16
16
  useEffect(() => {
@@ -26,6 +26,10 @@ const GroupMembersPanelCrm = ({ totalCount, members, isRealGroup, isLoading, err
26
26
  };
27
27
  const privileged = isRealGroup ? members.filter((m) => m.isOwner || m.isAdmin) : [];
28
28
  const regular = isRealGroup ? members.filter((m) => !m.isOwner && !m.isAdmin) : members;
29
+ // Owners and admins come first in core's order, so the rest of the head-count is regular members.
30
+ const regularCount = isSearchActive
31
+ ? regular.length
32
+ : Math.max(totalCount - privileged.length, regular.length);
29
33
  const renderRow = (member) => {
30
34
  const items = isRealGroup || member.isCurrentUser ? buildMenu(member) : undefined;
31
35
  const roleChip = !isRealGroup && member.isOwner
@@ -48,8 +52,8 @@ const GroupMembersPanelCrm = ({ totalCount, members, isRealGroup, isLoading, err
48
52
  if (!members.length) {
49
53
  return _jsx(Empty, { description: isSearchActive ? t("no_member_found") : t("no_group_members") });
50
54
  }
51
- return (_jsxs(_Fragment, { children: [privileged.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { children: t("members_owner_admin_section") }), renderList(privileged)] })), regular.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { children: t("members_regular_section", { count: regular.length }) }), renderList(regular)] }))] }));
55
+ return (_jsxs(_Fragment, { children: [privileged.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { children: t("members_owner_admin_section") }), renderList(privileged)] })), regular.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { children: t("members_regular_section", { count: regularCount }) }), renderList(regular)] }))] }));
52
56
  };
53
- return (_jsxs("div", { ref: rootRef, tabIndex: -1, onKeyDown: handleKeyDown, "aria-label": t("members"), className: "flex h-full flex-col bg-white outline-none", "data-cp-name": "GroupMembersPanelCrm", children: [_jsx(GroupPanelHeader, { title: t("members_panel_title", { count: totalCount }), onClose: onClose }), _jsxs("div", { className: "flex gap-2 px-4 pb-1 pt-3", children: [_jsxs("label", { className: "flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg bg-gray-5 px-2.5 text-gray-600 focus-within:outline focus-within:outline-2 focus-within:outline-primary-200", children: [_jsx("span", { className: "sr-only", children: t("members_search_label") }), _jsx(Icon, { icon: "search-o", size: 16, className: "shrink-0" }), _jsx("input", { type: "search", value: search, onChange: (event) => onSearchChange(event.target.value), placeholder: t("members_search_label"), className: "min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-gray-900 outline-none placeholder:text-gray-600" })] }), canAddMember && (_jsx(Button, { type: "primary", className: "h-9 shrink-0 px-3", icon: _jsx(Icon, { icon: "user-add-o", size: 16 }), onClick: onAddMember, children: t("members_add") }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-2 pb-2", children: renderBody() })] }));
57
+ return (_jsxs("div", { ref: rootRef, tabIndex: -1, onKeyDown: handleKeyDown, "aria-label": t("members"), className: "flex h-full flex-col bg-white outline-none", "data-cp-name": "GroupMembersPanelCrm", children: [_jsx(GroupPanelHeader, { title: t("members_panel_title", { count: totalCount }), onClose: onClose }), _jsxs("div", { className: "flex gap-2 px-4 pb-1 pt-3", children: [_jsxs("label", { className: "flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg bg-gray-5 px-2.5 text-gray-600 focus-within:outline focus-within:outline-2 focus-within:outline-primary-200", children: [_jsx("span", { className: "sr-only", children: t("members_search_label") }), _jsx(Icon, { icon: "search-o", size: 16, className: "shrink-0" }), _jsx("input", { type: "search", value: search, onChange: (event) => onSearchChange(event.target.value), placeholder: t("members_search_label"), className: "min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-gray-900 outline-none placeholder:text-gray-600" })] }), canAddMember && (_jsx(Button, { type: "primary", className: "h-9 shrink-0 px-3", icon: _jsx(Icon, { icon: "user-add-o", size: 16 }), onClick: onAddMember, children: t("members_add") }))] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-2 pb-2", onScroll: onListScroll, children: [renderBody(), isLoadingMore && _jsx("div", { className: "flex justify-center py-3", children: _jsx(Spin, { size: "small" }) })] })] }));
54
58
  };
55
59
  export default GroupMembersPanelCrm;
@@ -1 +1 @@
1
- {"version":3,"file":"useMentionOptions.d.ts","sourceRoot":"","sources":["../../../../../src/components/message/footer/mention/useMentionOptions.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAe7C,eAAO,MAAM,iBAAiB,GAC5B,SAAS,MAAM,GAAG,SAAS,EAC3B,OAAO,MAAM,KACZ,aAAa,EAyCf,CAAC"}
1
+ {"version":3,"file":"useMentionOptions.d.ts","sourceRoot":"","sources":["../../../../../src/components/message/footer/mention/useMentionOptions.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAoB7C,eAAO,MAAM,iBAAiB,GAC5B,SAAS,MAAM,GAAG,SAAS,EAC3B,OAAO,MAAM,KACZ,aAAa,EA2Df,CAAC"}
@@ -1,8 +1,13 @@
1
- import { useMemo } from "react";
1
+ import { useEffect, useMemo } from "react";
2
2
  import { useTranslation } from "react-i18next";
3
3
  import { useGroupMembers } from "../../../../hooks/group/useGroupMembers";
4
+ import { useSearchGroupMembers } from "../../../../hooks/group/useSearchGroupMembers";
4
5
  import useAuthStore from "../../../../store/auth";
5
6
  export const ALL_MEMBERS_USER_ID = "__all__";
7
+ // Typing a name loads a big group's remaining members up to this many.
8
+ const MENTION_MEMBER_LOAD_LIMIT = 10000;
9
+ // The menu never renders more rows than one member page.
10
+ const MENTION_OPTION_LIMIT = 999;
6
11
  // Prefix matches rank above mid-string matches, then alphabetical — matches
7
12
  // the "closest match first" ranking spec'd for this feature (not just a
8
13
  // plain filter). No keyword: falls back to plain A-Z (AC-Open default).
@@ -18,13 +23,28 @@ const matchRank = (displayName, keyword) => {
18
23
  // left to handle here.
19
24
  export const useMentionOptions = (groupID, query) => {
20
25
  const { t } = useTranslation();
21
- const { members } = useGroupMembers(groupID);
26
+ const { members, hasMore, isLoadingMore, loadMore } = useGroupMembers(groupID);
27
+ // Core keeps only part of a big group locally, so its search alone misses
28
+ // members; typing a name also loads the rest, merged in as pages arrive.
29
+ const { results: searchResults } = useSearchGroupMembers(groupID, hasMore ? query : "");
22
30
  const currentUserID = useAuthStore((state) => state.userID);
31
+ const hasKeyword = query.trim().length > 0;
32
+ useEffect(() => {
33
+ if (!hasKeyword || !hasMore || isLoadingMore)
34
+ return;
35
+ if (members.length >= MENTION_MEMBER_LOAD_LIMIT)
36
+ return;
37
+ void loadMore();
38
+ }, [hasKeyword, hasMore, isLoadingMore, members.length, loadMore]);
23
39
  return useMemo(() => {
24
40
  const keyword = query.trim().toLowerCase();
41
+ const loadedIDs = new Set(members.map((member) => member.userID));
42
+ const candidates = keyword
43
+ ? [...members, ...searchResults.filter((member) => !loadedIDs.has(member.userID))]
44
+ : members;
25
45
  // Self-tagging isn't a useful mention target — the sender already
26
46
  // knows who they are — so the current user is filtered out entirely.
27
- const memberOptions = members
47
+ const memberOptions = candidates
28
48
  .filter((member) => member.userID !== currentUserID)
29
49
  .map((member) => ({
30
50
  userID: member.userID,
@@ -40,7 +60,8 @@ export const useMentionOptions = (groupID, query) => {
40
60
  return rankDiff !== 0
41
61
  ? rankDiff
42
62
  : a.displayName.localeCompare(b.displayName);
43
- });
63
+ })
64
+ .slice(0, MENTION_OPTION_LIMIT);
44
65
  const allOption = {
45
66
  userID: ALL_MEMBERS_USER_ID,
46
67
  displayName: t("mention.all_members"),
@@ -48,5 +69,5 @@ export const useMentionOptions = (groupID, query) => {
48
69
  };
49
70
  const matchesAll = !keyword || allOption.displayName.toLowerCase().includes(keyword);
50
71
  return matchesAll ? [allOption, ...memberOptions] : memberOptions;
51
- }, [members, query, t, currentUserID]);
72
+ }, [members, searchResults, query, t, currentUserID]);
52
73
  };
@@ -1,12 +1,19 @@
1
1
  import { GroupMemberItem, GroupMemberRole } from "@droppii-org/wasm-client-sdk";
2
+ interface UseGroupMembersOptions {
3
+ /** Rows per getGroupMemberList call; callers that never loadMore get one page. */
4
+ pageSize?: number;
5
+ }
2
6
  interface UseGroupMembersResult {
3
7
  members: GroupMemberItem[];
4
8
  isLoading: boolean;
9
+ isLoadingMore: boolean;
10
+ hasMore: boolean;
5
11
  error?: string;
6
12
  refetch: () => Promise<void>;
13
+ loadMore: () => Promise<void>;
7
14
  patchMemberRole: (userID: string, roleLevel: GroupMemberRole) => void;
8
15
  patchMemberPermissions: (userID: string, permissions: string[]) => void;
9
16
  }
10
- export declare const useGroupMembers: (groupID?: string) => UseGroupMembersResult;
17
+ export declare const useGroupMembers: (groupID?: string, { pageSize }?: UseGroupMembersOptions) => UseGroupMembersResult;
11
18
  export {};
12
19
  //# sourceMappingURL=useGroupMembers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useGroupMembers.d.ts","sourceRoot":"","sources":["../../../src/hooks/group/useGroupMembers.ts"],"names":[],"mappings":"AACA,OAAO,EAAY,eAAe,EAAE,eAAe,EAA4B,MAAM,8BAA8B,CAAC;AAMpH,UAAU,qBAAqB;IAC7B,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACtE,sBAAsB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACzE;AAED,eAAO,MAAM,eAAe,GAC1B,UAAU,MAAM,KACf,qBAyGF,CAAC"}
1
+ {"version":3,"file":"useGroupMembers.d.ts","sourceRoot":"","sources":["../../../src/hooks/group/useGroupMembers.ts"],"names":[],"mappings":"AACA,OAAO,EAAY,eAAe,EAAE,eAAe,EAA4B,MAAM,8BAA8B,CAAC;AAKpH,UAAU,sBAAsB;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,qBAAqB;IAC7B,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACtE,sBAAsB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACzE;AAOD,eAAO,MAAM,eAAe,GAC1B,UAAU,MAAM,EAChB,eAAkC,sBAA2B,KAC5D,qBAyKF,CAAC"}