@ermis-network/ermis-chat-react 1.0.6 → 1.0.7

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 (43) hide show
  1. package/dist/index.cjs +2410 -1308
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +471 -16
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +145 -1
  6. package/dist/index.d.ts +145 -1
  7. package/dist/index.mjs +2339 -1241
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/components/BannedOverlay.tsx +40 -0
  11. package/src/components/ChannelActions.tsx +231 -0
  12. package/src/components/ChannelHeader.tsx +38 -2
  13. package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
  14. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
  15. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
  17. package/src/components/ChannelList.tsx +460 -38
  18. package/src/components/ClosedTopicOverlay.tsx +38 -0
  19. package/src/components/MessageInput.tsx +19 -2
  20. package/src/components/MessageItem.tsx +8 -11
  21. package/src/components/MessageQuickReactions.tsx +3 -2
  22. package/src/components/MessageReactions.tsx +8 -3
  23. package/src/components/MessageRenderers.tsx +7 -9
  24. package/src/components/PendingOverlay.tsx +41 -0
  25. package/src/components/TopicModal.tsx +189 -0
  26. package/src/components/VirtualMessageList.tsx +74 -43
  27. package/src/hooks/useBannedState.ts +27 -3
  28. package/src/hooks/useChannelCapabilities.ts +7 -3
  29. package/src/hooks/useChannelData.ts +1 -1
  30. package/src/hooks/useChannelListUpdates.ts +24 -3
  31. package/src/hooks/useChannelRowUpdates.ts +6 -0
  32. package/src/hooks/useMessageActions.ts +1 -1
  33. package/src/index.ts +6 -1
  34. package/src/styles/_channel-info.css +21 -0
  35. package/src/styles/_channel-list.css +217 -6
  36. package/src/styles/_message-bubble.css +75 -9
  37. package/src/styles/_message-input.css +24 -0
  38. package/src/styles/_message-list.css +51 -6
  39. package/src/styles/_message-quick-reactions.css +5 -0
  40. package/src/styles/_message-reactions.css +7 -0
  41. package/src/styles/_topic-modal.css +154 -0
  42. package/src/styles/index.css +1 -0
  43. package/src/types.ts +157 -3
@@ -6,12 +6,16 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
6
6
  *
7
7
  * Reads the initial value from `channel.state.membership.banned` and subscribes
8
8
  * to `member.banned` / `member.unbanned` WebSocket events for real-time updates.
9
+ * If the channel is a topic, it also synchronizes with the parent channel's ban state.
9
10
  *
10
11
  * Only triggers a re-render when the *current user* is the target of the event.
11
12
  */
