@ermis-network/ermis-chat-react 1.0.7 → 1.0.9
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 +2787 -1858
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +364 -8
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +160 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.mjs +2787 -1890
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/ChannelActions.tsx +13 -11
- package/src/components/ChannelHeader.tsx +89 -4
- package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +59 -14
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +14 -11
- package/src/components/MessageItem.tsx +2 -1
- package/src/components/MessageRenderers.tsx +168 -46
- package/src/components/PendingOverlay.tsx +11 -1
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +91 -7
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -12
- package/src/hooks/useChannelListUpdates.ts +6 -4
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +3 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +61 -9
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-list.css +59 -0
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +99 -8
- package/src/styles/_message-list.css +25 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +46 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { isGroupChannel } from '../channelTypeUtils';
|
|
4
|
+
import { canManageChannel, CHANNEL_ROLES } from '../channelRoleUtils';
|
|
3
5
|
|
|
4
6
|
export const useChannelCapabilities = () => {
|
|
5
7
|
const { activeChannel, client } = useChatClient();
|
|
@@ -17,25 +19,21 @@ export const useChannelCapabilities = () => {
|
|
|
17
19
|
}, [activeChannel]);
|
|
18
20
|
|
|
19
21
|
const currentUserId = client?.userID || '';
|
|
20
|
-
const
|
|
21
|
-
const isMeetingChannel = activeChannel?.type === 'meeting';
|
|
22
|
-
const isTeamOrMeetingChannel = isTeamChannel || isMeetingChannel;
|
|
22
|
+
const isGroupCh = isGroupChannel(activeChannel);
|
|
23
23
|
const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
|
|
24
24
|
|
|
25
|
-
const isOwner = role ===
|
|
26
|
-
const isModerator = role ===
|
|
27
|
-
const isOwnerOrModerator = isOwner || isModerator;
|
|
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);
|
|
28
28
|
|
|
29
|
-
const capabilities: string[] =
|
|
29
|
+
const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
|
|
30
30
|
|
|
31
31
|
const hasCapability = useCallback((cap: string) => {
|
|
32
|
-
return !
|
|
33
|
-
}, [
|
|
32
|
+
return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
|
|
33
|
+
}, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
|
|
34
34
|
|
|
35
35
|
return {
|
|
36
|
-
|
|
37
|
-
isMeetingChannel,
|
|
38
|
-
isTeamOrMeetingChannel,
|
|
36
|
+
isGroupChannel: isGroupCh,
|
|
39
37
|
isOwner,
|
|
40
38
|
isModerator,
|
|
41
39
|
isOwnerOrModerator,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
3
|
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
5
|
+
import { isPendingMember } from '../channelRoleUtils';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Subscribes to real-time events and keeps the channel list in sync:
|
|
@@ -35,10 +37,8 @@ export function useChannelListUpdates(
|
|
|
35
37
|
const active = activeChannelRef.current;
|
|
36
38
|
if (active?.cid === eventCid && event.user?.id !== client.userID) {
|
|
37
39
|
const isBannedInActive = Boolean(active.state?.membership?.banned);
|
|
38
|
-
const isBlockedInActive = active
|
|
39
|
-
const isPendingActive =
|
|
40
|
-
active.state?.membership?.channel_role === 'pending' ||
|
|
41
|
-
(active.state?.membership as Record<string, unknown>)?.role === 'pending';
|
|
40
|
+
const isBlockedInActive = isDirectChannel(active) && Boolean(active.state?.membership?.blocked);
|
|
41
|
+
const isPendingActive = isPendingMember(active.state?.membership?.channel_role as string);
|
|
42
42
|
|
|
43
43
|
if (!isBannedInActive && !isBlockedInActive && !isPendingActive) {
|
|
44
44
|
active.markRead().catch(() => {
|
|
@@ -225,6 +225,7 @@ export function useChannelListUpdates(
|
|
|
225
225
|
const sub11 = client.on('channel.topic.created', handleGenericUpdate);
|
|
226
226
|
const sub12 = client.on('channel.pinned', handleGenericUpdate);
|
|
227
227
|
const sub13 = client.on('channel.unpinned', handleGenericUpdate);
|
|
228
|
+
const sub14 = client.on('notification.invite_messaging_skipped', handleMemberUpdated);
|
|
228
229
|
|
|
229
230
|
return () => {
|
|
230
231
|
sub1.unsubscribe();
|
|
@@ -240,6 +241,7 @@ export function useChannelListUpdates(
|
|
|
240
241
|
sub11.unsubscribe();
|
|
241
242
|
sub12.unsubscribe();
|
|
242
243
|
sub13.unsubscribe();
|
|
244
|
+
sub14.unsubscribe();
|
|
243
245
|
};
|
|
244
246
|
}, [client, setChannels, setActiveChannel]);
|
|
245
247
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useCallback } from 'react';
|
|
2
2
|
import type { Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
3
|
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isPendingMember } from '../channelRoleUtils';
|
|
4
5
|
|
|
5
6
|
export type UseChannelMessagesOptions = {
|
|
6
7
|
scrollToBottom: (smooth: boolean) => void;
|
|
@@ -100,9 +101,7 @@ export function useChannelMessages({
|
|
|
100
101
|
.then(() => {
|
|
101
102
|
syncMessages();
|
|
102
103
|
scheduleScrollToBottom(false);
|
|
103
|
-
const isPending =
|
|
104
|
-
activeChannel.state?.membership?.channel_role === 'pending' ||
|
|
105
|
-
(activeChannel.state?.membership as any)?.role === 'pending';
|
|
104
|
+
const isPending = isPendingMember(activeChannel.state?.membership?.channel_role as string);
|
|
106
105
|
if (!isPending) {
|
|
107
106
|
activeChannel.markRead().catch(() => {});
|
|
108
107
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Custom hook to abstract real-time row-level updates for a single channel.
|
|
@@ -9,7 +10,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
9
10
|
// Track banned state for the current user in this channel
|
|
10
11
|
const [isBannedInChannel, setIsBannedInChannel] = useState(() => Boolean(channel.state?.membership?.banned));
|
|
11
12
|
const [isBlockedInChannel, setIsBlockedInChannel] = useState(() => {
|
|
12
|
-
if (channel
|
|
13
|
+
if (!isDirectChannel(channel)) return false;
|
|
13
14
|
return Boolean(channel.state?.membership?.blocked);
|
|
14
15
|
});
|
|
15
16
|
|
|
@@ -19,7 +20,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
|
|
|
19
20
|
useEffect(() => {
|
|
20
21
|
setIsBannedInChannel(Boolean(channel.state?.membership?.banned));
|
|
21
22
|
setIsBlockedInChannel(
|
|
22
|
-
channel
|
|
23
|
+
isDirectChannel(channel) ? Boolean(channel.state?.membership?.blocked) : false
|
|
23
24
|
);
|
|
24
25
|
|
|
25
26
|
const handleBanned = (event: any) => {
|
|
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|
|
2
2
|
import { useChatClient } from './useChatClient';
|
|
3
3
|
import { useChannelCapabilities } from './useChannelCapabilities';
|
|
4
4
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
import { isSignalMessage, isSystemMessage } from '../messageTypeUtils';
|
|
5
6
|
|
|
6
7
|
export type MessageActionList = {
|
|
7
8
|
canEdit: boolean;
|
|
@@ -23,7 +24,7 @@ export type MessageActionList = {
|
|
|
23
24
|
|
|
24
25
|
export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
|
|
25
26
|
const { activeChannel, client } = useChatClient();
|
|
26
|
-
const {
|
|
27
|
+
const { isGroupChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
|
|
27
28
|
|
|
28
29
|
// Only depend on the specific message fields we actually read
|
|
29
30
|
const messageType = message.type;
|
|
@@ -50,18 +51,18 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const isSystem =
|
|
54
|
-
const isSignal =
|
|
54
|
+
const isSystem = isSystemMessage(message);
|
|
55
|
+
const isSignal = isSignalMessage(message);
|
|
55
56
|
const isPinned = isPinnedFlag;
|
|
56
57
|
|
|
57
58
|
const canEdit = !isSystem && !isSignal && isOwnMessage;
|
|
58
|
-
|
|
59
|
+
|
|
59
60
|
// Delete for everyone:
|
|
60
61
|
// + Team channel: only the owner can perform this action natively.
|
|
61
62
|
// + Messaging channel: only own messages can be deleted
|
|
62
63
|
const canDeleteForEveryoneTeam = isTeam && isOwner;
|
|
63
64
|
const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
|
|
64
|
-
|
|
65
|
+
|
|
65
66
|
const canDelete = !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
|
|
66
67
|
const canDeleteForMe = !isSystem;
|
|
67
68
|
const canReply = !isSystem && !isSignal;
|
|
@@ -74,14 +75,27 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
74
75
|
const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
|
|
75
76
|
// Apply the delete-own-message capability to the "delete for me" action for own messages
|
|
76
77
|
const hasCapDeleteForMe = !isTeam || isOwner || !isOwnMessage || hasCapability('delete-own-message');
|
|
77
|
-
|
|
78
|
+
|
|
78
79
|
const hasCapReply = hasCapability('send-reply');
|
|
79
80
|
const hasCapQuote = hasCapability('quote-message');
|
|
80
81
|
const hasCapPin = hasCapability('pin-message');
|
|
81
82
|
|
|
82
|
-
return {
|
|
83
|
-
canEdit,
|
|
84
|
-
|
|
83
|
+
return {
|
|
84
|
+
canEdit,
|
|
85
|
+
canDelete,
|
|
86
|
+
canDeleteForMe,
|
|
87
|
+
canReply,
|
|
88
|
+
canQuote,
|
|
89
|
+
canForward,
|
|
90
|
+
canPin,
|
|
91
|
+
canCopy,
|
|
92
|
+
isPinned,
|
|
93
|
+
hasCapEdit,
|
|
94
|
+
hasCapDelete,
|
|
95
|
+
hasCapDeleteForMe,
|
|
96
|
+
hasCapPin,
|
|
97
|
+
hasCapReply,
|
|
98
|
+
hasCapQuote,
|
|
85
99
|
};
|
|
86
100
|
}, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage]); // Use capabilities from hook
|
|
87
101
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isFriendChannel } from '../channelRoleUtils';
|
|
5
|
+
|
|
6
|
+
export type OnlineStatus = 'online' | 'offline' | 'unknown';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook that returns the online/offline status of a specific user.
|
|
10
|
+
*
|
|
11
|
+
* The status is determined by checking `channel.state.watchers` on the
|
|
12
|
+
* "friend" channel (direct channel where both members have `owner` role).
|
|
13
|
+
* Real-time updates are received via `user.watching.start` and
|
|
14
|
+
* `user.watching.stop` WebSocket events on that channel.
|
|
15
|
+
*
|
|
16
|
+
* Returns `'unknown'` if the user is not a friend (no qualifying channel found).
|
|
17
|
+
*
|
|
18
|
+
* @param userId – The user ID to check the online status of.
|
|
19
|
+
* @param channels – The full list of loaded channels (from ChannelList).
|
|
20
|
+
*/
|
|
21
|
+
export function useOnlineStatus(
|
|
22
|
+
userId: string | undefined,
|
|
23
|
+
channels: Channel[],
|
|
24
|
+
): OnlineStatus {
|
|
25
|
+
const { client } = useChatClient();
|
|
26
|
+
const currentUserId = client.userID;
|
|
27
|
+
|
|
28
|
+
// Find the friend channel for this user — memoized to avoid re-scans.
|
|
29
|
+
const friendChannel = useMemo(() => {
|
|
30
|
+
if (!userId || !currentUserId || userId === currentUserId) return null;
|
|
31
|
+
return channels.find((ch) => isFriendChannel(ch, userId, currentUserId)) || null;
|
|
32
|
+
}, [channels, userId, currentUserId]);
|
|
33
|
+
|
|
34
|
+
// Derive initial status from watchers state.
|
|
35
|
+
const [status, setStatus] = useState<OnlineStatus>(() => {
|
|
36
|
+
if (!friendChannel || !userId) return 'unknown';
|
|
37
|
+
return friendChannel.state?.watchers?.[userId] ? 'online' : 'offline';
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!friendChannel || !userId) {
|
|
42
|
+
setStatus('unknown');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sync initial state (in case friendChannel ref changed).
|
|
47
|
+
setStatus(friendChannel.state?.watchers?.[userId] ? 'online' : 'offline');
|
|
48
|
+
|
|
49
|
+
const handleWatchingStart = (event: Event) => {
|
|
50
|
+
if (event.user?.id === userId) {
|
|
51
|
+
setStatus('online');
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleWatchingStop = (event: Event) => {
|
|
56
|
+
if (event.user?.id === userId) {
|
|
57
|
+
setStatus('offline');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const sub1 = friendChannel.on('user.watching.start', handleWatchingStart);
|
|
62
|
+
const sub2 = friendChannel.on('user.watching.stop', handleWatchingStop);
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
sub1.unsubscribe();
|
|
66
|
+
sub2.unsubscribe();
|
|
67
|
+
};
|
|
68
|
+
}, [friendChannel, userId]);
|
|
69
|
+
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isFriendChannel } from '../channelRoleUtils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bulk hook that returns a `Set<string>` of user IDs that are currently online.
|
|
8
|
+
*
|
|
9
|
+
* Only users who are "friends" (exist in a direct channel where both
|
|
10
|
+
* members have `owner` role) are tracked. The status is derived from
|
|
11
|
+
* `channel.state.watchers` and kept in sync via `user.watching.start`
|
|
12
|
+
* and `user.watching.stop` WebSocket events at the **client** level
|
|
13
|
+
* for efficiency (single subscription instead of N per-channel ones).
|
|
14
|
+
*
|
|
15
|
+
* Usage in ChannelList: `const onlineUsers = useOnlineUsers(channels);`
|
|
16
|
+
* Then check: `onlineUsers.has(userId)`.
|
|
17
|
+
*
|
|
18
|
+
* @param channels – The full list of loaded channels (from ChannelList).
|
|
19
|
+
*/
|
|
20
|
+
export function useOnlineUsers(channels: Channel[]): Set<string> {
|
|
21
|
+
const { client } = useChatClient();
|
|
22
|
+
const currentUserId = client.userID;
|
|
23
|
+
|
|
24
|
+
// Build a map: friendUserId → Channel (the friend channel).
|
|
25
|
+
// This memoizes the friend channel lookup so we only iterate once per channels change.
|
|
26
|
+
const friendMap = useMemo(() => {
|
|
27
|
+
const map = new Map<string, Channel>();
|
|
28
|
+
if (!currentUserId) return map;
|
|
29
|
+
|
|
30
|
+
for (const ch of channels) {
|
|
31
|
+
const members = ch.state?.members;
|
|
32
|
+
if (!members) continue;
|
|
33
|
+
|
|
34
|
+
// Find the "other" user in this channel
|
|
35
|
+
for (const memberId of Object.keys(members)) {
|
|
36
|
+
if (memberId === currentUserId) continue;
|
|
37
|
+
if (isFriendChannel(ch, memberId, currentUserId)) {
|
|
38
|
+
map.set(memberId, ch);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return map;
|
|
43
|
+
}, [channels, currentUserId]);
|
|
44
|
+
|
|
45
|
+
// Compute the initial set of online users from watchers.
|
|
46
|
+
const computeOnlineSet = (): Set<string> => {
|
|
47
|
+
const set = new Set<string>();
|
|
48
|
+
for (const [userId, ch] of friendMap.entries()) {
|
|
49
|
+
if (ch.state?.watchers?.[userId]) {
|
|
50
|
+
set.add(userId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return set;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const [onlineUsers, setOnlineUsers] = useState<Set<string>>(() => computeOnlineSet());
|
|
57
|
+
|
|
58
|
+
// Keep friendMap in a ref so that event handlers always see the latest version.
|
|
59
|
+
const friendMapRef = useRef(friendMap);
|
|
60
|
+
friendMapRef.current = friendMap;
|
|
61
|
+
|
|
62
|
+
// Re-compute when friendMap changes (new channels loaded, channels array mutated).
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setOnlineUsers(computeOnlineSet());
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
+
}, [friendMap]);
|
|
67
|
+
|
|
68
|
+
// Subscribe at the client level for efficiency.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!currentUserId) return;
|
|
71
|
+
|
|
72
|
+
const handleWatchingStart = (event: Event) => {
|
|
73
|
+
const userId = event.user?.id;
|
|
74
|
+
const eventCid = event.cid;
|
|
75
|
+
if (!userId || !eventCid) return;
|
|
76
|
+
|
|
77
|
+
// Check if this userId belongs to a tracked friend channel
|
|
78
|
+
const tracked = friendMapRef.current.get(userId);
|
|
79
|
+
if (tracked && tracked.cid === eventCid) {
|
|
80
|
+
setOnlineUsers((prev) => {
|
|
81
|
+
if (prev.has(userId)) return prev;
|
|
82
|
+
const next = new Set(prev);
|
|
83
|
+
next.add(userId);
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleWatchingStop = (event: Event) => {
|
|
90
|
+
const userId = event.user?.id;
|
|
91
|
+
const eventCid = event.cid;
|
|
92
|
+
if (!userId || !eventCid) return;
|
|
93
|
+
|
|
94
|
+
const tracked = friendMapRef.current.get(userId);
|
|
95
|
+
if (tracked && tracked.cid === eventCid) {
|
|
96
|
+
setOnlineUsers((prev) => {
|
|
97
|
+
if (!prev.has(userId)) return prev;
|
|
98
|
+
const next = new Set(prev);
|
|
99
|
+
next.delete(userId);
|
|
100
|
+
return next;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const sub1 = client.on('user.watching.start', handleWatchingStart);
|
|
106
|
+
const sub2 = client.on('user.watching.stop', handleWatchingStop);
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
sub1.unsubscribe();
|
|
110
|
+
sub2.unsubscribe();
|
|
111
|
+
};
|
|
112
|
+
}, [client, currentUserId]);
|
|
113
|
+
|
|
114
|
+
return onlineUsers;
|
|
115
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { isPendingMember } from '../channelRoleUtils';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Hook that tracks whether the current user is in a 'pending' state for the given channel.
|
|
@@ -7,7 +8,7 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
|
7
8
|
export function usePendingState(channel: Channel | null | undefined, currentUserId?: string) {
|
|
8
9
|
const [isPending, setIsPending] = useState<boolean>(() => {
|
|
9
10
|
const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId || ''];
|
|
10
|
-
return membership?.channel_role
|
|
11
|
+
return isPendingMember(membership?.channel_role as string);
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
useEffect(() => {
|
|
@@ -18,7 +19,7 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
|
|
|
18
19
|
|
|
19
20
|
const checkPending = () => {
|
|
20
21
|
const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
|
|
21
|
-
return membership?.channel_role
|
|
22
|
+
return isPendingMember(membership?.channel_role as string);
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// Sync initial state
|
|
@@ -42,7 +43,9 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
|
|
|
42
43
|
if (eventUserId !== currentUserId) return; // Only react to own invite events
|
|
43
44
|
|
|
44
45
|
const eventCid =
|
|
45
|
-
event.cid ||
|
|
46
|
+
event.cid ||
|
|
47
|
+
(event.channel as Record<string, unknown>)?.cid ||
|
|
48
|
+
(event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
|
|
46
49
|
if (eventCid === channel.cid) {
|
|
47
50
|
defensiveUpdateState(event);
|
|
48
51
|
setIsPending(checkPending());
|
|
@@ -52,10 +55,12 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
|
|
|
52
55
|
const client = channel.getClient();
|
|
53
56
|
const sub1 = client.on('notification.invite_accepted', handleInviteAction);
|
|
54
57
|
const sub2 = client.on('notification.invite_rejected', handleInviteAction);
|
|
58
|
+
const sub3 = client.on('notification.invite_messaging_skipped', handleInviteAction);
|
|
55
59
|
|
|
56
60
|
return () => {
|
|
57
61
|
sub1.unsubscribe();
|
|
58
62
|
sub2.unsubscribe();
|
|
63
|
+
sub3.unsubscribe();
|
|
59
64
|
};
|
|
60
65
|
}, [channel, currentUserId]);
|
|
61
66
|
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { useChannelListUpdates } from './hooks/useChannelListUpdates';
|
|
|
13
13
|
export { useChannelRowUpdates } from './hooks/useChannelRowUpdates';
|
|
14
14
|
export { useBannedState } from './hooks/useBannedState';
|
|
15
15
|
export { useBlockedState } from './hooks/useBlockedState';
|
|
16
|
+
export { useOnlineStatus } from './hooks/useOnlineStatus';
|
|
17
|
+
export type { OnlineStatus } from './hooks/useOnlineStatus';
|
|
18
|
+
export { useOnlineUsers } from './hooks/useOnlineUsers';
|
|
16
19
|
export { usePendingState } from './hooks/usePendingState';
|
|
17
20
|
|
|
18
21
|
// Components
|
|
@@ -32,7 +35,14 @@ export { ChannelHeader } from './components/ChannelHeader';
|
|
|
32
35
|
export type { ChannelHeaderProps } from './components/ChannelHeader';
|
|
33
36
|
export type { ChannelHeaderData } from './types';
|
|
34
37
|
|
|
35
|
-
export type {
|
|
38
|
+
export type {
|
|
39
|
+
MessageListProps,
|
|
40
|
+
MessageBubbleProps,
|
|
41
|
+
MessageItemProps,
|
|
42
|
+
SystemMessageItemProps,
|
|
43
|
+
DateSeparatorProps,
|
|
44
|
+
JumpToLatestProps,
|
|
45
|
+
} from './types';
|
|
36
46
|
|
|
37
47
|
export { VirtualMessageList } from './components/VirtualMessageList';
|
|
38
48
|
|
|
@@ -54,6 +64,44 @@ export { MessageQuickReactions } from './components/MessageQuickReactions';
|
|
|
54
64
|
export { useMessageActions } from './hooks/useMessageActions';
|
|
55
65
|
|
|
56
66
|
export { formatTime, getDateKey, formatDateLabel, getMessageUserId, replaceMentionsForPreview } from './utils';
|
|
67
|
+
export {
|
|
68
|
+
isGroupChannel,
|
|
69
|
+
isDirectChannel,
|
|
70
|
+
isTopicChannel,
|
|
71
|
+
isPublicGroupChannel,
|
|
72
|
+
isGeneralProxy,
|
|
73
|
+
hasTopicsEnabled,
|
|
74
|
+
supportsBlocking,
|
|
75
|
+
} from './channelTypeUtils';
|
|
76
|
+
export {
|
|
77
|
+
CHANNEL_ROLES,
|
|
78
|
+
isPendingMember,
|
|
79
|
+
isSkippedMember,
|
|
80
|
+
isOwnerMember,
|
|
81
|
+
isFriendChannel,
|
|
82
|
+
canManageChannel,
|
|
83
|
+
canRemoveTargetMember,
|
|
84
|
+
canBanTargetMember,
|
|
85
|
+
canPromoteTargetMember,
|
|
86
|
+
canDemoteTargetMember,
|
|
87
|
+
} from './channelRoleUtils';
|
|
88
|
+
export type { ChannelRole } from './channelRoleUtils';
|
|
89
|
+
|
|
90
|
+
export {
|
|
91
|
+
MESSAGE_TYPES,
|
|
92
|
+
ATTACHMENT_TYPES,
|
|
93
|
+
isSystemMessage,
|
|
94
|
+
isStickerMessage,
|
|
95
|
+
isRegularMessage,
|
|
96
|
+
isSignalMessage,
|
|
97
|
+
isImageAttachment,
|
|
98
|
+
isVideoAttachment,
|
|
99
|
+
isVoiceRecordingAttachment,
|
|
100
|
+
isLinkPreviewAttachment,
|
|
101
|
+
isImage,
|
|
102
|
+
isVideo,
|
|
103
|
+
} from './messageTypeUtils';
|
|
104
|
+
export type { MessageType, AttachmentType } from './messageTypeUtils';
|
|
57
105
|
|
|
58
106
|
export {
|
|
59
107
|
defaultMessageRenderers,
|
|
@@ -68,8 +116,17 @@ export {
|
|
|
68
116
|
} from './components/MessageRenderers';
|
|
69
117
|
export type { MessageRendererProps, AttachmentProps } from './components/MessageRenderers';
|
|
70
118
|
|
|
119
|
+
export { MediaLightbox } from './components/MediaLightbox';
|
|
120
|
+
export type { MediaLightboxProps, MediaLightboxItem } from './types';
|
|
121
|
+
|
|
71
122
|
export { MessageInput } from './components/MessageInput';
|
|
72
|
-
export type {
|
|
123
|
+
export type {
|
|
124
|
+
MessageInputProps,
|
|
125
|
+
SendButtonProps,
|
|
126
|
+
AttachButtonProps,
|
|
127
|
+
EmojiPickerProps,
|
|
128
|
+
EmojiButtonProps,
|
|
129
|
+
} from './components/MessageInput';
|
|
73
130
|
|
|
74
131
|
export { FilesPreview } from './components/FilesPreview';
|
|
75
132
|
export type { FilePreviewItem, FilesPreviewProps } from './components/FilesPreview';
|
|
@@ -109,7 +166,7 @@ export {
|
|
|
109
166
|
DefaultChannelInfoHeader,
|
|
110
167
|
DefaultChannelInfoCover,
|
|
111
168
|
DefaultChannelInfoActions,
|
|
112
|
-
DefaultChannelInfoTabs
|
|
169
|
+
DefaultChannelInfoTabs,
|
|
113
170
|
} from './components/ChannelInfo';
|
|
114
171
|
|
|
115
172
|
export { Modal } from './components/Modal';
|
|
@@ -134,12 +191,7 @@ export type {
|
|
|
134
191
|
} from './types';
|
|
135
192
|
|
|
136
193
|
export { UserPicker } from './components/UserPicker';
|
|
137
|
-
export type {
|
|
138
|
-
UserPickerProps,
|
|
139
|
-
UserPickerUser,
|
|
140
|
-
UserPickerItemProps,
|
|
141
|
-
UserPickerSelectedBoxProps,
|
|
142
|
-
} from './types';
|
|
194
|
+
export type { UserPickerProps, UserPickerUser, UserPickerItemProps, UserPickerSelectedBoxProps } from './types';
|
|
143
195
|
|
|
144
196
|
export { CreateChannelModal } from './components/CreateChannelModal';
|
|
145
197
|
export type { CreateChannelModalProps } from './types';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const MESSAGE_TYPES = {
|
|
2
|
+
REGULAR: 'regular',
|
|
3
|
+
SYSTEM: 'system',
|
|
4
|
+
STICKER: 'sticker',
|
|
5
|
+
SIGNAL: 'signal',
|
|
6
|
+
ERROR: 'error',
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export const ATTACHMENT_TYPES = {
|
|
10
|
+
IMAGE: 'image',
|
|
11
|
+
VIDEO: 'video',
|
|
12
|
+
VOICE_RECORDING: 'voiceRecording',
|
|
13
|
+
LINK_PREVIEW: 'linkPreview',
|
|
14
|
+
FILE: 'file',
|
|
15
|
+
AUDIO: 'audio',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES] | string;
|
|
19
|
+
export type AttachmentType = (typeof ATTACHMENT_TYPES)[keyof typeof ATTACHMENT_TYPES] | string;
|
|
20
|
+
|
|
21
|
+
// Helpers cho message
|
|
22
|
+
export function isSystemMessage(message: any): boolean {
|
|
23
|
+
return message?.type === MESSAGE_TYPES.SYSTEM;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isStickerMessage(message: any): boolean {
|
|
27
|
+
return message?.type === MESSAGE_TYPES.STICKER;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isRegularMessage(message: any): boolean {
|
|
31
|
+
return !message?.type || message?.type === MESSAGE_TYPES.REGULAR;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isSignalMessage(message: any): boolean {
|
|
35
|
+
return message?.type === MESSAGE_TYPES.SIGNAL;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helpers cho attachment
|
|
39
|
+
export function isImageAttachment(attachment: any): boolean {
|
|
40
|
+
return attachment?.type === ATTACHMENT_TYPES.IMAGE;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isVideoAttachment(attachment: any): boolean {
|
|
44
|
+
return attachment?.type === ATTACHMENT_TYPES.VIDEO;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isVoiceRecordingAttachment(attachment: any): boolean {
|
|
48
|
+
return attachment?.type === ATTACHMENT_TYPES.VOICE_RECORDING;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isLinkPreviewAttachment(attachment: any): boolean {
|
|
52
|
+
return attachment?.type === ATTACHMENT_TYPES.LINK_PREVIEW;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isImage(attachment: any): boolean {
|
|
56
|
+
return Boolean(
|
|
57
|
+
isImageAttachment(attachment) ||
|
|
58
|
+
(!attachment?.type && (attachment?.mime_type?.startsWith('image/') || attachment?.image_url)),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isVideo(attachment: any): boolean {
|
|
63
|
+
return !!(isVideoAttachment(attachment) || (!attachment.type && attachment.mime_type?.startsWith('video/')));
|
|
64
|
+
}
|
|
@@ -57,6 +57,40 @@
|
|
|
57
57
|
text-overflow: ellipsis;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/* --- Online Status Indicator --- */
|
|
61
|
+
.ermis-channel-header__online-status {
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
gap: 5px;
|
|
65
|
+
margin-top: 1px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ermis-channel-header__online-dot {
|
|
69
|
+
width: 8px;
|
|
70
|
+
height: 8px;
|
|
71
|
+
border-radius: var(--ermis-radius-full);
|
|
72
|
+
flex-shrink: 0;
|
|
73
|
+
transition: background-color var(--ermis-transition);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ermis-channel-header__online-dot--online {
|
|
77
|
+
background-color: var(--ermis-color-success);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.ermis-channel-header__online-dot--offline {
|
|
81
|
+
background-color: var(--ermis-text-muted);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ermis-channel-header__online-label {
|
|
85
|
+
font-size: var(--ermis-font-size-xs);
|
|
86
|
+
color: var(--ermis-text-muted);
|
|
87
|
+
line-height: 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ermis-channel-header__online-status--online .ermis-channel-header__online-label {
|
|
91
|
+
color: var(--ermis-color-success);
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
.ermis-channel-header__info {
|
|
61
95
|
flex: 1;
|
|
62
96
|
}
|
|
@@ -121,6 +155,31 @@
|
|
|
121
155
|
flex: 1;
|
|
122
156
|
}
|
|
123
157
|
|
|
158
|
+
/* --- Avatar wrapper with online dot overlay --- */
|
|
159
|
+
.ermis-channel-list__item-avatar-wrapper {
|
|
160
|
+
position: relative;
|
|
161
|
+
flex-shrink: 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.ermis-channel-list__online-dot {
|
|
165
|
+
position: absolute;
|
|
166
|
+
bottom: 0;
|
|
167
|
+
right: 0;
|
|
168
|
+
width: 10px;
|
|
169
|
+
height: 10px;
|
|
170
|
+
border-radius: var(--ermis-radius-full);
|
|
171
|
+
border: 2px solid var(--ermis-bg-secondary);
|
|
172
|
+
transition: background-color var(--ermis-transition);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.ermis-channel-list__online-dot--online {
|
|
176
|
+
background-color: var(--ermis-color-success);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ermis-channel-list__online-dot--offline {
|
|
180
|
+
background-color: var(--ermis-text-muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
124
183
|
.ermis-channel-list__item-top-row {
|
|
125
184
|
display: flex;
|
|
126
185
|
align-items: baseline;
|