@ermis-network/ermis-chat-react 1.0.9 → 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.
- package/dist/index.cjs +15288 -4203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15238 -4179
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +126 -7
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- 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)
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|
+
}
|