@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,395 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { VList } from 'virtua';
|
|
3
|
+
import type { Channel, Event, ChannelFilters } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
6
|
+
import { useChannelListUpdates } from '../hooks/useChannelListUpdates';
|
|
7
|
+
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
8
|
+
import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
|
|
9
|
+
import { usePendingState } from '../hooks/usePendingState';
|
|
10
|
+
import { Avatar } from './Avatar';
|
|
11
|
+
import type { ChannelItemProps, ChannelListProps } from '../types';
|
|
12
|
+
|
|
13
|
+
export type { ChannelListProps, ChannelItemProps } from '../types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get a human-readable preview string for the last message,
|
|
17
|
+
* handling regular, system, and signal message types.
|
|
18
|
+
*/
|
|
19
|
+
function getLastMessagePreview(
|
|
20
|
+
channel: Channel,
|
|
21
|
+
): { text: string; user: string } {
|
|
22
|
+
const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
|
|
23
|
+
if (!lastMsg) return { text: '', user: '' };
|
|
24
|
+
|
|
25
|
+
const msgType = lastMsg.type || 'regular';
|
|
26
|
+
const rawText = lastMsg.text ?? '';
|
|
27
|
+
|
|
28
|
+
if (msgType === 'system') {
|
|
29
|
+
const userMap = buildUserMap(channel.state);
|
|
30
|
+
return { text: parseSystemMessage(rawText, userMap), user: '' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (msgType === 'signal') {
|
|
34
|
+
const userMap = buildUserMap(channel.state);
|
|
35
|
+
return { text: parseSignalMessage(rawText, userMap), user: '' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Regular / other
|
|
39
|
+
let displayText = rawText;
|
|
40
|
+
if (!displayText && lastMsg.attachments && lastMsg.attachments.length > 0) {
|
|
41
|
+
const att = lastMsg.attachments[0];
|
|
42
|
+
const type = att.type || '';
|
|
43
|
+
switch (type) {
|
|
44
|
+
case 'image':
|
|
45
|
+
displayText = '๐ท Photo';
|
|
46
|
+
break;
|
|
47
|
+
case 'video':
|
|
48
|
+
displayText = '๐ฌ Video';
|
|
49
|
+
break;
|
|
50
|
+
case 'voiceRecording':
|
|
51
|
+
displayText = '๐ค Voice message';
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
displayText = '๐ File';
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (lastMsg.attachments.length > 1) {
|
|
58
|
+
displayText += ` +${lastMsg.attachments.length - 1}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Format mentions if necessary
|
|
63
|
+
const lastMsgRecord = lastMsg as Record<string, unknown>;
|
|
64
|
+
const mentionedUsers = lastMsgRecord.mentioned_users as string[] | undefined;
|
|
65
|
+
const mentionedAll = lastMsgRecord.mentioned_all as boolean | undefined;
|
|
66
|
+
|
|
67
|
+
if (displayText && (mentionedAll || (mentionedUsers && mentionedUsers.length > 0))) {
|
|
68
|
+
const userMap = buildUserMap(channel.state);
|
|
69
|
+
displayText = replaceMentionsForPreview(displayText, lastMsg as any, userMap);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
text: displayText,
|
|
74
|
+
user: lastMsg.user?.name || lastMsg.user_id || '',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ----------------------------------------------------------
|
|
79
|
+
Memoized channel list item (exported for consumer reuse)
|
|
80
|
+
---------------------------------------------------------- */
|
|
81
|
+
export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
82
|
+
channel,
|
|
83
|
+
isActive,
|
|
84
|
+
hasUnread,
|
|
85
|
+
unreadCount,
|
|
86
|
+
lastMessageText,
|
|
87
|
+
lastMessageUser,
|
|
88
|
+
onSelect,
|
|
89
|
+
AvatarComponent,
|
|
90
|
+
isBlocked,
|
|
91
|
+
isPending,
|
|
92
|
+
pendingBadgeLabel,
|
|
93
|
+
blockedBadgeLabel,
|
|
94
|
+
}) => {
|
|
95
|
+
// Subscribe to channel.updated so that when name/image/description change,
|
|
96
|
+
// we re-render from within (bypasses React.memo which only blocks parent-driven re-renders)
|
|
97
|
+
const [, forceUpdate] = useState(0);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const sub = channel.on('channel.updated', () => forceUpdate((c) => c + 1));
|
|
100
|
+
return () => sub.unsubscribe();
|
|
101
|
+
}, [channel]);
|
|
102
|
+
|
|
103
|
+
const name = channel.data?.name || channel.cid;
|
|
104
|
+
const image = channel.data?.image as string | undefined;
|
|
105
|
+
const showUnread = hasUnread && !isActive;
|
|
106
|
+
|
|
107
|
+
const handleClick = useCallback(() => {
|
|
108
|
+
onSelect(channel);
|
|
109
|
+
}, [channel, onSelect]);
|
|
110
|
+
|
|
111
|
+
const itemClass = [
|
|
112
|
+
'ermis-channel-list__item',
|
|
113
|
+
isActive ? 'ermis-channel-list__item--active' : '',
|
|
114
|
+
showUnread ? 'ermis-channel-list__item--unread' : '',
|
|
115
|
+
isBlocked ? 'ermis-channel-list__item--blocked' : '',
|
|
116
|
+
isPending ? 'ermis-channel-list__item--pending' : '',
|
|
117
|
+
].filter(Boolean).join(' ');
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className={itemClass} onClick={handleClick}>
|
|
121
|
+
<AvatarComponent image={image} name={name} size={40} />
|
|
122
|
+
<div className="ermis-channel-list__item-content">
|
|
123
|
+
<div className="ermis-channel-list__item-name">{name}</div>
|
|
124
|
+
{lastMessageText && (
|
|
125
|
+
<div className="ermis-channel-list__item-last-message">
|
|
126
|
+
{lastMessageUser && (
|
|
127
|
+
<span className="ermis-channel-list__item-last-message-user">
|
|
128
|
+
{lastMessageUser}:{' '}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
<span>{lastMessageText}</span>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
{showUnread && unreadCount > 0 && (
|
|
136
|
+
<span className="ermis-channel-list__unread-badge">
|
|
137
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
{isBlocked && (
|
|
141
|
+
<span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
|
|
142
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
143
|
+
<circle cx="12" cy="12" r="10" />
|
|
144
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
145
|
+
</svg>
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
{isPending && (
|
|
149
|
+
<span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
ChannelItem.displayName = 'ChannelItem';
|
|
155
|
+
|
|
156
|
+
const DefaultLoading = React.memo(({ text }: { text?: string }) => (
|
|
157
|
+
<div className="ermis-channel-list__loading">{text || 'Loading channels...'}</div>
|
|
158
|
+
));
|
|
159
|
+
DefaultLoading.displayName = 'DefaultLoading';
|
|
160
|
+
|
|
161
|
+
const DefaultEmpty = React.memo(({ text }: { text?: string }) => (
|
|
162
|
+
<div className="ermis-channel-list__empty">{text || 'No channels found'}</div>
|
|
163
|
+
));
|
|
164
|
+
DefaultEmpty.displayName = 'DefaultEmpty';
|
|
165
|
+
|
|
166
|
+
/* ----------------------------------------------------------
|
|
167
|
+
Virtual Row Component to map channel and defer parsing
|
|
168
|
+
---------------------------------------------------------- */
|
|
169
|
+
type ChannelRowProps = {
|
|
170
|
+
channel: Channel;
|
|
171
|
+
isActive: boolean;
|
|
172
|
+
handleSelect: (c: Channel) => void;
|
|
173
|
+
renderChannel?: (c: Channel, active: boolean) => React.ReactNode;
|
|
174
|
+
ChannelItemComponent: React.ComponentType<ChannelItemProps>;
|
|
175
|
+
AvatarComponent: React.ComponentType<any>;
|
|
176
|
+
currentUserId?: string;
|
|
177
|
+
pendingBadgeLabel?: string;
|
|
178
|
+
blockedBadgeLabel?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
182
|
+
channel,
|
|
183
|
+
isActive,
|
|
184
|
+
handleSelect,
|
|
185
|
+
renderChannel,
|
|
186
|
+
ChannelItemComponent,
|
|
187
|
+
AvatarComponent,
|
|
188
|
+
currentUserId,
|
|
189
|
+
pendingBadgeLabel,
|
|
190
|
+
blockedBadgeLabel,
|
|
191
|
+
}) => {
|
|
192
|
+
// Use the new custom hook to handle all row-level realtime updates
|
|
193
|
+
const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
|
|
194
|
+
const { isPending } = usePendingState(channel, currentUserId);
|
|
195
|
+
|
|
196
|
+
const channelState = channel.state as unknown as Record<string, unknown> | undefined;
|
|
197
|
+
const rawUnreadCount = (channelState?.unreadCount as number) ?? 0;
|
|
198
|
+
const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending) ? 0 : rawUnreadCount;
|
|
199
|
+
const hasUnread = unreadCount > 0;
|
|
200
|
+
|
|
201
|
+
// Derive last message preview computation is deferred here,
|
|
202
|
+
// so it only executes when VList actually mounts this visible item
|
|
203
|
+
const { text: rawLastMessageText, user: rawLastMessageUser } = useMemo(
|
|
204
|
+
() => getLastMessagePreview(channel),
|
|
205
|
+
// Recompute if latestMessage changes or we get a force update
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
+
[channel, channel.state?.latestMessages, updateCount]
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Hide last message preview when banned, blocked, or pending
|
|
211
|
+
const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageText;
|
|
212
|
+
const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageUser;
|
|
213
|
+
|
|
214
|
+
if (renderChannel) {
|
|
215
|
+
return (
|
|
216
|
+
<div onClick={() => handleSelect(channel)}>
|
|
217
|
+
{renderChannel(channel, isActive)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<ChannelItemComponent
|
|
224
|
+
channel={channel}
|
|
225
|
+
isActive={isActive}
|
|
226
|
+
hasUnread={hasUnread}
|
|
227
|
+
unreadCount={unreadCount}
|
|
228
|
+
lastMessageText={lastMessageText}
|
|
229
|
+
lastMessageUser={lastMessageUser}
|
|
230
|
+
onSelect={handleSelect}
|
|
231
|
+
AvatarComponent={AvatarComponent}
|
|
232
|
+
isBlocked={isBlockedInChannel}
|
|
233
|
+
isPending={isPending}
|
|
234
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
235
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
ChannelRow.displayName = 'ChannelRow';
|
|
240
|
+
|
|
241
|
+
export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
242
|
+
filters = { type: ['messaging', 'team'], include_pinned_messages: true } as unknown as ChannelFilters,
|
|
243
|
+
sort = [],
|
|
244
|
+
options = { message_limit: 25 } as unknown as ChannelListProps['options'],
|
|
245
|
+
renderChannel,
|
|
246
|
+
onChannelSelect,
|
|
247
|
+
className,
|
|
248
|
+
LoadingIndicator = DefaultLoading,
|
|
249
|
+
EmptyStateIndicator = DefaultEmpty,
|
|
250
|
+
AvatarComponent = Avatar,
|
|
251
|
+
ChannelItemComponent = ChannelItem,
|
|
252
|
+
pendingInvitesLabel,
|
|
253
|
+
channelsLabel = 'Channels',
|
|
254
|
+
pendingBadgeLabel,
|
|
255
|
+
loadingLabel,
|
|
256
|
+
emptyStateLabel = 'No channels found',
|
|
257
|
+
blockedBadgeLabel = 'Blocked',
|
|
258
|
+
}) => {
|
|
259
|
+
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
260
|
+
const [channels, setChannels] = useState<Channel[]>([]);
|
|
261
|
+
const [loading, setLoading] = useState(true);
|
|
262
|
+
const [isPendingExpanded, setIsPendingExpanded] = useState(true);
|
|
263
|
+
|
|
264
|
+
// Group channels into pending and regular
|
|
265
|
+
const { pendingChannels, regularChannels } = useMemo<{ pendingChannels: Channel[], regularChannels: Channel[] }>(() => {
|
|
266
|
+
const pending: Channel[] = [];
|
|
267
|
+
const regular: Channel[] = [];
|
|
268
|
+
|
|
269
|
+
channels.forEach(ch => {
|
|
270
|
+
const ms = ch.state?.membership as Record<string, unknown> | undefined;
|
|
271
|
+
const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
|
|
272
|
+
if (isPending) {
|
|
273
|
+
pending.push(ch);
|
|
274
|
+
} else {
|
|
275
|
+
regular.push(ch);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { pendingChannels: pending, regularChannels: regular };
|
|
280
|
+
}, [channels]);
|
|
281
|
+
|
|
282
|
+
const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
|
|
283
|
+
|
|
284
|
+
const loadChannels = useCallback(async () => {
|
|
285
|
+
try {
|
|
286
|
+
setLoading(true);
|
|
287
|
+
const result = await client.queryChannels(filters, sort, options as { message_limit?: number });
|
|
288
|
+
setChannels(result);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error('Failed to load channels:', err);
|
|
291
|
+
} finally {
|
|
292
|
+
setLoading(false);
|
|
293
|
+
}
|
|
294
|
+
}, [client, filtersKey]);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
loadChannels();
|
|
298
|
+
}, [loadChannels]);
|
|
299
|
+
|
|
300
|
+
// Real-time: List manipulation (move to top, add, delete)
|
|
301
|
+
useChannelListUpdates(channels, setChannels);
|
|
302
|
+
|
|
303
|
+
const handleSelect = useCallback(
|
|
304
|
+
(channel: Channel) => {
|
|
305
|
+
setActiveChannel(channel);
|
|
306
|
+
onChannelSelect?.(channel);
|
|
307
|
+
|
|
308
|
+
// Mark as read when user selects a channel (skip if banned, blocked, or pending)
|
|
309
|
+
const ms = channel.state?.membership as Record<string, unknown> | undefined;
|
|
310
|
+
const chState = channel.state as unknown as Record<string, unknown> | undefined;
|
|
311
|
+
const isBannedInChannel = Boolean(ms?.banned);
|
|
312
|
+
const isBlockedInChannel = channel.type === 'messaging' && Boolean(ms?.blocked);
|
|
313
|
+
const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
|
|
314
|
+
|
|
315
|
+
if (!isBannedInChannel && !isBlockedInChannel && !isPending && (chState?.unreadCount as number) > 0) {
|
|
316
|
+
channel.markRead().catch(() => { });
|
|
317
|
+
// Optimistically reset unread to update UI immediately
|
|
318
|
+
if (chState) chState.unreadCount = 0;
|
|
319
|
+
setChannels((prev) => [...prev]);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
[setActiveChannel, onChannelSelect, setChannels],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (loading) return <LoadingIndicator text={loadingLabel} />;
|
|
326
|
+
if (channels.length === 0) return <EmptyStateIndicator text={emptyStateLabel} />;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className={`ermis-channel-list${className ? ` ${className}` : ''}`}>
|
|
330
|
+
{/* VList requires its container to have a height to work. */}
|
|
331
|
+
<VList style={{ height: '100%' }}>
|
|
332
|
+
{pendingChannels.length > 0 && (
|
|
333
|
+
<div
|
|
334
|
+
className="ermis-channel-list__accordion-header"
|
|
335
|
+
onClick={() => setIsPendingExpanded(prev => !prev)}
|
|
336
|
+
>
|
|
337
|
+
<span>
|
|
338
|
+
{typeof pendingInvitesLabel === 'function'
|
|
339
|
+
? pendingInvitesLabel(pendingChannels.length)
|
|
340
|
+
: pendingInvitesLabel || `Invites (${pendingChannels.length})`}
|
|
341
|
+
</span>
|
|
342
|
+
<svg
|
|
343
|
+
className={`ermis-channel-list__accordion-icon ${isPendingExpanded ? 'ermis-channel-list__accordion-icon--expanded' : ''}`}
|
|
344
|
+
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
345
|
+
>
|
|
346
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
347
|
+
</svg>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
{isPendingExpanded && pendingChannels.map((channel: Channel) => {
|
|
351
|
+
const isActive = activeChannel?.cid === channel.cid;
|
|
352
|
+
return (
|
|
353
|
+
<ChannelRow
|
|
354
|
+
key={channel.cid}
|
|
355
|
+
channel={channel}
|
|
356
|
+
isActive={isActive}
|
|
357
|
+
handleSelect={handleSelect}
|
|
358
|
+
renderChannel={renderChannel}
|
|
359
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
360
|
+
AvatarComponent={AvatarComponent}
|
|
361
|
+
currentUserId={client.userID}
|
|
362
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
363
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
364
|
+
/>
|
|
365
|
+
);
|
|
366
|
+
})}
|
|
367
|
+
{pendingChannels.length > 0 && regularChannels.length > 0 && (
|
|
368
|
+
<div className="ermis-channel-list__accordion-header ermis-channel-list__accordion-header--static">
|
|
369
|
+
<span>{channelsLabel}</span>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
{regularChannels.map((channel: Channel) => {
|
|
373
|
+
const isActive = activeChannel?.cid === channel.cid;
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<ChannelRow
|
|
377
|
+
key={channel.cid}
|
|
378
|
+
channel={channel}
|
|
379
|
+
isActive={isActive}
|
|
380
|
+
handleSelect={handleSelect}
|
|
381
|
+
renderChannel={renderChannel}
|
|
382
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
383
|
+
AvatarComponent={AvatarComponent}
|
|
384
|
+
currentUserId={client.userID}
|
|
385
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
386
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
387
|
+
/>
|
|
388
|
+
);
|
|
389
|
+
})}
|
|
390
|
+
</VList>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
ChannelList.displayName = 'ChannelList'; 'ChannelList';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
// Global event name used to close any other open dropdowns
|
|
5
|
+
const CLOSE_ALL_EVENT = 'ermis:close-all-dropdowns';
|
|
6
|
+
|
|
7
|
+
/** Dispatch a global event to close all open dropdowns */
|
|
8
|
+
export const closeAllDropdowns = () => {
|
|
9
|
+
document.dispatchEvent(new CustomEvent(CLOSE_ALL_EVENT));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface DropdownProps {
|
|
13
|
+
/** Whether the dropdown is open */
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
/** Rect from getBoundingClientRect() of the anchor element */
|
|
16
|
+
anchorRect: DOMRect | null;
|
|
17
|
+
/** Callback when dropdown requests to close (e.g., click outside, scroll, Escape) */
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
/** Dropdown menu content */
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
/** Horizontal alignment relative to the anchor. Default: 'left' */
|
|
22
|
+
align?: 'left' | 'right';
|
|
23
|
+
/** Optional custom CSS class for the container */
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Optional custom CSS style for the container */
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Dropdown: React.FC<DropdownProps> = ({
|
|
30
|
+
isOpen,
|
|
31
|
+
anchorRect,
|
|
32
|
+
onClose,
|
|
33
|
+
children,
|
|
34
|
+
align = 'left',
|
|
35
|
+
className = '',
|
|
36
|
+
style: propStyle = {},
|
|
37
|
+
}) => {
|
|
38
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const instanceId = useRef(Math.random().toString(36).slice(2));
|
|
40
|
+
|
|
41
|
+
// Listen for global close event โ only register when open to avoid N listeners
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!isOpen) return;
|
|
44
|
+
|
|
45
|
+
// Broadcast: close all OTHER open dropdowns
|
|
46
|
+
document.dispatchEvent(new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId.current }));
|
|
47
|
+
|
|
48
|
+
const handleGlobalClose = (e: Event) => {
|
|
49
|
+
const detail = (e as CustomEvent).detail;
|
|
50
|
+
if (!detail || detail !== instanceId.current) {
|
|
51
|
+
onClose();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
56
|
+
// Allow the click to process if it's on a trigger button so it can toggle itself
|
|
57
|
+
// We rely on the trigger stopping propagation or using the global close event.
|
|
58
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
59
|
+
onClose();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
64
|
+
if (e.key === 'Escape') onClose();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleScroll = () => onClose();
|
|
68
|
+
|
|
69
|
+
// Delay click listener to prevent instant close from the opening click
|
|
70
|
+
const tid = setTimeout(() => {
|
|
71
|
+
document.addEventListener('click', handleClickOutside);
|
|
72
|
+
}, 10);
|
|
73
|
+
|
|
74
|
+
document.addEventListener(CLOSE_ALL_EVENT, handleGlobalClose);
|
|
75
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
76
|
+
document.addEventListener('scroll', handleScroll, true);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
clearTimeout(tid);
|
|
80
|
+
document.removeEventListener(CLOSE_ALL_EVENT, handleGlobalClose);
|
|
81
|
+
document.removeEventListener('click', handleClickOutside);
|
|
82
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
83
|
+
document.removeEventListener('scroll', handleScroll, true);
|
|
84
|
+
};
|
|
85
|
+
}, [isOpen, onClose]);
|
|
86
|
+
|
|
87
|
+
if (!isOpen || !anchorRect) return null;
|
|
88
|
+
|
|
89
|
+
const spaceBelow = window.innerHeight - anchorRect.bottom;
|
|
90
|
+
const spaceAbove = anchorRect.top;
|
|
91
|
+
const estimatedDropdownHeight = 250;
|
|
92
|
+
|
|
93
|
+
let verticalStyle: React.CSSProperties = {};
|
|
94
|
+
if (spaceBelow < estimatedDropdownHeight && spaceAbove > spaceBelow) {
|
|
95
|
+
// Open upwards (bottom-aligned to the top of the trigger)
|
|
96
|
+
verticalStyle = { bottom: window.innerHeight - anchorRect.top + 4 };
|
|
97
|
+
} else {
|
|
98
|
+
// Open downwards
|
|
99
|
+
verticalStyle = { top: anchorRect.bottom + 4 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const style: React.CSSProperties = {
|
|
103
|
+
position: 'fixed',
|
|
104
|
+
zIndex: 99999,
|
|
105
|
+
...verticalStyle,
|
|
106
|
+
...(align === 'right'
|
|
107
|
+
? { right: window.innerWidth - anchorRect.right }
|
|
108
|
+
: { left: anchorRect.left }),
|
|
109
|
+
...propStyle
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const portalTarget = document.querySelector('.ermis-chat') || document.body;
|
|
113
|
+
|
|
114
|
+
return createPortal(
|
|
115
|
+
<div ref={containerRef} className={`ermis-dropdown ${className}`.trim()} style={style}>
|
|
116
|
+
{children}
|
|
117
|
+
</div>,
|
|
118
|
+
portalTarget
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
3
|
+
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
4
|
+
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
|
|
6
|
+
const MAX_PREVIEW_LENGTH = 120;
|
|
7
|
+
|
|
8
|
+
function truncateText(text: string, maxLength: number): string {
|
|
9
|
+
if (text.length <= maxLength) return text;
|
|
10
|
+
return text.slice(0, maxLength).trimEnd() + 'โฆ';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Get a human-readable summary of attachments */
|
|
14
|
+
function getAttachmentSummary(attachments: any[]): string {
|
|
15
|
+
if (!attachments || attachments.length === 0) return '';
|
|
16
|
+
|
|
17
|
+
const types: Record<string, number> = {};
|
|
18
|
+
for (const att of attachments) {
|
|
19
|
+
const type = att.type || 'file';
|
|
20
|
+
types[type] = (types[type] || 0) + 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const labels: string[] = [];
|
|
24
|
+
const typeLabels: Record<string, string> = {
|
|
25
|
+
image: '๐ผ๏ธ Image',
|
|
26
|
+
video: '๐ฌ Video',
|
|
27
|
+
audio: '๐ต Audio',
|
|
28
|
+
file: '๐ File',
|
|
29
|
+
voiceRecording: '๐ค Voice',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
for (const [type, count] of Object.entries(types)) {
|
|
33
|
+
const label = typeLabels[type] || `๐ ${type}`;
|
|
34
|
+
labels.push(count > 1 ? `${label} (${count})` : label);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return labels.join(', ');
|
|
38
|
+
}
|
|
39
|
+
export const EditPreview: React.FC<{
|
|
40
|
+
message: FormatMessageResponse;
|
|
41
|
+
onDismiss: () => void;
|
|
42
|
+
}> = React.memo(({
|
|
43
|
+
message,
|
|
44
|
+
onDismiss,
|
|
45
|
+
editingMessageLabel = 'Editing message',
|
|
46
|
+
}: any) => {
|
|
47
|
+
console.log('--message--', message)
|
|
48
|
+
const { activeChannel } = useChatClient();
|
|
49
|
+
|
|
50
|
+
const userMap = useMemo<Record<string, string>>(() => {
|
|
51
|
+
return buildUserMap(activeChannel?.state);
|
|
52
|
+
}, [activeChannel]);
|
|
53
|
+
|
|
54
|
+
const userName = message.user?.name || message.user_id || 'Unknown';
|
|
55
|
+
|
|
56
|
+
const rawText = message.text || '';
|
|
57
|
+
const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
|
|
58
|
+
const hasText = !!formattedText.trim();
|
|
59
|
+
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
60
|
+
const isSticker = message.type === 'sticker';
|
|
61
|
+
const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
|
|
62
|
+
|
|
63
|
+
// Build preview content
|
|
64
|
+
let previewContent: React.ReactNode = null;
|
|
65
|
+
if (isSticker) {
|
|
66
|
+
previewContent = (
|
|
67
|
+
<span className="ermis-message-input__reply-preview-text">
|
|
68
|
+
๐ Sticker
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
previewContent = (
|
|
73
|
+
<span className="ermis-message-input__reply-preview-text">
|
|
74
|
+
{hasText && truncateText(formattedText, MAX_PREVIEW_LENGTH)}
|
|
75
|
+
{hasText && hasAttachments && ' ยท '}
|
|
76
|
+
{hasAttachments && attachmentSummary}
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="ermis-message-input__reply-preview">
|
|
83
|
+
<div className="ermis-message-input__reply-preview-body">
|
|
84
|
+
<span className="ermis-message-input__reply-preview-label">{editingMessageLabel}</span>
|
|
85
|
+
<span className="ermis-message-input__reply-preview-user">{userName}</span>
|
|
86
|
+
{previewContent}
|
|
87
|
+
</div>
|
|
88
|
+
<button
|
|
89
|
+
className="ermis-message-input__reply-preview-dismiss"
|
|
90
|
+
onClick={onDismiss}
|
|
91
|
+
title="Cancel edit"
|
|
92
|
+
>
|
|
93
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
94
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
95
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
EditPreview.displayName = 'EditPreview';
|