Skip to content

Commit 30af490

Browse files
fix(chat): Typing change event behavior (#1906)
* Fixed incorrect event type emitted by the chat. * Added correct clean-up state for timeout callback. * State is correctly emitted on longer input chains. * When submitting a message invoke it after the message event. --------- Co-authored-by: Galina Edinakova <[email protected]>
1 parent a66e1d0 commit 30af490

File tree

3 files changed

+97
-38
lines changed

3 files changed

+97
-38
lines changed

src/components/chat/chat-input.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { addThemingController } from '../../theming/theming-controller.js';
88
import IgcIconButtonComponent from '../button/icon-button.js';
99
import IgcChipComponent from '../chip/chip.js';
1010
import { chatContext, chatUserInputContext } from '../common/context.js';
11-
import { enterKey } from '../common/controllers/key-bindings.js';
11+
import { enterKey, tabKey } from '../common/controllers/key-bindings.js';
1212
import { registerComponent } from '../common/definitions/register.js';
1313
import { partMap } from '../common/part-map.js';
1414
import { bindIf, hasFiles, isEmpty, trimmedHtml } from '../common/util.js';
@@ -91,6 +91,10 @@ export default class IgcChatInputComponent extends LitElement {
9191
sendButton: () => this._renderSendButton(),
9292
});
9393

94+
private _userIsTyping = false;
95+
private _userLastTypeTime = Date.now();
96+
private _typingTimeout = 0;
97+
9498
@consume({ context: chatContext, subscribe: true })
9599
private readonly _state!: ChatState;
96100

@@ -148,6 +152,11 @@ export default class IgcChatInputComponent extends LitElement {
148152
this.focusInput();
149153
}
150154

155+
private _setTypingStateAndEmit(state: boolean): void {
156+
this._userIsTyping = state;
157+
this._userInputState.emitUserTypingState(state);
158+
}
159+
151160
private _handleAttachmentRemoved(attachment: IgcChatMessageAttachment): void {
152161
const current = this._userInputState.inputAttachments;
153162

@@ -160,16 +169,38 @@ export default class IgcChatInputComponent extends LitElement {
160169
}
161170

162171
private _handleKeydown(event: KeyboardEvent): void {
163-
const isSendRequest =
164-
event.key.toLowerCase() === enterKey.toLowerCase() && !event.shiftKey;
172+
this._userLastTypeTime = Date.now();
173+
const isEnterKey = event.key.toLowerCase() === enterKey.toLowerCase();
174+
const isTab = event.key.toLocaleLowerCase() === tabKey.toLowerCase();
165175

166-
if (isSendRequest) {
176+
if (isTab && !this._userIsTyping) {
177+
return;
178+
}
179+
180+
if (isEnterKey && !event.shiftKey) {
167181
event.preventDefault();
168182
this._sendMessage();
169-
} else {
170-
// TODO:
171-
this._state.handleKeyDown(event);
183+
184+
if (this._userIsTyping) {
185+
clearTimeout(this._typingTimeout);
186+
this._setTypingStateAndEmit(false);
187+
}
188+
189+
return;
172190
}
191+
192+
clearTimeout(this._typingTimeout);
193+
const delay = this._state.stopTypingDelay;
194+
195+
if (!this._userIsTyping) {
196+
this._setTypingStateAndEmit(true);
197+
}
198+
199+
this._typingTimeout = setTimeout(() => {
200+
if (this._userIsTyping && this._userLastTypeTime + delay <= Date.now()) {
201+
this._setTypingStateAndEmit(false);
202+
}
203+
}, delay);
173204
}
174205

175206
private _handleFileInputClick(): void {
@@ -238,6 +269,7 @@ export default class IgcChatInputComponent extends LitElement {
238269
this._state.attachFilesWithEvent(Array.from(input.files!));
239270
}
240271
}
272+
241273
/**
242274
* Default attachments area template used when no custom template is provided.
243275
* Renders the list of input attachments as chips.

src/components/chat/chat-state.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ export class ChatState {
4646
*/
4747
private _acceptedTypesCache: ChatAcceptedFileTypes | null = null;
4848

49-
private _isTyping = false;
50-
private _lastTyped = Date.now();
51-
5249
public resourceStrings = IgcChatResourceStringEN;
5350

5451
//#endregion
@@ -217,6 +214,11 @@ export class ChatState {
217214
return this._host.emitEvent('igcMessageReact', { detail: reaction });
218215
}
219216

217+
/** @internal */
218+
public emitUserTypingState(state: boolean): boolean {
219+
return this._host.emitEvent('igcTypingChange', { detail: state });
220+
}
221+
220222
/**
221223
* @internal
222224
*/
@@ -326,29 +328,5 @@ export class ChatState {
326328
}
327329
}
328330

329-
public handleKeyDown = (_: KeyboardEvent) => {
330-
this._lastTyped = Date.now();
331-
if (!this._isTyping) {
332-
this.emitEvent('igcTypingChange', {
333-
detail: { isTyping: true },
334-
});
335-
this._isTyping = true;
336-
337-
const stopTypingDelay = this.stopTypingDelay;
338-
setTimeout(() => {
339-
if (
340-
this._isTyping &&
341-
stopTypingDelay &&
342-
this._lastTyped + stopTypingDelay < Date.now()
343-
) {
344-
this.emitEvent('igcTypingChange', {
345-
detail: { isTyping: false },
346-
});
347-
this._isTyping = false;
348-
}
349-
}, stopTypingDelay);
350-
}
351-
};
352-
353331
//#endregion
354332
}

