@ermis-network/ermis-chat-react 1.0.9 → 2.0.1

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 (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ermis-network/ermis-chat-react",
3
- "version": "1.0.9",
3
+ "version": "2.0.1",
4
4
  "description": "React UI components for Ermis Chat",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
@@ -20,8 +20,10 @@
20
20
  "/src"
21
21
  ],
22
22
  "dependencies": {
23
- "@ermis-network/ermis-chat-sdk": "1.0.9",
24
- "virtua": "^0.48.8"
23
+ "@ermis-network/ermis-chat-sdk": "2.0.1",
24
+ "react-ts-audio-recorder": "^1.1.4",
25
+ "virtua": "^0.48.8",
26
+ "frimousse": "^0.3.0"
25
27
  },
26
28
  "peerDependencies": {
27
29
  "react": ">=18.0.0",
@@ -30,13 +32,16 @@
30
32
  "devDependencies": {
31
33
  "@types/react": "^18.2.0",
32
34
  "@types/react-dom": "^18.2.0",
35
+ "lucide-react": "^0.474.0",
36
+ "motion": "^12.0.0",
33
37
  "react": "^18.2.0",
34
38
  "react-dom": "^18.2.0",
39
+ "react-virtuoso": "^4.12.5",
35
40
  "tsup": "^8.0.0",
36
41
  "typescript": "^5.9.3"
37
42
  },
38
43
  "scripts": {
39
- "build": "rm -rf dist && tsup",
44
+ "build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsup",
40
45
  "dev": "tsup --watch"
41
46
  }
42
47
  }
