@ermis-network/ermis-chat-react 1.0.6 → 1.0.8
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 +3802 -1772
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +836 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.mjs +3755 -1761
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +233 -0
- package/src/components/ChannelHeader.tsx +126 -5
- package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +514 -47
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +21 -3
- package/src/components/MessageItem.tsx +10 -12
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +174 -54
- package/src/components/PendingOverlay.tsx +51 -0
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +162 -47
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -8
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +28 -5
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +9 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +67 -10
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +276 -6
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +170 -13
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +76 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +2 -0
- package/src/types.ts +203 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export type ClosedTopicOverlayProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle: string;
|
|
6
|
+
canManageTopic: boolean;
|
|
7
|
+
reopenLabel: string;
|
|
8
|
+
onReopen?: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ClosedTopicOverlay: React.FC<ClosedTopicOverlayProps> = React.memo(({
|
|
12
|
+
title,
|
|
13
|
+
subtitle,
|
|
14
|
+
canManageTopic,
|
|
15
|
+
reopenLabel,
|
|
16
|
+
onReopen,
|
|
17
|
+
}) => (
|
|
18
|
+
<div className="ermis-message-list__closed-overlay">
|
|
19
|
+
<div className="ermis-message-list__closed-overlay-icon">
|
|
20
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
21
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
22
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
23
|
+
</svg>
|
|
24
|
+
</div>
|
|
25
|
+
<span className="ermis-message-list__closed-overlay-title">{title}</span>
|
|
26
|
+
<span className="ermis-message-list__closed-overlay-subtitle">{subtitle}</span>
|
|
27
|
+
{canManageTopic && onReopen && (
|
|
28
|
+
<button
|
|
29
|
+
className="ermis-message-list__reopen-btn"
|
|
30
|
+
onClick={onReopen}
|
|
31
|
+
>
|
|
32
|
+
{reopenLabel}
|
|
33
|
+
</button>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
));
|
|
37
|
+
|
|
38
|
+
ClosedTopicOverlay.displayName = 'ClosedTopicOverlay';
|
|
@@ -4,6 +4,7 @@ import { UserPicker } from './UserPicker';
|
|
|
4
4
|
import { Avatar } from './Avatar';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import type { CreateChannelModalProps, UserPickerUser } from '../types';
|
|
7
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
@@ -24,8 +25,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
24
25
|
cancelButtonLabel = 'Cancel',
|
|
25
26
|
createButtonLabel = 'Create',
|
|
26
27
|
creatingButtonLabel = 'Creating...',
|
|
28
|
+
messageButtonLabel = 'Message',
|
|
27
29
|
}) => {
|
|
28
|
-
const { client } = useChatClient();
|
|
30
|
+
const { client, setActiveChannel } = useChatClient();
|
|
29
31
|
const currentUserId = client?.userID;
|
|
30
32
|
|
|
31
33
|
/* ---------- State ---------- */
|
|
@@ -45,19 +47,20 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
45
47
|
const [error, setError] = useState<string | null>(null);
|
|
46
48
|
|
|
47
49
|
/* ---------- Exclude IDs for Direct ---------- */
|
|
48
|
-
const
|
|
49
|
-
if (!client || !currentUserId || tab !== 'messaging') return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Object.values(client.activeChannels).
|
|
53
|
-
if (
|
|
54
|
-
Object.keys(
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const hasExistingDirectChannel = useMemo(() => {
|
|
51
|
+
if (!client || !currentUserId || tab !== 'messaging' || selectedUsers.length === 0) return false;
|
|
52
|
+
const targetUserId = selectedUsers[0].id;
|
|
53
|
+
|
|
54
|
+
return Object.values(client.activeChannels).some((ch: any) => {
|
|
55
|
+
if (isDirectChannel(ch) && ch.state?.members) {
|
|
56
|
+
const membersList = Object.keys(ch.state.members);
|
|
57
|
+
return membersList.length === 2 &&
|
|
58
|
+
membersList.includes(currentUserId) &&
|
|
59
|
+
membersList.includes(targetUserId);
|
|
57
60
|
}
|
|
61
|
+
return false;
|
|
58
62
|
});
|
|
59
|
-
|
|
60
|
-
}, [client, currentUserId, tab]);
|
|
63
|
+
}, [client, currentUserId, tab, selectedUsers]);
|
|
61
64
|
|
|
62
65
|
/* ---------- Handlers ---------- */
|
|
63
66
|
const handleCreate = useCallback(async () => {
|
|
@@ -82,10 +85,37 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
82
85
|
|
|
83
86
|
if (tab === 'messaging') {
|
|
84
87
|
const targetUserId = selectedUsers[0].id;
|
|
88
|
+
|
|
89
|
+
// Try to find an existing direct channel locally
|
|
90
|
+
const existingChannel = Object.values(client.activeChannels).find((ch: any) => {
|
|
91
|
+
if (isDirectChannel(ch) && ch.state?.members) {
|
|
92
|
+
const membersList = Object.keys(ch.state.members);
|
|
93
|
+
return membersList.length === 2 &&
|
|
94
|
+
membersList.includes(currentUserId) &&
|
|
95
|
+
membersList.includes(targetUserId);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (existingChannel) {
|
|
101
|
+
if (setActiveChannel) setActiveChannel(existingChannel as any);
|
|
102
|
+
if (onSuccess) {
|
|
103
|
+
onSuccess(existingChannel as any);
|
|
104
|
+
} else {
|
|
105
|
+
onClose();
|
|
106
|
+
}
|
|
107
|
+
setIsCreating(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
createdChannel = client.channel('messaging', {
|
|
86
112
|
members: [currentUserId, targetUserId],
|
|
87
113
|
} as any);
|
|
88
|
-
await createdChannel.create();
|
|
114
|
+
const response = (await createdChannel.create()) as any;
|
|
115
|
+
if (response?.channel?.id) {
|
|
116
|
+
createdChannel = client.channel('messaging', response.channel.id);
|
|
117
|
+
await createdChannel.watch();
|
|
118
|
+
}
|
|
89
119
|
} else {
|
|
90
120
|
// Group Channel
|
|
91
121
|
const memberIds = selectedUsers.map(member => member.id);
|
|
@@ -105,7 +135,15 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
createdChannel = client.channel('team', payload);
|
|
108
|
-
await createdChannel.create();
|
|
138
|
+
const response = (await createdChannel.create()) as any;
|
|
139
|
+
if (response?.channel?.id) {
|
|
140
|
+
createdChannel = client.channel('team', response.channel.id);
|
|
141
|
+
await createdChannel.watch();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (setActiveChannel) {
|
|
146
|
+
setActiveChannel(createdChannel);
|
|
109
147
|
}
|
|
110
148
|
|
|
111
149
|
// Cleanup and execute callback
|
|
@@ -136,7 +174,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
136
174
|
<div className="ermis-create-channel__footer">
|
|
137
175
|
<button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
|
|
138
176
|
<button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={handleCreate} disabled={isCreating || !isValid}>
|
|
139
|
-
{isCreating ? creatingButtonLabel : createButtonLabel}
|
|
177
|
+
{isCreating ? creatingButtonLabel : (hasExistingDirectChannel ? messageButtonLabel : createButtonLabel)}
|
|
140
178
|
</button>
|
|
141
179
|
</div>
|
|
142
180
|
);
|
|
@@ -247,7 +285,6 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
247
285
|
<UserPicker
|
|
248
286
|
mode={tab === 'messaging' ? 'radio' : 'checkbox'}
|
|
249
287
|
onSelectionChange={setSelectedUsers}
|
|
250
|
-
excludeUserIds={tab === 'messaging' ? existingDirectUserIds : []}
|
|
251
288
|
initialSelectedUsers={selectedUsers}
|
|
252
289
|
AvatarComponent={AvatarComponent}
|
|
253
290
|
UserItemComponent={UserItemComponent as any}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
6
|
|
|
6
7
|
const MAX_PREVIEW_LENGTH = 120;
|
|
@@ -57,7 +58,7 @@ export const EditPreview: React.FC<{
|
|
|
57
58
|
const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
|
|
58
59
|
const hasText = !!formattedText.trim();
|
|
59
60
|
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
60
|
-
const isSticker = message
|
|
61
|
+
const isSticker = isStickerMessage(message);
|
|
61
62
|
const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
|
|
62
63
|
|
|
63
64
|
// Build preview content
|
|
@@ -5,6 +5,7 @@ import { useChatClient } from '../hooks/useChatClient';
|
|
|
5
5
|
import { Avatar } from './Avatar';
|
|
6
6
|
import { Modal } from './Modal';
|
|
7
7
|
import type { ForwardMessageModalProps, ForwardChannelItemProps, AvatarProps } from '../types';
|
|
8
|
+
import { isTopicChannel } from '../channelTypeUtils';
|
|
8
9
|
|
|
9
10
|
export type { ForwardMessageModalProps, ForwardChannelItemProps } from '../types';
|
|
10
11
|
|
|
@@ -66,7 +67,7 @@ export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
|
|
|
66
67
|
/* ---------- Get channels from client state (exclude topics) ---------- */
|
|
67
68
|
const channels = useMemo(() => {
|
|
68
69
|
return (Object.values(client.activeChannels) as Channel[]).filter(
|
|
69
|
-
(ch) => ch
|
|
70
|
+
(ch) => !isTopicChannel(ch),
|
|
70
71
|
);
|
|
71
72
|
}, [client.activeChannels]);
|
|
72
73
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom';
|
|
3
|
+
import { preloadImage } from '../utils';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import type { MediaLightboxProps } from '../types';
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MediaLightbox – full-screen overlay for viewing images & videos.
|
|
21
|
+
* Supports prev/next navigation, keyboard controls, and image zoom.
|
|
22
|
+
* Renders via React portal into document.body.
|
|
23
|
+
*/
|
|
24
|
+
export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
|
|
25
|
+
items,
|
|
26
|
+
initialIndex = 0,
|
|
27
|
+
isOpen,
|
|
28
|
+
onClose,
|
|
29
|
+
}) => {
|
|
30
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
31
|
+
const [zoom, setZoom] = useState(1);
|
|
32
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
33
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
34
|
+
const dragStart = useRef({ x: 0, y: 0 });
|
|
35
|
+
const panStart = useRef({ x: 0, y: 0 });
|
|
36
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
37
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
|
|
39
|
+
// Reset state when opening or when items change
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
setCurrentIndex(initialIndex);
|
|
43
|
+
setZoom(1);
|
|
44
|
+
setPan({ x: 0, y: 0 });
|
|
45
|
+
}
|
|
46
|
+
}, [isOpen, initialIndex]);
|
|
47
|
+
|
|
48
|
+
// Preload adjacent images
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!isOpen) return;
|
|
51
|
+
const preloadIdx = [currentIndex - 1, currentIndex + 1];
|
|
52
|
+
preloadIdx.forEach(idx => {
|
|
53
|
+
if (idx >= 0 && idx < items.length && items[idx].type === 'image') {
|
|
54
|
+
preloadImage(items[idx].src);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}, [isOpen, currentIndex, items]);
|
|
58
|
+
|
|
59
|
+
// Pause video when navigating away or closing
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return () => {
|
|
62
|
+
if (videoRef.current) {
|
|
63
|
+
videoRef.current.pause();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}, [currentIndex]);
|
|
67
|
+
|
|
68
|
+
// Lock body scroll when open
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isOpen) {
|
|
71
|
+
const prev = document.body.style.overflow;
|
|
72
|
+
document.body.style.overflow = 'hidden';
|
|
73
|
+
return () => { document.body.style.overflow = prev; };
|
|
74
|
+
}
|
|
75
|
+
}, [isOpen]);
|
|
76
|
+
|
|
77
|
+
const goTo = useCallback((idx: number) => {
|
|
78
|
+
if (videoRef.current) videoRef.current.pause();
|
|
79
|
+
setCurrentIndex(idx);
|
|
80
|
+
setZoom(1);
|
|
81
|
+
setPan({ x: 0, y: 0 });
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const goPrev = useCallback(() => {
|
|
85
|
+
if (currentIndex > 0) goTo(currentIndex - 1);
|
|
86
|
+
}, [currentIndex, goTo]);
|
|
87
|
+
|
|
88
|
+
const goNext = useCallback(() => {
|
|
89
|
+
if (currentIndex < items.length - 1) goTo(currentIndex + 1);
|
|
90
|
+
}, [currentIndex, items.length, goTo]);
|
|
91
|
+
|
|
92
|
+
// Keyboard navigation
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!isOpen) return;
|
|
95
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
96
|
+
switch (e.key) {
|
|
97
|
+
case 'Escape':
|
|
98
|
+
onClose();
|
|
99
|
+
break;
|
|
100
|
+
case 'ArrowLeft':
|
|
101
|
+
goPrev();
|
|
102
|
+
break;
|
|
103
|
+
case 'ArrowRight':
|
|
104
|
+
goNext();
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
document.addEventListener('keydown', handleKey);
|
|
109
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
110
|
+
}, [isOpen, onClose, goPrev, goNext]);
|
|
111
|
+
|
|
112
|
+
// Double-click zoom toggle (image only)
|
|
113
|
+
const handleDoubleClick = useCallback(() => {
|
|
114
|
+
const current = items[currentIndex];
|
|
115
|
+
if (current?.type !== 'image') return;
|
|
116
|
+
|
|
117
|
+
if (zoom === 1) {
|
|
118
|
+
setZoom(2);
|
|
119
|
+
} else {
|
|
120
|
+
setZoom(1);
|
|
121
|
+
setPan({ x: 0, y: 0 });
|
|
122
|
+
}
|
|
123
|
+
}, [currentIndex, items, zoom]);
|
|
124
|
+
|
|
125
|
+
// Wheel zoom (image only)
|
|
126
|
+
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
127
|
+
const current = items[currentIndex];
|
|
128
|
+
if (current?.type !== 'image') return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
|
|
131
|
+
setZoom(prev => {
|
|
132
|
+
const next = prev - e.deltaY * 0.002;
|
|
133
|
+
const clamped = Math.max(1, Math.min(3, next));
|
|
134
|
+
if (clamped === 1) setPan({ x: 0, y: 0 });
|
|
135
|
+
return clamped;
|
|
136
|
+
});
|
|
137
|
+
}, [currentIndex, items]);
|
|
138
|
+
|
|
139
|
+
// Mouse drag for panning (image zoomed)
|
|
140
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
141
|
+
if (zoom <= 1) return;
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
setIsDragging(true);
|
|
144
|
+
dragStart.current = { x: e.clientX, y: e.clientY };
|
|
145
|
+
panStart.current = { ...pan };
|
|
146
|
+
}, [zoom, pan]);
|
|
147
|
+
|
|
148
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
149
|
+
if (!isDragging) return;
|
|
150
|
+
const dx = e.clientX - dragStart.current.x;
|
|
151
|
+
const dy = e.clientY - dragStart.current.y;
|
|
152
|
+
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
|
|
153
|
+
}, [isDragging]);
|
|
154
|
+
|
|
155
|
+
const handleMouseUp = useCallback(() => {
|
|
156
|
+
setIsDragging(false);
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
// Click on backdrop closes
|
|
160
|
+
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
|
161
|
+
if (e.target === containerRef.current) {
|
|
162
|
+
onClose();
|
|
163
|
+
}
|
|
164
|
+
}, [onClose]);
|
|
165
|
+
|
|
166
|
+
const { client } = useChatClient();
|
|
167
|
+
|
|
168
|
+
const currentItem = items[currentIndex];
|
|
169
|
+
const hasMultiple = items.length > 1;
|
|
170
|
+
|
|
171
|
+
const handleDownload = useCallback(async () => {
|
|
172
|
+
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]);
|
|
189
|
+
|
|
190
|
+
const content = useMemo(() => {
|
|
191
|
+
if (!currentItem) return null;
|
|
192
|
+
|
|
193
|
+
if (currentItem.type === 'video') {
|
|
194
|
+
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
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const imgStyle: React.CSSProperties = {
|
|
209
|
+
transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
|
|
210
|
+
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<img
|
|
215
|
+
className={`ermis-lightbox__image${zoom > 1 ? ' ermis-lightbox__image--zoomed' : ''}`}
|
|
216
|
+
src={currentItem.src}
|
|
217
|
+
alt={currentItem.alt || ''}
|
|
218
|
+
style={imgStyle}
|
|
219
|
+
draggable={false}
|
|
220
|
+
onDoubleClick={handleDoubleClick}
|
|
221
|
+
onMouseDown={handleMouseDown}
|
|
222
|
+
onMouseMove={handleMouseMove}
|
|
223
|
+
onMouseUp={handleMouseUp}
|
|
224
|
+
onMouseLeave={handleMouseUp}
|
|
225
|
+
onClick={(e) => e.stopPropagation()}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
228
|
+
}, [currentItem, zoom, pan, isDragging, handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]);
|
|
229
|
+
|
|
230
|
+
if (!isOpen || !currentItem) return null;
|
|
231
|
+
|
|
232
|
+
return ReactDOM.createPortal(
|
|
233
|
+
<div className="ermis-lightbox" onWheel={handleWheel}>
|
|
234
|
+
<div className="ermis-lightbox__backdrop" />
|
|
235
|
+
|
|
236
|
+
{/* Header: counter + actions */}
|
|
237
|
+
<div className="ermis-lightbox__header">
|
|
238
|
+
{hasMultiple && (
|
|
239
|
+
<span className="ermis-lightbox__counter">
|
|
240
|
+
{currentIndex + 1} / {items.length}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
<div className="ermis-lightbox__actions">
|
|
244
|
+
<button
|
|
245
|
+
className="ermis-lightbox__action-btn"
|
|
246
|
+
onClick={handleDownload}
|
|
247
|
+
aria-label="Download"
|
|
248
|
+
title="Download"
|
|
249
|
+
>
|
|
250
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
251
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
252
|
+
<polyline points="7 10 12 15 17 10" />
|
|
253
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
254
|
+
</svg>
|
|
255
|
+
</button>
|
|
256
|
+
<button
|
|
257
|
+
className="ermis-lightbox__action-btn"
|
|
258
|
+
onClick={onClose}
|
|
259
|
+
aria-label="Close"
|
|
260
|
+
title="Close"
|
|
261
|
+
>
|
|
262
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
263
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
264
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
265
|
+
</svg>
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Main content area */}
|
|
271
|
+
<div
|
|
272
|
+
ref={containerRef}
|
|
273
|
+
className="ermis-lightbox__content"
|
|
274
|
+
onClick={handleBackdropClick}
|
|
275
|
+
>
|
|
276
|
+
{/* Prev button */}
|
|
277
|
+
{hasMultiple && currentIndex > 0 && (
|
|
278
|
+
<button
|
|
279
|
+
className="ermis-lightbox__nav ermis-lightbox__nav--prev"
|
|
280
|
+
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
|
281
|
+
aria-label="Previous"
|
|
282
|
+
>
|
|
283
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
284
|
+
<polyline points="15 18 9 12 15 6" />
|
|
285
|
+
</svg>
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{/* Media */}
|
|
290
|
+
{content}
|
|
291
|
+
|
|
292
|
+
{/* Next button */}
|
|
293
|
+
{hasMultiple && currentIndex < items.length - 1 && (
|
|
294
|
+
<button
|
|
295
|
+
className="ermis-lightbox__nav ermis-lightbox__nav--next"
|
|
296
|
+
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
|
297
|
+
aria-label="Next"
|
|
298
|
+
>
|
|
299
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
300
|
+
<polyline points="9 18 15 12 9 6" />
|
|
301
|
+
</svg>
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* Filename */}
|
|
307
|
+
{currentItem.alt && (
|
|
308
|
+
<div className="ermis-lightbox__filename">{currentItem.alt}</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>,
|
|
311
|
+
document.body
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
MediaLightbox.displayName = 'MediaLightbox';
|
|
@@ -15,6 +15,7 @@ import { EditPreview } from './EditPreview';
|
|
|
15
15
|
import { buildUserMap, replaceMentionsForPreview, moveCaretToEnd } from '../utils';
|
|
16
16
|
import { getMentionHtml } from '../hooks/useMentions';
|
|
17
17
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
18
|
+
import { CHANNEL_ROLES } from '../channelRoleUtils';
|
|
18
19
|
import type { MentionMember, MessageInputProps, FilePreviewItem } from '../types';
|
|
19
20
|
|
|
20
21
|
export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from '../types';
|
|
@@ -35,7 +36,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
35
36
|
EmojiButtonComponent = DefaultEmojiButton,
|
|
36
37
|
ReplyPreviewComponent = ReplyPreview,
|
|
37
38
|
EditPreviewComponent = EditPreview,
|
|
38
|
-
bannedLabel = 'You have been
|
|
39
|
+
bannedLabel = 'You have been banned from this channel',
|
|
39
40
|
blockedLabel = 'You have blocked this user. Unblock to send messages.',
|
|
40
41
|
linksDisabledLabel = 'Message blocked: Sending links is disabled for members.',
|
|
41
42
|
keywordBlockedLabel = (match: string) => `Message blocked: Contains restricted word "${match}".`,
|
|
@@ -43,6 +44,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
43
44
|
slowModeLabel = (cooldown: number) => (
|
|
44
45
|
<>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
|
|
45
46
|
),
|
|
47
|
+
closedTopicLabel = 'This topic is closed.',
|
|
46
48
|
}) => {
|
|
47
49
|
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
|
|
48
50
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
@@ -51,7 +53,8 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
51
53
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
52
54
|
const [hasContent, setHasContent] = useState(false);
|
|
53
55
|
|
|
54
|
-
const { role, isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
56
|
+
const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
57
|
+
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
55
58
|
|
|
56
59
|
// Slow Mode Logic
|
|
57
60
|
const [memberMessageCooldown, setMemberMessageCooldown] = useState(Number(activeChannel?.data?.member_message_cooldown) || 0);
|
|
@@ -69,7 +72,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
69
72
|
};
|
|
70
73
|
}, [activeChannel]);
|
|
71
74
|
|
|
72
|
-
const isSlowModeApplied = isTeamChannel && role ===
|
|
75
|
+
const isSlowModeApplied = isTeamChannel && role === CHANNEL_ROLES.MEMBER && memberMessageCooldown > 0;
|
|
73
76
|
|
|
74
77
|
const [cooldownEnd, setCooldownEnd] = useState<number | null>(null);
|
|
75
78
|
const [cooldown, setCooldown] = useState(0);
|
|
@@ -398,6 +401,21 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
398
401
|
);
|
|
399
402
|
}
|
|
400
403
|
|
|
404
|
+
// Show closed topic banner instead of input
|
|
405
|
+
if (isClosedTopic) {
|
|
406
|
+
return (
|
|
407
|
+
<div className={`ermis-message-input ermis-message-input--closed${className ? ` ${className}` : ''}`}>
|
|
408
|
+
<div className="ermis-message-input__closed-banner">
|
|
409
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
410
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
411
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
412
|
+
</svg>
|
|
413
|
+
<span>{closedTopicLabel}</span>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
401
419
|
const isStillUploading = files.some((f) => f.status === 'uploading');
|
|
402
420
|
|
|
403
421
|
return (
|
|
@@ -7,6 +7,7 @@ import { MessageQuickReactions } from './MessageQuickReactions';
|
|
|
7
7
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
8
8
|
import { useChatClient } from '../hooks/useChatClient';
|
|
9
9
|
import { formatTime } from '../utils';
|
|
10
|
+
import { isSystemMessage } from '../messageTypeUtils';
|
|
10
11
|
|
|
11
12
|
export type { MessageItemProps, SystemMessageItemProps } from '../types';
|
|
12
13
|
|
|
@@ -155,9 +156,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
155
156
|
/>
|
|
156
157
|
)}
|
|
157
158
|
<div className="ermis-message-list__bubble-wrapper">
|
|
158
|
-
<
|
|
159
|
-
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} />
|
|
160
|
-
</div>
|
|
159
|
+
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} />
|
|
161
160
|
<MessageBubble message={message} isOwnMessage={isOwnMessage}>
|
|
162
161
|
{isForwarded && (
|
|
163
162
|
<span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
|
|
@@ -178,7 +177,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
178
177
|
</MessageBubble>
|
|
179
178
|
|
|
180
179
|
{/* Actions: hover buttons + dropdown menu */}
|
|
181
|
-
{message
|
|
180
|
+
{!isSystemMessage(message) && (
|
|
182
181
|
<MessageActionsBoxComponent
|
|
183
182
|
message={message}
|
|
184
183
|
isOwnMessage={isOwnMessage}
|
|
@@ -187,14 +186,13 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
187
186
|
|
|
188
187
|
{/* Message Reactions */}
|
|
189
188
|
{MessageReactionsComponent && (
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
</div>
|
|
189
|
+
<MessageReactionsComponent
|
|
190
|
+
reactionCounts={(message as any).reaction_counts}
|
|
191
|
+
ownReactions={(message as any).own_reactions}
|
|
192
|
+
latestReactions={(message as any).latest_reactions}
|
|
193
|
+
onClickReaction={handleReactionToggle}
|
|
194
|
+
disabled={!canReact}
|
|
195
|
+
/>
|
|
198
196
|
)}
|
|
199
197
|
</div>
|
|
200
198
|
</div>
|
|
@@ -14,7 +14,8 @@ const EMOJI_MAP: Record<string, string> = {
|
|
|
14
14
|
export const MessageQuickReactions: React.FC<{
|
|
15
15
|
message: FormatMessageResponse;
|
|
16
16
|
isOwnMessage: boolean;
|
|
17
|
-
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}> = React.memo(({ message, isOwnMessage, disabled }) => {
|
|
18
19
|
const { activeChannel, client } = useChatClient();
|
|
19
20
|
const currentUserId = client?.userID;
|
|
20
21
|
|
|
@@ -41,7 +42,7 @@ export const MessageQuickReactions: React.FC<{
|
|
|
41
42
|
);
|
|
42
43
|
|
|
43
44
|
return (
|
|
44
|
-
<div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''}`}>
|
|
45
|
+
<div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''} ${disabled ? 'ermis-message-quick-reactions--disabled' : ''}`}>
|
|
45
46
|
{QUICK_REACTIONS.map((type) => {
|
|
46
47
|
const isOwn =
|
|
47
48
|
(message as any).own_reactions?.some((r: any) => r.type === type) ||
|
|
@@ -16,6 +16,7 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
16
16
|
ownReactions,
|
|
17
17
|
latestReactions,
|
|
18
18
|
onClickReaction,
|
|
19
|
+
disabled,
|
|
19
20
|
}) => {
|
|
20
21
|
const { client } = useChatClient();
|
|
21
22
|
const currentUserId = client?.userID;
|
|
@@ -23,18 +24,22 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
23
24
|
if (!reactionCounts || Object.keys(reactionCounts).length === 0) return null;
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
|
-
<div className=
|
|
27
|
+
<div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}`}>
|
|
27
28
|
{Object.entries(reactionCounts).map(([type, count]) => {
|
|
28
29
|
const isOwn =
|
|
29
30
|
ownReactions?.some((r) => r.type === type) ||
|
|
30
31
|
latestReactions?.some((r) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId));
|
|
31
32
|
|
|
32
33
|
// Find users who reacted with this type for the tooltip
|
|
33
|
-
const
|
|
34
|
+
const rawUserNames = latestReactions
|
|
34
35
|
?.filter((r) => r.type === type)
|
|
35
36
|
.map((r: any) => r.user?.name || r.user?.id || r.user_id || 'Someone');
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
+
const userNames = Array.from(new Set(rawUserNames || []))
|
|
39
|
+
.map((n: any) => typeof n === 'string' ? n.replace(/‎|\u200E/gi, '').trim() : n)
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
|
|
42
|
+
const tooltip = userNames.length > 0 ? userNames.join(', ') : type;
|
|
38
43
|
const emoji = defaultReactionEmojiMap[type] || type;
|
|
39
44
|
|
|
40
45
|
return (
|