Skip to content

Commit 2b3790a

Browse files
committed
feat: Disable message sendind during slow mode #205
1 parent 3880056 commit 2b3790a

File tree

5 files changed

+300
-14
lines changed

5 files changed

+300
-14
lines changed

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,20 @@ describe('ChannelService', () => {
132132
service.channels$.subscribe(channelsSpy);
133133
const messageToQuoteSpy = jasmine.createSpy();
134134
service.messageToQuote$.subscribe(messageToQuoteSpy);
135+
const latestMessagesSpy = jasmine.createSpy();
136+
service.latestMessageDateByUserByChannels$.subscribe(latestMessagesSpy);
135137
messagesSpy.calls.reset();
136138
activeChannelSpy.calls.reset();
137139
channelsSpy.calls.reset();
138140
messageToQuoteSpy.calls.reset();
141+
latestMessagesSpy.calls.reset();
139142
service.reset();
140143

141144
expect(messagesSpy).toHaveBeenCalledWith([]);
142145
expect(channelsSpy).toHaveBeenCalledWith(undefined);
143146
expect(activeChannelSpy).toHaveBeenCalledWith(undefined);
144147
expect(messageToQuoteSpy).toHaveBeenCalledWith(undefined);
148+
expect(latestMessagesSpy).toHaveBeenCalledWith({});
145149
});
146150