src/components/chat/chat.spec.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { html, nothing } from 'lit';
33
import { spy, stub, useFakeTimers } from 'sinon';
44
import type IgcIconButtonComponent from '../button/icon-button.js';
55
import IgcChipComponent from '../chip/chip.js';
6-
import { enterKey } from '../common/controllers/key-bindings.js';
6+
import { enterKey, tabKey } from '../common/controllers/key-bindings.js';
77
import { defineComponents } from '../common/definitions/defineComponents.js';
88
import { first, last } from '../common/util.js';
99
import {
1010
isFocused,
1111
simulateBlur,
1212
simulateClick,
1313
simulateFocus,
14+
simulateInput,
1415
simulateKeyboard,
1516
} from '../common/utils.spec.js';
1617
import { simulateFileUpload } from '../file-input/file-input.spec.js';
@@ -888,21 +889,69 @@ describe('Chat', () => {
888889
const textArea = getChatDOM(chat).input.textarea;
889890

890891
chat.options = { stopTypingDelay: 2500 };
891-
simulateKeyboard(textArea, 'a');
892+
simulateKeyboard(textArea, 'a', 15);
892893
await elementUpdated(chat);
893894

894895
expect(eventSpy).calledWith('igcTypingChange');
895-
expect(eventSpy.firstCall.args[1]?.detail).to.eql({ isTyping: true });
896+
expect(eventSpy.firstCall.args[1]?.detail).to.be.true;
896897

897898
clock.setSystemTime(2501);
898899
await clock.runAllAsync();
899900

900901
expect(eventSpy).calledWith('igcTypingChange');
901-
expect(eventSpy.lastCall.args[1]?.detail).to.eql({ isTyping: false });
902+
expect(eventSpy.lastCall.args[1]?.detail).to.be.false;
902903

903904
clock.restore();
904905
});
905906

907+
it('emits igcTypingChange after sending a message', async () => {
908+
const eventSpy = spy(chat, 'emitEvent');
909+
const textArea = getChatDOM(chat).input.textarea;
910+
const internalInput = textArea.renderRoot.querySelector('textarea')!;
911+
912+
chat.options = { stopTypingDelay: 2500 };
913+
914+
// Simulate typing some text and the event sequence following after sending a message
915+
916+
// Fires igcTypingChange
917+
simulateKeyboard(textArea, 'a', 15);
918+
await elementUpdated(textArea);
919+
920+
// Fires igcInputChange
921+
simulateInput(internalInput, { value: 'a'.repeat(15) });
922+
await elementUpdated(textArea);
923+
924+
// Fires igcMessageCreated -> igcTypingChange -> igcInputFocus since sending a message refocuses
925+
// the textarea
926+
simulateKeyboard(textArea, enterKey);
927+
await elementUpdated(chat);
928+
929+
const expectedEventSequence = [
930+
'igcTypingChange',
931+
'igcInputChange',
932+
'igcMessageCreated',
933+
'igcTypingChange',
934+
'igcInputFocus',
935+
];
936+
937+
for (const [idx, event] of expectedEventSequence.entries()) {
938+
expect(eventSpy.getCall(idx).firstArg).to.equal(event);
939+
}
940+
});
941+
942+
it('should not emit igcTypingChange on Tab key', async () => {
943+
const eventSpy = spy(chat, 'emitEvent');
944+
const textArea = getChatDOM(chat).input.textarea;
945+
const internalInput = textArea.renderRoot.querySelector('textarea')!;
946+
947+
chat.options = { stopTypingDelay: 2500 };
948+
949+
simulateKeyboard(internalInput, tabKey);
950+
await elementUpdated(chat);
951+
952+
expect(eventSpy.getCalls()).is.empty;
953+
});
954+
906955
it('emits igcInputFocus', async () => {
907956
const eventSpy = spy(chat, 'emitEvent');
908957

0 commit comments

Comments
 (0)