@ermis-network/ermis-chat-sdk 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.
@@ -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
- const audioReader = audioProcessor.readable.getReader();
350
+ this.audioReader = audioProcessor.readable.getReader();
312
351
 
313
352
  try {
314
353
  while (true) {
315
- const { done, value: frame } = await audioReader.read();
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',
@@ -19,32 +19,57 @@ export interface SignalMessageResult {
19
19
  }
20
20
 
21
21
  /**
22
- * Format duration from milliseconds to "X min, Y sec" format.
22
+ * Translation templates for signal messages.
23
23
  */
24
- function formatDuration(durationMs: string): string {
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
- return `${minutes} min, ${seconds} sec`;
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 - Raw signal message string from the server
42
- * @param myUserId - The current user's ID (from client.userID)
43
- * @returns Parsed signal message object, or null if input is empty
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
  }
@@ -19,17 +19,78 @@ function resolveUser(userId: string, userMap: Record<string, string>): string {
19
19
  }
20
20
 
21
21
  /**
22
- * Parse a raw system message string into a human-readable English sentence.
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 - 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
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
- case '3':
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
- case '4':
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
- case '5':
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
- case '6':
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
- case '7':
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
- case '8':
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
- case '9':
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
- case '10':
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
- case '11':
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
- case '12':
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
- case '13':
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 channelType = rawType === 'true' ? 'public' : 'private';
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
- const durationText = DURATION_MAP[duration] ?? `${duration}ms`;
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
- case '17':
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
- return `Admin ${oldUserName} left and assigned ${newUserName} as the new admin.`;
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
- case '20':
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
  }