@@ -22,7 +22,7 @@ export function isDirectChannel(channel: Channel | null | undefined): boolean {
22
22
 
23
23
  /** Channel is a topic (sub-channel of a group channel) */
24
24
  export function isTopicChannel(channel: Channel | null | undefined): boolean {
25
- return channel ? (channel.type === 'topic' || Boolean(channel.data?.parent_cid)) : false;
25
+ return channel ? channel.type === 'topic' || Boolean(channel.data?.parent_cid) : false;
26
26
  }
27
27
 
28
28
  /** Channel is a public group that users can join without invite */
@@ -1,5 +1,6 @@
1
1
  import React, { useMemo, useState } from 'react';
2
2
  import { MediaLightbox } from './MediaLightbox';
3
+ import { getAvatarGradient } from '../utils/avatarColors';
3
4
  import type { AvatarProps } from '../types';
4
5
 
5
6
  export type { AvatarProps } from '../types';
@@ -85,7 +86,7 @@ export const Avatar: React.FC<AvatarProps> = React.memo(({
85
86
  {/* 1. Underlying Fallback (Placeholder) */}
86
87
  <div
87
88
  className="ermis-avatar ermis-avatar--fallback"
88
- style={contentStyle}
89
+ style={{ ...contentStyle, background: getAvatarGradient(name) }}
89
90
  title={name}
90
91
  >
91
92
  {initials}
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
+ import { useChatComponents } from '../context/ChatComponentsContext';
3
4
  import { useBannedState } from '../hooks/useBannedState';
4
5
  import { useBlockedState } from '../hooks/useBlockedState';
5
6
  import { ForwardMessageModal } from './ForwardMessageModal';
@@ -25,18 +26,18 @@ export const Channel: React.FC<ChannelProps> = React.memo(({
25
26
  className,
26
27
  EmptyStateIndicator = DefaultEmpty,
27
28
  HeaderComponent,
28
- ForwardMessageModalComponent = ForwardMessageModal,
29
+ ForwardMessageModalComponent: ForwardMessageModalProp,
29
30
  }) => {
30
31
  const { activeChannel, client, forwardingMessage, setForwardingMessage } = useChatClient();
32
+ const { ForwardMessageModalComponent: ForwardMessageModalContext } = useChatComponents();
33
+
34
+ const ForwardMessageModalView = ForwardMessageModalProp || ForwardMessageModalContext || ForwardMessageModal;
31
35
  const { isBanned } = useBannedState(activeChannel, client.userID);
32
36
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
33
37
 
34
38
  // Force re-render when channel info is updated via WS
35
39
  const [channelUpdateCount, setChannelUpdateCount] = useState(0);
36
40
  useEffect(() => {
37
-
38
- console.log('---activeChannel--', activeChannel)
39
-
40
41
  if (!activeChannel) return;
41
42
  const sub = activeChannel.on('channel.updated', () => setChannelUpdateCount((c) => c + 1));
42
43
  return () => sub.unsubscribe();
@@ -64,7 +65,7 @@ export const Channel: React.FC<ChannelProps> = React.memo(({
64
65
  {HeaderComponent && headerData && <HeaderComponent {...headerData} />}
65
66
  {children}
66
67
  {forwardingMessage && (
67
- <ForwardMessageModalComponent
68
+ <ForwardMessageModalView
68
69
  message={forwardingMessage}
69
70
  onDismiss={() => setForwardingMessage(null)}
70
71
  />
@@ -32,6 +32,8 @@ export function computeDefaultActions(
32
32
  onAddTopic?: (channel: Channel) => void;
33
33
  onEditTopic?: (channel: Channel) => void;
34
34
  onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
35
+ onDeleteTopic?: (channel: Channel) => void;
36
+ onTruncateChannel?: (channel: Channel) => void;
35
37
  isBlocked?: boolean;
36
38
  actionLabels?: ChannelActionLabels;
37
39
  actionIcons?: ChannelActionIcons;
@@ -99,6 +101,25 @@ export function computeDefaultActions(
99
101
  }
100
102
  },
101
103
  });
104
+
105
+ // Direct channel: Truncate / Clear history
106
+ actions.push({
107
+ id: 'truncate',
108
+ label: actionLabels?.truncateChannel || 'Clear history',
109
+ icon: actionIcons?.TruncateChannelIcon || <TrashIcon />,
110
+ isDanger: true,
111
+ onClick: async (ch) => {
112
+ if (options?.onTruncateChannel) {
113
+ await options.onTruncateChannel(ch);
114
+ } else {
115
+ try {
116
+ await ch.truncate();
117
+ } catch (e) {
118
+ console.error('Error clearing channel history', e);
119
+ }
120
+ }
121
+ },
122
+ });
102
123
  } else if (isTopic) {
103
124
  // Topic: Edit topic (owner & moder only)
104
125
  if (canManageChannel(role)) {
@@ -118,8 +139,46 @@ export function computeDefaultActions(
118
139
  label: isClosed ? (actionLabels?.reopenTopic || 'Reopen topic') : (actionLabels?.closeTopic || 'Close topic'),
119
140
  icon: isClosed ? (actionIcons?.ReopenTopicIcon || <UnlockIcon />) : (actionIcons?.CloseTopicIcon || <LockIcon />),
120
141
  isDanger: !isClosed,
121
- onClick: (ch) => {
122
- options?.onToggleCloseTopic?.(ch, isClosed);
142
+ onClick: async (ch) => {
143
+ if (options?.onToggleCloseTopic) {
144
+ await options.onToggleCloseTopic(ch, isClosed);
145
+ return;
146
+ }
147
+ // Default behavior: call SDK API directly
148
+ const parentCid = ch.data?.parent_cid as string | undefined;
149
+ if (!parentCid) return;
150
+ try {
151
+ const client = ch.getClient();
152
+ const parentChannel = client.activeChannels[parentCid];
153
+ if (!parentChannel) return;
154
+ if (isClosed) {
155
+ await parentChannel.reopenTopic(ch.cid);
156
+ } else {
157
+ await parentChannel.closeTopic(ch.cid);
158
+ }
159
+ } catch (err) {
160
+ console.error('Failed to toggle topic close state', err);
161
+ }
162
+ },
163
+ });
164
+ }
165
+ // Topic: Delete (owner only)
166
+ if (role === CHANNEL_ROLES.OWNER) {
167
+ actions.push({
168
+ id: 'delete_topic',
169
+ label: actionLabels?.deleteTopic || 'Delete topic',
170
+ icon: actionIcons?.DeleteTopicIcon || <TrashIcon />,
171
+ isDanger: true,
172
+ onClick: async (ch) => {
173
+ if (options?.onDeleteTopic) {
174
+ await options.onDeleteTopic(ch);
175
+ return;
176
+ }
177
+ try {
178
+ await ch.delete();
179
+ } catch (err) {
180
+ console.error('Failed to delete topic', err);
181
+ }
123
182
  },
124
183
  });
125
184
  }
@@ -157,9 +216,14 @@ export function computeDefaultActions(
157
216
  isDanger: true,
158
217
  onClick: async (ch) => {
159
218
  try {
160
- await ch.removeMembers([currentUserId]);
219
+ if (ch.data?.mls_enabled) {
220
+ await ch.leaveChannelE2ee(currentUserId);
221
+ } else {
222
+ await ch.removeMembers([currentUserId]);
223
+ }
161
224
  } catch (e) {
162
225
  console.error('Error leaving channel', e);
226
+ throw e;
163
227
  }
164
228
  },
165
229
  });
@@ -47,9 +47,9 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
47
47
  const { isPending } = usePendingState(activeChannel, client.userID);
48
48
  const callContext = useContext(ErmisCallContext);
49
49
 
50
- const isSkipped = client.userID
51
- ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
52
- isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
50
+ const isSkipped = client.userID
51
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
52
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
53
53
  : false;
54
54
 
55
55
  const actionDisabled = isPending || isSkipped;
@@ -59,9 +59,23 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
59
59
 
60
60
  useEffect(() => {
61
61
  if (!activeChannel) return;
62
- const sub = activeChannel.on('channel.updated', () => setChannelUpdateCount(c => c + 1));
63
- return () => sub.unsubscribe();
64
- }, [activeChannel]);
62
+ const handleUpdate = () => setChannelUpdateCount((c) => c + 1);
63
+
64
+ const sub1 = activeChannel.on('channel.updated', handleUpdate);
65
+
66
+ // Also listen for client-level notifications that might affect this channel's roles/members
67
+ // We only care about this for messaging (direct) channels to update online status
68
+ const sub2 = client.on('notification.invite_accepted', (event) => {
69
+ if (event.cid === activeChannel.cid && isDirectChannel(activeChannel)) {
70
+ handleUpdate();
71
+ }
72
+ });
73
+
74
+ return () => {
75
+ sub1.unsubscribe();
76
+ sub2.unsubscribe();
77
+ };
78
+ }, [activeChannel, client]);
65
79
 
66
80
  // eslint-disable-next-line react-hooks/exhaustive-deps
67
81
  const channelName = useMemo(() =>
@@ -75,26 +89,7 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
75
89
  [image, activeChannel?.data?.image, channelUpdateCount],
76
90
  );
77
91
 
78
- const teamName = useMemo(() => {
79
- if (!activeChannel) return undefined;
80
-
81
- // If it's a topic, derive from parent_cid
82
- const parentCid = activeChannel.data?.parent_cid as string | undefined;
83
- if (parentCid && client.activeChannels[parentCid]) {
84
- return client.activeChannels[parentCid].data?.name || client.activeChannels[parentCid].cid;
85
- }
86
-
87
- // If it's a topics-enabled team channel (the general proxy), the proxy overrides data.name.
88
- // We can pull the original name from the SDK cache.
89
- if (hasTopicsEnabled(activeChannel)) {
90
- const rawChannel = client.activeChannels[activeChannel.cid];
91
- if (rawChannel && rawChannel.data?.name && rawChannel.data.name !== activeChannel.data?.name) {
92
- return rawChannel.data.name;
93
- }
94
- }
95
-
96
- return undefined;
97
- }, [activeChannel, client.activeChannels]);
92
+ const teamName = undefined;
98
93
 
99
94
  // ── Online Status (direct friend channels only) ──
100
95
  const currentUserId = client.userID;
@@ -108,13 +103,13 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
108
103
  if (memberId !== currentUserId) return memberId;
109
104
  }
110
105
  return undefined;
111
- }, [activeChannel, currentUserId]);
106
+ }, [activeChannel, currentUserId, channelUpdateCount]);
112
107
 
113
108
  // Check if this is a friend channel (both members are owner).
114
109
  const isFriend = useMemo(() => {
115
110
  if (!otherUserId || !currentUserId || !activeChannel) return false;
116
111
  return isFriendChannel(activeChannel, otherUserId, currentUserId);
117
- }, [activeChannel, otherUserId, currentUserId]);
112
+ }, [activeChannel, otherUserId, currentUserId, channelUpdateCount]);
118
113
 
119
114
  // Derive online status from watchers + subscribe to realtime events.
120
115
  const [onlineStatus, setOnlineStatus] = useState<OnlineStatus>('unknown');
@@ -158,12 +153,12 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
158
153
  <div className={`ermis-channel-header${className ? ` ${className}` : ''}`}>
159
154
  {activeChannel.data?.parent_cid ? (
160
155
  <div className="ermis-channel-header__topic-avatar">
161
- {channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
162
- ? channelImage.replace('emoji://', '')
156
+ {channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
157
+ ? channelImage.replace('emoji://', '')
163
158
  : '#'}
164
159
  </div>
165
160
  ) : (
166
- <AvatarComponent image={channelImage} name={teamName || channelName} size={32} />
161
+ <AvatarComponent image={channelImage} name={teamName || channelName} size={44} />
167
162
  )}
168
163
 
169
164
  <div className="ermis-channel-header__info">
@@ -171,11 +166,6 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
171
166
  renderTitle(activeChannel)
172
167
  ) : (
173
168
  <div className="ermis-channel-header__title-container">
174
- {teamName && (
175
- <div className="ermis-channel-header__team-name">
176
- {teamName}
177
- </div>
178
- )}
179
169
  <div className="ermis-channel-header__name">{channelName}</div>
180
170
  </div>
181
171
  )}
