diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index 6f3e25ac..db7f9ce1 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -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 () => { @@ -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 }); + }); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index f2867483..45250759 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -85,12 +85,25 @@ export class ChannelService { * Emits the currently selected parent message. If no message is selected, it emits undefined. */ activeParentMessage$: Observable; + /** + * Emits the currently selected message to quote + */ messageToQuote$: Observable; /** - * 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; + /** + * Emits the list of users that are currently typing in the active thread (current user is not included) + */ usersTypingInThread$: Observable; + /** + * 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 @@ -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; @@ -272,6 +288,8 @@ export class ChannelService { this.usersTypingInChannel$ = this.usersTypingInChannelSubject.asObservable(); this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable(); + this.latestMessageDateByUserByChannels$ = + this.latestMessageDateByUserByChannelsSubject.asObservable(); } /** @@ -393,6 +411,7 @@ export class ChannelService { this.activeParentMessageIdSubject.next(undefined); this.activeThreadMessagesSubject.next([]); this.channelsSubject.next(undefined); + this.latestMessageDateByUserByChannelsSubject.next({}); this.selectMessageToQuote(undefined); } @@ -757,6 +776,7 @@ export class ChannelService { void c?.markRead(); } }); + this.updateLatestMessages(event); }); }) ); @@ -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 @@ -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, + }); + } + } } diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html index 2c15edbe..f0c3493f 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html @@ -61,7 +61,10 @@ class="rfu-image-previewer-angular-host" >
- +
+ {{ cooldown$ | async }} +
diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index cf88db10..05be6e8e 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -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'; @@ -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; let mockActiveParentMessageId$: BehaviorSubject; let sendMessageSpy: jasmine.Spy; @@ -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(); @@ -70,6 +81,7 @@ describe('MessageInputComponent', () => { mockMessageToQuote$ = new BehaviorSubject( undefined ); + latestMessageDateByUserByChannels$ = new BehaviorSubject({}); selectMessageToQuoteSpy = jasmine.createSpy(); TestBed.overrideComponent(MessageInputComponent, { set: { @@ -107,6 +119,7 @@ describe('MessageInputComponent', () => { selectMessageToQuote: selectMessageToQuoteSpy, typingStarted: typingStartedSpy, typingStopped: typingStoppedSpy, + latestMessageDateByUserByChannels$, }, }, { @@ -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', () => { @@ -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(); + }); }); diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts index 4e753dd4..de58c40d 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts @@ -17,8 +17,8 @@ import { ViewChild, } from '@angular/core'; import { ChatClientService } from '../chat-client.service'; -import { Observable, Subject, Subscription } from 'rxjs'; -import { first } from 'rxjs/operators'; +import { combineLatest, Observable, Subject, Subscription, timer } from 'rxjs'; +import { first, map, take, tap } from 'rxjs/operators'; import { AppSettings, Channel, UserResponse } from 'stream-chat'; import { AttachmentService } from '../attachment.service'; import { ChannelService } from '../channel.service'; @@ -107,6 +107,8 @@ export class MessageInputComponent mentionedUsers: UserResponse[] = []; quotedMessage: undefined | StreamMessage; typingStart$ = new Subject(); + cooldown$: Observable | undefined; + isCooldownInProgress = false; @ViewChild('fileInput') private fileInput!: ElementRef; @ViewChild(TextareaDirective, { static: false }) private textareaAnchor!: TextareaDirective; @@ -186,6 +188,39 @@ export class MessageInputComponent () => void this.channelService.typingStarted(this.parentMessageId) ) ); + + this.subscriptions.push( + combineLatest([ + this.channelService.latestMessageDateByUserByChannels$, + this.channelService.activeChannel$, + ]) + .pipe( + map( + ([latestMessages, channel]): [ + Date | undefined, + Channel | undefined + ] => [latestMessages[channel?.cid || ''], channel!] + ) + ) + .subscribe(([latestMessageDate, channel]) => { + const cooldown = + (channel?.data?.cooldown as number) && + latestMessageDate && + Math.round( + (channel?.data?.cooldown as number) - + (new Date().getTime() - latestMessageDate.getTime()) / 1000 + ); + if ( + cooldown && + cooldown > 0 && + (channel?.data?.own_capabilities as string[]).includes('slow-mode') + ) { + this.startCooldown(cooldown); + } else if (this.isCooldownInProgress) { + this.stopCooldown(); + } + }) + ); } ngAfterViewInit(): void { @@ -315,6 +350,17 @@ export class MessageInputComponent : []; } + get disabledTextareaText() { + if (!this.canSendMessages) { + return this.mode === 'thread' + ? "streamChat.You can't send thread replies in this channel" + : "streamChat.You can't send messages in this channel"; + } else if (this.cooldown$) { + return 'streamChat.Slow Mode ON'; + } + return ''; + } + async filesSelected(fileList: FileList | null) { if (!(await this.areAttachemntsValid(fileList))) { return; @@ -446,4 +492,26 @@ export class MessageInputComponent return parentMessageId; } + + private startCooldown(cooldown: number) { + this.isCooldownInProgress = true; + this.cooldown$ = timer(0, 1000).pipe( + take(cooldown + 1), + map((v) => cooldown - v), + tap((v) => { + if (v === 0) { + this.stopCooldown(); + } + }) + ); + } + + private stopCooldown() { + this.cooldown$ = undefined; + this.isCooldownInProgress = false; + // the anchor directive will be recreated because of *ngIf, so we will have to reinit the textarea as well + this.textareaRef = undefined; + // we can only create the textarea after the anchor was recreated, so we will have to wait a change detection cycle with setTimeout + setTimeout(() => this.initTextarea()); + } }