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
8 changes: 8 additions & 0 deletions docusaurus/docs/Angular/components/message-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ You can add an emoji picker by [providing your own emoji picker template](../cod
| --------------------------------------------- |
| `TemplateRef<{emojiInput$: Subject<string>}>` |

### typingIndicatorTemplate

You can provide your own typing indicator template instead of the default one.

| Type |
| ---------------------------------------------------------------------------- |
| `TemplateRef<{ usersTyping$: Observable<UserResponse<DefaultUserType>[]> }>` |

## Outputs

### messageUpdate
Expand Down
75 changes: 75 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 @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
80 changes: 80 additions & 0 deletions projects/stream-chat-angular/src/lib/channel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserResponse[]>;
usersTypingInThread$: Observable<UserResponse[]>;
customNewMessageNotificationHandler?: (
notification: Notification,
channelListSetter: (channels: Channel[]) => void
Expand Down Expand Up @@ -197,6 +199,8 @@ export class ChannelService {
private messageToQuoteSubject = new BehaviorSubject<
StreamMessage | undefined
>(undefined);
private usersTypingInChannelSubject = new BehaviorSubject<UserResponse[]>([]);
private usersTypingInThreadSubject = new BehaviorSubject<UserResponse[]>([]);

private channelListSetter = (channels: Channel[]) => {
this.channelsSubject.next(channels);
Expand Down Expand Up @@ -264,6 +268,10 @@ export class ChannelService {
.subscribe(() => {
void this.setAsActiveParentMessage(undefined);
});

this.usersTypingInChannel$ =
this.usersTypingInChannelSubject.asObservable();
this.usersTypingInThread$ = this.usersTypingInThreadSubject.asObservable();
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
*ngIf="canSendMessages; else notAllowed"
streamTextarea
[(value)]="textareaValue"
(valueChange)="typingStart$.next()"
(send)="messageSent()"
[componentRef]="textareaRef"
(userMentions)="mentionedUsers = $event"
Expand Down
Loading