@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
@@ -5,9 +5,33 @@ import { Avatar } from './Avatar';
5
5
  import { useChatClient } from '../hooks/useChatClient';
6
6
  import { useChatComponents } from '../context/ChatComponentsContext';
7
7
  import { markChannelAsFullyQueried } from '../hooks/useChannelMessages';
8
- import type { CreateChannelModalProps, UserPickerUser } from '../types';
8
+ import type { CreateChannelE2eeToggleProps, CreateChannelModalProps, UserPickerUser } from '../types';
9
9
  import { isDirectChannel } from '../channelTypeUtils';
10
10
 
11
+ const DefaultE2eeToggle: React.FC<CreateChannelE2eeToggleProps> = ({
12
+ enabled,
13
+ onChange,
14
+ disabled,
15
+ label = 'End-to-end encrypted',
16
+ description,
17
+ }) => (
18
+ <div className="ermis-create-channel__field ermis-create-channel__field--toggle">
19
+ <div>
20
+ <label className="ermis-create-channel__label">{label}</label>
21
+ {description && <div className="ermis-create-channel__hint">{description}</div>}
22
+ </div>
23
+ <button
24
+ type="button"
25
+ role="switch"
26
+ aria-checked={enabled}
27
+ className={`ermis-create-channel__toggle ${enabled ? 'ermis-create-channel__toggle--on' : ''}`}
28
+ onClick={() => onChange(!enabled)}
29
+ disabled={disabled}
30
+ >
31
+ <span className="ermis-create-channel__toggle-thumb" />
32
+ </button>
33
+ </div>
34
+ );
11
35
 
12
36
  export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
13
37
  isOpen,
@@ -31,11 +55,16 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
31
55
  nextButtonLabel = 'Next',
32
56
  backButtonLabel = 'Back',
33
57
  emptyStateLabel = 'No users found',
58
+ e2eeLabel = 'End-to-end encrypted',
59
+ e2eeDescription = 'Only channel members can read encrypted messages.',
60
+ e2eeUnavailableLabel = 'E2EE is unavailable on this device.',
61
+ e2eeRecoveryPolicy = 'member_assisted',
34
62
  TabsComponent,
35
63
  FooterComponent,
36
64
  GroupFieldsComponent,
37
65
  SearchInputComponent,
38
66
  SelectedBoxComponent,
