@ermis-network/ermis-chat-react 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- package/src/utils.ts +242 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
|
|
2
|
+
import { VList } from 'virtua';
|
|
3
|
+
import { ROLE_WEIGHTS, MESSAGING_TABS, ALL_TABS, PENDING_STYLE, READY_STYLE } from './utils';
|
|
4
|
+
import { useBannedState } from '../../hooks/useBannedState';
|
|
5
|
+
import { useBlockedState } from '../../hooks/useBlockedState';
|
|
6
|
+
import { MediaGridItem, MediaRow } from './MediaGridItem';
|
|
7
|
+
import { LinkListItem } from './LinkListItem';
|
|
8
|
+
import { FileListItem } from './FileListItem';
|
|
9
|
+
import { MemberListItem } from './MemberListItem';
|
|
10
|
+
import { TabEmptyState, TabLoadingState } from './States';
|
|
11
|
+
import type { ChannelInfoTabsProps, MediaTab, AttachmentItem } from '../../types';
|
|
12
|
+
|
|
13
|
+
export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo(({
|
|
14
|
+
channel,
|
|
15
|
+
members,
|
|
16
|
+
AvatarComponent,
|
|
17
|
+
currentUserId,
|
|
18
|
+
currentUserRole,
|
|
19
|
+
onAddMemberClick,
|
|
20
|
+
onRemoveMember,
|
|
21
|
+
onBanMember,
|
|
22
|
+
onUnbanMember,
|
|
23
|
+
onPromoteMember,
|
|
24
|
+
onDemoteMember,
|
|
25
|
+
addMemberButtonLabel = 'Add Member',
|
|
26
|
+
AddMemberButtonComponent,
|
|
27
|
+
MemberItemComponent,
|
|
28
|
+
MediaItemComponent,
|
|
29
|
+
LinkItemComponent,
|
|
30
|
+
FileItemComponent,
|
|
31
|
+
EmptyStateComponent,
|
|
32
|
+
LoadingComponent,
|
|
33
|
+
}) => {
|
|
34
|
+
const isMessaging = channel?.type === 'messaging';
|
|
35
|
+
const { isBanned } = useBannedState(channel, currentUserId);
|
|
36
|
+
const { isBlocked } = useBlockedState(channel, currentUserId);
|
|
37
|
+
|
|
38
|
+
const availableTabs: MediaTab[] = isMessaging ? MESSAGING_TABS : ALL_TABS;
|
|
39
|
+
|
|
40
|
+
const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
|
|
41
|
+
const contentTab = useDeferredValue(activeTab);
|
|
42
|
+
const isPending = activeTab !== contentTab;
|
|
43
|
+
|
|
44
|
+
// Always reset to the first available tab when the user switches channels
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setActiveTab(availableTabs[0]);
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [channel?.cid]);
|
|
49
|
+
|
|
50
|
+
// Resolve sub-components with defaults
|
|
51
|
+
const MemberItem = MemberItemComponent || MemberListItem;
|
|
52
|
+
const MediaItem = MediaItemComponent || MediaGridItem;
|
|
53
|
+
const LinkItem = LinkItemComponent || LinkListItem;
|
|
54
|
+
const FileItem = FileItemComponent || FileListItem;
|
|
55
|
+
const EmptyState = EmptyStateComponent || TabEmptyState;
|
|
56
|
+
const Loading = LoadingComponent || TabLoadingState;
|
|
57
|
+
|
|
58
|
+
const [allAttachments, setAllAttachments] = useState<AttachmentItem[]>([]);
|
|
59
|
+
const [loading, setLoading] = useState(true);
|
|
60
|
+
|
|
61
|
+
const sortedMembers = useMemo(() => {
|
|
62
|
+
return [...members].sort((a, b) => {
|
|
63
|
+
const aWeight = ROLE_WEIGHTS[a.channel_role || 'member'] || 0;
|
|
64
|
+
const bWeight = ROLE_WEIGHTS[b.channel_role || 'member'] || 0;
|
|
65
|
+
return bWeight - aWeight;
|
|
66
|
+
});
|
|
67
|
+
}, [members]);
|
|
68
|
+
|
|
69
|
+
// Categorize attachments by type
|
|
70
|
+
const mediaItems = useMemo(() =>
|
|
71
|
+
allAttachments.filter(a => a.attachment_type === 'image' || a.attachment_type === 'video'),
|
|
72
|
+
[allAttachments]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const linkItems = useMemo(() =>
|
|
76
|
+
allAttachments.filter(a => a.attachment_type === 'linkPreview'),
|
|
77
|
+
[allAttachments]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const fileItems = useMemo(() =>
|
|
81
|
+
allAttachments.filter(a => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
|
|
82
|
+
[allAttachments]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let active = true;
|
|
87
|
+
|
|
88
|
+
// Don't fetch media/files if user is banned or blocked
|
|
89
|
+
if (isBanned || isBlocked) {
|
|
90
|
+
setAllAttachments([]);
|
|
91
|
+
setLoading(false);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fetchMedia = async () => {
|
|
96
|
+
setLoading(true);
|
|
97
|
+
try {
|
|
98
|
+
const response: any = await channel.queryAttachmentMessages();
|
|
99
|
+
|
|
100
|
+
if (active) {
|
|
101
|
+
const items = response?.attachments || [];
|
|
102
|
+
setAllAttachments(items);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error("Failed to query media for channel info", err);
|
|
106
|
+
if (active) setAllAttachments([]);
|
|
107
|
+
} finally {
|
|
108
|
+
if (active) setLoading(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
fetchMedia();
|
|
113
|
+
|
|
114
|
+
return () => { active = false; };
|
|
115
|
+
}, [channel, isBanned, isBlocked]);
|
|
116
|
+
|
|
117
|
+
const tabCounts = useMemo<Record<MediaTab, number>>(() => ({
|
|
118
|
+
members: members.length,
|
|
119
|
+
media: mediaItems.length,
|
|
120
|
+
links: linkItems.length,
|
|
121
|
+
files: fileItems.length,
|
|
122
|
+
}), [members.length, mediaItems.length, linkItems.length, fileItems.length]);
|
|
123
|
+
|
|
124
|
+
const handleOpenUrl = useCallback((url: string) => {
|
|
125
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
// Group media into rows of 3 for grid layout inside VList
|
|
129
|
+
const mediaRows = useMemo(() => {
|
|
130
|
+
const rows: AttachmentItem[][] = [];
|
|
131
|
+
for (let i = 0; i < mediaItems.length; i += 3) {
|
|
132
|
+
rows.push(mediaItems.slice(i, i + 3));
|
|
133
|
+
}
|
|
134
|
+
return rows;
|
|
135
|
+
}, [mediaItems]);
|
|
136
|
+
|
|
137
|
+
// Build VList children based on contentTab (deferred)
|
|
138
|
+
const vlistChildren = useMemo(() => {
|
|
139
|
+
switch (contentTab) {
|
|
140
|
+
case 'members': {
|
|
141
|
+
const items: React.ReactNode[] = [];
|
|
142
|
+
if (onAddMemberClick) {
|
|
143
|
+
if (AddMemberButtonComponent) {
|
|
144
|
+
items.push(
|
|
145
|
+
<div key="__add-member__" className="ermis-channel-info__add-member-wrap">
|
|
146
|
+
<AddMemberButtonComponent onClick={onAddMemberClick} label={addMemberButtonLabel} />
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
items.push(
|
|
151
|
+
<div key="__add-member__" className="ermis-channel-info__add-member-wrap">
|
|
152
|
+
<button className="ermis-channel-info__add-member-btn" onClick={onAddMemberClick}>
|
|
153
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
154
|
+
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
|
155
|
+
<circle cx="8.5" cy="7" r="4"></circle>
|
|
156
|
+
<line x1="20" y1="8" x2="20" y2="14"></line>
|
|
157
|
+
<line x1="23" y1="11" x2="17" y2="11"></line>
|
|
158
|
+
</svg>
|
|
159
|
+
{addMemberButtonLabel}
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
sortedMembers.forEach(member => {
|
|
166
|
+
const role = member.channel_role || 'member';
|
|
167
|
+
const isTargetRemovable = role === 'member' || role === 'pending' || (currentUserRole === 'owner' && role === 'moder');
|
|
168
|
+
|
|
169
|
+
const canRemove = Boolean(
|
|
170
|
+
(currentUserRole === 'owner' || currentUserRole === 'moder') &&
|
|
171
|
+
isTargetRemovable &&
|
|
172
|
+
member.user_id !== currentUserId
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const canBan = Boolean(
|
|
176
|
+
(currentUserRole === 'owner' || currentUserRole === 'moder') &&
|
|
177
|
+
isTargetRemovable &&
|
|
178
|
+
role !== 'pending' &&
|
|
179
|
+
member.user_id !== currentUserId &&
|
|
180
|
+
!member.banned
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const canUnban = Boolean(
|
|
184
|
+
(currentUserRole === 'owner' || currentUserRole === 'moder') &&
|
|
185
|
+
isTargetRemovable &&
|
|
186
|
+
role !== 'pending' &&
|
|
187
|
+
member.user_id !== currentUserId &&
|
|
188
|
+
member.banned
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const canPromote = Boolean(
|
|
192
|
+
currentUserRole === 'owner' &&
|
|
193
|
+
role === 'member' &&
|
|
194
|
+
member.user_id !== currentUserId
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const canDemote = Boolean(
|
|
198
|
+
currentUserRole === 'owner' &&
|
|
199
|
+
role === 'moder' &&
|
|
200
|
+
member.user_id !== currentUserId
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
items.push(
|
|
204
|
+
<MemberItem
|
|
205
|
+
key={member?.user_id}
|
|
206
|
+
member={member}
|
|
207
|
+
AvatarComponent={AvatarComponent}
|
|
208
|
+
onRemove={onRemoveMember}
|
|
209
|
+
canRemove={canRemove}
|
|
210
|
+
onBan={onBanMember}
|
|
211
|
+
canBan={canBan}
|
|
212
|
+
onUnban={onUnbanMember}
|
|
213
|
+
canUnban={canUnban}
|
|
214
|
+
onPromote={onPromoteMember}
|
|
215
|
+
canPromote={canPromote}
|
|
216
|
+
onDemote={onDemoteMember}
|
|
217
|
+
canDemote={canDemote}
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
return items;
|
|
222
|
+
}
|
|
223
|
+
case 'media':
|
|
224
|
+
if (MediaItem === MediaGridItem) {
|
|
225
|
+
// Default: use grid rows
|
|
226
|
+
return mediaRows.map((row, rowIdx) => (
|
|
227
|
+
<MediaRow key={row[0]?.id || rowIdx} row={row} onClick={handleOpenUrl} />
|
|
228
|
+
));
|
|
229
|
+
}
|
|
230
|
+
// Custom: render each item individually
|
|
231
|
+
return mediaItems.map((item, idx) => (
|
|
232
|
+
<MediaItem key={item.id || idx} item={item} onClick={handleOpenUrl} />
|
|
233
|
+
));
|
|
234
|
+
case 'links':
|
|
235
|
+
return linkItems.map((item, idx) => (
|
|
236
|
+
<LinkItem key={item.id || idx} item={item} />
|
|
237
|
+
));
|
|
238
|
+
case 'files':
|
|
239
|
+
return fileItems.map((item, idx) => (
|
|
240
|
+
<FileItem key={item.id || idx} item={item} onClick={handleOpenUrl} />
|
|
241
|
+
));
|
|
242
|
+
default:
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick, AvatarComponent, handleOpenUrl, MemberItem, MediaItem, LinkItem, FileItem]);
|
|
246
|
+
|
|
247
|
+
// Check if content is empty for the content tab (deferred)
|
|
248
|
+
const isTabEmpty = vlistChildren.length === 0 && !(loading && contentTab !== 'members');
|
|
249
|
+
const emptyLabel = contentTab === 'members' ? 'members' : contentTab;
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className="ermis-channel-info__section ermis-channel-info__media-section">
|
|
253
|
+
<div className="ermis-channel-info__media-tabs">
|
|
254
|
+
{availableTabs.map(tab => (
|
|
255
|
+
<button
|
|
256
|
+
key={tab}
|
|
257
|
+
className={`ermis-channel-info__media-tab ${activeTab === tab ? 'ermis-channel-info__media-tab--active' : ''}`}
|
|
258
|
+
onClick={() => setActiveTab(tab)}
|
|
259
|
+
>
|
|
260
|
+
<span className="ermis-channel-info__media-tab-label">
|
|
261
|
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
262
|
+
</span>
|
|
263
|
+
{tabCounts[tab] > 0 && (
|
|
264
|
+
<span className="ermis-channel-info__media-tab-count">{tabCounts[tab]}</span>
|
|
265
|
+
)}
|
|
266
|
+
</button>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div
|
|
271
|
+
className="ermis-channel-info__media-content"
|
|
272
|
+
style={isPending ? PENDING_STYLE : READY_STYLE}
|
|
273
|
+
>
|
|
274
|
+
{loading && contentTab !== 'members' ? <Loading /> : isTabEmpty ? <EmptyState label={emptyLabel} /> : (
|
|
275
|
+
<VList style={{ height: '100%' }}>
|
|
276
|
+
{vlistChildren}
|
|
277
|
+
</VList>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
});
|