@ermis-network/ermis-chat-react 2.0.0 → 2.0.1

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 (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -4,6 +4,7 @@ import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
4
4
  import { isDirectChannel } from '../channelTypeUtils';
5
5
  import { getLastMessagePreview } from '../utils';
6
6
  import { SystemMessageTranslations, SignalMessageTranslations } from '@ermis-network/ermis-chat-sdk';
7
+ import { useChatClient } from './useChatClient';
7
8
 
8
9
  /** Preview data for the most recent message across the topic group */
9
10
  export type LatestMessagePreview = {
@@ -21,6 +22,8 @@ export type TopicGroupUpdatesOptions = {
21
22
  videoMessageLabel?: React.ReactNode;
22
23
  voiceRecordingMessageLabel?: React.ReactNode;
23
24
  fileMessageLabel?: React.ReactNode;
25
+ encryptedMessageLabel?: React.ReactNode;
26
+ encryptedMessageUnavailableLabel?: React.ReactNode;
24
27
  systemMessageTranslations?: SystemMessageTranslations;
25
28
  signalMessageTranslations?: SignalMessageTranslations;
26
29
  };
@@ -46,12 +49,24 @@ export function useTopicGroupUpdates(
46
49
  updateCount: number;
47
50
  latestMessagePreview: LatestMessagePreview | null;
48
51
  } {
52
+ const { client: chatClient, activeChannel } = useChatClient();
49
53
  const [updateCount, setUpdateCount] = useState(0);
50
54
  const bump = useCallback(() => setUpdateCount((c) => c + 1), []);
51
55
 
52
56
  // Subscribe to realtime events on parent + all topics
53
57
  useEffect(() => {
54
58
  const subs: { unsubscribe: () => void }[] = [];
59
+ const client = channel.getClient();
60
+ const isTopicGroupCid = (cid?: string) => {
61
+ if (!cid) return false;
62
+ if (cid === channel.cid) return true;
63
+ return (channel.state?.topics || []).some((topic: Channel) => topic.cid === cid);
64
+ };
65
+ const handleE2eePreviewUpdate = (event: any) => {
66
+ if (isTopicGroupCid(event?.cid)) {
67
+ bump();
68
+ }
69
+ };
55
70
 
56
71
  // Parent channel events
57
72
  subs.push(channel.on('message.new', bump));
@@ -61,6 +76,9 @@ export function useTopicGroupUpdates(
61
76
  subs.push(channel.on('channel.topic.created', bump));
62
77
  subs.push(channel.on('channel.pinned', bump));
63
78
  subs.push(channel.on('channel.unpinned', bump));
79
+ subs.push(client.on('e2ee.message_decrypted' as any, handleE2eePreviewUpdate));
80
+ subs.push(client.on('e2ee.local_messages_loaded' as any, handleE2eePreviewUpdate));
81
+ subs.push(client.on('e2ee.post_join_sync' as any, handleE2eePreviewUpdate));
64
82
 
65
83
  // Topic children events
66
84
  const currentTopics = channel.state?.topics || [];
@@ -90,17 +108,19 @@ export function useTopicGroupUpdates(
90
108
 
91
109
  // Helper: check if user is excluded from unread counting
92
110
  const isExcludedUser = (ch: Channel): boolean => {
93
- const ms = ch.state?.membership as Record<string, unknown> | undefined;
111
+ const client = ch.getClient();
112
+ const activeCh = client.activeChannels[ch.cid] || ch;
113
+ const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
94
114
  if (!ms) return false;
95
115
  const isBannedSelf = Boolean(ms.banned);
96
116
 
97
117
  // Topic support: check parent channel's ban status
98
- const parentCid = ch.data?.parent_cid as string | undefined;
99
- const parentChannel = parentCid ? ch.getClient().activeChannels[parentCid] : undefined;
118
+ const parentCid = activeCh.data?.parent_cid as string | undefined;
119
+ const parentChannel = parentCid ? client.activeChannels[parentCid] : undefined;
100
120
  const isBannedParent = Boolean(parentChannel?.state?.membership?.banned);
101
121
 
102
122
  const isBanned = isBannedSelf || isBannedParent;
103
- const isBlocked = isDirectChannel(ch) && Boolean(ms.blocked);
123
+ const isBlocked = isDirectChannel(activeCh) && Boolean(ms.blocked);
104
124
  const isPending = isPendingMember(ms.channel_role as string);
105
125
  const isSkipped = isSkippedMember(ms.channel_role as string);
106
126
  return isBanned || isBlocked || isPending || isSkipped;
@@ -109,8 +129,10 @@ export function useTopicGroupUpdates(
109
129
  // Helper: get unread count for a channel (reads from SDK state directly)
110
130
  const getUnreadCount = (ch: Channel): number => {
111
131
  if (!currentUserId || isExcludedUser(ch)) return 0;
112
- // Primary: use the SDK's tracked unreadCount
113
- const state = ch.state as unknown as Record<string, unknown> | undefined;
132
+ // Primary: use the SDK's tracked unreadCount from activeChannels to avoid stale state
133
+ const client = ch.getClient();
134
+ const activeCh = client.activeChannels[ch.cid] || ch;
135
+ const state = activeCh.state as unknown as Record<string, unknown> | undefined;
114
136
  const count = (state?.unreadCount as number) ?? 0;
115
137
  return count;
116
138
  };
@@ -118,7 +140,9 @@ export function useTopicGroupUpdates(
118
140
  // Sort topics: pinned first → last activity descending
119
141
  const topics = useMemo(() => {
120
142
  const allTopics = channel.state?.topics || [];
121
- return [...allTopics].sort((a: Channel, b: Channel) => {
143
+ const client = channel.getClient();
144
+ const upToDateTopics = allTopics.map(t => client.activeChannels[t.cid] || t);
145
+ return upToDateTopics.sort((a: Channel, b: Channel) => {
122
146
  const aPinned = a.data?.is_pinned === true;
123
147
  const bPinned = b.data?.is_pinned === true;
124
148
  if (aPinned && !bPinned) return -1;
@@ -130,16 +154,29 @@ export function useTopicGroupUpdates(
130
154
 
131
155
  // Aggregated unread count across parent + all topics
132
156
  const aggregatedUnreadCount = useMemo(() => {
133
- let total = getUnreadCount(channel);
157
+ const client = channel.getClient();
158
+ const activeParent = client.activeChannels[channel.cid] || channel;
159
+
160
+ // Ignore the currently active channel's unread count to match UI behavior
161
+ // where active channels don't show unread badges.
162
+ const activeChannelCid = Object.values(client.activeChannels || {}).find(c => c.state && c.cid === activeChannel?.cid)?.cid || activeChannel?.cid;
163
+
164
+ let total = 0;
165
+ if (activeParent.cid !== activeChannelCid) {
166
+ total += getUnreadCount(activeParent);
167
+ }
134
168
 
135
169
  const allTopics = channel.state?.topics || [];
136
170
  allTopics.forEach((topic: Channel) => {
137
- total += getUnreadCount(topic);
171
+ const activeTopic = client.activeChannels[topic.cid] || topic;
172
+ if (activeTopic.cid !== activeChannelCid) {
173
+ total += getUnreadCount(activeTopic);
174
+ }
138
175
  });
139
176
 
140
177
  return total;
141
178
  // eslint-disable-next-line react-hooks/exhaustive-deps
142
- }, [channel, channel.state?.topics, currentUserId, updateCount]);
179
+ }, [channel, channel.state?.topics, currentUserId, updateCount, activeChannel?.cid]);
143
180
 
144
181
  const hasUnread = aggregatedUnreadCount > 0;
145
182
 
@@ -188,10 +225,11 @@ export function useTopicGroupUpdates(
188
225
  options?.videoMessageLabel,
189
226
  options?.voiceRecordingMessageLabel,
190
227
  options?.fileMessageLabel,
228
+ options?.encryptedMessageLabel,
229
+ options?.encryptedMessageUnavailableLabel,
191
230
  options?.systemMessageTranslations,
192
231
  options?.signalMessageTranslations,
193
232
  ]);
194
233
 
195
234
  return { topics, aggregatedUnreadCount, hasUnread, updateCount, latestMessagePreview };
196
235
  }
197
-
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ export { useTopicGroupUpdates } from './hooks/useTopicGroupUpdates';
27
27
  export { useDragAndDrop } from './hooks/useDragAndDrop';
28
28
  export { useMessageSend } from './hooks/useMessageSend';
29
29
  export { useFileUpload } from './hooks/useFileUpload';
30
+ export { useE2eeFileUpload } from './hooks/useE2eeFileUpload';
31
+ export { useE2eeAttachmentRenderer } from './hooks/useE2eeAttachmentRenderer';
32
+ export { usePendingE2eeSends } from './hooks/usePendingE2eeSends';
30
33
  export { useEmojiPicker } from './hooks/useEmojiPicker';
31
34
  export { useStickerPicker } from './hooks/useStickerPicker';
32
35
  export type { UseStickerPickerOptions } from './hooks/useStickerPicker';
@@ -184,6 +187,8 @@ export { useChannelMessages, markChannelAsFullyQueried } from './hooks/useChanne
184
187
  export type { UseChannelMessagesOptions } from './hooks/useChannelMessages';
185
188
 
186
189
  export { useForwardMessage } from './hooks/useForwardMessage';
190
+ export { useRecoveryPin } from './hooks/useRecoveryPin';
191
+ export type { UseRecoveryPinReturn, RecoveryPinStatus, RecoveryRestoredMessage, RecoveryStatusInfo } from './hooks/useRecoveryPin';
187
192
 
188
193
  export { QuotedMessagePreview } from './components/QuotedMessagePreview';
189
194
  export type { QuotedMessagePreviewProps } from './components/QuotedMessagePreview';
@@ -245,6 +250,24 @@ export type { UserPickerProps, UserPickerUser, UserPickerItemProps, UserPickerSe
245
250
 
246
251
  export { CreateChannelModal } from './components/CreateChannelModal';
247
252
  export type { CreateChannelModalProps } from './types';
253
+ export {
254
+ RecoveryPinSetup,
255
+ RecoveryPinRestore,
256
+ RecoveryPinChange,
257
+ RecoveryStatus,
258
+ RecoveryGap,
259
+ RecoveryGate,
260
+ RecoveryRestoreProgress,
261
+ } from './components/RecoveryPin';
262
+ export type {
263
+ RecoveryPinSetupProps,
264
+ RecoveryPinRestoreProps,
265
+ RecoveryPinChangeProps,
266
+ RecoveryStatusProps,
267
+ RecoveryGapProps,
268
+ RecoveryGateProps,
269
+ RecoveryRestoreProgressProps,
270
+ } from './components/RecoveryPin';
248
271
 
249
272
  // Call Components
250
273
  export { ErmisCallContext } from './context/ErmisCallContext';
@@ -63,6 +63,20 @@ export function isVideo(attachment: any): boolean {
63
63
  return !!(isVideoAttachment(attachment) || (!attachment.type && attachment.mime_type?.startsWith('video/')));
64
64
  }
65
65
 
66
+ export function isAudioAttachment(attachment: any): boolean {
67
+ return attachment?.type === ATTACHMENT_TYPES.AUDIO;
68
+ }
69
+
70
+ export function isAudio(attachment: any): boolean {
71
+ return !!(
72
+ isAudioAttachment(attachment) ||
73
+ isVoiceRecordingAttachment(attachment) ||
74
+ attachment.mime_type?.startsWith('audio/') ||
75
+ attachment.file_name?.toLowerCase().endsWith('.mp3') ||
76
+ attachment.title?.toLowerCase().endsWith('.mp3')
77
+ );
78
+ }
79
+
66
80
  export const MESSAGE_DISPLAY_TYPES = {
67
81
  NORMAL: 'normal',
68
82
  DELETED: 'deleted',
@@ -500,6 +500,15 @@
500
500
  margin-left: 2px;
501
501
  }
502
502
 
503
+ .ermis-channel-info__media-spinner {
504
+ width: 16px;
505
+ height: 16px;
506
+ border: 2px solid rgba(255, 255, 255, 0.35);
507
+ border-top-color: #fff;
508
+ border-radius: 50%;
509
+ animation: ermis-lightbox-spin 0.8s linear infinite;
510
+ }
511
+
503
512
  /* ============================================
504
513
  Links List
505
514
  ============================================ */
@@ -4,10 +4,10 @@
4
4
  .ermis-channel-header {
5
5
  display: flex;
6
6
  align-items: center;
7
- gap: var(--ermis-spacing-md);
7
+ gap: var(--ermis-spacing-sm);
8
8
  padding: var(--ermis-spacing-md) var(--ermis-spacing-lg);
9
9
  border-bottom: 1px solid var(--ermis-border);
10
- background-color: var(--ermis-bg-secondary);
10
+ background-color: var(--ermis-bg-primary);
11
11
  font-family: var(--ermis-font-family);
12
12
  }
13
13
 
@@ -37,15 +37,15 @@
37
37
  }
38
38
 
39
39
  .ermis-channel-header__topic-avatar {
40
- width: 32px;
41
- height: 32px;
42
- min-width: 32px;
40
+ width: 44px;
41
+ height: 44px;
42
+ min-width: 44px;
43
43
  border-radius: var(--ermis-radius-md);
44
44
  background-color: var(--ermis-bg-primary);
45
45
  display: flex;
46
46
  align-items: center;
47
47
  justify-content: center;
48
- font-size: 16px;
48
+ font-size: 24px;
49
49
  color: var(--ermis-text-secondary);
50
50
  }
51
51
 
@@ -132,7 +132,7 @@
132
132
  display: flex;
133
133
  align-items: center;
134
134
  gap: var(--ermis-spacing-md);
135
- padding: var(--ermis-spacing-md) var(--ermis-spacing-lg);
135
+ padding: calc(var(--ermis-spacing-md) - 0.05rem) var(--ermis-spacing-lg);
136
136
  cursor: pointer;
137
137
  border-left: 2px solid transparent;
138
138
  transition: background-color var(--ermis-transition), border-color var(--ermis-transition);
@@ -180,6 +180,31 @@
180
180
  background-color: var(--ermis-text-muted);
181
181
  }
182
182
 
183
+ .ermis-channel-list__avatar-unread-badge {
184
+ display: none;
185
+ position: absolute;
186
+ top: -2px;
187
+ right: -2px;
188
+ min-width: 16px;
189
+ height: 16px;
190
+ padding: 0 4px;
191
+ border-radius: var(--ermis-radius-full);
192
+ background-color: var(--ermis-color-danger, #ef4444);
193
+ color: #fff;
194
+ font-size: 9px;
195
+ font-weight: 700;
196
+ line-height: 1;
197
+ border: 1.5px solid var(--ermis-bg-primary);
198
+ z-index: 2;
199
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
200
+ }
201
+
202
+ .channel-sidebar-collapsed .ermis-channel-list__avatar-unread-badge {
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: center;
206
+ }
207
+
183
208
  .ermis-channel-list__item-top-row {
184
209
  display: flex;
185
210
  align-items: baseline;
@@ -196,7 +221,7 @@
196
221
  }
197
222
 
198
223
  .ermis-channel-list__item-name {
199
- font-size: var(--ermis-font-size-sm);
224
+ font-size: calc(var(--ermis-font-size-sm) + 1px);
200
225
  font-weight: 500;
201
226
  color: var(--ermis-text-primary);
202
227
  white-space: nowrap;
@@ -214,7 +239,7 @@
214
239
  }
215
240
 
216
241
  .ermis-channel-list__item-last-message {
217
- font-size: var(--ermis-font-size-xs);
242
+ font-size: calc(var(--ermis-font-size-sm));
218
243
  color: var(--ermis-text-muted);
219
244
  white-space: nowrap;
220
245
  overflow: hidden;
@@ -266,8 +291,7 @@
266
291
  }
267
292
 
268
293
  .ermis-channel-list__item-last-message-source {
269
- color: var(--ermis-accent);
270
- font-weight: 500;
294
+ color: var(--ermis-text-secondary);
271
295
  }
272
296
 
273
297
  .ermis-channel-list__item-actions-wrapper,
@@ -312,13 +336,12 @@
312
336
 
313
337
  /* --- Unread channel indicator --- */
314
338
  .ermis-channel-list__item--unread .ermis-channel-list__item-name {
315
- font-weight: 700;
339
+ font-weight: 600;
316
340
  color: var(--ermis-text-primary);
317
341
  }
318
342
 
319
343
  .ermis-channel-list__item--unread .ermis-channel-list__item-last-message {
320
344
  color: var(--ermis-text-secondary);
321
- font-weight: 600;
322
345
  }
323
346
 
324
347
  .ermis-channel-list__unread-badge {
@@ -524,4 +547,4 @@
524
547
 
525
548
  .ermis-channel-list__error-retry:hover svg {
526
549
  transform: rotate(180deg);
527
- }
550
+ }
@@ -23,8 +23,12 @@
23
23
  }
24
24
 
25
25
  @keyframes ermis-lightbox-fade-in {
26
- from { opacity: 0; }
27
- to { opacity: 1; }
26
+ from {
27
+ opacity: 0;
28
+ }
29
+ to {
30
+ opacity: 1;
31
+ }
28
32
  }
29
33
 
30
34
  /* ----------------------------------------------------------
@@ -74,6 +78,11 @@
74
78
  color: #fff;
75
79
  }
76
80
 
81
+ .ermis-lightbox__action-btn:disabled {
82
+ opacity: 0.4;
83
+ cursor: not-allowed;
84
+ }
85
+
77
86
  /* ----------------------------------------------------------
78
87
  Content area
79
88
  ---------------------------------------------------------- */
@@ -157,8 +166,32 @@
157
166
  animation: ermis-lightbox-spin 0.8s linear infinite;
158
167
  }
159
168
 
169
+ .ermis-lightbox__video-retry {
170
+ flex-direction: column;
171
+ gap: 10px;
172
+ }
173
+
174
+ .ermis-lightbox__progress-label {
175
+ color: rgba(255, 255, 255, 0.86);
176
+ font-size: 13px;
177
+ font-weight: 500;
178
+ }
179
+
180
+ .ermis-lightbox__media-placeholder {
181
+ width: min(80vw, 720px);
182
+ height: min(60vh, 420px);
183
+ border-radius: 4px;
184
+ background: rgba(255, 255, 255, 0.08);
185
+ }
186
+
187
+ .ermis-lightbox__image--poster {
188
+ filter: none;
189
+ }
190
+
160
191
  @keyframes ermis-lightbox-spin {
161
- to { transform: rotate(360deg); }
192
+ to {
193
+ transform: rotate(360deg);
194
+ }
162
195
  }
163
196
 
164
197
  /* ----------------------------------------------------------