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
23 changes: 23 additions & 0 deletions projects/stream-chat-angular/src/lib/channel.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,20 @@ describe('ChannelService', () => {
service.channels$.subscribe(channelsSpy);
const messageToQuoteSpy = jasmine.createSpy();
service.messageToQuote$.subscribe(messageToQuoteSpy);
const latestMessagesSpy = jasmine.createSpy();
service.latestMessageDateByUserByChannels$.subscribe(latestMessagesSpy);
messagesSpy.calls.reset();
activeChannelSpy.calls.reset();
channelsSpy.calls.reset();
messageToQuoteSpy.calls.reset();
latestMessagesSpy.calls.reset();
service.reset();

expect(messagesSpy).toHaveBeenCalledWith([]);
expect(channelsSpy).toHaveBeenCalledWith(undefined);
expect(activeChannelSpy).toHaveBeenCalledWith(undefined);
expect(messageToQuoteSpy).toHaveBeenCalledWith(undefined);
expect(latestMessagesSpy).toHaveBeenCalledWith({});
});

it('should tell if user #hasMoreChannels$', async () => {
Expand Down Expand Up @@ -1277,4 +1281,23 @@ describe('ChannelService', () => {
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
});

it('should emit the date of latest messages sent by the user by channels', async () => {
await init();
let activeChannel!: Channel;
service.activeChannel$
.pipe(first())
.subscribe((c) => (activeChannel = c as Channel));
const newMessage = mockMessage();
newMessage.cid = 'channel1';
newMessage.created_at = new Date();
newMessage.user_id = user.id;
const spy = jasmine.createSpy();
service.latestMessageDateByUserByChannels$.subscribe(spy);
(activeChannel as MockChannel).handleEvent('message.new', {
message: newMessage,
});

expect(spy).toHaveBeenCalledWith({ channel1: newMessage.created_at });
});
});
50 changes: 48 additions & 2 deletions projects/stream-chat-angular/src/lib/channel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,25 @@ export class ChannelService {
* Emits the currently selected parent message. If no message is selected, it emits undefined.
*/
activeParentMessage$: Observable<StreamMessage | undefined>;
/**
* Emits the currently selected message to quote
*/
messageToQuote$: Observable<StreamMessage | undefined>;
/**
* Custom event handler to call if a new message received from a channel that is not being watched, provide an event handler if you want to override the [default channel list ordering](./ChannelService.mdx/#channels)
* Emits the list of users that are currently typing in the channel (current user is not included)
*/
usersTypingInChannel$: Observable<UserResponse[]>;
/**
* Emits the list of users that are currently typing in the active thread (current user is not included)
*/
usersTypingInThread$: Observable<UserResponse[]>;
/**
* Emits a map that contains the date of the latest message sent by the current user by channels (this is used to detect is slow mode countdown should be started)
*/
latestMessageDateByUserByChannels$: Observable<{ [key: string]: Date }>;
/**
* Custom event handler to call if a new message received from a channel that is not being watched, provide an event handler if you want to override the [default channel list ordering](./ChannelService.mdx/#channels)
*/
customNewMessageNotificationHandler?: (
notification: Notification,
channelListSetter: (channels: Channel[]) => void
Expand Down Expand Up @@ -192,6 +205,9 @@ export class ChannelService {
private activeThreadMessagesSubject = new BehaviorSubject<
(StreamMessage | MessageResponse | FormatMessageResponse)[]
>([]);
private latestMessageDateByUserByChannelsSubject = new BehaviorSubject<{
[key: string]: Date;
}>({});
private filters: ChannelFilters | undefined;
private sort: ChannelSort | undefined;
private options: ChannelOptions | undefined;
Expand Down Expand Up @@ -272,6 +288,8 @@ export class ChannelService {
this.usersTypingInChannel$ =
this.usersTypingInChannelSubject.asObservable();
this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable();
this.latestMessageDateByUserByChannels$ =
this.latestMessageDateByUserByChannelsSubject.asObservable();
}

/**
Expand Down Expand Up @@ -393,6 +411,7 @@ export class ChannelService {
this.activeParentMessageIdSubject.next(undefined);
this.activeThreadMessagesSubject.next([]);
this.channelsSubject.next(undefined);
this.latestMessageDateByUserByChannelsSubject.next({});
this.selectMessageToQuote(undefined);
}

Expand Down Expand Up @@ -757,6 +776,7 @@ export class ChannelService {
void c?.markRead();
}
});
this.updateLatestMessages(event);
});
})
);
Expand Down Expand Up @@ -1069,7 +1089,6 @@ export class ChannelService {
}
}

// truncate active thread as well
private handleChannelTruncate(event: Event) {
const channelIndex = this.channels.findIndex(
(c) => c.cid === event.channel!.cid
Expand Down Expand Up @@ -1168,4 +1187,31 @@ export class ChannelService {
return;
}
}

private updateLatestMessages(event: Event) {
if (
event.message?.user?.id !== this.chatClientService?.chatClient.user?.id
) {
return;
}
const latestMessages =
this.latestMessageDateByUserByChannelsSubject.getValue();
if (!event.message?.created_at) {
return;
}
const channelId = event?.message?.cid;
if (!channelId) {
return;
}
const messageDate = new Date(event.message.created_at);
if (
!latestMessages[channelId] ||
latestMessages[channelId]?.getTime() < messageDate.getTime()
) {
latestMessages[channelId] = messageDate;
this.latestMessageDateByUserByChannelsSubject.next({
...latestMessages,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@
class="rfu-image-previewer-angular-host"
></stream-attachment-preview-list>
<div class="rta str-chat__textarea str-chat-angular__textarea">
<ng-container *ngIf="emojiPickerTemplate">
<ng-container
*ngIf="emojiPickerTemplate && !isCooldownInProgress"
data-testid="emoji-picker"
>
<div
class="
str-chat__input-flat-emojiselect
Expand All @@ -76,8 +79,15 @@
></ng-container>
</div>
</ng-container>
<div
class="str-chat__input-flat-cooldown str-chat-angular__cooldown"
*ngIf="isCooldownInProgress"
data-testid="cooldown-timer"
>
{{ cooldown$ | async }}
</div>
<ng-template
*ngIf="canSendMessages; else notAllowed"
*ngIf="canSendMessages && !isCooldownInProgress; else notAllowed"
streamTextarea
[(value)]="textareaValue"
(valueChange)="typingStart$.next()"
Expand All @@ -93,18 +103,19 @@
<textarea
disabled
rows="1"
[value]="
(mode === 'thread'
? 'You can\'t send thread replies in this channel'
: 'streamChat.You can\'t send messages in this channel'
) | translate
"
[value]="disabledTextareaText | translate"
class="rta__textarea str-chat__textarea__textarea"
data-testid="disabled-textarea"
></textarea>
</ng-template>
</div>
<div
*ngIf="isFileUploadEnabled && isFileUploadAuthorized && canSendMessages"
*ngIf="
isFileUploadEnabled &&
isFileUploadAuthorized &&
canSendMessages &&
!isCooldownInProgress
"
class="str-chat__fileupload-wrapper"
data-testid="file-upload-button"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ComponentFixture,
discardPeriodicTasks,
fakeAsync,
flush,
TestBed,
tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, Subject } from 'rxjs';
Expand All @@ -25,6 +32,7 @@ describe('MessageInputComponent', () => {
let querySendButton: () => HTMLButtonElement | null;
let queryattachmentUploadButton: () => HTMLElement | null;
let queryFileInput: () => HTMLInputElement | null;
let queryCooldownTimer: () => HTMLElement | null;
let mockActiveChannel$: BehaviorSubject<Channel>;
let mockActiveParentMessageId$: BehaviorSubject<string | undefined>;
let sendMessageSpy: jasmine.Spy;
Expand All @@ -45,6 +53,9 @@ describe('MessageInputComponent', () => {
let selectMessageToQuoteSpy: jasmine.Spy;
let typingStartedSpy: jasmine.Spy;
let typingStoppedSpy: jasmine.Spy;
let latestMessageDateByUserByChannels$: BehaviorSubject<{
[key: string]: Date;
}>;

beforeEach(() => {
appSettings$ = new Subject<AppSettings>();
Expand All @@ -70,6 +81,7 @@ describe('MessageInputComponent', () => {
mockMessageToQuote$ = new BehaviorSubject<undefined | StreamMessage>(
undefined
);
latestMessageDateByUserByChannels$ = new BehaviorSubject({});
selectMessageToQuoteSpy = jasmine.createSpy();
TestBed.overrideComponent(MessageInputComponent, {
set: {
Expand Down Expand Up @@ -107,6 +119,7 @@ describe('MessageInputComponent', () => {
selectMessageToQuote: selectMessageToQuoteSpy,
typingStarted: typingStartedSpy,
typingStopped: typingStoppedSpy,
latestMessageDateByUserByChannels$,
},
},
{
Expand All @@ -130,6 +143,8 @@ describe('MessageInputComponent', () => {
nativeElement.querySelector('[data-testid="file-upload-button"]');
queryFileInput = () =>
nativeElement.querySelector('[data-testid="file-input"]');
queryCooldownTimer = () =>
nativeElement.querySelector('[data-testid="cooldown-timer"]');
});

it('should display textarea', () => {
Expand Down Expand Up @@ -803,4 +818,127 @@ describe('MessageInputComponent', () => {

expect(typingStartedSpy).toHaveBeenCalledWith('parentMessage');
});

it(`shouldn't activate cooldown for users without 'slow-mode' restriction`, () => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = [];
channel.data!.cooldown = 3;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid]: new Date(),
});

expect(component.isCooldownInProgress).toBeFalse();
});

it('should activate cooldown timer', fakeAsync(() => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode'];
channel.data!.cooldown = 30;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid]: new Date(),
});
const spy = jasmine.createSpy();
component.cooldown$?.subscribe(spy);
tick(1);

expect(spy).toHaveBeenCalledWith(30);

tick(1000);

expect(spy).toHaveBeenCalledWith(29);

tick(1000);

expect(spy).toHaveBeenCalledWith(28);

spy.calls.reset();
tick(28000);

expect(spy).toHaveBeenCalledWith(0);

discardPeriodicTasks();
}));

it('should disable text input during cooldown period', fakeAsync(() => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
channel.data!.cooldown = 30;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid]: new Date(),
});
fixture.detectChanges();

const textarea = nativeElement.querySelector(
'[data-testid="disabled-textarea"]'
) as HTMLTextAreaElement;

expect(textarea?.disabled).toBeTrue();
expect(textarea?.value).toContain('streamChat.Slow Mode ON');

discardPeriodicTasks();
}));

it('should not display emoji picker and file upload button during cooldown period', fakeAsync(() => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
channel.data!.cooldown = 30;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid]: new Date(),
});
tick(1);
fixture.detectChanges();

expect(queryattachmentUploadButton()).toBeNull();
expect(
nativeElement.querySelector('[data-testid="emoji-picker"]')
).toBeNull();

flush();
discardPeriodicTasks();
}));

it('should display cooldown timer', fakeAsync(() => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
channel.data!.cooldown = 30;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid]: new Date(),
});
fixture.detectChanges();
tick(1);
fixture.detectChanges();

expect(queryCooldownTimer()).not.toBeNull();
expect(queryCooldownTimer()?.innerHTML).toContain(30);

discardPeriodicTasks();
}));

it('should discard cooldown timer after channel is chnaged', () => {
component.isCooldownInProgress = true;
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
channel.data!.cooldown = 30;
channel.cid = 'newchannel';
mockActiveChannel$.next(channel);

expect(component.isCooldownInProgress).toBeFalse();
});

it(`shouldn't start a cooldown if message was sent in another channel`, () => {
const channel = generateMockChannels(1)[0];
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
channel.data!.cooldown = 30;
mockActiveChannel$.next(channel);
latestMessageDateByUserByChannels$.next({
[channel.cid + 'not']: new Date(),
});

expect(component.isCooldownInProgress).toBeFalse();
});
});
Loading