@ermis-network/ermis-chat-react 1.0.9 → 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 (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hash-based avatar color palette.
3
+ *
4
+ * Each user/channel is assigned a deterministic gradient based on
5
+ * the hash of their name. This provides visual variety in the
6
+ * channel list and improves readability over a single brand-color
7
+ * fallback.
8
+ */
9
+
10
+ /** Curated gradient pairs [from, to] – all pass WCAG AA contrast with white text. */
11
+ const AVATAR_GRADIENTS: readonly [string, string][] = [
12
+ ['#0EA5E9', '#38BDF8'], // Teal
13
+ ['#10B981', '#34D399'], // Emerald
14
+ ['#D97706', '#F59E0B'], // Amber
15
+ ['#E11D48', '#FB7185'], // Rose
16
+ ['#7C3AED', '#A78BFA'], // Violet
17
+ ['#4F46E5', '#818CF8'], // Indigo
18
+ ['#DB2777', '#F472B6'], // Pink
19
+ ['#EA580C', '#FB923C'], // Orange
20
+ ['#0891B2', '#22D3EE'], // Cyan
21
+ ['#65A30D', '#A3E635'], // Lime
22
+ ] as const;
23
+
24
+ /** Neutral fallback when no name is available. */
25
+ const FALLBACK_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #545f71 100%)';
26
+
27
+ /**
28
+ * Simple djb2-variant string hash → non-negative integer.
29
+ */
30
+ function hashString(str: string): number {
31
+ let hash = 0;
32
+ for (let i = 0; i < str.length; i++) {
33
+ hash = ((hash << 5) - hash) + str.charCodeAt(i);
34
+ hash |= 0; // Convert to 32-bit integer
35
+ }
36
+ return Math.abs(hash);
37
+ }
38
+
39
+ /**
40
+ * Returns a CSS `linear-gradient(...)` string for a given name.
41
+ * The same name always produces the same gradient (deterministic).
42
+ */
43
+ export function getAvatarGradient(name?: string): string {
44
+ if (!name) return FALLBACK_GRADIENT;
45
+ const idx = hashString(name) % AVATAR_GRADIENTS.length;
46
+ const [from, to] = AVATAR_GRADIENTS[idx];
47
+ return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
48
+ }
package/src/utils.ts CHANGED
@@ -1,13 +1,36 @@
1
+ import React from 'react';
1
2
  import type { MentionMember } from './types';
2
- import type { Attachment, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
+ import type { Attachment, FormatMessageResponse, Channel } from '@ermis-network/ermis-chat-sdk';
4
+ import {
5
+ parseSystemMessage,
6
+ parseSignalMessage,
7
+ SystemMessageTranslations,
8
+ SignalMessageTranslations,
9
+ } from '@ermis-network/ermis-chat-sdk';
10
+ import { isDeletedDisplayMessage } from './messageTypeUtils';
3
11
 
4
12
  /**
5
- * Format a Date or date-string to a short time string (HH:MM).
13
+ * Remove Vietnamese diacritics (accents) from a string.
14
+ */
15
+ export function removeAccents(str: string): string {
16
+ if (!str) return '';
17
+ return str
18
+ .normalize('NFD')
19
+ .replace(/[\u0300-\u036f]/g, '')
20
+ .replace(/đ/g, 'd')
21
+ .replace(/Đ/g, 'D');
22
+ }
23
+
24
+ /**
25
+ * Format a Date or date-string to a short time string (HH:MM, 24-hour).
26
+ * Matches Telegram's compact time display.
6
27
  */
7
28
  export function formatTime(date: Date | string | undefined): string {
8
29
  if (!date) return '';
9
30
  const d = date instanceof Date ? date : new Date(date);
10
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
31
+ const hours = String(d.getHours()).padStart(2, '0');
32
+ const minutes = String(d.getMinutes()).padStart(2, '0');
33
+ return `${hours}:${minutes}`;
11
34
  }
12
35
 
13
36
  /**
@@ -42,8 +65,10 @@ export function getDateKey(date: Date | string | undefined): string {
42
65
 
43
66
  /**
44
67
  * Format a date into a human-friendly label (Today / Yesterday / full date).
68
+ * When `locale` is provided, "Today" / "Yesterday" are localised via
69
+ * `Intl.RelativeTimeFormat` and the full date uses that locale for formatting.
45
70
  */
46
- export function formatDateLabel(date: Date | string | undefined): string {
71
+ export function formatDateLabel(date: Date | string | undefined, locale?: string): string {
47
72
  if (!date) return '';
48
73
  const d = date instanceof Date ? date : new Date(date);
49
74
  const now = new Date();
@@ -52,9 +77,26 @@ export function formatDateLabel(date: Date | string | undefined): string {
52
77
  const diffMs = today.getTime() - msgDay.getTime();
53
78
  const diffDays = Math.round(diffMs / 86400000);
54
79
 
55
- if (diffDays === 0) return 'Today';
56
- if (diffDays === 1) return 'Yesterday';
57
- return d.toLocaleDateString(undefined, {
80
+ if (diffDays === 0) {
81
+ if (locale && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
82
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
83
+ // Format -0 days → "today" / "hôm nay" etc.
84
+ const parts = rtf.formatToParts(0, 'day');
85
+ const label = parts.map((p) => p.value).join('');
86
+ return label.charAt(0).toUpperCase() + label.slice(1);
87
+ }
88
+ return 'Today';
89
+ }
90
+ if (diffDays === 1) {
91
+ if (locale && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
92
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
93
+ const parts = rtf.formatToParts(-1, 'day');
94
+ const label = parts.map((p) => p.value).join('');
95
+ return label.charAt(0).toUpperCase() + label.slice(1);
96
+ }
97
+ return 'Yesterday';
98
+ }
99
+ return d.toLocaleDateString(locale || undefined, {
58
100
  year: 'numeric',
59
101
  month: 'long',
60
102
  day: 'numeric',
@@ -74,11 +116,11 @@ export function getMessageUserId(message: FormatMessageResponse): string {
74
116
  */
75
117
  export function replaceMentionsForPreview(
76
118
  text: string,
77
- message: FormatMessageResponse | { mentioned_users?: string[]; mentioned_all?: boolean },
119
+ message: FormatMessageResponse | { mentioned_users?: any[]; mentioned_all?: boolean },
78
120
  userMap: Record<string, string>,
79
- renderWrapper?: (userId: string, name: string) => string
121
+ renderWrapper?: (userId: string, name: string) => string,
80
122
  ): string {
81
- const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
123
+ const mentionedUsers: any[] = (message as any).mentioned_users ?? [];
82
124
  const mentionedAll: boolean = (message as any).mentioned_all ?? false;
83
125
 
84
126
  // If no mentions, nothing to replace
@@ -88,9 +130,12 @@ export function replaceMentionsForPreview(
88
130
 
89
131
  const replacements: { pattern: string; label: string }[] = [];
90
132
 
91
- for (const userId of mentionedUsers) {
133
+ for (const userItem of mentionedUsers) {
134
+ if (!userItem) continue;
135
+ const userId = typeof userItem === 'string' ? userItem : userItem.id;
92
136
  if (!userId) continue;
93
- const name = userMap[userId] ?? userId;
137
+ const itemObjName = typeof userItem === 'object' ? userItem.name : undefined;
138
+ const name = userMap[userId] ?? itemObjName ?? userId;
94
139
  replacements.push({
95
140
  pattern: `@${userId}`,
96
141
  label: renderWrapper ? renderWrapper(userId, name) : `@${name}`,
@@ -100,7 +145,7 @@ export function replaceMentionsForPreview(
100
145
  if (mentionedAll) {
101
146
  replacements.push({
102
147
  pattern: '@all',
103
- label: renderWrapper ? renderWrapper('__all__', 'all') : '@all'
148
+ label: renderWrapper ? renderWrapper('__all__', 'all') : '@all',
104
149
  });
105
150
  }
106
151
 
@@ -120,14 +165,48 @@ export function replaceMentionsForPreview(
120
165
  * Common helper to build a dictionary of User ID -> Display Name
121
166
  * from the channel state, used for rendering Mentions and System logs.
122
167
  */
123
- export function buildUserMap(channelState: any): Record<string, string> {
168
+ export function buildUserMap(channelState: any, extraUsers?: Record<string, any>): Record<string, string> {
124
169
  const map: Record<string, string> = {};
170
+ const setDisplayName = (id: string, name?: string) => {
171
+ if (!id || !name) return;
172
+ const current = map[id];
173
+ if (current && current !== id) return;
174
+ map[id] = name;
175
+ };
176
+
177
+ // 1. Fallback: Global user cache from client state
178
+ if (extraUsers && typeof extraUsers === 'object') {
179
+ for (const [id, user] of Object.entries<any>(extraUsers)) {
180
+ setDisplayName(id, user?.name);
181
+ }
182
+ }
183
+
184
+ // 2. Current members
125
185
  const members = channelState?.members;
126
186
  if (members && typeof members === 'object') {
127
187
  for (const [id, member] of Object.entries<any>(members)) {
128
- map[id] = member?.user?.name || member?.user_id || id;
188
+ const name = member?.user?.name || member?.user_id || id;
189
+ setDisplayName(id, name);
190
+ }
191
+ }
192
+
193
+ // 3. Fallback: scan latest messages
194
+ const messages = channelState?.latestMessages;
195
+ if (Array.isArray(messages)) {
196
+ messages.forEach((msg: any) => {
197
+ const u = msg.user;
198
+ setDisplayName(u?.id, u?.name);
199
+ });
200
+ }
201
+
202
+ // 4. Fallback: check watchers
203
+ const watchers = channelState?.watchers;
204
+ if (watchers && typeof watchers === 'object') {
205
+ for (const [id, user] of Object.entries<any>(watchers)) {
206
+ setDisplayName(id, user?.name);
129
207
  }
130
208
  }
209
+
131
210
  return map;
132
211
  }
133
212
 
@@ -214,7 +293,11 @@ export function formatRelativeDate(dateStr: string): string {
214
293
  if (diffDays === 1) return 'Yesterday';
215
294
  if (diffDays < 7) return `${diffDays}d ago`;
216
295
 
217
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
296
+ return date.toLocaleDateString('en-US', {
297
+ month: 'short',
298
+ day: 'numeric',
299
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
300
+ });
218
301
  }
219
302
 
220
303
  /**
@@ -240,3 +323,123 @@ export function extractDomain(url: string): string {
240
323
  return url;
241
324
  }
242
325
  }
326
+
327
+ /**
328
+ * Get a human-readable preview string for the last message,
329
+ * handling regular, system, and signal message types.
330
+ */
331
+ export function getLastMessagePreview(
332
+ channel: Channel,
333
+ myUserId?: string,
334
+ options?: {
335
+ deletedMessageLabel?: React.ReactNode;
336
+ stickerMessageLabel?: React.ReactNode;
337
+ photoMessageLabel?: React.ReactNode;
338
+ videoMessageLabel?: React.ReactNode;
339
+ voiceRecordingMessageLabel?: React.ReactNode;
340
+ fileMessageLabel?: React.ReactNode;
341
+ encryptedMessageLabel?: React.ReactNode;
342
+ encryptedMessageUnavailableLabel?: React.ReactNode;
343
+ systemMessageTranslations?: SystemMessageTranslations;
344
+ signalMessageTranslations?: SignalMessageTranslations;
345
+ },
346
+ ): { text: React.ReactNode; user: string; timestamp?: string | Date } {
347
+ const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
348
+ if (!lastMsg) return { text: '', user: '' };
349
+
350
+ const timestamp = lastMsg.created_at;
351
+
352
+ const msgType = lastMsg.type || 'regular';
353
+ const isDeleted = isDeletedDisplayMessage(lastMsg);
354
+ const rawText = lastMsg.text ?? '';
355
+ const isEncrypted = lastMsg.content_type === 'mls' || Boolean((lastMsg as any).mls_ciphertext);
356
+
357
+ const client = (channel as any).getClient?.() || (channel as any).client;
358
+ const userMap = buildUserMap(channel.state, client?.state?.users);
359
+
360
+ if (isDeleted) {
361
+ return { text: options?.deletedMessageLabel || 'This message was deleted', user: '', timestamp };
362
+ }
363
+
364
+ if (msgType === 'system') {
365
+ return { text: parseSystemMessage(rawText, userMap, options?.systemMessageTranslations), user: '', timestamp };
366
+ }
367
+
368
+ if (msgType === 'signal') {
369
+ const result = parseSignalMessage(rawText, myUserId || '', options?.signalMessageTranslations);
370
+ return { text: result?.text || rawText, user: '', timestamp };
371
+ }
372
+
373
+ const userId = lastMsg.user_id || '';
374
+ const currentUser = userId && userId === myUserId ? client?.user : undefined;
375
+ const senderName = currentUser?.name || lastMsg.user?.name || (userId && userMap[userId]) || userId || '';
376
+
377
+ // Display 'Sticker' if message is a sticker
378
+ const isSticker = msgType === 'sticker' || (lastMsg as any).sticker_url;
379
+ if (isSticker) {
380
+ return { text: options?.stickerMessageLabel || 'Sticker', user: senderName, timestamp };
381
+ }
382
+
383
+ // Regular / other
384
+ let displayText: React.ReactNode = rawText;
385
+ if (!displayText && lastMsg.attachments && lastMsg.attachments.length > 0) {
386
+ const att = lastMsg.attachments[0];
387
+ const type = att.type || '';
388
+ switch (type) {
389
+ case 'image':
390
+ displayText = options?.photoMessageLabel || '📷 Photo';
391
+ break;
392
+ case 'video':
393
+ displayText = options?.videoMessageLabel || '🎬 Video';
394
+ break;
395
+ case 'voiceRecording':
396
+ displayText = options?.voiceRecordingMessageLabel || '🎤 Voice message';
397
+ break;
398
+ default:
399
+ displayText = options?.fileMessageLabel || '📎 File';
400
+ break;
401
+ }
402
+ if (lastMsg.attachments.length > 1) {
403
+ if (typeof displayText === 'string') {
404
+ displayText += ` +${lastMsg.attachments.length - 1}`;
405
+ } else {
406
+ const extraText = ` +${lastMsg.attachments.length - 1}`;
407
+ displayText = React.createElement(React.Fragment, null, displayText, extraText);
408
+ }
409
+ }
410
+ }
411
+ if (!displayText && isEncrypted) {
412
+ displayText =
413
+ (lastMsg as any).e2ee_status === 'failed'
414
+ ? options?.encryptedMessageUnavailableLabel || 'Encrypted message unavailable'
415
+ : options?.encryptedMessageLabel || 'Encrypted message';
416
+ }
417
+
418
+ // Format mentions if necessary
419
+ const lastMsgRecord = lastMsg as any;
420
+ const mentionedUsers = lastMsgRecord.mentioned_users as string[] | undefined;
421
+ const mentionedAll = lastMsgRecord.mentioned_all as boolean | undefined;
422
+
423
+ if (
424
+ typeof displayText === 'string' &&
425
+ displayText &&
426
+ (mentionedAll || (mentionedUsers && mentionedUsers.length > 0))
427
+ ) {
428
+ displayText = replaceMentionsForPreview(displayText, lastMsg as any, userMap);
429
+ }
430
+
431
+ return {
432
+ text: displayText,
433
+ user: senderName,
434
+ timestamp,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Count the number of words in a string.
440
+ * A word is defined as a non-empty sequence of characters separated by whitespace.
441
+ */
442
+ export function countWords(str: string): number {
443
+ if (!str) return 0;
444
+ return str.trim().split(/\s+/).filter(Boolean).length;
445
+ }