UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

222 lines (194 loc) 7.91 kB
import axios from 'axios'; import type { StreamChat } from './client'; import type { UploadRequestOptions } from './messageComposer/configuration/types'; import { StateStore } from './store'; import type { AttachmentManager } from '.'; /** * Whether an upload ended because it was aborted rather than because it failed. * * {@link UploadManager.deleteUploadRecord} and {@link UploadManager.cancelAllUploads} abort the * request through its `AbortController`, which is what happens when an attachment is removed * from the composer mid-upload or the client disconnects. Axios reports that as a * `CanceledError` (`axios.isCancel`); a custom `doUploadRequest` on another transport * conventionally throws a `DOMException` named `AbortError`. * * Callers use this to keep a deliberate cancellation from being reported as an error. */ export const isUploadCancellation = (error: unknown) => axios.isCancel(error) || (error instanceof Error && error.name === 'AbortError'); export type UploadRecord = { id: string; /** * `true` once every byte has been handed to the transport but the server has not responded * yet. * * Upload progress measures bytes *written to the connection*, not bytes the server * acknowledged, so it reaches 100% the moment the request body is flushed — then the * connection sits idle while the CDN ingests the file and the response travels back. On a * large file over a slow link that window is long, and a UI that renders 100% as "done" * claims the upload is confirmed while it is not. * * Nothing measurable happens during that window, so it is the point at which a determinate * progress bar should hand over to an indeterminate one. Confirmation is the record being * removed, not progress reaching 100. * * Requires `AttachmentManagerConfig.trackUploadProgress`; without progress reporting there is * no way to tell when the flush happened, and this stays `false`. */ uploadConfirmationPending?: boolean; uploadProgress?: number; }; export type UploadManagerState = { uploads: Record<string, UploadRecord>; }; const initState = (): UploadManagerState => ({ uploads: {} }); const upsertById = ( uploads: Record<string, UploadRecord>, record: UploadRecord, ): Record<string, UploadRecord> => ({ ...uploads, [record.id]: { ...uploads[record.id], ...record }, }); const updateById = ( uploads: Record<string, UploadRecord>, record: UploadRecord, ): Record<string, UploadRecord> | null => { if (!(record.id in uploads)) return null; const current = uploads[record.id]; return { ...uploads, [record.id]: { ...current, ...record } }; }; type UploadPromise = ReturnType<typeof AttachmentManager.prototype.doUploadRequest>; type InFlightUpload = { promise: UploadPromise; abortController: AbortController }; /** * @internal */ export class UploadManager { readonly state: StateStore<UploadManagerState>; private inFlightUploads = new Map<string, InFlightUpload>(); constructor(private readonly client: StreamChat) { this.state = new StateStore<UploadManagerState>(initState()); } private resolveAttachmentManager(channelCid: string) { const colon = channelCid.indexOf(':'); if (colon <= 0 || colon === channelCid.length - 1) { throw new Error(`Invalid channelCid: ${channelCid}`); } const channelType = channelCid.slice(0, colon); const channelId = channelCid.slice(colon + 1); return this.client.channel(channelType, channelId).messageComposer.attachmentManager; } get uploads() { return this.state.getLatestValue().uploads; } getUpload = (id: string) => this.uploads[id]; /** * Clears all upload records. * Invoked when the user disconnects so a later session does not inherit stale upload state. * Aborts every in-flight upload request via its `UploadRequestOptions.abortSignal`. */ reset = () => { for (const { abortController } of this.inFlightUploads.values()) { abortController.abort(); } this.inFlightUploads.clear(); this.state.next(initState()); }; /** * Removes the upload record for `id` if present. * If an upload is still in progress, aborts its `UploadRequestOptions.abortSignal`. */ deleteUploadRecord = (id: string) => { const flight = this.inFlightUploads.get(id); if (flight) { this.inFlightUploads.delete(id); flight.abortController.abort(); } this.state.next((current) => { if (!(id in current.uploads)) return current; const uploads = { ...current.uploads }; delete uploads[id]; return { ...current, uploads }; }); }; /** * Starts an upload for `id`, or returns the existing in-flight promise if one is already running. * Uses {@link StreamChat.channel}(`channelCid`) → `messageComposer.attachmentManager.doUploadRequest`. * Resolves with that result; rejects if the upload rejects (the record is removed from state either way). */ upload = ({ id, channelCid, file, }: { id: string; channelCid: string; file: Parameters<typeof AttachmentManager.prototype.doUploadRequest>[0]; }): ReturnType<typeof AttachmentManager.prototype.doUploadRequest> => { const existing = this.inFlightUploads.get(id); if (existing) return existing.promise; let resolvePromise!: (value: Awaited<UploadPromise>) => void; let rejectPromise!: (reason?: unknown) => void; const promise = new Promise<Awaited<UploadPromise>>((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); const abortController = new AbortController(); this.inFlightUploads.set(id, { promise, abortController }); void (async () => { const attachmentManager = this.resolveAttachmentManager(channelCid); const trackProgress = attachmentManager.config.trackUploadProgress; try { this.upsertUpload({ uploadConfirmationPending: false, id, uploadProgress: trackProgress ? 0 : undefined, }); const onProgress = trackProgress ? (progress?: number) => { this.updateUpload({ id, uploadProgress: progress, // Only a number says anything about the flush, so a report without one leaves // the flag alone: `undefined` means the transport cannot measure this upload, // not that bytes were un-sent. Lowering it there would drop a UI that had // already gone indeterminate back to a determinate one with nothing to show. // // The record is removed in the `finally` below, so a record that still exists // while reporting 100% can only mean "flushed, awaiting the response". ...(typeof progress === 'number' ? { uploadConfirmationPending: progress >= 100 } : {}), }); } : undefined; const uploadRequestOptions: UploadRequestOptions = { abortSignal: abortController.signal, ...(onProgress ? { onProgress } : {}), }; const response = await attachmentManager.doUploadRequest( file, uploadRequestOptions, ); resolvePromise(response); } catch (error) { rejectPromise(error); } finally { this.inFlightUploads.delete(id); this.deleteUploadRecord(id); } })(); return promise; }; private upsertUpload = (record: UploadRecord) => { this.state.partialNext({ uploads: upsertById(this.uploads, record), }); }; private updateUpload = (record: UploadRecord) => { this.state.next((current) => { const nextUploads = updateById(current.uploads, record); if (!nextUploads) return current; return { ...current, uploads: nextUploads }; }); }; }