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
4 changes: 4 additions & 0 deletions projects/stream-chat-angular/src/assets/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,9 @@ export const en = {
'An error has occurred during recording':
'An error has occurred during recording',
'Media recording not supported': 'Media recording not supported',
"You can't uplod more than {{max}} attachments":
"You can't uplod more than {{max}} attachments",
'You currently have {{count}} attachments, the maximum is {{max}}':
'You currently have {{count}} attachments, the maximum is {{max}}',
},
};
97 changes: 97 additions & 0 deletions projects/stream-chat-angular/src/lib/attachment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,100 @@ describe('AttachmentService', () => {
);
});

it('should display error message if attachment limit is exceeded', () => {
const notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'addPermanentNotification');
const attachments = new Array(30)
.fill(null)
.map(() => ({ type: 'custom' }));
service.customAttachments$.next(attachments);

expect(notificationService.addPermanentNotification).not.toHaveBeenCalled();

service.customAttachments$.next([
...service.customAttachments$.value,
{ type: 'custom' },
]);

expect(notificationService.addPermanentNotification).toHaveBeenCalledWith(
'streamChat.You currently have {{count}} attachments, the maximum is {{max}}',
'error',
{ count: 31, max: service.maxNumberOfAttachments }
);
});

it('should prevent file upload if limit would be exceeded', async () => {
const notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'addTemporaryNotification');
const attachments = new Array(28)
.fill(null)
.map(() => ({ type: 'custom' }));
service.customAttachments$.next(attachments);

expect(notificationService.addTemporaryNotification).not.toHaveBeenCalled();

const files = new Array(3)
.fill(null)
.map(() => ({ name: 'my_image.png', type: 'image/png' } as File));
await service.filesSelected(files);

expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith(
`streamChat.You can't uplod more than {{max}} attachments`,
'error',
undefined,
{ max: service.maxNumberOfAttachments }
);

expect(uploadAttachmentsSpy).not.toHaveBeenCalled();
});

it('should prevent voice recording upload if limit be exceeded', async () => {
const notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'addTemporaryNotification');
const attachments = new Array(29)
.fill(null)
.map(() => ({ type: 'custom' }));
service.customAttachments$.next(attachments);

expect(notificationService.addTemporaryNotification).not.toHaveBeenCalled();

const voiceRecording = {
recording: { name: 'test', type: 'audio/wav' } as File,
asset_url: 'test',
duration: 3.073,
file_size: 96044,
mime_type: 'audio/wav',
waveform_data: [
0, 0, 0.1931696001580504, 0.1931696001580504, 0.2430087421276868,
0.2430087421276868, 0.2531576820785543,
],
};

uploadAttachmentsSpy.and.resolveTo([
{
file: voiceRecording.recording,
state: 'success',
type: 'voiceRecording',
},
]);

await service.uploadVoiceRecording(voiceRecording);

expect(notificationService.addTemporaryNotification).not.toHaveBeenCalled();

uploadAttachmentsSpy.calls.reset();
await service.uploadVoiceRecording(voiceRecording);

expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith(
`streamChat.You can't uplod more than {{max}} attachments`,
'error',
undefined,
{ max: service.maxNumberOfAttachments }
);

expect(uploadAttachmentsSpy).not.toHaveBeenCalled();
});

