@ermis-network/ermis-chat-sdk 1.0.8 → 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.
- package/bin/init-call.js +9 -0
- package/dist/index.browser.cjs +778 -1628
- package/dist/index.browser.cjs.map +1 -1
- package/dist/index.browser.full-bundle.min.js +16 -18
- package/dist/index.browser.full-bundle.min.js.map +1 -1
- package/dist/index.browser.mjs +780 -1630
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.cjs +778 -1628
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +173 -40
- package/dist/index.d.ts +173 -40
- package/dist/index.mjs +780 -1630
- package/dist/index.mjs.map +1 -1
- package/dist/wasm_worker.worker.mjs +1596 -0
- package/dist/wasm_worker.worker.mjs.map +1 -0
- package/package.json +2 -2
- package/public/ermis_call_node_wasm_bg.wasm +0 -0
- package/src/channel.ts +117 -44
- package/src/channel_state.ts +6 -1
- package/src/client.ts +198 -56
- package/src/ermis_call_node.ts +123 -55
- package/src/index.ts +2 -1
- package/src/media_stream_receiver.ts +103 -35
- package/src/media_stream_sender.ts +72 -7
- package/src/signal_message.ts +48 -23
- package/src/system_message.ts +169 -27
- package/src/types.ts +13 -0
- package/src/utils.ts +22 -3
- package/src/wasm/ermis_call_node_wasm.d.ts +80 -78
- package/src/wasm/ermis_call_node_wasm.js +1427 -1357
- package/src/wasm_worker.ts +219 -0
- package/src/wasm_worker_proxy.ts +244 -0
|
@@ -5,6 +5,7 @@ export class MediaStreamSender {
|
|
|
5
5
|
private videoEncoder: VideoEncoder | null = null;
|
|
6
6
|
private audioEncoder: AudioEncoder | null = null;
|
|
7
7
|
private videoReader: ReadableStreamDefaultReader<VideoFrame> | null = null;
|
|
8
|
+
private audioReader: ReadableStreamDefaultReader<AudioData> | null = null;
|
|
8
9
|
|
|
9
10
|
private localStream: MediaStream | null = null;
|
|
10
11
|
|
|
@@ -18,9 +19,13 @@ export class MediaStreamSender {
|
|
|
18
19
|
private hasAudio: boolean = false;
|
|
19
20
|
|
|
20
21
|
private forceKeyFrame: boolean = false;
|
|
22
|
+
private isSendingVideo: boolean = false;
|
|
23
|
+
private isSendingAudio: boolean = false;
|
|
21
24
|
|
|
22
25
|
private nodeCall: INodeCall;
|
|
23
26
|
|
|
27
|
+
private healthCallInterval: ReturnType<typeof setInterval> | null = null;
|
|
28
|
+
|
|
24
29
|
constructor(nodeCall: INodeCall) {
|
|
25
30
|
this.nodeCall = nodeCall;
|
|
26
31
|
}
|
|
@@ -33,6 +38,9 @@ export class MediaStreamSender {
|
|
|
33
38
|
await this.nodeCall.connect(address);
|
|
34
39
|
await this.sendConnected();
|
|
35
40
|
await this.sendConfigs();
|
|
41
|
+
|
|
42
|
+
// Start health call keep-alive (every 5s, matching native SDK)
|
|
43
|
+
this.startHealthCallInterval();
|
|
36
44
|
} catch (error) {
|
|
37
45
|
console.error('Error starting MediaStreamSender:', error);
|
|
38
46
|
}
|
|
@@ -56,6 +64,12 @@ export class MediaStreamSender {
|
|
|
56
64
|
* Dừng và reset encoders
|
|
57
65
|
*/
|
|
58
66
|
public stop = (): void => {
|
|
67
|
+
// Stop health call keep-alive
|
|
68
|
+
if (this.healthCallInterval) {
|
|
69
|
+
clearInterval(this.healthCallInterval);
|
|
70
|
+
this.healthCallInterval = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
if (this.videoReader) {
|
|
60
74
|
try {
|
|
61
75
|
this.videoReader.cancel('Stream stopped').catch(() => {});
|
|
@@ -73,7 +87,6 @@ export class MediaStreamSender {
|
|
|
73
87
|
this.videoEncoder = null;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
// Reset and close audio encoder
|
|
77
90
|
if (this.audioEncoder) {
|
|
78
91
|
try {
|
|
79
92
|
if (this.audioEncoder.state !== 'closed') {
|
|
@@ -84,6 +97,13 @@ export class MediaStreamSender {
|
|
|
84
97
|
this.audioEncoder = null;
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
if (this.audioReader) {
|
|
101
|
+
try {
|
|
102
|
+
this.audioReader.cancel('Stream stopped').catch(() => {});
|
|
103
|
+
} catch (e) {}
|
|
104
|
+
this.audioReader = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
// Reset configs and flags
|
|
88
108
|
this.videoConfig = null;
|
|
89
109
|
this.audioConfig = null;
|
|
@@ -118,16 +138,22 @@ export class MediaStreamSender {
|
|
|
118
138
|
numberOfChannels: metadata.decoderConfig.numberOfChannels ?? 1,
|
|
119
139
|
...(description && { description }),
|
|
120
140
|
};
|
|
141
|
+
|
|
142
|
+
this.sendAudioConfig();
|
|
121
143
|
}
|
|
122
144
|
|
|
123
145
|
if (chunk && this.isReadyToSendData('audio')) {
|
|
146
|
+
if (this.isSendingAudio) return; // Backpressure: drop if network is congested
|
|
147
|
+
|
|
148
|
+
this.isSendingAudio = true;
|
|
124
149
|
const data = new ArrayBuffer(chunk.byteLength);
|
|
125
150
|
chunk.copyTo(data);
|
|
126
|
-
// const timestamp = Math.floor(chunk.timestamp / 1000);
|
|
127
151
|
const timestamp = chunk.timestamp;
|
|
128
152
|
|
|
129
153
|
const packet = createPacketWithHeader(data, timestamp, 'audio', null);
|
|
130
|
-
this.sendPacketOrQueue(packet, 'audio', null)
|
|
154
|
+
this.sendPacketOrQueue(packet, 'audio', null).finally(() => {
|
|
155
|
+
this.isSendingAudio = false;
|
|
156
|
+
});
|
|
131
157
|
}
|
|
132
158
|
},
|
|
133
159
|
error: (e) => console.error('AudioEncoder error:', e),
|
|
@@ -135,9 +161,11 @@ export class MediaStreamSender {
|
|
|
135
161
|
|
|
136
162
|
audioEncoder.configure({
|
|
137
163
|
codec: 'mp4a.40.2',
|
|
164
|
+
// codec: 'opus',
|
|
138
165
|
sampleRate: 48000,
|
|
139
166
|
numberOfChannels: 1,
|
|
140
167
|
bitrate: 128000,
|
|
168
|
+
// bitrate: 64000,
|
|
141
169
|
});
|
|
142
170
|
|
|
143
171
|
this.audioEncoder = audioEncoder;
|
|
@@ -177,14 +205,18 @@ export class MediaStreamSender {
|
|
|
177
205
|
}
|
|
178
206
|
|
|
179
207
|
if (chunk && this.isReadyToSendData('video')) {
|
|
208
|
+
if (this.isSendingVideo) return; // Backpressure: drop if network is congested
|
|
209
|
+
|
|
210
|
+
this.isSendingVideo = true;
|
|
180
211
|
const data = new ArrayBuffer(chunk.byteLength);
|
|
181
212
|
chunk.copyTo(data);
|
|
182
213
|
const frameType = chunk.type === 'key' ? 'video-key' : 'video-delta';
|
|
183
|
-
// const timestamp = Math.floor(chunk.timestamp / 1000);
|
|
184
214
|
const timestamp = chunk.timestamp;
|
|
185
215
|
|
|
186
216
|
const packet = createPacketWithHeader(data, timestamp, frameType, null);
|
|
187
|
-
this.sendPacketOrQueue(packet, 'video', frameType)
|
|
217
|
+
this.sendPacketOrQueue(packet, 'video', frameType).finally(() => {
|
|
218
|
+
this.isSendingVideo = false;
|
|
219
|
+
});
|
|
188
220
|
}
|
|
189
221
|
},
|
|
190
222
|
error: (e) => console.error('VideoEncoder error:', e),
|
|
@@ -244,6 +276,13 @@ export class MediaStreamSender {
|
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
public async replaceAudioTrack(track: MediaStreamTrack): Promise<void> {
|
|
279
|
+
if (this.audioReader) {
|
|
280
|
+
try {
|
|
281
|
+
await this.audioReader.cancel('Replacing audio track');
|
|
282
|
+
} catch (e) {}
|
|
283
|
+
this.audioReader = null;
|
|
284
|
+
}
|
|
285
|
+
|
|
247
286
|
if (track) {
|
|
248
287
|
this.processAudioFrames(track);
|
|
249
288
|
}
|
|
@@ -308,11 +347,13 @@ export class MediaStreamSender {
|
|
|
308
347
|
private processAudioFrames = async (audioTrack: MediaStreamTrack) => {
|
|
309
348
|
// @ts-ignore
|
|
310
349
|
const audioProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
|
|
311
|
-
|
|
350
|
+
this.audioReader = audioProcessor.readable.getReader();
|
|
312
351
|
|
|
313
352
|
try {
|
|
314
353
|
while (true) {
|
|
315
|
-
|
|
354
|
+
if (!this.audioReader) break;
|
|
355
|
+
|
|
356
|
+
const { done, value: frame } = await this.audioReader.read();
|
|
316
357
|
if (done) break;
|
|
317
358
|
|
|
318
359
|
if (!this.audioEncoder) {
|
|
@@ -332,6 +373,12 @@ export class MediaStreamSender {
|
|
|
332
373
|
}
|
|
333
374
|
} catch (error: any) {
|
|
334
375
|
console.error(`Error processing audio frames: ${error.message}`);
|
|
376
|
+
} finally {
|
|
377
|
+
if (this.audioReader) {
|
|
378
|
+
try {
|
|
379
|
+
this.audioReader.releaseLock();
|
|
380
|
+
} catch (e) {}
|
|
381
|
+
}
|
|
335
382
|
}
|
|
336
383
|
};
|
|
337
384
|
|
|
@@ -378,6 +425,24 @@ export class MediaStreamSender {
|
|
|
378
425
|
await this.nodeCall.sendControlFrame(configPacket);
|
|
379
426
|
};
|
|
380
427
|
|
|
428
|
+
private startHealthCallInterval = (): void => {
|
|
429
|
+
if (this.healthCallInterval) {
|
|
430
|
+
clearInterval(this.healthCallInterval);
|
|
431
|
+
}
|
|
432
|
+
this.healthCallInterval = setInterval(() => {
|
|
433
|
+
this.sendHealthCall().catch(() => {});
|
|
434
|
+
}, 5000);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
private sendHealthCall = async (): Promise<void> => {
|
|
438
|
+
try {
|
|
439
|
+
const packet = createPacketWithHeader(null, null, 'healthCall', null);
|
|
440
|
+
await this.nodeCall.sendControlFrame(packet);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
// Silently ignore — connection may have closed
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
381
446
|
private sendPacketOrQueue = async (
|
|
382
447
|
packet: Uint8Array,
|
|
383
448
|
type: 'video' | 'audio',
|
package/src/signal_message.ts
CHANGED
|
@@ -19,32 +19,57 @@ export interface SignalMessageResult {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Translation templates for signal messages.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
export interface SignalMessageTranslations {
|
|
25
|
+
calling?: string; // "Calling..."
|
|
26
|
+
incomingAudioCall?: string; // "Incoming audio call..."
|
|
27
|
+
incomingVideoCall?: string; // "Incoming video call..."
|
|
28
|
+
outgoingAudioCall?: string; // "Outgoing audio call"
|
|
29
|
+
outgoingVideoCall?: string; // "Outgoing video call"
|
|
30
|
+
missedAudioCall?: string; // "You missed audio call"
|
|
31
|
+
missedVideoCall?: string; // "You missed video call"
|
|
32
|
+
cancelAudioCall?: string; // "You cancel audio call"
|
|
33
|
+
cancelVideoCall?: string; // "You cancel video call"
|
|
34
|
+
rejectedAudioCallRecipient?: string; // "Recipient rejected audio call"
|
|
35
|
+
rejectedAudioCallYou?: string; // "You rejected audio call"
|
|
36
|
+
rejectedVideoCallRecipient?: string; // "Recipient rejected video call"
|
|
37
|
+
rejectedVideoCallYou?: string; // "You rejected video call"
|
|
38
|
+
busyRecipient?: string; // "Recipient was busy"
|
|
39
|
+
durationUnitMin?: string; // "min"
|
|
40
|
+
durationUnitSec?: string; // "sec"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format duration from milliseconds to human-readable format.
|
|
45
|
+
*/
|
|
46
|
+
function formatDuration(durationMs: string, translations?: SignalMessageTranslations): string {
|
|
25
47
|
if (!durationMs) return '';
|
|
26
48
|
const ms = parseInt(durationMs, 10);
|
|
27
49
|
if (isNaN(ms) || ms <= 0) return '';
|
|
28
50
|
const totalSeconds = Math.floor(ms / 1000);
|
|
29
51
|
const minutes = Math.floor(totalSeconds / 60);
|
|
30
52
|
const seconds = totalSeconds % 60;
|
|
31
|
-
|
|
53
|
+
const minUnit = translations?.durationUnitMin ?? 'min';
|
|
54
|
+
const secUnit = translations?.durationUnitSec ?? 'sec';
|
|
55
|
+
return `${minutes} ${minUnit}, ${seconds} ${secUnit}`;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
/**
|
|
35
|
-
* Parse a raw signal message string into a structured object
|
|
36
|
-
* containing text, duration, call type, and color.
|
|
59
|
+
* Parse a raw signal message string into a structured object.
|
|
37
60
|
*
|
|
38
61
|
* Signal messages represent call events. The raw format is:
|
|
39
62
|
* `"<formatId> <callerId> [<enderId> <duration>]"`
|
|
40
63
|
*
|
|
41
|
-
* @param value
|
|
42
|
-
* @param myUserId
|
|
43
|
-
* @
|
|
64
|
+
* @param value - Raw signal message string from the server
|
|
65
|
+
* @param myUserId - The current user's ID
|
|
66
|
+
* @param translations - Optional translation templates
|
|
67
|
+
* @returns Parsed signal message object, or null if input is empty
|
|
44
68
|
*/
|
|
45
69
|
export function parseSignalMessage(
|
|
46
70
|
value: string,
|
|
47
71
|
myUserId: string,
|
|
72
|
+
translations?: SignalMessageTranslations,
|
|
48
73
|
): SignalMessageResult | null {
|
|
49
74
|
if (!value || typeof value !== 'string') return null;
|
|
50
75
|
|
|
@@ -69,70 +94,70 @@ export function parseSignalMessage(
|
|
|
69
94
|
let text: string;
|
|
70
95
|
switch (number) {
|
|
71
96
|
case 1: // AudioCallStarted
|
|
72
|
-
text = isMe ? 'Calling...' : 'Incoming audio call...';
|
|
97
|
+
text = isMe ? (translations?.calling ?? 'Calling...') : (translations?.incomingAudioCall ?? 'Incoming audio call...');
|
|
73
98
|
callType = CallType.AUDIO;
|
|
74
99
|
color = '#54D62C';
|
|
75
100
|
break;
|
|
76
101
|
case 2: // AudioCallMissed
|
|
77
|
-
text = isMe ? 'Outgoing audio call' : 'You missed audio call';
|
|
102
|
+
text = isMe ? (translations?.outgoingAudioCall ?? 'Outgoing audio call') : (translations?.missedAudioCall ?? 'You missed audio call');
|
|
78
103
|
callType = CallType.AUDIO;
|
|
79
104
|
color = '#FF4842';
|
|
80
105
|
break;
|
|
81
106
|
case 3: // AudioCallEnded
|
|
82
107
|
if (duration) {
|
|
83
|
-
text = isMe ? 'Outgoing audio call' : 'Incoming audio call';
|
|
108
|
+
text = isMe ? (translations?.outgoingAudioCall ?? 'Outgoing audio call') : (translations?.incomingAudioCall ?? 'Incoming audio call');
|
|
84
109
|
color = '#54D62C';
|
|
85
110
|
} else {
|
|
86
111
|
if (enderId === myUserId) {
|
|
87
|
-
text = 'You cancel audio call';
|
|
112
|
+
text = translations?.cancelAudioCall ?? 'You cancel audio call';
|
|
88
113
|
} else {
|
|
89
|
-
text = 'You missed audio call';
|
|
114
|
+
text = translations?.missedAudioCall ?? 'You missed audio call';
|
|
90
115
|
}
|
|
91
116
|
color = '#FF4842';
|
|
92
117
|
}
|
|
93
118
|
callType = CallType.AUDIO;
|
|
94
119
|
break;
|
|
95
120
|
case 4: // VideoCallStarted
|
|
96
|
-
text = isMe ? 'Calling...' : 'Incoming video call...';
|
|
121
|
+
text = isMe ? (translations?.calling ?? 'Calling...') : (translations?.incomingVideoCall ?? 'Incoming video call...');
|
|
97
122
|
callType = CallType.VIDEO;
|
|
98
123
|
color = '#54D62C';
|
|
99
124
|
break;
|
|
100
125
|
case 5: // VideoCallMissed
|
|
101
|
-
text = isMe ? 'Outgoing video call' : 'You missed video call';
|
|
126
|
+
text = isMe ? (translations?.outgoingVideoCall ?? 'Outgoing video call') : (translations?.missedVideoCall ?? 'You missed video call');
|
|
102
127
|
callType = CallType.VIDEO;
|
|
103
128
|
color = '#FF4842';
|
|
104
129
|
break;
|
|
105
130
|
case 6: // VideoCallEnded
|
|
106
131
|
if (duration) {
|
|
107
|
-
text = isMe ? 'Outgoing video call' : 'Incoming video call';
|
|
132
|
+
text = isMe ? (translations?.outgoingVideoCall ?? 'Outgoing video call') : (translations?.incomingVideoCall ?? 'Incoming video call');
|
|
108
133
|
color = '#54D62C';
|
|
109
134
|
} else {
|
|
110
135
|
if (enderId === myUserId) {
|
|
111
|
-
text = 'You cancel video call';
|
|
136
|
+
text = translations?.cancelVideoCall ?? 'You cancel video call';
|
|
112
137
|
} else {
|
|
113
|
-
text = 'You missed video call';
|
|
138
|
+
text = translations?.missedVideoCall ?? 'You missed video call';
|
|
114
139
|
}
|
|
115
140
|
color = '#FF4842';
|
|
116
141
|
}
|
|
117
142
|
callType = CallType.VIDEO;
|
|
118
143
|
break;
|
|
119
144
|
case 7: // AudioCallRejected
|
|
120
|
-
text = isMe ? 'Recipient rejected audio call' : 'You rejected audio call';
|
|
145
|
+
text = isMe ? (translations?.rejectedAudioCallRecipient ?? 'Recipient rejected audio call') : (translations?.rejectedAudioCallYou ?? 'You rejected audio call');
|
|
121
146
|
callType = CallType.AUDIO;
|
|
122
147
|
color = '#FF4842';
|
|
123
148
|
break;
|
|
124
149
|
case 8: // VideoCallRejected
|
|
125
|
-
text = isMe ? 'Recipient rejected video call' : 'You rejected video call';
|
|
150
|
+
text = isMe ? (translations?.rejectedVideoCallRecipient ?? 'Recipient rejected video call') : (translations?.rejectedVideoCallYou ?? 'You rejected video call');
|
|
126
151
|
callType = CallType.VIDEO;
|
|
127
152
|
color = '#FF4842';
|
|
128
153
|
break;
|
|
129
154
|
case 9: // AudioCallBusy
|
|
130
|
-
text = isMe ? 'Recipient was busy' : 'You missed audio call';
|
|
155
|
+
text = isMe ? (translations?.busyRecipient ?? 'Recipient was busy') : (translations?.missedAudioCall ?? 'You missed audio call');
|
|
131
156
|
callType = CallType.AUDIO;
|
|
132
157
|
color = '#FF4842';
|
|
133
158
|
break;
|
|
134
159
|
case 10: // VideoCallBusy
|
|
135
|
-
text = isMe ? 'Recipient was busy' : 'You missed video call';
|
|
160
|
+
text = isMe ? (translations?.busyRecipient ?? 'Recipient was busy') : (translations?.missedVideoCall ?? 'You missed video call');
|
|
136
161
|
callType = CallType.VIDEO;
|
|
137
162
|
color = '#FF4842';
|
|
138
163
|
break;
|
|
@@ -142,5 +167,5 @@ export function parseSignalMessage(
|
|
|
142
167
|
color = '';
|
|
143
168
|
}
|
|
144
169
|
|
|
145
|
-
return { text, duration: formatDuration(duration), callType, color };
|
|
170
|
+
return { text, duration: formatDuration(duration, translations), callType, color };
|
|
146
171
|
}
|
package/src/system_message.ts
CHANGED
|
@@ -19,17 +19,78 @@ function resolveUser(userId: string, userMap: Record<string, string>): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Translation templates for system messages.
|
|
23
|
+
* Placeholders: {{user}}, {{channel}}, {{type}}, {{duration}}, {{targetUser}}, {{admin}}
|
|
24
|
+
*/
|
|
25
|
+
export interface SystemMessageTranslations {
|
|
26
|
+
// Format IDs as keys
|
|
27
|
+
'1'?: string;
|
|
28
|
+
'2'?: string;
|
|
29
|
+
'3'?: string;
|
|
30
|
+
'4'?: string;
|
|
31
|
+
'5'?: string;
|
|
32
|
+
'6'?: string;
|
|
33
|
+
'7'?: string;
|
|
34
|
+
'8'?: string;
|
|
35
|
+
'9'?: string;
|
|
36
|
+
'10'?: string;
|
|
37
|
+
'11'?: string;
|
|
38
|
+
'12'?: string;
|
|
39
|
+
'13'?: string;
|
|
40
|
+
'14'?: string;
|
|
41
|
+
'15_on'?: string;
|
|
42
|
+
'15_off'?: string;
|
|
43
|
+
'16'?: string;
|
|
44
|
+
'17'?: string;
|
|
45
|
+
'18'?: string;
|
|
46
|
+
'19'?: string;
|
|
47
|
+
'20'?: string;
|
|
48
|
+
|
|
49
|
+
// Semantic aliases
|
|
50
|
+
changeName?: string; // 1
|
|
51
|
+
changeAvatar?: string; // 2
|
|
52
|
+
changeDescription?: string; // 3
|
|
53
|
+
removed?: string; // 4
|
|
54
|
+
banned?: string; // 5
|
|
55
|
+
unbanned?: string; // 6
|
|
56
|
+
promoted?: string; // 7
|
|
57
|
+
demoted?: string; // 8
|
|
58
|
+
permissionsUpdated?: string; // 9
|
|
59
|
+
joined?: string; // 10
|
|
60
|
+
declined?: string; // 11
|
|
61
|
+
left?: string; // 12
|
|
62
|
+
clearedHistory?: string; // 13
|
|
63
|
+
changeType?: string; // 14
|
|
64
|
+
cooldownOn?: string; // 15_on
|
|
65
|
+
cooldownOff?: string; // 15_off
|
|
66
|
+
bannedWordsUpdated?: string; // 16
|
|
67
|
+
added?: string; // 17
|
|
68
|
+
adminTransfer?: string; // 18
|
|
69
|
+
pinned?: string; // 19
|
|
70
|
+
unpinned?: string; // 20
|
|
71
|
+
|
|
72
|
+
public?: string;
|
|
73
|
+
private?: string;
|
|
74
|
+
userFallback?: string;
|
|
75
|
+
adminFallback?: string;
|
|
76
|
+
durationUnitMin?: string; // "minute" / "minutes"
|
|
77
|
+
durationUnitSec?: string; // "second" / "seconds"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a raw system message string into a human-readable sentence.
|
|
23
82
|
*
|
|
24
83
|
* The raw format is: `"<formatId> <userID> [<param1> <param2> ...]"`
|
|
25
84
|
*
|
|
26
|
-
* @param value
|
|
27
|
-
* @param userMap
|
|
28
|
-
* @
|
|
85
|
+
* @param value - Raw system message string from the server
|
|
86
|
+
* @param userMap - Mapping of user IDs → display names
|
|
87
|
+
* @param translations - Optional translation templates
|
|
88
|
+
* @returns Parsed text, or the original string if the format is unknown
|
|
29
89
|
*/
|
|
30
90
|
export function parseSystemMessage(
|
|
31
91
|
value: string,
|
|
32
92
|
userMap: Record<string, string>,
|
|
93
|
+
translations?: SystemMessageTranslations,
|
|
33
94
|
): string {
|
|
34
95
|
if (!value || typeof value !== 'string') return value ?? '';
|
|
35
96
|
|
|
@@ -39,77 +100,158 @@ export function parseSystemMessage(
|
|
|
39
100
|
const parts = trimmed.split(' ');
|
|
40
101
|
const formatId = parts[0];
|
|
41
102
|
const userId = parts[1] ?? '';
|
|
42
|
-
const userName = userId ? resolveUser(userId, userMap) : 'User';
|
|
103
|
+
const userName = userId ? resolveUser(userId, userMap) : (translations?.userFallback ?? 'User');
|
|
43
104
|
|
|
44
105
|
switch (formatId) {
|
|
45
106
|
// 1: userName changed the channel name to channelName (may contain spaces)
|
|
46
107
|
case '1': {
|
|
47
108
|
const channelName = parts.slice(2).join(' ');
|
|
109
|
+
const template = translations?.['1'] ?? translations?.changeName;
|
|
110
|
+
if (template) {
|
|
111
|
+
return template.replace('{{user}}', userName).replace('{{channel}}', channelName);
|
|
112
|
+
}
|
|
48
113
|
return `${userName} changed the channel name to ${channelName}.`;
|
|
49
114
|
}
|
|
50
115
|
|
|
51
116
|
// 2–13, 16–17: single-user actions
|
|
52
|
-
case '2':
|
|
117
|
+
case '2': {
|
|
118
|
+
const template = translations?.['2'] ?? translations?.changeAvatar;
|
|
119
|
+
if (template) return template.replace('{{user}}', userName);
|
|
53
120
|
return `${userName} changed the channel avatar.`;
|
|
54
|
-
|
|
121
|
+
}
|
|
122
|
+
case '3': {
|
|
123
|
+
const template = translations?.['3'] ?? translations?.changeDescription;
|
|
124
|
+
if (template) return template.replace('{{user}}', userName);
|
|
55
125
|
return `${userName} changed the channel description.`;
|
|
56
|
-
|
|
126
|
+
}
|
|
127
|
+
case '4': {
|
|
128
|
+
const template = translations?.['4'] ?? translations?.removed;
|
|
129
|
+
if (template) return template.replace('{{user}}', userName);
|
|
57
130
|
return `${userName} was removed from the channel.`;
|
|
58
|
-
|
|
131
|
+
}
|
|
132
|
+
case '5': {
|
|
133
|
+
const template = translations?.['5'] ?? translations?.banned;
|
|
134
|
+
if (template) return template.replace('{{user}}', userName);
|
|
59
135
|
return `${userName} was banned.`;
|
|
60
|
-
|
|
136
|
+
}
|
|
137
|
+
case '6': {
|
|
138
|
+
const template = translations?.['6'] ?? translations?.unbanned;
|
|
139
|
+
if (template) return template.replace('{{user}}', userName);
|
|
61
140
|
return `${userName} was unbanned.`;
|
|
62
|
-
|
|
141
|
+
}
|
|
142
|
+
case '7': {
|
|
143
|
+
const template = translations?.['7'] ?? translations?.promoted;
|
|
144
|
+
if (template) return template.replace('{{user}}', userName);
|
|
63
145
|
return `${userName} was promoted to moderator.`;
|
|
64
|
-
|
|
146
|
+
}
|
|
147
|
+
case '8': {
|
|
148
|
+
const template = translations?.['8'] ?? translations?.demoted;
|
|
149
|
+
if (template) return template.replace('{{user}}', userName);
|
|
65
150
|
return `${userName} was demoted from moderator.`;
|
|
66
|
-
|
|
151
|
+
}
|
|
152
|
+
case '9': {
|
|
153
|
+
const template = translations?.['9'] ?? translations?.permissionsUpdated;
|
|
154
|
+
if (template) return template.replace('{{user}}', userName);
|
|
67
155
|
return `${userName}'s permissions were updated.`;
|
|
68
|
-
|
|
156
|
+
}
|
|
157
|
+
case '10': {
|
|
158
|
+
const template = translations?.['10'] ?? translations?.joined;
|
|
159
|
+
if (template) return template.replace('{{user}}', userName);
|
|
69
160
|
return `${userName} joined the channel.`;
|
|
70
|
-
|
|
161
|
+
}
|
|
162
|
+
case '11': {
|
|
163
|
+
const template = translations?.['11'] ?? translations?.declined;
|
|
164
|
+
if (template) return template.replace('{{user}}', userName);
|
|
71
165
|
return `${userName} declined the channel invitation.`;
|
|
72
|
-
|
|
166
|
+
}
|
|
167
|
+
case '12': {
|
|
168
|
+
const template = translations?.['12'] ?? translations?.left;
|
|
169
|
+
if (template) return template.replace('{{user}}', userName);
|
|
73
170
|
return `${userName} left the channel.`;
|
|
74
|
-
|
|
171
|
+
}
|
|
172
|
+
case '13': {
|
|
173
|
+
const template = translations?.['13'] ?? translations?.clearedHistory;
|
|
174
|
+
if (template) return template.replace('{{user}}', userName);
|
|
75
175
|
return `${userName} cleared the chat history.`;
|
|
176
|
+
}
|
|
76
177
|
|
|
77
178
|
// 14: channel type change (true = public, false = private)
|
|
78
179
|
case '14': {
|
|
79
180
|
const rawType = parts[2] ?? '';
|
|
80
|
-
const
|
|
181
|
+
const typeKey = rawType === 'true' ? 'public' : 'private';
|
|
182
|
+
const channelType = translations?.[typeKey] ?? typeKey;
|
|
183
|
+
const template = translations?.['14'] ?? translations?.changeType;
|
|
184
|
+
if (template) {
|
|
185
|
+
return template.replace('{{user}}', userName).replace('{{type}}', channelType);
|
|
186
|
+
}
|
|
81
187
|
return `${userName} changed the channel to ${channelType}.`;
|
|
82
188
|
}
|
|
83
189
|
|
|
84
|
-
// 15: cooldown toggle / duration
|
|
85
190
|
case '15': {
|
|
86
191
|
const duration = parts[2] ?? '0';
|
|
87
192
|
if (duration === '0') {
|
|
193
|
+
const template = translations?.['15_off'] ?? translations?.cooldownOff;
|
|
194
|
+
if (template) return template.replace('{{user}}', userName);
|
|
88
195
|
return `${userName} disabled cooldown.`;
|
|
89
196
|
}
|
|
90
|
-
|
|
197
|
+
|
|
198
|
+
let durationText = `${duration}ms`;
|
|
199
|
+
const minLabel = translations?.durationUnitMin ?? 'minute';
|
|
200
|
+
const secLabel = translations?.durationUnitSec ?? 'seconds';
|
|
201
|
+
|
|
202
|
+
if (duration === '10000') durationText = `10 ${secLabel}`;
|
|
203
|
+
else if (duration === '30000') durationText = `30 ${secLabel}`;
|
|
204
|
+
else if (duration === '60000') durationText = `1 ${minLabel}`;
|
|
205
|
+
else if (duration === '300000') durationText = `5 ${minLabel}`;
|
|
206
|
+
else if (duration === '900000') durationText = `15 ${minLabel}`;
|
|
207
|
+
else if (duration === '3600000') durationText = `60 ${minLabel}`;
|
|
208
|
+
|
|
209
|
+
const template = translations?.['15_on'] ?? translations?.cooldownOn;
|
|
210
|
+
if (template) {
|
|
211
|
+
return template.replace('{{user}}', userName).replace('{{duration}}', durationText);
|
|
212
|
+
}
|
|
91
213
|
return `${userName} enabled cooldown for ${durationText}.`;
|
|
92
214
|
}
|
|
93
215
|
|
|
94
|
-
case '16':
|
|
216
|
+
case '16': {
|
|
217
|
+
const template = translations?.['16'] ?? translations?.bannedWordsUpdated;
|
|
218
|
+
if (template) return template.replace('{{user}}', userName);
|
|
95
219
|
return `${userName} updated the banned words.`;
|
|
96
|
-
|
|
220
|
+
}
|
|
221
|
+
case '17': {
|
|
222
|
+
const template = translations?.['17'] ?? translations?.added;
|
|
223
|
+
if (template) return template.replace('{{user}}', userName);
|
|
97
224
|
return `${userName} was added to the channel.`;
|
|
225
|
+
}
|
|
98
226
|
|
|
99
227
|
// 18: admin transfer (two user IDs)
|
|
100
228
|
case '18': {
|
|
101
229
|
const oldUserId = parts[1] ?? '';
|
|
102
230
|
const newUserId = parts[2] ?? '';
|
|
103
|
-
const oldUserName = oldUserId ? resolveUser(oldUserId, userMap) : 'User';
|
|
104
|
-
const newUserName = newUserId ? resolveUser(newUserId, userMap) : 'User';
|
|
105
|
-
|
|
231
|
+
const oldUserName = oldUserId ? resolveUser(oldUserId, userMap) : (translations?.userFallback ?? 'User');
|
|
232
|
+
const newUserName = newUserId ? resolveUser(newUserId, userMap) : (translations?.userFallback ?? 'User');
|
|
233
|
+
const adminLabel = translations?.adminFallback ?? 'Admin';
|
|
234
|
+
const template = translations?.['18'] ?? translations?.adminTransfer;
|
|
235
|
+
if (template) {
|
|
236
|
+
return template
|
|
237
|
+
.replace('{{user}}', oldUserName)
|
|
238
|
+
.replace('{{targetUser}}', newUserName)
|
|
239
|
+
.replace('{{admin}}', adminLabel);
|
|
240
|
+
}
|
|
241
|
+
return `${adminLabel} ${oldUserName} left and assigned ${newUserName} as the new admin.`;
|
|
106
242
|
}
|
|
107
243
|
|
|
108
244
|
// 19–20: pin / unpin (userId + msgID)
|
|
109
|
-
case '19':
|
|
245
|
+
case '19': {
|
|
246
|
+
const template = translations?.['19'] ?? translations?.pinned;
|
|
247
|
+
if (template) return template.replace('{{user}}', userName);
|
|
110
248
|
return `${userName} pinned a message.`;
|
|
111
|
-
|
|
249
|
+
}
|
|
250
|
+
case '20': {
|
|
251
|
+
const template = translations?.['20'] ?? translations?.unpinned;
|
|
252
|
+
if (template) return template.replace('{{user}}', userName);
|
|
112
253
|
return `${userName} unpinned a message.`;
|
|
254
|
+
}
|
|
113
255
|
|
|
114
256
|
default:
|
|
115
257
|
return trimmed;
|
package/src/types.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type FormatMessageResponse<ErmisChatGenerics extends ExtendableGenerics =
|
|
|
116
116
|
updated_at: Date;
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
+
export type MessageDisplayType = 'normal' | 'deleted';
|
|
120
|
+
|
|
119
121
|
export type MessageResponse<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> =
|
|
120
122
|
MessageResponseBase<ErmisChatGenerics> & {
|
|
121
123
|
quoted_message?: MessageResponseBase<ErmisChatGenerics>;
|
|
@@ -124,6 +126,7 @@ export type MessageResponse<ErmisChatGenerics extends ExtendableGenerics = Defau
|
|
|
124
126
|
export type MessageResponseBase<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> =
|
|
125
127
|
MessageBase<ErmisChatGenerics> & {
|
|
126
128
|
type: MessageLabel;
|
|
129
|
+
display_type?: MessageDisplayType;
|
|
127
130
|
channel?: ChannelResponse<ErmisChatGenerics>;
|
|
128
131
|
cid?: string;
|
|
129
132
|
created_at?: string;
|
|
@@ -209,6 +212,7 @@ export type ChannelQueryOptions = {
|
|
|
209
212
|
id_lt?: string;
|
|
210
213
|
id_gt?: string;
|
|
211
214
|
id_around?: string;
|
|
215
|
+
include_hidden_messages?: boolean;
|
|
212
216
|
};
|
|
213
217
|
};
|
|
214
218
|
|
|
@@ -245,6 +249,11 @@ export type ErmisChatOptions = AxiosRequestConfig & {
|
|
|
245
249
|
// Set the instance of StableWSConnection on chat client. Its purely for testing purpose and should
|
|
246
250
|
// not be used in production apps.
|
|
247
251
|
wsConnection?: StableWSConnection;
|
|
252
|
+
recoveryConfig?: {
|
|
253
|
+
filter?: ChannelFilters;
|
|
254
|
+
sort?: ChannelSort;
|
|
255
|
+
options?: { message_limit?: number };
|
|
256
|
+
};
|
|
248
257
|
};
|
|
249
258
|
|
|
250
259
|
/**
|
|
@@ -300,6 +309,8 @@ export type ChannelFilters = {
|
|
|
300
309
|
parent_cid?: string;
|
|
301
310
|
parent_id?: string;
|
|
302
311
|
include_parent?: boolean;
|
|
312
|
+
include_hidden_messages?: boolean;
|
|
313
|
+
include_quoted_messages?: boolean;
|
|
303
314
|
};
|
|
304
315
|
|
|
305
316
|
export type CreateTopicData = {
|
|
@@ -499,6 +510,7 @@ export enum CallAction {
|
|
|
499
510
|
}
|
|
500
511
|
|
|
501
512
|
export enum CallStatus {
|
|
513
|
+
PREPARING = 'preparing',
|
|
502
514
|
RINGING = 'ringing',
|
|
503
515
|
ENDED = 'ended',
|
|
504
516
|
CONNECTED = 'connected',
|
|
@@ -578,4 +590,5 @@ export enum FRAME_TYPE {
|
|
|
578
590
|
REQUEST_CONFIG = 8,
|
|
579
591
|
REQUEST_KEY_FRAME = 9,
|
|
580
592
|
END_CALL = 10,
|
|
593
|
+
HEALTH_CALL = 11,
|
|
581
594
|
}
|