@ermis-network/ermis-chat-react 1.0.9 → 2.0.1
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/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- 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 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- 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 +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- 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/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -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 +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -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/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -4,6 +4,7 @@ import { formatMessage } from '@ermis-network/ermis-chat-sdk';
|
|
|
4
4
|
import type { VListHandle } from 'virtua';
|
|
5
5
|
import { dedupMessages } from './useLoadMessages';
|
|
6
6
|
import { useChatClient } from './useChatClient';
|
|
7
|
+
import { getDateKey } from '../utils';
|
|
7
8
|
|
|
8
9
|
export type UseScrollToMessageOptions = {
|
|
9
10
|
vlistRef: React.RefObject<VListHandle | null>;
|
|
@@ -23,6 +24,23 @@ export type UseScrollToMessageReturn = {
|
|
|
23
24
|
jumpToLatest: () => void;
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
function getRenderedMessageIndex(messages: FormatMessageResponse[], messageId: string): number {
|
|
28
|
+
let renderedIndex = 0;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
31
|
+
const message = messages[i];
|
|
32
|
+
const prevMessage = i > 0 ? messages[i - 1] : null;
|
|
33
|
+
const showDateSeparator =
|
|
34
|
+
!prevMessage || getDateKey(message.created_at) !== getDateKey(prevMessage.created_at);
|
|
35
|
+
|
|
36
|
+
if (showDateSeparator) renderedIndex += 1;
|
|
37
|
+
if (message.id === messageId) return renderedIndex;
|
|
38
|
+
renderedIndex += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export function useScrollToMessage({
|
|
27
45
|
vlistRef,
|
|
28
46
|
messagesRef,
|
|
@@ -60,7 +78,14 @@ export function useScrollToMessage({
|
|
|
60
78
|
// Case 1: message is already in current list
|
|
61
79
|
const idx = messagesRef.current.findIndex((m) => m.id === messageId);
|
|
62
80
|
if (idx !== -1) {
|
|
63
|
-
|
|
81
|
+
const renderedIdx = getRenderedMessageIndex(messagesRef.current, messageId);
|
|
82
|
+
if (renderedIdx !== -1) {
|
|
83
|
+
jumpingRef.current = true;
|
|
84
|
+
vlistRef.current?.scrollToIndex(renderedIdx, { align: 'center', smooth: true });
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
jumpingRef.current = false;
|
|
87
|
+
}, 500);
|
|
88
|
+
}
|
|
64
89
|
highlight(messageId);
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
@@ -93,14 +118,14 @@ export function useScrollToMessage({
|
|
|
93
118
|
|
|
94
119
|
// Wait for VList to render, then jump while hidden, then fade in
|
|
95
120
|
setTimeout(() => {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
121
|
+
const renderedIdx = getRenderedMessageIndex(unique, messageId);
|
|
122
|
+
if (renderedIdx === -1) {
|
|
98
123
|
jumpingRef.current = false;
|
|
99
124
|
if (vlistEl) vlistEl.style.opacity = '1';
|
|
100
125
|
return;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
vlistRef.current?.scrollToIndex(
|
|
128
|
+
vlistRef.current?.scrollToIndex(renderedIdx, { align: 'center' });
|
|
104
129
|
|
|
105
130
|
setTimeout(() => {
|
|
106
131
|
if (vlistEl) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export type UseStickerPickerOptions = {
|
|
5
|
+
activeChannel?: Channel | null;
|
|
6
|
+
stickerIframeUrl?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function useStickerPicker({
|
|
10
|
+
activeChannel,
|
|
11
|
+
stickerIframeUrl = 'https://sticker.ermis.network',
|
|
12
|
+
}: UseStickerPickerOptions) {
|
|
13
|
+
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
|
|
14
|
+
|
|
15
|
+
const toggleStickerPicker = useCallback(() => {
|
|
16
|
+
setStickerPickerOpen((prev) => !prev);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
const closeStickerPicker = useCallback(() => {
|
|
20
|
+
setStickerPickerOpen(false);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const handleStickerSend = useCallback(
|
|
24
|
+
async (stickerUrl: string) => {
|
|
25
|
+
if (!activeChannel) return;
|
|
26
|
+
try {
|
|
27
|
+
await activeChannel.sendMessage({
|
|
28
|
+
text: '',
|
|
29
|
+
attachments: [],
|
|
30
|
+
sticker_url: stickerUrl,
|
|
31
|
+
});
|
|
32
|
+
setStickerPickerOpen(false);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('Failed to send sticker', error);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
[activeChannel],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!stickerPickerOpen) return;
|
|
42
|
+
|
|
43
|
+
const handleMessage = (event: MessageEvent) => {
|
|
44
|
+
const stickerUrl = event.data?.data?.content?.url;
|
|
45
|
+
if (!stickerUrl || typeof stickerUrl !== 'string') return;
|
|
46
|
+
const fullUrl = `${stickerIframeUrl}/${stickerUrl}`;
|
|
47
|
+
handleStickerSend(fullUrl);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
window.addEventListener('message', handleMessage);
|
|
51
|
+
return () => {
|
|
52
|
+
window.removeEventListener('message', handleMessage);
|
|
53
|
+
};
|
|
54
|
+
}, [stickerPickerOpen, stickerIframeUrl, handleStickerSend]);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
stickerPickerOpen,
|
|
58
|
+
toggleStickerPicker,
|
|
59
|
+
closeStickerPicker,
|
|
60
|
+
handleStickerSend,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
|
|
4
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
5
|
+
import { getLastMessagePreview } from '../utils';
|
|
6
|
+
import { SystemMessageTranslations, SignalMessageTranslations } from '@ermis-network/ermis-chat-sdk';
|
|
7
|
+
import { useChatClient } from './useChatClient';
|
|
8
|
+
|
|
9
|
+
/** Preview data for the most recent message across the topic group */
|
|
10
|
+
export type LatestMessagePreview = {
|
|
11
|
+
text: React.ReactNode;
|
|
12
|
+
user: string;
|
|
13
|
+
timestamp?: string | Date;
|
|
14
|
+
/** Topic name if the message came from a sub-topic, null if from general/parent */
|
|
15
|
+
sourceName: string | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TopicGroupUpdatesOptions = {
|
|
19
|
+
deletedMessageLabel?: React.ReactNode;
|
|
20
|
+
stickerMessageLabel?: React.ReactNode;
|
|
21
|
+
photoMessageLabel?: React.ReactNode;
|
|
22
|
+
videoMessageLabel?: React.ReactNode;
|
|
23
|
+
voiceRecordingMessageLabel?: React.ReactNode;
|
|
24
|
+
fileMessageLabel?: React.ReactNode;
|
|
25
|
+
encryptedMessageLabel?: React.ReactNode;
|
|
26
|
+
encryptedMessageUnavailableLabel?: React.ReactNode;
|
|
27
|
+
systemMessageTranslations?: SystemMessageTranslations;
|
|
28
|
+
signalMessageTranslations?: SignalMessageTranslations;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook encapsulating realtime logic for a topic-enabled channel group.
|
|
33
|
+
*
|
|
34
|
+
* Subscribes to message and pin events on the parent channel AND all its
|
|
35
|
+
* topics to compute:
|
|
36
|
+
* - sorted topics list (pinned first, then by last activity)
|
|
37
|
+
* - aggregated unread count across parent + all topics
|
|
38
|
+
* - boolean flag indicating if any unread exists
|
|
39
|
+
* - latest message preview across parent + all topics
|
|
40
|
+
*/
|
|
41
|
+
export function useTopicGroupUpdates(
|
|
42
|
+
channel: Channel,
|
|
43
|
+
currentUserId?: string,
|
|
44
|
+
options?: TopicGroupUpdatesOptions
|
|
45
|
+
): {
|
|
46
|
+
topics: Channel[];
|
|
47
|
+
aggregatedUnreadCount: number;
|
|
48
|
+
hasUnread: boolean;
|
|
49
|
+
updateCount: number;
|
|
50
|
+
latestMessagePreview: LatestMessagePreview | null;
|
|
51
|
+
} {
|
|
52
|
+
const { client: chatClient, activeChannel } = useChatClient();
|
|
53
|
+
const [updateCount, setUpdateCount] = useState(0);
|
|
54
|
+
const bump = useCallback(() => setUpdateCount((c) => c + 1), []);
|
|
55
|
+
|
|
56
|
+
// Subscribe to realtime events on parent + all topics
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const subs: { unsubscribe: () => void }[] = [];
|
|
59
|
+
const client = channel.getClient();
|
|
60
|
+
const isTopicGroupCid = (cid?: string) => {
|
|
61
|
+
if (!cid) return false;
|
|
62
|
+
if (cid === channel.cid) return true;
|
|
63
|
+
return (channel.state?.topics || []).some((topic: Channel) => topic.cid === cid);
|
|
64
|
+
};
|
|
65
|
+
const handleE2eePreviewUpdate = (event: any) => {
|
|
66
|
+
if (isTopicGroupCid(event?.cid)) {
|
|
67
|
+
bump();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Parent channel events
|
|
72
|
+
subs.push(channel.on('message.new', bump));
|
|
73
|
+
subs.push(channel.on('message.read', bump));
|
|
74
|
+
subs.push(channel.on('message.deleted', bump));
|
|
75
|
+
subs.push(channel.on('channel.updated', bump));
|
|
76
|
+
subs.push(channel.on('channel.topic.created', bump));
|
|
77
|
+
subs.push(channel.on('channel.pinned', bump));
|
|
78
|
+
subs.push(channel.on('channel.unpinned', bump));
|
|
79
|
+
subs.push(client.on('e2ee.message_decrypted' as any, handleE2eePreviewUpdate));
|
|
80
|
+
subs.push(client.on('e2ee.local_messages_loaded' as any, handleE2eePreviewUpdate));
|
|
81
|
+
subs.push(client.on('e2ee.post_join_sync' as any, handleE2eePreviewUpdate));
|
|
82
|
+
|
|
83
|
+
// Topic children events
|
|
84
|
+
const currentTopics = channel.state?.topics || [];
|
|
85
|
+
currentTopics.forEach((t: Channel) => {
|
|
86
|
+
subs.push(t.on('message.new', bump));
|
|
87
|
+
subs.push(t.on('message.read', bump));
|
|
88
|
+
subs.push(t.on('message.deleted', bump));
|
|
89
|
+
subs.push(t.on('channel.updated', bump));
|
|
90
|
+
subs.push(t.on('channel.deleted', bump));
|
|
91
|
+
subs.push(t.on('channel.pinned', bump));
|
|
92
|
+
subs.push(t.on('channel.unpinned', bump));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
subs.forEach((s) => s.unsubscribe());
|
|
97
|
+
};
|
|
98
|
+
}, [channel, channel.state?.topics, channel.state?.topics?.length, bump]);
|
|
99
|
+
|
|
100
|
+
// Helper: get sort timestamp for a channel/topic
|
|
101
|
+
const getTopicTime = (t: Channel): number => {
|
|
102
|
+
const lastMsg = t.state?.latestMessages?.slice(-1)[0];
|
|
103
|
+
if (lastMsg?.created_at) return new Date(lastMsg.created_at).getTime();
|
|
104
|
+
if (t.data?.last_message_at) return new Date(t.data.last_message_at as string | Date).getTime();
|
|
105
|
+
if (t.data?.created_at) return new Date(t.data.created_at as string | Date).getTime();
|
|
106
|
+
return 0;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Helper: check if user is excluded from unread counting
|
|
110
|
+
const isExcludedUser = (ch: Channel): boolean => {
|
|
111
|
+
const client = ch.getClient();
|
|
112
|
+
const activeCh = client.activeChannels[ch.cid] || ch;
|
|
113
|
+
const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
|
|
114
|
+
if (!ms) return false;
|
|
115
|
+
const isBannedSelf = Boolean(ms.banned);
|
|
116
|
+
|
|
117
|
+
// Topic support: check parent channel's ban status
|
|
118
|
+
const parentCid = activeCh.data?.parent_cid as string | undefined;
|
|
119
|
+
const parentChannel = parentCid ? client.activeChannels[parentCid] : undefined;
|
|
120
|
+
const isBannedParent = Boolean(parentChannel?.state?.membership?.banned);
|
|
121
|
+
|
|
122
|
+
const isBanned = isBannedSelf || isBannedParent;
|
|
123
|
+
const isBlocked = isDirectChannel(activeCh) && Boolean(ms.blocked);
|
|
124
|
+
const isPending = isPendingMember(ms.channel_role as string);
|
|
125
|
+
const isSkipped = isSkippedMember(ms.channel_role as string);
|
|
126
|
+
return isBanned || isBlocked || isPending || isSkipped;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Helper: get unread count for a channel (reads from SDK state directly)
|
|
130
|
+
const getUnreadCount = (ch: Channel): number => {
|
|
131
|
+
if (!currentUserId || isExcludedUser(ch)) return 0;
|
|
132
|
+
// Primary: use the SDK's tracked unreadCount from activeChannels to avoid stale state
|
|
133
|
+
const client = ch.getClient();
|
|
134
|
+
const activeCh = client.activeChannels[ch.cid] || ch;
|
|
135
|
+
const state = activeCh.state as unknown as Record<string, unknown> | undefined;
|
|
136
|
+
const count = (state?.unreadCount as number) ?? 0;
|
|
137
|
+
return count;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Sort topics: pinned first → last activity descending
|
|
141
|
+
const topics = useMemo(() => {
|
|
142
|
+
const allTopics = channel.state?.topics || [];
|
|
143
|
+
const client = channel.getClient();
|
|
144
|
+
const upToDateTopics = allTopics.map(t => client.activeChannels[t.cid] || t);
|
|
145
|
+
return upToDateTopics.sort((a: Channel, b: Channel) => {
|
|
146
|
+
const aPinned = a.data?.is_pinned === true;
|
|
147
|
+
const bPinned = b.data?.is_pinned === true;
|
|
148
|
+
if (aPinned && !bPinned) return -1;
|
|
149
|
+
if (!aPinned && bPinned) return 1;
|
|
150
|
+
return getTopicTime(b) - getTopicTime(a);
|
|
151
|
+
});
|
|
152
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
153
|
+
}, [channel.state?.topics, updateCount]);
|
|
154
|
+
|
|
155
|
+
// Aggregated unread count across parent + all topics
|
|
156
|
+
const aggregatedUnreadCount = useMemo(() => {
|
|
157
|
+
const client = channel.getClient();
|
|
158
|
+
const activeParent = client.activeChannels[channel.cid] || channel;
|
|
159
|
+
|
|
160
|
+
// Ignore the currently active channel's unread count to match UI behavior
|
|
161
|
+
// where active channels don't show unread badges.
|
|
162
|
+
const activeChannelCid = Object.values(client.activeChannels || {}).find(c => c.state && c.cid === activeChannel?.cid)?.cid || activeChannel?.cid;
|
|
163
|
+
|
|
164
|
+
let total = 0;
|
|
165
|
+
if (activeParent.cid !== activeChannelCid) {
|
|
166
|
+
total += getUnreadCount(activeParent);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allTopics = channel.state?.topics || [];
|
|
170
|
+
allTopics.forEach((topic: Channel) => {
|
|
171
|
+
const activeTopic = client.activeChannels[topic.cid] || topic;
|
|
172
|
+
if (activeTopic.cid !== activeChannelCid) {
|
|
173
|
+
total += getUnreadCount(activeTopic);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return total;
|
|
178
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
|
+
}, [channel, channel.state?.topics, currentUserId, updateCount, activeChannel?.cid]);
|
|
180
|
+
|
|
181
|
+
const hasUnread = aggregatedUnreadCount > 0;
|
|
182
|
+
|
|
183
|
+
// Latest message preview across parent + all topics (Option B: prefix topic name)
|
|
184
|
+
const latestMessagePreview = useMemo((): LatestMessagePreview | null => {
|
|
185
|
+
// If banned from the main group, hide previews for all sub-items
|
|
186
|
+
if (isExcludedUser(channel)) return null;
|
|
187
|
+
|
|
188
|
+
const allChannels = [channel, ...(channel.state?.topics || [])];
|
|
189
|
+
|
|
190
|
+
let bestTime = 0;
|
|
191
|
+
let bestChannel: Channel | null = null;
|
|
192
|
+
|
|
193
|
+
for (const ch of allChannels) {
|
|
194
|
+
const time = getTopicTime(ch);
|
|
195
|
+
if (time > bestTime) {
|
|
196
|
+
bestTime = time;
|
|
197
|
+
bestChannel = ch;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!bestChannel) return null;
|
|
202
|
+
|
|
203
|
+
const preview = getLastMessagePreview(bestChannel, currentUserId, options);
|
|
204
|
+
if (!preview.text && !preview.user) return null;
|
|
205
|
+
|
|
206
|
+
// sourceName is non-null only when the message comes from a sub-topic (not the parent/general)
|
|
207
|
+
const isFromSubTopic = bestChannel !== channel;
|
|
208
|
+
const sourceName = isFromSubTopic ? (bestChannel.data?.name as string || null) : null;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
text: preview.text,
|
|
212
|
+
user: preview.user,
|
|
213
|
+
timestamp: preview.timestamp,
|
|
214
|
+
sourceName,
|
|
215
|
+
};
|
|
216
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
217
|
+
}, [
|
|
218
|
+
channel,
|
|
219
|
+
channel.state?.topics,
|
|
220
|
+
currentUserId,
|
|
221
|
+
updateCount,
|
|
222
|
+
options?.deletedMessageLabel,
|
|
223
|
+
options?.stickerMessageLabel,
|
|
224
|
+
options?.photoMessageLabel,
|
|
225
|
+
options?.videoMessageLabel,
|
|
226
|
+
options?.voiceRecordingMessageLabel,
|
|
227
|
+
options?.fileMessageLabel,
|
|
228
|
+
options?.encryptedMessageLabel,
|
|
229
|
+
options?.encryptedMessageUnavailableLabel,
|
|
230
|
+
options?.systemMessageTranslations,
|
|
231
|
+
options?.signalMessageTranslations,
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
return { topics, aggregatedUnreadCount, hasUnread, updateCount, latestMessagePreview };
|
|
235
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,11 @@ export type { ChatProviderProps, ChatContextValue, Theme } from './context/ChatP
|
|
|
7
7
|
|
|
8
8
|
// Hooks
|
|
9
9
|
export { useChatClient } from './hooks/useChatClient';
|
|
10
|
+
export { useChatUser } from './hooks/useChatUser';
|
|
11
|
+
export { useInviteChannels } from './hooks/useInviteChannels';
|
|
12
|
+
export { useContactChannels } from './hooks/useContactChannels';
|
|
13
|
+
export { useInviteCount } from './hooks/useInviteCount';
|
|
14
|
+
export { useContactCount } from './hooks/useContactCount';
|
|
10
15
|
export { useChannel } from './hooks/useChannel';
|
|
11
16
|
export type { UseChannelReturn } from './hooks/useChannel';
|
|
12
17
|
export { useChannelListUpdates } from './hooks/useChannelListUpdates';
|
|
@@ -17,14 +22,32 @@ export { useOnlineStatus } from './hooks/useOnlineStatus';
|
|
|
17
22
|
export type { OnlineStatus } from './hooks/useOnlineStatus';
|
|
18
23
|
export { useOnlineUsers } from './hooks/useOnlineUsers';
|
|
19
24
|
export { usePendingState } from './hooks/usePendingState';
|
|
25
|
+
export { usePreviewState } from './hooks/usePreviewState';
|
|
26
|
+
export { useTopicGroupUpdates } from './hooks/useTopicGroupUpdates';
|
|
27
|
+
export { useDragAndDrop } from './hooks/useDragAndDrop';
|
|
28
|
+
export { useMessageSend } from './hooks/useMessageSend';
|
|
29
|
+
export { useFileUpload } from './hooks/useFileUpload';
|
|
30
|
+
export { useE2eeFileUpload } from './hooks/useE2eeFileUpload';
|
|
31
|
+
export { useE2eeAttachmentRenderer } from './hooks/useE2eeAttachmentRenderer';
|
|
32
|
+
export { usePendingE2eeSends } from './hooks/usePendingE2eeSends';
|
|
33
|
+
export { useEmojiPicker } from './hooks/useEmojiPicker';
|
|
34
|
+
export { useStickerPicker } from './hooks/useStickerPicker';
|
|
35
|
+
export type { UseStickerPickerOptions } from './hooks/useStickerPicker';
|
|
36
|
+
export { useChannelCapabilities } from './hooks/useChannelCapabilities';
|
|
37
|
+
export { useChannelMembers, useChannelProfile } from './hooks/useChannelData';
|
|
20
38
|
|
|
21
39
|
// Components
|
|
22
40
|
export { Avatar } from './components/Avatar';
|
|
23
41
|
export type { AvatarProps } from './components/Avatar';
|
|
24
42
|
|
|
25
|
-
export { ChannelList, ChannelItem,
|
|
43
|
+
export { ChannelList, ChannelItem, ChannelRow, DefaultPinnedIcon } from './components/ChannelList';
|
|
26
44
|
export type { ChannelListProps, ChannelItemProps } from './components/ChannelList';
|
|
27
45
|
|
|
46
|
+
export { FlatTopicGroupItem } from './components/FlatTopicGroupItem';
|
|
47
|
+
export type { TopicPillProps, TopicListProps } from './types';
|
|
48
|
+
|
|
49
|
+
export { TopicList } from './components/TopicList';
|
|
50
|
+
|
|
28
51
|
export { DefaultChannelActions, computeDefaultActions } from './components/ChannelActions';
|
|
29
52
|
export type { ChannelAction, ChannelActionsProps } from './types';
|
|
30
53
|
|
|
@@ -54,7 +77,7 @@ export { MessageActionsBox } from './components/MessageActionsBox';
|
|
|
54
77
|
export type { MessageActionsBoxProps } from './types';
|
|
55
78
|
|
|
56
79
|
export { Dropdown, closeAllDropdowns } from './components/Dropdown';
|
|
57
|
-
export type { DropdownProps } from './
|
|
80
|
+
export type { DropdownProps } from './types';
|
|
58
81
|
|
|
59
82
|
export { MessageReactions } from './components/MessageReactions';
|
|
60
83
|
export type { MessageReactionsProps, ReactionUser, LatestReaction } from './types';
|
|
@@ -63,7 +86,19 @@ export { MessageQuickReactions } from './components/MessageQuickReactions';
|
|
|
63
86
|
|
|
64
87
|
export { useMessageActions } from './hooks/useMessageActions';
|
|
65
88
|
|
|
66
|
-
export {
|
|
89
|
+
export {
|
|
90
|
+
formatTime,
|
|
91
|
+
getDateKey,
|
|
92
|
+
formatDateLabel,
|
|
93
|
+
getMessageUserId,
|
|
94
|
+
replaceMentionsForPreview,
|
|
95
|
+
getLastMessagePreview,
|
|
96
|
+
buildUserMap,
|
|
97
|
+
removeAccents,
|
|
98
|
+
formatRelativeDate,
|
|
99
|
+
countWords,
|
|
100
|
+
} from './utils';
|
|
101
|
+
export { getAvatarGradient } from './utils/avatarColors';
|
|
67
102
|
export {
|
|
68
103
|
isGroupChannel,
|
|
69
104
|
isDirectChannel,
|
|
@@ -100,8 +135,10 @@ export {
|
|
|
100
135
|
isLinkPreviewAttachment,
|
|
101
136
|
isImage,
|
|
102
137
|
isVideo,
|
|
138
|
+
MESSAGE_DISPLAY_TYPES,
|
|
139
|
+
isDeletedDisplayMessage,
|
|
103
140
|
} from './messageTypeUtils';
|
|
104
|
-
export type { MessageType, AttachmentType } from './messageTypeUtils';
|
|
141
|
+
export type { MessageType, AttachmentType, MessageDisplayType } from './messageTypeUtils';
|
|
105
142
|
|
|
106
143
|
export {
|
|
107
144
|
defaultMessageRenderers,
|
|
@@ -134,7 +171,10 @@ export type { FilePreviewItem, FilesPreviewProps } from './components/FilesPrevi
|
|
|
134
171
|
export { MentionSuggestions } from './components/MentionSuggestions';
|
|
135
172
|
export type { MentionSuggestionsProps } from './components/MentionSuggestions';
|
|
136
173
|
|
|
137
|
-
export {
|
|
174
|
+
export { EditPreview } from './components/EditPreview';
|
|
175
|
+
export { PreviewOverlay } from './components/PreviewOverlay';
|
|
176
|
+
|
|
177
|
+
export { useMentions, getMentionHtml } from './hooks/useMentions';
|
|
138
178
|
export type { MentionMember, MentionPayload, UseMentionsOptions, UseMentionsReturn } from './hooks/useMentions';
|
|
139
179
|
|
|
140
180
|
export { useScrollToMessage } from './hooks/useScrollToMessage';
|
|
@@ -143,9 +183,13 @@ export type { UseScrollToMessageOptions, UseScrollToMessageReturn } from './hook
|
|
|
143
183
|
export { useLoadMessages, dedupMessages } from './hooks/useLoadMessages';
|
|
144
184
|
export type { UseLoadMessagesOptions, UseLoadMessagesReturn } from './hooks/useLoadMessages';
|
|
145
185
|
|
|
146
|
-
export { useChannelMessages } from './hooks/useChannelMessages';
|
|
186
|
+
export { useChannelMessages, markChannelAsFullyQueried } from './hooks/useChannelMessages';
|
|
147
187
|
export type { UseChannelMessagesOptions } from './hooks/useChannelMessages';
|
|
148
188
|
|
|
189
|
+
export { useForwardMessage } from './hooks/useForwardMessage';
|
|
190
|
+
export { useRecoveryPin } from './hooks/useRecoveryPin';
|
|
191
|
+
export type { UseRecoveryPinReturn, RecoveryPinStatus, RecoveryRestoredMessage, RecoveryStatusInfo } from './hooks/useRecoveryPin';
|
|
192
|
+
|
|
149
193
|
export { QuotedMessagePreview } from './components/QuotedMessagePreview';
|
|
150
194
|
export type { QuotedMessagePreviewProps } from './components/QuotedMessagePreview';
|
|
151
195
|
export { ReplyPreview } from './components/ReplyPreview';
|
|
@@ -167,6 +211,11 @@ export {
|
|
|
167
211
|
DefaultChannelInfoCover,
|
|
168
212
|
DefaultChannelInfoActions,
|
|
169
213
|
DefaultChannelInfoTabs,
|
|
214
|
+
MessageSearchPanel,
|
|
215
|
+
HighlightedText,
|
|
216
|
+
useMessageSearch,
|
|
217
|
+
ChannelSettingsPanel,
|
|
218
|
+
useChannelSettings,
|
|
170
219
|
} from './components/ChannelInfo';
|
|
171
220
|
|
|
172
221
|
export { Modal } from './components/Modal';
|
|
@@ -177,6 +226,7 @@ export type {
|
|
|
177
226
|
ChannelInfoCoverProps,
|
|
178
227
|
ChannelInfoActionsProps,
|
|
179
228
|
ChannelInfoTabsProps,
|
|
229
|
+
ChannelInfoTabHeaderProps,
|
|
180
230
|
ChannelInfoMemberItemProps,
|
|
181
231
|
ChannelInfoMediaItemProps,
|
|
182
232
|
ChannelInfoLinkItemProps,
|
|
@@ -188,6 +238,11 @@ export type {
|
|
|
188
238
|
AddMemberModalProps,
|
|
189
239
|
AddMemberUserItemProps,
|
|
190
240
|
AddMemberButtonProps,
|
|
241
|
+
EditChannelModalProps,
|
|
242
|
+
EditChannelData,
|
|
243
|
+
TopicModalProps,
|
|
244
|
+
MessageSearchPanelProps,
|
|
245
|
+
ChannelSettingsPanelProps,
|
|
191
246
|
} from './types';
|
|
192
247
|
|
|
193
248
|
export { UserPicker } from './components/UserPicker';
|
|
@@ -195,6 +250,24 @@ export type { UserPickerProps, UserPickerUser, UserPickerItemProps, UserPickerSe
|
|
|
195
250
|
|
|
196
251
|
export { CreateChannelModal } from './components/CreateChannelModal';
|
|
197
252
|
export type { CreateChannelModalProps } from './types';
|
|
253
|
+
export {
|
|
254
|
+
RecoveryPinSetup,
|
|
255
|
+
RecoveryPinRestore,
|
|
256
|
+
RecoveryPinChange,
|
|
257
|
+
RecoveryStatus,
|
|
258
|
+
RecoveryGap,
|
|
259
|
+
RecoveryGate,
|
|
260
|
+
RecoveryRestoreProgress,
|
|
261
|
+
} from './components/RecoveryPin';
|
|
262
|
+
export type {
|
|
263
|
+
RecoveryPinSetupProps,
|
|
264
|
+
RecoveryPinRestoreProps,
|
|
265
|
+
RecoveryPinChangeProps,
|
|
266
|
+
RecoveryStatusProps,
|
|
267
|
+
RecoveryGapProps,
|
|
268
|
+
RecoveryGateProps,
|
|
269
|
+
RecoveryRestoreProgressProps,
|
|
270
|
+
} from './components/RecoveryPin';
|
|
198
271
|
|
|
199
272
|
// Call Components
|
|
200
273
|
export { ErmisCallContext } from './context/ErmisCallContext';
|
package/src/messageTypeUtils.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function isSystemMessage(message: any): boolean {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function isStickerMessage(message: any): boolean {
|
|
27
|
-
return message?.type === MESSAGE_TYPES.STICKER;
|
|
27
|
+
return message?.type === MESSAGE_TYPES.STICKER || Boolean(message?.sticker_url);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function isRegularMessage(message: any): boolean {
|
|
@@ -62,3 +62,29 @@ export function isImage(attachment: any): boolean {
|
|
|
62
62
|
export function isVideo(attachment: any): boolean {
|
|
63
63
|
return !!(isVideoAttachment(attachment) || (!attachment.type && attachment.mime_type?.startsWith('video/')));
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
export function isAudioAttachment(attachment: any): boolean {
|
|
67
|
+
return attachment?.type === ATTACHMENT_TYPES.AUDIO;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isAudio(attachment: any): boolean {
|
|
71
|
+
return !!(
|
|
72
|
+
isAudioAttachment(attachment) ||
|
|
73
|
+
isVoiceRecordingAttachment(attachment) ||
|
|
74
|
+
attachment.mime_type?.startsWith('audio/') ||
|
|
75
|
+
attachment.file_name?.toLowerCase().endsWith('.mp3') ||
|
|
76
|
+
attachment.title?.toLowerCase().endsWith('.mp3')
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const MESSAGE_DISPLAY_TYPES = {
|
|
81
|
+
NORMAL: 'normal',
|
|
82
|
+
DELETED: 'deleted',
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
export type MessageDisplayType = (typeof MESSAGE_DISPLAY_TYPES)[keyof typeof MESSAGE_DISPLAY_TYPES] | string;
|
|
86
|
+
|
|
87
|
+
/** Check if a message was deleted for current user (display_type === 'deleted') */
|
|
88
|
+
export function isDeletedDisplayMessage(message: any): boolean {
|
|
89
|
+
return message?.display_type === MESSAGE_DISPLAY_TYPES.DELETED;
|
|
90
|
+
}
|
package/src/styles/_base.css
CHANGED
package/src/styles/_call-ui.css
CHANGED
|
@@ -187,6 +187,12 @@
|
|
|
187
187
|
transform: scale(0.95);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
.ermis-call-ui__action-circle:disabled {
|
|
191
|
+
opacity: 0.7;
|
|
192
|
+
cursor: not-allowed;
|
|
193
|
+
transform: none;
|
|
194
|
+
}
|
|
195
|
+
|
|
190
196
|
.ermis-call-ui__action-circle--reject {
|
|
191
197
|
background-color: var(--ermis-color-danger);
|
|
192
198
|
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.35);
|
|
@@ -242,7 +248,7 @@
|
|
|
242
248
|
.ermis-call-ui__video-remote {
|
|
243
249
|
width: 100%;
|
|
244
250
|
height: 100%;
|
|
245
|
-
object-fit:
|
|
251
|
+
object-fit: contain;
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
.ermis-call-ui__video-local {
|
|
@@ -268,7 +274,7 @@
|
|
|
268
274
|
.ermis-call-ui__video-local-stream {
|
|
269
275
|
width: 100%;
|
|
270
276
|
height: 100%;
|
|
271
|
-
object-fit:
|
|
277
|
+
object-fit: contain;
|
|
272
278
|
transform: scaleX(-1);
|
|
273
279
|
}
|
|
274
280
|
|
|
@@ -325,6 +331,39 @@
|
|
|
325
331
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
|
326
332
|
}
|
|
327
333
|
|
|
334
|
+
/* Video call status bar: mic-muted icon + duration timer in one row */
|
|
335
|
+
.ermis-call-ui__video-timer {
|
|
336
|
+
position: absolute;
|
|
337
|
+
top: 16px;
|
|
338
|
+
left: 16px;
|
|
339
|
+
z-index: 15;
|
|
340
|
+
display: flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 6px;
|
|
343
|
+
padding: 4px 12px;
|
|
344
|
+
border-radius: var(--ermis-radius-full);
|
|
345
|
+
background: rgba(0, 0, 0, 0.45);
|
|
346
|
+
backdrop-filter: blur(12px);
|
|
347
|
+
-webkit-backdrop-filter: blur(12px);
|
|
348
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
349
|
+
color: rgba(255, 255, 255, 0.85);
|
|
350
|
+
font-size: var(--ermis-font-size-sm);
|
|
351
|
+
font-variant-numeric: tabular-nums;
|
|
352
|
+
font-weight: 500;
|
|
353
|
+
user-select: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.ermis-call-ui__video-timer-mic {
|
|
357
|
+
display: flex;
|
|
358
|
+
align-items: center;
|
|
359
|
+
color: #f87171; /* red-400 */
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.ermis-call-ui__video-timer-mic svg {
|
|
363
|
+
width: 16px;
|
|
364
|
+
height: 16px;
|
|
365
|
+
}
|
|
366
|
+
|
|
328
367
|
/* -- Audio Layout -- */
|
|
329
368
|
.ermis-call-ui__audio-container {
|
|
330
369
|
text-align: center;
|
|
@@ -741,3 +780,21 @@
|
|
|
741
780
|
transform: none;
|
|
742
781
|
}
|
|
743
782
|
}
|
|
783
|
+
|
|
784
|
+
/* ============================================================
|
|
785
|
+
SPINNER
|
|
786
|
+
============================================================ */
|
|
787
|
+
.ermis-call-ui__spinner {
|
|
788
|
+
width: 20px;
|
|
789
|
+
height: 20px;
|
|
790
|
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
791
|
+
border-radius: 50%;
|
|
792
|
+
border-top-color: #ffffff;
|
|
793
|
+
animation: ermis-call-spin 0.8s linear infinite;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
@keyframes ermis-call-spin {
|
|
797
|
+
to {
|
|
798
|
+
transform: rotate(360deg);
|
|
799
|
+
}
|
|
800
|
+
}
|