@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,218 @@
1
+ import React from 'react';
2
+ import type { MessageItemProps, SystemMessageItemProps } from '../types';
3
+ import { QuotedMessagePreview } from './QuotedMessagePreview';
4
+ import { MessageActionsBox } from './MessageActionsBox';
5
+ import { MessageReactions } from './MessageReactions';
6
+ import { MessageQuickReactions } from './MessageQuickReactions';
7
+ import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
8
+ import { useChatClient } from '../hooks/useChatClient';
9
+ import { formatTime } from '../utils';
10
+
11
+ export type { MessageItemProps, SystemMessageItemProps } from '../types';
12
+
13
+ /* ----------------------------------------------------------
14
+ MessageItem — single regular/signal message row
15
+ ---------------------------------------------------------- */
16
+ /* Inline status icon for own messages (sent / sending / error) */
17
+ const InlineStatusIcon: React.FC<{ status?: string; isOwnMessage: boolean; isLastInGroup: boolean }> = React.memo(({
18
+ status,
19
+ isOwnMessage,
20
+ isLastInGroup,
21
+ }) => {
22
+ if (!isOwnMessage) return null;
23
+
24
+ const isError = status === 'error' || status === 'failed_offline';
25
+ if (!isLastInGroup && !isError) return null;
26
+
27
+ if (isError) {
28
+ return (
29
+ <span className="ermis-message-status-icon ermis-message-status-icon--failed" title="Failed to send">
30
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
31
+ <circle cx="12" cy="12" r="10"></circle>
32
+ <line x1="12" y1="8" x2="12" y2="12"></line>
33
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
34
+ </svg>
35
+ </span>
36
+ );
37
+ }
38
+
39
+ if (status === 'sending') {
40
+ return (
41
+ <span className="ermis-message-status-icon ermis-message-status-icon--sending" title="Sending...">
42
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
43
+ <circle cx="12" cy="12" r="10"></circle>
44
+ <polyline points="12 6 12 12 16 14"></polyline>
45
+ </svg>
46
+ </span>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <span className="ermis-message-status-icon ermis-message-status-icon--sent" title="Sent">
52
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
53
+ <polyline points="20 6 9 17 4 12"></polyline>
54
+ </svg>
55
+ </span>
56
+ );
57
+ });
58
+ InlineStatusIcon.displayName = 'InlineStatusIcon';
59
+
60
+ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
61
+ message,
62
+ isOwnMessage,
63
+ isFirstInGroup,
64
+ isLastInGroup,
65
+ isHighlighted,
66
+ AvatarComponent,
67
+ MessageBubble,
68
+ MessageRenderer,
69
+ onClickQuote,
70
+ QuotedMessagePreviewComponent = QuotedMessagePreview,
71
+ MessageActionsBoxComponent = MessageActionsBox,
72
+ MessageReactionsComponent = MessageReactions,
73
+ forwardedLabel = 'Forwarded',
74
+ editedLabel = 'Edited',
75
+ }) => {
76
+ const { activeChannel, client } = useChatClient();
77
+ const { hasCapability } = useChannelCapabilities();
78
+
79
+ const canReact = hasCapability('send-reaction');
80
+
81
+ const userName = message.user?.name || message.user_id;
82
+ const userAvatar = message.user?.avatar;
83
+
84
+ const quotedMessage = (message as any).quoted_message;
85
+ const isForwarded = !!(message as any).forward_cid;
86
+ const oldTexts = (message as any).old_texts;
87
+ const isEdited = oldTexts && oldTexts.length > 0;
88
+ const hasAttachments = message.attachments && message.attachments.length > 0;
89
+
90
+ const handleReactionToggle = React.useCallback(async (type: string) => {
91
+ if (!activeChannel || !canReact) return;
92
+ const currentUserId = client?.userID;
93
+ const isOwn =
94
+ (message as any).own_reactions?.some((r: any) => r.type === type) ||
95
+ (message as any).latest_reactions?.some((r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId));
96
+
97
+ try {
98
+ if (isOwn) {
99
+ await activeChannel.deleteReaction(message.id!, type);
100
+ } else {
101
+ await activeChannel.sendReaction(message.id!, type);
102
+ }
103
+ } catch (err) {
104
+ console.error('Failed to toggle reaction', err);
105
+ }
106
+ }, [activeChannel, message, client?.userID]);
107
+
108
+ const statusClass =
109
+ message.status === 'sending'
110
+ ? 'ermis-message--sending'
111
+ : (message.status === 'error' || message.status === 'failed_offline')
112
+ ? 'ermis-message--error'
113
+ : '';
114
+
115
+ const isNewMessage = React.useMemo(() => {
116
+ if (!message.created_at) return false;
117
+ return Date.now() - new Date(message.created_at).getTime() < 1000;
118
+ }, [message.created_at]);
119
+
120
+ const itemClass = [
121
+ 'ermis-message-list__item',
122
+ isOwnMessage ? 'ermis-message-list__item--own' : 'ermis-message-list__item--other',
123
+ isFirstInGroup ? 'ermis-message-list__item--group-start' : 'ermis-message-list__item--group-cont',
124
+ isHighlighted ? 'ermis-message-list__item--highlighted' : '',
125
+ isNewMessage ? 'ermis-message-list__item--new' : '',
126
+ statusClass,
127
+ ].filter(Boolean).join(' ');
128
+
129
+ const contentClass = [
130
+ 'ermis-message-list__item-content',
131
+ hasAttachments ? 'ermis-message-list__item-content--has-attachments' : '',
132
+ ].filter(Boolean).join(' ');
133
+
134
+ return (
135
+ <div className={itemClass} data-message-id={message.id}>
136
+ {/* Avatar area: show avatar only on first message, otherwise placeholder for alignment */}
137
+ {!isOwnMessage && (
138
+ <div className="ermis-message-list__item-avatar">
139
+ {isFirstInGroup
140
+ ? <AvatarComponent image={userAvatar} name={userName} size={28} />
141
+ : <div style={{ width: 28 }} />
142
+ }
143
+ </div>
144
+ )}
145
+ <div className={contentClass}>
146
+ {!isOwnMessage && isFirstInGroup && (
147
+ <span className="ermis-message-list__item-user">{userName}</span>
148
+ )}
149
+ {/* Quoted message preview */}
150
+ {quotedMessage && onClickQuote && (
151
+ <QuotedMessagePreviewComponent
152
+ quotedMessage={quotedMessage}
153
+ isOwnMessage={isOwnMessage}
154
+ onClick={onClickQuote}
155
+ />
156
+ )}
157
+ <div className="ermis-message-list__bubble-wrapper">
158
+ <div style={!canReact ? { opacity: 0.5, pointerEvents: 'none' } : {}}>
159
+ <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} />
160
+ </div>
161
+ <MessageBubble message={message} isOwnMessage={isOwnMessage}>
162
+ {isForwarded && (
163
+ <span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
164
+ )}
165
+ <MessageRenderer message={message} isOwnMessage={isOwnMessage} />
166
+ <span className="ermis-message-list__item-time">
167
+ {isEdited && (
168
+ <span
169
+ className="ermis-message-list__edited-indicator"
170
+ // data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
171
+ >
172
+ {editedLabel}
173
+ </span>
174
+ )}
175
+ {formatTime(message.created_at)}
176
+ <InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
177
+ </span>
178
+ </MessageBubble>
179
+
180
+ {/* Actions: hover buttons + dropdown menu */}
181
+ {message.type !== 'system' && (
182
+ <MessageActionsBoxComponent
183
+ message={message}
184
+ isOwnMessage={isOwnMessage}
185
+ />
186
+ )}
187
+
188
+ {/* Message Reactions */}
189
+ {MessageReactionsComponent && (
190
+ <div style={!canReact ? { opacity: 0.8, pointerEvents: 'none' } : {}}>
191
+ <MessageReactionsComponent
192
+ reactionCounts={(message as any).reaction_counts}
193
+ ownReactions={(message as any).own_reactions}
194
+ latestReactions={(message as any).latest_reactions}
195
+ onClickReaction={handleReactionToggle}
196
+ />
197
+ </div>
198
+ )}
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ });
204
+ MessageItem.displayName = 'MessageItem';
205
+
206
+ /* ----------------------------------------------------------
207
+ SystemMessageItem — system/notification message row
208
+ ---------------------------------------------------------- */
209
+ export const SystemMessageItem: React.FC<SystemMessageItemProps> = React.memo(({
210
+ message,
211
+ isOwnMessage,
212
+ SystemRenderer,
213
+ }) => (
214
+ <div className="ermis-message-list__system">
215
+ <SystemRenderer message={message} isOwnMessage={isOwnMessage} />
216
+ </div>
217
+ ));
218
+ SystemMessageItem.displayName = 'SystemMessageItem';
@@ -0,0 +1,73 @@
1
+ import React, { useCallback } from 'react';
2
+ import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from '../hooks/useChatClient';
4
+
5
+ const QUICK_REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
6
+ const EMOJI_MAP: Record<string, string> = {
7
+ like: '👍',
8
+ love: '❤️',
9
+ haha: '😂',
10
+ sad: '😢',
11
+ fire: '🔥',
12
+ };
13
+
14
+ export const MessageQuickReactions: React.FC<{
15
+ message: FormatMessageResponse;
16
+ isOwnMessage: boolean;
17
+ }> = React.memo(({ message, isOwnMessage }) => {
18
+ const { activeChannel, client } = useChatClient();
19
+ const currentUserId = client?.userID;
20
+
21
+ const handleReactionToggle = useCallback(
22
+ async (type: string) => {
23
+ if (!activeChannel) return;
24
+ const isOwn =
25
+ (message as any).own_reactions?.some((r: any) => r.type === type) ||
26
+ (message as any).latest_reactions?.some(
27
+ (r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId)
28
+ );
29
+
30
+ try {
31
+ if (isOwn) {
32
+ await activeChannel.deleteReaction(message.id!, type);
33
+ } else {
34
+ await activeChannel.sendReaction(message.id!, type);
35
+ }
36
+ } catch (err) {
37
+ console.error('Failed to toggle reaction', err);
38
+ }
39
+ },
40
+ [activeChannel, message, currentUserId]
41
+ );
42
+
43
+ return (
44
+ <div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''}`}>
45
+ {QUICK_REACTIONS.map((type) => {
46
+ const isOwn =
47
+ (message as any).own_reactions?.some((r: any) => r.type === type) ||
48
+ (message as any).latest_reactions?.some(
49
+ (r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId)
50
+ );
51
+
52
+ return (
53
+ <button
54
+ key={type}
55
+ className={`ermis-message-quick-reactions__btn ${
56
+ isOwn ? 'ermis-message-quick-reactions__btn--active' : ''
57
+ }`}
58
+ title={type}
59
+ onClick={(e) => {
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+ handleReactionToggle(type);
63
+ }}
64
+ >
65
+ {EMOJI_MAP[type]}
66
+ </button>
67
+ );
68
+ })}
69
+ </div>
70
+ );
71
+ });
72
+
73
+ MessageQuickReactions.displayName = 'MessageQuickReactions';
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import type { MessageReactionsProps } from '../types';
3
+
4
+ import { useChatClient } from '../hooks/useChatClient';
5
+
6
+ const defaultReactionEmojiMap: Record<string, string> = {
7
+ like: '👍',
8
+ love: '❤️',
9
+ haha: '😂',
10
+ sad: '😢',
11
+ fire: '🔥',
12
+ };
13
+
14
+ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
15
+ reactionCounts,
16
+ ownReactions,
17
+ latestReactions,
18
+ onClickReaction,
19
+ }) => {
20
+ const { client } = useChatClient();
21
+ const currentUserId = client?.userID;
22
+
23
+ if (!reactionCounts || Object.keys(reactionCounts).length === 0) return null;
24
+
25
+ return (
26
+ <div className="ermis-message-reactions">
27
+ {Object.entries(reactionCounts).map(([type, count]) => {
28
+ const isOwn =
29
+ ownReactions?.some((r) => r.type === type) ||
30
+ latestReactions?.some((r) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId));
31
+
32
+ // Find users who reacted with this type for the tooltip
33
+ const userNames = latestReactions
34
+ ?.filter((r) => r.type === type)
35
+ .map((r: any) => r.user?.name || r.user?.id || r.user_id || 'Someone');
36
+
37
+ const tooltip = userNames && userNames.length > 0 ? userNames.join('\n') : type;
38
+ const emoji = defaultReactionEmojiMap[type] || type;
39
+
40
+ return (
41
+ <button
42
+ key={type}
43
+ className={`ermis-message-reactions__item ${
44
+ isOwn ? 'ermis-message-reactions__item--active' : ''
45
+ }`}
46
+ data-tooltip={tooltip}
47
+ onClick={() => onClickReaction?.(type)}
48
+ type="button"
49
+ >
50
+ <span className="ermis-message-reactions__emoji">{emoji}</span>
51
+ <span className="ermis-message-reactions__count">{count}</span>
52
+ </button>
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ });
58
+
59
+ MessageReactions.displayName = 'MessageReactions';