@ermis-network/ermis-chat-react 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ermis-network/ermis-chat-react",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "React UI components for Ermis Chat",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
@@ -20,7 +20,7 @@
20
20
  "/src"
21
21
  ],
22
22
  "dependencies": {
23
- "@ermis-network/ermis-chat-sdk": "1.0.0",
23
+ "@ermis-network/ermis-chat-sdk": "1.0.3",
24
24
  "virtua": "^0.48.8"
25
25
  },
26
26
  "peerDependencies": {
@@ -1,10 +1,9 @@
1
- import React, { useMemo, useState, useEffect } from 'react';
1
+ import React, { useMemo, useState, useEffect, useContext } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { usePendingState } from '../hooks/usePendingState';
4
- import { useBannedState } from '../hooks/useBannedState';
5
- import { useBlockedState } from '../hooks/useBlockedState';
6
4
  import { Avatar } from './Avatar';
7
5
  import type { ChannelHeaderProps } from '../types';
6
+ import { ErmisCallContext } from '../context/ErmisCallContext';
8
7
 
9
8
  export type { ChannelHeaderProps } from '../types';
10
9
 
@@ -28,9 +27,15 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
28
27
  subtitle,
29
28
  renderRight,
30
29
  renderTitle,
30
+ renderAudioCallButton,
31
+ renderVideoCallButton,
32
+ audioCallTitle = 'Audio Call',
33
+ videoCallTitle = 'Video Call',
34
+ CallBadgeComponent,
31
35
  }) => {
32
- const { activeChannel, client } = useChatClient();
36
+ const { activeChannel, client, enableCall } = useChatClient();
33
37
  const { isPending } = usePendingState(activeChannel, client.userID);
38
+ const callContext = useContext(ErmisCallContext);
34
39
 
35
40
  const actionDisabled = isPending;
36
41
 
@@ -73,11 +78,47 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
73
78
  </div>
74
79
 
75
80
  {/* renderRight exposes actionDisabled for consumers to disable UI features natively */}
76
- {renderRight && (
77
- <div className="ermis-channel-header__actions">
78
- {renderRight(activeChannel, actionDisabled)}
79
- </div>
80
- )}
81
+ <div className="ermis-channel-header__actions">
82
+ {enableCall && callContext && activeChannel?.type === 'messaging' && !isPending && (
83
+ <>
84
+ {renderAudioCallButton ? (
85
+ renderAudioCallButton(() => callContext.createCall('audio', activeChannel.cid || ''), actionDisabled)
86
+ ) : (
87
+ <button
88
+ className="ermis-btn ermis-btn--icon"
89
+ disabled={actionDisabled}
90
+ onClick={() => callContext.createCall('audio', activeChannel.cid || '')}
91
+ title={audioCallTitle}
92
+ >
93
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
94
+ <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
95
+ </svg>
96
+ </button>
97
+ )}
98
+
99
+ {renderVideoCallButton ? (
100
+ renderVideoCallButton(() => callContext.createCall('video', activeChannel.cid || ''), actionDisabled)
101
+ ) : (
102
+ <button
103
+ className="ermis-btn ermis-btn--icon"
104
+ disabled={actionDisabled}
105
+ onClick={() => callContext.createCall('video', activeChannel.cid || '')}
106
+ title={videoCallTitle}
107
+ >
108
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
109
+ <polygon points="23 7 16 12 23 17 23 7"></polygon>
110
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
111
+ </svg>
112
+ </button>
113
+ )}
114
+ </>
115
+ )}
116
+ {/* C8: Active call badge */}
117
+ {enableCall && callContext && callContext.callStatus && CallBadgeComponent && (
118
+ <CallBadgeComponent callType={callContext.callType} />
119
+ )}
120
+ {renderRight && renderRight(activeChannel, actionDisabled)}
121
+ </div>
81
122
  </div>
82
123
  );
83
124
  });
@@ -18,6 +18,7 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
18
18
  */
