Skip to content

Commit ad9dafe

Browse files
authored
Merge pull request #413 from GetStream/custom-attachment-upload
feat: Add custom attachment upload template to message input
2 parents 652dd63 + ae6d36f commit ad9dafe

File tree

9 files changed

+299
-72
lines changed

9 files changed

+299
-72
lines changed

docusaurus/docs/Angular/components/AttachmentPreviewListComponent.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import Screenshot from "../assets/attachment-preview-list-screenshot.png";
22

33
The `AttachmentPreviewList` component displays a preview of the attachments uploaded to a message. Users can delete attachments using the preview component, or retry upload if it failed previously.
44

5+
The following attachment types are supported:
6+
7+
- Images - displayed inline
8+
- Video files - no preview provided inside the message input, but displayed inline inside the message list
9+
- Other files - no preview provided, users can download the uploaded files
10+
511
**Example 1** - attachment previews
612

713
<img src={Screenshot} width="1000" />

projects/customizations-example/src/app/app.component.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,18 @@
234234
<ng-template #customChannelInfo let-channel="channel">
235235
This channel has {{ channel?.data?.member_count }} members
236236
</ng-template>
237+
238+
<!-- Message inputs use separate AttachmentService instances and component-tree based DI lookup doesn't work with content injection so we need to provide the AttachmentService as an input -->
239+
<ng-template
240+
#customAttachmentUpload
241+
let-isMultipleFileUploadEnabled="isMultipleFileUploadEnabled"
242+
let-attachmentService="attachmentService"
243+
>
244+
<input
245+
#fileInput
246+
type="file"
247+
[multiple]="isMultipleFileUploadEnabled"
248+
(change)="filesSelected(fileInput.files, attachmentService)"
249+
/>
250+
<button (click)="addRandomImage(attachmentService)">Random image</button>
251+
</ng-template>