12
13
  export function useBannedState(channel: Channel | null | undefined, currentUserId?: string) {
13
14
  const [isBanned, setIsBanned] = useState<boolean>(() => {
14
- return Boolean(channel?.state?.membership?.banned);
15
+ if (!channel) return false;
16
+ const parentCid = channel.data?.parent_cid as string | undefined;
17
+ const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
18
+ return Boolean(channel.state?.membership?.banned || parentChannel?.state?.membership?.banned);
15
19
  });
16
20
 
17
21
  useEffect(() => {
@@ -20,8 +24,11 @@ export function useBannedState(channel: Channel | null | undefined, currentUserI
20
24
  return;
21
25
  }
22
26
 
27
+ const parentCid = channel.data?.parent_cid as string | undefined;
28
+ const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
29
+
23
30
  // Sync initial state when channel changes
24
- setIsBanned(Boolean(channel.state?.membership?.banned));
31
+ setIsBanned(Boolean(channel.state?.membership?.banned || parentChannel?.state?.membership?.banned));
25
32
 
26
33
  const handleBanned = (event: any) => {
27
34
  if (event.member?.user_id === currentUserId) {
@@ -31,16 +38,33 @@ export function useBannedState(channel: Channel | null | undefined, currentUserI
31
38
 
32
39
  const handleUnbanned = (event: any) => {
33
40
  if (event.member?.user_id === currentUserId) {
34
- setIsBanned(false);
41
+ const eventCid = event.cid || (event.channel_type ? `${event.channel_type}:${event.channel_id}` : undefined);
42
+ let cBanned = Boolean(channel.state?.membership?.banned);
43
+ let pBanned = Boolean(parentChannel?.state?.membership?.banned);
44
+
45
+ if (eventCid === channel.cid) cBanned = false;
46
+ if (parentChannel && eventCid === parentChannel.cid) pBanned = false;
47
+
48
+ setIsBanned(cBanned || pBanned);
35
49
  }
36
50
  };
37
51
 
38
52
  const sub1 = channel.on('member.banned', handleBanned);
39
53
  const sub2 = channel.on('member.unbanned', handleUnbanned);
40
54
 
55
+ let sub3: { unsubscribe: () => void } | undefined;
56
+ let sub4: { unsubscribe: () => void } | undefined;
57
+
58
+ if (parentChannel) {
59
+ sub3 = parentChannel.on('member.banned', handleBanned);
60
+ sub4 = parentChannel.on('member.unbanned', handleUnbanned);
61
+ }
62
+
41
63
  return () => {
42
64
  sub1.unsubscribe();
43
65
  sub2.unsubscribe();
66
+ if (sub3) sub3.unsubscribe();
67
+ if (sub4) sub4.unsubscribe();
44
68
  };
45
69
  }, [channel, currentUserId]);
46
70
 
@@ -18,20 +18,24 @@ export const useChannelCapabilities = () => {
18
18
 
19
19
  const currentUserId = client?.userID || '';
20
20
  const isTeamChannel = activeChannel?.type === 'team';
21
+ const isMeetingChannel = activeChannel?.type === 'meeting';
22
+ const isTeamOrMeetingChannel = isTeamChannel || isMeetingChannel;
21
23
  const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
22
24
 
23
25
  const isOwner = role === 'owner' || activeChannel?.data?.created_by_id === currentUserId;
24
26
  const isModerator = role === 'moder';
25
27
  const isOwnerOrModerator = isOwner || isModerator;
26
28
 
27
- const capabilities: string[] = isTeamChannel ? (activeChannel?.data as any)?.member_capabilities || [] : [];
29
+ const capabilities: string[] = isTeamOrMeetingChannel ? (activeChannel?.data as any)?.member_capabilities || [] : [];
28
30
 
29
31
  const hasCapability = useCallback((cap: string) => {
30
- return !isTeamChannel || isOwnerOrModerator || capabilities.includes(cap);
31
- }, [isTeamChannel, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
32
+ return !isTeamOrMeetingChannel || isOwnerOrModerator || capabilities.includes(cap);
33
+ }, [isTeamOrMeetingChannel, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
32
34
 
33
35
  return {
34
36
  isTeamChannel,
37
+ isMeetingChannel,
38
+ isTeamOrMeetingChannel,
35
39
  isOwner,
36
40
  isModerator,
37
41
  isOwnerOrModerator,
@@ -47,7 +47,7 @@ export const useChannelProfile = (channel: Channel | null | undefined) => {
47
47
  return () => sub.unsubscribe();
48
48
  }, [channel]);
49
49
 
50
- const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channelUpdateCount]);
50
+ const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channel?.type, channelUpdateCount]);
51
51
  const channelImage = useMemo(() => channel?.data?.image as string | undefined, [channel?.data?.image, channelUpdateCount]);
52
52
  const channelDescription = useMemo(() => channel?.data?.description as string | undefined, [channel?.data?.description, channelUpdateCount]);
53
53
 
@@ -37,7 +37,8 @@ export function useChannelListUpdates(
37
37
  const isBannedInActive = Boolean(active.state?.membership?.banned);
38
38
  const isBlockedInActive = active.type === 'messaging' && Boolean(active.state?.membership?.blocked);
39
39
  const isPendingActive =
40
- active.state?.membership?.channel_role === 'pending' || (active.state?.membership as Record<string, unknown>)?.role === 'pending';
40
+ active.state?.membership?.channel_role === 'pending' ||
41
+ (active.state?.membership as Record<string, unknown>)?.role === 'pending';
41
42
 
42
43
  if (!isBannedInActive && !isBlockedInActive && !isPendingActive) {
43
44
  active.markRead().catch(() => {
@@ -121,7 +122,10 @@ export function useChannelListUpdates(
121
122
  // we optimistically inject the membership so it instantly jumps into pending invites!
122
123
  // We DO NOT do this for channel.created, because in channel.created, event.member is the creator (owner).
123
124
  if (!forceWatch && event.type === 'member.added' && event.member && channelInstance.state) {
124
- channelInstance.state.membership = { ...channelInstance.state.membership, ...event.member } as unknown as Record<string, unknown>;
125
+ channelInstance.state.membership = {
126
+ ...channelInstance.state.membership,
127
+ ...event.member,
128
+ } as unknown as Record<string, unknown>;
125
129
  }
126
130
 
127
131
  // If the caller requested an explicit api call (e.g. for channel.created)
@@ -183,7 +187,9 @@ export function useChannelListUpdates(
183
187
  const eventCid =
184
188
  event.cid ||
185
189
  event.channel?.cid ||
186
- ((event as Record<string, unknown>).channel_id ? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}` : undefined);
190
+ ((event as Record<string, unknown>).channel_id
191
+ ? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}`
192
+ : undefined);
187
193
 
188
194
  if (eventCid && event.member) {
189
195
  const targetChannel = prev.find((c) => c.cid === eventCid);
@@ -201,6 +207,11 @@ export function useChannelListUpdates(
201
207
  }
202
208
  };
203
209
 
210
+ // --- channel.topic.enabled / disabled / created / channel.pinned / channel.unpinned: force re-render so ChannelList toggles Accordion UI, inserts new topic, or updates pinned channels ---
211
+ const handleGenericUpdate = (event: Event) => {
212
+ setChannels((prev) => [...prev]);
213
+ };
214
+
204
215
  const sub1 = client.on('message.new', handleNewMessage);
205
216
  const sub2 = client.on('channel.deleted', handleChannelDeleted);
206
217
  const sub3 = client.on('member.removed', handleMemberRemoved);
@@ -209,6 +220,11 @@ export function useChannelListUpdates(
209
220
  const sub6 = client.on('notification.added_to_channel', handleMemberAdded);
210
221
  const sub7 = client.on('notification.invite_rejected', handleMemberRemoved);
211
222
  const sub8 = client.on('notification.invite_accepted', handleMemberUpdated);
223
+ const sub9 = client.on('channel.topic.enabled', handleGenericUpdate);
224
+ const sub10 = client.on('channel.topic.disabled', handleGenericUpdate);
225
+ const sub11 = client.on('channel.topic.created', handleGenericUpdate);
226
+ const sub12 = client.on('channel.pinned', handleGenericUpdate);
227
+ const sub13 = client.on('channel.unpinned', handleGenericUpdate);
212
228
 
213
229
  return () => {
214
230
  sub1.unsubscribe();
@@ -219,6 +235,11 @@ export function useChannelListUpdates(
219
235
  sub6.unsubscribe();
220
236
  sub7.unsubscribe();
221
237
  sub8.unsubscribe();
238
+ sub9.unsubscribe();
239
+ sub10.unsubscribe();
240
+ sub11.unsubscribe();
241
+ sub12.unsubscribe();
242
+ sub13.unsubscribe();
222
243
  };
223
244
  }, [client, setChannels, setActiveChannel]);
224
245
  }
@@ -58,6 +58,9 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
58
58
  };
59
59
  const sub10 = channel.on('member.blocked', handleBlocked);
60
60
  const sub11 = channel.on('member.unblocked', handleUnblocked);
61
+ const sub12 = channel.on('channel.topic.created', handleUpdate);
62
+ const sub13 = channel.on('channel.pinned', handleUpdate);
63
+ const sub14 = channel.on('channel.unpinned', handleUpdate);
61
64
 
62
65
  return () => {
63
66
  sub1.unsubscribe();
@@ -71,6 +74,9 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
71
74
  sub9.unsubscribe();
72
75
  sub10.unsubscribe();
73
76
  sub11.unsubscribe();
77
+ sub12.unsubscribe();
78
+ sub13.unsubscribe();
79
+ sub14.unsubscribe();
74
80
  };
75
81
  }, [channel, currentUserId]);
76
82
 
@@ -23,7 +23,7 @@ export type MessageActionList = {
23
23
 
24
24
  export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
25
25
  const { activeChannel, client } = useChatClient();
26
- const { isTeamChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
26
+ const { isTeamOrMeetingChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
27
27
 
28
28
  // Only depend on the specific message fields we actually read
29
29
  const messageType = message.type;
package/src/index.ts CHANGED
@@ -19,9 +19,12 @@ export { usePendingState } from './hooks/usePendingState';
19
19
  export { Avatar } from './components/Avatar';
20
20
  export type { AvatarProps } from './components/Avatar';
21
21
 
22
- export { ChannelList, ChannelItem } from './components/ChannelList';
22
+ export { ChannelList, ChannelItem, ChannelTopicGroup } from './components/ChannelList';
23
23
  export type { ChannelListProps, ChannelItemProps } from './components/ChannelList';
24
24
 
25
+ export { DefaultChannelActions, computeDefaultActions } from './components/ChannelActions';
26
+ export type { ChannelAction, ChannelActionsProps } from './types';
27
+
25
28
  export { Channel } from './components/Channel';
26
29
  export type { ChannelProps } from './components/Channel';
27
30
 
@@ -94,6 +97,8 @@ export type { ReplyPreviewProps } from './types';
94
97
  export { ForwardMessageModal } from './components/ForwardMessageModal';
95
98
  export type { ForwardMessageModalProps, ForwardChannelItemProps } from './components/ForwardMessageModal';
96
99
 
100
+ export { TopicModal } from './components/TopicModal';
101
+
97
102
  export { TypingIndicator } from './components/TypingIndicator';
98
103
  export type { TypingIndicatorProps } from './components/TypingIndicator';
99
104
  export { useTypingIndicator } from './hooks/useTypingIndicator';
@@ -74,6 +74,27 @@
74
74
  margin: 0;
75
75
  }
76
76
 
77
+ .ermis-channel-info__parent-name {
78
+ font-size: 13px;
79
+ color: var(--ermis-accent);
80
+ margin-top: -4px;
81
+ margin-bottom: 8px;
82
+ font-weight: 500;
83
+ }
84
+
85
+ .ermis-channel-info__topic-emoji-avatar {
86
+ font-size: 48px;
87
+ line-height: 80px;
88
+ width: 80px;
89
+ height: 80px;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ background: var(--ermis-bg-secondary);
94
+ border-radius: 50%;
95
+ margin-bottom: 16px;
96
+ }
97
+
77
98
  .ermis-channel-info__cover-edit-btn {
78
99
  background: transparent;
79
100
  border: none;
@@ -26,6 +26,29 @@
26
26
  text-overflow: ellipsis;
27
27
  }
28
28
 
29
+ .ermis-channel-header__team-name {
30
+ font-size: var(--ermis-font-size-xs);
31
+ color: var(--ermis-text-secondary);
32
+ margin-bottom: 2px;
33
+ font-weight: 500;
34
+ white-space: nowrap;
35
+ overflow: hidden;
36
+ text-overflow: ellipsis;
37
+ }
38
+
39
+ .ermis-channel-header__topic-avatar {
40
+ width: 32px;
41
+ height: 32px;
42
+ min-width: 32px;
43
+ border-radius: var(--ermis-radius-md);
44
+ background-color: var(--ermis-bg-primary);
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ font-size: 16px;
49
+ color: var(--ermis-text-secondary);
50
+ }
51
+
29
52
  .ermis-channel-header__subtitle {
30
53
  font-size: var(--ermis-font-size-xs);
31
54
  color: var(--ermis-text-muted);
@@ -79,6 +102,7 @@
79
102
  cursor: pointer;
80
103
  border-left: 2px solid transparent;
81
104
  transition: background-color var(--ermis-transition), border-color var(--ermis-transition);
105
+ position: relative;
82
106
  }
83
107
 
84
108
  .ermis-channel-list__item:hover {
@@ -97,6 +121,21 @@
97
121
  flex: 1;
98
122
  }
99
123
 
124
+ .ermis-channel-list__item-top-row {
125
+ display: flex;
126
+ align-items: baseline;
127
+ justify-content: space-between;
128
+ gap: var(--ermis-spacing-sm);
129
+ }
130
+
131
+ .ermis-channel-list__item-bottom-row {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: space-between;
135
+ gap: var(--ermis-spacing-sm);
136
+ margin-top: 2px;
137
+ }
138
+
100
139
  .ermis-channel-list__item-name {
101
140
  font-size: var(--ermis-font-size-sm);
102
141
  font-weight: 500;
@@ -104,6 +143,15 @@
104
143
  white-space: nowrap;
105
144
  overflow: hidden;
106
145
  text-overflow: ellipsis;
146
+ flex: 1;
147
+ }
148
+
149
+ .ermis-channel-list__item-timestamp {
150
+ font-size: var(--ermis-font-size-xs);
151
+ color: var(--ermis-text-muted);
152
+ white-space: nowrap;
153
+ flex-shrink: 0;
154
+ margin-top: 2px;
107
155
  }
108
156
 
109
157
  .ermis-channel-list__item-last-message {
@@ -112,13 +160,92 @@
112
160
  white-space: nowrap;
113
161
  overflow: hidden;
114
162
  text-overflow: ellipsis;
115
- margin-top: 2px;
163
+ flex: 1;
164
+ }
165
+
166
+ .ermis-channel-list__item-closed-indicator {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 6px;
170
+ font-size: var(--ermis-font-size-xs);
171
+ color: var(--ermis-text-secondary);
172
+ font-weight: 500;
173
+ white-space: nowrap;
174
+ overflow: hidden;
175
+ text-overflow: ellipsis;
176
+ flex: 1;
177
+ }
178
+
179
+ .ermis-channel-list__closed-icon {
180
+ display: inline-flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ flex-shrink: 0;
184
+ color: var(--ermis-color-danger);
185
+ }
186
+
187
+ .ermis-channel-list__pinned-icon {
188
+ position: absolute;
189
+ top: -5px;
190
+ right: 0px;
191
+ color: var(--ermis-color-danger, #ef4444);
192
+ display: inline-flex;
193
+ align-items: center;
194
+ justify-content: center;
195
+ transform: rotate(45deg);
196
+ }
197
+
198
+ .ermis-channel-list__item-badges {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 4px;
202
+ flex-shrink: 0;
116
203
  }
117
204
 
118
205
  .ermis-channel-list__item-last-message-user {
119
206
  color: var(--ermis-text-secondary);
120
207
  }
121
208
 
209
+ .ermis-channel-list__item-actions-wrapper,
210
+ .ermis-channel-list__topic-actions-wrapper {
211
+ position: absolute;
212
+ right: var(--ermis-spacing-sm);
213
+ top: 50%;
214
+ transform: translateY(-50%);
215
+ opacity: 0;
216
+ transition: opacity var(--ermis-transition);
217
+ display: flex;
218
+ align-items: center;
219
+ z-index: 1;
220
+ }
221
+
222
+ .ermis-channel-list__item:hover .ermis-channel-list__item-actions-wrapper,
223
+ .ermis-channel-list__topic-header:hover .ermis-channel-list__topic-actions-wrapper,
224
+ .ermis-channel-list__item-actions-wrapper:has(.ermis-channel-list__actions-trigger--active),
225
+ .ermis-channel-list__topic-actions-wrapper:has(.ermis-channel-list__actions-trigger--active) {
226
+ opacity: 1;
227
+ }
228
+
229
+ .ermis-channel-list__actions-trigger {
230
+ display: flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ width: 24px;
234
+ height: 24px;
235
+ border-radius: var(--ermis-radius-sm);
236
+ background-color: var(--ermis-bg-primary);
237
+ color: var(--ermis-text-muted);
238
+ border: none;
239
+ cursor: pointer;
240
+ transition: all var(--ermis-transition);
241
+ }
242
+
243
+ .ermis-channel-list__actions-trigger:hover,
244
+ .ermis-channel-list__actions-trigger--active {
245
+ background-color: var(--ermis-bg-primary);
246
+ color: var(--ermis-accent);
247
+ }
248
+
122
249
  /* --- Unread channel indicator --- */
123
250
  .ermis-channel-list__item--unread .ermis-channel-list__item-name {
124
251
  font-weight: 700;
@@ -146,11 +273,6 @@
146
273
  flex-shrink: 0;
147
274
  }
148
275
 
149
- /* --- Blocked channel indicator --- */
150
- .ermis-channel-list__item--blocked {
151
- opacity: 0.5;
152
- }
153
-
154
276
  .ermis-channel-list__blocked-icon {
155
277
  display: inline-flex;
156
278
  align-items: center;
@@ -215,3 +337,92 @@
215
337
  .ermis-channel-list__accordion-icon--expanded {
216
338
  transform: rotate(0deg);
217
339
  }
340
+
341
+ /* --- Topic Group --- */
342
+ .ermis-channel-list__topic-group {
343
+ display: flex;
344
+ flex-direction: column;
345
+ }
346
+
347
+ .ermis-channel-list__topic-header {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: var(--ermis-spacing-md);
351
+ padding: var(--ermis-spacing-md) var(--ermis-spacing-lg);
352
+ cursor: pointer;
353
+ border-left: 2px solid transparent;
354
+ transition: background-color var(--ermis-transition);
355
+ position: relative;
356
+ }
357
+
358
+ .ermis-channel-list__topic-header:hover {
359
+ background-color: var(--ermis-bg-hover);
360
+ }
361
+
362
+ .ermis-channel-list__topic-header-name {
363
+ font-size: var(--ermis-font-size-sm);
364
+ font-weight: 600;
365
+ color: var(--ermis-text-primary);
366
+ white-space: nowrap;
367
+ overflow: hidden;
368
+ text-overflow: ellipsis;
369
+ flex: 1;
370
+ }
371
+
372
+ .ermis-channel-list__add-topic-btn {
373
+ display: flex;
374
+ align-items: center;
375
+ justify-content: center;
376
+ width: 24px;
377
+ height: 24px;
378
+ border-radius: var(--ermis-radius-sm);
379
+ background-color: transparent;
380
+ color: var(--ermis-text-muted);
381
+ border: none;
382
+ cursor: pointer;
383
+ transition: all var(--ermis-transition);
384
+ }
385
+
386
+ .ermis-channel-list__add-topic-btn:hover {
387
+ background-color: var(--ermis-bg-primary);
388
+ color: var(--ermis-accent);
389
+ }
390
+
391
+ .ermis-channel-list__topic-header--expanded .ermis-channel-list__accordion-icon {
392
+ transform: rotate(0deg);
393
+ }
394
+
395
+ .ermis-channel-list__topic-sublist {
396
+ display: flex;
397
+ flex-direction: column;
398
+ background-color: var(--ermis-bg-secondary);
399
+ }
400
+
401
+ /* Indent nested items and provide hierarchical line */
402
+ .ermis-channel-list__topic-sublist .ermis-channel-list__item {
403
+ padding-left: var(--ermis-spacing-md);
404
+ margin-left: 20px;
405
+ border-left: 2px solid var(--ermis-border);
406
+ }
407
+
408
+ .ermis-channel-list__topic-sublist .ermis-channel-list__item:hover {
409
+ border-left-color: var(--ermis-border-hover);
410
+ }
411
+
412
+ .ermis-channel-list__topic-sublist .ermis-channel-list__item--active {
413
+ border-left-color: var(--ermis-accent);
414
+ }
415
+
416
+ .ermis-channel-list__topic-hashtag {
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ width: 24px;
421
+ height: 24px;
422
+ background-color: transparent;
423
+ color: var(--ermis-text-muted);
424
+ border-radius: var(--ermis-radius-full);
425
+ font-size: 0.95rem;
426
+ font-weight: 600;
427
+ flex-shrink: 0;
428
+ }
@@ -190,7 +190,7 @@
190
190
  align-items: flex-end;
191
191
  padding: var(--ermis-spacing-sm) var(--ermis-spacing-md);
192
192
  border-radius: var(--ermis-radius-lg);
193
- width: 100%;
193
+ /* width: 100%; */
194
194
  word-break: break-word;
195
195
  }
196
196
 
@@ -323,6 +323,10 @@
323
323
  width: 350px;
324
324
  }
325
325
 
326
+ .ermis-message-list__item-content--has-attachments .ermis-message-bubble {
327
+ width: 100%;
328
+ }
329
+
326
330
  /* Container for messages with both text + attachments */
327
331
  .ermis-message-content--with-attachments {
328
332
  display: flex;
@@ -365,11 +369,19 @@
365
369
  height: 100%;
366
370
  max-width: none;
367
371
  max-height: 200px;
368
- object-fit: cover;
369
372
  border-radius: 0;
370
373
  display: block;
371
374
  }
372
375
 
376
+ .ermis-attachment-grid .ermis-attachment--image {
377
+ object-fit: cover;
378
+ }
379
+
380
+ .ermis-attachment-grid .ermis-attachment--video {
381
+ object-fit: contain;
382
+ background-color: var(--ermis-bg-hover);
383
+ }
384
+
373
385
  /* Single media: larger height allowed */
374
386
  .ermis-attachment-grid--single .ermis-attachment--image,
375
387
  .ermis-attachment-grid--single .ermis-attachment--video {
@@ -392,6 +404,10 @@
392
404
  background-color: var(--ermis-bg-hover);
393
405
  }
394
406
 
407
+ .ermis-attachment-aspect-box--4-3 {
408
+ padding-bottom: 75%;
409
+ }
410
+
395
411
  /* Blurred thumbnail preview (shown while full image loads) */
396
412
  .ermis-attachment-blur-preview {
397
413
  position: absolute;
@@ -427,14 +443,18 @@
427
443
  }
428
444
  }
429
445
 
430
- /* Full image — hidden until loaded, fades in over blur/shimmer */
431
- .ermis-attachment-aspect-box .ermis-attachment--image {
446
+ .ermis-attachment--hidden-loader {
447
+ display: none;
448
+ }
449
+
450
+ /* Full media — hidden until loaded, fades in over blur/shimmer */
451
+ .ermis-attachment-aspect-box .ermis-attachment--image,
452
+ .ermis-attachment-aspect-box .ermis-attachment--video {
432
453
  position: absolute;
433
454
  top: 0;
434
455
  left: 0;
435
456
  width: 100%;
436
457
  height: 100%;
437
- object-fit: cover;
438
458
  opacity: 0;
439
459
  transition: opacity 0.3s ease;
440
460
  z-index: 2;
@@ -444,7 +464,17 @@
444
464
  border-radius: 0;
445
465
  }
446
466
 
447
- .ermis-attachment-aspect-box .ermis-attachment--image.ermis-attachment--loaded {
467
+ .ermis-attachment-aspect-box .ermis-attachment--image {
468
+ object-fit: cover;
469
+ }
470
+
471
+ .ermis-attachment-aspect-box .ermis-attachment--video {
472
+ object-fit: contain;
473
+ background-color: var(--ermis-bg-hover);
474
+ }
475
+
476
+ .ermis-attachment-aspect-box .ermis-attachment--image.ermis-attachment--loaded,
477
+ .ermis-attachment-aspect-box .ermis-attachment--video.ermis-attachment--loaded {
448
478
  opacity: 1;
449
479
  }
450
480
 
@@ -467,6 +497,7 @@
467
497
  max-width: 300px;
468
498
  max-height: 200px;
469
499
  border-radius: var(--ermis-radius-md);
500
+ object-fit: contain;
470
501
  }
471
502
 
472
503
  .ermis-attachment--file {
@@ -557,10 +588,28 @@
557
588
  border-color: var(--ermis-accent);
558
589
  }
559
590
 
591
+ .ermis-attachment__link-image-wrapper {
592
+ position: relative;
593
+ width: 100%;
594
+ min-height: 120px;
595
+ background-color: var(--ermis-bg-hover);
596
+ overflow: hidden;
597
+ }
598
+
560
599
  .ermis-attachment__link-image {
600
+ display: block;
561
601
  width: 100%;
562
- max-height: 160px;
602
+ height: 100%;
563
603
  object-fit: cover;
604
+ position: absolute;
605
+ top: 0;
606
+ left: 0;
607
+ opacity: 0;
608
+ transition: opacity 0.3s ease;
609
+ }
610
+
611
+ .ermis-attachment__link-image.ermis-attachment--loaded {
612
+ opacity: 1;
564
613
  }
565
614
 
566
615
  .ermis-attachment__link-info {
@@ -612,9 +661,26 @@
612
661
  }
613
662
 
614
663
  /* --- Sticker message --- */
664
+ .ermis-message-sticker-wrapper {
665
+ position: relative;
666
+ width: 120px;
667
+ height: 120px;
668
+ overflow: hidden;
669
+ }
670
+
615
671
  .ermis-message-sticker {
616
- max-width: 120px;
617
- max-height: 120px;
672
+ position: absolute;
673
+ top: 0;
674
+ left: 0;
675
+ width: 100%;
676
+ height: 100%;
677
+ object-fit: contain;
678
+ opacity: 0;
679
+ transition: opacity 0.3s ease;
680
+ }
681
+
682
+ .ermis-message-sticker.ermis-attachment--loaded {
683
+ opacity: 1;
618
684
  }
619
685
 
620
686
  /* --- Error message --- */