@ermis-network/ermis-chat-react 1.0.7 → 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.
Files changed (50) hide show
  1. package/dist/index.cjs +2780 -1852
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +364 -8
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +160 -1
  6. package/dist/index.d.ts +160 -1
  7. package/dist/index.mjs +2780 -1884
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/channelRoleUtils.ts +73 -0
  11. package/src/channelTypeUtils.ts +46 -0
  12. package/src/components/Avatar.tsx +57 -31
  13. package/src/components/ChannelActions.tsx +13 -11
  14. package/src/components/ChannelHeader.tsx +89 -4
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
  16. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
  17. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
  18. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
  19. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  20. package/src/components/ChannelList.tsx +59 -14
  21. package/src/components/CreateChannelModal.tsx +53 -16
  22. package/src/components/EditPreview.tsx +2 -1
  23. package/src/components/ForwardMessageModal.tsx +2 -1
  24. package/src/components/MediaLightbox.tsx +314 -0
  25. package/src/components/MessageInput.tsx +3 -2
  26. package/src/components/MessageItem.tsx +2 -1
  27. package/src/components/MessageRenderers.tsx +168 -46
  28. package/src/components/PendingOverlay.tsx +11 -1
  29. package/src/components/PinnedMessages.tsx +2 -1
  30. package/src/components/ReplyPreview.tsx +2 -1
  31. package/src/components/SkippedOverlay.tsx +36 -0
  32. package/src/components/UserPicker.tsx +1 -1
  33. package/src/components/VirtualMessageList.tsx +91 -7
  34. package/src/hooks/useBlockedState.ts +3 -2
  35. package/src/hooks/useChannelCapabilities.ts +10 -12
  36. package/src/hooks/useChannelListUpdates.ts +6 -4
  37. package/src/hooks/useChannelMessages.ts +2 -3
  38. package/src/hooks/useChannelRowUpdates.ts +3 -2
  39. package/src/hooks/useMessageActions.ts +23 -9
  40. package/src/hooks/useOnlineStatus.ts +71 -0
  41. package/src/hooks/useOnlineUsers.ts +115 -0
  42. package/src/hooks/usePendingState.ts +8 -3
  43. package/src/index.ts +61 -9
  44. package/src/messageTypeUtils.ts +64 -0
  45. package/src/styles/_channel-list.css +59 -0
  46. package/src/styles/_media-lightbox.css +263 -0
  47. package/src/styles/_message-bubble.css +99 -8
  48. package/src/styles/_message-list.css +25 -0
  49. package/src/styles/index.css +1 -0
  50. package/src/types.ts +46 -0
