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

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 (99) hide show
  1. package/dist/index.cjs +15295 -4209
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15246 -4186
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +137 -16
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -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%, #9CA3AF 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,5 +1,25 @@
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';
11
+
12
+ /**
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
+ }
3
23
 
4
24
  /**
5
25
  * Format a Date or date-string to a short time string (HH:MM).
@@ -42,8 +62,10 @@ export function getDateKey(date: Date | string | undefined): string {
42
62
 
43
63
  /**
44
64
  * Format a date into a human-friendly label (Today / Yesterday / full date).
65
+ * When `locale` is provided, "Today" / "Yesterday" are localised via
66
+ * `Intl.RelativeTimeFormat` and the full date uses that locale for formatting.
45
67
  */
46
- export function formatDateLabel(date: Date | string | undefined): string {
68
+ export function formatDateLabel(date: Date | string | undefined, locale?: string): string {
47
69
  if (!date) return '';
48
70
  const d = date instanceof Date ? date : new Date(date);
49
71
  const now = new Date();
@@ -52,9 +74,26 @@ export function formatDateLabel(date: Date | string | undefined): string {
52
74
  const diffMs = today.getTime() - msgDay.getTime();
53
75
  const diffDays = Math.round(diffMs / 86400000);
54
76
 
55
- if (diffDays === 0) return 'Today';
56
- if (diffDays === 1) return 'Yesterday';
57
- return d.toLocaleDateString(undefined, {
77
+ if (diffDays === 0) {
78
+ if (locale && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
79
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
80
+ // Format -0 days → "today" / "hôm nay" etc.
81
+ const parts = rtf.formatToParts(0, 'day');
82
+ const label = parts.map((p) => p.value).join('');
83
+ return label.charAt(0).toUpperCase() + label.slice(1);
84
+ }
85
+ return 'Today';
86
+ }
87
+ if (diffDays === 1) {
88
+ if (locale && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
89
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
90
+ const parts = rtf.formatToParts(-1, 'day');
91
+ const label = parts.map((p) => p.value).join('');
92
+ return label.charAt(0).toUpperCase() + label.slice(1);
93
+ }
94
+ return 'Yesterday';
95
+ }
96
+ return d.toLocaleDateString(locale || undefined, {
58
97
  year: 'numeric',
59
98
  month: 'long',
60
99
  day: 'numeric',
@@ -76,7 +115,7 @@ export function replaceMentionsForPreview(
76
115
  text: string,
77
116
  message: FormatMessageResponse | { mentioned_users?: string[]; mentioned_all?: boolean },
78
117
  userMap: Record<string, string>,
79
- renderWrapper?: (userId: string, name: string) => string
118
+ renderWrapper?: (userId: string, name: string) => string,
80
119
  ): string {
81
120
  const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
82
121
  const mentionedAll: boolean = (message as any).mentioned_all ?? false;
@@ -100,7 +139,7 @@ export function replaceMentionsForPreview(
100
139
  if (mentionedAll) {
101
140
  replacements.push({
102
141
  pattern: '@all',
103
- label: renderWrapper ? renderWrapper('__all__', 'all') : '@all'
142
+ label: renderWrapper ? renderWrapper('__all__', 'all') : '@all',
104
143
  });
105
144
  }
106
145
 
@@ -120,14 +159,48 @@ export function replaceMentionsForPreview(
120
159
  * Common helper to build a dictionary of User ID -> Display Name
121
160
  * from the channel state, used for rendering Mentions and System logs.
122
161
  */
123
- export function buildUserMap(channelState: any): Record<string, string> {
162
+ export function buildUserMap(channelState: any, extraUsers?: Record<string, any>): Record<string, string> {
124
163
  const map: Record<string, string> = {};
164
+
165
+ // 1. Fallback: Global user cache from client state
166
+ if (extraUsers && typeof extraUsers === 'object') {
167
+ for (const [id, user] of Object.entries<any>(extraUsers)) {
168
+ if (user?.name) {
169
+ map[id] = user.name;
170
+ }
171
+ }
172
+ }
173
+
174
+ // 2. Current members
125
175
  const members = channelState?.members;
126
176
  if (members && typeof members === 'object') {
127
177
  for (const [id, member] of Object.entries<any>(members)) {
128
- map[id] = member?.user?.name || member?.user_id || id;
178
+ const name = member?.user?.name || member?.user_id || id;
179
+ if (name) map[id] = name;
180
+ }
181
+ }
182
+
183
+ // 3. Fallback: scan latest messages
184
+ const messages = channelState?.latestMessages;
185
+ if (Array.isArray(messages)) {
186
+ messages.forEach((msg: any) => {
187
+ const u = msg.user;
188
+ if (u?.id && !map[u.id] && u.name) {
189
+ map[u.id] = u.name;
190
+ }
191
+ });
192
+ }
193
+
194
+ // 4. Fallback: check watchers
195
+ const watchers = channelState?.watchers;
196
+ if (watchers && typeof watchers === 'object') {
197
+ for (const [id, user] of Object.entries<any>(watchers)) {
198
+ if (!map[id] && user?.name) {
199
+ map[id] = user.name;
200
+ }
129
201
  }
130
202
  }
203
+
131
204
  return map;
132
205
  }
133
206
 
@@ -214,7 +287,11 @@ export function formatRelativeDate(dateStr: string): string {
214
287
  if (diffDays === 1) return 'Yesterday';
215
288
  if (diffDays < 7) return `${diffDays}d ago`;
216
289
 
217
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
290
+ return date.toLocaleDateString('en-US', {
291
+ month: 'short',
292
+ day: 'numeric',
293
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
294
+ });
218
295
  }
219
296
 
220
297
  /**
@@ -240,3 +317,109 @@ export function extractDomain(url: string): string {
240
317
  return url;
241
318
  }
242
319
  }
320
+
321
+ /**
322
+ * Get a human-readable preview string for the last message,
323
+ * handling regular, system, and signal message types.
324
+ */
325
+ export function getLastMessagePreview(
326
+ channel: Channel,
327
+ myUserId?: string,
328
+ options?: {
329
+ deletedMessageLabel?: React.ReactNode;
330
+ stickerMessageLabel?: React.ReactNode;
331
+ photoMessageLabel?: React.ReactNode;
332
+ videoMessageLabel?: React.ReactNode;
333
+ voiceRecordingMessageLabel?: React.ReactNode;
334
+ fileMessageLabel?: React.ReactNode;
335
+ systemMessageTranslations?: SystemMessageTranslations;
336
+ signalMessageTranslations?: SignalMessageTranslations;
337
+ },
338
+ ): { text: React.ReactNode; user: string; timestamp?: string | Date } {
339
+ const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
340
+ if (!lastMsg) return { text: '', user: '' };
341
+
342
+ const timestamp = lastMsg.created_at;
343
+
344
+ const msgType = lastMsg.type || 'regular';
345
+ const isDeleted = isDeletedDisplayMessage(lastMsg);
346
+ const rawText = lastMsg.text ?? '';
347
+
348
+ const client = (channel as any).getClient?.() || (channel as any).client;
349
+ const userMap = buildUserMap(channel.state, client?.state?.users);
350
+
351
+ if (isDeleted) {
352
+ return { text: options?.deletedMessageLabel || 'This message was deleted', user: '', timestamp };
353
+ }
354
+
355
+ if (msgType === 'system') {
356
+ return { text: parseSystemMessage(rawText, userMap, options?.systemMessageTranslations), user: '', timestamp };
357
+ }
358
+
359
+ if (msgType === 'signal') {
360
+ const result = parseSignalMessage(rawText, myUserId || '', options?.signalMessageTranslations);
361
+ return { text: result?.text || rawText, user: '', timestamp };
362
+ }
363
+
364
+ const userId = lastMsg.user_id || '';
365
+ const senderName = (userId && userMap[userId]) || lastMsg.user?.name || userId || '';
366
+
367
+ // Display 'Sticker' if message is a sticker
368
+ const isSticker = msgType === 'sticker' || (lastMsg as any).sticker_url;
369
+ if (isSticker) {
370
+ return { text: options?.stickerMessageLabel || 'Sticker', user: senderName, timestamp };
371
+ }
372
+
373
+ // Regular / other
374
+ let displayText: React.ReactNode = rawText;
375
+ if (!displayText && lastMsg.attachments && lastMsg.attachments.length > 0) {
376
+ const att = lastMsg.attachments[0];
377
+ const type = att.type || '';
378
+ switch (type) {
379
+ case 'image':
380
+ displayText = options?.photoMessageLabel || '📷 Photo';
381
+ break;
382
+ case 'video':
383
+ displayText = options?.videoMessageLabel || '🎬 Video';
384
+ break;
385
+ case 'voiceRecording':
386
+ displayText = options?.voiceRecordingMessageLabel || '🎤 Voice message';
387
+ break;
388
+ default:
389
+ displayText = options?.fileMessageLabel || '📎 File';
390
+ break;
391
+ }
392
+ if (lastMsg.attachments.length > 1) {
393
+ if (typeof displayText === 'string') {
394
+ displayText += ` +${lastMsg.attachments.length - 1}`;
395
+ } else {
396
+ const extraText = ` +${lastMsg.attachments.length - 1}`;
397
+ displayText = React.createElement(React.Fragment, null, displayText, extraText);
398
+ }
399
+ }
400
+ }
401
+
402
+ // Format mentions if necessary
403
+ const lastMsgRecord = lastMsg as any;
404
+ const mentionedUsers = lastMsgRecord.mentioned_users as string[] | undefined;
405
+ const mentionedAll = lastMsgRecord.mentioned_all as boolean | undefined;
406
+
407
+ if (typeof displayText === 'string' && displayText && (mentionedAll || (mentionedUsers && mentionedUsers.length > 0))) {
408
+ displayText = replaceMentionsForPreview(displayText, lastMsg as any, userMap);
409
+ }
410
+
411
+ return {
412
+ text: displayText,
413
+ user: senderName,
414
+ timestamp,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Count the number of words in a string.
420
+ * A word is defined as a non-empty sequence of characters separated by whitespace.
421
+ */
422
+ export function countWords(str: string): number {
423
+ if (!str) return 0;
424
+ return str.trim().split(/\s+/).filter(Boolean).length;
425
+ }