it('should retry attachment upload', async () => {
const file = { name: 'my_image.png', type: 'image/png' } as File;
uploadAttachmentsSpy.and.resolveTo([
Expand Down Expand Up @@ -381,6 +475,8 @@ describe('AttachmentService', () => {
}));

it('should reset attachments', () => {
const errorNotificationHideSpy = jasmine.createSpy();
service['attachmentLimitNotificationHide'] = errorNotificationHideSpy;
const spy = jasmine.createSpy();
service.customAttachments$.next([{ type: 'custom' }]);
service.attachmentUploads$.subscribe(spy);
Expand All @@ -389,6 +485,7 @@ describe('AttachmentService', () => {

expect(spy).toHaveBeenCalledWith([]);
expect(service.customAttachments$.value).toEqual([]);
expect(errorNotificationHideSpy).toHaveBeenCalledWith();
});

it('should map to attachments', async () => {
Expand Down
73 changes: 72 additions & 1 deletion projects/stream-chat-angular/src/lib/attachment.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Injectable } from '@angular/core';
import { createUriFromBlob, isImageFile } from './file-utils';
import { BehaviorSubject, Observable } from 'rxjs';
import {
BehaviorSubject,
combineLatest,
map,
Observable,
shareReplay,
take,
} from 'rxjs';
import { AppSettings, Attachment } from 'stream-chat';
import { ChannelService } from './channel.service';
import { isImageAttachment } from './is-image-attachment';
Expand Down Expand Up @@ -42,10 +49,21 @@ export class AttachmentService<
* By default the SDK components won't display these, but you can provide your own `customAttachmentPreviewListTemplate$` and `customAttachmentListTemplate$` for the [`CustomTemplatesService`](../../services/CustomTemplatesService).
*/
customAttachments$ = new BehaviorSubject<Attachment<T>[]>([]);
/**
* The current number of attachments
*/
attachmentsCounter$: Observable<number>;
/**
* The maximum number of attachments allowed for a message.
*
* The maximum is 30, you can set it to lower, but not higher.
*/
maxNumberOfAttachments = 30;
private attachmentUploadsSubject = new BehaviorSubject<AttachmentUpload[]>(
[]
);
private appSettings: AppSettings | undefined;
private attachmentLimitNotificationHide?: () => void;

constructor(
private channelService: ChannelService,
Expand All @@ -57,6 +75,30 @@ export class AttachmentService<
this.chatClientService.appSettings$.subscribe(
(appSettings) => (this.appSettings = appSettings)
);
this.attachmentsCounter$ = combineLatest([
this.attachmentUploads$,
this.customAttachments$,
]).pipe(
map(([attchmentUploads, customAttachments]) => {
return (
attchmentUploads.filter((u) => u.state === 'success').length +
customAttachments.length
);
}),
shareReplay(1)
);
this.attachmentsCounter$.subscribe((count) => {
if (count > this.maxNumberOfAttachments) {
this.attachmentLimitNotificationHide =
this.notificationService.addPermanentNotification(
'streamChat.You currently have {{count}} attachments, the maximum is {{max}}',
'error',
{ count, max: this.maxNumberOfAttachments }
);
} else {
this.attachmentLimitNotificationHide?.();
}
});
}

/**
Expand All @@ -65,6 +107,7 @@ export class AttachmentService<
resetAttachmentUploads() {
this.attachmentUploadsSubject.next([]);
this.customAttachments$.next([]);
this.attachmentLimitNotificationHide?.();
}

/**
Expand All @@ -73,6 +116,9 @@ export class AttachmentService<
* @returns A promise with true or false. If false is returned the upload was canceled because of a client side error. The error is emitted via the `NotificationService`.
*/
async uploadVoiceRecording(audioRecording: AudioRecording) {
if (!this.isWithinLimit(1)) {
return false;
}
if (
!(await this.areAttachmentsHaveValidExtension([audioRecording.recording]))
) {
Expand Down Expand Up @@ -112,6 +158,10 @@ export class AttachmentService<

const files = Array.from(fileList);

if (!this.isWithinLimit(files.length)) {
return false;
}

if (!(await this.areAttachmentsHaveValidExtension(files))) {
return false;
}
Expand Down Expand Up @@ -501,4 +551,25 @@ export class AttachmentService<
});
return isValid;
}

private isWithinLimit(numberOfNewAttachments: number) {
let currentNumberOfAttachments: number = 0;
this.attachmentsCounter$
.pipe(take(1))
.subscribe((counter) => (currentNumberOfAttachments = counter));
if (
currentNumberOfAttachments + numberOfNewAttachments >
this.maxNumberOfAttachments
) {
this.notificationService.addTemporaryNotification(
`streamChat.You can't uplod more than {{max}} attachments`,
'error',
undefined,
{ max: this.maxNumberOfAttachments }
);
return false;
} else {
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
data-testid="file-input"
[multiple]="isMultipleFileUploadEnabled"
id="{{ fileInputId }}"
[disabled]="
(attachmentService.attachmentsCounter$ | async)! >=
attachmentService.maxNumberOfAttachments
"
(change)="filesSelected(fileInput.files)"
/>
<label class="str-chat__file-input-label" for="{{ fileInputId }}">
Expand Down Expand Up @@ -148,6 +152,8 @@
class="str-chat__send-button"
[disabled]="
(attachmentUploadInProgressCounter$ | async)! > 0 ||
(attachmentService.attachmentsCounter$ | async)! >
attachmentService.maxNumberOfAttachments ||
(!textareaValue &&
(attachmentUploads$ | async)!.length === 0 &&
(customAttachments$ | async)!.length === 0)
Expand All @@ -170,7 +176,9 @@
data-testid="start-voice-recording"
[disabled]="
voiceRecorderService.isRecorderVisible$.value ||
audioRecorder?.isRecording
audioRecorder?.isRecording ||
(attachmentService.attachmentsCounter$ | async)! >=
attachmentService.maxNumberOfAttachments
"
(click)="startVoiceRecording()"
(keyup.enter)="startVoiceRecording()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ describe('MessageInputComponent', () => {
let queryFileInput: () => HTMLInputElement | null;
let queryCooldownTimer: () => HTMLElement | null;
let queryAttachmentPreviewList: () => AttachmentPreviewListComponent;
let queryVoiceRecorderButton: () => HTMLButtonElement | null;
let mockActiveChannel$: BehaviorSubject<Channel<DefaultStreamChatGenerics>>;
let mockActiveParentMessageId$: BehaviorSubject<string | undefined>;
let sendMessageSpy: jasmine.Spy;
let updateMessageSpy: jasmine.Spy;
let channel: Channel<DefaultStreamChatGenerics>;
let user: UserResponse;
let attachmentService: {
attachmentsCounter$: BehaviorSubject<number>;
maxNumberOfAttachments: number;
customAttachments$: BehaviorSubject<Attachment[]>;
attachmentUploadInProgressCounter$: Subject<number>;
attachmentUploads$: Subject<AttachmentUpload[]>;
Expand Down Expand Up @@ -87,6 +90,8 @@ describe('MessageInputComponent', () => {
typingStartedSpy = jasmine.createSpy();
typingStoppedSpy = jasmine.createSpy();
attachmentService = {
maxNumberOfAttachments: 30,
attachmentsCounter$: new BehaviorSubject(0),
customAttachments$: new BehaviorSubject<Attachment[]>([]),
resetAttachmentUploads: jasmine.createSpy(),
attachmentUploadInProgressCounter$: new BehaviorSubject(0),
Expand Down Expand Up @@ -171,6 +176,8 @@ describe('MessageInputComponent', () => {
queryAttachmentPreviewList = () =>
fixture.debugElement.query(By.directive(AttachmentPreviewListComponent))
.componentInstance as AttachmentPreviewListComponent;
queryVoiceRecorderButton = () =>
nativeElement.querySelector('[data-testid="start-voice-recording"]');
fixture.detectChanges();
});

Expand Down Expand Up @@ -254,16 +261,12 @@ describe('MessageInputComponent', () => {
component.displayVoiceRecordingButton = false;
fixture.detectChanges();

expect(
nativeElement.querySelector('[data-testid="start-voice-recording"]')
).toBeNull();
expect(queryVoiceRecorderButton()).toBeNull();

component.displayVoiceRecordingButton = true;
fixture.detectChanges();

expect(
nativeElement.querySelector('[data-testid="start-voice-recording"]')
).not.toBeNull();
expect(queryVoiceRecorderButton()).not.toBeNull();
});

it('should emit #messageUpdate event if message update was successful', async () => {
Expand Down Expand Up @@ -1107,4 +1110,25 @@ describe('MessageInputComponent', () => {
expect(component['messageToUpdateChanged']).not.toHaveBeenCalledWith();
expect(component.message).toBe(undefined);
});

it('should disable send button if uploaded attachments exceed limit', () => {
attachmentService.attachmentsCounter$.next(
attachmentService.maxNumberOfAttachments + 1
);
component.textareaValue = 'text message';
fixture.detectChanges();

expect(querySendButton()?.disabled).toBe(true);
});

it('should disable upload buttons if attachment counter limit is reached', () => {
component.displayVoiceRecordingButton = true;
attachmentService.attachmentsCounter$.next(
attachmentService.maxNumberOfAttachments
);
fixture.detectChanges();

expect(queryFileInput()?.disabled).toBe(true);
expect(queryVoiceRecorderButton()?.disabled).toBe(true);
});
});