@@ -0,0 +1,115 @@
1
+ import { useState, useEffect, useMemo, useRef } from 'react';
2
+ import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from './useChatClient';
4
+ import { isFriendChannel } from '../channelRoleUtils';
5
+
6
+ /**
7
+ * Bulk hook that returns a `Set<string>` of user IDs that are currently online.
8
+ *
9
+ * Only users who are "friends" (exist in a direct channel where both
10
+ * members have `owner` role) are tracked. The status is derived from
11
+ * `channel.state.watchers` and kept in sync via `user.watching.start`
12
+ * and `user.watching.stop` WebSocket events at the **client** level
13
+ * for efficiency (single subscription instead of N per-channel ones).
14
+ *
15
+ * Usage in ChannelList: `const onlineUsers = useOnlineUsers(channels);`
16
+ * Then check: `onlineUsers.has(userId)`.
17
+ *
18
+ * @param channels – The full list of loaded channels (from ChannelList).
19
+ */
20
+ export function useOnlineUsers(channels: Channel[]): Set<string> {
21
+ const { client } = useChatClient();
22
+ const currentUserId = client.userID;
23
+
24
+ // Build a map: friendUserId → Channel (the friend channel).
25
+ // This memoizes the friend channel lookup so we only iterate once per channels change.
26
+ const friendMap = useMemo(() => {
27
+ const map = new Map<string, Channel>();
28
+ if (!currentUserId) return map;
29
+
30
+ for (const ch of channels) {
31
+ const members = ch.state?.members;
32
+ if (!members) continue;
33
+
34
+ // Find the "other" user in this channel
35
+ for (const memberId of Object.keys(members)) {
36
+ if (memberId === currentUserId) continue;
37
+ if (isFriendChannel(ch, memberId, currentUserId)) {
38
+ map.set(memberId, ch);
39
+ }
40
+ }
41
+ }
42
+ return map;
43
+ }, [channels, currentUserId]);
44
+
45
+ // Compute the initial set of online users from watchers.
46
+ const computeOnlineSet = (): Set<string> => {
47
+ const set = new Set<string>();
48
+ for (const [userId, ch] of friendMap.entries()) {
49
+ if (ch.state?.watchers?.[userId]) {
50
+ set.add(userId);
51
+ }
52
+ }
53
+ return set;
54
+ };
55
+
56
+ const [onlineUsers, setOnlineUsers] = useState<Set<string>>(() => computeOnlineSet());
57
+
58
+ // Keep friendMap in a ref so that event handlers always see the latest version.
59
+ const friendMapRef = useRef(friendMap);
60
+ friendMapRef.current = friendMap;
61
+
62
+ // Re-compute when friendMap changes (new channels loaded, channels array mutated).
63
+ useEffect(() => {
64
+ setOnlineUsers(computeOnlineSet());
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [friendMap]);
67
+
68
+ // Subscribe at the client level for efficiency.
69
+ useEffect(() => {
70
+ if (!currentUserId) return;
71
+
72
+ const handleWatchingStart = (event: Event) => {
73
+ const userId = event.user?.id;
74
+ const eventCid = event.cid;
75
+ if (!userId || !eventCid) return;
76
+
77
+ // Check if this userId belongs to a tracked friend channel
78
+ const tracked = friendMapRef.current.get(userId);
79
+ if (tracked && tracked.cid === eventCid) {
80
+ setOnlineUsers((prev) => {
81
+ if (prev.has(userId)) return prev;
82
+ const next = new Set(prev);
83
+ next.add(userId);
84
+ return next;
85
+ });
86
+ }
87
+ };
88
+
89
+ const handleWatchingStop = (event: Event) => {
90
+ const userId = event.user?.id;
91
+ const eventCid = event.cid;
92
+ if (!userId || !eventCid) return;
93
+
94
+ const tracked = friendMapRef.current.get(userId);
95
+ if (tracked && tracked.cid === eventCid) {
96
+ setOnlineUsers((prev) => {
97
+ if (!prev.has(userId)) return prev;
98
+ const next = new Set(prev);
99
+ next.delete(userId);
100
+ return next;
101
+ });
102
+ }
103
+ };
104
+
105
+ const sub1 = client.on('user.watching.start', handleWatchingStart);
106
+ const sub2 = client.on('user.watching.stop', handleWatchingStop);
107
+
108
+ return () => {
109
+ sub1.unsubscribe();
110
+ sub2.unsubscribe();
111
+ };
112
+ }, [client, currentUserId]);
113
+
114
+ return onlineUsers;
115
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { isPendingMember } from '../channelRoleUtils';
3
4
 
4
5
  /**
5
6
  * Hook that tracks whether the current user is in a 'pending' state for the given channel.
@@ -7,7 +8,7 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
7
8
  export function usePendingState(channel: Channel | null | undefined, currentUserId?: string) {
8
9
  const [isPending, setIsPending] = useState<boolean>(() => {
9
10
  const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId || ''];
10
- return membership?.channel_role === 'pending' || (membership as Record<string, unknown>)?.role === 'pending';
11
+ return isPendingMember(membership?.channel_role as string);
11
12
  });
12
13
 
13
14
  useEffect(() => {
@@ -18,7 +19,7 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
18
19
 
19
20
  const checkPending = () => {
20
21
  const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
21
- return membership?.channel_role === 'pending' || (membership as Record<string, unknown>)?.role === 'pending';
22
+ return isPendingMember(membership?.channel_role as string);
22
23
  };
23
24
 
24
25
  // Sync initial state
@@ -42,7 +43,9 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
42
43
  if (eventUserId !== currentUserId) return; // Only react to own invite events
43
44
 
44
45
  const eventCid =
45
- event.cid || (event.channel as Record<string, unknown>)?.cid || (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
46
+ event.cid ||
47
+ (event.channel as Record<string, unknown>)?.cid ||
48
+ (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
46
49
  if (eventCid === channel.cid) {
47
50
  defensiveUpdateState(event);
48
51
  setIsPending(checkPending());
@@ -52,10 +55,12 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
52
55
  const client = channel.getClient();
53
56
  const sub1 = client.on('notification.invite_accepted', handleInviteAction);
54
57
  const sub2 = client.on('notification.invite_rejected', handleInviteAction);
58
+ const sub3 = client.on('notification.invite_messaging_skipped', handleInviteAction);
55
59
 
56
60
  return () => {
57
61
  sub1.unsubscribe();
58
62
  sub2.unsubscribe();
63
+ sub3.unsubscribe();
59
64
  };
60
65
  }, [channel, currentUserId]);
61
66
 
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ export { useChannelListUpdates } from './hooks/useChannelListUpdates';
13
13
  export { useChannelRowUpdates } from './hooks/useChannelRowUpdates';
14
14
  export { useBannedState } from './hooks/useBannedState';
15
15
  export { useBlockedState } from './hooks/useBlockedState';
16
+ export { useOnlineStatus } from './hooks/useOnlineStatus';
17
+ export type { OnlineStatus } from './hooks/useOnlineStatus';
18
+ export { useOnlineUsers } from './hooks/useOnlineUsers';
16
19
  export { usePendingState } from './hooks/usePendingState';
17
20
 
18
21
  // Components
@@ -32,7 +35,14 @@ export { ChannelHeader } from './components/ChannelHeader';
32
35
  export type { ChannelHeaderProps } from './components/ChannelHeader';
33
36
  export type { ChannelHeaderData } from './types';
34
37
 
35
- export type { MessageListProps, MessageBubbleProps, MessageItemProps, SystemMessageItemProps, DateSeparatorProps, JumpToLatestProps } from './types';
38
+ export type {
39
+ MessageListProps,
40
+ MessageBubbleProps,
41
+ MessageItemProps,
42
+ SystemMessageItemProps,
43
+ DateSeparatorProps,
44
+ JumpToLatestProps,
45
+ } from './types';
36
46
 
37
47
  export { VirtualMessageList } from './components/VirtualMessageList';
38
48
 
@@ -54,6 +64,44 @@ export { MessageQuickReactions } from './components/MessageQuickReactions';
54
64
  export { useMessageActions } from './hooks/useMessageActions';
55
65
 
56
66
  export { formatTime, getDateKey, formatDateLabel, getMessageUserId, replaceMentionsForPreview } from './utils';
67
+ export {
68
+ isGroupChannel,
69
+ isDirectChannel,
70
+ isTopicChannel,
71
+ isPublicGroupChannel,
72
+ isGeneralProxy,
73
+ hasTopicsEnabled,
74
+ supportsBlocking,
75
+ } from './channelTypeUtils';
76
+ export {
77
+ CHANNEL_ROLES,
78
+ isPendingMember,
79
+ isSkippedMember,
80
+ isOwnerMember,
81
+ isFriendChannel,
82
+ canManageChannel,
83
+ canRemoveTargetMember,
84
+ canBanTargetMember,
85
+ canPromoteTargetMember,
86
+ canDemoteTargetMember,
87
+ } from './channelRoleUtils';
88
+ export type { ChannelRole } from './channelRoleUtils';
89
+
90
+ export {
91
+ MESSAGE_TYPES,
92
+ ATTACHMENT_TYPES,
93
+ isSystemMessage,
94
+ isStickerMessage,
95
+ isRegularMessage,
96
+ isSignalMessage,
97
+ isImageAttachment,
98
+ isVideoAttachment,
99
+ isVoiceRecordingAttachment,
100
+ isLinkPreviewAttachment,
101
+ isImage,
102
+ isVideo,
103
+ } from './messageTypeUtils';
104
+ export type { MessageType, AttachmentType } from './messageTypeUtils';
57
105
 
58
106
  export {
59
107
  defaultMessageRenderers,
@@ -68,8 +116,17 @@ export {
68
116
  } from './components/MessageRenderers';
69
117
  export type { MessageRendererProps, AttachmentProps } from './components/MessageRenderers';
70
118
 
119
+ export { MediaLightbox } from './components/MediaLightbox';
120
+ export type { MediaLightboxProps, MediaLightboxItem } from './types';
121
+
71
122
  export { MessageInput } from './components/MessageInput';
72
- export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from './components/MessageInput';
123
+ export type {
124
+ MessageInputProps,
125
+ SendButtonProps,
126
+ AttachButtonProps,
127
+ EmojiPickerProps,
128
+ EmojiButtonProps,
129
+ } from './components/MessageInput';
73
130
 
74
131
  export { FilesPreview } from './components/FilesPreview';
75
132
  export type { FilePreviewItem, FilesPreviewProps } from './components/FilesPreview';
@@ -109,7 +166,7 @@ export {
109
166
  DefaultChannelInfoHeader,
110
167
  DefaultChannelInfoCover,
111
168
  DefaultChannelInfoActions,
112
- DefaultChannelInfoTabs
169
+ DefaultChannelInfoTabs,
113
170
  } from './components/ChannelInfo';
114
171
 
115
172
  export { Modal } from './components/Modal';
@@ -134,12 +191,7 @@ export type {
134
191
  } from './types';
135
192
 
136
193
  export { UserPicker } from './components/UserPicker';
137
- export type {
138
- UserPickerProps,
139
- UserPickerUser,
140
- UserPickerItemProps,
141
- UserPickerSelectedBoxProps,
142
- } from './types';
194
+ export type { UserPickerProps, UserPickerUser, UserPickerItemProps, UserPickerSelectedBoxProps } from './types';
143
195
 
144
196
  export { CreateChannelModal } from './components/CreateChannelModal';
145
197
  export type { CreateChannelModalProps } from './types';
@@ -0,0 +1,64 @@
1
+ export const MESSAGE_TYPES = {
2
+ REGULAR: 'regular',
3
+ SYSTEM: 'system',
4
+ STICKER: 'sticker',
5
+ SIGNAL: 'signal',
6
+ ERROR: 'error',
7
+ } as const;
8
+
9
+ export const ATTACHMENT_TYPES = {
10
+ IMAGE: 'image',
11
+ VIDEO: 'video',
12
+ VOICE_RECORDING: 'voiceRecording',
13
+ LINK_PREVIEW: 'linkPreview',
14
+ FILE: 'file',
15
+ AUDIO: 'audio',
16
+ } as const;
17
+
18
+ export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES] | string;
19
+ export type AttachmentType = (typeof ATTACHMENT_TYPES)[keyof typeof ATTACHMENT_TYPES] | string;
20
+
21
+ // Helpers cho message
22
+ export function isSystemMessage(message: any): boolean {
23
+ return message?.type === MESSAGE_TYPES.SYSTEM;
24
+ }
25
+
26
+ export function isStickerMessage(message: any): boolean {
27
+ return message?.type === MESSAGE_TYPES.STICKER;
28
+ }
29
+
30
+ export function isRegularMessage(message: any): boolean {
31
+ return !message?.type || message?.type === MESSAGE_TYPES.REGULAR;
32
+ }
33
+
34
+ export function isSignalMessage(message: any): boolean {
35
+ return message?.type === MESSAGE_TYPES.SIGNAL;
36
+ }
37
+
38
+ // Helpers cho attachment
39
+ export function isImageAttachment(attachment: any): boolean {
40
+ return attachment?.type === ATTACHMENT_TYPES.IMAGE;
41
+ }
42
+
43
+ export function isVideoAttachment(attachment: any): boolean {
44
+ return attachment?.type === ATTACHMENT_TYPES.VIDEO;
45
+ }
46
+
47
+ export function isVoiceRecordingAttachment(attachment: any): boolean {
48
+ return attachment?.type === ATTACHMENT_TYPES.VOICE_RECORDING;
49
+ }
50
+
51
+ export function isLinkPreviewAttachment(attachment: any): boolean {
52
+ return attachment?.type === ATTACHMENT_TYPES.LINK_PREVIEW;
53
+ }
54
+
55
+ export function isImage(attachment: any): boolean {
56
+ return Boolean(
57
+ isImageAttachment(attachment) ||
58
+ (!attachment?.type && (attachment?.mime_type?.startsWith('image/') || attachment?.image_url)),
59
+ );
60
+ }
61
+
62
+ export function isVideo(attachment: any): boolean {
63
+ return !!(isVideoAttachment(attachment) || (!attachment.type && attachment.mime_type?.startsWith('video/')));
64
+ }
@@ -57,6 +57,40 @@
57
57
  text-overflow: ellipsis;
58
58
  }
59
59
 
60
+ /* --- Online Status Indicator --- */
61
+ .ermis-channel-header__online-status {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 5px;
65
+ margin-top: 1px;
66
+ }
67
+
68
+ .ermis-channel-header__online-dot {
69
+ width: 8px;
70
+ height: 8px;
71
+ border-radius: var(--ermis-radius-full);
72
+ flex-shrink: 0;
73
+ transition: background-color var(--ermis-transition);
74
+ }
75
+
76
+ .ermis-channel-header__online-dot--online {
77
+ background-color: var(--ermis-color-success);
78
+ }
79
+
80
+ .ermis-channel-header__online-dot--offline {
81
+ background-color: var(--ermis-text-muted);
82
+ }
83
+
84
+ .ermis-channel-header__online-label {
85
+ font-size: var(--ermis-font-size-xs);
86
+ color: var(--ermis-text-muted);
87
+ line-height: 1;
88
+ }
89
+
90
+ .ermis-channel-header__online-status--online .ermis-channel-header__online-label {
91
+ color: var(--ermis-color-success);
92
+ }
93
+
60
94
  .ermis-channel-header__info {
61
95
  flex: 1;
62
96
  }
@@ -121,6 +155,31 @@
121
155
  flex: 1;
122
156
  }
123
157
 
158
+ /* --- Avatar wrapper with online dot overlay --- */
159
+ .ermis-channel-list__item-avatar-wrapper {
160
+ position: relative;
161
+ flex-shrink: 0;
162
+ }
163
+
164
+ .ermis-channel-list__online-dot {
165
+ position: absolute;
166
+ bottom: 0;
167
+ right: 0;
168
+ width: 10px;
169
+ height: 10px;
170
+ border-radius: var(--ermis-radius-full);
171
+ border: 2px solid var(--ermis-bg-secondary);
172
+ transition: background-color var(--ermis-transition);
173
+ }
174
+
175
+ .ermis-channel-list__online-dot--online {
176
+ background-color: var(--ermis-color-success);
177
+ }
178
+
179
+ .ermis-channel-list__online-dot--offline {
180
+ background-color: var(--ermis-text-muted);
181
+ }
182
+
124
183
  .ermis-channel-list__item-top-row {
125
184
  display: flex;
126
185
  align-items: baseline;
@@ -0,0 +1,263 @@
1
+ /* ============================================================
2
+ Media Lightbox – Full-screen overlay for images & videos
3
+ BEM: .ermis-lightbox__{element}--{modifier}
4
+ ============================================================ */
5
+
6
+ /* ----------------------------------------------------------
7
+ Overlay & backdrop
8
+ ---------------------------------------------------------- */
9
+ .ermis-lightbox {
10
+ position: fixed;
11
+ inset: 0;
12
+ z-index: 1100;
13
+ display: flex;
14
+ flex-direction: column;
15
+ animation: ermis-lightbox-fade-in 0.2s ease-out;
16
+ }
17
+
18
+ .ermis-lightbox__backdrop {
19
+ position: absolute;
20
+ inset: 0;
21
+ background-color: rgba(0, 0, 0, 0.92);
22
+ backdrop-filter: blur(8px);
23
+ }
24
+
25
+ @keyframes ermis-lightbox-fade-in {
26
+ from { opacity: 0; }
27
+ to { opacity: 1; }
28
+ }
29
+
30
+ /* ----------------------------------------------------------
31
+ Header: counter + close
32
+ ---------------------------------------------------------- */
33
+ .ermis-lightbox__header {
34
+ position: relative;
35
+ z-index: 2;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ padding: 12px 16px;
40
+ min-height: 48px;
41
+ }
42
+
43
+ .ermis-lightbox__counter {
44
+ color: rgba(255, 255, 255, 0.8);
45
+ font-size: 14px;
46
+ font-weight: 500;
47
+ letter-spacing: 0.5px;
48
+ user-select: none;
49
+ }
50
+
51
+ .ermis-lightbox__actions {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 8px;
55
+ margin-left: auto;
56
+ }
57
+
58
+ .ermis-lightbox__action-btn {
59
+ background: rgba(255, 255, 255, 0.1);
60
+ border: none;
61
+ border-radius: 50%;
62
+ width: 36px;
63
+ height: 36px;
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ color: rgba(255, 255, 255, 0.85);
68
+ cursor: pointer;
69
+ transition: background-color 0.15s ease, color 0.15s ease;
70
+ }
71
+
72
+ .ermis-lightbox__action-btn:hover {
73
+ background: rgba(255, 255, 255, 0.2);
74
+ color: #fff;
75
+ }
76
+
77
+ /* ----------------------------------------------------------
78
+ Content area
79
+ ---------------------------------------------------------- */
80
+ .ermis-lightbox__content {
81
+ position: relative;
82
+ z-index: 1;
83
+ flex: 1;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: 0 60px;
88
+ overflow: hidden;
89
+ cursor: default;
90
+ }
91
+
92
+ /* ----------------------------------------------------------
93
+ Image
94
+ ---------------------------------------------------------- */
95
+ .ermis-lightbox__image {
96
+ max-width: 90vw;
97
+ max-height: 80vh;
98
+ object-fit: contain;
99
+ border-radius: 4px;
100
+ user-select: none;
101
+ transition: transform 0.15s ease;
102
+ animation: ermis-lightbox-zoom-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
103
+ }
104
+
105
+ .ermis-lightbox__image--zoomed {
106
+ transition: none;
107
+ }
108
+
109
+ @keyframes ermis-lightbox-zoom-in {
110
+ from {
111
+ opacity: 0;
112
+ transform: scale(0.92);
113
+ }
114
+ to {
115
+ opacity: 1;
116
+ transform: scale(1);
117
+ }
118
+ }
119
+
120
+ /* ----------------------------------------------------------
121
+ Video
122
+ ---------------------------------------------------------- */
123
+ .ermis-lightbox__video {
124
+ max-width: 90vw;
125
+ max-height: 80vh;
126
+ border-radius: 4px;
127
+ background: #000;
128
+ outline: none;
129
+ animation: ermis-lightbox-zoom-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
130
+ }
131
+
132
+ /* ----------------------------------------------------------
133
+ Navigation buttons
134
+ ---------------------------------------------------------- */
135
+ .ermis-lightbox__nav {
136
+ position: absolute;
137
+ top: 50%;
138
+ transform: translateY(-50%);
139
+ z-index: 3;
140
+ background: rgba(255, 255, 255, 0.08);
141
+ border: none;
142
+ border-radius: 50%;
143
+ width: 44px;
144
+ height: 44px;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ color: rgba(255, 255, 255, 0.75);
149
+ cursor: pointer;
150
+ transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
151
+ }
152
+
153
+ .ermis-lightbox__nav:hover {
154
+ background: rgba(255, 255, 255, 0.18);
155
+ color: #fff;
156
+ }
157
+
158
+ .ermis-lightbox__nav--prev {
159
+ left: 12px;
160
+ }
161
+
162
+ .ermis-lightbox__nav--prev:hover {
163
+ transform: translateY(-50%) translateX(-2px);
164
+ }
165
+
166
+ .ermis-lightbox__nav--next {
167
+ right: 12px;
168
+ }
169
+
170
+ .ermis-lightbox__nav--next:hover {
171
+ transform: translateY(-50%) translateX(2px);
172
+ }
173
+
174
+ /* ----------------------------------------------------------
175
+ Filename
176
+ ---------------------------------------------------------- */
177
+ .ermis-lightbox__filename {
178
+ position: relative;
179
+ z-index: 2;
180
+ text-align: center;
181
+ padding: 8px 16px 16px;
182
+ color: rgba(255, 255, 255, 0.6);
183
+ font-size: 13px;
184
+ white-space: nowrap;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ user-select: none;
188
+ }
189
+
190
+ /* ----------------------------------------------------------
191
+ Clickable attachment overlay (hover effect on images/videos in messages)
192
+ ---------------------------------------------------------- */
193
+ .ermis-attachment--clickable {
194
+ cursor: pointer;
195
+ position: relative;
196
+ }
197
+
198
+ .ermis-attachment--clickable::after {
199
+ content: '';
200
+ position: absolute;
201
+ inset: 0;
202
+ background: rgba(0, 0, 0, 0);
203
+ border-radius: inherit;
204
+ transition: background-color 0.2s ease;
205
+ pointer-events: none;
206
+ }
207
+
208
+ .ermis-attachment--clickable:hover::after {
209
+ background: rgba(0, 0, 0, 0.12);
210
+ }
211
+
212
+ /* Zoom hint icon on hover */
213
+ .ermis-attachment__overlay {
214
+ position: absolute;
215
+ top: 50%;
216
+ left: 50%;
217
+ transform: translate(-50%, -50%) scale(0.8);
218
+ z-index: 2;
219
+ opacity: 0;
220
+ transition: opacity 0.2s ease, transform 0.2s ease;
221
+ color: #fff;
222
+ background: rgba(0, 0, 0, 0.5);
223
+ border-radius: 50%;
224
+ width: 36px;
225
+ height: 36px;
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ pointer-events: none;
230
+ }
231
+
232
+ .ermis-attachment--clickable:hover .ermis-attachment__overlay {
233
+ opacity: 1;
234
+ transform: translate(-50%, -50%) scale(1);
235
+ }
236
+
237
+ /* ----------------------------------------------------------
238
+ Responsive: small viewports
239
+ ---------------------------------------------------------- */
240
+ @media (max-width: 768px) {
241
+ .ermis-lightbox__content {
242
+ padding: 0 12px;
243
+ }
244
+
245
+ .ermis-lightbox__image,
246
+ .ermis-lightbox__video {
247
+ max-width: 100vw;
248
+ max-height: 75vh;
249
+ }
250
+
251
+ .ermis-lightbox__nav {
252
+ width: 36px;
253
+ height: 36px;
254
+ }
255
+
256
+ .ermis-lightbox__nav--prev {
257
+ left: 4px;
258
+ }
259
+
260
+ .ermis-lightbox__nav--next {
261
+ right: 4px;
262
+ }
263
+ }