From c9136a568f686322cec09062ea0af9d807864ebd Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 18 Feb 2022 14:24:44 +0100 Subject: [PATCH] feat: Typing indicator #194 --- .../docs/Angular/components/message-input.mdx | 8 ++ .../src/lib/channel.service.spec.ts | 75 +++++++++++++++++ .../src/lib/channel.service.thread.spec.ts | 81 +++++++++++++++++++ .../src/lib/channel.service.ts | 80 ++++++++++++++++++ .../lib/message-input/emoji-input.service.ts | 4 +- .../message-input.component.html | 1 + .../message-input.component.spec.ts | 36 ++++++++- .../message-input/message-input.component.ts | 29 +++++-- .../message-list/message-list.component.html | 26 ++++++ .../message-list.component.spec.ts | 48 ++++++++++- .../message-list/message-list.component.ts | 20 ++++- .../src/lib/mocks/index.ts | 10 +++ 12 files changed, 402 insertions(+), 16 deletions(-) diff --git a/docusaurus/docs/Angular/components/message-input.mdx b/docusaurus/docs/Angular/components/message-input.mdx index 033b42dc..46c2ae7c 100644 --- a/docusaurus/docs/Angular/components/message-input.mdx +++ b/docusaurus/docs/Angular/components/message-input.mdx @@ -173,6 +173,14 @@ You can add an emoji picker by [providing your own emoji picker template](../cod | --------------------------------------------- | | `TemplateRef<{emojiInput$: Subject}>` | +### typingIndicatorTemplate + +You can provide your own typing indicator template instead of the default one. + +| Type | +| ---------------------------------------------------------------------------- | +| `TemplateRef<{ usersTyping$: Observable[]> }>` | + ## Outputs ### messageUpdate 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 701df2fa..6f3e25ac 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -1202,4 +1202,79 @@ describe('ChannelService', () => { expect(spy).toHaveBeenCalledWith(undefined); }); + + it('should notify channel if typing started', async () => { + await init(); + let channel!: Channel; + service.activeChannel$.subscribe((c) => (channel = c!)); + spyOn(channel, 'keystroke'); + await service.typingStarted(); + + expect(channel.keystroke).toHaveBeenCalledWith(undefined); + }); + + it('should notify channel if typing stopped', async () => { + await init(); + let channel!: Channel; + service.activeChannel$.subscribe((c) => (channel = c!)); + spyOn(channel, 'stopTyping'); + await service.typingStopped(); + + expect(channel.stopTyping).toHaveBeenCalledWith(undefined); + }); + + it('should emit users that are currently typing', async () => { + await init(); + const usersTypingInChannelSpy = jasmine.createSpy(); + const usersTypingInThreadSpy = jasmine.createSpy(); + service.usersTypingInChannel$.subscribe(usersTypingInChannelSpy); + service.usersTypingInThread$.subscribe((e) => { + usersTypingInThreadSpy(e); + }); + let channel!: MockChannel; + service.activeChannel$.subscribe((c) => (channel = c as MockChannel)); + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'sara' }, + }); + + expect(usersTypingInChannelSpy).toHaveBeenCalledWith([{ id: 'sara' }]); + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'john' }, + }); + + expect(usersTypingInChannelSpy).toHaveBeenCalledWith([ + { id: 'sara' }, + { id: 'john' }, + ]); + + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.stop', { + type: 'typing.stop', + user: { id: 'sara' }, + }); + + expect(usersTypingInChannelSpy).toHaveBeenCalledWith([{ id: 'john' }]); + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user, + }); + + expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + }); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts index 0401ae22..1590b699 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts @@ -615,4 +615,85 @@ describe('ChannelService - threads', () => { expect(latestMessage.id).toBe('new message'); }); + + it('should notify channel if typing started', async () => { + await init(); + let channel!: Channel; + service.activeChannel$.subscribe((c) => (channel = c!)); + spyOn(channel, 'keystroke'); + await service.typingStarted('parentId'); + + expect(channel.keystroke).toHaveBeenCalledWith('parentId'); + }); + + it('should notify channel if typing stopped', async () => { + await init(); + let channel!: Channel; + service.activeChannel$.subscribe((c) => (channel = c!)); + spyOn(channel, 'stopTyping'); + await service.typingStopped('parentId'); + + expect(channel.stopTyping).toHaveBeenCalledWith('parentId'); + }); + + it('should emit users that are currently typing', async () => { + await init(); + const usersTypingInChannelSpy = jasmine.createSpy(); + const usersTypingInThreadSpy = jasmine.createSpy(); + service.usersTypingInChannel$.subscribe(usersTypingInChannelSpy); + service.usersTypingInThread$.subscribe((e) => { + usersTypingInThreadSpy(e); + }); + let channel!: MockChannel; + service.activeChannel$.subscribe((c) => (channel = c as MockChannel)); + const parentMessage = mockMessage(); + parentMessage.id = 'parent_id'; + await service.setAsActiveParentMessage(parentMessage); + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'sara' }, + parent_id: 'parent_id', + }); + + expect(usersTypingInThreadSpy).toHaveBeenCalledWith([{ id: 'sara' }]); + expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'jack' }, + parent_id: 'parent_id', + }); + + expect(usersTypingInThreadSpy).toHaveBeenCalledWith([ + { id: 'sara' }, + { id: 'jack' }, + ]); + + expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.stop', { + type: 'typing.stop', + user: { id: 'sara' }, + }); + + expect(usersTypingInThreadSpy).toHaveBeenCalledWith([{ id: 'jack' }]); + expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); + + usersTypingInThreadSpy.calls.reset(); + usersTypingInChannelSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'sophie' }, + parent_id: 'different_thread', + }); + + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); + }); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index e32ae44f..b6a5f830 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -89,6 +89,8 @@ export class ChannelService { /** * 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) */ + usersTypingInChannel$: Observable; + usersTypingInThread$: Observable; customNewMessageNotificationHandler?: ( notification: Notification, channelListSetter: (channels: Channel[]) => void @@ -197,6 +199,8 @@ export class ChannelService { private messageToQuoteSubject = new BehaviorSubject< StreamMessage | undefined >(undefined); + private usersTypingInChannelSubject = new BehaviorSubject([]); + private usersTypingInThreadSubject = new BehaviorSubject([]); private channelListSetter = (channels: Channel[]) => { this.channelsSubject.next(channels); @@ -264,6 +268,10 @@ export class ChannelService { .subscribe(() => { void this.setAsActiveParentMessage(undefined); }); + + this.usersTypingInChannel$ = + this.usersTypingInChannelSubject.asObservable(); + this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable(); } /** @@ -789,6 +797,34 @@ export class ChannelService { }); }) ); + this.activeChannelSubscriptions.push( + channel.on('typing.start', (e) => + this.ngZone.run(() => this.handleTypingStartEvent(e)) + ) + ); + this.activeChannelSubscriptions.push( + channel.on('typing.stop', (e) => + this.ngZone.run(() => this.handleTypingStopEvent(e)) + ) + ); + } + + /** + * Call this method if user started typing in the active channel + * @param parentId The id of the parent message, if user is typing in a thread + */ + async typingStarted(parentId?: string) { + const activeChannel = this.activeChannelSubject.getValue(); + await activeChannel?.keystroke(parentId); + } + + /** + * Call this method if user stopped typing in the active channel + * @param parentId The id of the parent message, if user were typing in a thread + */ + async typingStopped(parentId?: string) { + const activeChannel = this.activeChannelSubject.getValue(); + await activeChannel?.stopTyping(parentId); } private messageUpdated(event: Event) { @@ -1088,4 +1124,48 @@ export class ChannelService { }; } } + + private handleTypingStartEvent(event: Event) { + if (event.user?.id === this.chatClientService.chatClient.user?.id) { + return; + } + const isTypingInThread = !!event.parent_id; + if ( + isTypingInThread && + event.parent_id !== this.activeParentMessageIdSubject.getValue() + ) { + return; + } + const subject = isTypingInThread + ? this.usersTypingInThreadSubject + : this.usersTypingInChannelSubject; + const users: UserResponse[] = subject.getValue(); + const user = event.user; + if (user && !users.find((u) => u.id === user.id)) { + users.push(user); + subject.next([...users]); + } + } + + private handleTypingStopEvent(event: Event) { + const usersTypingInChannel = this.usersTypingInChannelSubject.getValue(); + const usersTypingInThread = this.usersTypingInThreadSubject.getValue(); + const user = event.user; + if (user && usersTypingInChannel.find((u) => u.id === user.id)) { + usersTypingInChannel.splice( + usersTypingInChannel.findIndex((u) => u.id === user.id), + 1 + ); + this.usersTypingInChannelSubject.next([...usersTypingInChannel]); + return; + } + if (user && usersTypingInThread.find((u) => u.id === user.id)) { + usersTypingInThread.splice( + usersTypingInThread.findIndex((u) => u.id === user.id), + 1 + ); + this.usersTypingInThreadSubject.next([...usersTypingInThread]); + return; + } + } } diff --git a/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts b/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts index 24d76744..7d055bf4 100644 --- a/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts +++ b/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts @@ -2,14 +2,14 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; /** - * + * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this service, more info can be found in [custom emoji picker guide](../code-examples/emoji-picker.mdx) */ @Injectable({ providedIn: 'root', }) export class EmojiInputService { /** - * + * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this Subject, more info can be found in [custom emoji picker guide](../code-examples/emoji-picker.mdx) */ emojiInput$ = new Subject(); 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 fb951161..2c15edbe 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 @@ -80,6 +80,7 @@ *ngIf="canSendMessages; else notAllowed" streamTextarea [(value)]="textareaValue" + (valueChange)="typingStart$.next()" (send)="messageSent()" [componentRef]="textareaRef" (userMentions)="mentionedUsers = $event" 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 6a16e68e..cf88db10 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 @@ -43,6 +43,8 @@ describe('MessageInputComponent', () => { let getAppSettings: jasmine.Spy; let mockMessageToQuote$: BehaviorSubject; let selectMessageToQuoteSpy: jasmine.Spy; + let typingStartedSpy: jasmine.Spy; + let typingStoppedSpy: jasmine.Spy; beforeEach(() => { appSettings$ = new Subject(); @@ -54,6 +56,8 @@ describe('MessageInputComponent', () => { user = mockCurrentUser(); sendMessageSpy = jasmine.createSpy(); updateMessageSpy = jasmine.createSpy(); + typingStartedSpy = jasmine.createSpy(); + typingStoppedSpy = jasmine.createSpy(); attachmentService = { resetAttachmentUploads: jasmine.createSpy(), attachmentUploadInProgressCounter$: new BehaviorSubject(0), @@ -101,6 +105,8 @@ describe('MessageInputComponent', () => { activeParentMessageId$: mockActiveParentMessageId$, messageToQuote$: mockMessageToQuote$, selectMessageToQuote: selectMessageToQuoteSpy, + typingStarted: typingStartedSpy, + typingStopped: typingStoppedSpy, }, }, { @@ -202,13 +208,13 @@ describe('MessageInputComponent', () => { expect(component.messageSent).toHaveBeenCalledWith(); }); - it('should send message', () => { + it('should send message', async () => { const message = 'This is my message'; component.textareaValue = message; attachmentService.mapToAttachments.and.returnValue([]); const mentionedUsers = [{ id: 'john', name: 'John' }]; component.mentionedUsers = mentionedUsers; - void component.messageSent(); + await component.messageSent(); fixture.detectChanges(); expect(sendMessageSpy).toHaveBeenCalledWith( @@ -218,6 +224,8 @@ describe('MessageInputComponent', () => { undefined, undefined ); + + expect(typingStoppedSpy).toHaveBeenCalledWith(undefined); }); it('reset textarea after message is sent', () => { @@ -621,7 +629,7 @@ describe('MessageInputComponent', () => { expect(getAppSettings).toHaveBeenCalledWith(); }); - it('should send parent message id if in thread mode', () => { + it('should send parent message id if in thread mode', async () => { component.mode = 'thread'; mockActiveParentMessageId$.next('parent message'); const message = 'This is my message'; @@ -632,7 +640,7 @@ describe('MessageInputComponent', () => { quotedMessage.id = 'message-to-quote'; mockMessageToQuote$.next(quotedMessage); component.mentionedUsers = []; - void component.messageSent(); + await component.messageSent(); fixture.detectChanges(); expect(sendMessageSpy).toHaveBeenCalledWith( @@ -642,6 +650,8 @@ describe('MessageInputComponent', () => { 'parent message', 'message-to-quote' ); + + expect(typingStoppedSpy).toHaveBeenCalledWith('parent message'); }); it(`shouldn't allow message send if in thread mode and "send-reply" capability is missing`, () => { @@ -775,4 +785,22 @@ describe('MessageInputComponent', () => { expect(component.quotedMessage).toBeUndefined(); }); + + it('should send typing start events - main mode', () => { + const textarea = queryTextarea(); + textarea?.valueChange.next('H'); + textarea?.valueChange.next('i'); + + expect(typingStartedSpy).toHaveBeenCalledTimes(2); + }); + + it('should send typing start events - thread mode', () => { + component.mode = 'thread'; + mockActiveParentMessageId$.next('parentMessage'); + fixture.detectChanges(); + const textarea = queryTextarea(); + textarea?.valueChange.next('H'); + + expect(typingStartedSpy).toHaveBeenCalledWith('parentMessage'); + }); }); 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 f36e0eb1..346d8c9d 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,7 +17,7 @@ import { ViewChild, } from '@angular/core'; import { ChatClientService } from '../chat-client.service'; -import { Observable, Subscription } from 'rxjs'; +import { Observable, Subject, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import { AppSettings, Channel, UserResponse } from 'stream-chat'; import { AttachmentService } from '../attachment.service'; @@ -71,6 +71,7 @@ export class MessageInputComponent textareaRef: ComponentRef | undefined; mentionedUsers: UserResponse[] = []; quotedMessage: undefined | StreamMessage; + typingStart$ = new Subject(); @ViewChild('fileInput') private fileInput!: ElementRef; @ViewChild(TextareaDirective, { static: false }) private textareaAnchor!: TextareaDirective; @@ -144,6 +145,12 @@ export class MessageInputComponent this.commandAutocompleteItemTemplate = this.configService.commandAutocompleteItemTemplate; this.emojiPickerTemplate = this.configService.emojiPickerTemplate; + + this.subscriptions.push( + this.typingStart$.subscribe( + () => void this.channelService.typingStarted(this.parentMessageId) + ) + ); } ngAfterViewInit(): void { @@ -225,12 +232,6 @@ export class MessageInputComponent if (!this.isUpdate) { this.textareaValue = ''; } - let parentMessageId: string | undefined = undefined; - if (this.mode === 'thread') { - this.channelService.activeParentMessageId$ - .pipe(first()) - .subscribe((id) => (parentMessageId = id)); - } try { await (this.isUpdate ? this.channelService.updateMessage({ @@ -242,7 +243,7 @@ export class MessageInputComponent text, attachments, this.mentionedUsers, - parentMessageId, + this.parentMessageId, this.quotedMessage?.id )); this.messageUpdate.emit(); @@ -256,6 +257,7 @@ export class MessageInputComponent ); } } + void this.channelService.typingStopped(this.parentMessageId); if (this.quotedMessage) { this.deselectMessageToQuote(); } @@ -398,4 +400,15 @@ export class MessageInputComponent this.initTextarea(); } } + + private get parentMessageId() { + let parentMessageId: string | undefined = undefined; + if (this.mode === 'thread') { + this.channelService.activeParentMessageId$ + .pipe(first()) + .subscribe((id) => (parentMessageId = id)); + } + + return parentMessageId; + } } diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.html b/projects/stream-chat-angular/src/lib/message-list/message-list.component.html index dc8c7608..19779288 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.html +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.html @@ -38,6 +38,32 @@ > + + + + +
+ +
+ + + +
+
+
diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts b/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts index cd467898..80bf7ec7 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts @@ -8,6 +8,7 @@ import { import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { Channel } from 'stream-chat'; +import { AvatarComponent } from '../avatar/avatar.component'; import { ChannelService } from '../channel.service'; import { ChatClientService } from '../chat-client.service'; import { MessageComponent } from '../message/message.component'; @@ -31,12 +32,14 @@ describe('MessageListComponent', () => { let queryMessages: () => HTMLElement[]; let queryScrollToBottomButton: () => HTMLElement | null; let queryParentMessage: () => MessageComponent | undefined; + let queryTypingIndicator: () => HTMLElement | null; + let queryTypingUserAvatars: () => AvatarComponent[]; beforeEach(fakeAsync(() => { channelServiceMock = mockChannelService(); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [MessageComponent, MessageListComponent], + declarations: [MessageComponent, MessageListComponent, AvatarComponent], providers: [ { provide: ChannelService, useValue: channelServiceMock }, { @@ -67,6 +70,13 @@ describe('MessageListComponent', () => { .query(By.css('[data-testid="parent-message"]')) ?.query(By.directive(MessageComponent)) .componentInstance as MessageComponent; + queryTypingIndicator = () => + nativeElement.querySelector('[data-testid="typing-indicator"]'); + queryTypingUserAvatars = () => + fixture.debugElement + .query(By.css('[data-testid="typing-indicator"]')) + ?.queryAll(By.directive(AvatarComponent)) + .map((e) => e.componentInstance as AvatarComponent); fixture.detectChanges(); const scrollContainer = queryScrollContainer()!; scrollContainer.style.maxHeight = '300px'; @@ -428,6 +438,23 @@ describe('MessageListComponent', () => { ]); }); + it('should display typing indicator', () => { + expect(queryTypingIndicator()).toBeNull(); + + channelServiceMock.usersTypingInChannel$.next([ + { id: 'jack' }, + { id: 'john', name: 'John' }, + ]); + fixture.detectChanges(); + + expect(queryTypingIndicator()).not.toBeNull(); + const avatars = queryTypingUserAvatars(); + + expect(avatars.length).toBe(2); + expect(avatars[0].name).toBe('jack'); + expect(avatars[1].name).toBe('John'); + }); + describe('thread mode', () => { beforeEach(() => { component.mode = 'thread'; @@ -520,5 +547,24 @@ describe('MessageListComponent', () => { expect(component.unreadMessageCount).toBe(4); expect(component.isUserScrolledUp).toBeTrue(); }); + + it('should display typing indicator in thread', () => { + channelServiceMock.usersTypingInChannel$.next([{ id: 'sara' }]); + + expect(queryTypingIndicator()).toBeNull(); + + channelServiceMock.usersTypingInThread$.next([ + { id: 'jack' }, + { id: 'john', name: 'John' }, + ]); + fixture.detectChanges(); + + expect(queryTypingIndicator()).not.toBeNull(); + const avatars = queryTypingUserAvatars(); + + expect(avatars.length).toBe(2); + expect(avatars[0].name).toBe('jack'); + expect(avatars[1].name).toBe('John'); + }); }); }); diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts index 2e92fee6..9f242908 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts @@ -14,10 +14,11 @@ import { import { ChannelService } from '../channel.service'; import { Observable, Subscription } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { StreamMessage } from '../types'; +import { DefaultUserType, StreamMessage } from '../types'; import { ChatClientService } from '../chat-client.service'; import { getGroupStyles, GroupStyle } from './group-styles'; import { ImageLoadService } from './image-load.service'; +import { UserResponse } from 'stream-chat'; @Component({ selector: 'stream-message-list', @@ -30,6 +31,9 @@ export class MessageListComponent @Input() messageTemplate: TemplateRef | undefined; @Input() messageInputTemplate: TemplateRef | undefined; @Input() mentionTemplate: TemplateRef | undefined; + @Input() typingIndicatorTemplate: + | TemplateRef<{ usersTyping$: Observable[]> }> + | undefined; /** * @deprecated https://getstream.io/chat/docs/sdk/angular/components/message_list/#caution-arereactionsenabled-deprecated */ @@ -67,6 +71,8 @@ export class MessageListComponent private readonly isUserScrolledUpThreshold = 300; private subscriptions: Subscription[] = []; private prevScrollTop: number | undefined; + private usersTypingInChannel$!: Observable[]>; + private usersTypingInThread$!: Observable[]>; constructor( private channelService: ChannelService, @@ -138,6 +144,8 @@ export class MessageListComponent this.parentMessage = message; }) ); + this.usersTypingInChannel$ = this.channelService.usersTypingInChannel$; + this.usersTypingInThread$ = this.channelService.usersTypingInThread$; } ngOnInit(): void { @@ -182,10 +190,20 @@ export class MessageListComponent this.subscriptions.forEach((s) => s.unsubscribe()); } + get usersTyping$() { + return this.mode === 'thread' + ? this.usersTypingInThread$ + : this.usersTypingInChannel$; + } + trackByMessageId(index: number, item: StreamMessage) { return item.id; } + trackByUserId(index: number, user: UserResponse) { + return user.id; + } + scrollToBottom(): void { this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight; diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index 6d34370b..64635aad 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -67,6 +67,7 @@ export const generateMockChannels = (length = 25) => { 'read-events', 'send-links', 'send-message', + 'typing-events', ], }, on: (arg1: EventTypes | Function, handler: () => {}) => { @@ -88,6 +89,8 @@ export const generateMockChannels = (length = 25) => { countUnread: () => {}, markRead: () => {}, getReplies: () => {}, + keystroke: () => {}, + stopTyping: () => {}, handleEvent: (name: EventTypes, payload?: any) => { if (eventHandlers[name as string]) { eventHandlers[name as string](payload as StreamMessage); @@ -160,6 +163,8 @@ export type MockChannelService = { activeThreadMessages$: BehaviorSubject; activeParentMessageId$: BehaviorSubject; activeParentMessage$: BehaviorSubject; + usersTypingInChannel$: BehaviorSubject; + usersTypingInThread$: BehaviorSubject; loadMoreMessages: () => void; loadMoreChannels: () => void; setAsActiveChannel: (c: Channel) => void; @@ -178,6 +183,8 @@ export const mockChannelService = (): MockChannelService => { const activeParentMessage$ = new BehaviorSubject( undefined ); + const usersTypingInChannel$ = new BehaviorSubject([]); + const usersTypingInThread$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject({ id: 'channelid', data: { @@ -187,6 +194,7 @@ export const mockChannelService = (): MockChannelService => { 'send-reaction', 'update-any-message', 'delete-any-message', + 'typing-events', ], }, state: { @@ -254,6 +262,8 @@ export const mockChannelService = (): MockChannelService => { activeParentMessage$, loadMoreThreadReplies, setAsActiveParentMessage, + usersTypingInChannel$, + usersTypingInThread$, }; };