@@ -216,7 +206,7 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
216
206
  </svg>
217
207
  </button>
218
208
  )}
219
-
209
+
220
210
  {renderVideoCallButton ? (
221
211
  renderVideoCallButton(() => callContext.createCall('video', activeChannel.cid || ''), actionDisabled)
222
212
  ) : (
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useMemo, useCallback } from 'react';
2
- import { Modal } from '../Modal';
2
+ import { Modal as DefaultModal } from '../Modal';
3
3
  import { UserPicker } from '../UserPicker';
4
4
  import { Avatar } from '../Avatar';
5
+ import { useChatComponents } from '../../context/ChatComponentsContext';
5
6
  import type { AddMemberModalProps, UserPickerUser } from '../../types';
6
7
 
7
8
  export const AddMemberModal: React.FC<AddMemberModalProps> = ({
@@ -19,6 +20,8 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
19
20
  UserItemComponent,
20
21
  SearchInputComponent,
21
22
  }) => {
23
+ const { ModalComponent } = useChatComponents();
24
+ const Modal = ModalComponent || DefaultModal;
22
25
  const [selectedUsers, setSelectedUsers] = useState<UserPickerUser[]>([]);
23
26
  const [isAdding, setIsAdding] = useState(false);
24
27
 
@@ -37,7 +40,13 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
37
40
  if (selectedUsers.length === 0 || isAdding) return;
38
41
  try {
39
42
  setIsAdding(true);
40
- await channel.addMembers(selectedUsers.map(u => u.id));
43
+ const memberIds = selectedUsers.map(u => u.id);
44
+ const encryptionManager = channel.getClient().encryptionManager;
45
+ if (channel.data?.mls_enabled && encryptionManager?.initialized && channel.id && channel.cid) {
46
+ await encryptionManager.addMembers(channel.type, channel.id, channel.cid, memberIds);
47
+ } else {
48
+ await channel.addMembers(memberIds);
49
+ }
41
50
  onClose();
42
51
  } catch (err) {
43
52
  console.error('Failed to add members:', err);
@@ -62,6 +71,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
62
71
  <Modal isOpen onClose={onClose} title={title} maxWidth="480px" footer={footer}>
63
72
  <UserPicker
64
73
  mode="checkbox"
74
+ friendsOnly={true}
65
75
  onSelectionChange={handleSelectionChange}
66
76
  excludeUserIds={excludeUserIds}
67
77
  pageSize={30}