@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.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. 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
- vlistRef.current?.scrollToIndex(idx, { align: 'center', smooth: true });
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 newIdx = unique.findIndex((m: any) => m.id === messageId);
97
- if (newIdx === -1) {
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(newIdx, { align: 'center' });
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, ChannelTopicGroup } from './components/ChannelList';
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 './components/Dropdown';
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 { formatTime, getDateKey, formatDateLabel, getMessageUserId, replaceMentionsForPreview } from './utils';
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 { useMentions } from './hooks/useMentions';
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';
@@ -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
+ }
@@ -23,7 +23,6 @@
23
23
  }
24
24
 
25
25
  .ermis-avatar--fallback {
26
- background: linear-gradient(135deg, var(--ermis-accent) 0%, var(--ermis-accent-hover) 100%);
27
26
  color: #fff;
28
27
  font-weight: 600;
29
28
  font-family: var(--ermis-font-family);
@@ -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: cover;
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: cover;
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
+ }