@ermis-network/ermis-chat-sdk 1.0.5 → 1.0.7

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.5",
3
+ "version": "1.0.7",
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.
@@ -132,13 +134,16 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
132
134
 
133
135
  // 3. Call API — don't update status on success (WS message.new will handle it)
134
136
  try {
135
- return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(
136
- this._channelURL() + '/message',
137
- { message: { ...message } },
138
- );
137
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(this._channelURL() + '/message', {
138
+ message: { ...message },
139
+ });
139
140
  } catch (error: any) {
140
141
  // 4. On error: check if it's an offline/network error
141
- const isOfflineError = !error.response || error.code === 'ERR_NETWORK' || error.isWSFailure || !this.getClient().wsConnection?.isHealthy;
142
+ const isOfflineError =
143
+ !error.response ||
144
+ error.code === 'ERR_NETWORK' ||
145
+ error.isWSFailure ||
146
+ !this.getClient().wsConnection?.isHealthy;
142
147
  const statusToSet = isOfflineError ? 'failed_offline' : 'error';
143
148
  this.state.updateMessageStatus(messageId, statusToSet);
144
149
  throw error;
@@ -166,12 +171,15 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
166
171
  }
167
172
 
168
173
  try {
169
- return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(
170
- this._channelURL() + '/message',
171
- { message: messagePayload },
172
- );
174
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(this._channelURL() + '/message', {
175
+ message: messagePayload,
176
+ });
173
177
  } catch (error: any) {
174
- const isOfflineError = !error.response || error.code === 'ERR_NETWORK' || error.isWSFailure || !this.getClient().wsConnection?.isHealthy;
178
+ const isOfflineError =
179
+ !error.response ||
180
+ error.code === 'ERR_NETWORK' ||
181
+ error.isWSFailure ||
182
+ !this.getClient().wsConnection?.isHealthy;
175
183
  this.state.updateMessageStatus(messageId, isOfflineError ? 'failed_offline' : 'error');
176
184
  throw error;
177
185
  }
@@ -218,6 +226,48 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
218
226
  );
219
227
  }
