diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5039f7fa47..9c28d99853 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,6 +96,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Fix [#2360](https://github.com/microsoft/BotFramework-WebChat/issues/2360). Timestamp should update on language change, by [@compulim](https://github.com/compulim) in PR [#2414](https://github.com/microsoft/BotFramework-WebChat/pull/2414)
- Fix [#2428](https://github.com/microsoft/BotFramework-WebChat/issues/2428). Should interrupt speech synthesis after microphone button is clicked, by [@compulim](https://github.com/compulim) in PR [#2429](https://github.com/microsoft/BotFramework-WebChat/pull/2429)
- Fix [#2422](https://github.com/microsoft/BotFramework-WebChat/issues/2422). Store thumbnail URL using the activity's `attachment.thumbnailUrl` field, by [@compulim](https://github.com/compulim) in PR [#2433](https://github.com/microsoft/BotFramework-WebChat/pull/2433)
+- Fix [#2435](https://github.com/microsoft/BotFramework-WebChat/issues/2435). Fix microphone button getting stuck on voice-triggered expecting input hint without a speech synthesis engine, by [@compulim](https://github.com/compulim) in PR [#2445](https://github.com/microsoft/BotFramework-WebChat/pull/2445)
### Added
diff --git a/__tests__/speech.synthesis.js b/__tests__/speech.synthesis.js
index f10b141096..8ce634d30e 100644
--- a/__tests__/speech.synthesis.js
+++ b/__tests__/speech.synthesis.js
@@ -124,4 +124,28 @@ describe('speech synthesis', () => {
await expect(speechRecognitionStartCalled().fn(driver)).resolves.toBeTruthy();
await driver.wait(negateCondition(speechSynthesisUtterancePended()), timeouts.ui);
});
+
+ describe('without speech synthesis', () => {
+ test('should start recognition immediately after receiving expected input hint', async () => {
+ const { driver, pageObjects } = await setupWebDriver({
+ props: {
+ webSpeechPonyfillFactory: () => {
+ const { SpeechGrammarList, SpeechRecognition } = window.WebSpeechMock;
+
+ return {
+ SpeechGrammarList,
+ SpeechRecognition
+ };
+ }
+ }
+ });
+
+ await pageObjects.sendMessageViaMicrophone('input hint expected');
+
+ await driver.wait(minNumActivitiesShown(2), timeouts.directLine);
+
+ await expect(speechRecognitionStartCalled().fn(driver)).resolves.toBeTruthy();
+ await driver.wait(negateCondition(speechSynthesisUtterancePended()), timeouts.ui);
+ });
+ });
});
diff --git a/packages/component/src/BasicTranscript.js b/packages/component/src/BasicTranscript.js
index 8a80c50614..8a6b83020e 100644
--- a/packages/component/src/BasicTranscript.js
+++ b/packages/component/src/BasicTranscript.js
@@ -9,6 +9,11 @@ import connectToWebChat from './connectToWebChat';
import ScrollToEndButton from './Activity/ScrollToEndButton';
import SpeakActivity from './Activity/Speak';
+import {
+ speechSynthesis as bypassSpeechSynthesis,
+ SpeechSynthesisUtterance as BypassSpeechSynthesisUtterance
+} from './Speech/BypassSpeechSynthesisPonyfill';
+
const ROOT_CSS = css({
overflow: 'hidden',
position: 'relative'
@@ -85,7 +90,11 @@ const BasicTranscript = ({
-
+
{activityElements.map(({ activity, element }, index) => (
{element}
{// TODO: [P2] We should use core/definitions/speakingActivity for this predicate instead
- speechSynthesis && activity.channelData && activity.channelData.speak && (
-
- )}
+ activity.channelData && activity.channelData.speak && }
))}
diff --git a/packages/component/src/Speech/BypassSpeechSynthesisPonyfill.js b/packages/component/src/Speech/BypassSpeechSynthesisPonyfill.js
new file mode 100644
index 0000000000..26a993dae4
--- /dev/null
+++ b/packages/component/src/Speech/BypassSpeechSynthesisPonyfill.js
@@ -0,0 +1,164 @@
+// Since this is a bypass, we will relax some ESLint rules.
+// All classes/properties defined here are in W3C Web Speech API.
+
+/* eslint class-methods-use-this: "off" */
+/* eslint getter-return: "off" */
+/* eslint max-classes-per-file: ["error", 4] */
+/* eslint no-empty-function: "off" */
+
+import EventTarget, { defineEventAttribute } from '../external/event-target-shim';
+
+class SpeechSynthesisEvent {
+ constructor(type, utterance) {
+ this._type = type;
+ this._utterance = utterance;
+ }
+
+ get charIndex() {
+ return 0;
+ }
+
+ get elapsedTime() {
+ return 0;
+ }
+
+ get name() {}
+
+ get type() {
+ return this._type;
+ }
+
+ get utterance() {
+ return this._utterance;
+ }
+}
+
+class SpeechSynthesisUtterance extends EventTarget {
+ constructor(text) {
+ super();
+
+ this._lang = 'en-US';
+ this._pitch = 1;
+ this._rate = 1;
+ this._text = text;
+ this._voice = null;
+ this._volume = 1;
+ }
+
+ get lang() {
+ return this._lang;
+ }
+
+ set lang(value) {
+ this._lang = value;
+ }
+
+ get pitch() {
+ return this._pitch;
+ }
+
+ set pitch(value) {
+ this._pitch = value;
+ }
+
+ get rate() {
+ return this._rate;
+ }
+
+ set rate(value) {
+ this._rate = value;
+ }
+
+ get text() {
+ return this._text;
+ }
+
+ set text(value) {
+ this._text = value;
+ }
+
+ get voice() {
+ return this._voice;
+ }
+
+ set voice(value) {
+ this._voice = value;
+ }
+
+ get volume() {
+ return this._volume;
+ }
+
+ set volume(value) {
+ this._volume = value;
+ }
+}
+
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'boundary');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'end');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'error');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'mark');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'pause');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'resume');
+defineEventAttribute(SpeechSynthesisUtterance.prototype, 'start');
+
+class SpeechSynthesisVoice {
+ get default() {
+ return true;
+ }
+
+ get lang() {
+ return 'en-US';
+ }
+
+ get localService() {
+ return true;
+ }
+
+ get name() {
+ return 'English (US)';
+ }
+
+ get voiceURI() {
+ return 'English (US)';
+ }
+}
+
+class SpeechSynthesis extends EventTarget {
+ get paused() {
+ return false;
+ }
+
+ get pending() {
+ return false;
+ }
+
+ get speaking() {
+ return false;
+ }
+
+ cancel() {}
+
+ getVoices() {
+ return [new SpeechSynthesisVoice()];
+ }
+
+ pause() {
+ throw new Error('pause is not implemented.');
+ }
+
+ resume() {
+ throw new Error('resume is not implemented.');
+ }
+
+ speak(utterance) {
+ utterance.dispatchEvent(new SpeechSynthesisEvent('start', utterance));
+ utterance.dispatchEvent(new SpeechSynthesisEvent('end', utterance));
+ }
+}
+
+defineEventAttribute(SpeechSynthesis.prototype, 'voiceschanged');
+
+const speechSynthesis = new SpeechSynthesis();
+
+export { speechSynthesis, SpeechSynthesisEvent, SpeechSynthesisUtterance, SpeechSynthesisVoice };
diff --git a/packages/component/src/external/event-target-shim.js b/packages/component/src/external/event-target-shim.js
new file mode 100644
index 0000000000..bb71ce05a6
--- /dev/null
+++ b/packages/component/src/external/event-target-shim.js
@@ -0,0 +1,863 @@
+// This is adopted from event-target-shim@5.0.1 under MIT License.
+// The source code is copied here because the original package does not support ES5 browsers.
+
+// Webpack assumes all code under node_modules are correctly transpiled to ES5.
+// But since this package did not transpile, thus, the output bundle will contains non-ES5 code which break older browsers.
+
+/* eslint-disable */
+
+/*!
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2015 Toru Nagashima
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+/**
+ * @author Toru Nagashima
+ * @copyright 2015 Toru Nagashima. All rights reserved.
+ * See LICENSE file in root directory for full license.
+ */
+'use strict';
+
+Object.defineProperty(exports, '__esModule', { value: true });
+
+/**
+ * @typedef {object} PrivateData
+ * @property {EventTarget} eventTarget The event target.
+ * @property {{type:string}} event The original event object.
+ * @property {number} eventPhase The current event phase.
+ * @property {EventTarget|null} currentTarget The current event target.
+ * @property {boolean} canceled The flag to prevent default.
+ * @property {boolean} stopped The flag to stop propagation.
+ * @property {boolean} immediateStopped The flag to stop propagation immediately.
+ * @property {Function|null} passiveListener The listener if the current listener is passive. Otherwise this is null.
+ * @property {number} timeStamp The unix time.
+ * @private
+ */
+
+/**
+ * Private data for event wrappers.
+ * @type {WeakMap}
+ * @private
+ */
+const privateData = new WeakMap();
+
+/**
+ * Cache for wrapper classes.
+ * @type {WeakMap