@ermis-network/ermis-chat-sdk 1.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.
- package/README.md +0 -0
- package/dist/index.browser.cjs +7318 -0
- package/dist/index.browser.cjs.map +1 -0
- package/dist/index.browser.full-bundle.min.js +48 -0
- package/dist/index.browser.full-bundle.min.js.map +1 -0
- package/dist/index.browser.mjs +7257 -0
- package/dist/index.browser.mjs.map +1 -0
- package/dist/index.cjs +7317 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +1469 -0
- package/dist/index.d.ts +1469 -0
- package/dist/index.mjs +7256 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
- package/src/attachment_utils.ts +148 -0
- package/src/auth.ts +352 -0
- package/src/channel.ts +1704 -0
- package/src/channel_state.ts +580 -0
- package/src/client.ts +1343 -0
- package/src/client_state.ts +55 -0
- package/src/connection.ts +587 -0
- package/src/ermis_call_node.ts +948 -0
- package/src/errors.ts +60 -0
- package/src/events.ts +46 -0
- package/src/hevc_decoder_config.ts +305 -0
- package/src/index.ts +15 -0
- package/src/media_stream_receiver.ts +525 -0
- package/src/media_stream_sender.ts +400 -0
- package/src/shims/empty.ts +1 -0
- package/src/signal_message.ts +96 -0
- package/src/system_message.ts +117 -0
- package/src/token_manager.ts +48 -0
- package/src/types.ts +567 -0
- package/src/utils.ts +534 -0
- package/src/wasm/ermis_call_node_wasm.d.ts +154 -0
- package/src/wasm/ermis_call_node_wasm.js +1498 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { base64Encode, createPacketWithHeader } from './utils';
|
|
2
|
+
import { AudioConfig, INodeCall, TransceiverState, VideoConfig } from './types';
|
|
3
|
+
|
|
4
|
+
export class MediaStreamSender {
|
|
5
|
+
private videoEncoder: VideoEncoder | null = null;
|
|
6
|
+
private audioEncoder: AudioEncoder | null = null;
|
|
7
|
+
private videoReader: ReadableStreamDefaultReader<VideoFrame> | null = null;
|
|
8
|
+
|
|
9
|
+
private localStream: MediaStream | null = null;
|
|
10
|
+
|
|
11
|
+
private videoConfig: VideoConfig | null = null;
|
|
12
|
+
private audioConfig: AudioConfig | null = null;
|
|
13
|
+
|
|
14
|
+
private videoConfigSent: boolean = false;
|
|
15
|
+
private audioConfigSent: boolean = false;
|
|
16
|
+
|
|
17
|
+
private hasVideo: boolean = false;
|
|
18
|
+
private hasAudio: boolean = false;
|
|
19
|
+
|
|
20
|
+
private forceKeyFrame: boolean = false;
|
|
21
|
+
|
|
22
|
+
private nodeCall: INodeCall;
|
|
23
|
+
|
|
24
|
+
constructor(nodeCall: INodeCall) {
|
|
25
|
+
this.nodeCall = nodeCall;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bắt đầu xử lý MediaStream
|
|
30
|
+
*/
|
|
31
|
+
public async connect(address: string): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
await this.nodeCall.connect(address);
|
|
34
|
+
await this.sendConnected();
|
|
35
|
+
await this.sendConfigs();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Error starting MediaStreamSender:', error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async sendConfigs(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await this.sendTransceiverState(this.hasAudio, this.hasVideo);
|
|
44
|
+
await this.sendAudioConfig();
|
|
45
|
+
|
|
46
|
+
const videoTrack = this.localStream?.getVideoTracks()[0];
|
|
47
|
+
if (videoTrack) {
|
|
48
|
+
await this.sendVideoConfig();
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Error sending configs:', error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Dừng và reset encoders
|
|
57
|
+
*/
|
|
58
|
+
public stop = (): void => {
|
|
59
|
+
if (this.videoReader) {
|
|
60
|
+
try {
|
|
61
|
+
this.videoReader.cancel('Stream stopped').catch(() => {});
|
|
62
|
+
} catch (e) {}
|
|
63
|
+
this.videoReader = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this.videoEncoder) {
|
|
67
|
+
try {
|
|
68
|
+
if (this.videoEncoder.state !== 'closed') {
|
|
69
|
+
this.videoEncoder.reset(); // Xả frame
|
|
70
|
+
this.videoEncoder.close();
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {}
|
|
73
|
+
this.videoEncoder = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reset and close audio encoder
|
|
77
|
+
if (this.audioEncoder) {
|
|
78
|
+
try {
|
|
79
|
+
if (this.audioEncoder.state !== 'closed') {
|
|
80
|
+
this.audioEncoder.reset();
|
|
81
|
+
this.audioEncoder.close();
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {}
|
|
84
|
+
this.audioEncoder = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Reset configs and flags
|
|
88
|
+
this.videoConfig = null;
|
|
89
|
+
this.audioConfig = null;
|
|
90
|
+
|
|
91
|
+
this.videoConfigSent = false;
|
|
92
|
+
this.audioConfigSent = false;
|
|
93
|
+
this.hasVideo = false;
|
|
94
|
+
this.hasAudio = false;
|
|
95
|
+
|
|
96
|
+
if (this.localStream) {
|
|
97
|
+
this.localStream.getTracks().forEach((track) => track.stop());
|
|
98
|
+
this.localStream = null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
public initAudioEncoder = (audioTrack: MediaStreamTrack): void => {
|
|
103
|
+
this.localStream = new MediaStream([audioTrack]);
|
|
104
|
+
this.audioConfigSent = false;
|
|
105
|
+
this.hasAudio = !!audioTrack;
|
|
106
|
+
|
|
107
|
+
const audioEncoder = new AudioEncoder({
|
|
108
|
+
output: (chunk, metadata) => {
|
|
109
|
+
if (metadata?.decoderConfig && !this.audioConfigSent) {
|
|
110
|
+
let description: string | undefined = undefined;
|
|
111
|
+
if (metadata.decoderConfig.description) {
|
|
112
|
+
description = base64Encode(metadata.decoderConfig.description as ArrayBuffer);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.audioConfig = {
|
|
116
|
+
codec: metadata.decoderConfig.codec ?? 'opus',
|
|
117
|
+
sampleRate: metadata.decoderConfig.sampleRate ?? 48000,
|
|
118
|
+
numberOfChannels: metadata.decoderConfig.numberOfChannels ?? 1,
|
|
119
|
+
...(description && { description }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (chunk && this.isReadyToSendData('audio')) {
|
|
124
|
+
const data = new ArrayBuffer(chunk.byteLength);
|
|
125
|
+
chunk.copyTo(data);
|
|
126
|
+
// const timestamp = Math.floor(chunk.timestamp / 1000);
|
|
127
|
+
const timestamp = chunk.timestamp;
|
|
128
|
+
|
|
129
|
+
const packet = createPacketWithHeader(data, timestamp, 'audio', null);
|
|
130
|
+
this.sendPacketOrQueue(packet, 'audio', null);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
error: (e) => console.error('AudioEncoder error:', e),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
audioEncoder.configure({
|
|
137
|
+
codec: 'mp4a.40.2',
|
|
138
|
+
sampleRate: 48000,
|
|
139
|
+
numberOfChannels: 1,
|
|
140
|
+
bitrate: 128000,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.audioEncoder = audioEncoder;
|
|
144
|
+
this.processAudioFrames(audioTrack);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
public initVideoEncoder(videoTrack: MediaStreamTrack): void {
|
|
148
|
+
if (this.localStream) {
|
|
149
|
+
this.localStream.addTrack(videoTrack);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.videoConfigSent = false;
|
|
153
|
+
this.hasVideo = !!videoTrack;
|
|
154
|
+
|
|
155
|
+
const settings = videoTrack.getSettings();
|
|
156
|
+
const videoWidth = settings.width || 1280;
|
|
157
|
+
const videoHeight = settings.height || 720;
|
|
158
|
+
|
|
159
|
+
const videoEncoder = new VideoEncoder({
|
|
160
|
+
output: async (chunk, metadata) => {
|
|
161
|
+
if (metadata?.decoderConfig && !this.videoConfigSent) {
|
|
162
|
+
let description: string | undefined = undefined;
|
|
163
|
+
if (metadata.decoderConfig.description) {
|
|
164
|
+
description = base64Encode(metadata.decoderConfig.description as ArrayBuffer);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.videoConfig = {
|
|
168
|
+
codec: metadata.decoderConfig.codec ?? 'hev1.1.6.L93.B0',
|
|
169
|
+
codedWidth: metadata.decoderConfig.codedWidth ?? videoWidth,
|
|
170
|
+
codedHeight: metadata.decoderConfig.codedHeight ?? videoHeight,
|
|
171
|
+
frameRate: 30.0,
|
|
172
|
+
orientation: 0,
|
|
173
|
+
...(description && { description }),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await this.sendVideoConfig();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (chunk && this.isReadyToSendData('video')) {
|
|
180
|
+
const data = new ArrayBuffer(chunk.byteLength);
|
|
181
|
+
chunk.copyTo(data);
|
|
182
|
+
const frameType = chunk.type === 'key' ? 'video-key' : 'video-delta';
|
|
183
|
+
// const timestamp = Math.floor(chunk.timestamp / 1000);
|
|
184
|
+
const timestamp = chunk.timestamp;
|
|
185
|
+
|
|
186
|
+
const packet = createPacketWithHeader(data, timestamp, frameType, null);
|
|
187
|
+
this.sendPacketOrQueue(packet, 'video', frameType);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
error: (e) => console.error('VideoEncoder error:', e),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
videoEncoder.configure({
|
|
194
|
+
codec: 'hev1.1.6.L93.B0',
|
|
195
|
+
width: videoWidth,
|
|
196
|
+
height: videoHeight,
|
|
197
|
+
bitrate: 500_000,
|
|
198
|
+
framerate: 30,
|
|
199
|
+
latencyMode: 'realtime',
|
|
200
|
+
hardwareAcceleration: 'prefer-hardware',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.videoEncoder = videoEncoder;
|
|
204
|
+
this.processVideoFrames(videoTrack);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public initEncoders = (stream: MediaStream): void => {
|
|
208
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
209
|
+
const audioTrack = stream.getAudioTracks()[0];
|
|
210
|
+
|
|
211
|
+
if (audioTrack) {
|
|
212
|
+
this.initAudioEncoder(audioTrack);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (videoTrack) {
|
|
216
|
+
this.initVideoEncoder(videoTrack);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
public sendTransceiverState = async (audioEnable: boolean, videoEnable: boolean) => {
|
|
221
|
+
const state: TransceiverState = {
|
|
222
|
+
audio_enable: !!audioEnable,
|
|
223
|
+
video_enable: !!videoEnable,
|
|
224
|
+
};
|
|
225
|
+
const configPacket = createPacketWithHeader(null, null, 'transciverState', state);
|
|
226
|
+
await this.nodeCall.sendControlFrame(configPacket);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
public async replaceVideoTrack(track: MediaStreamTrack): Promise<void> {
|
|
230
|
+
// 1. Dừng reader của track cũ (quan trọng)
|
|
231
|
+
if (this.videoReader) {
|
|
232
|
+
try {
|
|
233
|
+
// Việc gọi cancel sẽ làm Promise tại dòng await read() bên dưới throw lỗi hoặc trả về done
|
|
234
|
+
await this.videoReader.cancel('Replacing track');
|
|
235
|
+
} catch (e) {
|
|
236
|
+
// Bỏ qua lỗi khi cancel
|
|
237
|
+
}
|
|
238
|
+
this.videoReader = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (track) {
|
|
242
|
+
this.processVideoFrames(track);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
public async replaceAudioTrack(track: MediaStreamTrack): Promise<void> {
|
|
247
|
+
if (track) {
|
|
248
|
+
this.processAudioFrames(track);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Yêu cầu gửi keyframe ngay lập tức (được gọi khi nhận REQUEST_KEY_FRAME từ receiver)
|
|
254
|
+
*/
|
|
255
|
+
public requestKeyFrame = (): void => {
|
|
256
|
+
console.log('📥 KeyFrame requested');
|
|
257
|
+
this.forceKeyFrame = true;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ================= PRIVATE METHODS =================
|
|
261
|
+
|
|
262
|
+
private processVideoFrames = async (videoTrack: MediaStreamTrack) => {
|
|
263
|
+
try {
|
|
264
|
+
// @ts-ignore: MediaStreamTrackProcessor is explicitly defined in WebCodecs types
|
|
265
|
+
const videoProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
|
|
266
|
+
this.videoReader = videoProcessor.readable.getReader();
|
|
267
|
+
|
|
268
|
+
let frameCounter = 0;
|
|
269
|
+
while (true) {
|
|
270
|
+
if (!this.videoReader) break;
|
|
271
|
+
|
|
272
|
+
const { done, value: frame } = await this.videoReader.read();
|
|
273
|
+
if (done) break;
|
|
274
|
+
|
|
275
|
+
if (!this.videoEncoder) {
|
|
276
|
+
frame?.close();
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (frame) {
|
|
281
|
+
frameCounter += 1;
|
|
282
|
+
const keyFrame = frameCounter % 60 === 0 || this.forceKeyFrame;
|
|
283
|
+
if (this.forceKeyFrame) {
|
|
284
|
+
console.log('📤 Sending forced KeyFrame');
|
|
285
|
+
this.forceKeyFrame = false;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
this.videoEncoder.encode(frame, { keyFrame });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error('Encode error:', err);
|
|
291
|
+
} finally {
|
|
292
|
+
frame.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (error: any) {
|
|
297
|
+
console.error(`Error processing video frames: ${error.message}`);
|
|
298
|
+
} finally {
|
|
299
|
+
if (this.videoReader) {
|
|
300
|
+
try {
|
|
301
|
+
this.videoReader.releaseLock();
|
|
302
|
+
} catch (e) {}
|
|
303
|
+
// this.videoReader = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
private processAudioFrames = async (audioTrack: MediaStreamTrack) => {
|
|
309
|
+
// @ts-ignore
|
|
310
|
+
const audioProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
|
|
311
|
+
const audioReader = audioProcessor.readable.getReader();
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
while (true) {
|
|
315
|
+
const { done, value: frame } = await audioReader.read();
|
|
316
|
+
if (done) break;
|
|
317
|
+
|
|
318
|
+
if (!this.audioEncoder) {
|
|
319
|
+
frame?.close();
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (frame) {
|
|
324
|
+
try {
|
|
325
|
+
this.audioEncoder.encode(frame);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.error('Audio Encoding error:', err);
|
|
328
|
+
} finally {
|
|
329
|
+
frame.close();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (error: any) {
|
|
334
|
+
console.error(`Error processing audio frames: ${error.message}`);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
private isReadyToSendData = (type: 'video' | 'audio'): boolean => {
|
|
339
|
+
const videoReady = !this.hasVideo || this.videoConfigSent;
|
|
340
|
+
const audioReady = !this.hasAudio || this.audioConfigSent;
|
|
341
|
+
const allConfigsSent = videoReady && audioReady;
|
|
342
|
+
|
|
343
|
+
if (type === 'video') {
|
|
344
|
+
return allConfigsSent && this.videoConfigSent;
|
|
345
|
+
} else if (type === 'audio') {
|
|
346
|
+
return allConfigsSent && this.audioConfigSent;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return false;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
private sendVideoConfig = async () => {
|
|
353
|
+
if (this.videoConfig && !this.videoConfigSent) {
|
|
354
|
+
try {
|
|
355
|
+
const configPacket = createPacketWithHeader(null, null, 'videoConfig', this.videoConfig);
|
|
356
|
+
await this.nodeCall.sendControlFrame(configPacket);
|
|
357
|
+
this.videoConfigSent = true;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.error('Error sending video config:', error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
private sendAudioConfig = async () => {
|
|
365
|
+
if (this.audioConfig && !this.audioConfigSent) {
|
|
366
|
+
try {
|
|
367
|
+
const configPacket = createPacketWithHeader(null, null, 'audioConfig', this.audioConfig);
|
|
368
|
+
await this.nodeCall.sendControlFrame(configPacket);
|
|
369
|
+
this.audioConfigSent = true;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('Error sending audio config:', error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
public sendConnected = async () => {
|
|
377
|
+
const configPacket = createPacketWithHeader(null, null, 'connected', null);
|
|
378
|
+
await this.nodeCall.sendControlFrame(configPacket);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
private sendPacketOrQueue = async (
|
|
382
|
+
packet: Uint8Array,
|
|
383
|
+
type: 'video' | 'audio',
|
|
384
|
+
frameType: 'video-key' | 'video-delta' | null,
|
|
385
|
+
) => {
|
|
386
|
+
if (!this.isReadyToSendData(type)) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (type === 'audio') {
|
|
391
|
+
await this.nodeCall.sendAudioFrame(packet);
|
|
392
|
+
} else if (type === 'video') {
|
|
393
|
+
if (frameType === 'video-key') {
|
|
394
|
+
await this.nodeCall.beginWithGop(packet);
|
|
395
|
+
} else if (frameType === 'video-delta') {
|
|
396
|
+
await this.nodeCall.sendFrame(packet);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default null;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert duration in seconds to mm:ss format.
|
|
3
|
+
*/
|
|
4
|
+
function formatDuration(durationSec: string): string {
|
|
5
|
+
const sec = parseInt(durationSec, 10);
|
|
6
|
+
if (isNaN(sec) || sec < 0) return durationSec;
|
|
7
|
+
const minutes = Math.floor(sec / 60);
|
|
8
|
+
const seconds = sec % 60;
|
|
9
|
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a user ID to a display name using the provided map.
|
|
14
|
+
* Falls back to the raw userId if no entry is found.
|
|
15
|
+
*/
|
|
16
|
+
function resolveUser(userId: string, userMap: Record<string, string>): string {
|
|
17
|
+
return userMap[userId] ?? userId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a raw signal message string into a human-readable English sentence.
|
|
22
|
+
*
|
|
23
|
+
* Signal messages represent call events. The raw format is:
|
|
24
|
+
* `"<formatId> <userID> [<param1> <param2> ...]"`
|
|
25
|
+
*
|
|
26
|
+
* @param value - Raw signal message string from the server
|
|
27
|
+
* @param userMap - Mapping of user IDs → display names
|
|
28
|
+
* @returns Parsed English text, or the original string if unknown
|
|
29
|
+
*/
|
|
30
|
+
export function parseSignalMessage(
|
|
31
|
+
value: string,
|
|
32
|
+
userMap: Record<string, string>,
|
|
33
|
+
): string {
|
|
34
|
+
if (!value || typeof value !== 'string') return value ?? '';
|
|
35
|
+
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (!trimmed) return '';
|
|
38
|
+
|
|
39
|
+
const parts = trimmed.split(' ');
|
|
40
|
+
const formatId = parts[0];
|
|
41
|
+
const userId = parts[1] ?? '';
|
|
42
|
+
const userName = userId ? resolveUser(userId, userMap) : 'User';
|
|
43
|
+
|
|
44
|
+
switch (formatId) {
|
|
45
|
+
// 1: Audio call started
|
|
46
|
+
case '1':
|
|
47
|
+
return `📞 ${userName} started an audio call.`;
|
|
48
|
+
|
|
49
|
+
// 2: Audio call missed
|
|
50
|
+
case '2':
|
|
51
|
+
return `📞 Missed audio call from ${userName}.`;
|
|
52
|
+
|
|
53
|
+
// 3: Audio call ended (caller_id ender_id duration)
|
|
54
|
+
case '3': {
|
|
55
|
+
const enderId = parts[2] ?? '';
|
|
56
|
+
const duration = parts[3] ?? '0';
|
|
57
|
+
const enderName = enderId ? resolveUser(enderId, userMap) : 'User';
|
|
58
|
+
return `📞 Audio call by ${userName}, ended by ${enderName}. Duration: ${formatDuration(duration)}.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4: Video call started
|
|
62
|
+
case '4':
|
|
63
|
+
return `📹 ${userName} started a video call.`;
|
|
64
|
+
|
|
65
|
+
// 5: Video call missed
|
|
66
|
+
case '5':
|
|
67
|
+
return `📹 Missed video call from ${userName}.`;
|
|
68
|
+
|
|
69
|
+
// 6: Video call ended (caller_id ender_id duration)
|
|
70
|
+
case '6': {
|
|
71
|
+
const enderId = parts[2] ?? '';
|
|
72
|
+
const duration = parts[3] ?? '0';
|
|
73
|
+
const enderName = enderId ? resolveUser(enderId, userMap) : 'User';
|
|
74
|
+
return `📹 Video call by ${userName}, ended by ${enderName}. Duration: ${formatDuration(duration)}.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 7: Audio call rejected
|
|
78
|
+
case '7':
|
|
79
|
+
return `📞 Audio call from ${userName} was rejected.`;
|
|
80
|
+
|
|
81
|
+
// 8: Video call rejected
|
|
82
|
+
case '8':
|
|
83
|
+
return `📹 Video call from ${userName} was rejected.`;
|
|
84
|
+
|
|
85
|
+
// 9: Audio call busy
|
|
86
|
+
case '9':
|
|
87
|
+
return `📞 Audio call from ${userName} — recipient was busy.`;
|
|
88
|
+
|
|
89
|
+
// 10: Video call busy
|
|
90
|
+
case '10':
|
|
91
|
+
return `📹 Video call from ${userName} — recipient was busy.`;
|
|
92
|
+
|
|
93
|
+
default:
|
|
94
|
+
return trimmed;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration lookup: milliseconds → human-readable text.
|
|
3
|
+
*/
|
|
4
|
+
const DURATION_MAP: Record<string, string> = {
|
|
5
|
+
'10000': '10 seconds',
|
|
6
|
+
'30000': '30 seconds',
|
|
7
|
+
'60000': '1 minute',
|
|
8
|
+
'300000': '5 minutes',
|
|
9
|
+
'900000': '15 minutes',
|
|
10
|
+
'3600000': '60 minutes',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a user ID to a display name using the provided map.
|
|
15
|
+
* Falls back to the raw userId if no entry is found.
|
|
16
|
+
*/
|
|
17
|
+
function resolveUser(userId: string, userMap: Record<string, string>): string {
|
|
18
|
+
return userMap[userId] ?? userId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a raw system message string into a human-readable English sentence.
|
|
23
|
+
*
|
|
24
|
+
* The raw format is: `"<formatId> <userID> [<param1> <param2> ...]"`
|
|
25
|
+
*
|
|
26
|
+
* @param value - Raw system message string from the server
|
|
27
|
+
* @param userMap - Mapping of user IDs → display names
|
|
28
|
+
* @returns Parsed English text, or the original string if the format is unknown
|
|
29
|
+
*/
|
|
30
|
+
export function parseSystemMessage(
|
|
31
|
+
value: string,
|
|
32
|
+
userMap: Record<string, string>,
|
|
33
|
+
): string {
|
|
34
|
+
if (!value || typeof value !== 'string') return value ?? '';
|
|
35
|
+
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (!trimmed) return '';
|
|
38
|
+
|
|
39
|
+
const parts = trimmed.split(' ');
|
|
40
|
+
const formatId = parts[0];
|
|
41
|
+
const userId = parts[1] ?? '';
|
|
42
|
+
const userName = userId ? resolveUser(userId, userMap) : 'User';
|
|
43
|
+
|
|
44
|
+
switch (formatId) {
|
|
45
|
+
// 1: userName changed the channel name to channelName (may contain spaces)
|
|
46
|
+
case '1': {
|
|
47
|
+
const channelName = parts.slice(2).join(' ');
|
|
48
|
+
return `${userName} changed the channel name to ${channelName}.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2–13, 16–17: single-user actions
|
|
52
|
+
case '2':
|
|
53
|
+
return `${userName} changed the channel avatar.`;
|
|
54
|
+
case '3':
|
|
55
|
+
return `${userName} changed the channel description.`;
|
|
56
|
+
case '4':
|
|
57
|
+
return `${userName} was removed from the channel.`;
|
|
58
|
+
case '5':
|
|
59
|
+
return `${userName} was banned.`;
|
|
60
|
+
case '6':
|
|
61
|
+
return `${userName} was unbanned.`;
|
|
62
|
+
case '7':
|
|
63
|
+
return `${userName} was promoted to moderator.`;
|
|
64
|
+
case '8':
|
|
65
|
+
return `${userName} was demoted from moderator.`;
|
|
66
|
+
case '9':
|
|
67
|
+
return `${userName}'s permissions were updated.`;
|
|
68
|
+
case '10':
|
|
69
|
+
return `${userName} joined the channel.`;
|
|
70
|
+
case '11':
|
|
71
|
+
return `${userName} declined the channel invitation.`;
|
|
72
|
+
case '12':
|
|
73
|
+
return `${userName} left the channel.`;
|
|
74
|
+
case '13':
|
|
75
|
+
return `${userName} cleared the chat history.`;
|
|
76
|
+
|
|
77
|
+
// 14: channel type change (true = public, false = private)
|
|
78
|
+
case '14': {
|
|
79
|
+
const rawType = parts[2] ?? '';
|
|
80
|
+
const channelType = rawType === 'true' ? 'public' : 'private';
|
|
81
|
+
return `${userName} changed the channel to ${channelType}.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 15: cooldown toggle / duration
|
|
85
|
+
case '15': {
|
|
86
|
+
const duration = parts[2] ?? '0';
|
|
87
|
+
if (duration === '0') {
|
|
88
|
+
return `${userName} disabled cooldown.`;
|
|
89
|
+
}
|
|
90
|
+
const durationText = DURATION_MAP[duration] ?? `${duration}ms`;
|
|
91
|
+
return `${userName} enabled cooldown for ${durationText}.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case '16':
|
|
95
|
+
return `${userName} updated the banned words.`;
|
|
96
|
+
case '17':
|
|
97
|
+
return `${userName} was added to the channel.`;
|
|
98
|
+
|
|
99
|
+
// 18: admin transfer (two user IDs)
|
|
100
|
+
case '18': {
|
|
101
|
+
const oldUserId = parts[1] ?? '';
|
|
102
|
+
const newUserId = parts[2] ?? '';
|
|
103
|
+
const oldUserName = oldUserId ? resolveUser(oldUserId, userMap) : 'User';
|
|
104
|
+
const newUserName = newUserId ? resolveUser(newUserId, userMap) : 'User';
|
|
105
|
+
return `Admin ${oldUserName} left and assigned ${newUserName} as the new admin.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 19–20: pin / unpin (userId + msgID)
|
|
109
|
+
case '19':
|
|
110
|
+
return `${userName} pinned a message.`;
|
|
111
|
+
case '20':
|
|
112
|
+
return `${userName} unpinned a message.`;
|
|
113
|
+
|
|
114
|
+
default:
|
|
115
|
+
return trimmed;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ExtendableGenerics, DefaultGenerics, UserResponse } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TokenManager
|
|
5
|
+
*
|
|
6
|
+
* Manages token storage and retrieval for the chat client.
|
|
7
|
+
*/
|
|
8
|
+
export class TokenManager<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
|
|
9
|
+
loadTokenPromise: Promise<string> | null;
|
|
10
|
+
token?: string;
|
|
11
|
+
user?: UserResponse<ErmisChatGenerics>;
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
this.loadTokenPromise = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Set the static string token.
|
|
19
|
+
*/
|
|
20
|
+
setTokenOrProvider = async (tokenOrProvider: string | null, user: UserResponse<ErmisChatGenerics>) => {
|
|
21
|
+
this.user = user;
|
|
22
|
+
|
|
23
|
+
if (typeof tokenOrProvider === 'string') {
|
|
24
|
+
this.token = tokenOrProvider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.loadTokenPromise = Promise.resolve(this.token as string);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resets the token manager.
|
|
32
|
+
*/
|
|
33
|
+
reset = () => {
|
|
34
|
+
this.token = undefined;
|
|
35
|
+
this.user = undefined;
|
|
36
|
+
this.loadTokenPromise = null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves when token is ready.
|
|
41
|
+
*/
|
|
42
|
+
tokenReady = () => this.loadTokenPromise;
|
|
43
|
+
|
|
44
|
+
/** Returns the current token */
|
|
45
|
+
getToken = () => {
|
|
46
|
+
return this.token;
|
|
47
|
+
};
|
|
48
|
+
}
|