220
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
+
221
271
  async editMessage(oldMessageID: string, message: EditMessage) {
222
272
  return await this.getClient().post(this.getClient().baseURL + `/messages/${this.type}/${this.id}/${oldMessageID}`, {
223
273
  message,
@@ -263,9 +313,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
263
313
 
264
314
  // 2. Upload all files in parallel
265
315
  const uploadResults = await Promise.allSettled(
266
- processedFiles.map((file) =>
267
- this.sendFile(file, file.name, file.type),
268
- ),
316
+ processedFiles.map((file) => this.sendFile(file, file.name, file.type)),
269
317
  );
270
318
 
271
319
  // 3. For successful video uploads, generate and upload thumbnails
@@ -280,11 +328,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
280
328
  try {
281
329
  const thumbBlob = await this.getThumbBlobVideo(files[i]);
282
330
  if (thumbBlob) {
283
- const thumbFile = new File(
284
- [thumbBlob],
285
- `thumb_${processedFiles[i].name}.jpg`,
286
- { type: 'image/jpeg' },
287
- );
331
+ const thumbFile = new File([thumbBlob], `thumb_${processedFiles[i].name}.jpg`, { type: 'image/jpeg' });
288
332
  const thumbResp = await this.sendFile(thumbFile, thumbFile.name, 'image/jpeg');
289
333
  thumbUrls.set(i, thumbResp.file);
290
334
  }
@@ -306,9 +350,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
306
350
  const uploadedUrl = result.value.file;
307
351
  const thumbUrl = thumbUrls.get(i);
308
352
  const voiceMeta = options?.voiceMetadata?.get(i);
309
- attachments.push(
310
- buildAttachmentPayload(processedFiles[i], uploadedUrl, thumbUrl, voiceMeta),
311
- );
353
+ attachments.push(buildAttachmentPayload(processedFiles[i], uploadedUrl, thumbUrl, voiceMeta));
312
354
  } else {
313
355
  failedFiles.push({
314
356
  file: files[i],
@@ -728,7 +770,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
728
770
  }
729
771
  };
730
772
 
731
- async createTopic(data: any) {
773
+ async createTopic(data: CreateTopicData) {
732
774
  const project_id = this._client.projectId;
733
775
  const uuid = randomId();
734
776
  const topicID = `${project_id}:${uuid}`;
@@ -756,7 +798,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
756
798
  if (this.id) {
757
799
  queryURL += `/${this.id}`;
758
800
  } else {
759
- if (this.type === 'team') {
801
+ if (this.type === 'team' || this.type === 'meeting') {
760
802
  const uuid = randomId();
761
803
  this.id = `${project_id}:${uuid}`;
762
804
  queryURL += `/${this.id}`;
@@ -1081,7 +1123,7 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1081
1123
  });
1082
1124
  }
1083
1125
 
1084
- async editTopic(topicCID: string, data: any) {
1126
+ async editTopic(topicCID: string, data: EditTopicData) {
1085
1127
  const response: any = await this.getClient().post(
1086
1128
  this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics`,
1087
1129
  {
@@ -1392,6 +1434,18 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1392
1434
  delete channelState.members[event.user.id];
1393
1435
  }
1394
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;
1395
1449
  case 'channel.updated':
1396
1450
  if (event.channel) {
1397
1451
  channel.data = {
@@ -1547,7 +1601,13 @@ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGener
1547
1601
  const topic = this.getClient().channel(event.channel_type || '', event.channel_id || '');
1548
1602
  topic.data = event.channel;
1549
1603
  topic._initializeState(topicState, 'latest');
1550
- 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
+ }
1551
1611
  break;
1552
1612
  case 'channel.topic.closed':
1553
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
 
@@ -609,8 +614,8 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
609
614
  dispatchEvent = (event: Event<ErmisChatGenerics>) => {
610
615
  if (!event.received_at) event.received_at = new Date();
611
616
 
612
- // If the event is channel.created, handle it asynchronously
613
- if (event.type === 'channel.created') {
617
+ // If the event is channel.created or channel.topic.created, handle it asynchronously
618
+ if (event.type === 'channel.created' || event.type === 'channel.topic.created') {
614
619
  this._handleChannelCreatedEvent(event).then(() => {
615
620
  this._afterDispatchEvent(event);
616
621
  });
@@ -789,6 +794,85 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
789
794
  }
790
795
  }
791
796
 
797
+ if (event.type === 'message.new' && event.channel_type === 'topic') {
798
+ postListenerCallbacks.push(() => {
799
+ const parentCid = event.parent_cid || event.channel?.parent_cid;
800
+ if (parentCid && this.activeChannels[parentCid]) {
801
+ const parentChannel = this.activeChannels[parentCid];
802
+ if (parentChannel.state.topics) {
803
+ parentChannel.state.topics.sort((a, b) => {
804
+ const aLatest = a.state?.latestMessages?.[a.state.latestMessages.length - 1]?.created_at;
805
+ const bLatest = b.state?.latestMessages?.[b.state.latestMessages.length - 1]?.created_at;
806
+ const aTime = aLatest ? new Date(aLatest).getTime() : 0;
807
+ const bTime = bLatest ? new Date(bLatest).getTime() : 0;
808
+ return bTime - aTime;
809
+ });
810
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
811
+ }
812
+ }
813
+ });
814
+ }
815
+ if (event.type === 'channel.topic.updated') {
816
+ postListenerCallbacks.push(() => {
817
+ const parentCid = event.parent_cid || event.channel?.parent_cid;
818
+ if (parentCid && this.activeChannels[parentCid]) {
819
+ const parentChannel = this.activeChannels[parentCid];
820
+ if (parentChannel.state?.topics && event.channel) {
821
+ const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
822
+ if (topicIndex !== -1) {
823
+ const t = parentChannel.state.topics[topicIndex] as any;
824
+ if (t.data) {
825
+ t.data = { ...t.data, ...event.channel };
826
+ } else if (t.channel) {
827
+ t.channel = { ...t.channel, ...event.channel };
828
+ } else {
829
+ Object.assign(t, event.channel);
830
+ }
831
+ }
832
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
833
+ }
834
+ }
835
+
836
+ if (event.cid && this.activeChannels[event.cid]) {
837
+ const topicChannel = this.activeChannels[event.cid];
838
+ if (event.channel) {
839
+ topicChannel.data = { ...topicChannel.data, ...event.channel };
840
+ topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
841
+ }
842
+ }
843
+ });
844
+ }
845
+
846
+ if (event.type === 'channel.topic.closed' || event.type === 'channel.topic.reopen') {
847
+ postListenerCallbacks.push(() => {
848
+ const isClosed = event.type === 'channel.topic.closed';
849
+ const parentCid = event.parent_cid;
850
+ if (parentCid && this.activeChannels[parentCid]) {
851
+ const parentChannel = this.activeChannels[parentCid];
852
+ if (parentChannel.state?.topics) {
853
+ const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
854
+ if (topicIndex !== -1) {
855
+ const t = parentChannel.state.topics[topicIndex] as any;
856
+ if (t.data) t.data.is_closed_topic = isClosed;
857
+ else if (t.channel) t.channel.is_closed_topic = isClosed;
858
+ else t.is_closed_topic = isClosed;
859
+ }
860
+ parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
861
+ }
862
+ }
863
+
864
+ if (event.cid && this.activeChannels[event.cid]) {
865
+ const topicChannel = this.activeChannels[event.cid];
866
+ if (topicChannel.data) {
867
+ topicChannel.data.is_closed_topic = isClosed;
868
+ }
869
+ topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
870
+ }
871
+ });
872
+ }
873
+
874
+
875
+
792
876
  if (event.type === 'connection.recovered') {
793
877
  postListenerCallbacks.push(() => {
794
878
  // Auto-resend offline failed messages
@@ -1086,7 +1170,14 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1086
1170
  this.projectId = project_id;
1087
1171
  }
1088
1172
 
1089
- async uploadFile(file: File) {
1173
+ /**
1174
+ * Uploads a new avatar image for the current user.
1175
+ * The user's avatar URL is automatically updated in both the client and the local state.
1176
+ *
1177
+ * @param file - The image file to upload.
1178
+ * @returns The response containing the new avatar URL.
1179
+ */
1180
+ async uploadAvatar(file: File) {
1090
1181
  const formData = new FormData();
1091
1182
  formData.append('avatar', file);
1092
1183
  let response = await this.post<{ avatar: string }>(this.userBaseURL + '/users/upload', formData, {
@@ -1102,12 +1193,8 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1102
1193
 
1103
1194
  return response;
1104
1195
  }
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);
1196
+ async updateProfile(updates: Partial<UserResponse<ErmisChatGenerics>>) {
1197
+ let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', updates);
1111
1198
  this.user = response;
1112
1199
  this.state.updateUser(response);
1113
1200
  return response;
@@ -1358,6 +1445,63 @@ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGen
1358
1445
  return channel;
1359
1446
  };
1360
1447
 
1448
+ /**
1449
+ * Creates a quick channel and immediately registers it on the server.
1450
+ * Quick channels are public group channels that anyone can join without an invitation.
1451
+ * The creator is added as the first member automatically.
1452
+ *
1453
+ * @param name - An optional display name for the channel.
1454
+ * @returns A promise that resolves to the created `Channel` object.
1455
+ */
1456
+ async createQuickChannel(name?: string): Promise<Channel<ErmisChatGenerics>> {
1457
+ if (!this.userID) {
1458
+ throw Error('Call connectUser before creating a channel');
1459
+ }
1460
+
1461
+ const now = new Date();
1462
+ const formattedDate = new Intl.DateTimeFormat('en-US', {
1463
+ month: 'short', day: '2-digit', year: 'numeric',
1464
+ hour: '2-digit', minute: '2-digit', hour12: false,
1465
+ }).format(now);
1466
+
1467
+ const payload = {
1468
+ name: name || `Quick Channel - ${formattedDate}`,
1469
+ members: [this.userID],
1470
+ public: true,
1471
+ } as unknown as ChannelData<ErmisChatGenerics>;
1472
+
1473
+ const quickChannel = this.channel('meeting', payload);
1474
+ await quickChannel.create();
1475
+
1476
+ return quickChannel;
1477
+ }
1478
+
1479
+ /**
1480
+ * Joins a quick channel by its ID.
1481
+ * Automatically checks whether the caller is already a member.
1482
+ * If not, it joins the channel and synchronizes state.
1483
+ *
1484
+ * @param channelId - The ID of the quick channel to join.
1485
+ * @returns A promise that resolves to the joined `Channel` object.
1486
+ */
1487
+ async joinQuickChannel(channelId: string): Promise<Channel<ErmisChatGenerics>> {
1488
+ if (!this.userID) {
1489
+ throw Error('Call connectUser before joining a channel');
1490
+ }
1491
+
1492
+ const quickChannel = this.channel('meeting', channelId);
1493
+ await quickChannel.watch();
1494
+
1495
+ const isMember = quickChannel.state.members && quickChannel.state.members[this.userID];
1496
+
1497
+ if (!isMember) {
1498
+ await quickChannel.acceptInvite('join');
1499
+ await quickChannel.watch();
1500
+ }
1501
+
1502
+ return quickChannel;
1503
+ }
1504
+
1361
1505
  _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) {
1362
1506
  let pinExpires: null | string = null;
1363
1507
  if (typeof timeoutOrExpirationDate === 'number') {
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;