@ermis-network/ermis-chat-react 1.0.8 → 2.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 +15295 -4209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15246 -4186
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +137 -16
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +193 -10
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import type { ModalProps, DropdownProps, PanelProps, ForwardMessageModalProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export type ChatComponentsContextValue = {
|
|
5
|
+
ModalComponent?: React.ComponentType<ModalProps>;
|
|
6
|
+
DropdownComponent?: React.ComponentType<DropdownProps>;
|
|
7
|
+
PanelComponent?: React.ComponentType<PanelProps>;
|
|
8
|
+
ForwardMessageModalComponent?: React.ComponentType<ForwardMessageModalProps>;
|
|
9
|
+
ChannelListErrorIndicator?: React.ComponentType<{ text?: string; onRetry?: () => void }>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const ChatComponentsContext = createContext<ChatComponentsContextValue>({});
|
|
13
|
+
|
|
14
|
+
export const useChatComponents = () => useContext(ChatComponentsContext);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { createContext, useState, useCallback } from 'react';
|
|
1
|
+
import React, { createContext, useState, useCallback, useRef } from 'react';
|
|
2
2
|
import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
3
|
import type { Theme, ChatContextValue, ChatProviderProps, ReadStateEntry } from '../types';
|
|
4
4
|
import { ErmisCallProvider } from '../components/ErmisCallProvider';
|
|
5
5
|
import { ErmisCallUI } from '../components/ErmisCallUI';
|
|
6
|
+
import { ChatComponentsContext } from './ChatComponentsContext';
|
|
6
7
|
|
|
7
8
|
export type { Theme, ChatContextValue, ChatProviderProps } from '../types';
|
|
8
9
|
|
|
@@ -11,6 +12,7 @@ export const ChatContext = createContext<ChatContextValue | null>(null);
|
|
|
11
12
|
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
12
13
|
client,
|
|
13
14
|
children,
|
|
15
|
+
components = {},
|
|
14
16
|
initialTheme = 'light',
|
|
15
17
|
enableCall = false,
|
|
16
18
|
callSessionId,
|
|
@@ -36,18 +38,18 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
36
38
|
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
|
37
39
|
|
|
38
40
|
const activeChannel = activeChannelRaw;
|
|
41
|
+
const activeChannelCidRef = useRef<string | null>(null);
|
|
39
42
|
|
|
40
43
|
const setActiveChannel = useCallback((channel: Channel | null) => {
|
|
44
|
+
const newCid = channel?.cid || null;
|
|
45
|
+
if (activeChannelCidRef.current === newCid) return;
|
|
46
|
+
|
|
47
|
+
activeChannelCidRef.current = newCid;
|
|
41
48
|
setActiveChannelRaw(channel);
|
|
42
49
|
setQuotedMessage(null);
|
|
43
50
|
setEditingMessage(null);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
setReadState({ ...channel.state.read });
|
|
47
|
-
} else {
|
|
48
|
-
setMessages([]);
|
|
49
|
-
setReadState({});
|
|
50
|
-
}
|
|
51
|
+
setMessages([]);
|
|
52
|
+
setReadState({});
|
|
51
53
|
}, []);
|
|
52
54
|
|
|
53
55
|
/** Re-read messages from SDK state into React state */
|
|
@@ -87,12 +89,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
87
89
|
);
|
|
88
90
|
|
|
89
91
|
const content = (
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
<ChatComponentsContext.Provider value={components}>
|
|
93
|
+
<ChatContext.Provider value={value}>
|
|
94
|
+
<div className={`ermis-chat ermis-chat--${theme}`}>
|
|
95
|
+
{children}
|
|
96
|
+
{enableCall && CallUIView}
|
|
97
|
+
</div>
|
|
98
|
+
</ChatContext.Provider>
|
|
99
|
+
</ChatComponentsContext.Provider>
|
|
96
100
|
);
|
|
97
101
|
|
|
98
102
|
if (enableCall) {
|
|
@@ -32,6 +32,10 @@ export type CallContextValue = {
|
|
|
32
32
|
isRemoteVideoMuted: boolean;
|
|
33
33
|
upgradeCall: () => Promise<void>;
|
|
34
34
|
callDuration: number;
|
|
35
|
+
isAccepting: boolean;
|
|
36
|
+
isRejecting: boolean;
|
|
37
|
+
isEnding: boolean;
|
|
38
|
+
resetCall: () => void;
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
export const ErmisCallContext = React.createContext<CallContextValue | undefined>(undefined);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { usePreviewState } from './usePreviewState';
|
|
3
4
|
import { isGroupChannel } from '../channelTypeUtils';
|
|
4
5
|
import { canManageChannel, CHANNEL_ROLES } from '../channelRoleUtils';
|
|
5
6
|
|
|
@@ -19,18 +20,20 @@ export const useChannelCapabilities = () => {
|
|
|
19
20
|
}, [activeChannel]);
|
|
20
21
|
|
|
21
22
|
const currentUserId = client?.userID || '';
|
|
23
|
+
const { isPreviewMode } = usePreviewState(activeChannel, currentUserId);
|
|
22
24
|
const isGroupCh = isGroupChannel(activeChannel);
|
|
23
25
|
const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
|
|
24
26
|
|
|
25
|
-
const isOwner = role === CHANNEL_ROLES.OWNER || activeChannel?.data?.created_by_id === currentUserId;
|
|
26
|
-
const isModerator = role === CHANNEL_ROLES.MODERATOR;
|
|
27
|
-
const isOwnerOrModerator = isOwner || isModerator || canManageChannel(role);
|
|
27
|
+
const isOwner = isPreviewMode ? false : (role === CHANNEL_ROLES.OWNER || activeChannel?.data?.created_by_id === currentUserId);
|
|
28
|
+
const isModerator = isPreviewMode ? false : (role === CHANNEL_ROLES.MODERATOR);
|
|
29
|
+
const isOwnerOrModerator = isOwner || isModerator || (!isPreviewMode && canManageChannel(role));
|
|
28
30
|
|
|
29
31
|
const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
|
|
30
32
|
|
|
31
33
|
const hasCapability = useCallback((cap: string) => {
|
|
34
|
+
if (isPreviewMode) return false;
|
|
32
35
|
return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
|
|
33
|
-
}, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]);
|
|
36
|
+
}, [isGroupCh, isOwnerOrModerator, capabilities, updateTick, isPreviewMode]);
|
|
34
37
|
|
|
35
38
|
return {
|
|
36
39
|
isGroupChannel: isGroupCh,
|
|
@@ -43,13 +43,20 @@ export const useChannelProfile = (channel: Channel | null | undefined) => {
|
|
|
43
43
|
useEffect(() => {
|
|
44
44
|
if (!channel) return;
|
|
45
45
|
const updateChannel = () => setChannelUpdateCount(c => c + 1);
|
|
46
|
-
const
|
|
47
|
-
|
|
46
|
+
const sub1 = channel.on('channel.updated', updateChannel);
|
|
47
|
+
const sub2 = channel.on('channel.pinned', updateChannel);
|
|
48
|
+
const sub3 = channel.on('channel.unpinned', updateChannel);
|
|
49
|
+
return () => {
|
|
50
|
+
sub1.unsubscribe();
|
|
51
|
+
sub2.unsubscribe();
|
|
52
|
+
sub3.unsubscribe();
|
|
53
|
+
};
|
|
48
54
|
}, [channel]);
|
|
49
55
|
|
|
50
56
|
const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channel?.type, channelUpdateCount]);
|
|
51
57
|
const channelImage = useMemo(() => channel?.data?.image as string | undefined, [channel?.data?.image, channelUpdateCount]);
|
|
52
58
|
const channelDescription = useMemo(() => channel?.data?.description as string | undefined, [channel?.data?.description, channelUpdateCount]);
|
|
59
|
+
const isPinned = useMemo(() => channel?.data?.is_pinned === true, [channel?.data?.is_pinned, channelUpdateCount]);
|
|
53
60
|
|
|
54
|
-
return { channelName, channelImage, channelDescription };
|
|
61
|
+
return { channelName, channelImage, channelDescription, isPinned };
|
|
55
62
|
};
|
|
@@ -18,6 +18,7 @@ import { isPendingMember } from '../channelRoleUtils';
|
|
|
18
18
|
export function useChannelListUpdates(
|
|
19
19
|
channels: Channel[],
|
|
20
20
|
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>,
|
|
21
|
+
onOwnMessageNew?: () => void,
|
|
21
22
|
): void {
|
|
22
23
|
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
23
24
|
|
|
@@ -25,17 +26,23 @@ export function useChannelListUpdates(
|
|
|
25
26
|
const activeChannelRef = useRef(activeChannel);
|
|
26
27
|
activeChannelRef.current = activeChannel;
|
|
27
28
|
|
|
29
|
+
// Ref to always have the latest callback without re-subscribing
|
|
30
|
+
const onOwnMessageNewRef = useRef(onOwnMessageNew);
|
|
31
|
+
onOwnMessageNewRef.current = onOwnMessageNew;
|
|
32
|
+
|
|
28
33
|
useEffect(() => {
|
|
29
34
|
// --- message.new: re-sort + auto mark-read ---
|
|
30
35
|
const handleNewMessage = (event: Event) => {
|
|
31
36
|
const eventCid = event.cid;
|
|
32
37
|
if (!eventCid) return;
|
|
33
38
|
|
|
39
|
+
const isOwnMessage = event.user?.id === client.userID;
|
|
40
|
+
|
|
34
41
|
// If the new message is on the active channel and from someone else,
|
|
35
42
|
// mark it as read immediately so unreadCount resets to 0.
|
|
36
43
|
// Skip markRead if the current user is banned, blocked, or pending in that channel.
|
|
37
44
|
const active = activeChannelRef.current;
|
|
38
|
-
if (active?.cid === eventCid &&
|
|
45
|
+
if (active?.cid === eventCid && !isOwnMessage) {
|
|
39
46
|
const isBannedInActive = Boolean(active.state?.membership?.banned);
|
|
40
47
|
const isBlockedInActive = isDirectChannel(active) && Boolean(active.state?.membership?.blocked);
|
|
41
48
|
const isPendingActive = isPendingMember(active.state?.membership?.channel_role as string);
|
|
@@ -48,13 +55,23 @@ export function useChannelListUpdates(
|
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
setChannels((prev) => {
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
let targetIdx = prev.findIndex((ch) => ch.cid === eventCid);
|
|
59
|
+
|
|
60
|
+
// If not found directly, check if this is a topic message — move the parent channel to top
|
|
61
|
+
if (targetIdx < 0) {
|
|
62
|
+
const topicChannel = client.activeChannels[eventCid];
|
|
63
|
+
const parentCid = topicChannel?.data?.parent_cid as string | undefined;
|
|
64
|
+
if (parentCid) {
|
|
65
|
+
targetIdx = prev.findIndex((ch) => ch.cid === parentCid);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (targetIdx <= 0) {
|
|
53
70
|
// Already at top or not found — just create a new reference
|
|
54
|
-
return
|
|
71
|
+
return targetIdx === 0 ? [...prev] : prev;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
const channel = prev[
|
|
74
|
+
const channel = prev[targetIdx];
|
|
58
75
|
|
|
59
76
|
// Don't move banned channels to the top
|
|
60
77
|
if (channel.state?.membership?.banned) {
|
|
@@ -63,10 +80,17 @@ export function useChannelListUpdates(
|
|
|
63
80
|
|
|
64
81
|
// Move channel to the top
|
|
65
82
|
const updated = [...prev];
|
|
66
|
-
const [ch] = updated.splice(
|
|
83
|
+
const [ch] = updated.splice(targetIdx, 1);
|
|
67
84
|
updated.unshift(ch);
|
|
68
85
|
return updated;
|
|
69
86
|
});
|
|
87
|
+
|
|
88
|
+
// Notify the component layer that the current user sent a message
|
|
89
|
+
// so it can scroll to top. Use setTimeout(0) to ensure React has
|
|
90
|
+
// flushed the setChannels state update before the scroll fires.
|
|
91
|
+
if (isOwnMessage && onOwnMessageNewRef.current) {
|
|
92
|
+
setTimeout(() => onOwnMessageNewRef.current?.(), 0);
|
|
93
|
+
}
|
|
70
94
|
};
|
|
71
95
|
|
|
72
96
|
// --- channel.deleted: remove from list and reset active ---
|
|
@@ -178,32 +202,53 @@ export function useChannelListUpdates(
|
|
|
178
202
|
}
|
|
179
203
|
};
|
|
180
204
|
|
|
181
|
-
// --- notification.invite_accepted: force re-grouping ---
|
|
182
|
-
const handleMemberUpdated = (event: Event) => {
|
|
205
|
+
// --- notification.invite_accepted / member.joined: force re-grouping or add to list ---
|
|
206
|
+
const handleMemberUpdated = async (event: Event) => {
|
|
183
207
|
const updatedUserId = event.member?.user_id || event.member?.user?.id || event.user?.id;
|
|
184
208
|
if (updatedUserId === client.userID) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
event.
|
|
190
|
-
|
|
191
|
-
? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}`
|
|
192
|
-
: undefined);
|
|
209
|
+
const eventCid =
|
|
210
|
+
event.cid ||
|
|
211
|
+
event.channel?.cid ||
|
|
212
|
+
((event as Record<string, unknown>).channel_id
|
|
213
|
+
? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}`
|
|
214
|
+
: undefined);
|
|
193
215
|
|
|
216
|
+
setChannels((prev) => {
|
|
194
217
|
if (eventCid && event.member) {
|
|
195
218
|
const targetChannel = prev.find((c) => c.cid === eventCid);
|
|
196
|
-
// We forcefully map the updated incoming member data into the static channel representation
|
|
197
219
|
if (targetChannel && targetChannel.state) {
|
|
220
|
+
// Channel already in list — just update membership for re-grouping
|
|
198
221
|
targetChannel.state.membership = {
|
|
199
222
|
...targetChannel.state.membership,
|
|
200
223
|
...event.member,
|
|
201
224
|
} as unknown as Record<string, unknown>;
|
|
202
225
|
}
|
|
203
226
|
}
|
|
204
|
-
|
|
205
|
-
return [...prev]; // Force react map to regenerate
|
|
227
|
+
return [...prev];
|
|
206
228
|
});
|
|
229
|
+
|
|
230
|
+
// If the channel is NOT in the list yet (e.g. user just joined a public channel
|
|
231
|
+
// from search), add it — same logic as handleChannelCreated
|
|
232
|
+
if (eventCid) {
|
|
233
|
+
setChannels((prev) => {
|
|
234
|
+
if (prev.some((c) => c.cid === eventCid)) return prev; // already in list
|
|
235
|
+
const type = event.channel?.type || (event as Record<string, unknown>).channel_type;
|
|
236
|
+
const id = event.channel?.id || (event as Record<string, unknown>).channel_id;
|
|
237
|
+
if (!type || !id) return prev;
|
|
238
|
+
const channelInstance = client.channel(type as string, id as string);
|
|
239
|
+
if (channelInstance.state) {
|
|
240
|
+
channelInstance.state.membership = {
|
|
241
|
+
...channelInstance.state.membership,
|
|
242
|
+
...event.member,
|
|
243
|
+
} as unknown as Record<string, unknown>;
|
|
244
|
+
}
|
|
245
|
+
// Watch if not initialized so we get full state
|
|
246
|
+
if (!channelInstance.initialized) {
|
|
247
|
+
channelInstance.watch().catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
return [channelInstance, ...prev];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
207
252
|
}
|
|
208
253
|
};
|
|
209
254
|
|
|
@@ -215,7 +260,10 @@ export function useChannelListUpdates(
|
|
|
215
260
|
const sub1 = client.on('message.new', handleNewMessage);
|
|
216
261
|
const sub2 = client.on('channel.deleted', handleChannelDeleted);
|
|
217
262
|
const sub3 = client.on('member.removed', handleMemberRemoved);
|
|
218
|
-
const sub4 = client.on('channel.created', (event) =>
|
|
263
|
+
const sub4 = client.on('channel.created', (event) => {
|
|
264
|
+
const isCreator = event.user?.id === client.userID || event.user_id === client.userID;
|
|
265
|
+
handleChannelCreated(event, !isCreator);
|
|
266
|
+
});
|
|
219
267
|
const sub5 = client.on('member.added', handleMemberAdded);
|
|
220
268
|
const sub6 = client.on('notification.added_to_channel', handleMemberAdded);
|
|
221
269
|
const sub7 = client.on('notification.invite_rejected', handleMemberRemoved);
|
|
@@ -226,6 +274,9 @@ export function useChannelListUpdates(
|
|
|
226
274
|
const sub12 = client.on('channel.pinned', handleGenericUpdate);
|
|
227
275
|
const sub13 = client.on('channel.unpinned', handleGenericUpdate);
|
|
228
276
|
const sub14 = client.on('notification.invite_messaging_skipped', handleMemberUpdated);
|
|
277
|
+
// When a user joins a public channel (action='join'), the server sends member.joined
|
|
278
|
+
// instead of notification.invite_accepted — handle it to re-group the channel list
|
|
279
|
+
const sub15 = client.on('member.joined', handleMemberUpdated);
|
|
229
280
|
|
|
230
281
|
return () => {
|
|
231
282
|
sub1.unsubscribe();
|
|
@@ -242,6 +293,7 @@ export function useChannelListUpdates(
|
|
|
242
293
|
sub12.unsubscribe();
|
|
243
294
|
sub13.unsubscribe();
|
|
244
295
|
sub14.unsubscribe();
|
|
296
|
+
sub15.unsubscribe();
|
|
245
297
|
};
|
|
246
298
|
}, [client, setChannels, setActiveChannel]);
|
|
247
299
|
}
|
|
@@ -10,8 +10,16 @@ export type UseChannelMessagesOptions = {
|
|
|
10
10
|
isAtBottomRef: React.MutableRefObject<boolean>;
|
|
11
11
|
/** Called to reset load-more state when channel switches */
|
|
12
12
|
onChannelSwitch?: () => void;
|
|
13
|
+
/** Whether to include hidden (deleted) messages in the initial channel query */
|
|
14
|
+
includeHiddenMessages?: boolean;
|
|
15
|
+
/** Ref to the message list container for smooth opacity transitions */
|
|
16
|
+
containerRef?: React.RefObject<HTMLDivElement>;
|
|
13
17
|
};
|
|
14
18
|
|
|
19
|
+
// Track channels that have already been queried with include_hidden_messages globally for the session
|
|
20
|
+
const fullyQueriedChannels = new Set<string>();
|
|
21
|
+
export const markChannelAsFullyQueried = (cid: string) => fullyQueriedChannels.add(cid);
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* Schedule multiple scroll-to-bottom attempts with increasing delays.
|
|
17
25
|
* Handles content that changes height after initial render (images, embeds).
|
|
@@ -29,6 +37,8 @@ export function useChannelMessages({
|
|
|
29
37
|
jumpingRef,
|
|
30
38
|
isAtBottomRef,
|
|
31
39
|
onChannelSwitch,
|
|
40
|
+
includeHiddenMessages = true,
|
|
41
|
+
containerRef,
|
|
32
42
|
}: UseChannelMessagesOptions): void {
|
|
33
43
|
const { client, activeChannel, syncMessages, setReadState } = useChatClient();
|
|
34
44
|
|
|
@@ -60,15 +70,58 @@ export function useChannelMessages({
|
|
|
60
70
|
|
|
61
71
|
// Block scroll triggers during channel-switch scroll
|
|
62
72
|
jumpingRef.current = true;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
|
|
74
|
+
// Instantly hide the list when channel changes
|
|
75
|
+
const el = containerRef?.current;
|
|
76
|
+
if (el) {
|
|
77
|
+
el.style.opacity = '0';
|
|
78
|
+
el.style.transition = 'none';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fadeListIn = () => {
|
|
82
|
+
if (!el) return;
|
|
83
|
+
// Allow virtua a brief moment to measure items after scroll before showing
|
|
68
84
|
setTimeout(() => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
el.style.transition = 'opacity 0.1s ease-out';
|
|
86
|
+
el.style.opacity = '1';
|
|
87
|
+
}, 50);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Fetch hidden messages if not already done for this channel
|
|
91
|
+
const cid = activeChannel.cid;
|
|
92
|
+
if (includeHiddenMessages && cid && !fullyQueriedChannels.has(cid)) {
|
|
93
|
+
activeChannel
|
|
94
|
+
.query({
|
|
95
|
+
messages: { limit: 25, include_hidden_messages: true },
|
|
96
|
+
})
|
|
97
|
+
.then(() => {
|
|
98
|
+
fullyQueriedChannels.add(cid);
|
|
99
|
+
syncMessages();
|
|
100
|
+
// Sync initial read state from SDK so read receipts show immediately
|
|
101
|
+
setReadState({ ...activeChannel.state.read });
|
|
102
|
+
scheduleScrollToBottom(false);
|
|
103
|
+
fadeListIn(); // Fade in AFTER query finishes and sync is called
|
|
104
|
+
})
|
|
105
|
+
.catch((err: any) => {
|
|
106
|
+
console.error('Failed to query channel on select', err);
|
|
107
|
+
fadeListIn(); // Fade in anyway on error
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
// Already queried or disabled: sync cache, scroll and fade in quickly
|
|
111
|
+
syncMessages();
|
|
112
|
+
// Sync initial read state from SDK so read receipts show immediately
|
|
113
|
+
setReadState({ ...activeChannel.state.read });
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
scheduleScrollToBottom(false);
|
|
116
|
+
fadeListIn();
|
|
117
|
+
}, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Wait long enough for scrollToBottom's internal retries and the browser
|
|
121
|
+
// to execute the scroll event
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
jumpingRef.current = false;
|
|
124
|
+
}, 100);
|
|
72
125
|
|
|
73
126
|
const handleNewMessage = (event: Event) => {
|
|
74
127
|
// Capture scroll state BEFORE sync causes re-render
|
|
@@ -106,7 +159,7 @@ export function useChannelMessages({
|
|
|
106
159
|
activeChannel.markRead().catch(() => {});
|
|
107
160
|
}
|
|
108
161
|
})
|
|
109
|
-
.catch((e) => console.error('Failed to sync messages after unblock', e));
|
|
162
|
+
.catch((e: any) => console.error('Failed to sync messages after unblock', e));
|
|
110
163
|
}
|
|
111
164
|
};
|
|
112
165
|
|
|
@@ -124,10 +177,15 @@ export function useChannelMessages({
|
|
|
124
177
|
scheduleScrollToBottom(false);
|
|
125
178
|
activeChannel.markRead().catch(() => {});
|
|
126
179
|
})
|
|
127
|
-
.catch((e) => console.error('Failed to sync messages after accepting invite', e));
|
|
180
|
+
.catch((e: any) => console.error('Failed to sync messages after accepting invite', e));
|
|
128
181
|
}
|
|
129
182
|
};
|
|
130
183
|
|
|
184
|
+
const handleRecovery = () => {
|
|
185
|
+
syncMessages();
|
|
186
|
+
scheduleScrollToBottom(false);
|
|
187
|
+
};
|
|
188
|
+
|
|
131
189
|
const client = activeChannel.getClient();
|
|
132
190
|
const sub1 = activeChannel.on('message.new', handleNewMessage);
|
|
133
191
|
const sub2 = activeChannel.on('message.updated', handleMessageChange);
|
|
@@ -140,6 +198,8 @@ export function useChannelMessages({
|
|
|
140
198
|
const sub9 = activeChannel.on('reaction.deleted', handleMessageChange);
|
|
141
199
|
const sub10 = activeChannel.on('member.unblocked', handleUnblocked);
|
|
142
200
|
const sub11 = client.on('notification.invite_accepted', handleInviteAccepted);
|
|
201
|
+
const sub12 = client.on('connection.recovered', handleRecovery);
|
|
202
|
+
const sub13 = client.on('channels.queried', handleRecovery);
|
|
143
203
|
|
|
144
204
|
return () => {
|
|
145
205
|
sub1.unsubscribe();
|
|
@@ -153,6 +213,8 @@ export function useChannelMessages({
|
|
|
153
213
|
sub9.unsubscribe();
|
|
154
214
|
sub10.unsubscribe();
|
|
155
215
|
sub11.unsubscribe();
|
|
216
|
+
sub12.unsubscribe();
|
|
217
|
+
sub13.unsubscribe();
|
|
156
218
|
};
|
|
157
219
|
}, [activeChannel, scrollToBottom, scheduleScrollToBottom, syncMessages, onChannelSwitch, setReadState]);
|
|
158
220
|
}
|
|
@@ -18,10 +18,17 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
18
18
|
const [updateCount, setUpdateCount] = useState(0);
|
|
19
19
|
|
|
20
20
|
useEffect(() => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
21
|
+
const parentCid = channel.data?.parent_cid as string | undefined;
|
|
22
|
+
const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
|
|
23
|
+
|
|
24
|
+
const computeIsBanned = () => {
|
|
25
|
+
const selfBanned = Boolean(channel.state?.membership?.banned);
|
|
26
|
+
const parentBanned = Boolean(parentChannel?.state?.membership?.banned);
|
|
27
|
+
return selfBanned || parentBanned;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
setIsBannedInChannel(computeIsBanned());
|
|
31
|
+
setIsBlockedInChannel(isDirectChannel(channel) ? Boolean(channel.state?.membership?.blocked) : false);
|
|
25
32
|
|
|
26
33
|
const handleBanned = (event: any) => {
|
|
27
34
|
if (event.member?.user_id === currentUserId) {
|
|
@@ -30,7 +37,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
30
37
|
};
|
|
31
38
|
const handleUnbanned = (event: any) => {
|
|
32
39
|
if (event.member?.user_id === currentUserId) {
|
|
33
|
-
setIsBannedInChannel(
|
|
40
|
+
setIsBannedInChannel(computeIsBanned());
|
|
34
41
|
}
|
|
35
42
|
};
|
|
36
43
|
|
|
@@ -42,6 +49,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
42
49
|
const sub4 = channel.on('message.read', handleUpdate);
|
|
43
50
|
const sub5 = channel.on('message.updated', handleUpdate);
|
|
44
51
|
const sub6 = channel.on('message.deleted', handleUpdate);
|
|
52
|
+
const sub6_me = channel.on('message.deleted_for_me', handleUpdate);
|
|
45
53
|
const sub7 = channel.on('channel.updated', handleUpdate);
|
|
46
54
|
const sub8 = channel.on('member.added', handleUpdate);
|
|
47
55
|
const sub9 = channel.on('member.removed', handleUpdate);
|
|
@@ -63,6 +71,14 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
63
71
|
const sub13 = channel.on('channel.pinned', handleUpdate);
|
|
64
72
|
const sub14 = channel.on('channel.unpinned', handleUpdate);
|
|
65
73
|
|
|
74
|
+
// Topic support: listen for ban events on parent channel too
|
|
75
|
+
let sub15: { unsubscribe: () => void } | undefined;
|
|
76
|
+
let sub16: { unsubscribe: () => void } | undefined;
|
|
77
|
+
if (parentChannel) {
|
|
78
|
+
sub15 = parentChannel.on('member.banned', handleBanned);
|
|
79
|
+
sub16 = parentChannel.on('member.unbanned', handleUnbanned);
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
return () => {
|
|
67
83
|
sub1.unsubscribe();
|
|
68
84
|
sub2.unsubscribe();
|
|
@@ -70,6 +86,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
70
86
|
sub4.unsubscribe();
|
|
71
87
|
sub5.unsubscribe();
|
|
72
88
|
sub6.unsubscribe();
|
|
89
|
+
sub6_me.unsubscribe();
|
|
73
90
|
sub7.unsubscribe();
|
|
74
91
|
sub8.unsubscribe();
|
|
75
92
|
sub9.unsubscribe();
|
|
@@ -78,6 +95,8 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
78
95
|
sub12.unsubscribe();
|
|
79
96
|
sub13.unsubscribe();
|
|
80
97
|
sub14.unsubscribe();
|
|
98
|
+
if (sub15) sub15.unsubscribe();
|
|
99
|
+
if (sub16) sub16.unsubscribe();
|
|
81
100
|
};
|
|
82
101
|
}, [channel, currentUserId]);
|
|
83
102
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
import type { UserResponse, ExtendableGenerics, DefaultGenerics } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
|
|
5
|
+
export const useChatUser = <ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics>() => {
|
|
6
|
+
const { client } = useChatClient();
|
|
7
|
+
const [user, setUser] = useState<UserResponse<ErmisChatGenerics> | undefined>(client?.user);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!client) return;
|
|
11
|
+
|
|
12
|
+
// Set initial user in case it changed before the effect runs
|
|
13
|
+
setUser(client.user);
|
|
14
|
+
|
|
15
|
+
const handleUserUpdated = (event: any) => {
|
|
16
|
+
if (event.me) {
|
|
17
|
+
setUser((prev) => ({ ...prev, ...event.me }));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const listener = client.on('user.updated', handleUserUpdated);
|
|
22
|
+
const healthListener = client.on('health.check', handleUserUpdated);
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
listener.unsubscribe();
|
|
26
|
+
healthListener.unsubscribe();
|
|
27
|
+
};
|
|
28
|
+
}, [client]);
|
|
29
|
+
|
|
30
|
+
return { user };
|
|
31
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
5
|
+
import { isOwnerMember } from '../channelRoleUtils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A hook that retrieves all friend (contact) channels from the SDK's local cache
|
|
9
|
+
* without triggering an extra API network query.
|
|
10
|
+
*
|
|
11
|
+
* A contact is defined as a direct (1-1) channel where both members
|
|
12
|
+
* hold the 'owner' channel_role.
|
|
13
|
+
*
|
|
14
|
+
* Re-renders automatically when related events arrive.
|
|
15
|
+
*/
|
|
16
|
+
export function useContactChannels(): Channel[] {
|
|
17
|
+
const { client } = useChatClient();
|
|
18
|
+
const [updateCount, setUpdateCount] = useState(0);
|
|
19
|
+
|
|
20
|
+
const forceUpdate = useCallback(() => setUpdateCount((c) => c + 1), []);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!client) return;
|
|
24
|
+
|
|
25
|
+
const listeners = [
|
|
26
|
+
client.on('channels.queried', forceUpdate),
|
|
27
|
+
client.on('notification.invite_accepted', forceUpdate),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
return () => listeners.forEach((l) => l.unsubscribe());
|
|
31
|
+
}, [client, forceUpdate]);
|
|
32
|
+
|
|
33
|
+
return useMemo(() => {
|
|
34
|
+
if (!client) return [];
|
|
35
|
+
|
|
36
|
+
return Object.values(client.activeChannels).filter((channel) => {
|
|
37
|
+
if (!isDirectChannel(channel)) return false;
|
|
38
|
+
|
|
39
|
+
const members = Object.values(channel.state?.members || {});
|
|
40
|
+
if (members.length !== 2) return false;
|
|
41
|
+
|
|
42
|
+
return members.every((m) => isOwnerMember(m.channel_role as string));
|
|
43
|
+
});
|
|
44
|
+
}, [client, updateCount]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
4
|
+
import { isOwnerMember } from '../channelRoleUtils';
|
|
5
|
+
|
|
6
|
+
export const useContactCount = () => {
|
|
7
|
+
const { client } = useChatClient();
|
|
8
|
+
const [contactCount, setContactCount] = useState(0);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!client || !client.user) return;
|
|
12
|
+
|
|
13
|
+
const countContacts = () => {
|
|
14
|
+
let count = 0;
|
|
15
|
+
const channels = Object.values(client.activeChannels);
|
|
16
|
+
for (const channel of channels) {
|
|
17
|
+
if (!isDirectChannel(channel)) continue;
|
|
18
|
+
|
|
19
|
+
const members = Object.values(channel.state?.members || {});
|
|
20
|
+
// Contacts are direct channels where both members are owners
|
|
21
|
+
if (members.length === 2) {
|
|
22
|
+
const isAllOwners = members.every((m) => isOwnerMember(m.channel_role as string));
|
|
23
|
+
if (isAllOwners) count++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return count;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Calculate initial count
|
|
30
|
+
setContactCount(countContacts());
|
|
31
|
+
|
|
32
|
+
const handleEvent = () => {
|
|
33
|
+
// Delay slightly to ensure client.activeChannels is updated by SDK internal handlers first
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
setContactCount(countContacts());
|
|
36
|
+
}, 0);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const listeners = [
|
|
40
|
+
client.on('channels.queried', handleEvent),
|
|
41
|
+
client.on('notification.invite_accepted', handleEvent),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.forEach((l) => l.unsubscribe());
|
|
46
|
+
};
|
|
47
|
+
}, [client]);
|
|
48
|
+
|
|
49
|
+
return { contactCount };
|
|
50
|
+
};
|