@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,36 @@
1
+ import { useCallback } from 'react';
2
+ import { useChatClient } from './useChatClient';
3
+
4
+ export const useDownloadHandler = () => {
5
+ const { client } = useChatClient();
6
+
7
+ const downloadFile = useCallback(async (url: string | undefined, filename?: string) => {
8
+ if (!url) return;
9
+
10
+ try {
11
+ const blob = await client.downloadMedia(url);
12
+ const urlBlob = window.URL.createObjectURL(blob);
13
+
14
+ const a = document.createElement('a');
15
+ a.style.display = 'none';
16
+ a.href = urlBlob;
17
+ a.download = filename || 'file';
18
+ document.body.appendChild(a);
19
+
20
+ a.click();
21
+
22
+ // Cleanup after a delay to ensure the browser has started the download
23
+ setTimeout(() => {
24
+ if (document.body.contains(a)) {
25
+ document.body.removeChild(a);
26
+ }
27
+ window.URL.revokeObjectURL(urlBlob);
28
+ }, 1000);
29
+ } catch (err) {
30
+ console.warn('Download failed, falling back to new tab:', err);
31
+ window.open(url, '_blank', 'noopener,noreferrer');
32
+ }
33
+ }, [client]);
34
+
35
+ return { downloadFile };
36
+ };
@@ -0,0 +1,79 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { isHeicFile, isVideoFile } from '@ermis-network/ermis-chat-sdk';
3
+
4
+ export function useDragAndDrop(
5
+ onFilesDrop: (files: FileList) => void,
6
+ disabled: boolean = false
7
+ ) {
8
+ const [isDragging, setIsDragging] = useState(false);
9
+ const dragCounter = useRef(0);
10
+
11
+ const handleDragEnter = useCallback((e: DragEvent) => {
12
+ e.preventDefault();
13
+ e.stopPropagation();
14
+
15
+ if (disabled) return;
16
+
17
+ dragCounter.current += 1;
18
+
19
+ // Only allow files
20
+ if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
21
+ const hasFiles = Array.from(e.dataTransfer.items).some(
22
+ (item) => item.kind === 'file'
23
+ );
24
+ if (hasFiles) {
25
+ setIsDragging(true);
26
+ }
27
+ }
28
+ }, [disabled]);
29
+
30
+ const handleDragLeave = useCallback((e: DragEvent) => {
31
+ e.preventDefault();
32
+ e.stopPropagation();
33
+
34
+ if (disabled) return;
35
+
36
+ dragCounter.current -= 1;
37
+ if (dragCounter.current === 0) {
38
+ setIsDragging(false);
39
+ }
40
+ }, [disabled]);
41
+
42
+ const handleDragOver = useCallback((e: DragEvent) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+ }, []);
46
+
47
+ const handleDrop = useCallback((e: DragEvent) => {
48
+ e.preventDefault();
49
+ e.stopPropagation();
50
+
51
+ dragCounter.current = 0;
52
+ setIsDragging(false);
53
+
54
+ if (disabled) return;
55
+
56
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
57
+ onFilesDrop(e.dataTransfer.files);
58
+ }
59
+ }, [disabled, onFilesDrop]);
60
+
61
+ useEffect(() => {
62
+ // Attach to the entire window so anywhere the user drags a file in the chat layout, it works
63
+ window.addEventListener('dragenter', handleDragEnter);
64
+ window.addEventListener('dragleave', handleDragLeave);
65
+ window.addEventListener('dragover', handleDragOver);
66
+ window.addEventListener('drop', handleDrop);
67
+
68
+ return () => {
69
+ window.removeEventListener('dragenter', handleDragEnter);
70
+ window.removeEventListener('dragleave', handleDragLeave);
71
+ window.removeEventListener('dragover', handleDragOver);
72
+ window.removeEventListener('drop', handleDrop);
73
+ };
74
+ }, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop]);
75
+
76
+ return {
77
+ isDragging,
78
+ };
79
+ }
@@ -0,0 +1,112 @@
1
+ import { useState, useMemo, useCallback } from 'react';
2
+ import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
+ import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
4
+ import { useChatClient } from './useChatClient';
5
+ import { removeAccents } from '../utils';
6
+ import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
7
+
8
+ export function useForwardMessage(message: FormatMessageResponse, onDismiss: () => void) {
9
+ const { client, activeChannel } = useChatClient();
10
+ const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
11
+ const [search, setSearch] = useState('');
12
+ const [sending, setSending] = useState(false);
13
+ const [results, setResults] = useState<{ success: string[]; failed: string[] } | null>(null);
14
+
15
+ /* ---------- Get channels from client state (include topics) ---------- */
16
+ const channels = useMemo(() => {
17
+ return (Object.values(client.activeChannels) as Channel[]).filter((ch) => {
18
+ const role = ch.state?.membership?.channel_role as string;
19
+ return !isPendingMember(role) && !isSkippedMember(role);
20
+ });
21
+ }, [client.activeChannels]);
22
+
23
+ /* ---------- Filter by search ---------- */
24
+ const filteredChannels = useMemo(() => {
25
+ if (!search.trim()) return channels;
26
+ const q = search.toLowerCase();
27
+ const cleanQ = removeAccents(q);
28
+ const isStrict = q !== cleanQ;
29
+
30
+ return channels.filter((ch) => {
31
+ const name = (ch.data?.name || ch.cid) as string;
32
+ const t = name.toLowerCase();
33
+ const cleanT = removeAccents(t);
34
+
35
+ const parentCid = ch.data?.parent_cid as string | undefined;
36
+ const parent = parentCid ? client.activeChannels[parentCid] : null;
37
+ const parentName = parent?.data?.name || '';
38
+ const pt = parentName.toLowerCase();
39
+ const cleanPT = removeAccents(pt);
40
+
41
+ if (isStrict) {
42
+ // Strict match when query has accents
43
+ return t.includes(q) || pt.includes(q);
44
+ } else {
45
+ // Broad match when query is accent-less
46
+ return cleanT.includes(cleanQ) || cleanPT.includes(cleanQ);
47
+ }
48
+ });
49
+ }, [channels, search, client.activeChannels]);
50
+
51
+ /* ---------- Toggle selection ---------- */
52
+ const toggleChannel = useCallback((channel: Channel) => {
53
+ setSelectedChannels((prev) => {
54
+ const next = new Set(prev);
55
+ if (next.has(channel.cid)) {
56
+ next.delete(channel.cid);
57
+ } else {
58
+ next.add(channel.cid);
59
+ }
60
+ return next;
61
+ });
62
+ }, []);
63
+
64
+ /* ---------- Send forward ---------- */
65
+ const handleSend = useCallback(async () => {
66
+ if (!activeChannel || selectedChannels.size === 0 || sending) return;
67
+ setSending(true);
68
+ const success: string[] = [];
69
+ const failed: string[] = [];
70
+
71
+ for (const cid of selectedChannels) {
72
+ const targetChannel = channels.find((c) => c.cid === cid);
73
+ if (!targetChannel) continue;
74
+ try {
75
+ const forwardPayload = createForwardMessagePayload(
76
+ message,
77
+ targetChannel.cid as string,
78
+ activeChannel.cid as string,
79
+ );
80
+
81
+ await activeChannel.forwardMessage(forwardPayload, {
82
+ type: targetChannel.type,
83
+ channelID: targetChannel.id!,
84
+ });
85
+ success.push((targetChannel.data?.name || targetChannel.cid) as string);
86
+ } catch (err) {
87
+ console.error(`Failed to forward to ${cid}`, err);
88
+ failed.push((targetChannel.data?.name || targetChannel.cid) as string);
89
+ }
90
+ }
91
+
92
+ setResults({ success, failed });
93
+ setSending(false);
94
+
95
+ // Auto-close after success (short delay)
96
+ if (failed.length === 0) {
97
+ setTimeout(() => onDismiss(), 1200);
98
+ }
99
+ }, [activeChannel, selectedChannels, channels, message, sending, onDismiss]);
100
+
101
+ return {
102
+ search,
103
+ setSearch,
104
+ selectedChannels,
105
+ toggleChannel,
106
+ sending,
107
+ results,
108
+ setResults,
109
+ filteredChannels,
110
+ handleSend,
111
+ };
112
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useState, useMemo } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from './useChatClient';
4
+ import { isPendingMember } from '../channelRoleUtils';
5
+ import { isTopicChannel } from '../channelTypeUtils';
6
+
7
+ /**
8
+ * A hook that retrieves all pending invite channels from the SDK's local cache
9
+ * without triggering an extra API network query.
10
+ *
11
+ * Re-renders automatically when related events (e.g., invites, accepts, deletes) arrive.
12
+ */
13
+ export function useInviteChannels(): Channel[] {
14
+ const { client } = useChatClient();
15
+ const [updateCount, setUpdateCount] = useState(0);
16
+
17
+ useEffect(() => {
18
+ if (!client) return;
19
+
20
+ const forceUpdate = () => setUpdateCount((c) => c + 1);
21
+
22
+ const handleEvent = (event: any) => {
23
+ // If a new channel is created or we are added to it, wait for SDK initialization
24
+ const isNewChannelEvent =
25
+ event.type === 'member.added' ||
26
+ event.type === 'notification.added_to_channel' ||
27
+ event.type === 'channel.created';
28
+
29
+ if (isNewChannelEvent) {
30
+ const cid =
31
+ event.channel?.cid ||
32
+ event.cid ||
33
+ (event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
34
+
35
+ if (cid) {
36
+ console.log('[useInviteChannels] Received new channel event:', event.type, cid);
37
+ let attempts = 0;
38
+ const checkInitialized = setInterval(() => {
39
+ attempts++;
40
+ const channel = client.activeChannels[cid];
41
+ if ((channel && channel.initialized) || attempts > 30) {
42
+ console.log(
43
+ '[useInviteChannels] Channel initialized or timeout:',
44
+ cid,
45
+ 'initialized:',
46
+ channel?.initialized,
47
+ 'attempts:',
48
+ attempts,
49
+ );
50
+ clearInterval(checkInitialized);
51
+ forceUpdate();
52
+ }
53
+ }, 100);
54
+ return;
55
+ }
56
+ }
57
+ setTimeout(forceUpdate, 0);
58
+ };
59
+
60
+ const listeners = [
61
+ client.on('channels.queried', handleEvent),
62
+ client.on('notification.invite_accepted', handleEvent),
63
+ client.on('notification.invite_rejected', handleEvent),
64
+ client.on('notification.invite_messaging_skipped', handleEvent),
65
+ client.on('channel.created', handleEvent),
66
+ client.on('channel.deleted', handleEvent),
67
+ client.on('notification.channel_deleted', handleEvent),
68
+ client.on('member.added', handleEvent),
69
+ client.on('member.removed', handleEvent),
70
+ client.on('notification.added_to_channel' as any, handleEvent),
71
+ client.on('notification.invited' as any, handleEvent),
72
+ ];
73
+
74
+ return () => listeners.forEach((l) => l.unsubscribe());
75
+ }, [client]);
76
+
77
+ return useMemo(() => {
78
+ if (!client) return [];
79
+
80
+ return Object.values(client.activeChannels).filter((ch) => {
81
+ // Exclude topic channels from the invites list
82
+ if (isTopicChannel(ch)) return false;
83
+
84
+ const ms = ch.state?.membership as Record<string, unknown> | undefined;
85
+ return isPendingMember(ms?.channel_role as string);
86
+ });
87
+ }, [client, updateCount]);
88
+ }
@@ -0,0 +1,104 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useChatClient } from './useChatClient';
3
+ import { isPendingMember } from '../channelRoleUtils';
4
+ import { isTopicChannel } from '../channelTypeUtils';
5
+
6
+ export const useInviteCount = () => {
7
+ const { client } = useChatClient();
8
+ const [inviteCount, setInviteCount] = useState(0);
9
+
10
+ useEffect(() => {
11
+ if (!client || !client.user) return;
12
+
13
+ const countInvites = () => {
14
+ let count = 0;
15
+ const channels = Object.values(client.activeChannels);
16
+ const userId = client.user?.id;
17
+ if (!userId) return 0;
18
+ for (const channel of channels) {
19
+ // Exclude topic channels from the count
20
+ if (isTopicChannel(channel)) continue;
21
+
22
+ const membership = channel.state?.membership || channel.state?.members?.[userId];
23
+ if (isPendingMember(membership?.channel_role as string)) {
24
+ count++;
25
+ }
26
+ }
27
+ return count;
28
+ };
29
+
30
+ // Calculate initial count
31
+ setInviteCount(countInvites());
32
+
33
+ const handleEvent = (event: any) => {
34
+ if (
35
+ event.type === 'channel.created' &&
36
+ (event.user?.id === client.user?.id || event.user_id === client.user?.id)
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ // If a new channel is created or we are added to it, wait for SDK initialization
42
+ const isNewChannelEvent =
43
+ event.type === 'member.added' ||
44
+ event.type === 'notification.added_to_channel' ||
45
+ event.type === 'channel.created';
46
+
47
+ if (isNewChannelEvent) {
48
+ const cid =
49
+ event.channel?.cid ||
50
+ event.cid ||
51
+ (event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
52
+
53
+ if (cid) {
54
+ console.log('[useInviteCount] Received new channel event:', event.type, cid);
55
+ let attempts = 0;
56
+ const checkInitialized = setInterval(() => {
57
+ attempts++;
58
+ const channel = client.activeChannels[cid];
59
+ if ((channel && channel.initialized) || attempts > 30) {
60
+ console.log(
61
+ '[useInviteCount] Channel initialized or timeout:',
62
+ cid,
63
+ 'initialized:',
64
+ channel?.initialized,
65
+ 'attempts:',
66
+ attempts,
67
+ );
68
+ clearInterval(checkInitialized);
69
+ const newCount = countInvites();
70
+ console.log('[useInviteCount] New invite count:', newCount);
71
+ setInviteCount(newCount);
72
+ }
73
+ }, 100);
74
+ return;
75
+ }
76
+ }
77
+
78
+ // Delay slightly to ensure client.activeChannels is updated by SDK internal handlers first
79
+ setTimeout(() => {
80
+ setInviteCount(countInvites());
81
+ }, 0);
82
+ };
83
+
84
+ const listeners = [
85
+ client.on('channels.queried', handleEvent),
86
+ client.on('notification.invite_accepted', handleEvent),
87
+ client.on('notification.invite_rejected', handleEvent),
88
+ client.on('notification.invite_messaging_skipped', handleEvent),
89
+ client.on('channel.created', handleEvent),
90
+ client.on('channel.deleted', handleEvent),
91
+ client.on('notification.channel_deleted', handleEvent),
92
+ client.on('member.added', handleEvent),
93
+ client.on('member.removed', handleEvent),
94
+ client.on('notification.added_to_channel' as any, handleEvent),
95
+ client.on('notification.invited' as any, handleEvent),
96
+ ];
97
+
98
+ return () => {
99
+ listeners.forEach((l) => l.unsubscribe());
100
+ };
101
+ }, [client]);
102
+
103
+ return { inviteCount };
104
+ };
@@ -151,7 +151,6 @@ export function useMentions({
151
151
 
152
152
  for (const m of members) {
153
153
  if (m.id === currentUserId) continue; // skip self
154
- if (activeMentionIds.has(m.id)) continue; // skip already mentioned
155
154
  if (q && !m.name.toLowerCase().includes(q)) continue; // filter by query
156
155
  result.push(m);
157
156
  }
@@ -1,8 +1,9 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useChatClient } from './useChatClient';
3
3
  import { useChannelCapabilities } from './useChannelCapabilities';
4
+ import { usePreviewState } from './usePreviewState';
4
5
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
5
- import { isSignalMessage, isSystemMessage } from '../messageTypeUtils';
6
+ import { isSignalMessage, isSystemMessage, isStickerMessage } from '../messageTypeUtils';
6
7
 
7
8
  export type MessageActionList = {
8
9
  canEdit: boolean;
@@ -25,6 +26,7 @@ export type MessageActionList = {
25
26
  export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
26
27
  const { activeChannel, client } = useChatClient();
27
28
  const { isGroupChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
29
+ const { isPreviewMode } = usePreviewState(activeChannel, client?.userID);
28
30
 
29
31
  // Only depend on the specific message fields we actually read
30
32
  const messageType = message.type;
@@ -53,9 +55,10 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
53
55
 
54
56
  const isSystem = isSystemMessage(message);
55
57
  const isSignal = isSignalMessage(message);
58
+ const isSticker = isStickerMessage(message);
56
59
  const isPinned = isPinnedFlag;
57
60
 
58
- const canEdit = !isSystem && !isSignal && isOwnMessage;
61
+ const canEdit = !isPreviewMode && !isSystem && !isSignal && !isSticker && isOwnMessage;
59
62
 
60
63
  // Delete for everyone:
61
64
  // + Team channel: only the owner can perform this action natively.
@@ -63,13 +66,13 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
63
66
  const canDeleteForEveryoneTeam = isTeam && isOwner;
64
67
  const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
65
68
 
66
- const canDelete = !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
67
- const canDeleteForMe = !isSystem;
68
- const canReply = !isSystem && !isSignal;
69
- const canQuote = !isSystem && !isSignal;
70
- const canForward = !isSystem && !isSignal;
71
- const canPin = !isSystem && !isSignal;
72
- const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim());
69
+ const canDelete = !isPreviewMode && !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
70
+ const canDeleteForMe = !isPreviewMode && !isSystem;
71
+ const canReply = !isPreviewMode && !isSystem && !isSignal;
72
+ const canQuote = !isPreviewMode && !isSystem && !isSignal;
73
+ const canForward = !isPreviewMode && !isSystem && !isSignal;
74
+ const canPin = !isPreviewMode && !isSystem && !isSignal;
75
+ const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim()); // Allow copy even in preview mode
73
76
 
74
77
  const hasCapEdit = hasCapability('update-own-message');
75
78
  const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
@@ -97,5 +100,5 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
97
100
  hasCapReply,
98
101
  hasCapQuote,
99
102
  };
100
- }, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage]); // Use capabilities from hook
103
+ }, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage, isPreviewMode]); // Use capabilities from hook
101
104
  };
