@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,164 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { buildAttachmentPayload } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import type { FilePreviewItem } from '../types';
|
|
5
|
+
|
|
6
|
+
export type UseMessageSendOptions = {
|
|
7
|
+
activeChannel: Channel | null;
|
|
8
|
+
editableRef: React.RefObject<HTMLDivElement | null>;
|
|
9
|
+
files: FilePreviewItem[];
|
|
10
|
+
setFiles: React.Dispatch<React.SetStateAction<FilePreviewItem[]>>;
|
|
11
|
+
hasContent: boolean;
|
|
12
|
+
setHasContent: (value: boolean) => void;
|
|
13
|
+
isTeamChannel: boolean;
|
|
14
|
+
buildPayload: () => { text: string; mentioned_all: boolean; mentioned_users: string[] };
|
|
15
|
+
reset: () => void;
|
|
16
|
+
syncMessages: () => void;
|
|
17
|
+
onSend?: (text: string) => void;
|
|
18
|
+
onBeforeSend?: (text: string, attachments: FilePreviewItem[]) => boolean | Promise<boolean>;
|
|
19
|
+
/** Message being replied to */
|
|
20
|
+
quotedMessage?: FormatMessageResponse | null;
|
|
21
|
+
/** Clear quoted message after send */
|
|
22
|
+
clearQuotedMessage?: () => void;
|
|
23
|
+
/** Message being edited */
|
|
24
|
+
editingMessage?: FormatMessageResponse | null;
|
|
25
|
+
/** Clear edited message after send */
|
|
26
|
+
clearEditingMessage?: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function useMessageSend({
|
|
30
|
+
activeChannel,
|
|
31
|
+
editableRef,
|
|
32
|
+
files,
|
|
33
|
+
setFiles,
|
|
34
|
+
hasContent,
|
|
35
|
+
setHasContent,
|
|
36
|
+
isTeamChannel,
|
|
37
|
+
buildPayload,
|
|
38
|
+
reset,
|
|
39
|
+
syncMessages,
|
|
40
|
+
onSend,
|
|
41
|
+
onBeforeSend,
|
|
42
|
+
quotedMessage,
|
|
43
|
+
clearQuotedMessage,
|
|
44
|
+
editingMessage,
|
|
45
|
+
clearEditingMessage,
|
|
46
|
+
}: UseMessageSendOptions) {
|
|
47
|
+
const [sending, setSending] = useState(false);
|
|
48
|
+
const isProcessingRef = useRef(false);
|
|
49
|
+
|
|
50
|
+
const handleSend = useCallback(async () => {
|
|
51
|
+
if (!activeChannel || !hasContent || sending || isProcessingRef.current) return;
|
|
52
|
+
|
|
53
|
+
// Wait for all files to finish uploading
|
|
54
|
+
const stillUploading = files.some((f) => f.status === 'uploading');
|
|
55
|
+
if (stillUploading) return;
|
|
56
|
+
|
|
57
|
+
isProcessingRef.current = true;
|
|
58
|
+
|
|
59
|
+
const payload = buildPayload();
|
|
60
|
+
const text = payload.text.trim();
|
|
61
|
+
const uploadedFiles = files.filter((f) => f.status === 'done');
|
|
62
|
+
|
|
63
|
+
if (!text && uploadedFiles.length === 0) return;
|
|
64
|
+
|
|
65
|
+
// onBeforeSend hook — return false to cancel
|
|
66
|
+
if (onBeforeSend) {
|
|
67
|
+
const proceed = await onBeforeSend(text, uploadedFiles);
|
|
68
|
+
if (!proceed) {
|
|
69
|
+
isProcessingRef.current = false;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
setSending(true);
|
|
76
|
+
|
|
77
|
+
// Build attachment payloads from already-uploaded files (only applied on new messages)
|
|
78
|
+
const attachments = uploadedFiles.map((f) => {
|
|
79
|
+
if (f.originalAttachment) {
|
|
80
|
+
return f.originalAttachment;
|
|
81
|
+
}
|
|
82
|
+
const fileObj = f.normalizedFile || f.file!;
|
|
83
|
+
return buildAttachmentPayload(fileObj, f.uploadedUrl!, f.thumbUrl);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Build message
|
|
87
|
+
const message: Record<string, any> = { text };
|
|
88
|
+
|
|
89
|
+
// The API does not accept attachment arrays during standard text editing
|
|
90
|
+
if (!editingMessage && attachments.length > 0) {
|
|
91
|
+
message.attachments = attachments;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isTeamChannel) {
|
|
95
|
+
message.mentioned_all = payload.mentioned_all;
|
|
96
|
+
message.mentioned_users = payload.mentioned_users;
|
|
97
|
+
}
|
|
98
|
+
let sendPromise;
|
|
99
|
+
|
|
100
|
+
if (editingMessage?.id) {
|
|
101
|
+
sendPromise = activeChannel.editMessage(editingMessage.id, message as any);
|
|
102
|
+
} else {
|
|
103
|
+
if (quotedMessage?.id) {
|
|
104
|
+
message.quoted_message_id = quotedMessage.id;
|
|
105
|
+
}
|
|
106
|
+
sendPromise = activeChannel.sendMessage(message as any);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- 0. OPTIMISTIC UI UPDATE ---
|
|
110
|
+
// Instantly injects the `status: 'sending'` message scaffold from SDK into the React map
|
|
111
|
+
syncMessages();
|
|
112
|
+
|
|
113
|
+
// --- 1. CLEAR UI IMMEDIATELY (FIRE AND FORGET) ---
|
|
114
|
+
// Clear successful files
|
|
115
|
+
files.forEach((f) => {
|
|
116
|
+
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const errorFiles = files.filter((f) => f.status === 'error');
|
|
120
|
+
setFiles(errorFiles);
|
|
121
|
+
setHasContent(errorFiles.length > 0);
|
|
122
|
+
|
|
123
|
+
reset();
|
|
124
|
+
clearQuotedMessage?.();
|
|
125
|
+
clearEditingMessage?.();
|
|
126
|
+
onSend?.(payload.text);
|
|
127
|
+
// Stop typing indicator immediately on send
|
|
128
|
+
activeChannel?.stopTyping();
|
|
129
|
+
|
|
130
|
+
// --- 2. DELEGATE TO WEBSOCKET ---
|
|
131
|
+
// The API call runs in background. We do not block the UI for resolution.
|
|
132
|
+
// Message lists will automatically update when the backend blasts the `message.new` WS event.
|
|
133
|
+
sendPromise.catch((err: Error) => {
|
|
134
|
+
console.error('Failed to send message over API:', err);
|
|
135
|
+
// Sync React to render the SDK's internal 'status: failed' UI state
|
|
136
|
+
syncMessages();
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error('Failed to process message send:', err);
|
|
140
|
+
} finally {
|
|
141
|
+
isProcessingRef.current = false;
|
|
142
|
+
setSending(false);
|
|
143
|
+
requestAnimationFrame(() => {
|
|
144
|
+
editableRef.current?.focus();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}, [
|
|
148
|
+
activeChannel,
|
|
149
|
+
hasContent,
|
|
150
|
+
sending,
|
|
151
|
+
buildPayload,
|
|
152
|
+
reset,
|
|
153
|
+
onSend,
|
|
154
|
+
isTeamChannel,
|
|
155
|
+
files,
|
|
156
|
+
onBeforeSend,
|
|
157
|
+
syncMessages,
|
|
158
|
+
editableRef,
|
|
159
|
+
setFiles,
|
|
160
|
+
setHasContent,
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
return { sending, handleSend };
|
|
164
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook that tracks whether the current user is in a 'pending' state for the given channel.
|
|
6
|
+
*/
|
|
7
|
+
export function usePendingState(channel: Channel | null | undefined, currentUserId?: string) {
|
|
8
|
+
const [isPending, setIsPending] = useState<boolean>(() => {
|
|
9
|
+
const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId || ''];
|
|
10
|
+
return membership?.channel_role === 'pending' || (membership as Record<string, unknown>)?.role === 'pending';
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!channel || !currentUserId) {
|
|
15
|
+
setIsPending(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const checkPending = () => {
|
|
20
|
+
const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
|
|
21
|
+
return membership?.channel_role === 'pending' || (membership as Record<string, unknown>)?.role === 'pending';
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Sync initial state
|
|
25
|
+
setIsPending(checkPending());
|
|
26
|
+
|
|
27
|
+
const defensiveUpdateState = (event: Record<string, unknown>) => {
|
|
28
|
+
// The SDK does not aggressively mutate local state for all events,
|
|
29
|
+
// so we manually map the incoming `member` data onto the channel state so `checkPending` sees it.
|
|
30
|
+
if (event.member && channel.state && channel.state.membership) {
|
|
31
|
+
channel.state.membership = {
|
|
32
|
+
...channel.state.membership,
|
|
33
|
+
...(event.member as Record<string, unknown>),
|
|
34
|
+
} as unknown as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleInviteAction = (event: Record<string, unknown>) => {
|
|
39
|
+
const eventMember = event.member as Record<string, unknown>;
|
|
40
|
+
const eventUser = event.user as Record<string, unknown>;
|
|
41
|
+
const eventUserId = eventMember?.user_id || (eventMember?.user as Record<string, unknown>)?.id || eventUser?.id;
|
|
42
|
+
if (eventUserId !== currentUserId) return; // Only react to own invite events
|
|
43
|
+
|
|
44
|
+
const eventCid =
|
|
45
|
+
event.cid || (event.channel as Record<string, unknown>)?.cid || (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
|
|
46
|
+
if (eventCid === channel.cid) {
|
|
47
|
+
defensiveUpdateState(event);
|
|
48
|
+
setIsPending(checkPending());
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const client = channel.getClient();
|
|
53
|
+
const sub1 = client.on('notification.invite_accepted', handleInviteAction);
|
|
54
|
+
const sub2 = client.on('notification.invite_rejected', handleInviteAction);
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
sub1.unsubscribe();
|
|
58
|
+
sub2.unsubscribe();
|
|
59
|
+
};
|
|
60
|
+
}, [channel, currentUserId]);
|
|
61
|
+
|
|
62
|
+
return { isPending };
|
|
63
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { formatMessage } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import type { VListHandle } from 'virtua';
|
|
5
|
+
import { dedupMessages } from './useLoadMessages';
|
|
6
|
+
import { useChatClient } from './useChatClient';
|
|
7
|
+
|
|
8
|
+
export type UseScrollToMessageOptions = {
|
|
9
|
+
vlistRef: React.RefObject<VListHandle | null>;
|
|
10
|
+
messagesRef: React.MutableRefObject<FormatMessageResponse[]>;
|
|
11
|
+
setHasMore: React.Dispatch<React.SetStateAction<boolean>>;
|
|
12
|
+
setHasNewer: React.Dispatch<React.SetStateAction<boolean>>;
|
|
13
|
+
/** Getter to access the VList DOM element (scoped to container) */
|
|
14
|
+
getVListElement: () => HTMLElement | null;
|
|
15
|
+
scrollToBottom: (smooth: boolean) => void;
|
|
16
|
+
/** Shared guard ref — blocks scroll-triggered loads during jumps */
|
|
17
|
+
jumpingRef: React.MutableRefObject<boolean>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type UseScrollToMessageReturn = {
|
|
21
|
+
highlightedId: string | null;
|
|
22
|
+
scrollToMessage: (messageId: string) => void;
|
|
23
|
+
jumpToLatest: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useScrollToMessage({
|
|
27
|
+
vlistRef,
|
|
28
|
+
messagesRef,
|
|
29
|
+
setHasMore,
|
|
30
|
+
setHasNewer,
|
|
31
|
+
getVListElement,
|
|
32
|
+
scrollToBottom,
|
|
33
|
+
jumpingRef,
|
|
34
|
+
}: UseScrollToMessageOptions): UseScrollToMessageReturn {
|
|
35
|
+
const { activeChannel, setMessages } = useChatClient();
|
|
36
|
+
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
37
|
+
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
38
|
+
|
|
39
|
+
// Cleanup highlight timer on unmount
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
return () => {
|
|
42
|
+
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const highlight = useCallback((messageId: string) => {
|
|
47
|
+
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
48
|
+
setHighlightedId(messageId);
|
|
49
|
+
highlightTimerRef.current = setTimeout(() => {
|
|
50
|
+
setHighlightedId(null);
|
|
51
|
+
highlightTimerRef.current = null;
|
|
52
|
+
}, 2500);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const scrollToMessage = useCallback(
|
|
56
|
+
async (messageId: string) => {
|
|
57
|
+
// Prevent concurrent calls
|
|
58
|
+
if (jumpingRef.current) return;
|
|
59
|
+
|
|
60
|
+
// Case 1: message is already in current list
|
|
61
|
+
const idx = messagesRef.current.findIndex((m) => m.id === messageId);
|
|
62
|
+
if (idx !== -1) {
|
|
63
|
+
vlistRef.current?.scrollToIndex(idx, { align: 'center', smooth: true });
|
|
64
|
+
highlight(messageId);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Case 2: message NOT in list — fetch around it
|
|
69
|
+
if (!activeChannel) return;
|
|
70
|
+
|
|
71
|
+
jumpingRef.current = true;
|
|
72
|
+
|
|
73
|
+
const vlistEl = getVListElement();
|
|
74
|
+
if (vlistEl) {
|
|
75
|
+
vlistEl.style.transition = 'opacity 150ms ease-out';
|
|
76
|
+
vlistEl.style.opacity = '0';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const rawMessages = await activeChannel.queryMessagesAroundId(messageId, 25);
|
|
81
|
+
if (!rawMessages || rawMessages.length === 0) {
|
|
82
|
+
jumpingRef.current = false;
|
|
83
|
+
if (vlistEl) vlistEl.style.opacity = '1';
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const formatted = rawMessages.map((msg: any) => formatMessage(msg));
|
|
88
|
+
const unique = dedupMessages(formatted);
|
|
89
|
+
|
|
90
|
+
setHasMore(true);
|
|
91
|
+
setHasNewer(true);
|
|
92
|
+
setMessages(unique);
|
|
93
|
+
|
|
94
|
+
// Wait for VList to render, then jump while hidden, then fade in
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
const newIdx = unique.findIndex((m: any) => m.id === messageId);
|
|
97
|
+
if (newIdx === -1) {
|
|
98
|
+
jumpingRef.current = false;
|
|
99
|
+
if (vlistEl) vlistEl.style.opacity = '1';
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
vlistRef.current?.scrollToIndex(newIdx, { align: 'center' });
|
|
104
|
+
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
if (vlistEl) {
|
|
107
|
+
vlistEl.style.transition = 'opacity 200ms ease-in';
|
|
108
|
+
vlistEl.style.opacity = '1';
|
|
109
|
+
}
|
|
110
|
+
highlight(messageId);
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
jumpingRef.current = false;
|
|
113
|
+
}, 500);
|
|
114
|
+
}, 100);
|
|
115
|
+
}, 200);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('Failed to fetch messages around ID:', err);
|
|
118
|
+
jumpingRef.current = false;
|
|
119
|
+
if (vlistEl) vlistEl.style.opacity = '1';
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
[activeChannel, highlight, setMessages, setHasMore, setHasNewer, getVListElement],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const jumpToLatest = useCallback(() => {
|
|
126
|
+
if (!activeChannel) return;
|
|
127
|
+
jumpingRef.current = true;
|
|
128
|
+
|
|
129
|
+
const vlistEl = getVListElement();
|
|
130
|
+
if (vlistEl) {
|
|
131
|
+
vlistEl.style.transition = 'opacity 150ms ease-out';
|
|
132
|
+
vlistEl.style.opacity = '0';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const latestMsgs = [...activeChannel.state.latestMessages];
|
|
136
|
+
setMessages(latestMsgs);
|
|
137
|
+
setHasNewer(false);
|
|
138
|
+
setHasMore(true);
|
|
139
|
+
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
scrollToBottom(false);
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
if (vlistEl) {
|
|
144
|
+
vlistEl.style.transition = 'opacity 200ms ease-in';
|
|
145
|
+
vlistEl.style.opacity = '1';
|
|
146
|
+
}
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
jumpingRef.current = false;
|
|
149
|
+
}, 500);
|
|
150
|
+
}, 100);
|
|
151
|
+
}, 200);
|
|
152
|
+
}, [activeChannel, scrollToBottom, getVListElement, setMessages, setHasMore, setHasNewer]);
|
|
153
|
+
|
|
154
|
+
return { highlightedId, scrollToMessage, jumpToLatest };
|
|
155
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import type { Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
|
|
5
|
+
export type TypingUser = {
|
|
6
|
+
id: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hook that subscribes to typing events on the active channel
|
|
12
|
+
* and returns the list of currently‑typing users (excluding the current user).
|
|
13
|
+
*
|
|
14
|
+
* Stale entries are auto‑cleaned every 7 seconds, consistent with
|
|
15
|
+
* the SDK's `channel.state.clean()` behaviour.
|
|
16
|
+
*/
|
|
17
|
+
export function useTypingIndicator() {
|
|
18
|
+
const { activeChannel, client } = useChatClient();
|
|
19
|
+
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
|
|
20
|
+
const currentUserId = client.userID;
|
|
21
|
+
|
|
22
|
+
// Keep a mutable map so event handlers can read/write without
|
|
23
|
+
// creating stale‑closure issues.
|
|
24
|
+
const typingMapRef = useRef<Map<string, { user: TypingUser; timestamp: number }>>(new Map());
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!activeChannel) {
|
|
28
|
+
setTypingUsers([]);
|
|
29
|
+
typingMapRef.current.clear();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Reset when channel switches
|
|
34
|
+
typingMapRef.current.clear();
|
|
35
|
+
setTypingUsers([]);
|
|
36
|
+
|
|
37
|
+
const syncState = () => {
|
|
38
|
+
const users = Array.from(typingMapRef.current.values()).map((v) => v.user);
|
|
39
|
+
setTypingUsers(users);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleTypingStart = (event: Event) => {
|
|
43
|
+
const userId = event.user?.id;
|
|
44
|
+
if (!userId || userId === currentUserId) return;
|
|
45
|
+
|
|
46
|
+
typingMapRef.current.set(userId, {
|
|
47
|
+
user: { id: userId, name: event.user?.name },
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
});
|
|
50
|
+
syncState();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleTypingStop = (event: Event) => {
|
|
54
|
+
const userId = event.user?.id;
|
|
55
|
+
if (!userId) return;
|
|
56
|
+
|
|
57
|
+
typingMapRef.current.delete(userId);
|
|
58
|
+
syncState();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const sub1 = activeChannel.on('typing.start', handleTypingStart);
|
|
62
|
+
const sub2 = activeChannel.on('typing.stop', handleTypingStop);
|
|
63
|
+
|
|
64
|
+
// Auto‑clean stale entries every 7 seconds
|
|
65
|
+
const cleanupInterval = setInterval(() => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
let changed = false;
|
|
68
|
+
for (const [uid, entry] of typingMapRef.current.entries()) {
|
|
69
|
+
if (now - entry.timestamp > 7000) {
|
|
70
|
+
typingMapRef.current.delete(uid);
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (changed) syncState();
|
|
75
|
+
}, 3000);
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
sub1.unsubscribe();
|
|
79
|
+
sub2.unsubscribe();
|
|
80
|
+
clearInterval(cleanupInterval);
|
|
81
|
+
typingMapRef.current.clear();
|
|
82
|
+
};
|
|
83
|
+
}, [activeChannel, currentUserId]);
|
|
84
|
+
|
|
85
|
+
return { typingUsers };
|
|
86
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Styles
|
|
2
|
+
import './styles/index.css';
|
|
3
|
+
|
|
4
|
+
// Context
|
|
5
|
+
export { ChatProvider } from './context/ChatProvider';
|
|
6
|
+
export type { ChatProviderProps, ChatContextValue, Theme } from './context/ChatProvider';
|
|
7
|
+
|
|
8
|
+
// Hooks
|
|
9
|
+
export { useChatClient } from './hooks/useChatClient';
|
|
10
|
+
export { useChannel } from './hooks/useChannel';
|
|
11
|
+
export type { UseChannelReturn } from './hooks/useChannel';
|
|
12
|
+
export { useChannelListUpdates } from './hooks/useChannelListUpdates';
|
|
13
|
+
export { useChannelRowUpdates } from './hooks/useChannelRowUpdates';
|
|
14
|
+
export { useBannedState } from './hooks/useBannedState';
|
|
15
|
+
export { useBlockedState } from './hooks/useBlockedState';
|
|
16
|
+
export { usePendingState } from './hooks/usePendingState';
|
|
17
|
+
|
|
18
|
+
// Components
|
|
19
|
+
export { Avatar } from './components/Avatar';
|
|
20
|
+
export type { AvatarProps } from './components/Avatar';
|
|
21
|
+
|
|
22
|
+
export { ChannelList, ChannelItem } from './components/ChannelList';
|
|
23
|
+
export type { ChannelListProps, ChannelItemProps } from './components/ChannelList';
|
|
24
|
+
|
|
25
|
+
export { Channel } from './components/Channel';
|
|
26
|
+
export type { ChannelProps } from './components/Channel';
|
|
27
|
+
|
|
28
|
+
export { ChannelHeader } from './components/ChannelHeader';
|
|
29
|
+
export type { ChannelHeaderProps } from './components/ChannelHeader';
|
|
30
|
+
export type { ChannelHeaderData } from './types';
|
|
31
|
+
|
|
32
|
+
export type { MessageListProps, MessageBubbleProps, MessageItemProps, SystemMessageItemProps, DateSeparatorProps, JumpToLatestProps } from './types';
|
|
33
|
+
|
|
34
|
+
export { VirtualMessageList } from './components/VirtualMessageList';
|
|
35
|
+
|
|
36
|
+
export { PinnedMessages } from './components/PinnedMessages';
|
|
37
|
+
export type { PinnedMessagesProps, PinnedMessageItemProps } from './types';
|
|
38
|
+
|
|
39
|
+
export { MessageItem, SystemMessageItem } from './components/MessageItem';
|
|
40
|
+
export { MessageActionsBox } from './components/MessageActionsBox';
|
|
41
|
+
export type { MessageActionsBoxProps } from './types';
|
|
42
|
+
|
|
43
|
+
export { Dropdown, closeAllDropdowns } from './components/Dropdown';
|
|
44
|
+
export type { DropdownProps } from './components/Dropdown';
|
|
45
|
+
|
|
46
|
+
export { MessageReactions } from './components/MessageReactions';
|
|
47
|
+
export type { MessageReactionsProps, ReactionUser, LatestReaction } from './types';
|
|
48
|
+
|
|
49
|
+
export { MessageQuickReactions } from './components/MessageQuickReactions';
|
|
50
|
+
|
|
51
|
+
export { useMessageActions } from './hooks/useMessageActions';
|
|
52
|
+
|
|
53
|
+
export { formatTime, getDateKey, formatDateLabel, getMessageUserId, replaceMentionsForPreview } from './utils';
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
defaultMessageRenderers,
|
|
57
|
+
RegularMessage,
|
|
58
|
+
SystemMessage,
|
|
59
|
+
SignalMessage,
|
|
60
|
+
PollMessage,
|
|
61
|
+
StickerMessage,
|
|
62
|
+
ErrorMessage,
|
|
63
|
+
AttachmentList,
|
|
64
|
+
MessageAttachment,
|
|
65
|
+
} from './components/MessageRenderers';
|
|
66
|
+
export type { MessageRendererProps, AttachmentProps } from './components/MessageRenderers';
|
|
67
|
+
|
|
68
|
+
export { MessageInput } from './components/MessageInput';
|
|
69
|
+
export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from './components/MessageInput';
|
|
70
|
+
|
|
71
|
+
export { FilesPreview } from './components/FilesPreview';
|
|
72
|
+
export type { FilePreviewItem, FilesPreviewProps } from './components/FilesPreview';
|
|
73
|
+
|
|
74
|
+
export { MentionSuggestions } from './components/MentionSuggestions';
|
|
75
|
+
export type { MentionSuggestionsProps } from './components/MentionSuggestions';
|
|
76
|
+
|
|
77
|
+
export { useMentions } from './hooks/useMentions';
|
|
78
|
+
export type { MentionMember, MentionPayload, UseMentionsOptions, UseMentionsReturn } from './hooks/useMentions';
|
|
79
|
+
|
|
80
|
+
export { useScrollToMessage } from './hooks/useScrollToMessage';
|
|
81
|
+
export type { UseScrollToMessageOptions, UseScrollToMessageReturn } from './hooks/useScrollToMessage';
|
|
82
|
+
|
|
83
|
+
export { useLoadMessages, dedupMessages } from './hooks/useLoadMessages';
|
|
84
|
+
export type { UseLoadMessagesOptions, UseLoadMessagesReturn } from './hooks/useLoadMessages';
|
|
85
|
+
|
|
86
|
+
export { useChannelMessages } from './hooks/useChannelMessages';
|
|
87
|
+
export type { UseChannelMessagesOptions } from './hooks/useChannelMessages';
|
|
88
|
+
|
|
89
|
+
export { QuotedMessagePreview } from './components/QuotedMessagePreview';
|
|
90
|
+
export type { QuotedMessagePreviewProps } from './components/QuotedMessagePreview';
|
|
91
|
+
export { ReplyPreview } from './components/ReplyPreview';
|
|
92
|
+
export type { ReplyPreviewProps } from './types';
|
|
93
|
+
|
|
94
|
+
export { ForwardMessageModal } from './components/ForwardMessageModal';
|
|
95
|
+
export type { ForwardMessageModalProps, ForwardChannelItemProps } from './components/ForwardMessageModal';
|
|
96
|
+
|
|
97
|
+
export { TypingIndicator } from './components/TypingIndicator';
|
|
98
|
+
export type { TypingIndicatorProps } from './components/TypingIndicator';
|
|
99
|
+
export { useTypingIndicator } from './hooks/useTypingIndicator';
|
|
100
|
+
export type { TypingUser } from './hooks/useTypingIndicator';
|
|
101
|
+
|
|
102
|
+
export {
|
|
103
|
+
ChannelInfo,
|
|
104
|
+
DefaultChannelInfoHeader,
|
|
105
|
+
DefaultChannelInfoCover,
|
|
106
|
+
DefaultChannelInfoActions,
|
|
107
|
+
DefaultChannelInfoTabs
|
|
108
|
+
} from './components/ChannelInfo';
|
|
109
|
+
|
|
110
|
+
export { Modal } from './components/Modal';
|
|
111
|
+
export { Panel } from './components/Panel';
|
|
112
|
+
export type {
|
|
113
|
+
ChannelInfoProps,
|
|
114
|
+
ChannelInfoHeaderProps,
|
|
115
|
+
ChannelInfoCoverProps,
|
|
116
|
+
ChannelInfoActionsProps,
|
|
117
|
+
ChannelInfoTabsProps,
|
|
118
|
+
ChannelInfoMemberItemProps,
|
|
119
|
+
ChannelInfoMediaItemProps,
|
|
120
|
+
ChannelInfoLinkItemProps,
|
|
121
|
+
ChannelInfoFileItemProps,
|
|
122
|
+
ChannelInfoEmptyStateProps,
|
|
123
|
+
AttachmentItem,
|
|
124
|
+
MediaTab,
|
|
125
|
+
ModalProps,
|
|
126
|
+
AddMemberModalProps,
|
|
127
|
+
AddMemberUserItemProps,
|
|
128
|
+
AddMemberButtonProps,
|
|
129
|
+
} from './types';
|