@ermis-network/ermis-chat-react 1.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 (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. package/src/utils.ts +242 -0
@@ -0,0 +1,165 @@
1
+ import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { Avatar } from './Avatar';
4
+ import { replaceMentionsForPreview, buildUserMap } from '../utils';
5
+ import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
6
+ import type { PinnedMessageItemProps, PinnedMessagesProps } from '../types';
7
+
8
+ /* ----------------------------------------------------------
9
+ Default PinnedMessageItem
10
+ ---------------------------------------------------------- */
11
+ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
12
+ message,
13
+ isOwnMessage,
14
+ onClickMessage,
15
+ onUnpin,
16
+ AvatarComponent,
17
+ }) => {
18
+ const { activeChannel } = useChatClient();
19
+ const userName = message.user?.name || message.user_id || 'Unknown';
20
+ const userAvatar = message.user?.avatar;
21
+ const hasAttachments = message.attachments && message.attachments.length > 0;
22
+
23
+ const userMap = useMemo<Record<string, string>>(() => {
24
+ return buildUserMap(activeChannel?.state);
25
+ }, [activeChannel?.state]);
26
+
27
+ let previewText = message.text || '';
28
+ const isSticker = message.type === 'sticker';
29
+
30
+ if (!previewText && hasAttachments) {
31
+ const firstAttach = message.attachments![0];
32
+ previewText = firstAttach.title || `${firstAttach.type || 'file'}`;
33
+ } else if (isSticker) {
34
+ previewText = 'Sticker';
35
+ }
36
+
37
+ // Convert @userId โ†’ @UserName in preview text
38
+ if (previewText) {
39
+ previewText = replaceMentionsForPreview(previewText, message, userMap);
40
+ }
41
+
42
+ // Attachment icon prefix
43
+ let attachIcon = '';
44
+ if (hasAttachments) {
45
+ const type = message.attachments![0].type;
46
+ if (type === 'image') attachIcon = '๐Ÿ“ท ';
47
+ else if (type === 'video') attachIcon = '๐ŸŽฅ ';
48
+ else if (type === 'audio') attachIcon = '๐ŸŽต ';
49
+ else attachIcon = '๐Ÿ“„ ';
50
+ } else if (isSticker) {
51
+ attachIcon = '๐Ÿ˜€ ';
52
+ }
53
+
54
+ return (
55
+ <div
56
+ className={`ermis-pinned-messages__item ${isOwnMessage ? 'ermis-pinned-messages__item--own' : ''}`}
57
+ onClick={() => onClickMessage?.(message.id)}
58
+ role="button"
59
+ tabIndex={0}
60
+ >
61
+ <AvatarComponent image={userAvatar} name={userName} size={28} />
62
+ <div className="ermis-pinned-messages__item-content">
63
+ <span className="ermis-pinned-messages__item-user">{userName}</span>
64
+ <span className="ermis-pinned-messages__item-text">{attachIcon}{previewText || 'Attachment'}</span>
65
+ </div>
66
+ <button
67
+ className="ermis-pinned-messages__unpin-btn"
68
+ onClick={(e) => { e.stopPropagation(); onUnpin?.(message.id); }}
69
+ title="Unpin message"
70
+ aria-label="Unpin message"
71
+ >
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
73
+ <line x1="2" y1="2" x2="22" y2="22" />
74
+ <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
75
+ </svg>
76
+ </button>
77
+ </div>
78
+ );
79
+ });
80
+ DefaultPinnedMessageItem.displayName = 'DefaultPinnedMessageItem';
81
+
82
+ /* ----------------------------------------------------------
83
+ PinnedMessages component
84
+ ---------------------------------------------------------- */
85
+ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
86
+ className,
87
+ AvatarComponent = Avatar,
88
+ PinnedMessageItemComponent = DefaultPinnedMessageItem,
89
+ onClickMessage,
90
+ maxCollapsed = 1,
91
+ }) => {
92
+ const { activeChannel, client, messages } = useChatClient();
93
+ const [expanded, setExpanded] = useState(false);
94
+ const currentUserId = client.userID;
95
+
96
+ // Reset expanded state when switching channels
97
+ useEffect(() => {
98
+ setExpanded(false);
99
+ }, [activeChannel]);
100
+
101
+ const pinnedMessages = useMemo<FormatMessageResponse[]>(() => {
102
+ if (!activeChannel) return [];
103
+ const pinned = (activeChannel.state as any)?.pinnedMessages;
104
+ return Array.isArray(pinned) ? pinned : [];
105
+ }, [activeChannel, messages]);
106
+
107
+ const toggleExpanded = useCallback(() => {
108
+ setExpanded((prev) => !prev);
109
+ }, []);
110
+
111
+ const handleUnpin = useCallback(async (messageId: string) => {
112
+ if (!activeChannel) return;
113
+ try {
114
+ await activeChannel.unpinMessage(messageId);
115
+ } catch (err) {
116
+ console.error('Failed to unpin message', err);
117
+ }
118
+ }, [activeChannel]);
119
+
120
+ if (pinnedMessages.length === 0) return null;
121
+
122
+ const displayedMessages = expanded
123
+ ? pinnedMessages
124
+ : pinnedMessages.slice(0, maxCollapsed);
125
+
126
+ const hasMore = pinnedMessages.length > maxCollapsed;
127
+
128
+ return (
129
+ <div className={`ermis-pinned-messages${expanded ? ' ermis-pinned-messages--expanded' : ''}${className ? ` ${className}` : ''}`}>
130
+ {/* Header bar */}
131
+ <div className="ermis-pinned-messages__header" onClick={toggleExpanded}>
132
+ <svg className="ermis-pinned-messages__icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
133
+ <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
134
+ </svg>
135
+ <span className="ermis-pinned-messages__label">
136
+ {pinnedMessages.length} pinned message{pinnedMessages.length > 1 ? 's' : ''}
137
+ </span>
138
+ {hasMore && (
139
+ <button
140
+ className="ermis-pinned-messages__toggle"
141
+ onClick={(e) => { e.stopPropagation(); toggleExpanded(); }}
142
+ >
143
+ {expanded ? 'Collapse' : 'See all'}
144
+ </button>
145
+ )}
146
+ </div>
147
+
148
+ {/* Pinned message list */}
149
+ <div className="ermis-pinned-messages__list">
150
+ {displayedMessages.map((msg) => (
151
+ <PinnedMessageItemComponent
152
+ key={msg.id}
153
+ message={msg}
154
+ isOwnMessage={msg.user_id === currentUserId || msg.user?.id === currentUserId}
155
+ onClickMessage={onClickMessage}
156
+ onUnpin={handleUnpin}
157
+ AvatarComponent={AvatarComponent}
158
+ />
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ });
164
+
165
+ PinnedMessages.displayName = 'PinnedMessages';
@@ -0,0 +1,55 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { replaceMentionsForPreview, buildUserMap } from '../utils';
4
+ import type { QuotedMessagePreviewProps } from '../types';
5
+
6
+ export type { QuotedMessagePreviewProps } from '../types';
7
+
8
+ const MAX_PREVIEW_LENGTH = 100;
9
+
10
+ function truncateText(text: string, maxLength: number): string {
11
+ if (text.length <= maxLength) return text;
12
+ return text.slice(0, maxLength).trimEnd() + 'โ€ฆ';
13
+ }
14
+
15
+ export const QuotedMessagePreview: React.FC<QuotedMessagePreviewProps> = React.memo(({
16
+ quotedMessage,
17
+ isOwnMessage,
18
+ onClick,
19
+ }) => {
20
+ const { activeChannel } = useChatClient();
21
+
22
+ const userMap = useMemo<Record<string, string>>(() => {
23
+ return buildUserMap(activeChannel?.state);
24
+ }, [activeChannel]);
25
+
26
+ const authorName = quotedMessage.user?.name || quotedMessage.user?.id || 'Unknown';
27
+
28
+ const rawText = quotedMessage.text || '';
29
+ const formattedText = useMemo(() => replaceMentionsForPreview(rawText, quotedMessage as any, userMap), [rawText, quotedMessage, userMap]);
30
+
31
+ const previewText = formattedText
32
+ ? truncateText(formattedText, MAX_PREVIEW_LENGTH)
33
+ : 'Attachment';
34
+
35
+ const handleClick = () => {
36
+ onClick(quotedMessage.id);
37
+ };
38
+
39
+ return (
40
+ <div
41
+ className={`ermis-quoted-message ${isOwnMessage ? 'ermis-quoted-message--own' : ''}`}
42
+ onClick={handleClick}
43
+ role="button"
44
+ tabIndex={0}
45
+ onKeyDown={(e) => {
46
+ if (e.key === 'Enter') handleClick();
47
+ }}
48
+ >
49
+ <span className="ermis-quoted-message__author">{authorName}</span>
50
+ <span className="ermis-quoted-message__text">{previewText}</span>
51
+ </div>
52
+ );
53
+ });
54
+
55
+ QuotedMessagePreview.displayName = 'QuotedMessagePreview';
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import type { ReadReceiptsProps, ReadReceiptsTooltipProps } from '../types';
3
+ import { Avatar } from './Avatar';
4
+ import { formatReadTimestamp } from '../utils';
5
+
6
+ export type { ReadReceiptsProps, ReadReceiptsTooltipProps } from '../types';
7
+
8
+ /* ----------------------------------------------------------
9
+ Default Tooltip โ€” shown on hover
10
+ ---------------------------------------------------------- */
11
+ const DefaultReadReceiptsTooltip: React.FC<ReadReceiptsTooltipProps> = React.memo(({
12
+ readers,
13
+ AvatarComponent,
14
+ }) => (
15
+ <div className="ermis-read-receipts__tooltip-wrapper">
16
+ <div className="ermis-read-receipts__tooltip">
17
+ {readers.map((reader) => (
18
+ <div key={reader.id} className="ermis-read-receipts__tooltip-item">
19
+ <AvatarComponent
20
+ image={reader.avatar}
21
+ name={reader.name || reader.id}
22
+ size={20}
23
+ />
24
+ <div className="ermis-read-receipts__tooltip-info">
25
+ <span className="ermis-read-receipts__tooltip-name">{reader.name || reader.id}</span>
26
+ <span className="ermis-read-receipts__tooltip-time">{formatReadTimestamp(reader.last_read)}</span>
27
+ </div>
28
+ </div>
29
+ ))}
30
+ </div>
31
+ </div>
32
+ ));
33
+ DefaultReadReceiptsTooltip.displayName = 'DefaultReadReceiptsTooltip';
34
+
35
+ /* ----------------------------------------------------------
36
+ ReadReceipts โ€” main component
37
+ ---------------------------------------------------------- */
38
+ export const ReadReceipts: React.FC<ReadReceiptsProps> = React.memo(({
39
+ readers,
40
+ maxAvatars = 5,
41
+ AvatarComponent = Avatar,
42
+ TooltipComponent = DefaultReadReceiptsTooltip,
43
+ showTooltip = true,
44
+ }) => {
45
+ // Only render when there are actual readers (avatar-based display)
46
+ // Sent/Sending/Error status icons are now rendered inline inside the message bubble
47
+ if (!readers || readers.length === 0) {
48
+ return null;
49
+ }
50
+
51
+ const visible = readers.slice(0, maxAvatars);
52
+ const overflow = readers.length - maxAvatars;
53
+
54
+ return (
55
+ <div className="ermis-read-receipts">
56
+ <div className="ermis-read-receipts__avatars">
57
+ {visible.map((reader) => (
58
+ <AvatarComponent
59
+ key={reader.id}
60
+ image={reader.avatar}
61
+ name={reader.name || reader.id}
62
+ size={16}
63
+ className="ermis-read-receipts__avatar"
64
+ />
65
+ ))}
66
+ {overflow > 0 && (
67
+ <span className="ermis-read-receipts__overflow">+{overflow}</span>
68
+ )}
69
+ {showTooltip && (
70
+ <TooltipComponent
71
+ readers={readers}
72
+ AvatarComponent={AvatarComponent}
73
+ />
74
+ )}
75
+ </div>
76
+ </div>
77
+ );
78
+ });
79
+
80
+ ReadReceipts.displayName = 'ReadReceipts';
@@ -0,0 +1,98 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { replaceMentionsForPreview, buildUserMap } from '../utils';
4
+ import type { ReplyPreviewProps } from '../types';
5
+
6
+ const MAX_PREVIEW_LENGTH = 120;
7
+
8
+ function truncateText(text: string, maxLength: number): string {
9
+ if (text.length <= maxLength) return text;
10
+ return text.slice(0, maxLength).trimEnd() + 'โ€ฆ';
11
+ }
12
+
13
+ /** Get a human-readable summary of attachments */
14
+ function getAttachmentSummary(attachments: any[]): string {
15
+ if (!attachments || attachments.length === 0) return '';
16
+
17
+ const types: Record<string, number> = {};
18
+ for (const att of attachments) {
19
+ const type = att.type || 'file';
20
+ types[type] = (types[type] || 0) + 1;
21
+ }
22
+
23
+ const labels: string[] = [];
24
+ const typeLabels: Record<string, string> = {
25
+ image: '๐Ÿ–ผ๏ธ Image',
26
+ video: '๐ŸŽฌ Video',
27
+ audio: '๐ŸŽต Audio',
28
+ file: '๐Ÿ“Ž File',
29
+ voiceRecording: '๐ŸŽค Voice',
30
+ };
31
+
32
+ for (const [type, count] of Object.entries(types)) {
33
+ const label = typeLabels[type] || `๐Ÿ“Ž ${type}`;
34
+ labels.push(count > 1 ? `${label} (${count})` : label);
35
+ }
36
+
37
+ return labels.join(', ');
38
+ }
39
+ export const ReplyPreview: React.FC<ReplyPreviewProps> = React.memo(({
40
+ message,
41
+ onDismiss,
42
+ replyingToLabel = 'Replying to',
43
+ }) => {
44
+ const { activeChannel } = useChatClient();
45
+
46
+ const userMap = useMemo<Record<string, string>>(() => {
47
+ return buildUserMap(activeChannel?.state);
48
+ }, [activeChannel]);
49
+
50
+ const userName = message.user?.name || message.user_id || 'Unknown';
51
+
52
+ const rawText = message.text || '';
53
+ const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
54
+ const hasText = !!formattedText.trim();
55
+ const hasAttachments = message.attachments && message.attachments.length > 0;
56
+ const isSticker = message.type === 'sticker';
57
+ const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
58
+
59
+ // Build preview content
60
+ let previewContent: React.ReactNode = null;
61
+ if (isSticker) {
62
+ previewContent = (
63
+ <span className="ermis-message-input__reply-preview-text">
64
+ ๐Ÿ˜€ Sticker
65
+ </span>
66
+ );
67
+ } else {
68
+ previewContent = (
69
+ <span className="ermis-message-input__reply-preview-text">
70
+ {hasText && truncateText(formattedText, MAX_PREVIEW_LENGTH)}
71
+ {hasText && hasAttachments && ' ยท '}
72
+ {hasAttachments && attachmentSummary}
73
+ </span>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <div className="ermis-message-input__reply-preview">
79
+ <div className="ermis-message-input__reply-preview-body">
80
+ <span className="ermis-message-input__reply-preview-label">{replyingToLabel}</span>
81
+ <span className="ermis-message-input__reply-preview-user">{userName}</span>
82
+ {previewContent}
83
+ </div>
84
+ <button
85
+ className="ermis-message-input__reply-preview-dismiss"
86
+ onClick={onDismiss}
87
+ title="Cancel reply"
88
+ >
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
+ <line x1="18" y1="6" x2="6" y2="18" />
91
+ <line x1="6" y1="6" x2="18" y2="18" />
92
+ </svg>
93
+ </button>
94
+ </div>
95
+ );
96
+ });
97
+
98
+ ReplyPreview.displayName = 'ReplyPreview';
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { useTypingIndicator, type TypingUser } from '../hooks/useTypingIndicator';
3
+
4
+ export type TypingIndicatorProps = {
5
+ /** Custom render function for the typing text */
6
+ renderText?: (users: TypingUser[]) => React.ReactNode;
7
+ };
8
+
9
+ /**
10
+ * Displays a "X is typing..." indicator below the message list.
11
+ * Automatically subscribes to typing events via the useTypingIndicator hook.
12
+ */
13
+ export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ renderText }) => {
14
+ const { typingUsers } = useTypingIndicator();
15
+
16
+ const isActive = typingUsers.length > 0;
17
+
18
+ const text = isActive
19
+ ? (renderText ? renderText(typingUsers) : formatTypingText(typingUsers))
20
+ : null;
21
+
22
+ return (
23
+ <div className={`ermis-typing-indicator${isActive ? ' ermis-typing-indicator--active' : ''}`}>
24
+ {isActive && (
25
+ <>
26
+ <div className="ermis-typing-indicator__dots">
27
+ <span className="ermis-typing-indicator__dot" />
28
+ <span className="ermis-typing-indicator__dot" />
29
+ <span className="ermis-typing-indicator__dot" />
30
+ </div>
31
+ <span className="ermis-typing-indicator__text">{text}</span>
32
+ </>
33
+ )}
34
+ </div>
35
+ );
36
+ });
37
+
38
+ TypingIndicator.displayName = 'TypingIndicator';
39
+
40
+ /**
41
+ * Format typing text based on number of users:
42
+ * - 1 user: "Alice is typing..."
43
+ * - 2 users: "Alice and Bob are typing..."
44
+ * - 3+ users: "Alice, Bob and 2 others are typing..."
45
+ */
46
+ function formatTypingText(users: TypingUser[]): string {
47
+ const names = users.map((u) => u.name || u.id);
48
+
49
+ if (names.length === 1) {
50
+ return `${names[0]} is typing...`;
51
+ }
52
+ if (names.length === 2) {
53
+ return `${names[0]} and ${names[1]} are typing...`;
54
+ }
55
+ const remaining = names.length - 2;
56
+ return `${names[0]}, ${names[1]} and ${remaining} other${remaining > 1 ? 's' : ''} are typing...`;
57
+ }