67
+ E2eeToggleComponent = DefaultE2eeToggle,
39
68
  }) => {
40
69
  const { client, setActiveChannel } = useChatClient();
41
70
  const { ModalComponent } = useChatComponents();
@@ -50,6 +79,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
50
79
  const [name, setName] = useState('');
51
80
  const [description, setDescription] = useState('');
52
81
  const [isPublic, setIsPublic] = useState(false);
82
+ const [e2eeEnabled, setE2eeEnabled] = useState(false);
53
83
 
54
84
  // Users
55
85
  const [selectedUsers, setSelectedUsers] = useState<UserPickerUser[]>([]);
@@ -57,6 +87,11 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
57
87
  // Progress/Error
58
88
  const [isCreating, setIsCreating] = useState(false);
59
89
  const [error, setError] = useState<string | null>(null);
90
+ const e2eeAvailable = Boolean(client?.encryptionManager?.initialized);
91
+
92
+ const handleE2eeChange = useCallback((enabled: boolean) => {
93
+ setE2eeEnabled(enabled);
94
+ }, []);
60
95
 
61
96
  /* ---------- Exclude IDs for Direct ---------- */
62
97
  const hasExistingDirectChannel = useMemo(() => {
@@ -120,14 +155,33 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
120
155
  return;
121
156
  }
122
157
 
123
- createdChannel = client.channel('messaging', {
124
- members: [currentUserId, targetUserId],
125
- } as any);
158
+ const members = [currentUserId, targetUserId];
159
+ const payload: Record<string, any> = { members };
160
+
161
+ if (e2eeEnabled) {
162
+ const encryptionManager = client.encryptionManager;
163
+ if (!encryptionManager?.initialized) {
164
+ throw new Error(e2eeUnavailableLabel);
165
+ }
166
+ const bundle = await encryptionManager.createE2eeChannel('messaging', null, null, members);
167
+ Object.assign(payload, {
168
+ mls_enabled: true,
169
+ e2ee_recovery_policy: e2eeRecoveryPolicy,
170
+ channel_id: bundle.channel_id,
171
+ ...bundle,
172
+ });
173
+ }
174
+
175
+ createdChannel = client.channel('messaging', payload as any);
126
176
  const response = (await createdChannel.create()) as any;
127
177
  if (response?.channel?.id) {
128
178
  createdChannel = client.channel('messaging', response.channel.id);
129
179
  await createdChannel.watch({ messages: { limit: 25, include_hidden_messages: true } });
130
180
  markChannelAsFullyQueried(createdChannel.cid);
181
+ if (e2eeEnabled && client.encryptionManager?.initialized && createdChannel.id) {
182
+ client.encryptionManager.archiveCurrentEpoch(createdChannel.type, createdChannel.id)
183
+ .catch((err: unknown) => console.warn('[E2EE] Initial epoch archive failed:', err));
184
+ }
131
185
  }
132
186
  } else {
133
187
  // Group Channel
@@ -147,12 +201,36 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
147
201
  payload.description = description.trim();
148
202
  }
149
203
 
150
- createdChannel = client.channel('team', payload);
204
+ if (e2eeEnabled) {
205
+ const encryptionManager = client.encryptionManager;
206
+ if (!encryptionManager?.initialized) {
207
+ throw new Error(e2eeUnavailableLabel);
208
+ }
209
+ const uuid =
210
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
211
+ ? crypto.randomUUID()
212
+ : Math.random().toString(36).slice(2);
213
+ const channelId = `${client.projectId}:${uuid}`;
214
+ const cid = `team:${channelId}`;
215
+ const bundle = await encryptionManager.createE2eeChannel('team', channelId, cid, memberIds);
216
+ Object.assign(payload, {
217
+ mls_enabled: true,
218
+ e2ee_recovery_policy: e2eeRecoveryPolicy,
219
+ ...bundle,
220
+ });
221
+ createdChannel = client.channel('team', channelId, payload);
222
+ } else {
223
+ createdChannel = client.channel('team', payload);
224
+ }
151
225
  const response = (await createdChannel.create()) as any;
152
226
  if (response?.channel?.id) {
153
227
  createdChannel = client.channel('team', response.channel.id);
154
228
  await createdChannel.watch({ messages: { limit: 25, include_hidden_messages: true } });
155
229
  markChannelAsFullyQueried(createdChannel.cid);
230
+ if (e2eeEnabled && client.encryptionManager?.initialized && createdChannel.id) {
231
+ client.encryptionManager.archiveCurrentEpoch(createdChannel.type, createdChannel.id)
232
+ .catch((err: unknown) => console.warn('[E2EE] Initial epoch archive failed:', err));
233
+ }
156
234
  }
157
235
  }
158
236
 
@@ -172,7 +250,21 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
172
250
  } finally {
173
251
  setIsCreating(false);
174
252
  }
175
- }, [client, currentUserId, isCreating, selectedUsers, tab, name, isPublic, description, onSuccess, onClose]);
253
+ }, [
254
+ client,
255
+ currentUserId,
256
+ isCreating,
257
+ selectedUsers,
258
+ tab,
259
+ name,
260
+ isPublic,
261
+ description,
262
+ e2eeEnabled,
263
+ e2eeRecoveryPolicy,
264
+ e2eeUnavailableLabel,
265
+ onSuccess,
266
+ onClose,
267
+ ]);
176
268
 
177
269
 
178
270
  const isValid = useMemo(() => {
@@ -214,6 +306,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
214
306
  messageButtonLabel={messageButtonLabel}
215
307
  nextButtonLabel={nextButtonLabel}
216
308
  backButtonLabel={backButtonLabel}
309
+ e2eeEnabled={e2eeEnabled}
217
310
  />
218
311
  );
219
312
  } else if (tab === 'messaging') {
@@ -256,8 +349,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
256
349
  onTabChange={(t) => {
257
350
  setTab(t);
258
351
  setStep(1);
259
- setSelectedUsers([]);
260
- setError(null);
352
+ setSelectedUsers([]);
353
+ setE2eeEnabled(false);
354
+ setError(null);
261
355
  }}
262
356
  disabled={isCreating}
263
357
  directTabLabel={directTabLabel}
@@ -270,8 +364,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
270
364
  onClick={() => {
271
365
  setTab('messaging');
272
366
  setStep(1);
273
- setSelectedUsers([]);
274
- setError(null);
367
+ setSelectedUsers([]);
368
+ setE2eeEnabled(false);
369
+ setError(null);
275
370
  }}
276
371
  disabled={isCreating}
277
372
  >
@@ -282,8 +377,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
282
377
  onClick={() => {
283
378
  setTab('team');
284
379
  setStep(1);
285
- setSelectedUsers([]);
286
- setError(null);
380
+ setSelectedUsers([]);
381
+ setE2eeEnabled(false);
382
+ setError(null);
287
383
  }}
288
384
  disabled={isCreating}
289
385
  >
@@ -308,6 +404,11 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
308
404
  groupDescriptionLabel={groupDescriptionLabel}
309
405
  groupDescriptionPlaceholder={groupDescriptionPlaceholder}
310
406
  groupPublicLabel={groupPublicLabel}
407
+ e2eeEnabled={e2eeEnabled}
408
+ onE2eeChange={handleE2eeChange}
409
+ e2eeLabel={e2eeLabel}
410
+ e2eeDescription={e2eeDescription}
411
+ e2eeDisabled={!e2eeAvailable || isCreating}
311
412
  />
312
413
  ) : (
313
414
  <>
@@ -349,10 +450,28 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
349
450
  <span className="ermis-create-channel__toggle-thumb" />
350
451
  </button>
351
452
  </div>
453
+
454
+ <DefaultE2eeToggle
455
+ enabled={e2eeEnabled}
456
+ onChange={handleE2eeChange}
457
+ disabled={!e2eeAvailable || isCreating}
458
+ label={e2eeLabel}
459
+ description={e2eeAvailable ? e2eeDescription : e2eeUnavailableLabel}
460
+ />
352
461
  </>
353
462
  )
