@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.
- package/dist/index.cjs +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- 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
|
+
}
|