147151
it('should tell if user #hasMoreChannels$', async () => {
@@ -1277,4 +1281,23 @@ describe('ChannelService', () => {
12771281
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
12781282
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
12791283
});
1284+
1285+
it('should emit the date of latest messages sent by the user by channels', async () => {
1286+
await init();
1287+
let activeChannel!: Channel;
1288+
service.activeChannel$
1289+
.pipe(first())
1290+
.subscribe((c) => (activeChannel = c as Channel));
1291+
const newMessage = mockMessage();
1292+
newMessage.cid = 'channel1';
1293+
newMessage.created_at = new Date();
1294+
newMessage.user_id = user.id;
1295+
const spy = jasmine.createSpy();
1296+
service.latestMessageDateByUserByChannels$.subscribe(spy);
1297+
(activeChannel as MockChannel).handleEvent('message.new', {
1298+
message: newMessage,
1299+
});
1300+
1301+
expect(spy).toHaveBeenCalledWith({ channel1: newMessage.created_at });
1302+
});
12801303
});

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,25 @@ export class ChannelService {
8585
* Emits the currently selected parent message. If no message is selected, it emits undefined.
8686
*/
8787
activeParentMessage$: Observable<StreamMessage | undefined>;
88+
/**
89+
* Emits the currently selected message to quote
90+
*/
8891
messageToQuote$: Observable<StreamMessage | undefined>;
8992
/**
90-
* 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)
93+
* Emits the list of users that are currently typing in the channel (current user is not included)
9194
*/
9295
usersTypingInChannel$: Observable<UserResponse[]>;
96+
/**
97+
* Emits the list of users that are currently typing in the active thread (current user is not included)
98+
*/
9399
usersTypingInThread$: Observable<UserResponse[]>;
100+
/**
101+
* 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)
102+
*/
103+
latestMessageDateByUserByChannels$: Observable<{ [key: string]: Date }>;
104+
/**
105+
* 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)
106+
*/
94107
customNewMessageNotificationHandler?: (
95108
notification: Notification,
96109
channelListSetter: (channels: Channel[]) => void
@@ -192,6 +205,9 @@ export class ChannelService {
192205
private activeThreadMessagesSubject = new BehaviorSubject<
193206
(StreamMessage | MessageResponse | FormatMessageResponse)[]
194207
>([]);
208+
private latestMessageDateByUserByChannelsSubject = new BehaviorSubject<{
209+
[key: string]: Date;
210+
}>({});
195211
private filters: ChannelFilters | undefined;
196212
private sort: ChannelSort | undefined;
197213
private options: ChannelOptions | undefined;
@@ -272,6 +288,8 @@ export class ChannelService {
272288
this.usersTypingInChannel$ =
273289
this.usersTypingInChannelSubject.asObservable();
274290
this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable();
291+
this.latestMessageDateByUserByChannels$ =
292+
this.latestMessageDateByUserByChannelsSubject.asObservable();
275293
}
276294

277295
/**
@@ -393,6 +411,7 @@ export class ChannelService {
393411
this.activeParentMessageIdSubject.next(undefined);
394412
this.activeThreadMessagesSubject.next([]);
395413
this.channelsSubject.next(undefined);
414+
this.latestMessageDateByUserByChannelsSubject.next({});
396415
this.selectMessageToQuote(undefined);
397416
}
398417

@@ -757,6 +776,7 @@ export class ChannelService {
757776
void c?.markRead();
758777
}
759778
});
779+
this.updateLatestMessages(event);
760780
});
761781
})
762782
);
@@ -1069,7 +1089,6 @@ export class ChannelService {
10691089
}
10701090
}
10711091

1072-
// truncate active thread as well
10731092
private handleChannelTruncate(event: Event) {
10741093
const channelIndex = this.channels.findIndex(
10751094
(c) => c.cid === event.channel!.cid
@@ -1168,4 +1187,31 @@ export class ChannelService {
11681187
return;
11691188
}
11701189
}
1190+
1191+
private updateLatestMessages(event: Event) {
1192+
if (
1193+
event.message?.user?.id !== this.chatClientService?.chatClient.user?.id
1194+
) {
1195+
return;
1196+
}
1197+
const latestMessages =
1198+
this.latestMessageDateByUserByChannelsSubject.getValue();
1199+
if (!event.message?.created_at) {
1200+
return;
1201+
}
1202+
const channelId = event?.message?.cid;
1203+
if (!channelId) {
1204+
return;
1205+
}
1206+
const messageDate = new Date(event.message.created_at);
1207+
if (
1208+
!latestMessages[channelId] ||
1209+
latestMessages[channelId]?.getTime() < messageDate.getTime()
1210+
) {
1211+
latestMessages[channelId] = messageDate;
1212+
this.latestMessageDateByUserByChannelsSubject.next({
1213+
...latestMessages,
1214+
});
1215+
}
1216+
}
11711217
}

projects/stream-chat-angular/src/lib/message-input/message-input.component.html

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@
6161
class="rfu-image-previewer-angular-host"
6262
></stream-attachment-preview-list>
6363
<div class="rta str-chat__textarea str-chat-angular__textarea">
64-
<ng-container *ngIf="emojiPickerTemplate">
64+
<ng-container
65+
*ngIf="emojiPickerTemplate && !isCooldownInProgress"
66+
data-testid="emoji-picker"
67+
>
6568
<div
6669
class="
6770
str-chat__input-flat-emojiselect
@@ -76,8 +79,15 @@
7679
></ng-container>
7780
</div>
7881
</ng-container>
82+
<div
83+
class="str-chat__input-flat-cooldown str-chat-angular__cooldown"
84+
*ngIf="isCooldownInProgress"
85+
data-testid="cooldown-timer"
86+
>
87+
{{ cooldown$ | async }}
88+
</div>
7989
<ng-template
80-
*ngIf="canSendMessages; else notAllowed"
90+
*ngIf="canSendMessages && !isCooldownInProgress; else notAllowed"
8191
streamTextarea
8292
[(value)]="textareaValue"
8393
(valueChange)="typingStart$.next()"
@@ -93,18 +103,19 @@
93103
<textarea
94104
disabled
95105
rows="1"
96-
[value]="
97-
(mode === 'thread'
98-
? 'You can\'t send thread replies in this channel'
99-
: 'streamChat.You can\'t send messages in this channel'
100-
) | translate
101-
"
106+
[value]="disabledTextareaText | translate"
102107
class="rta__textarea str-chat__textarea__textarea"
108+
data-testid="disabled-textarea"
103109
></textarea>
104110
</ng-template>
105111
</div>
106112
<div
107-
*ngIf="isFileUploadEnabled && isFileUploadAuthorized && canSendMessages"
113+
*ngIf="
114+
isFileUploadEnabled &&
115+
isFileUploadAuthorized &&
116+
canSendMessages &&
117+
!isCooldownInProgress
118+
"
108119
class="str-chat__fileupload-wrapper"
109120
data-testid="file-upload-button"
110121
>

projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core';
2-
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import {
3+
ComponentFixture,
4+
discardPeriodicTasks,
5+
fakeAsync,
6+
flush,
7+
TestBed,
8+
tick,
9+
} from '@angular/core/testing';
310
import { By } from '@angular/platform-browser';
411
import { TranslateModule } from '@ngx-translate/core';
512
import { BehaviorSubject, Subject } from 'rxjs';
@@ -25,6 +32,7 @@ describe('MessageInputComponent', () => {
2532
let querySendButton: () => HTMLButtonElement | null;
2633
let queryattachmentUploadButton: () => HTMLElement | null;
2734
let queryFileInput: () => HTMLInputElement | null;
35+
let queryCooldownTimer: () => HTMLElement | null;
2836
let mockActiveChannel$: BehaviorSubject<Channel>;
2937
let mockActiveParentMessageId$: BehaviorSubject<string | undefined>;
3038
let sendMessageSpy: jasmine.Spy;
@@ -45,6 +53,9 @@ describe('MessageInputComponent', () => {
4553
let selectMessageToQuoteSpy: jasmine.Spy;
4654
let typingStartedSpy: jasmine.Spy;
4755
let typingStoppedSpy: jasmine.Spy;
56+
let latestMessageDateByUserByChannels$: BehaviorSubject<{
57+
[key: string]: Date;
58+
}>;
4859

4960
beforeEach(() => {
5061
appSettings$ = new Subject<AppSettings>();
@@ -70,6 +81,7 @@ describe('MessageInputComponent', () => {
7081
mockMessageToQuote$ = new BehaviorSubject<undefined | StreamMessage>(
7182
undefined
7283
);
84+
latestMessageDateByUserByChannels$ = new BehaviorSubject({});
7385
selectMessageToQuoteSpy = jasmine.createSpy();
7486
TestBed.overrideComponent(MessageInputComponent, {
7587
set: {
@@ -107,6 +119,7 @@ describe('MessageInputComponent', () => {
107119
selectMessageToQuote: selectMessageToQuoteSpy,
108120
typingStarted: typingStartedSpy,
109121
typingStopped: typingStoppedSpy,
122+
latestMessageDateByUserByChannels$,
110123
},
111124
},
112125
{
@@ -130,6 +143,8 @@ describe('MessageInputComponent', () => {
130143
nativeElement.querySelector('[data-testid="file-upload-button"]');
131144
queryFileInput = () =>
132145
nativeElement.querySelector('[data-testid="file-input"]');
146+
queryCooldownTimer = () =>
147+
nativeElement.querySelector('[data-testid="cooldown-timer"]');
133148
});
134149

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

804819
expect(typingStartedSpy).toHaveBeenCalledWith('parentMessage');
805820
});
821+
822+
it(`shouldn't activate cooldown for users without 'slow-mode' restriction`, () => {
823+
const channel = generateMockChannels(1)[0];
824+
channel.data!.own_capabilities = [];
825+
channel.data!.cooldown = 3;
826+
mockActiveChannel$.next(channel);
827+
latestMessageDateByUserByChannels$.next({
828+
[channel.cid]: new Date(),
829+
});
830+
831+
expect(component.isCooldownInProgress).toBeFalse();
832+
});
833+
834+
it('should activate cooldown timer', fakeAsync(() => {
835+
const channel = generateMockChannels(1)[0];
836+
channel.data!.own_capabilities = ['slow-mode'];
837+
channel.data!.cooldown = 30;
838+
mockActiveChannel$.next(channel);
839+
latestMessageDateByUserByChannels$.next({
840+
[channel.cid]: new Date(),
841+
});
842+
const spy = jasmine.createSpy();
843+
component.cooldown$?.subscribe(spy);
844+
tick(1);
845+
846+
expect(spy).toHaveBeenCalledWith(30);
847+
848+
tick(1000);
849+
850+
expect(spy).toHaveBeenCalledWith(29);
851+
852+
tick(1000);
853+
854+
expect(spy).toHaveBeenCalledWith(28);
855+
856+
spy.calls.reset();
857+
tick(28000);
858+
859+
expect(spy).toHaveBeenCalledWith(0);
860+
861+
discardPeriodicTasks();
862+
}));
863+
864+
it('should disable text input during cooldown period', fakeAsync(() => {
865+
const channel = generateMockChannels(1)[0];
866+
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
867+
channel.data!.cooldown = 30;
868+
mockActiveChannel$.next(channel);
869+
latestMessageDateByUserByChannels$.next({
870+
[channel.cid]: new Date(),
871+
});
872+
fixture.detectChanges();
873+
874+
const textarea = nativeElement.querySelector(
875+
'[data-testid="disabled-textarea"]'
876+
) as HTMLTextAreaElement;
877+
878+
expect(textarea?.disabled).toBeTrue();
879+
expect(textarea?.value).toContain('streamChat.Slow Mode ON');
880+
881+
discardPeriodicTasks();
882+
}));
883+
884+
it('should not display emoji picker and file upload button during cooldown period', fakeAsync(() => {
885+
const channel = generateMockChannels(1)[0];
886+
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
887+
channel.data!.cooldown = 30;
888+
mockActiveChannel$.next(channel);
889+
latestMessageDateByUserByChannels$.next({
890+
[channel.cid]: new Date(),
891+
});
892+
tick(1);
893+
fixture.detectChanges();
894+
895+
expect(queryattachmentUploadButton()).toBeNull();
896+
expect(
897+
nativeElement.querySelector('[data-testid="emoji-picker"]')
898+
).toBeNull();
899+
900+
flush();
901+
discardPeriodicTasks();
902+
}));
903+
904+
it('should display cooldown timer', fakeAsync(() => {
905+
const channel = generateMockChannels(1)[0];
906+
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
907+
channel.data!.cooldown = 30;
908+
mockActiveChannel$.next(channel);
909+
latestMessageDateByUserByChannels$.next({
910+
[channel.cid]: new Date(),
911+
});
912+
fixture.detectChanges();
913+
tick(1);
914+
fixture.detectChanges();
915+
916+
expect(queryCooldownTimer()).not.toBeNull();
917+
expect(queryCooldownTimer()?.innerHTML).toContain(30);
918+
919+
discardPeriodicTasks();
920+
}));
921+
922+
it('should discard cooldown timer after channel is chnaged', () => {
923+
component.isCooldownInProgress = true;
924+
const channel = generateMockChannels(1)[0];
925+
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
926+
channel.data!.cooldown = 30;
927+
channel.cid = 'newchannel';
928+
mockActiveChannel$.next(channel);
929+
930+
expect(component.isCooldownInProgress).toBeFalse();
931+
});
932+
933+
it(`shouldn't start a cooldown if message was sent in another channel`, () => {
934+
const channel = generateMockChannels(1)[0];
935+
channel.data!.own_capabilities = ['slow-mode', 'send-message'];
936+
channel.data!.cooldown = 30;
937+
mockActiveChannel$.next(channel);
938+
latestMessageDateByUserByChannels$.next({
939+
[channel.cid + 'not']: new Date(),
940+
});
941+
942+
expect(component.isCooldownInProgress).toBeFalse();
943+
});
806944
});

0 commit comments

Comments
 (0)