@ermis-network/ermis-chat-react 1.0.8 → 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.
- package/dist/index.cjs +15295 -4209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15246 -4186
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +137 -16
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- 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
|
|
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
|
-
|
|
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
|
-
<
|
|
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 {
|
|
61
|
-
const
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 {
|
|
4
|
+
import { useDownloadHandler } from '../hooks/useDownloadHandler';
|
|
5
5
|
import type { MediaLightboxProps } from '../types';
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|