@ermis-network/ermis-chat-react 1.0.9 → 2.0.0

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