@ermis-network/ermis-chat-sdk 1.0.6 → 1.0.8

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-sdk",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Ermis Chat SDK",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
package/src/channel.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  PollMessage,
37
37
  EditMessage,
38
38
  ForwardMessage,
39
+ CreateTopicData,
40
+ EditTopicData,
39
41
  } from './types';
40
42
  /**
41
43
  * Represents a Channel in the Ermis Network.
@@ -224,6 +226,48 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
224
226
  );
225
227
  }
226
228
 
229
+ async pin() {
230
+ if (this.data) this.data.is_pinned = true;
231
+ this.getClient().dispatchEvent({
232
+ type: 'channel.pinned',
233
+ cid: this.cid,
234
+ channel: this.data,
235
+ } as Event<ErmisChatGenerics>);
236
+
237
+ try {
238
+ return await this.getClient().pinChannel(this.type, this.id as string);
239
+ } catch (e) {
240
+ if (this.data) this.data.is_pinned = false;
241
+ this.getClient().dispatchEvent({
242
+ type: 'channel.unpinned',
243
+ cid: this.cid,
244
+ channel: this.data,
245
+ } as Event<ErmisChatGenerics>);
246
+ throw e;
247
+ }
248
+ }
249
+
250
+ async unpin() {
251
+ if (this.data) this.data.is_pinned = false;
252
+ this.getClient().dispatchEvent({
253
+ type: 'channel.unpinned',
254
+ cid: this.cid,
255
+ channel: this.data,
256
+ } as Event<ErmisChatGenerics>);
257
+
258
+ try {
259
+ return await this.getClient().unpinChannel(this.type, this.id as string);
260
+ } catch (e) {
261
+ if (this.data) this.data.is_pinned = true;
262
+ this.getClient().dispatchEvent({
263
+ type: 'channel.pinned',
264
+ cid: this.cid,
265
+ channel: this.data,
266
+ } as Event<ErmisChatGenerics>);
267
+ throw e;
268
+ }
269
+ }
270
+
227
271
  async editMessage(oldMessageID: string, message: EditMessage) {
228
272
  return await this.getClient().post(this.getClient().baseURL + `/messages/${this.type}/${this.id}/${oldMessageID}`, {
229
273
  message,
@@ -726,7 +770,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
726
770
  }
727
771
  };
728
772
 
729
- async createTopic(data: any) {
773
+ async createTopic(data: CreateTopicData) {
730
774
  const project_id = this._client.projectId;
731
775
  const uuid = randomId();
732
776
  const topicID = `${project_id}:${uuid}`;
@@ -1079,7 +1123,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1079
1123
  });
1080
1124
  }
1081
1125
 
1082
- async editTopic(topicCID: string, data: any) {
1126
+ async editTopic(topicCID: string, data: EditTopicData) {
1083
1127
  const response: any = await this.getClient().post(
1084
1128
  this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics`,
1085
1129
  {
@@ -1390,6 +1434,18 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1390
1434
  delete channelState.members[event.user.id];
1391
1435
  }
1392
1436
  break;
1437
+ case 'channel.topic.enabled':
1438
+ if (channel.data) {
1439
+ channel.data.topics_enabled = true;
1440
+ }
1441
+ channelState.topics = channelState.topics || [];
1442
+ break;
1443
+ case 'channel.topic.disabled':
1444
+ if (channel.data) {
1445
+ channel.data.topics_enabled = false;
1446
+ }
1447
+ channelState.topics = [];
1448
+ break;
1393
1449
  case 'channel.updated':
1394
1450
  if (event.channel) {
1395
1451
  channel.data = {
@@ -1545,7 +1601,13 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1545
1601
  const topic = this.getClient().channel(event.channel_type || '', event.channel_id || '');
1546
1602
  topic.data = event.channel;
1547
1603
  topic._initializeState(topicState, 'latest');
1548
- channelState.topics?.unshift(topic);
1604
+
1605
+ if (!channelState.topics) {
1606
+ channelState.topics = [];
1607
+ }
1608
+ if (!channelState.topics.some((t) => t.cid === topic.cid)) {
1609
+ channelState.topics.push(topic);
1610
+ }
1549
1611
  break;
1550
1612
  case 'channel.topic.closed':
1551
1613
  if (channel.data) {
package/src/client.ts CHANGED
@@ -205,7 +205,6 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
205
205
  params.avatar = user.avatar;
206
206
  }
207
207
  const url = this.userBaseURL + '/get_token/external_auth';
208
- const query = new URLSearchParams(params).toString();
209
208
  const headers: Record<string, string> = {
210
209
  'Content-Type': 'application/json',
211
210
  };
@@ -213,21 +212,21 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
213
212
  const tokenStr = typeof token === 'string' && token.startsWith('Bearer ') ? token : `Bearer ${token}`;
214
213
  headers['Authorization'] = tokenStr;
215
214
  }
216
- const response = await fetch(`${url}?${query}`, {
217
- method: 'GET',
218
- headers,
219
- });
220
- if (!response.ok) {
221
- let errorMsg = '';
222
- try {
223
- const errorData = await response.json();
224
- errorMsg = errorData.message || JSON.stringify(errorData);
225
- } catch {
226
- errorMsg = await response.text();
215
+ try {
216
+ const response = await this.axiosInstance.get(url, {
217
+ params,
218
+ headers,
219
+ });
220
+ return response.data;
221
+ } catch (error: any) {
222
+ let errorMsg = 'Failed to fetch external auth token';
223
+ if (error.response && error.response.data) {
224
+ errorMsg = error.response.data.message || JSON.stringify(error.response.data);
225
+ } else if (error.message) {
226
+ errorMsg = error.message;
227
227
  }
228
228
  throw new Error(errorMsg);
229
229
  }
230
- return await response.json();
231
230
  }
232
231
 
233
232
  /**
@@ -242,7 +241,7 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
242
241
  connectUser = async (
243
242
  user: UserResponse<ErmisChatGenerics>,
244
243
  userTokenOrProvider: string | null,
245
- extenal_auth?: boolean, // pass true if you are using external auth
244
+ external_auth?: boolean, // pass true if you are using external auth
246
245
  ) => {
247
246
  this.logger('info', 'client:connectUser() - started', {
248
247
  tags: ['connection', 'client'],
@@ -251,19 +250,25 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
251
250
  throw new Error('The "id" field on the user is missing');
252
251
  }
253
252
 
253
+ let connectionUser = user;
254
+ let connectionToken = userTokenOrProvider;
255
+
254
256
  // If external auth is enabled, get the token from the server
255
- if (extenal_auth) {
257
+ if (external_auth) {
256
258
  const external_auth_token = await this.getExternalAuthToken(user, userTokenOrProvider);
257
259
 
258
- userTokenOrProvider = external_auth_token.token;
259
- user.id = external_auth_token.user_id;
260
+ connectionToken = external_auth_token.token;
261
+ connectionUser = {
262
+ ...user,
263
+ id: external_auth_token.user_id,
264
+ };
260
265
  }
261
266
 
262
267
  /**
263
268
  * Calling connectUser multiple times is potentially the result of a bad integration, however,
264
269
  * If the user id remains the same we don't throw error
265
270
  */