19
19
  function getLastMessagePreview(
20
20
  channel: Channel,
21
+ myUserId?: string,
21
22
  ): { text: string; user: string } {
22
23
  const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
23
24
  if (!lastMsg) return { text: '', user: '' };
@@ -31,8 +32,8 @@ function getLastMessagePreview(
31
32
  }
32
33
 
33
34
  if (msgType === 'signal') {
34
- const userMap = buildUserMap(channel.state);
35
- return { text: parseSignalMessage(rawText, userMap), user: '' };
35
+ const result = parseSignalMessage(rawText, myUserId || '');
36
+ return { text: result?.text || rawText, user: '' };
36
37
  }
37
38
 
38
39
  // Display 'Sticker' if message is a sticker
@@ -206,7 +207,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
206
207
  // Derive last message preview computation is deferred here,
207
208
  // so it only executes when VList actually mounts this visible item
208
209
  const { text: rawLastMessageText, user: rawLastMessageUser } = useMemo(
209
- () => getLastMessagePreview(channel),
210
+ () => getLastMessagePreview(channel, currentUserId),
210
211
  // Recompute if latestMessage changes or we get a force update
211
212
  // eslint-disable-next-line react-hooks/exhaustive-deps
212
213
  [channel, channel.state?.latestMessages, updateCount]
@@ -0,0 +1,279 @@
1
+ import React, { useEffect, useState, useCallback, useRef } from 'react';
2
+ import { CallStatus, ErmisCallNode, type UserCallInfo, type CallEventData } from '@ermis-network/ermis-chat-sdk';
3
+ import { ErmisCallContext } from '../context/ErmisCallContext';
4
+ import type { ErmisCallProviderProps } from '../types';
5
+
6
+ export type { ErmisCallProviderProps } from '../types';
7
+
8
+ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
9
+ children,
10
+ client,
11
+ sessionId,
12
+ wasmPath = '/ermis_call_node_wasm_bg.wasm',
13
+ relayUrl = 'https://iroh-relay.ermis.network:8443',
14
+ onCallStart,
15
+ onCallEnd,
16
+ onCallError,
17
+ onIncomingCall,
18
+ onCallAccepted,
19
+ onCallRejected,
20
+ }) => {
21
+ const [callNode, setCallNode] = useState<ErmisCallNode | null>(null);
22
+ const [callStatus, setCallStatus] = useState<CallStatus | ''>('');
23
+ const [localStream, setLocalStream] = useState<MediaStream | null>(null);
24
+ const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
25
+ const [callType, setCallType] = useState<string>('audio');
26
+ const [callerInfo, setCallerInfo] = useState<UserCallInfo | undefined>(undefined);
27
+ const [receiverInfo, setReceiverInfo] = useState<UserCallInfo | undefined>(undefined);
28
+ const [isIncoming, setIsIncoming] = useState<boolean>(false);
29
+ const [isMicMuted, setIsMicMuted] = useState(false);
30
+ const [isVideoMuted, setIsVideoMuted] = useState(true); // Default to true until a video track is added
31
+ const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([]);
32
+ const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
33
+ const [selectedAudioDeviceId, setSelectedAudioDeviceId] = useState<string>('');
34
+ const [selectedVideoDeviceId, setSelectedVideoDeviceId] = useState<string>('');
35
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
36
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
37
+ const [isRemoteMicMuted, setIsRemoteMicMuted] = useState(false);
38
+ const [isRemoteVideoMuted, setIsRemoteVideoMuted] = useState(false);
39
+
40
+ // Call duration timer (C7 — exposed via context)
41
+ const [callDuration, setCallDuration] = useState(0);
42
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
43
+
44
+ const startTimer = useCallback(() => {
45
+ setCallDuration(0);
46
+ timerRef.current = setInterval(() => {
47
+ setCallDuration((prev) => prev + 1);
48
+ }, 1000);
49
+ }, []);
50
+
51
+ const stopTimer = useCallback(() => {
52
+ if (timerRef.current) {
53
+ clearInterval(timerRef.current);
54
+ timerRef.current = null;
55
+ }
56
+ setCallDuration(0);
57
+ }, []);
58
+
59
+ useEffect(() => {
60
+ if (callStatus === CallStatus.CONNECTED) {
61
+ startTimer();
62
+ } else {
63
+ stopTimer();
64
+ }
65
+ return () => stopTimer();
66
+ }, [callStatus, startTimer, stopTimer]);
67
+
68
+ useEffect(() => {
69
+ if (!client || !sessionId) return;
70
+
71
+ // Create new call node instance
72
+ const node = new ErmisCallNode(client, sessionId, wasmPath, relayUrl);
73
+ setCallNode(node);
74
+
75
+ // Register Call Events
76
+ node.onCallEvent = (data: CallEventData) => {
77
+ setIsIncoming(data.type === 'incoming');
78
+ setCallType(data.callType);
79
+ setCallerInfo(data.callerInfo);
80
+ setReceiverInfo(data.receiverInfo);
81
+ // C1: Lifecycle callback — incoming call
82
+ if (data.type === 'incoming' && data.callerInfo) {
83
+ onIncomingCall?.(data.callerInfo);
84
+ }
85
+ };
86
+
87
+ node.onError = (error: string) => {
88
+ setErrorMessage(error);
89
+ // C1: Lifecycle callback — error
90
+ onCallError?.(error);
91
+ };
92
+ node.onDeviceChange = (audio, video) => {
93
+ setAudioDevices(audio);
94
+ setVideoDevices(video);
95
+ };
96
+ node.onScreenShareChange = (isSharing: boolean) => setIsScreenSharing(isSharing);
97
+
98
+ node.getDevices().then(({ audioDevices: a, videoDevices: v }) => {
99
+ setAudioDevices(a);
100
+ setVideoDevices(v);
101
+ });
102
+
103
+ node.onCallStatus = (status: string | null) => {
104
+ const parsedStatus = status as CallStatus | '';
105
+ setCallStatus(parsedStatus);
106
+ if (parsedStatus === CallStatus.ENDED || !parsedStatus) {
107
+ setLocalStream(null);
108
+ setRemoteStream(null);
109
+ setIsIncoming(false);
110
+ setCallStatus('');
111
+ }
112
+ };
113
+
114
+ node.onLocalStream = (stream: MediaStream) => {
115
+ setLocalStream(stream);
116
+ const audioTracks = stream.getAudioTracks();
117
+ setIsMicMuted(audioTracks.length === 0 || !audioTracks[0].enabled);
118
+ const videoTracks = stream.getVideoTracks();
119
+ setIsVideoMuted(videoTracks.length === 0 || !videoTracks[0].enabled);
120
+ };
121
+
122
+ node.onRemoteStream = (stream: MediaStream) => setRemoteStream(stream);
123
+
124
+ // Listen for remote peer transceiver state via data channel
125
+ node.onDataChannelMessage = (state: { audio_enable?: boolean; video_enable?: boolean }) => {
126
+ if (typeof state?.audio_enable === 'boolean') {
127
+ setIsRemoteMicMuted(!state.audio_enable);
128
+ }
129
+ if (typeof state?.video_enable === 'boolean') {
130
+ setIsRemoteVideoMuted(!state.video_enable);
131
+ }
132
+ };
133
+
134
+ // Listen for remote peer requesting to upgrade call (audio → video)
135
+ // Pattern 2: Automatically switch UI layout to video without prompting.
136
+ node.onUpgradeCall = () => {
137
+ setCallType('video');
138
+ // Note: We don't turn on our own camera automatically.
139
+ };
140
+
141
+ return () => {
142
+ const cleanup = async () => {
143
+ try {
144
+ await node.endCall();
145
+ } catch (e) { } // ignore during unmount
146
+ };
147
+ cleanup();
148
+ };
149
+ }, [client, sessionId, wasmPath, relayUrl, onIncomingCall, onCallError]);
150
+
151
+ const createCall = useCallback(async (type: 'audio' | 'video', cid: string) => {
152
+ if (!callNode) return;
153
+ setCallType(type);
154
+ setIsIncoming(false);
155
+ setCallStatus(CallStatus.RINGING);
156
+ await callNode.createCall(type, cid);
157
+ // C1: Lifecycle callback — call started
158
+ onCallStart?.(type, cid);
159
+ }, [callNode, onCallStart]);
160
+
161
+ const acceptCall = useCallback(async () => {
162
+ if (callNode) await callNode.acceptCall();
163
+ // C1: Lifecycle callback — call accepted
164
+ onCallAccepted?.();
165
+ }, [callNode, onCallAccepted]);
166
+
167
+ const rejectCall = useCallback(async () => {
168
+ if (callNode) await callNode.rejectCall();
169
+ setCallStatus('');
170
+ setIsIncoming(false);
171
+ // C1: Lifecycle callback — call rejected
172
+ onCallRejected?.();
173
+ }, [callNode, onCallRejected]);
174
+
175
+ const endCall = useCallback(async () => {
176
+ if (callNode) await callNode.endCall();
177
+ // C1: Lifecycle callback — call ended (capture duration before reset)
178
+ const duration = callDuration;
179
+ setCallStatus('');
180
+ setIsIncoming(false);
181
+ setLocalStream(null);
182
+ setRemoteStream(null);
183
+ onCallEnd?.(duration);
184
+ }, [callNode, callDuration, onCallEnd]);
185
+
186
+ const toggleScreenShare = useCallback(async () => {
187
+ if (!callNode) return;
188
+ if (isScreenSharing) {
189
+ await callNode.stopScreenShare();
190
+ } else {
191
+ await callNode.startScreenShare();
192
+ }
193
+ }, [callNode, isScreenSharing]);
194
+
195
+ const switchAudioDevice = useCallback(async (deviceId: string) => {
196
+ if (!callNode) return;
197
+ const success = await callNode.switchAudioDevice(deviceId);
198
+ if (success) setSelectedAudioDeviceId(deviceId);
199
+ }, [callNode]);
200
+
201
+ const switchVideoDevice = useCallback(async (deviceId: string) => {
202
+ if (!callNode) return;
203
+ const success = await callNode.switchVideoDevice(deviceId);
204
+ if (success) setSelectedVideoDeviceId(deviceId);
205
+ }, [callNode]);
206
+
207
+ const clearError = useCallback(() => setErrorMessage(null), []);
208
+
209
+ const upgradeCall = useCallback(async () => {
210
+ if (!callNode) return;
211
+ await callNode.upgradeCall();
212
+ setCallType('video');
213
+ }, [callNode]);
214
+
215
+ const toggleMic = useCallback(async () => {
216
+ if (!callNode || !localStream) return;
217
+ const newMutedState = !isMicMuted;
218
+ await callNode.toggleMic(!newMutedState);
219
+ setIsMicMuted(newMutedState);
220
+ }, [callNode, localStream, isMicMuted]);
221
+
222
+ const toggleVideo = useCallback(async () => {
223
+ if (!callNode) return;
224
+ if (localStream) {
225
+ if (localStream.getVideoTracks().length > 0) {
226
+ const newMutedState = !isVideoMuted;
227
+ await callNode.toggleCamera(!newMutedState);
228
+ setIsVideoMuted(newMutedState);
229
+ } else {
230
+ // One-way video case: we are in a video call but our camera is off (no track).
231
+ // Clicking toggle video should add our camera track via requestUpgradeCall.
232
+ // This avoids sending the UPGRADE_CALL signal to the backend again.
233
+ await callNode.requestUpgradeCall(true);
234
+ setIsVideoMuted(false);
235
+ }
236
+ }
237
+ }, [callNode, localStream, isVideoMuted]);
238
+
239
+ const value = {
240
+ callNode,
241
+ callStatus,
242
+ localStream,
243
+ remoteStream,
244
+ callType,
245
+ callerInfo,
246
+ receiverInfo,
247
+ isIncoming,
248
+ createCall,
249
+ acceptCall,
250
+ rejectCall,
251
+ endCall,
252
+ toggleMic,
253
+ toggleVideo,
254
+ isMicMuted,
255
+ isVideoMuted,
256
+ audioDevices,
257
+ videoDevices,
258
+ selectedAudioDeviceId,
259
+ selectedVideoDeviceId,
260
+ isScreenSharing,
261
+ errorMessage,
262
+ toggleScreenShare,
263
+ switchAudioDevice,
264
+ switchVideoDevice,
265
+ clearError,
266
+ isRemoteMicMuted,
267
+ isRemoteVideoMuted,
268
+ upgradeCall,
269
+ callDuration,
270
+ };
271
+
272
+ return (
273
+ <ErmisCallContext.Provider value={value}>
274
+ {children}
275
+ </ErmisCallContext.Provider>
276
+ );
277
+ };
278
+
279
+ ErmisCallProvider.displayName = 'ErmisCallProvider';