@@ -10,6 +10,7 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
10
10
  const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId || ''];
11
11
  return isPendingMember(membership?.channel_role as string);
12
12
  });
13
+ const [inviteUpdateCount, setInviteUpdateCount] = useState(0);
13
14
 
14
15
  useEffect(() => {
15
16
  if (!channel || !currentUserId) {
@@ -18,6 +19,10 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
18
19
  }
19
20
 
20
21
  const checkPending = () => {
22
+ // Topics are accessible by default if the user is in the parent channel.
23
+ // We ignore the individual pending invite state for topics to avoid redundant overlays.
24
+ if (channel.type === 'topic') return false;
25
+
21
26
  const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
22
27
  return isPendingMember(membership?.channel_role as string);
23
28
  };
@@ -40,29 +45,41 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
40
45
  const eventMember = event.member as Record<string, unknown>;
41
46
  const eventUser = event.user as Record<string, unknown>;
42
47
  const eventUserId = eventMember?.user_id || (eventMember?.user as Record<string, unknown>)?.id || eventUser?.id;
43
- if (eventUserId !== currentUserId) return; // Only react to own invite events
44
48
 
45
49
  const eventCid =
46
50
  event.cid ||
47
51
  (event.channel as Record<string, unknown>)?.cid ||
48
52
  (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
49
- if (eventCid === channel.cid) {
53
+
54
+ if (eventCid !== channel.cid) return; // Only react to events on this channel
55
+
56
+ // If this event is for the current user, update their membership state
57
+ if (eventUserId === currentUserId) {
50
58
  defensiveUpdateState(event);
51
- setIsPending(checkPending());
52
59
  }
60
+
61
+ // Re-check pending state regardless of which user triggered the event.
62
+ // This handles both cases:
63
+ // - Current user accepts/rejects their own invite
64
+ // - Another user accepts an invite (hide pending-invitee box on inviter's side)
65
+ setIsPending(checkPending());
66
+ setInviteUpdateCount(c => c + 1);
53
67
  };
54
68
 
55
69
  const client = channel.getClient();
56
70
  const sub1 = client.on('notification.invite_accepted', handleInviteAction);
57
71
  const sub2 = client.on('notification.invite_rejected', handleInviteAction);
58
72
  const sub3 = client.on('notification.invite_messaging_skipped', handleInviteAction);
73
+ // Public channel join sends 'member.joined' instead of 'notification.invite_accepted'
74
+ const sub4 = client.on('member.joined', handleInviteAction);
59
75
 
60
76
  return () => {
61
77
  sub1.unsubscribe();
62
78
  sub2.unsubscribe();
63
79
  sub3.unsubscribe();
80
+ sub4.unsubscribe();
64
81
  };
65
82
  }, [channel, currentUserId]);
66
83
 
67
- return { isPending };
84
+ return { isPending, inviteUpdateCount };
68
85
  }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { isPublicGroupChannel } from '../channelTypeUtils';