projects/customizations-example/src/app/app.component.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import {
44
TemplateRef,
55
ViewChild,
66
} from '@angular/core';
7-
import { ChannelHeaderInfoContext } from 'projects/stream-chat-angular/src/public-api';
8-
import { Channel } from 'stream-chat';
7+
import {
8+
AttachmentService,
9+
ChannelHeaderInfoContext,
10+
} from 'projects/stream-chat-angular/src/public-api';
11+
import { Attachment, Channel } from 'stream-chat';
912
import {
1013
ChatClientService,
1114
ChannelService,
@@ -31,6 +34,7 @@ import {
3134
ModalContext,
3235
NotificationContext,
3336
ThreadHeaderContext,
37+
CustomAttachmentUploadContext,
3438
} from 'stream-chat-angular';
3539
import { environment } from '../environments/environment';
3640

@@ -82,6 +86,8 @@ export class AppComponent implements AfterViewInit {
8286
private threadHeaderTemplate!: TemplateRef<ThreadHeaderContext>;
8387
@ViewChild('customChannelInfo')
8488
private chstomChannelInfoTemplate!: TemplateRef<ChannelHeaderInfoContext>;
89+
@ViewChild('customAttachmentUpload')
90+
private customAttachmentUploadTemplate!: TemplateRef<CustomAttachmentUploadContext>;
8591

8692
constructor(
8793
private chatService: ChatClientService,
@@ -155,6 +161,9 @@ export class AppComponent implements AfterViewInit {
155161
this.customTemplatesService.channelHeaderInfoTemplate$.next(
156162
this.chstomChannelInfoTemplate
157163
);
164+
this.customTemplatesService.customAttachmentUploadTemplate$.next(
165+
this.customAttachmentUploadTemplate
166+
);
158167
}
159168

160169
inviteClicked(channel: Channel) {
@@ -164,4 +173,20 @@ export class AppComponent implements AfterViewInit {
164173
} channel`
165174
);
166175
}
176+
177+
filesSelected(files: FileList | null, attachmentService: AttachmentService) {
178+
if (!files) {
179+
return;
180+
}
181+
void attachmentService.filesSelected(files);
182+
}
183+
184+
addRandomImage(attachmentService: AttachmentService) {
185+
const customAttachment: Attachment = {
186+
type: 'image',
187+
image_url: 'https://picsum.photos/200/300',
188+
fallback: 'Just a random image',
189+
};
190+
attachmentService.addAttachment(customAttachment);
191+
}
167192
}

projects/stream-chat-angular/src/lib/attachment.service.spec.ts

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
22
import { first } from 'rxjs/operators';
3+
import { Attachment } from 'stream-chat';
34
import { AttachmentService } from './attachment.service';
45
import { ChannelService } from './channel.service';
56
import { NotificationService } from './notification.service';
6-
import { AttachmentUpload } from './types';
7+
import { AttachmentUpload, DefaultStreamChatGenerics } from './types';
78

89
describe('AttachmentService', () => {
9-
let service: AttachmentService;
10+
let service: AttachmentService<DefaultStreamChatGenerics>;
1011
let uploadAttachmentsSpy: jasmine.Spy;
1112
let deleteAttachmentSpy: jasmine.Spy;
1213
let readAsDataURLSpy: jasmine.Spy;
@@ -30,7 +31,9 @@ describe('AttachmentService', () => {
3031
},
3132
],
3233
});
33-
service = TestBed.inject(AttachmentService);
34+
service = TestBed.inject(
35+
AttachmentService
36+
) as AttachmentService<DefaultStreamChatGenerics>;
3437
});
3538

3639
it('should delete attachment, if file is already uploaded', async () => {
@@ -118,6 +121,26 @@ describe('AttachmentService', () => {
118121
});
119122
}));
120123

124+
it(`shouldn't try to delete custom attachments from CDN`, fakeAsync(() => {
125+
const customAttachment = {
126+
image_url: 'url/to/my/image',
127+
type: 'image',
128+
};
129+
const attachmentUploadsSpy = jasmine.createSpy('attachmentUploadsSpy');
130+
service.attachmentUploads$.subscribe(attachmentUploadsSpy);
131+
service.addAttachment(customAttachment);
132+
let attachmentUpload!: AttachmentUpload;
133+
service.attachmentUploads$.pipe(first()).subscribe((uploads) => {
134+
attachmentUpload = uploads[0];
135+
});
136+
attachmentUploadsSpy.calls.reset();
137+
void service.deleteAttachment(attachmentUpload);
138+
tick();
139+
140+
expect(attachmentUploadsSpy).toHaveBeenCalledWith([]);
141+
expect(deleteAttachmentSpy).not.toHaveBeenCalled();
142+
}));
143+
121144
it('should display error message, if upload was unsuccessful', async () => {
122145
const image = { name: 'my_image.png', type: 'image/png' } as File;
123146
const file = { name: 'user_guide.pdf', type: 'application/pdf' } as File;
@@ -322,6 +345,14 @@ describe('AttachmentService', () => {
322345
videoFile,
323346
] as any as FileList);
324347

348+
const customAttachment: Attachment = {
349+
type: 'video',
350+
asset_url: 'url/to/my/video',
351+
thumb_url: 'url/to/my/thumb',
352+
};
353+
354+
service.addAttachment(customAttachment);
355+
325356
expect(service.mapToAttachments()).toEqual([
326357
{ fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image' },
327358
{
@@ -338,57 +369,81 @@ describe('AttachmentService', () => {
338369
type: 'file',
339370
thumb_url: undefined,
340371
},
372+
{
373+
type: 'video',
374+
asset_url: 'url/to/my/video',
375+
thumb_url: 'url/to/my/thumb',
376+
isCustomAttachment: true,
377+
},
341378
]);
342379
});
343380

344381
it('should create attachmentUploads from attachments', () => {
382+
const attachments = [
383+
{ fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image' },
384+
{
385+
title: 'note.txt',
386+
file_size: 3272969,
387+
asset_url: 'http://url/to/data',
388+
type: 'file',
389+
},
390+
{
391+
title: 'cute.mov',
392+
file_size: 45367543,
393+
asset_url: 'http://url/to/video',
394+
type: 'video',
395+
thumb_url: 'http://url/to/poster',
396+
},
397+
{
398+
type: 'file',
399+
asset_url: 'url/to/my/file',
400+
title: 'my-file.pdf',
401+
isCustomAttachment: true,
402+
},
403+
];
345404
const imageFile = { name: 'flower.png' };
346405
const dataFile = { name: 'note.txt', size: 3272969 };
347406
const videoFile = { name: 'cute.mov', size: 45367543 };
407+
const customFile = { name: 'my-file.pdf', size: undefined };
348408
const result = [
349409
{
350410
file: imageFile,
351411
state: 'success',
352412
url: 'http://url/to/img',
353413
type: 'image',
414+
fromAttachment: attachments[0],
354415
},
355416
{
356417
file: dataFile,
357418
state: 'success',
358419
url: 'http://url/to/data',
359420
type: 'file',
360421
thumb_url: undefined,
422+
fromAttachment: attachments[1],
361423
},
362424
{
363425
file: videoFile,
364426
state: 'success',
365427
url: 'http://url/to/video',
366428
type: 'video',
367429
thumb_url: 'http://url/to/poster',
430+
fromAttachment: attachments[2],
368431
},
369-
];
370-
const attachments = [
371-
{ fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image' },
372432
{
373-
title: 'note.txt',
374-
file_size: 3272969,
375-
asset_url: 'http://url/to/data',
433+
file: customFile,
376434
type: 'file',
377-
},
378-
{
379-
title: 'cute.mov',
380-
file_size: 45367543,
381-
asset_url: 'http://url/to/video',
382-
type: 'video',
383-
thumb_url: 'http://url/to/poster',
435+
url: 'url/to/my/file',
436+
state: 'success',
437+
thumb_url: undefined,
438+
fromAttachment: attachments[3],
384439
},
385440
];
386441
const spy = jasmine.createSpy();
387442
service.attachmentUploads$.subscribe(spy);
388443
spy.calls.reset();
389444
service.createFromAttachments(attachments);
390445

391-
expect(spy).toHaveBeenCalledWith(result);
446+
expect(spy).toHaveBeenCalledWith(jasmine.arrayContaining(result));
392447
});
393448

394449
it('should ignore URL attachments if creating from attachments', () => {
@@ -400,4 +455,32 @@ describe('AttachmentService', () => {
400455

401456
expect(spy).not.toHaveBeenCalled();
402457
});
458+
459+
it('should be able to add custom attachment', () => {
460+
const customAttachment: Attachment = {
461+
type: 'file',
462+
asset_url: 'url/to/my/file',
463+
file_size: undefined,
464+
title: 'my-file.pdf',
465+
};
466+
467+
const spy = jasmine.createSpy();
468+
service.attachmentUploads$.subscribe(spy);
469+
spy.calls.reset();
470+
service.addAttachment(customAttachment);
471+
472+
expect(spy).toHaveBeenCalledWith([
473+
{
474+
url: 'url/to/my/file',
475+
state: 'success',
476+
file: {
477+
name: 'my-file.pdf',
478+
size: undefined,
479+
} as unknown as File,
480+
type: 'file',
481+
fromAttachment: { ...customAttachment, isCustomAttachment: true },
482+
thumb_url: undefined,
483+
},
484+
]);
485+
});
403486
});

projects/stream-chat-angular/src/lib/attachment.service.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import { Attachment } from 'stream-chat';
55
import { ChannelService } from './channel.service';
66
import { isImageAttachment } from './is-image-attachment';
77
import { NotificationService } from './notification.service';
8-
import { AttachmentUpload } from './types';
8+
import { AttachmentUpload, DefaultStreamChatGenerics } from './types';
99

1010
/**
1111
* The `AttachmentService` manages the uploads of a message input.
1212
*/
1313
@Injectable({
1414
providedIn: 'root',
1515
})
16-
export class AttachmentService {
16+
export class AttachmentService<
17+
T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
18+
> {
1719
/**
1820
* Emits the number of uploads in progress.
1921
*/
@@ -91,6 +93,18 @@ export class AttachmentService {
9193
await this.uploadAttachments(newUploads);
9294
}
9395

96+
/**
97+
* You can add custom `image`, `video` and `file` attachments using this method.
98+
*
99+
* Note: If you just want to use your own CDN for file uploads, you don't necessary need this method, you can just specify you own upload function in the [`ChannelService`](./ChannelService.mdx)
100+
*
101+
* @param attachment
102+
*/
103+
addAttachment(attachment: Attachment<T>) {
104+
attachment.isCustomAttachment = true;
105+
this.createFromAttachments([attachment]);
106+
}
107+
94108
/**
95109
* Retries to upload an attachment.
96110
* @param file
@@ -114,7 +128,10 @@ export class AttachmentService {
114128
async deleteAttachment(upload: AttachmentUpload) {
115129
const attachmentUploads = this.attachmentUploadsSubject.getValue();
116130
let result!: AttachmentUpload[];
117-
if (upload.state === 'success') {
131+
if (
132+
upload.state === 'success' &&
133+
!upload.fromAttachment?.isCustomAttachment
134+
) {
118135
try {
119136
await this.channelService.deleteAttachment(upload);
120137
result = [...attachmentUploads];
@@ -146,14 +163,18 @@ export class AttachmentService {
146163
const attachment: Attachment = {
147164
type: r.type,
148165
};
149-
if (r.type === 'image') {
150-
attachment.fallback = r.file?.name;
151-
attachment.image_url = r.url;
166+
if (r.fromAttachment) {
167+
return r.fromAttachment;
152168
} else {
153-
attachment.asset_url = r.url;
154-
attachment.title = r.file?.name;
155-
attachment.file_size = r.file?.size;
156-
attachment.thumb_url = r.thumb_url;
169+
if (r.type === 'image') {
170+
attachment.fallback = r.file?.name;
171+
attachment.image_url = r.url;
172+
} else {
173+
attachment.asset_url = r.url;
174+
attachment.title = r.file?.name;
175+
attachment.file_size = r.file?.size;
176+
attachment.thumb_url = r.thumb_url;
177+
}
157178
}
158179

159180
return attachment;
@@ -164,7 +185,7 @@ export class AttachmentService {
164185
* Maps attachments received from the Stream API to uploads. This is useful when editing a message.
165186
* @param attachments Attachemnts received with the message
166187
*/
167-
createFromAttachments(attachments: Attachment[]) {
188+
createFromAttachments(attachments: Attachment<T>[]) {
168189
const attachmentUploads: AttachmentUpload[] = [];
169190
attachments.forEach((attachment) => {
170191
if (isImageAttachment(attachment)) {
@@ -177,6 +198,7 @@ export class AttachmentService {
177198
file: {
178199
name: attachment.fallback,
179200
} as File,
201+
fromAttachment: attachment,
180202
});
181203
} else if (attachment.type === 'file' || attachment.type === 'video') {
182204
attachmentUploads.push({
@@ -188,6 +210,7 @@ export class AttachmentService {
188210
} as File,
189211
type: attachment.type,
190212
thumb_url: attachment.thumb_url,
213+
fromAttachment: attachment,
191214
});
192215
}
193216
});

0 commit comments

Comments
 (0)