354
463
  )}
355
464
 
465
+ {tab === 'messaging' && (
466
+ <E2eeToggleComponent
467
+ enabled={e2eeEnabled}
468
+ onChange={handleE2eeChange}
469
+ disabled={!e2eeAvailable || isCreating}
470
+ label={e2eeLabel}
471
+ description={e2eeAvailable ? e2eeDescription : e2eeUnavailableLabel}
472
+ />
473
+ )}
474
+
356
475
  {/* User Selection - Step 2 (Group) or Step 1 (Messaging) */}
357
476
  {((tab === 'team' && step === 2) || tab === 'messaging') && (
358
477
  <div className={`ermis-create-channel__users ermis-create-channel__users--${tab}`}>
@@ -37,7 +37,7 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
37
37
  const fileName = item.file?.name || item.originalAttachment?.title || 'Unknown file';
38
38
  const fileSize = item.file?.size || item.originalAttachment?.file_size || 0;
39
39
 
40
- const isHeic = item.file ? isHeicFile(item.file) : (fileType === 'image/heic' || fileType === 'image/heif');
40
+ const isHeic = item.file ? isHeicFile(item.file) : fileType === 'image/heic' || fileType === 'image/heif';
41
41
  const isImage = fileType.startsWith('image/') && !isHeic;
42
42
  const isVideo = fileType.startsWith('video/');
43
43
  const isUploading = item.status === 'uploading';
@@ -62,17 +62,9 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
62
62
 
63
63
  {/* Preview content */}
64
64
  {isImage && previewUrl ? (
65
- <img
66
- className="ermis-files-preview__thumb"
67
- src={previewUrl}
68
- alt={fileName}
69
- />
65
+ <img className="ermis-files-preview__thumb" src={previewUrl} alt={fileName} />
70
66
  ) : isVideo && previewUrl ? (
71
- <video
72
- className="ermis-files-preview__thumb"
73
- src={previewUrl}
74
- muted
75
- />
67
+ <video className="ermis-files-preview__thumb" src={previewUrl} muted />
76
68
  ) : (
77
69
  <div className="ermis-files-preview__file-icon">
78
70
  <span>{getFileIcon(fileType)}</span>
@@ -88,7 +80,11 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
88
80
  {/* Upload status overlay */}
89
81
  {isUploading && (
90
82
  <div className="ermis-files-preview__uploading">
91
- <span className="ermis-files-preview__spinner" />
83
+ {item.progress !== undefined ? (
84
+ <span className="ermis-files-preview__progress">{item.progress}%</span>
85
+ ) : (
86
+ <span className="ermis-files-preview__spinner" />
87
+ )}
92
88
  </div>
93
89
  )}
94
90
 
@@ -57,6 +57,7 @@ type FlatTopicGroupItemProps = {
57
57
  fileMessageLabel?: React.ReactNode;
58
58
  systemMessageTranslations?: SystemMessageTranslations;
59
59
  signalMessageTranslations?: SignalMessageTranslations;
60
+ showTopicPills?: boolean;
60
61
  };
61
62
 
62
63
  /* ----------------------------------------------------------
@@ -87,6 +88,7 @@ export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(
87
88
  fileMessageLabel,
88
89
  systemMessageTranslations,
89
90
  signalMessageTranslations,
91
+ showTopicPills = false,
90
92
  }) => {
91
93
  const { client } = useChatClient();
92
94
  const currentUserId = client.userID;
@@ -113,7 +115,11 @@ export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(
113
115
  const name = channel.data?.name || channel.cid;
114
116
  const image = channel.data?.image as string | undefined;
115
117
  const isPinned = channel.data?.is_pinned === true;
116
- const showUnread = hasUnread && !isActive;
118
+ // For topic groups, always show the unread badge if there are unreads.
119
+ // Unlike normal channels, isActive only means the user is viewing ONE topic,
120
+ // not that they've read ALL topics. The aggregatedUnreadCount already correctly
121
+ // reflects only the truly unread topics.
122
+ const showUnread = hasUnread;
117
123
 
118
124
  // Latest message data from the aggregated preview
119
125
  const lastMessageText = latestMessagePreview?.text || '';
@@ -167,7 +173,12 @@ export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(
167
173
  return (
168
174
  <div className={itemClass} onClick={handleClick}>
169
175
  <div className="ermis-channel-list__item-avatar-wrapper">
170
- <AvatarComponent image={image} name={name} size={40} disableLightbox className="ermis-avatar-wrapper--group" />
176
+ <AvatarComponent image={image} name={name} size={45} disableLightbox className="ermis-avatar-wrapper--group" />
177
+ {showUnread && aggregatedUnreadCount > 0 && (
178
+ <span className="ermis-channel-list__avatar-unread-badge">
179
+ {aggregatedUnreadCount > 99 ? '99+' : aggregatedUnreadCount}
180
+ </span>
181
+ )}
171
182
  </div>
172
183
  <div className="ermis-channel-list__item-content">
173
184
  {/* Row 1: name + pinned + timestamp */}
@@ -207,20 +218,20 @@ export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(
207
218
  </div>
208
219
  {/* Row 3: topic pills — always visible (at least general pill) */}
209
220
  <div className="ermis-channel-list__item-topics-row">
210
- <div className="ermis-channel-list__topic-pills">
211
- {/* General pill — always first */}
212
- <span className="ermis-channel-list__topic-pill">
213
- <span className="ermis-channel-list__topic-pill-avatar">#</span>
214
- <span className="ermis-channel-list__topic-pill-name">{generalTopicLabel}</span>
215
- </span>
216
- {/* Sub-topic pills */}
217
- {visibleTopics.map((topic: Channel) => (
218
- <Pill key={topic.cid} topic={topic} />
219
- ))}
220
- {hasOverflow && (
221
- <span className="ermis-channel-list__topic-overflow">{moreTopicsLabel}</span>
222
- )}
223
- </div>
221
+ {showTopicPills && (
222
+ <div className="ermis-channel-list__topic-pills">
223
+ <span className="ermis-channel-list__topic-pill">
224
+ <span className="ermis-channel-list__topic-pill-avatar">#</span>
225
+ <span className="ermis-channel-list__topic-pill-name">{generalTopicLabel}</span>
226
+ </span>
227
+ {visibleTopics.map((topic: Channel) => (
228
+ <Pill key={topic.cid} topic={topic} />
229
+ ))}
230
+ {hasOverflow && (
231
+ <span className="ermis-channel-list__topic-overflow">{moreTopicsLabel}</span>
232
+ )}
233
+ </div>
234
+ )}
224
235
  </div>
225
236
  </div>
226
237
  <div className="ermis-channel-list__item-actions-wrapper">
@@ -73,6 +73,7 @@ export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
73
73
  const { ModalComponent } = useChatComponents();
74
74
  const Modal = ModalComponent || DefaultModal;
75
75
  const backdropRef = useRef<HTMLDivElement>(null);
76
+ const { client } = useChatClient();
76
77
 
77
78
  const {
78
79
  search,
@@ -99,9 +100,16 @@ export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
99
100
  }, [onDismiss]);
100
101
 
101
102
  /* ---------- Message preview ---------- */
102
- const previewText = message.text
103
- ? (message.text.length > 120 ? message.text.slice(0, 120) + '…' : message.text)
104
- : '';
103
+ let previewText = message.text || '';
104
+
105
+ if (previewText && message.mentioned_users && message.mentioned_users.length > 0) {
106
+ message.mentioned_users.forEach((userId) => {
107
+ const name = client.state.users[userId]?.name || userId;
108
+ previewText = previewText.replace(new RegExp(`@${userId}`, 'g'), `@${name}`);
109
+ });
110
+ }
111
+
112
+ previewText = previewText.length > 120 ? previewText.slice(0, 120) + '…' : previewText;
105
113
  const attachmentCount = message.attachments?.length ?? 0;
106
114
 
107
115
  const footer = (