4
+
5
+ /**
6
+ * Hook that tracks whether the current user is previewing a public channel
7
+ * without being a member.
8
+ */
9
+ export function usePreviewState(channel: Channel | null | undefined, currentUserId?: string) {
10
+ const [isPreviewMode, setIsPreviewMode] = useState<boolean>(() => {
11
+ if (!channel || !currentUserId || !isPublicGroupChannel(channel)) return false;
12
+ const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId];
13
+ const isMembershipEmpty = !membership || Object.keys(membership).length === 0;
14
+ return isMembershipEmpty;
15
+ });
16
+
17
+ useEffect(() => {
18
+ if (!channel || !currentUserId || !isPublicGroupChannel(channel)) {
19
+ setIsPreviewMode(false);
20
+ return;
21
+ }
22
+
23
+ const checkPreviewMode = () => {
24
+ const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
25
+ const isMembershipEmpty = !membership || Object.keys(membership).length === 0;
26
+ return isMembershipEmpty;
27
+ };
28
+
29
+ // Sync initial state
30
+ setIsPreviewMode(checkPreviewMode());
31
+
32
+ const defensiveUpdateState = (event: Record<string, unknown>) => {
33
+ if (event.member && channel.state && channel.state.membership !== undefined) {
34
+ channel.state.membership = {
35
+ ...channel.state.membership,
36
+ ...(event.member as Record<string, unknown>),
37
+ } as unknown as Record<string, unknown>;
38
+ }
39
+ };
40
+
41
+ const handleMembershipChange = (event: Record<string, unknown>) => {
42
+ const eventMember = event.member as Record<string, unknown>;
43
+ const eventUser = event.user as Record<string, unknown>;
44
+ const eventUserId = eventMember?.user_id || (eventMember?.user as Record<string, unknown>)?.id || eventUser?.id;
45
+
46
+ // Only react if the event concerns the current user
47
+ if (eventUserId !== currentUserId) return;
48
+
49
+ const eventCid =
50
+ event.cid ||
51
+ (event.channel as Record<string, unknown>)?.cid ||
52
+ (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
53
+
54
+ if (eventCid === channel.cid) {
55
+ defensiveUpdateState(event);
56
+ setIsPreviewMode(checkPreviewMode());
57
+ }
58
+ };
59
+
60
+ const client = channel.getClient();
61
+ const sub1 = client.on('member.joined', handleMembershipChange);
62
+
63
+ return () => {
64
+ sub1.unsubscribe();
65
+ };
66
+ }, [channel, currentUserId]);
67
+
68
+ return { isPreviewMode };
69
+ }
@@ -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
+ }