@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,55 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export const useChannelMembers = (channel: Channel | null | undefined) => {
|
|
5
|
+
const [memberUpdateCount, setMemberUpdateCount] = useState(0);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!channel) return;
|
|
9
|
+
const updateMembers = () => setMemberUpdateCount(c => c + 1);
|
|
10
|
+
|
|
11
|
+
const sub1 = channel.on('member.added', updateMembers);
|
|
12
|
+
const sub2 = channel.on('member.removed', updateMembers);
|
|
13
|
+
const sub3 = channel.on('member.updated', updateMembers);
|
|
14
|
+
const sub4 = channel.on('member.promoted', updateMembers);
|
|
15
|
+
const sub5 = channel.on('member.demoted', updateMembers);
|
|
16
|
+
const sub6 = channel.on('member.banned', updateMembers);
|
|
17
|
+
const sub7 = channel.on('member.unbanned', updateMembers);
|
|
18
|
+
const sub8 = channel.on('notification.invite_rejected', updateMembers);
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
sub1.unsubscribe();
|
|
22
|
+
sub2.unsubscribe();
|
|
23
|
+
sub3.unsubscribe();
|
|
24
|
+
sub4.unsubscribe();
|
|
25
|
+
sub5.unsubscribe();
|
|
26
|
+
sub6.unsubscribe();
|
|
27
|
+
sub7.unsubscribe();
|
|
28
|
+
sub8.unsubscribe();
|
|
29
|
+
};
|
|
30
|
+
}, [channel]);
|
|
31
|
+
|
|
32
|
+
const membersArray = useMemo(() => {
|
|
33
|
+
if (!channel?.state?.members) return [];
|
|
34
|
+
return Object.values(channel.state.members) as Array<Record<string, unknown>>;
|
|
35
|
+
}, [channel?.state?.members, memberUpdateCount]);
|
|
36
|
+
|
|
37
|
+
return { members: membersArray, memberUpdateCount };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const useChannelProfile = (channel: Channel | null | undefined) => {
|
|
41
|
+
const [channelUpdateCount, setChannelUpdateCount] = useState(0);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!channel) return;
|
|
45
|
+
const updateChannel = () => setChannelUpdateCount(c => c + 1);
|
|
46
|
+
const sub = channel.on('channel.updated', updateChannel);
|
|
47
|
+
return () => sub.unsubscribe();
|
|
48
|
+
}, [channel]);
|
|
49
|
+
|
|
50
|
+
const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channelUpdateCount]);
|
|
51
|
+
const channelImage = useMemo(() => channel?.data?.image as string | undefined, [channel?.data?.image, channelUpdateCount]);
|
|
52
|
+
const channelDescription = useMemo(() => channel?.data?.description as string | undefined, [channel?.data?.description, channelUpdateCount]);
|
|
53
|
+
|
|
54
|
+
return { channelName, channelImage, channelDescription };
|
|
55
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscribes to real-time events and keeps the channel list in sync:
|
|
7
|
+
*
|
|
8
|
+
* 1. `message.new` → moves channel to top, auto-calls `markRead()` if
|
|
9
|
+
* the channel is currently active
|
|
10
|
+
* 2. `message.read` → triggers re-render so the unread badge disappears
|
|
11
|
+
*
|
|
12
|
+
* The SDK already mutates `channel.state.latestMessages` and
|
|
13
|
+
* `channel.state.unreadCount` before our listener fires, so we only
|
|
14
|
+
* need to re-order / flush the React state.
|
|
15
|
+
*/
|
|
16
|
+
export function useChannelListUpdates(
|
|
17
|
+
channels: Channel[],
|
|
18
|
+
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>,
|
|
19
|
+
): void {
|
|
20
|
+
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
21
|
+
|
|
22
|
+
// Ref to always have the latest activeChannel without re-subscribing
|
|
23
|
+
const activeChannelRef = useRef(activeChannel);
|
|
24
|
+
activeChannelRef.current = activeChannel;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// --- message.new: re-sort + auto mark-read ---
|
|
28
|
+
const handleNewMessage = (event: Event) => {
|
|
29
|
+
const eventCid = event.cid;
|
|
30
|
+
if (!eventCid) return;
|
|
31
|
+
|
|
32
|
+
// If the new message is on the active channel and from someone else,
|
|
33
|
+
// mark it as read immediately so unreadCount resets to 0.
|
|
34
|
+
// Skip markRead if the current user is banned, blocked, or pending in that channel.
|
|
35
|
+
const active = activeChannelRef.current;
|
|
36
|
+
if (active?.cid === eventCid && event.user?.id !== client.userID) {
|
|
37
|
+
const isBannedInActive = Boolean(active.state?.membership?.banned);
|
|
38
|
+
const isBlockedInActive = active.type === 'messaging' && Boolean(active.state?.membership?.blocked);
|
|
39
|
+
const isPendingActive =
|
|
40
|
+
active.state?.membership?.channel_role === 'pending' || (active.state?.membership as Record<string, unknown>)?.role === 'pending';
|
|
41
|
+
|
|
42
|
+
if (!isBannedInActive && !isBlockedInActive && !isPendingActive) {
|
|
43
|
+
active.markRead().catch(() => {
|
|
44
|
+
// silently ignore mark-read errors
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setChannels((prev) => {
|
|
50
|
+
const idx = prev.findIndex((ch) => ch.cid === eventCid);
|
|
51
|
+
if (idx <= 0) {
|
|
52
|
+
// Already at top or not found — just create a new reference
|
|
53
|
+
return idx === 0 ? [...prev] : prev;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const channel = prev[idx];
|
|
57
|
+
|
|
58
|
+
// Don't move banned channels to the top
|
|
59
|
+
if (channel.state?.membership?.banned) {
|
|
60
|
+
return [...prev];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Move channel to the top
|
|
64
|
+
const updated = [...prev];
|
|
65
|
+
const [ch] = updated.splice(idx, 1);
|
|
66
|
+
updated.unshift(ch);
|
|
67
|
+
return updated;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// --- channel.deleted: remove from list and reset active ---
|
|
72
|
+
const handleChannelDeleted = (event: Event) => {
|
|
73
|
+
const eventCid = event.cid || event.channel?.cid;
|
|
74
|
+
if (!eventCid) return;
|
|
75
|
+
|
|
76
|
+
if (activeChannelRef.current?.cid === eventCid) {
|
|
77
|
+
setActiveChannel(null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setChannels((prev) => prev.filter((ch) => ch.cid !== eventCid));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// --- member.removed / notification.invite_rejected: remove from list if it's current user ---
|
|
84
|
+
const handleMemberRemoved = (event: Event) => {
|
|
85
|
+
const eventCid = event.cid || event.channel?.cid;
|
|
86
|
+
// channel_id is often used in notification.* events instead of .cid directly
|
|
87
|
+
const normalizedCid = eventCid
|
|
88
|
+
? eventCid
|
|
89
|
+
: (event as Record<string, unknown>).channel_id
|
|
90
|
+
? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}`
|
|
91
|
+
: undefined;
|
|
92
|
+
|
|
93
|
+
if (!normalizedCid) return;
|
|
94
|
+
|
|
95
|
+
const removedUserId = event.member?.user_id || event.member?.user?.id || event.user?.id;
|
|
96
|
+
|
|
97
|
+
// If the current user was removed or rejected the invite, remove the channel from their list
|
|
98
|
+
if (removedUserId === client.userID) {
|
|
99
|
+
if (activeChannelRef.current?.cid === normalizedCid) {
|
|
100
|
+
setActiveChannel(null);
|
|
101
|
+
}
|
|
102
|
+
setChannels((prev) => prev.filter((ch) => ch.cid !== normalizedCid));
|
|
103
|
+
}
|
|
104
|
+
// Note: We don't trigger a global global re-render here if someone else is removed.
|
|
105
|
+
// Individual ChannelRow components handle UI updates (e.g., via channel.updated or member.removed events locally).
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// --- channel.created: fetch channel details and prepend to list ---
|
|
109
|
+
const handleChannelCreated = async (event: Event, forceWatch: boolean = false) => {
|
|
110
|
+
const type = event.channel?.type || (event as Record<string, unknown>).channel_type;
|
|
111
|
+
const id = event.channel?.id || (event as Record<string, unknown>).channel_id;
|
|
112
|
+
const cid = event.channel?.cid || event.cid || `${type}:${id}`;
|
|
113
|
+
|
|
114
|
+
if (!type || !id) return;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Initialize or retrieve channel instance from SDK cache
|
|
118
|
+
const channelInstance = client.channel(type as string, id as string);
|
|
119
|
+
|
|
120
|
+
// If this is a member.added event (where event.member belongs to the current user),
|
|
121
|
+
// we optimistically inject the membership so it instantly jumps into pending invites!
|
|
122
|
+
// We DO NOT do this for channel.created, because in channel.created, event.member is the creator (owner).
|
|
123
|
+
if (!forceWatch && event.type === 'member.added' && event.member && channelInstance.state) {
|
|
124
|
+
channelInstance.state.membership = { ...channelInstance.state.membership, ...event.member } as unknown as Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If the caller requested an explicit api call (e.g. for channel.created)
|
|
128
|
+
if (forceWatch && !channelInstance.initialized) {
|
|
129
|
+
await channelInstance.watch().catch((err) => console.error('Failed to watch channel:', err));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setChannels((prev) => {
|
|
133
|
+
// Double check to prevent duplicates after async pause
|
|
134
|
+
if (prev.some((c) => c.cid === cid)) {
|
|
135
|
+
return prev;
|
|
136
|
+
}
|
|
137
|
+
return [channelInstance, ...prev];
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Loop wait for the core SDK to finish populating the local state from its own watch
|
|
141
|
+
if (!channelInstance.initialized) {
|
|
142
|
+
let attempts = 0;
|
|
143
|
+
const checkInitialized = setInterval(() => {
|
|
144
|
+
attempts++;
|
|
145
|
+
if (channelInstance.initialized || attempts > 60 /* 3s max */) {
|
|
146
|
+
clearInterval(checkInitialized);
|
|
147
|
+
if (channelInstance.initialized) {
|
|
148
|
+
// Force useMemo in ChannelList to recalculate classification (invite vs regular) just in case
|
|
149
|
+
setChannels((p) => [...p]);
|
|
150
|
+
// Manually synthesize a channel.updated event to bust the React.memo inside ChannelItem
|
|
151
|
+
const clientObj = channelInstance.getClient();
|
|
152
|
+
if ('dispatchEvent' in clientObj && typeof clientObj.dispatchEvent === 'function') {
|
|
153
|
+
(clientObj.dispatchEvent as Function)({
|
|
154
|
+
type: 'channel.updated',
|
|
155
|
+
cid: channelInstance.cid,
|
|
156
|
+
channel: channelInstance.data,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}, 50);
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error('Failed to watch newly created channel:', err);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// --- member.added / notification.added_to_channel: fetch if current user is added/invited ---
|
|
169
|
+
const handleMemberAdded = async (event: Event) => {
|
|
170
|
+
const addedUserId = event.member?.user_id || event.member?.user?.id;
|
|
171
|
+
// If the current user was invited or added, fetch the channel (NO API duplication)
|
|
172
|
+
if (addedUserId === client.userID) {
|
|
173
|
+
await handleChannelCreated(event, false);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// --- notification.invite_accepted: force re-grouping ---
|
|
178
|
+
const handleMemberUpdated = (event: Event) => {
|
|
179
|
+
const updatedUserId = event.member?.user_id || event.member?.user?.id || event.user?.id;
|
|
180
|
+
if (updatedUserId === client.userID) {
|
|
181
|
+
setChannels((prev) => {
|
|
182
|
+
// Defensively mutate the channel's membership before grouping logic runs
|
|
183
|
+
const eventCid =
|
|
184
|
+
event.cid ||
|
|
185
|
+
event.channel?.cid ||
|
|
186
|
+
((event as Record<string, unknown>).channel_id ? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}` : undefined);
|
|
187
|
+
|
|
188
|
+
if (eventCid && event.member) {
|
|
189
|
+
const targetChannel = prev.find((c) => c.cid === eventCid);
|
|
190
|
+
// We forcefully map the updated incoming member data into the static channel representation
|
|
191
|
+
if (targetChannel && targetChannel.state) {
|
|
192
|
+
targetChannel.state.membership = {
|
|
193
|
+
...targetChannel.state.membership,
|
|
194
|
+
...event.member,
|
|
195
|
+
} as unknown as Record<string, unknown>;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [...prev]; // Force react map to regenerate
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const sub1 = client.on('message.new', handleNewMessage);
|
|
205
|
+
const sub2 = client.on('channel.deleted', handleChannelDeleted);
|
|
206
|
+
const sub3 = client.on('member.removed', handleMemberRemoved);
|
|
207
|
+
const sub4 = client.on('channel.created', (event) => handleChannelCreated(event, true));
|
|
208
|
+
const sub5 = client.on('member.added', handleMemberAdded);
|
|
209
|
+
const sub6 = client.on('notification.added_to_channel', handleMemberAdded);
|
|
210
|
+
const sub7 = client.on('notification.invite_rejected', handleMemberRemoved);
|
|
211
|
+
const sub8 = client.on('notification.invite_accepted', handleMemberUpdated);
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
sub1.unsubscribe();
|
|
215
|
+
sub2.unsubscribe();
|
|
216
|
+
sub3.unsubscribe();
|
|
217
|
+
sub4.unsubscribe();
|
|
218
|
+
sub5.unsubscribe();
|
|
219
|
+
sub6.unsubscribe();
|
|
220
|
+
sub7.unsubscribe();
|
|
221
|
+
sub8.unsubscribe();
|
|
222
|
+
};
|
|
223
|
+
}, [client, setChannels, setActiveChannel]);
|
|
224
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect, useCallback } from 'react';
|
|
2
|
+
import type { Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
|
|
5
|
+
export type UseChannelMessagesOptions = {
|
|
6
|
+
scrollToBottom: (smooth: boolean) => void;
|
|
7
|
+
/** Shared guard ref — blocks scroll-triggered loads during channel switch */
|
|
8
|
+
jumpingRef: React.MutableRefObject<boolean>;
|
|
9
|
+
isAtBottomRef: React.MutableRefObject<boolean>;
|
|
10
|
+
/** Called to reset load-more state when channel switches */
|
|
11
|
+
onChannelSwitch?: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Schedule multiple scroll-to-bottom attempts with increasing delays.
|
|
16
|
+
* Handles content that changes height after initial render (images, embeds).
|
|
17
|
+
*/
|
|
18
|
+
const SCROLL_DELAYS = [50, 200, 500, 1000];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Subscribes to channel message events and handles:
|
|
22
|
+
* - message.new → sync + scroll to bottom
|
|
23
|
+
* - message.updated / message.deleted → sync only
|
|
24
|
+
* - Channel switch → reset state + scroll to bottom
|
|
25
|
+
*/
|
|
26
|
+
export function useChannelMessages({
|
|
27
|
+
scrollToBottom,
|
|
28
|
+
jumpingRef,
|
|
29
|
+
isAtBottomRef,
|
|
30
|
+
onChannelSwitch,
|
|
31
|
+
}: UseChannelMessagesOptions): void {
|
|
32
|
+
const { client, activeChannel, syncMessages, setReadState } = useChatClient();
|
|
33
|
+
|
|
34
|
+
const scheduleScrollToBottom = useCallback(
|
|
35
|
+
(smooth: boolean) => {
|
|
36
|
+
if (smooth) {
|
|
37
|
+
// Trigger smooth scroll exactly once, otherwise browsers will
|
|
38
|
+
// cancel the smooth animation if called multiple times in a row
|
|
39
|
+
setTimeout(() => scrollToBottom(true), 100);
|
|
40
|
+
} else {
|
|
41
|
+
SCROLL_DELAYS.forEach((delay) => {
|
|
42
|
+
setTimeout(() => scrollToBottom(false), delay);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[scrollToBottom],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!activeChannel) return;
|
|
51
|
+
|
|
52
|
+
// Reset state for the new channel
|
|
53
|
+
onChannelSwitch?.();
|
|
54
|
+
|
|
55
|
+
// Manually force isAtBottom to true because we are jumping to the bottom.
|
|
56
|
+
// jumpingRef blocks the resulting scroll event from updating isAtBottomRef,
|
|
57
|
+
// so if it was false in the previous channel, it would stay false!
|
|
58
|
+
isAtBottomRef.current = true;
|
|
59
|
+
|
|
60
|
+
// Block scroll triggers during channel-switch scroll
|
|
61
|
+
jumpingRef.current = true;
|
|
62
|
+
// Defer scroll outside React lifecycle to avoid virtua flushSync warning
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
scrollToBottom(false);
|
|
65
|
+
// Wait long enough for scrollToBottom's internal retries and the browser
|
|
66
|
+
// to execute the scroll event
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
jumpingRef.current = false;
|
|
69
|
+
}, 100);
|
|
70
|
+
}, 0);
|
|
71
|
+
|
|
72
|
+
const handleNewMessage = (event: Event) => {
|
|
73
|
+
// Capture scroll state BEFORE sync causes re-render
|
|
74
|
+
const wasAtBottom = isAtBottomRef.current;
|
|
75
|
+
|
|
76
|
+
syncMessages();
|
|
77
|
+
|
|
78
|
+
const isOwnMessage = event.message?.user?.id === client.userID || event.message?.user_id === client.userID;
|
|
79
|
+
|
|
80
|
+
if (isOwnMessage || wasAtBottom) {
|
|
81
|
+
scheduleScrollToBottom(true);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleMessageChange = (_event: Event) => {
|
|
86
|
+
syncMessages();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleMessageRead = (_event: Event) => {
|
|
90
|
+
// SDK already updated channel.state.read — sync into React state
|
|
91
|
+
setReadState({ ...activeChannel.state.read });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleUnblocked = (event: Event) => {
|
|
95
|
+
// If the current user's block status was updated (meaning we unblocked someone)
|
|
96
|
+
if (event.member?.user_id === client.userID) {
|
|
97
|
+
// Refetch latest messages to fill in any missed during the block period
|
|
98
|
+
activeChannel
|
|
99
|
+
.query({ messages: { limit: 30 } })
|
|
100
|
+
.then(() => {
|
|
101
|
+
syncMessages();
|
|
102
|
+
scheduleScrollToBottom(false);
|
|
103
|
+
const isPending =
|
|
104
|
+
activeChannel.state?.membership?.channel_role === 'pending' ||
|
|
105
|
+
(activeChannel.state?.membership as any)?.role === 'pending';
|
|
106
|
+
if (!isPending) {
|
|
107
|
+
activeChannel.markRead().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.catch((e) => console.error('Failed to sync messages after unblock', e));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleInviteAccepted = (event: Event) => {
|
|
115
|
+
// Make sure the accepted invite corresponds to the actively opened channel
|
|
116
|
+
const eventCid =
|
|
117
|
+
event.cid ||
|
|
118
|
+
event.channel?.cid ||
|
|
119
|
+
((event as any).channel_id ? `${(event as any).channel_type}:${(event as any).channel_id}` : undefined);
|
|
120
|
+
if (eventCid === activeChannel.cid) {
|
|
121
|
+
activeChannel
|
|
122
|
+
.query({ messages: { limit: 30 } })
|
|
123
|
+
.then(() => {
|
|
124
|
+
syncMessages();
|
|
125
|
+
scheduleScrollToBottom(false);
|
|
126
|
+
activeChannel.markRead().catch(() => {});
|
|
127
|
+
})
|
|
128
|
+
.catch((e) => console.error('Failed to sync messages after accepting invite', e));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const client = activeChannel.getClient();
|
|
133
|
+
const sub1 = activeChannel.on('message.new', handleNewMessage);
|
|
134
|
+
const sub2 = activeChannel.on('message.updated', handleMessageChange);
|
|
135
|
+
const sub3 = activeChannel.on('message.deleted', handleMessageChange);
|
|
136
|
+
const sub4 = activeChannel.on('message.pinned', handleMessageChange);
|
|
137
|
+
const sub5 = activeChannel.on('message.unpinned', handleMessageChange);
|
|
138
|
+
const sub6 = activeChannel.on('message.read', handleMessageRead);
|
|
139
|
+
const sub7 = activeChannel.on('message.deleted_for_me', handleMessageChange);
|
|
140
|
+
const sub8 = activeChannel.on('reaction.new', handleMessageChange);
|
|
141
|
+
const sub9 = activeChannel.on('reaction.deleted', handleMessageChange);
|
|
142
|
+
const sub10 = activeChannel.on('member.unblocked', handleUnblocked);
|
|
143
|
+
const sub11 = client.on('notification.invite_accepted', handleInviteAccepted);
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
sub1.unsubscribe();
|
|
147
|
+
sub2.unsubscribe();
|
|
148
|
+
sub3.unsubscribe();
|
|
149
|
+
sub4.unsubscribe();
|
|
150
|
+
sub5.unsubscribe();
|
|
151
|
+
sub6.unsubscribe();
|
|
152
|
+
sub7.unsubscribe();
|
|
153
|
+
sub8.unsubscribe();
|
|
154
|
+
sub9.unsubscribe();
|
|
155
|
+
sub10.unsubscribe();
|
|
156
|
+
sub11.unsubscribe();
|
|
157
|
+
};
|
|
158
|
+
}, [activeChannel, scrollToBottom, scheduleScrollToBottom, syncMessages, onChannelSwitch, setReadState]);
|
|
159
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom hook to abstract real-time row-level updates for a single channel.
|
|
6
|
+
* Manages the local unread count, last message preview, and banned status for the channel row in the list.
|
|
7
|
+
*/
|
|
8
|
+
export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
9
|
+
// Track banned state for the current user in this channel
|
|
10
|
+
const [isBannedInChannel, setIsBannedInChannel] = useState(() => Boolean(channel.state?.membership?.banned));
|
|
11
|
+
const [isBlockedInChannel, setIsBlockedInChannel] = useState(() => {
|
|
12
|
+
if (channel.type !== 'messaging') return false;
|
|
13
|
+
return Boolean(channel.state?.membership?.blocked);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Force re-render when messages, members, or read state changes
|
|
17
|
+
const [updateCount, setUpdateCount] = useState(0);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setIsBannedInChannel(Boolean(channel.state?.membership?.banned));
|
|
21
|
+
setIsBlockedInChannel(
|
|
22
|
+
channel.type === 'messaging' ? Boolean(channel.state?.membership?.blocked) : false
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const handleBanned = (event: any) => {
|
|
26
|
+
if (event.member?.user_id === currentUserId) {
|
|
27
|
+
setIsBannedInChannel(true);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const handleUnbanned = (event: any) => {
|
|
31
|
+
if (event.member?.user_id === currentUserId) {
|
|
32
|
+
setIsBannedInChannel(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleUpdate = () => setUpdateCount((c) => c + 1);
|
|
37
|
+
|
|
38
|
+
const sub1 = channel.on('member.banned', handleBanned);
|
|
39
|
+
const sub2 = channel.on('member.unbanned', handleUnbanned);
|
|
40
|
+
const sub3 = channel.on('message.new', handleUpdate);
|
|
41
|
+
const sub4 = channel.on('message.read', handleUpdate);
|
|
42
|
+
const sub5 = channel.on('message.updated', handleUpdate);
|
|
43
|
+
const sub6 = channel.on('message.deleted', handleUpdate);
|
|
44
|
+
const sub7 = channel.on('channel.updated', handleUpdate);
|
|
45
|
+
const sub8 = channel.on('member.added', handleUpdate);
|
|
46
|
+
const sub9 = channel.on('member.removed', handleUpdate);
|
|
47
|
+
|
|
48
|
+
// Blocked state (messaging channels only)
|
|
49
|
+
const handleBlocked = (event: any) => {
|
|
50
|
+
if (event.member?.user_id === currentUserId) {
|
|
51
|
+
setIsBlockedInChannel(true);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const handleUnblocked = (event: any) => {
|
|
55
|
+
if (event.member?.user_id === currentUserId) {
|
|
56
|
+
setIsBlockedInChannel(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const sub10 = channel.on('member.blocked', handleBlocked);
|
|
60
|
+
const sub11 = channel.on('member.unblocked', handleUnblocked);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
sub1.unsubscribe();
|
|
64
|
+
sub2.unsubscribe();
|
|
65
|
+
sub3.unsubscribe();
|
|
66
|
+
sub4.unsubscribe();
|
|
67
|
+
sub5.unsubscribe();
|
|
68
|
+
sub6.unsubscribe();
|
|
69
|
+
sub7.unsubscribe();
|
|
70
|
+
sub8.unsubscribe();
|
|
71
|
+
sub9.unsubscribe();
|
|
72
|
+
sub10.unsubscribe();
|
|
73
|
+
sub11.unsubscribe();
|
|
74
|
+
};
|
|
75
|
+
}, [channel, currentUserId]);
|
|
76
|
+
|
|
77
|
+
return { isBannedInChannel, isBlockedInChannel, updateCount };
|
|
78
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { ChatContext } from '../context/ChatProvider';
|
|
3
|
+
import type { ChatContextValue } from '../context/ChatProvider';
|
|
4
|
+
|
|
5
|
+
export const useChatClient = (): ChatContextValue => {
|
|
6
|
+
const ctx = useContext(ChatContext);
|
|
7
|
+
if (!ctx) {
|
|
8
|
+
throw new Error('useChatClient must be used within a ChatProvider');
|
|
9
|
+
}
|
|
10
|
+
return ctx;
|
|
11
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { moveCaretToEnd } from '../utils';
|
|
3
|
+
|
|
4
|
+
export type UseEmojiPickerOptions = {
|
|
5
|
+
editableRef: React.RefObject<HTMLDivElement | null>;
|
|
6
|
+
setHasContent: (value: boolean) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function useEmojiPicker({ editableRef, setHasContent }: UseEmojiPickerOptions) {
|
|
10
|
+
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
|
11
|
+
const savedRangeRef = useRef<Range | null>(null);
|
|
12
|
+
|
|
13
|
+
const handleEmojiSelect = useCallback((emoji: string) => {
|
|
14
|
+
const el = editableRef.current;
|
|
15
|
+
if (!el) return;
|
|
16
|
+
|
|
17
|
+
// Restore saved cursor position, or move to end
|
|
18
|
+
el.focus();
|
|
19
|
+
const sel = window.getSelection();
|
|
20
|
+
if (sel && savedRangeRef.current) {
|
|
21
|
+
sel.removeAllRanges();
|
|
22
|
+
sel.addRange(savedRangeRef.current);
|
|
23
|
+
savedRangeRef.current = null;
|
|
24
|
+
} else {
|
|
25
|
+
moveCaretToEnd(el);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
document.execCommand('insertText', false, emoji + ' ');
|
|
29
|
+
setHasContent(true);
|
|
30
|
+
setEmojiPickerOpen(false);
|
|
31
|
+
}, [editableRef, setHasContent]);
|
|
32
|
+
|
|
33
|
+
const handleEmojiClose = useCallback(() => {
|
|
34
|
+
setEmojiPickerOpen(false);
|
|
35
|
+
savedRangeRef.current = null;
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const toggleEmojiPicker = useCallback(() => {
|
|
39
|
+
// Save current cursor position before picker steals focus
|
|
40
|
+
const sel = window.getSelection();
|
|
41
|
+
if (sel && sel.rangeCount > 0) {
|
|
42
|
+
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
|
43
|
+
}
|
|
44
|
+
setEmojiPickerOpen((prev) => !prev);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
emojiPickerOpen,
|
|
49
|
+
handleEmojiSelect,
|
|
50
|
+
handleEmojiClose,
|
|
51
|
+
toggleEmojiPicker,
|
|
52
|
+
};
|
|
53
|
+
}
|