Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 116 additions & 25 deletions src/messageComposer/attachmentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
isFileReference,
isImageFile,
} from './fileUtils';
import {
AttachmentPostUploadMiddlewareExecutor,
AttachmentPreUploadMiddlewareExecutor,
} from './middleware/attachmentManager';
import { StateStore } from '../store';
import { generateUUIDv4 } from '../utils';
import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants';
Expand All @@ -22,23 +26,14 @@ import type {
FileLike,
FileReference,
LocalAttachment,
LocalAudioAttachment,
LocalFileAttachment,
LocalNotImageAttachment,
LocalUploadAttachment,
LocalVideoAttachment,
LocalVoiceRecordingAttachment,
UploadPermissionCheckResult,
} from './types';
import type { ChannelResponse, DraftMessage, LocalMessage } from '../types';
import type { MessageComposer } from './messageComposer';
import { mergeWithDiff } from '../utils/mergeWith';

type LocalNotImageAttachment =
| LocalFileAttachment
| LocalAudioAttachment
| LocalVideoAttachment
| LocalVoiceRecordingAttachment;

export type FileUploadFilter = (file: Partial<LocalUploadAttachment>) => boolean;

export type AttachmentManagerState = {
Expand Down Expand Up @@ -71,6 +66,8 @@ const initState = ({
export class AttachmentManager {
readonly state: StateStore<AttachmentManagerState>;
readonly composer: MessageComposer;
readonly preUploadMiddlewareExecutor: AttachmentPreUploadMiddlewareExecutor;
readonly postUploadMiddlewareExecutor: AttachmentPostUploadMiddlewareExecutor;
private attachmentsByIdGetterCache: {
attachmentsById: Record<string, LocalAttachment>;
attachments: LocalAttachment[];
Expand All @@ -80,6 +77,13 @@ export class AttachmentManager {
this.composer = composer;
this.state = new StateStore<AttachmentManagerState>(initState({ message }));
this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] };

this.preUploadMiddlewareExecutor = new AttachmentPreUploadMiddlewareExecutor({
composer,
});
this.postUploadMiddlewareExecutor = new AttachmentPostUploadMiddlewareExecutor({
composer,
});
}

get attachmentsById() {
Expand Down Expand Up @@ -122,10 +126,16 @@ export class AttachmentManager {
this.composer.updateConfig({ attachments: { acceptedFiles } });
}

/*
@deprecated attachments can be filtered using injecting pre-upload middleware
*/
get fileUploadFilter() {
return this.config.fileUploadFilter;
}

/*
@deprecated attachments can be filtered using injecting pre-upload middleware
*/
set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) {
this.composer.updateConfig({ attachments: { fileUploadFilter } });
}
Expand Down Expand Up @@ -333,9 +343,9 @@ export class AttachmentManager {
return { uploadBlocked: false };
};

fileToLocalUploadAttachment = async (
static toLocalUploadAttachment = (
fileLike: FileReference | FileLike,
): Promise<LocalUploadAttachment> => {
): LocalUploadAttachment => {
const file =
isFileReference(fileLike) || isFile(fileLike)
? fileLike
Expand All @@ -345,16 +355,13 @@ export class AttachmentManager {
mimeType: fileLike.type,
});

const uploadPermissionCheck = await this.getUploadConfigCheck(file);

const localAttachment: LocalUploadAttachment = {
file_size: file.size,
mime_type: file.type,
localMetadata: {
file,
id: generateUUIDv4(),
uploadPermissionCheck,
uploadState: uploadPermissionCheck.uploadBlocked ? 'blocked' : 'pending',
uploadState: 'pending',
},
type: getAttachmentTypeFromMimeType(file.type),
};
Expand Down Expand Up @@ -383,10 +390,26 @@ export class AttachmentManager {
return localAttachment;
};

// @deprecated use AttachmentManager.toLocalUploadAttachment(file)
fileToLocalUploadAttachment = async (
fileLike: FileReference | FileLike,
): Promise<LocalUploadAttachment> => {
const localAttachment = AttachmentManager.toLocalUploadAttachment(fileLike);
const uploadPermissionCheck = await this.getUploadConfigCheck(
localAttachment.localMetadata.file,
);
localAttachment.localMetadata.uploadPermissionCheck = uploadPermissionCheck;
localAttachment.localMetadata.uploadState = uploadPermissionCheck.uploadBlocked
? 'blocked'
: 'pending';

return localAttachment;
};

private ensureLocalUploadAttachment = async (
attachment: Partial<LocalUploadAttachment>,
) => {
if (!attachment.localMetadata?.file || !attachment.localMetadata.id) {
if (!attachment.localMetadata?.file) {
this.client.notifications.addError({
message: 'File is required for upload attachment',
origin: { emitter: 'AttachmentManager', context: { attachment } },
Expand All @@ -395,6 +418,15 @@ export class AttachmentManager {
return;
}

if (!attachment.localMetadata.id) {
this.client.notifications.addError({
message: 'Local upload attachment missing local id',
origin: { emitter: 'AttachmentManager', context: { attachment } },
options: { type: 'validation:attachment:id:missing' },
});
return;
}

if (!this.fileUploadFilter(attachment)) return;

const newAttachment = await this.fileToLocalUploadAttachment(
Expand Down Expand Up @@ -446,6 +478,7 @@ export class AttachmentManager {
return this.doDefaultUploadRequest(fileLike);
};

// @deprecated use attachmentManager.uploadFile(file)
uploadAttachment = async (attachment: LocalUploadAttachment) => {
if (!this.isUploadEnabled) return;

Expand Down Expand Up @@ -546,20 +579,78 @@ export class AttachmentManager {
return uploadedAttachment;
};

uploadFile = async (file: FileReference | FileLike) => {
const preUpload = await this.preUploadMiddlewareExecutor.execute({
eventName: 'prepare',
initialValue: {
attachment: AttachmentManager.toLocalUploadAttachment(file),
},
mode: 'concurrent',
});

let attachment: LocalUploadAttachment = preUpload.state.attachment;

if (preUpload.status === 'discard') return attachment;
// todo: remove with the next major release as filtering can be done in middleware
// should we return the attachment object?
if (!this.fileUploadFilter(attachment)) return attachment;

if (attachment.localMetadata.uploadState === 'blocked') {
this.upsertAttachments([attachment]);
return preUpload.state.attachment;
}

attachment = {
...attachment,
localMetadata: {
...attachment.localMetadata,
uploadState: 'uploading',
},
};
this.upsertAttachments([attachment]);

let response: MinimumUploadRequestResult | undefined;
let error: Error | undefined;
try {
response = await this.doUploadRequest(file);
} catch (err) {
error = err instanceof Error ? err : undefined;
}

const postUpload = await this.postUploadMiddlewareExecutor.execute({
eventName: 'postProcess',
initialValue: {
attachment: {
...attachment,
localMetadata: {
...attachment.localMetadata,
uploadState: error ? 'failed' : 'finished',
},
},
error,
response,
},
mode: 'concurrent',
});
attachment = postUpload.state.attachment;

if (postUpload.status === 'discard') {
this.removeAttachments([attachment.localMetadata.id]);
return attachment;
}

this.updateAttachment(attachment);
return attachment;
};

uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => {
if (!this.isUploadEnabled) return;
const iterableFiles: FileReference[] | FileLike[] = isFileList(files)
? Array.from(files)
: files;
const attachments = await Promise.all(
iterableFiles.map(this.fileToLocalUploadAttachment),
);

return Promise.all(
attachments
.filter(this.fileUploadFilter)
.slice(0, this.availableUploadSlots)
.map(this.uploadAttachment),
return await Promise.all(
iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile),
);
};
}
5 changes: 3 additions & 2 deletions src/messageComposer/configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { LinkPreview } from '../linkPreviewsManager';
import type { FileUploadFilter } from '../attachmentManager';
import type { FileLike, FileReference } from '../types';

export type MinimumUploadRequestResult = { file: string; thumb_url?: string };
export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial<
Record<string, unknown>
>;

export type UploadRequestFn = (
fileLike: FileReference | FileLike,
Expand Down Expand Up @@ -39,7 +41,6 @@ export type AttachmentManagerConfig = {
* describing which file types are allowed to be selected when uploading files.
*/
acceptedFiles: string[];
// todo: refactor this. We want a pipeline where it would be possible to customize the preparation, upload, and post-upload steps.
/** Function that allows to customize the upload request. */
doUploadRequest?: UploadRequestFn;
};
Expand Down
3 changes: 3 additions & 0 deletions src/messageComposer/middleware/attachmentManager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './postUpload';
export * from './preUpload';
export * from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MiddlewareExecutor } from '../../../../middleware';
import type {
AttachmentPostUploadMiddlewareExecutorOptions,
AttachmentPostUploadMiddlewareState,
} from '../types';
import { createPostUploadAttachmentEnrichmentMiddleware } from './attachmentEnrichment';
import { createUploadErrorHandlerMiddleware } from './uploadErrorHandler';

export class AttachmentPostUploadMiddlewareExecutor extends MiddlewareExecutor<
AttachmentPostUploadMiddlewareState,
'postProcess'
> {
constructor({ composer }: AttachmentPostUploadMiddlewareExecutorOptions) {
super();
this.use([
createUploadErrorHandlerMiddleware(composer),
createPostUploadAttachmentEnrichmentMiddleware(),
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { MiddlewareHandlerParams } from '../../../../middleware';
import type {
AttachmentPostUploadMiddleware,
AttachmentPostUploadMiddlewareState,
} from '../types';
import { isLocalImageAttachment } from '../../../attachmentIdentity';
import type { LocalNotImageAttachment } from '../../../types';

export const createPostUploadAttachmentEnrichmentMiddleware =
(): AttachmentPostUploadMiddleware => ({
id: 'stream-io/attachment-manager-middleware/post-upload-enrichment',
handlers: {
postProcess: ({
state,
discard,
forward,
next,
}: MiddlewareHandlerParams<AttachmentPostUploadMiddlewareState>) => {
const { attachment, error, response } = state;
if (error) return forward();
if (!attachment || !response) return discard();

const enrichedAttachment = { ...attachment };
if (isLocalImageAttachment(attachment)) {
if (attachment.localMetadata.previewUri) {
URL.revokeObjectURL(attachment.localMetadata.previewUri);
delete enrichedAttachment.localMetadata.previewUri;
}
enrichedAttachment.image_url = response.file;
} else {
(enrichedAttachment as LocalNotImageAttachment).asset_url = response.file;
}
if (response.thumb_url) {
(enrichedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url;
}

return next({
...state,
attachment: enrichedAttachment,
});
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './attachmentEnrichment';
export * from './AttachmentPostUploadMiddlewareExecutor';
export * from './uploadErrorHandler';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { MiddlewareHandlerParams } from '../../../../middleware';
import type { MessageComposer } from '../../../messageComposer';
import type {
AttachmentPostUploadMiddleware,
AttachmentPostUploadMiddlewareState,
} from '../types';

export const createUploadErrorHandlerMiddleware = (
composer: MessageComposer,
): AttachmentPostUploadMiddleware => ({
id: 'stream-io/attachment-manager-middleware/upload-error',
handlers: {
postProcess: ({
state,
discard,
forward,
}: MiddlewareHandlerParams<AttachmentPostUploadMiddlewareState>) => {
const { attachment, error } = state;
if (!error) return forward();
if (!attachment) return discard();

const reason = error instanceof Error ? error.message : 'unknown error';
composer.client.notifications.addError({
message: 'Error uploading attachment',
origin: {
emitter: 'AttachmentManager',
context: { attachment },
},
options: {
type: 'api:attachment:upload:failed',
metadata: { reason },
originalError: error,
},
});

return forward();
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MiddlewareExecutor } from '../../../../middleware';
import type {
AttachmentPreUploadMiddlewareExecutorOptions,
AttachmentPreUploadMiddlewareState,
} from '../types';
import { createUploadConfigCheckMiddleware } from './serverUploadConfigCheck';
import { createBlockedAttachmentUploadNotificationMiddleware } from './blockedUploadNotification';

export class AttachmentPreUploadMiddlewareExecutor extends MiddlewareExecutor<
AttachmentPreUploadMiddlewareState,
'prepare'
> {
constructor({ composer }: AttachmentPreUploadMiddlewareExecutorOptions) {
super();
this.use([
createUploadConfigCheckMiddleware(composer),
createBlockedAttachmentUploadNotificationMiddleware(composer),
]);
}
}
Loading