@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.
Files changed (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. 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';