266
- if (this.userID === user.id && this.setUserPromise) {
271
+ if (this.userID === connectionUser.id && this.setUserPromise) {
267
272
  console.warn(
268
273
  'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.',
269
274
  );
@@ -283,11 +288,11 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
283
288
  }
284
289
 
285
290
  // we generate the client id client side
286
- this.userID = user.id;
291
+ this.userID = connectionUser.id;
287
292
 
288
- const setTokenPromise = this._setToken(user, userTokenOrProvider);
289
- this._setUser(user);
290
- this.state.updateUser({ id: user.id, name: user?.name || user.id, avatar: user?.avatar || '' });
293
+ const setTokenPromise = this._setToken(connectionUser, connectionToken);
294
+ this._setUser(connectionUser);
295
+ this.state.updateUser({ id: connectionUser.id, name: connectionUser?.name || connectionUser.id, avatar: connectionUser?.avatar || '' });
291
296
 
292
297
  const wsPromise = this.openConnection();
293
298
 
@@ -586,6 +591,21 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
586
591
  });
587
592
  }
588
593
 
594
+ /**
595
+ * Downloads a media file as a Blob via the SDK's configured axiosInstance.
596
+ * This avoids CORS issues that arise when using `fetch()` directly from the browser,
597
+ * because axios is routed through the SDK's authenticated transport layer.
598
+ *
599
+ * @param url - The full URL of the media file to download.
600
+ * @returns A Blob of the file content.
601
+ */
602
+ async downloadMedia(url: string): Promise<Blob> {
603
+ const response = await this.axiosInstance.get(url, {
604
+ responseType: 'blob',
605
+ });
606
+ return response.data as Blob;
607
+ }
608
+
589
609
  errorFromResponse(response: AxiosResponse<APIErrorResponse>): ErrorFromResponse<APIErrorResponse> {
590
610
  let err: ErrorFromResponse<APIErrorResponse>;
591
611
  err = new ErrorFromResponse(`ErmisChat error HTTP code: ${response.status}`);
@@ -609,8 +629,8 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
609
629
  dispatchEvent = (event: Event<ErmisChatGenerics>) => {
610
630
  if (!event.received_at) event.received_at = new Date();
611
631
 
612
- // If the event is channel.created, handle it asynchronously
613
- if (event.type === 'channel.created') {
632
+ // If the event is channel.created or channel.topic.created, handle it asynchronously
633
+ if (event.type === 'channel.created' || event.type === 'channel.topic.created') {
614
634
  this._handleChannelCreatedEvent(event).then(() => {
615
635
  this._afterDispatchEvent(event);
616
636
  });
@@ -789,6 +809,85 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
789
809
  }
790
810
  }
791
811
 
812
+ if (event.type === 'message.new' && event.channel_type === 'topic') {
813
+ postListenerCallbacks.push(() => {
814
+ const parentCid = event.parent_cid || event.channel?.parent_cid;
815
+ if (parentCid && this.activeChannels[parentCid]) {
816
+ const parentChannel = this.activeChannels[parentCid];
817
+ if (parentChannel.state.topics) {
818
+ parentChannel.state.topics.sort((a, b) => {
819
+ const aLatest = a.state?.latestMessages?.[a.state.latestMessages.length - 1]?.created_at;
820
+ const bLatest = b.state?.latestMessages?.[b.state.latestMessages.length - 1]?.created_at;
821
+ const aTime = aLatest ? new Date(aLatest).getTime() : 0;
822
+ const bTime = bLatest ? new Date(bLatest).getTime() : 0;
823
+ return bTime - aTime;
824
+ });
825
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
826
+ }
827
+ }
828
+ });
829
+ }
830
+ if (event.type === 'channel.topic.updated') {
831
+ postListenerCallbacks.push(() => {
832
+ const parentCid = event.parent_cid || event.channel?.parent_cid;
833
+ if (parentCid && this.activeChannels[parentCid]) {
834
+ const parentChannel = this.activeChannels[parentCid];
835
+ if (parentChannel.state?.topics && event.channel) {
836
+ const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
837
+ if (topicIndex !== -1) {
838
+ const t = parentChannel.state.topics[topicIndex] as any;
839
+ if (t.data) {
840
+ t.data = { ...t.data, ...event.channel };
841
+ } else if (t.channel) {
842
+ t.channel = { ...t.channel, ...event.channel };
843
+ } else {
844
+ Object.assign(t, event.channel);
845
+ }
846
+ }
847
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
848
+ }
849
+ }
850
+
851
+ if (event.cid && this.activeChannels[event.cid]) {
852
+ const topicChannel = this.activeChannels[event.cid];
853
+ if (event.channel) {
854
+ topicChannel.data = { ...topicChannel.data, ...event.channel };
855
+ topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
856
+ }
857
+ }
858
+ });
859
+ }
860
+
861
+ if (event.type === 'channel.topic.closed' || event.type === 'channel.topic.reopen') {
862
+ postListenerCallbacks.push(() => {
863
+ const isClosed = event.type === 'channel.topic.closed';
864
+ const parentCid = event.parent_cid;
865
+ if (parentCid && this.activeChannels[parentCid]) {
866
+ const parentChannel = this.activeChannels[parentCid];
867
+ if (parentChannel.state?.topics) {
868
+ const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
869
+ if (topicIndex !== -1) {
870
+ const t = parentChannel.state.topics[topicIndex] as any;
871
+ if (t.data) t.data.is_closed_topic = isClosed;
872
+ else if (t.channel) t.channel.is_closed_topic = isClosed;
873
+ else t.is_closed_topic = isClosed;
874
+ }
875
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
876
+ }
877
+ }
878
+
879
+ if (event.cid && this.activeChannels[event.cid]) {
880
+ const topicChannel = this.activeChannels[event.cid];
881
+ if (topicChannel.data) {
882
+ topicChannel.data.is_closed_topic = isClosed;
883
+ }
884
+ topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
885
+ }
886
+ });
887
+ }
888
+
889
+
890
+
792
891
  if (event.type === 'connection.recovered') {
793
892
  postListenerCallbacks.push(() => {
794
893
  // Auto-resend offline failed messages
@@ -1086,7 +1185,14 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1086
1185
  this.projectId = project_id;
1087
1186
  }
1088
1187
 
1089
- async uploadFile(file: File) {
1188
+ /**
1189
+ * Uploads a new avatar image for the current user.
1190
+ * The user's avatar URL is automatically updated in both the client and the local state.
1191
+ *
1192
+ * @param file - The image file to upload.
1193
+ * @returns The response containing the new avatar URL.
1194
+ */
1195
+ async uploadAvatar(file: File) {
1090
1196
  const formData = new FormData();
1091
1197
  formData.append('avatar', file);
1092
1198
  let response = await this.post<{ avatar: string }>(this.userBaseURL + '/users/upload', formData, {
@@ -1102,12 +1208,8 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1102
1208
 
1103
1209
  return response;
1104
1210
  }
1105
- async updateProfile(name: string, about_me: string) {
1106
- let body = {
1107
- name,
1108
- about_me,
1109
- };
1110
- let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', body);
1211
+ async updateProfile(updates: Partial<UserResponse<ErmisChatGenerics>>) {
1212
+ let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', updates);
1111
1213
  this.user = response;
1112
1214
  this.state.updateUser(response);
1113
1215
  return response;
@@ -1359,53 +1461,60 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1359
1461
  };
1360
1462
 
1361
1463
  /**
1362
- * Creates an interactive `meeting` Channel locally, and immediately creates it on the server.
1363
- * Consumers can customize the `name` field. `members` and `public` fields are constrained.
1464
+ * Creates a quick channel and immediately registers it on the server.
1465
+ * Quick channels are public group channels that anyone can join without an invitation.
1466
+ * The creator is added as the first member automatically.
1364
1467
  *
1365
- * @param name - The custom name for the meeting channel.
1468
+ * @param name - An optional display name for the channel.
1366
1469
  * @returns A promise that resolves to the created `Channel` object.
1367
1470
  */
1368
- async createMeetingChannel(name?: string): Promise<Channel<ErmisChatGenerics>> {
1471
+ async createQuickChannel(name?: string): Promise<Channel<ErmisChatGenerics>> {
1369
1472
  if (!this.userID) {
1370
1473
  throw Error('Call connectUser before creating a channel');
1371
1474
  }
1372
1475
 
1476
+ const now = new Date();
1477
+ const formattedDate = new Intl.DateTimeFormat('en-US', {
1478
+ month: 'short', day: '2-digit', year: 'numeric',
1479
+ hour: '2-digit', minute: '2-digit', hour12: false,
1480
+ }).format(now);
1481
+
1373
1482
  const payload = {
1374
- name: name || `Meeting Public - ${new Date().toISOString()}`,
1483
+ name: name || `Quick Channel - ${formattedDate}`,
1375
1484
  members: [this.userID],
1376
1485
  public: true,
1377
1486
  } as unknown as ChannelData<ErmisChatGenerics>;
1378
1487
 
1379
- const meetingChannel = this.channel('meeting', payload);
1380
- await meetingChannel.create();
1488
+ const quickChannel = this.channel('meeting', payload);
1489
+ await quickChannel.create();
1381
1490
 
1382
- return meetingChannel;
1491
+ return quickChannel;
1383
1492
  }
1384
1493
 
1385
1494
  /**
1386
- * Joins a `meeting` channel.
1387
- * It queries/watches the channel to see if caller is already a member.
1388
- * If not, it accepts the invite to join the channel, then watches it again to reflect changes.
1495
+ * Joins a quick channel by its ID.
1496
+ * Automatically checks whether the caller is already a member.
1497
+ * If not, it joins the channel and synchronizes state.
1389
1498
  *
1390
- * @param channelId - The ID of the meeting channel to join.
1499
+ * @param channelId - The ID of the quick channel to join.
1391
1500
  * @returns A promise that resolves to the joined `Channel` object.
1392
1501
  */
1393
- async joinMeetingChannel(channelId: string): Promise<Channel<ErmisChatGenerics>> {
1502
+ async joinQuickChannel(channelId: string): Promise<Channel<ErmisChatGenerics>> {
1394
1503
  if (!this.userID) {
1395
1504
  throw Error('Call connectUser before joining a channel');
1396
1505
  }
1397
1506
 
1398
- const meetingChannel = this.channel('meeting', channelId);
1399
- await meetingChannel.watch();
1507
+ const quickChannel = this.channel('meeting', channelId);
1508
+ await quickChannel.watch();
1400
1509
 
1401
- const isMember = meetingChannel.state.members && meetingChannel.state.members[this.userID];
1510
+ const isMember = quickChannel.state.members && quickChannel.state.members[this.userID];
1402
1511
 
1403
1512
  if (!isMember) {
1404
- await meetingChannel.acceptInvite('join');
1405
- await meetingChannel.watch();
1513
+ await quickChannel.acceptInvite('join');
1514
+ await quickChannel.watch();
1406
1515
  }
1407
1516
 
1408
- return meetingChannel;
1517
+ return quickChannel;
1409
1518
  }
1410
1519
 
1411
1520
  _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) {
package/src/types.ts CHANGED
@@ -58,6 +58,7 @@ export type ChannelResponse<ErmisChatGenerics extends ExtendableGenerics = Defau
58
58
  member_capabilities?: string[];
59
59
  is_pinned?: boolean;
60
60
  topics_enabled?: boolean;
61
+ parent_cid?: string;
61
62
  is_closed_topic?: boolean;
62
63
  };
63
64
 
@@ -265,6 +266,7 @@ export type Event<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics
265
266
  message?: MessageResponse<ErmisChatGenerics>;
266
267
  online?: boolean;
267
268
  parent_id?: string;
269
+ parent_cid?: string;
268
270
  reaction?: ReactionResponse<ErmisChatGenerics>;
269
271
  received_at?: string | Date;
270
272
  unread_messages?: number;
@@ -300,6 +302,18 @@ export type ChannelFilters = {
300
302
  include_parent?: boolean;
301
303
  };
302
304
 
305
+ export type CreateTopicData = {
306
+ name: string;
307
+ image?: string;
308
+ [key: string]: any;
309
+ };
310
+
311
+ export type EditTopicData = {
312
+ name?: string;
313
+ image?: string;
314
+ description?: string;
315
+ };
316
+
303
317
  export type ChannelSort = {
304
318
  field: string;
305
319
  direction: -1 | 1;