stream-chat 9.41.1 → 9.42.1

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.
@@ -3,6 +3,7 @@ import type WebSocket from 'isomorphic-ws';
3
3
  import { Channel } from './channel';
4
4
  import { ClientState } from './client_state';
5
5
  import { StableWSConnection } from './connection';
6
+ import { UploadManager } from './uploadManager';
6
7
  import { TokenManager } from './token_manager';
7
8
  import { WSConnectionFallback } from './connection_fallback';
8
9
  import { Campaign } from './campaign';
@@ -44,6 +45,10 @@ export type MessageComposerSetupState = {
44
45
  export declare class StreamChat {
45
46
  private static _instance?;
46
47
  messageDeliveryReporter: MessageDeliveryReporter;
48
+ /**
49
+ * @internal
50
+ */
51
+ uploadManager: UploadManager;
47
52
  _user?: OwnUserResponse | UserResponse;
48
53
  appSettingsPromise?: Promise<AppSettingsAPIResponse>;
49
54
  activeChannels: {
@@ -28,6 +28,7 @@ export type { ThreadState, ThreadReadState, ThreadRepliesPagination, ThreadUserR
28
28
  export * from './thread_manager';
29
29
  export * from './token_manager';
30
30
  export * from './types';
31
+ export * from './uploadManager';
31
32
  export * from './channel_manager';
32
33
  export * from './offline-support';
33
34
  export * from './LiveLocationManager';
@@ -21,7 +21,7 @@ export declare class LocationComposer {
21
21
  readonly composer: MessageComposer;
22
22
  private _deviceId;
23
23
  constructor({ composer, message }: LocationComposerOptions);
24
- get config(): import("..").LocationComposerConfig;
24
+ get config(): import("./configuration").LocationComposerConfig;
25
25
  get deviceId(): string;
26
26
  get location(): StaticLocationPayload | LiveLocationPreview | null;
27
27
  get validLocation(): StaticLocationPayload | LiveLocationPayload | null;
@@ -73,4 +73,5 @@ export declare class AttachmentManager {
73
73
  uploadAttachment: (attachment: LocalUploadAttachment) => Promise<LocalUploadAttachment | undefined>;
74
74
  uploadFile: (file: FileReference | FileLike) => Promise<LocalUploadAttachment>;
75
75
  uploadFiles: (files: FileReference[] | FileList | FileLike[]) => Promise<LocalUploadAttachment[] | undefined>;
76
+ private upload;
76
77
  }
@@ -5,9 +5,14 @@ export type MinimumUploadRequestResult = {
5
5
  file: string;
6
6
  thumb_url?: string;
7
7
  } & Partial<Record<string, unknown>>;
8
- /** Optional second argument to `UploadRequestFn`; integrators may call `onProgress` to report 0–100 or `undefined` when indeterminate. */
8
+ /**
9
+ * Optional second argument to `UploadRequestFn`.
10
+ * - Call `onProgress` to report 0–100 or `undefined` when indeterminate.
11
+ * - Forward `abortSignal` to your upload implementation to cancel the upload if the user deletes the attachment while it's uploading.
12
+ */
9
13
  export type UploadRequestOptions = {
10
14
  onProgress?: (percent: number | undefined) => void;
15
+ abortSignal?: AbortSignal;
11
16
  };
12
17
  export type UploadRequestFn = (fileLike: FileReference | FileLike, options?: UploadRequestOptions) => Promise<MinimumUploadRequestResult>;
13
18
  export type DraftsConfiguration = {
@@ -13,5 +13,5 @@ export declare const getExtensionFromMimeType: (mimeType: string) => string | un
13
13
  export declare const readFileAsArrayBuffer: (file: File) => Promise<ArrayBuffer>;
14
14
  export declare const generateFileName: (mimeType: string) => string;
15
15
  export declare const isImageFile: (fileLike: FileReference | FileLike) => boolean;
16
- export declare const getAttachmentTypeFromMimeType: (mimeType: string) => "file" | "audio" | "video" | "image";
16
+ export declare const getAttachmentTypeFromMimeType: (mimeType: string) => "file" | "image" | "audio" | "video";
17
17
  export declare const ensureIsLocalAttachment: (attachment: Attachment | LocalAttachment) => LocalAttachment | null;
@@ -61,15 +61,15 @@ export declare class MessageComposer extends WithSubscriptions {
61
61
  locationComposer: LocationComposer;
62
62
  customDataManager: CustomDataManager;
63
63
  constructor({ composition, config, compositionContext, client, }: MessageComposerOptions);
64
- static evaluateContextType(compositionContext: CompositionContext): "channel" | "message" | "thread" | "legacy_thread";
64
+ static evaluateContextType(compositionContext: CompositionContext): "message" | "channel" | "thread" | "legacy_thread";
65
65
  static constructTag(compositionContext: CompositionContext): `${ReturnType<typeof MessageComposer.evaluateContextType>}_${string}`;
66
66
  static generateId: typeof generateUUIDv4;
67
67
  get config(): MessageComposerConfig;
68
68
  get editedMessage(): LocalMessage | undefined;
69
69
  set editedMessage(editedMessage: LocalMessage | undefined);
70
70
  setEditedMessage: (editedMessage: LocalMessage | null | undefined) => void;
71
- get contextType(): "channel" | "message" | "thread" | "legacy_thread";
72
- get tag(): `channel_${string}` | `message_${string}` | `thread_${string}` | `legacy_thread_${string}`;
71
+ get contextType(): "message" | "channel" | "thread" | "legacy_thread";
72
+ get tag(): `message_${string}` | `channel_${string}` | `thread_${string}` | `legacy_thread_${string}`;
73
73
  get threadId(): string | null;
74
74
  get client(): StreamChat;
75
75
  get id(): string;
@@ -111,7 +111,7 @@ export declare class MessageComposer extends WithSubscriptions {
111
111
  clear: () => void;
112
112
  restore: () => void;
113
113
  compose: () => Promise<MessageComposerMiddlewareValue["state"] | undefined>;
114
- composeDraft: () => Promise<import("..").MessageDraftComposerMiddlewareValueState | undefined>;
114
+ composeDraft: () => Promise<import("./middleware").MessageDraftComposerMiddlewareValueState | undefined>;
115
115
  createDraft: () => Promise<void>;
116
116
  deleteDraft: () => Promise<void>;
117
117
  getDraft: () => Promise<void>;
@@ -21,12 +21,12 @@ export declare class CommandSearchSource extends BaseSearchSourceSync<CommandSug
21
21
  };
22
22
  query(searchQuery: string): {
23
23
  items: {
24
- id: "all" | "giphy" | "mute" | "unmute" | "ban" | "fun_set" | "moderation_set" | "unban";
24
+ id: "all" | "giphy" | "ban" | "fun_set" | "moderation_set" | "mute" | "unban" | "unmute";
25
25
  created_at?: string | undefined;
26
26
  updated_at?: string | undefined;
27
27
  args?: string;
28
28
  description?: string;
29
- name: "all" | "giphy" | "mute" | "unmute" | "ban" | "fun_set" | "moderation_set" | "unban";
29
+ name: "all" | "giphy" | "ban" | "fun_set" | "moderation_set" | "mute" | "unban" | "unmute";
30
30
  set?: import("../../..").CommandVariants;
31
31
  }[];
32
32
  next: null;
@@ -20,7 +20,7 @@ export declare class PollComposer {
20
20
  get id(): string;
21
21
  get max_votes_allowed(): string;
22
22
  get name(): string;
23
- get options(): import("..").PollComposerOption[];
23
+ get options(): import("./middleware").PollComposerOption[];
24
24
  get user_id(): string | undefined;
25
25
  get voting_visibility(): VotingVisibility | undefined;
26
26
  get canCreatePoll(): boolean;
@@ -32,5 +32,5 @@ export declare class PollComposer {
32
32
  */
33
33
  updateFields: (data: UpdateFieldsData, injectedFieldErrors?: PollComposerFieldErrors) => Promise<void>;
34
34
  handleFieldBlur: (field: keyof PollComposerState["data"]) => Promise<void>;
35
- compose: () => Promise<import("..").PollComposerCompositionMiddlewareValueState | undefined>;
35
+ compose: () => Promise<import("./middleware").PollComposerCompositionMiddlewareValueState | undefined>;
36
36
  }
@@ -17,7 +17,7 @@ export declare class TextComposer {
17
17
  middlewareExecutor: TextComposerMiddlewareExecutor;
18
18
  constructor({ composer, message }: TextComposerOptions);
19
19
  get channel(): import("..").Channel;
20
- get config(): import("..").TextComposerConfig;
20
+ get config(): import("./configuration").TextComposerConfig;
21
21
  get enabled(): boolean;
22
22
  set enabled(enabled: boolean);
23
23
  get defaultValue(): string | undefined;
@@ -31,7 +31,7 @@ export declare class TextComposer {
31
31
  get command(): CommandResponse | null | undefined;
32
32
  get mentionedUsers(): UserResponse[];
33
33
  get selection(): TextSelection;
34
- get suggestions(): Suggestions<import("..").Suggestion> | undefined;
34
+ get suggestions(): Suggestions<import("./middleware").Suggestion> | undefined;
35
35
  get text(): string;
36
36
  get textIsEmpty(): boolean;
37
37
  initState: ({ message }?: {
@@ -0,0 +1,45 @@
1
+ import type { StreamChat } from './client';
2
+ import { StateStore } from './store';
3
+ import type { AttachmentManager } from '.';
4
+ export type UploadRecord = {
5
+ id: string;
6
+ uploadProgress?: number;
7
+ };
8
+ export type UploadManagerState = {
9
+ uploads: Record<string, UploadRecord>;
10
+ };
11
+ /**
12
+ * @internal
13
+ */
14
+ export declare class UploadManager {
15
+ private readonly client;
16
+ readonly state: StateStore<UploadManagerState>;
17
+ private inFlightUploads;
18
+ constructor(client: StreamChat);
19
+ private resolveAttachmentManager;
20
+ get uploads(): Record<string, UploadRecord>;
21
+ getUpload: (id: string) => UploadRecord;
22
+ /**
23
+ * Clears all upload records.
24
+ * Invoked when the user disconnects so a later session does not inherit stale upload state.
25
+ * Aborts every in-flight upload request via its `UploadRequestOptions.abortSignal`.
26
+ */
27
+ reset: () => void;
28
+ /**
29
+ * Removes the upload record for `id` if present.
30
+ * If an upload is still in progress, aborts its `UploadRequestOptions.abortSignal`.
31
+ */
32
+ deleteUploadRecord: (id: string) => void;
33
+ /**
34
+ * Starts an upload for `id`, or returns the existing in-flight promise if one is already running.
35
+ * Uses {@link StreamChat.channel}(`channelCid`) → `messageComposer.attachmentManager.doUploadRequest`.
36
+ * Resolves with that result; rejects if the upload rejects (the record is removed from state either way).
37
+ */
38
+ upload: ({ id, channelCid, file, }: {
39
+ id: string;
40
+ channelCid: string;
41
+ file: Parameters<typeof AttachmentManager.prototype.doUploadRequest>[0];
42
+ }) => ReturnType<typeof AttachmentManager.prototype.doUploadRequest>;
43
+ private upsertUpload;
44
+ private updateUpload;
45
+ }
@@ -89,8 +89,6 @@ export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
89
89
  deleted_at: Date | null;
90
90
  text?: string | undefined;
91
91
  user_id?: string | undefined;
92
- user?: (UserResponse | null) | undefined;
93
- channel?: import("./types").ChannelResponse | undefined;
94
92
  command?: string | undefined;
95
93
  mentioned_users?: UserResponse[] | undefined;
96
94
  latest_reactions?: import("./types").ReactionResponse[] | undefined;
@@ -103,9 +101,10 @@ export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
103
101
  language: import("./types").TranslationLanguages;
104
102
  }) | undefined;
105
103
  html?: string | undefined;
106
- parent_id?: string | undefined;
104
+ user?: (UserResponse | null) | undefined;
107
105
  id: string;
108
106
  mml?: string | undefined;
107
+ parent_id?: string | undefined;
109
108
  pin_expires?: string | null | undefined;
110
109
  pinned?: boolean | undefined;
111
110
  poll_id?: string | undefined;
@@ -115,6 +114,7 @@ export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
115
114
  silent?: boolean | undefined;
116
115
  args?: string | undefined;
117
116
  before_message_send_failed?: boolean | undefined;
117
+ channel?: import("./types").ChannelResponse | undefined;
118
118
  cid?: string | undefined;
119
119
  command_info?: {
120
120
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.41.1",
3
+ "version": "9.42.1",
4
4
  "description": "JS SDK for the Stream Chat API",
5
5
  "homepage": "https://getstream.io/chat/",
6
6
  "author": {
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@types/jsonwebtoken": "^9.0.8",
53
53
  "@types/ws": "^8.5.14",
54
- "axios": "^1.12.2",
54
+ "axios": "^1.15.1",
55
55
  "base64-js": "^1.5.1",
56
56
  "form-data": "^4.0.4",
57
57
  "isomorphic-ws": "^5.0.0",
package/src/client.ts CHANGED
@@ -9,6 +9,7 @@ import type WebSocket from 'isomorphic-ws';
9
9
  import { Channel } from './channel';
10
10
  import { ClientState } from './client_state';
11
11
  import { StableWSConnection } from './connection';
12
+ import { UploadManager } from './uploadManager';
12
13
  import { CheckSignature, DevToken, JWTUserToken } from './signing';
13
14
  import { TokenManager } from './token_manager';
14
15
  import { WSConnectionFallback } from './connection_fallback';
@@ -296,6 +297,10 @@ export type MessageComposerSetupState = {
296
297
  export class StreamChat {
297
298
  private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
298
299
  messageDeliveryReporter: MessageDeliveryReporter;
300
+ /**
301
+ * @internal
302
+ */
303
+ uploadManager: UploadManager;
299
304
  _user?: OwnUserResponse | UserResponse;
300
305
  appSettingsPromise?: Promise<AppSettingsAPIResponse>;
301
306
  activeChannels: {
@@ -401,6 +406,7 @@ export class StreamChat {
401
406
  this.moderation = new Moderation(this);
402
407
 
403
408
  this.notifications = options?.notifications ?? new NotificationManager();
409
+ this.uploadManager = new UploadManager(this);
404
410
 
405
411
  // set the secret
406
412
  if (secretOrOptions && isString(secretOrOptions)) {
@@ -1020,6 +1026,7 @@ export class StreamChat {
1020
1026
  this.state = new ClientState({ client: this });
1021
1027
  // reset thread manager
1022
1028
  this.threads.resetState();
1029
+ this.uploadManager.reset();
1023
1030
 
1024
1031
  // Since we wipe all user data already, we should reset token manager as well
1025
1032
  closePromise
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  export * from './thread_manager';
34
34
  export * from './token_manager';
35
35
  export * from './types';
36
+ export * from './uploadManager';
36
37
  export * from './channel_manager';
37
38
  export * from './offline-support';
38
39
  export * from './LiveLocationManager';
@@ -237,12 +237,12 @@ export class AttachmentManager {
237
237
  return attachments;
238
238
  }
239
239
  }
240
- return null;
240
+ return stateAttachments;
241
241
  };
242
242
 
243
243
  updateAttachment = (attachmentToUpdate: LocalAttachment) => {
244
244
  const updatedAttachments = this.prepareAttachmentUpdate(attachmentToUpdate);
245
- if (updatedAttachments) {
245
+ if (updatedAttachments && updatedAttachments !== this.attachments) {
246
246
  this.state.partialNext({ attachments: updatedAttachments });
247
247
  }
248
248
  };
@@ -253,16 +253,17 @@ export class AttachmentManager {
253
253
  let hasUpdates = false;
254
254
  attachmentsToUpsert.forEach((attachment) => {
255
255
  const updatedAttachments = this.prepareAttachmentUpdate(attachment);
256
- if (updatedAttachments) {
257
- attachments = updatedAttachments;
258
- hasUpdates = true;
259
- } else {
256
+ if (updatedAttachments === null) {
260
257
  const localAttachment = ensureIsLocalAttachment(attachment);
261
258
  if (localAttachment) {
262
259
  attachments.push(localAttachment);
263
260
  hasUpdates = true;
264
261
  }
262
+ } else if (updatedAttachments !== this.attachments) {
263
+ attachments = updatedAttachments;
264
+ hasUpdates = true;
265
265
  }
266
+ // else: id exists and merge was a no-op (`prepareAttachmentUpdate` returns current state)
266
267
  });
267
268
  if (hasUpdates) {
268
269
  this.state.partialNext({ attachments });
@@ -270,11 +271,17 @@ export class AttachmentManager {
270
271
  };
271
272
 
272
273
  removeAttachments = (localAttachmentIds: string[]) => {
274
+ if (!localAttachmentIds.length) return;
275
+
273
276
  this.state.partialNext({
274
277
  attachments: this.attachments.filter(
275
278
  (attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id),
276
279
  ),
277
280
  });
281
+
282
+ for (const id of localAttachmentIds) {
283
+ this.client.uploadManager.deleteUploadRecord(id);
284
+ }
278
285
  };
279
286
 
280
287
  getUploadConfigCheck = async (
@@ -464,13 +471,21 @@ export class AttachmentManager {
464
471
  }
465
472
  : undefined;
466
473
 
474
+ const axiosUploadConfig =
475
+ progressHandler || options?.abortSignal
476
+ ? {
477
+ ...(progressHandler ? { onUploadProgress: progressHandler } : {}),
478
+ ...(options?.abortSignal ? { signal: options.abortSignal } : {}),
479
+ }
480
+ : undefined;
481
+
467
482
  if (isFileReference(fileLike)) {
468
483
  return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](
469
484
  fileLike.uri,
470
485
  fileLike.name,
471
486
  fileLike.type,
472
487
  undefined,
473
- progressHandler ? { onUploadProgress: progressHandler } : undefined,
488
+ axiosUploadConfig,
474
489
  );
475
490
  }
476
491
 
@@ -485,13 +500,7 @@ export class AttachmentManager {
485
500
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
486
501
  const { duration, ...result } = await this.channel[
487
502
  isImageFile(fileLike) ? 'sendImage' : 'sendFile'
488
- ](
489
- file,
490
- undefined,
491
- undefined,
492
- undefined,
493
- progressHandler ? { onUploadProgress: progressHandler } : undefined,
494
- );
503
+ ](file, undefined, undefined, undefined, axiosUploadConfig);
495
504
  return result;
496
505
  };
497
506
 
@@ -537,37 +546,9 @@ export class AttachmentManager {
537
546
  return localAttachment;
538
547
  }
539
548
 
540
- const shouldTrackProgress = this.config.trackUploadProgress;
541
- const uploadingAttachment: LocalUploadAttachment = {
542
- ...attachment,
543
- localMetadata: {
544
- ...attachment.localMetadata,
545
- uploadState: 'uploading',
546
- ...(shouldTrackProgress && { uploadProgress: 0 }),
547
- },
548
- };
549
- this.upsertAttachments([uploadingAttachment]);
550
-
551
- const uploadOptions = shouldTrackProgress
552
- ? {
553
- onProgress: (percent: number | undefined) => {
554
- this.updateAttachment({
555
- ...uploadingAttachment,
556
- localMetadata: {
557
- ...uploadingAttachment.localMetadata,
558
- uploadProgress: percent,
559
- },
560
- });
561
- },
562
- }
563
- : undefined;
564
-
565
549
  let response: MinimumUploadRequestResult;
566
550
  try {
567
- response = await this.doUploadRequest(
568
- localAttachment.localMetadata.file,
569
- uploadOptions,
570
- );
551
+ response = await this.upload(attachment);
571
552
  } catch (error) {
572
553
  const reason = error instanceof Error ? error.message : 'unknown error';
573
554
  const failedAttachment: LocalUploadAttachment = {
@@ -655,35 +636,10 @@ export class AttachmentManager {
655
636
  return preUpload.state.attachment;
656
637
  }
657
638
 
658
- const shouldTrackProgress = this.config.trackUploadProgress;
659
- attachment = {
660
- ...attachment,
661
- localMetadata: {
662
- ...attachment.localMetadata,
663
- uploadState: 'uploading',
664
- ...(shouldTrackProgress && { uploadProgress: 0 }),
665
- },
666
- };
667
- this.upsertAttachments([attachment]);
668
-
669
- const uploadOptions = shouldTrackProgress
670
- ? {
671
- onProgress: (percent: number | undefined) => {
672
- this.updateAttachment({
673
- ...attachment,
674
- localMetadata: {
675
- ...attachment.localMetadata,
676
- uploadProgress: percent,
677
- },
678
- });
679
- },
680
- }
681
- : undefined;
682
-
683
639
  let response: MinimumUploadRequestResult | undefined;
684
640
  let error: Error | undefined;
685
641
  try {
686
- response = await this.doUploadRequest(file, uploadOptions);
642
+ response = await this.upload(attachment);
687
643
  } catch (err) {
688
644
  error = err instanceof Error ? err : undefined;
689
645
  }
@@ -725,4 +681,44 @@ export class AttachmentManager {
725
681
  iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile),
726
682
  );
727
683
  };
684
+
685
+ private upload(attachment: LocalUploadAttachment) {
686
+ const localId = attachment.localMetadata.id;
687
+
688
+ this.upsertAttachments([
689
+ {
690
+ ...attachment,
691
+ localMetadata: {
692
+ ...attachment.localMetadata,
693
+ uploadState: 'uploading',
694
+ uploadProgress: this.config.trackUploadProgress ? 0 : undefined,
695
+ },
696
+ },
697
+ ]);
698
+
699
+ const unsubscribe = this.client.uploadManager.state.subscribeWithSelector(
700
+ (s) => ({ upload: s.uploads[localId] }),
701
+ ({ upload: nextUpload }) => {
702
+ if (!nextUpload) return;
703
+ this.updateAttachment({
704
+ ...attachment,
705
+ localMetadata: {
706
+ ...attachment.localMetadata,
707
+ uploadState: 'uploading',
708
+ uploadProgress: nextUpload.uploadProgress,
709
+ },
710
+ });
711
+ },
712
+ );
713
+
714
+ return this.client.uploadManager
715
+ .upload({
716
+ id: localId,
717
+ channelCid: this.channel.cid,
718
+ file: attachment.localMetadata.file,
719
+ })
720
+ .finally(() => {
721
+ unsubscribe();
722
+ });
723
+ }
728
724
  }
@@ -6,9 +6,14 @@ export type MinimumUploadRequestResult = { file: string; thumb_url?: string } &
6
6
  Record<string, unknown>
7
7
  >;
8
8
 
9
- /** Optional second argument to `UploadRequestFn`; integrators may call `onProgress` to report 0–100 or `undefined` when indeterminate. */
9
+ /**
10
+ * Optional second argument to `UploadRequestFn`.
11
+ * - Call `onProgress` to report 0–100 or `undefined` when indeterminate.
12
+ * - Forward `abortSignal` to your upload implementation to cancel the upload if the user deletes the attachment while it's uploading.
13
+ */
10
14
  export type UploadRequestOptions = {
11
15
  onProgress?: (percent: number | undefined) => void;
16
+ abortSignal?: AbortSignal;
12
17
  };
13
18
 
14
19
  export type UploadRequestFn = (
@@ -325,15 +325,6 @@ export class MessageComposer extends WithSubscriptions {
325
325
  }
326
326
 
327
327
  get hasSendableData() {
328
- // If the offline mode is enabled, we allow sending a message if the composition is not empty.
329
- if (this.client.offlineDb) {
330
- return (
331
- !this.textComposer.textIsEmpty ||
332
- !!this.attachmentManager.attachments.length ||
333
- !!this.pollId ||
334
- !!this.locationComposer.validLocation
335
- );
336
- }
337
328
  return !!(
338
329
  (!this.attachmentManager.uploadsInProgressCount &&
339
330
  (!this.textComposer.textIsEmpty ||