@ermis-network/ermis-chat-react 1.0.9 → 2.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 (99) hide show
  1. package/dist/index.cjs +15288 -4203
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15238 -4179
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +126 -7
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -0,0 +1,232 @@
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from '../hooks/useChatClient';
4
+ import { SystemMessageTranslations, SignalMessageTranslations } from '@ermis-network/ermis-chat-sdk';
5
+ import { useTopicGroupUpdates } from '../hooks/useTopicGroupUpdates';
6
+ import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
7
+ import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
8
+ import type { AvatarProps, ChannelActionsProps, ChannelActionLabels, ChannelActionIcons, TopicPillProps } from '../types';
9
+
10
+ /* ----------------------------------------------------------
11
+ Default TopicPill – renders a single topic preview
12
+ ---------------------------------------------------------- */
13
+ const DefaultTopicPill: React.FC<TopicPillProps> = React.memo(({ topic }) => {
14
+ const image = topic.data?.image as string | undefined;
15
+
16
+ let emoji = '💬';
17
+ if (image && typeof image === 'string' && image.startsWith('emoji://')) {
18
+ emoji = image.replace('emoji://', '');
19
+ }
20
+
21
+ const name = topic.data?.name || '';
22
+
23
+ return (
24
+ <span className="ermis-channel-list__topic-pill">
25
+ <span className="ermis-channel-list__topic-pill-avatar">{emoji}</span>
26
+ {name && <span className="ermis-channel-list__topic-pill-name">{name}</span>}
27
+ </span>
28
+ );
29
+ });
30
+ DefaultTopicPill.displayName = 'DefaultTopicPill';
31
+
32
+ /* ----------------------------------------------------------
33
+ FlatTopicGroupItem Props
34
+ ---------------------------------------------------------- */
35
+ type FlatTopicGroupItemProps = {
36
+ channel: Channel;
37
+ isActive: boolean;
38
+ onDrillDown?: (channel: Channel) => void;
39
+ AvatarComponent: React.ComponentType<AvatarProps>;
40
+ maxVisibleTopics?: number;
41
+ moreTopicsLabel?: string;
42
+ /** Label for the general pill (default: 'general') */
43
+ generalTopicLabel?: string;
44
+ TopicPillComponent?: React.ComponentType<TopicPillProps>;
45
+ PinnedIconComponent?: React.ComponentType;
46
+ ChannelActionsComponent?: React.ComponentType<ChannelActionsProps>;
47
+ onAddTopic?: (channel: Channel) => void;
48
+ onTruncateChannel?: (channel: Channel) => void;
49
+ hiddenActions?: string[];
50
+ actionLabels?: ChannelActionLabels;
51
+ actionIcons?: ChannelActionIcons;
52
+ deletedMessageLabel?: React.ReactNode;
53
+ stickerMessageLabel?: React.ReactNode;
54
+ photoMessageLabel?: React.ReactNode;
55
+ videoMessageLabel?: React.ReactNode;
56
+ voiceRecordingMessageLabel?: React.ReactNode;
57
+ fileMessageLabel?: React.ReactNode;
58
+ systemMessageTranslations?: SystemMessageTranslations;
59
+ signalMessageTranslations?: SignalMessageTranslations;
60
+ };
61
+
62
+ /* ----------------------------------------------------------
63
+ FlatTopicGroupItem – flat channel item with topic preview
64
+ Shows like a normal ChannelItem (name, last msg, timestamp,
65
+ unread badge) plus a row of topic pills.
66
+ ---------------------------------------------------------- */
67
+ export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(({
68
+ channel,
69
+ isActive,
70
+ onDrillDown,
71
+ AvatarComponent,
72
+ maxVisibleTopics = 3,
73
+ moreTopicsLabel = '...',
74
+ generalTopicLabel = 'general',
75
+ TopicPillComponent,
76
+ PinnedIconComponent,
77
+ ChannelActionsComponent,
78
+ onAddTopic,
79
+ hiddenActions,
80
+ actionLabels,
81
+ actionIcons,
82
+ deletedMessageLabel,
83
+ stickerMessageLabel,
84
+ photoMessageLabel,
85
+ videoMessageLabel,
86
+ voiceRecordingMessageLabel,
87
+ fileMessageLabel,
88
+ systemMessageTranslations,
89
+ signalMessageTranslations,
90
+ }) => {
91
+ const { client } = useChatClient();
92
+ const currentUserId = client.userID;
93
+
94
+ // Realtime updates for parent channel row (pin/unpin, channel.updated)
95
+ const { updateCount } = useChannelRowUpdates(channel, currentUserId);
96
+
97
+ // Realtime topic group data (sorted topics, aggregated unread, latest message)
98
+ const { topics, aggregatedUnreadCount, hasUnread, latestMessagePreview } = useTopicGroupUpdates(
99
+ channel,
100
+ currentUserId,
101
+ {
102
+ deletedMessageLabel,
103
+ stickerMessageLabel,
104
+ photoMessageLabel,
105
+ videoMessageLabel,
106
+ voiceRecordingMessageLabel,
107
+ fileMessageLabel,
108
+ systemMessageTranslations,
109
+ signalMessageTranslations,
110
+ }
111
+ );
112
+
113
+ const name = channel.data?.name || channel.cid;
114
+ const image = channel.data?.image as string | undefined;
115
+ const isPinned = channel.data?.is_pinned === true;
116
+ const showUnread = hasUnread && !isActive;
117
+
118
+ // Latest message data from the aggregated preview
119
+ const lastMessageText = latestMessagePreview?.text || '';
120
+ const lastMessageUser = latestMessagePreview?.user || '';
121
+ const lastMessageTimestamp = latestMessagePreview?.timestamp;
122
+ const lastMessageSourceName = latestMessagePreview?.sourceName || null;
123
+
124
+ const timestampText = useMemo(() => {
125
+ if (!lastMessageTimestamp) return null;
126
+ const d = new Date(lastMessageTimestamp);
127
+ if (isNaN(d.getTime())) return null;
128
+ const today = new Date();
129
+ const isToday = d.toDateString() === today.toDateString();
130
+ return isToday
131
+ ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
132
+ : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
133
+ }, [lastMessageTimestamp]);
134
+
135
+ // Visible topic pills: general pill + sub-topic pills (capped at maxVisibleTopics)
136
+ const visibleTopics = useMemo(
137
+ () => topics.slice(0, Math.max(0, maxVisibleTopics - 1)),
138
+ [topics, maxVisibleTopics],
139
+ );
140
+ const hasOverflow = (topics.length + 1) > maxVisibleTopics; // +1 for general pill
141
+
142
+ // Actions menu (pin, create topic, delete, leave)
143
+ const defaultActions = useMemo(
144
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ [channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
147
+ );
148
+ const filteredActions = useMemo(() => {
149
+ if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
150
+ return defaultActions.filter((a) => !hiddenActions.includes(a.id));
151
+ }, [defaultActions, hiddenActions]);
152
+ const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
153
+
154
+ const Pill = TopicPillComponent || DefaultTopicPill;
155
+
156
+ const handleClick = useCallback(() => {
157
+ if (onDrillDown) onDrillDown(channel);
158
+ }, [channel, onDrillDown]);
159
+
160
+ const itemClass = [
161
+ 'ermis-channel-list__item',
162
+ 'ermis-channel-list__item--topic-group',
163
+ isActive ? 'ermis-channel-list__item--active' : '',
164
+ showUnread ? 'ermis-channel-list__item--unread' : '',
165
+ ].filter(Boolean).join(' ');
166
+
167
+ return (
168
+ <div className={itemClass} onClick={handleClick}>
169
+ <div className="ermis-channel-list__item-avatar-wrapper">
170
+ <AvatarComponent image={image} name={name} size={40} disableLightbox className="ermis-avatar-wrapper--group" />
171
+ </div>
172
+ <div className="ermis-channel-list__item-content">
173
+ {/* Row 1: name + pinned + timestamp */}
174
+ <div className="ermis-channel-list__item-top-row">
175
+ <div className="ermis-channel-list__item-name">{name}</div>
176
+ {isPinned && PinnedIconComponent && (
177
+ <span className="ermis-channel-list__pinned-icon" title="Pinned">
178
+ <PinnedIconComponent />
179
+ </span>
180
+ )}
181
+ {timestampText && <div className="ermis-channel-list__item-timestamp">{timestampText}</div>}
182
+ </div>
183
+ {/* Row 2: last message + unread badge */}
184
+ <div className="ermis-channel-list__item-bottom-row">
185
+ {lastMessageText && (
186
+ <div className="ermis-channel-list__item-last-message">
187
+ {lastMessageSourceName && (
188
+ <span className="ermis-channel-list__item-last-message-source">
189
+ #{lastMessageSourceName} · {' '}
190
+ </span>
191
+ )}
192
+ {lastMessageUser && (
193
+ <span className="ermis-channel-list__item-last-message-user">
194
+ {lastMessageUser}:{' '}
195
+ </span>
196
+ )}
197
+ <span>{lastMessageText}</span>
198
+ </div>
199
+ )}
200
+ <div className="ermis-channel-list__item-badges">
201
+ {showUnread && aggregatedUnreadCount > 0 && (
202
+ <span className="ermis-channel-list__unread-badge">
203
+ {aggregatedUnreadCount > 99 ? '99+' : aggregatedUnreadCount}
204
+ </span>
205
+ )}
206
+ </div>
207
+ </div>
208
+ {/* Row 3: topic pills — always visible (at least general pill) */}
209
+ <div className="ermis-channel-list__item-topics-row">
210
+ <div className="ermis-channel-list__topic-pills">
211
+ {/* General pill — always first */}
212
+ <span className="ermis-channel-list__topic-pill">
213
+ <span className="ermis-channel-list__topic-pill-avatar">#</span>
214
+ <span className="ermis-channel-list__topic-pill-name">{generalTopicLabel}</span>
215
+ </span>
216
+ {/* Sub-topic pills */}
217
+ {visibleTopics.map((topic: Channel) => (
218
+ <Pill key={topic.cid} topic={topic} />
219
+ ))}
220
+ {hasOverflow && (
221
+ <span className="ermis-channel-list__topic-overflow">{moreTopicsLabel}</span>
222
+ )}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ <div className="ermis-channel-list__item-actions-wrapper">
227
+ <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
228
+ </div>
229
+ </div>
230
+ );
231
+ });
232
+ FlatTopicGroupItem.displayName = 'FlatTopicGroupItem';
@@ -1,11 +1,11 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
- import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
3
- import type { Channel } from '@ermis-network/ermis-chat-sdk';
4
2
  import { useChatClient } from '../hooks/useChatClient';
5
3
  import { Avatar } from './Avatar';
6
- import { Modal } from './Modal';
7
- import type { ForwardMessageModalProps, ForwardChannelItemProps, AvatarProps } from '../types';
4
+ import { Modal as DefaultModal } from './Modal';
5
+ import { useChatComponents } from '../context/ChatComponentsContext';
6
+ import type { ForwardMessageModalProps, ForwardChannelItemProps } from '../types';
8
7
  import { isTopicChannel } from '../channelTypeUtils';
8
+ import { useForwardMessage } from '../hooks/useForwardMessage';
9
9
 
10
10
  export type { ForwardMessageModalProps, ForwardChannelItemProps } from '../types';
11
11
 
@@ -18,12 +18,20 @@ const DefaultForwardChannelItem: React.FC<ForwardChannelItemProps> = React.memo(
18
18
  onToggle,
19
19
  AvatarComponent,
20
20
  }) => {
21
+ const { client } = useChatClient();
22
+ const isTopic = isTopicChannel(channel);
23
+ const parentCid = channel.data?.parent_cid as string | undefined;
24
+ const parent = parentCid ? client.activeChannels[parentCid] : null;
25
+ const parentName = parent?.data?.name || '';
26
+
21
27
  const name = (channel.data?.name || channel.cid) as string;
22
28
  const rawImage = channel.data?.image as string | undefined;
23
29
  // Parse emoji:// format → extract just the emoji for avatar fallback
24
30
  const isEmoji = rawImage?.startsWith('emoji://');
25
31
  const image = isEmoji ? undefined : rawImage;
26
- const emojiIcon = isEmoji ? rawImage!.replace('emoji://', '') : undefined;
32
+
33
+ // Use # for topics without explicit emoji/image
34
+ const emojiIcon = isEmoji ? rawImage!.replace('emoji://', '') : (isTopic && !image ? '#' : undefined);
27
35
 
28
36
  return (
29
37
  <div
@@ -35,7 +43,12 @@ const DefaultForwardChannelItem: React.FC<ForwardChannelItemProps> = React.memo(
35
43
  ) : (
36
44
  <AvatarComponent image={image} name={name} size={36} />
37
45
  )}
38
- <span className="ermis-forward-modal__channel-name">{name}</span>
46
+ <div className="ermis-forward-modal__channel-name-container">
47
+ {isTopic && parentName && (
48
+ <span className="ermis-forward-modal__channel-parent-name">{parentName}</span>
49
+ )}
50
+ <span className="ermis-forward-modal__channel-name">{name}</span>
51
+ </div>
39
52
  <div className={`ermis-forward-modal__checkbox ${selected ? 'ermis-forward-modal__checkbox--checked' : ''}`}>
40
53
  {selected && (
41
54
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
@@ -57,79 +70,20 @@ export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
57
70
  ChannelItemComponent = DefaultForwardChannelItem,
58
71
  SearchInputComponent,
59
72
  }) => {
60
- const { client, activeChannel } = useChatClient();
61
- const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
62
- const [search, setSearch] = useState('');
63
- const [sending, setSending] = useState(false);
64
- const [results, setResults] = useState<{ success: string[]; failed: string[] } | null>(null);
73
+ const { ModalComponent } = useChatComponents();
74
+ const Modal = ModalComponent || DefaultModal;
65
75
  const backdropRef = useRef<HTMLDivElement>(null);
66
76
 
67
- /* ---------- Get channels from client state (exclude topics) ---------- */
68
- const channels = useMemo(() => {
69
- return (Object.values(client.activeChannels) as Channel[]).filter(
70
- (ch) => !isTopicChannel(ch),
71
- );
72
- }, [client.activeChannels]);
73
-
74
- /* ---------- Filter by search ---------- */
75
- const filteredChannels = useMemo(() => {
76
- if (!search.trim()) return channels;
77
- const q = search.toLowerCase();
78
- return channels.filter((ch) => {
79
- const name = ((ch.data?.name || ch.cid) as string).toLowerCase();
80
- return name.includes(q);
81
- });
82
- }, [channels, search]);
83
-
84
- /* ---------- Toggle selection ---------- */
85
- const toggleChannel = useCallback((channel: Channel) => {
86
- setSelectedChannels((prev) => {
87
- const next = new Set(prev);
88
- if (next.has(channel.cid)) {
89
- next.delete(channel.cid);
90
- } else {
91
- next.add(channel.cid);
92
- }
93
- return next;
94
- });
95
- }, []);
96
-
97
- /* ---------- Send forward ---------- */
98
- const handleSend = useCallback(async () => {
99
- if (!activeChannel || selectedChannels.size === 0 || sending) return;
100
- setSending(true);
101
- const success: string[] = [];
102
- const failed: string[] = [];
103
-
104
- for (const cid of selectedChannels) {
105
- const targetChannel = channels.find((c) => c.cid === cid);
106
- if (!targetChannel) continue;
107
- try {
108
- const forwardPayload = createForwardMessagePayload(
109
- message,
110
- targetChannel.cid as string,
111
- activeChannel.cid as string,
112
- );
113
-
114
- await activeChannel.forwardMessage(forwardPayload, {
115
- type: targetChannel.type,
116
- channelID: targetChannel.id!,
117
- });
118
- success.push((targetChannel.data?.name || targetChannel.cid) as string);
119
- } catch (err) {
120
- console.error(`Failed to forward to ${cid}`, err);
121
- failed.push((targetChannel.data?.name || targetChannel.cid) as string);
122
- }
123
- }
124
-
125
- setResults({ success, failed });
126
- setSending(false);
127
-
128
- // Auto-close after success (short delay)
129
- if (failed.length === 0) {
130
- setTimeout(() => onDismiss(), 1200);
131
- }
132
- }, [activeChannel, selectedChannels, channels, message, sending, onDismiss]);
77
+ const {
78
+ search,
79
+ setSearch,
80
+ selectedChannels,
81
+ toggleChannel,
82
+ sending,
83
+ results,
84
+ filteredChannels,
85
+ handleSend,
86
+ } = useForwardMessage(message, onDismiss);
133
87
 
134
88
  /* ---------- Keyboard / backdrop close ---------- */
135
89
  useEffect(() => {
@@ -1,20 +1,13 @@
1
1
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import { preloadImage } from '../utils';
4
- import { useChatClient } from '../hooks/useChatClient';
4
+ import { useDownloadHandler } from '../hooks/useDownloadHandler';
5
5
  import type { MediaLightboxProps } from '../types';
6
6
 
7
- /** Extract a reasonable filename from a URL or alt text */
8
- const getFilename = (src: string, alt?: string): string => {
9
- if (alt) return alt;
10
- try {
11
- const pathname = new URL(src).pathname;
12
- const segments = pathname.split('/');
13
- return segments[segments.length - 1] || 'download';
14
- } catch {
15
- return 'download';
16
- }
17
- };
7
+ /** Max retry attempts for video loading (CDN may not be ready for large uploads) */
8
+ const VIDEO_MAX_RETRIES = 3;
9
+ /** Base delay in ms for exponential backoff: 1s, 2s, 4s */
10
+ const VIDEO_RETRY_BASE_DELAY = 1000;
18
11
 
19
12
  /**
20
13
  * MediaLightbox – full-screen overlay for viewing images & videos.
@@ -36,13 +29,23 @@ export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
36
29
  const videoRef = useRef<HTMLVideoElement>(null);
37
30
  const containerRef = useRef<HTMLDivElement>(null);
38
31
 
32
+ // Video retry state — handles CDN not-ready for large recently-uploaded files
33
+ const [videoRetryCount, setVideoRetryCount] = useState(0);
34
+ const [videoLoading, setVideoLoading] = useState(false);
35
+ const videoRetryTimerRef = useRef<ReturnType<typeof setTimeout>>();
36
+
39
37
  // Reset state when opening or when items change
40
38
  useEffect(() => {
41
39
  if (isOpen) {
42
40
  setCurrentIndex(initialIndex);
43
41
  setZoom(1);
44
42
  setPan({ x: 0, y: 0 });
43
+ setVideoRetryCount(0);
44
+ setVideoLoading(false);
45
45
  }
46
+ return () => {
47
+ if (videoRetryTimerRef.current) clearTimeout(videoRetryTimerRef.current);
48
+ };
46
49
  }, [isOpen, initialIndex]);
47
50
 
48
51
  // Preload adjacent images
@@ -76,9 +79,12 @@ export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
76
79
 
77
80
  const goTo = useCallback((idx: number) => {
78
81
  if (videoRef.current) videoRef.current.pause();
82
+ if (videoRetryTimerRef.current) clearTimeout(videoRetryTimerRef.current);
79
83
  setCurrentIndex(idx);
80
84
  setZoom(1);
81
85
  setPan({ x: 0, y: 0 });
86
+ setVideoRetryCount(0);
87
+ setVideoLoading(false);
82
88
  }, []);
83
89
 
84
90
  const goPrev = useCallback(() => {
@@ -163,45 +169,61 @@ export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
163
169
  }
164
170
  }, [onClose]);
165
171
 
166
- const { client } = useChatClient();
172
+ const { downloadFile } = useDownloadHandler();
167
173
 
168
174
  const currentItem = items[currentIndex];
169
175
  const hasMultiple = items.length > 1;
170
176
 
171
177
  const handleDownload = useCallback(async () => {
172
178
  if (!currentItem) return;
173
- const filename = getFilename(currentItem.src, currentItem.alt);
174
-
175
- try {
176
- const blob = await client.downloadMedia(currentItem.src);
177
- const urlBlob = window.URL.createObjectURL(blob);
178
- const a = document.createElement('a');
179
- a.href = urlBlob;
180
- a.download = filename;
181
- document.body.appendChild(a);
182
- a.click();
183
- a.remove();
184
- window.URL.revokeObjectURL(urlBlob);
185
- } catch {
186
- window.open(currentItem.src, '_blank', 'noopener,noreferrer');
187
- }
188
- }, [client, currentItem]);
179
+ await downloadFile(currentItem.src, currentItem.alt || 'media');
180
+ }, [currentItem, downloadFile]);
181
+
182
+ // Video error handler — retries loading with exponential backoff
183
+ // Handles CDN not-ready scenario for large recently-uploaded files
184
+ const handleVideoError = useCallback(() => {
185
+ setVideoRetryCount((prev) => {
186
+ if (prev >= VIDEO_MAX_RETRIES) return prev;
187
+ const nextAttempt = prev + 1;
188
+ const delay = VIDEO_RETRY_BASE_DELAY * Math.pow(2, prev); // 1s, 2s, 4s
189
+ setVideoLoading(true);
190
+ videoRetryTimerRef.current = setTimeout(() => {
191
+ // Force the video element to re-attempt loading by resetting src
192
+ if (videoRef.current) {
193
+ const src = videoRef.current.src;
194
+ videoRef.current.src = '';
195
+ videoRef.current.src = src;
196
+ videoRef.current.load();
197
+ }
198
+ setVideoLoading(false);
199
+ }, delay);
200
+ return nextAttempt;
201
+ });
202
+ }, []);
189
203
 
190
204
  const content = useMemo(() => {
191
205
  if (!currentItem) return null;
192
206
 
193
207
  if (currentItem.type === 'video') {
194
208
  return (
195
- <video
196
- ref={videoRef}
197
- className="ermis-lightbox__video"
198
- src={currentItem.src}
199
- poster={currentItem.posterSrc}
200
- controls
201
- autoPlay
202
- preload="metadata"
203
- onClick={(e) => e.stopPropagation()}
204
- />
209
+ <div className="ermis-lightbox__video-wrapper">
210
+ <video
211
+ ref={videoRef}
212
+ className="ermis-lightbox__video"
213
+ src={currentItem.src}
214
+ poster={currentItem.posterSrc}
215
+ controls
216
+ autoPlay
217
+ preload="metadata"
218
+ onClick={(e) => e.stopPropagation()}
219
+ onError={handleVideoError}
220
+ />
221
+ {videoLoading && (
222
+ <div className="ermis-lightbox__video-retry">
223
+ <div className="ermis-lightbox__video-spinner" />
224
+ </div>
225
+ )}
226
+ </div>
205
227
  );
206
228
  }
207
229
 
@@ -225,7 +247,7 @@ export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
225
247
  onClick={(e) => e.stopPropagation()}
226
248
  />
227
249
  );
228
- }, [currentItem, zoom, pan, isDragging, handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]);
250
+ }, [currentItem, zoom, pan, isDragging, videoLoading, handleDoubleClick, handleVideoError, handleMouseDown, handleMouseMove, handleMouseUp]);
229
251
 
230
252
  if (!isOpen || !currentItem) return null;
231
253
 
@@ -1,57 +1,69 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
- import { VList, VListHandle } from 'virtua';
3
2
  import { Avatar } from './Avatar';
4
3
  import type { MentionSuggestionsProps } from '../types';
5
4
 
6
5
  export type { MentionSuggestionsProps } from '../types';
7
6
 
8
- // Estimated item height
9
- const ITEM_HEIGHT = 42;
10
-
11
7
  export const MentionSuggestions: React.FC<MentionSuggestionsProps> = React.memo(({
12
8
  members,
13
9
  highlightIndex,
14
10
  onSelect,
15
11
  }) => {
16
- const listRef = useRef<VListHandle>(null);
12
+ const containerRef = useRef<HTMLDivElement>(null);
13
+ const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
17
14
 
18
15
  // Auto-scroll highlighted item into view
19
16
  useEffect(() => {
20
- // VList uses scrollToIndex
21
- listRef.current?.scrollToIndex(highlightIndex);
17
+ const el = itemsRef.current.get(highlightIndex);
18
+ if (el && containerRef.current) {
19
+ const container = containerRef.current;
20
+ const elementTop = el.offsetTop;
21
+ const elementBottom = elementTop + el.offsetHeight;
22
+ const containerTop = container.scrollTop;
23
+ const containerBottom = containerTop + container.clientHeight;
24
+
25
+ if (elementTop < containerTop) {
26
+ container.scrollTop = elementTop;
27
+ } else if (elementBottom > containerBottom) {
28
+ container.scrollTop = elementBottom - container.clientHeight;
29
+ }
30
+ }
22
31
  }, [highlightIndex]);
23
32
 
24
33
  if (members.length === 0) return null;
25
34
 
26
- // Calculate dynamic height based on item count, cap at 200px
27
- const listHeight = Math.min(members.length * ITEM_HEIGHT, 200);
28
-
29
35
  return (
30
- <div className="ermis-mention-suggestions" style={{ overflow: 'hidden' }}>
31
- <VList ref={listRef} style={{ height: listHeight }}>
32
- {members.map((member, index) => (
33
- <div
34
- key={member.id}
35
- className={`ermis-mention-suggestions__item${
36
- index === highlightIndex ? ' ermis-mention-suggestions__item--highlighted' : ''
37
- }`}
38
- onMouseDown={(e) => {
39
- // Use mousedown (not click) to fire before blur
40
- e.preventDefault();
41
- onSelect(member);
42
- }}
43
- >
44
- {member.id === '__all__' ? (
45
- <div className="ermis-mention-suggestions__all-icon">@</div>
46
- ) : (
47
- <Avatar image={member.avatar} name={member.name} size={24} />
48
- )}
49
- <span className="ermis-mention-suggestions__name">
50
- {member.id === '__all__' ? 'all' : member.name}
51
- </span>
52
- </div>
53
- ))}
54
- </VList>
36
+ <div
37
+ className="ermis-mention-suggestions"
38
+ ref={containerRef}
39
+ style={{ overflowY: 'auto', maxHeight: '200px' }}
40
+ >
41
+ {members.map((member, index) => (
42
+ <div
43
+ key={member.id}
44
+ ref={(el) => {
45
+ if (el) itemsRef.current.set(index, el);
46
+ else itemsRef.current.delete(index);
47
+ }}
48
+ className={`ermis-mention-suggestions__item${
49
+ index === highlightIndex ? ' ermis-mention-suggestions__item--highlighted' : ''
50
+ }`}
51
+ onMouseDown={(e) => {
52
+ // Use mousedown (not click) to fire before blur
53
+ e.preventDefault();
54
+ onSelect(member);
55
+ }}
56
+ >
57
+ {member.id === '__all__' ? (
58
+ <div className="ermis-mention-suggestions__all-icon">@</div>
59
+ ) : (
60
+ <Avatar image={member.avatar} name={member.name} size={24} />
61
+ )}
62
+ <span className="ermis-mention-suggestions__name">
63
+ {member.id === '__all__' ? 'all' : member.name}
64
+ </span>
65
+ </div>
66
+ ))}
55
67
  </div>
56
68
  );
57
69
  });
@@ -3,7 +3,8 @@ import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useMessageActions } from '../hooks/useMessageActions';
4
4
  import { useChatClient } from '../hooks/useChatClient';
5
5
  import type { MessageActionsBoxProps } from '../types';
6
- import { Dropdown, closeAllDropdowns } from './Dropdown';
6
+ import { Dropdown as DefaultDropdown, closeAllDropdowns } from './Dropdown';
7
+ import { useChatComponents } from '../context/ChatComponentsContext';
7
8
 
8
9
  // Aliased for backward compatibility
9
10
  export const closeAllActionBoxes = closeAllDropdowns;
@@ -26,6 +27,8 @@ export const MessageActionsBox: React.FC<MessageActionsBoxProps> = ({
26
27
  deleteForEveryoneLabel = 'Delete for everyone',
27
28
  }) => {
28
29
  const { setQuotedMessage, setEditingMessage, setForwardingMessage, activeChannel } = useChatClient();
30
+ const { DropdownComponent } = useChatComponents();
31
+ const Dropdown = DropdownComponent || DefaultDropdown;
29
32
  const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
30
33
  const actions = useMessageActions(message, isOwnMessage);
31
34