Skip to content

Commit f3e25bd

Browse files
authored
Merge pull request #202 from GetStream/typing-indicator
feat: Typing indicator #194
2 parents 24bf0c6 + c9136a5 commit f3e25bd

File tree

12 files changed

+402
-16
lines changed

12 files changed

+402
-16
lines changed

docusaurus/docs/Angular/components/message-input.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ You can add an emoji picker by [providing your own emoji picker template](../cod
173173
| --------------------------------------------- |
174174
| `TemplateRef<{emojiInput$: Subject<string>}>` |
175175

176+
### typingIndicatorTemplate
177+
178+
You can provide your own typing indicator template instead of the default one.
179+
180+
| Type |
181+
| ---------------------------------------------------------------------------- |
182+
| `TemplateRef<{ usersTyping$: Observable<UserResponse<DefaultUserType>[]> }>` |
183+
176184
## Outputs
177185

178186
### messageUpdate

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,4 +1202,79 @@ describe('ChannelService', () => {
12021202

12031203
expect(spy).toHaveBeenCalledWith(undefined);
12041204
});
1205+
1206+
it('should notify channel if typing started', async () => {
1207+
await init();
1208+
let channel!: Channel;
1209+
service.activeChannel$.subscribe((c) => (channel = c!));
1210+
spyOn(channel, 'keystroke');
1211+
await service.typingStarted();
1212+
1213+
expect(channel.keystroke).toHaveBeenCalledWith(undefined);
1214+
});
1215+
1216+
it('should notify channel if typing stopped', async () => {
1217+
await init();
1218+
let channel!: Channel;
1219+
service.activeChannel$.subscribe((c) => (channel = c!));
1220+
spyOn(channel, 'stopTyping');
1221+
await service.typingStopped();
1222+
1223+
expect(channel.stopTyping).toHaveBeenCalledWith(undefined);
1224+
});
1225+
1226+
it('should emit users that are currently typing', async () => {
1227+
await init();
1228+
const usersTypingInChannelSpy = jasmine.createSpy();
1229+
const usersTypingInThreadSpy = jasmine.createSpy();
1230+
service.usersTypingInChannel$.subscribe(usersTypingInChannelSpy);
1231+
service.usersTypingInThread$.subscribe((e) => {
1232+
usersTypingInThreadSpy(e);
1233+
});
1234+
let channel!: MockChannel;
1235+
service.activeChannel$.subscribe((c) => (channel = c as MockChannel));
1236+
usersTypingInThreadSpy.calls.reset();
1237+
usersTypingInChannelSpy.calls.reset();
1238+
channel.handleEvent('typing.start', {
1239+
type: 'typing.start',
1240+
user: { id: 'sara' },
1241+
});
1242+
1243+
expect(usersTypingInChannelSpy).toHaveBeenCalledWith([{ id: 'sara' }]);
1244+
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
1245+
1246+
usersTypingInThreadSpy.calls.reset();
1247+
usersTypingInChannelSpy.calls.reset();
1248+
channel.handleEvent('typing.start', {
1249+
type: 'typing.start',
1250+
user: { id: 'john' },
1251+
});
1252+
1253+
expect(usersTypingInChannelSpy).toHaveBeenCalledWith([
1254+
{ id: 'sara' },
1255+
{ id: 'john' },
1256+
]);
1257+
1258+
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
1259+
1260+
usersTypingInThreadSpy.calls.reset();
1261+
usersTypingInChannelSpy.calls.reset();
1262+
channel.handleEvent('typing.stop', {
1263+
type: 'typing.stop',
1264+
user: { id: 'sara' },
1265+
});
1266+
1267+
expect(usersTypingInChannelSpy).toHaveBeenCalledWith([{ id: 'john' }]);
1268+
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
1269+
1270+
usersTypingInThreadSpy.calls.reset();
1271+
usersTypingInChannelSpy.calls.reset();
1272+
channel.handleEvent('typing.start', {
1273+
type: 'typing.start',
1274+
user,
1275+
});
1276+
1277+
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
1278+
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
1279+
});
12051280
});

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,4 +615,85 @@ describe('ChannelService - threads', () => {
615615

616616
expect(latestMessage.id).toBe('new message');
617617
});
618+
619+
it('should notify channel if typing started', async () => {
620+
await init();
621+
let channel!: Channel;
622+
service.activeChannel$.subscribe((c) => (channel = c!));
623+
spyOn(channel, 'keystroke');
624+
await service.typingStarted('parentId');
625+
626+
expect(channel.keystroke).toHaveBeenCalledWith('parentId');
627+
});
628+
629+
it('should notify channel if typing stopped', async () => {
630+
await init();
631+
let channel!: Channel;
632+
service.activeChannel$.subscribe((c) => (channel = c!));
633+
spyOn(channel, 'stopTyping');
634+
await service.typingStopped('parentId');
635+
636+
expect(channel.stopTyping).toHaveBeenCalledWith('parentId');
637+
});
638+
639+
it('should emit users that are currently typing', async () => {
640+
await init();
641+
const usersTypingInChannelSpy = jasmine.createSpy();
642+
const usersTypingInThreadSpy = jasmine.createSpy();
643+
service.usersTypingInChannel$.subscribe(usersTypingInChannelSpy);
644+
service.usersTypingInThread$.subscribe((e) => {
645+
usersTypingInThreadSpy(e);
646+
});
647+
let channel!: MockChannel;
648+
service.activeChannel$.subscribe((c) => (channel = c as MockChannel));
649+
const parentMessage = mockMessage();
650+
parentMessage.id = 'parent_id';
651+
await service.setAsActiveParentMessage(parentMessage);
652+
usersTypingInThreadSpy.calls.reset();
653+
usersTypingInChannelSpy.calls.reset();
654+
channel.handleEvent('typing.start', {
655+
type: 'typing.start',
656+
user: { id: 'sara' },
657+
parent_id: 'parent_id',
658+
});
659+
660+
expect(usersTypingInThreadSpy).toHaveBeenCalledWith([{ id: 'sara' }]);
661+
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
662+
663+
usersTypingInThreadSpy.calls.reset();
664+
usersTypingInChannelSpy.calls.reset();
665+
channel.handleEvent('typing.start', {
666+
type: 'typing.start',
667+
user: { id: 'jack' },
668+
parent_id: 'parent_id',
669+
});
670+
671+
expect(usersTypingInThreadSpy).toHaveBeenCalledWith([
672+
{ id: 'sara' },
673+
{ id: 'jack' },
674+
]);
675+
676+
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
677+
678+
usersTypingInThreadSpy.calls.reset();
679+
usersTypingInChannelSpy.calls.reset();
680+
channel.handleEvent('typing.stop', {
681+
type: 'typing.stop',
682+
user: { id: 'sara' },
683+
});
684+
685+
expect(usersTypingInThreadSpy).toHaveBeenCalledWith([{ id: 'jack' }]);
686+
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
687+
688+
usersTypingInThreadSpy.calls.reset();
689+
usersTypingInChannelSpy.calls.reset();
690+
channel.handleEvent('typing.start', {
691+
type: 'typing.start',
692+
user: { id: 'sophie' },
693+
parent_id: 'different_thread',
694+
});
695+
696+
expect(usersTypingInThreadSpy).not.toHaveBeenCalled();
697+
expect(usersTypingInChannelSpy).not.toHaveBeenCalled();
698+
});
618699
});

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export class ChannelService {
8989
/**
9090
* 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)
9191
*/
92+
usersTypingInChannel$: Observable<UserResponse[]>;
93+
usersTypingInThread$: Observable<UserResponse[]>;
9294
customNewMessageNotificationHandler?: (
9395
notification: Notification,
9496
channelListSetter: (channels: Channel[]) => void
@@ -197,6 +199,8 @@ export class ChannelService {
197199
private messageToQuoteSubject = new BehaviorSubject<
198200
StreamMessage | undefined
199201
>(undefined);
202+
private usersTypingInChannelSubject = new BehaviorSubject<UserResponse[]>([]);
203+
private usersTypingInThreadSubject = new BehaviorSubject<UserResponse[]>([]);
200204

201205
private channelListSetter = (channels: Channel[]) => {
202206
this.channelsSubject.next(channels);
@@ -264,6 +268,10 @@ export class ChannelService {
264268
.subscribe(() => {
265269
void this.setAsActiveParentMessage(undefined);
266270
});
271+
272+
this.usersTypingInChannel$ =
273+
this.usersTypingInChannelSubject.asObservable();
274+
this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable();
267275
}
268276

269277
/**
@@ -789,6 +797,34 @@ export class ChannelService {
789797
});
790798
})
791799
);
800+
this.activeChannelSubscriptions.push(
801+
channel.on('typing.start', (e) =>
802+
this.ngZone.run(() => this.handleTypingStartEvent(e))
803+
)
804+
);
805+
this.activeChannelSubscriptions.push(
806+
channel.on('typing.stop', (e) =>
807+
this.ngZone.run(() => this.handleTypingStopEvent(e))
808+
)
809+
);
810+
}
811+
812+
/**
813+
* Call this method if user started typing in the active channel
814+
* @param parentId The id of the parent message, if user is typing in a thread
815+
*/
816+
async typingStarted(parentId?: string) {
817+
const activeChannel = this.activeChannelSubject.getValue();
818+
await activeChannel?.keystroke(parentId);
819+
}
820+
821+
/**
822+
* Call this method if user stopped typing in the active channel
823+
* @param parentId The id of the parent message, if user were typing in a thread
824+
*/
825+
async typingStopped(parentId?: string) {
826+
const activeChannel = this.activeChannelSubject.getValue();
827+
await activeChannel?.stopTyping(parentId);
792828
}
793829

794830
private messageUpdated(event: Event) {
@@ -1088,4 +1124,48 @@ export class ChannelService {
10881124
};
10891125
}
10901126
}
1127+
1128+
private handleTypingStartEvent(event: Event) {
1129+
if (event.user?.id === this.chatClientService.chatClient.user?.id) {
1130+
return;
1131+
}
1132+
const isTypingInThread = !!event.parent_id;
1133+
if (
1134+
isTypingInThread &&
1135+
event.parent_id !== this.activeParentMessageIdSubject.getValue()
1136+
) {
1137+
return;
1138+
}
1139+
const subject = isTypingInThread
1140+
? this.usersTypingInThreadSubject
1141+
: this.usersTypingInChannelSubject;
1142+
const users: UserResponse[] = subject.getValue();
1143+
const user = event.user;
1144+
if (user && !users.find((u) => u.id === user.id)) {
1145+
users.push(user);
1146+
subject.next([...users]);
1147+
}
1148+
}
1149+
1150+
private handleTypingStopEvent(event: Event) {
1151+
const usersTypingInChannel = this.usersTypingInChannelSubject.getValue();
1152+
const usersTypingInThread = this.usersTypingInThreadSubject.getValue();
1153+
const user = event.user;
1154+
if (user && usersTypingInChannel.find((u) => u.id === user.id)) {
1155+
usersTypingInChannel.splice(
1156+
usersTypingInChannel.findIndex((u) => u.id === user.id),
1157+
1
1158+
);
1159+
this.usersTypingInChannelSubject.next([...usersTypingInChannel]);
1160+
return;
1161+
}
1162+
if (user && usersTypingInThread.find((u) => u.id === user.id)) {
1163+
usersTypingInThread.splice(
1164+
usersTypingInThread.findIndex((u) => u.id === user.id),
1165+
1
1166+
);
1167+
this.usersTypingInThreadSubject.next([...usersTypingInThread]);
1168+
return;
1169+
}
1170+
}
10911171
}

projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { Injectable } from '@angular/core';
22
import { Subject } from 'rxjs';
33

44
/**
5-
*
5+
* 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)
66
*/
77
@Injectable({
88
providedIn: 'root',
99
})
1010
export class EmojiInputService {
1111
/**
12-
*
12+
* 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)
1313
*/
1414
emojiInput$ = new Subject<string>();
1515

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
*ngIf="canSendMessages; else notAllowed"
8181
streamTextarea
8282
[(value)]="textareaValue"
83+
(valueChange)="typingStart$.next()"
8384
(send)="messageSent()"
8485
[componentRef]="textareaRef"
8586
(userMentions)="mentionedUsers = $event"

0 commit comments

Comments
 (0)