diff --git a/CHANGELOG.md b/CHANGELOG.md index e513478297..c3ae904cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fix [#1383](https://github.com/Microsoft/BotFramework-WebChat/issues/1383). Added options to hide upload button, by [@compulim](https://github.com/compulim) in PR [#1491](https://github.com/Microsoft/BotFramework-WebChat/pull/1491) - Added support of avatar image, thru `styleOptions.botAvatarImage` and `styleOptions.userAvatarImage`, in PR [#1486](https://github.com/Microsoft/BotFramework-WebChat/pull/1486) - Added ability to style sendbox background and text color, thru `styleOptions.sendBoxBackground` and `styleOptions.sendBoxTextColor`, in PR [#1575](https://github.com/Microsoft/BotFramework-WebChat/pull/1575) +- `core`: Added `sendEvent`, in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) +- `core`: Added `CONNECT_FULFILLING` action to workaround `redux-saga` [design decision](https://github.com/redux-saga/redux-saga/issues/1651), in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) ### Changed - Moved `botAvatarImage` and `userAvatarImage` to `styleOptions.botAvatarImage` and `styleOptions.userAvatarImage` respectively, in PR [#1486](https://github.com/Microsoft/BotFramework-WebChat/pull/1486) @@ -43,6 +45,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `component`: Fix [#1560](https://github.com/Microsoft/BotFramework-WebChat/issues/1560). Fixed carousel layout did not show date and alignment issues, by [@compulim](https://github.com/compulim) in PR [#1561](https://github.com/Microsoft/BotFramework-WebChat/pull/1561) - `playground`: Fix [#1562](https://github.com/Microsoft/BotFramework-WebChat/issues/1562). Fixed timestamp grouping "Don't group" and added "Don't show timestamp", by [@compulim](https://github.com/compulim) in PR [#1563](https://github.com/Microsoft/BotFramework-WebChat/pull/1563) - `component`: Fix [#1576](https://github.com/Microsoft/BotFramework-WebChat/issues/1576). Rich card without `tap` should be rendered properly, by [@compulim](https://github.com/compulim) in PR [#1577](https://github.com/Microsoft/BotFramework-WebChat/pull/1577) +- `core`: Some sagas missed handling successive actions, in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) +- `core`: `incomingActivitySaga` may throw null-ref exception if the first activity is from user, in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) +- `component`: Fix [#1328](https://github.com/Microsoft/BotFramework-WebChat/issues/1328). Should not start microphone if input hint is set to `ignoringInput`, in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) +- `component`: Fix outgoing typing indicators are not sent and acknowledged properly, in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) ### Removed - `botAvatarImage` and `userAvatarImage` props, as they are moved inside `styleOptions`, in PR [#1486](https://github.com/Microsoft/BotFramework-WebChat/pull/1486) @@ -52,6 +58,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `component`: [Hide upload button](https://microsoft.github.io/BotFramework-WebChat/05.d.hide-upload-button-styling/), in [#1491](https://github.com/Microsoft/BotFramework-WebChat/pull/1491) - `component`: [Avatar image](https://microsoft.github.io/BotFramework-WebChat/04.b.display-user-bot-images-styling/), in [#1486](https://github.com/Microsoft/BotFramework-WebChat/pull/1486) - `core`: [Incoming activity to JavaScript event](https://microsoft.github.io/BotFramework-WebChat/15.b.incoming-activity-event/), in [#1567](https://github.com/Microsoft/BotFramework-WebChat/pull/1567) +- `core`: [Send welcome event](https://microsoft.github.io/BotFramework-WebChat/15.b.backchannel-send-welcome-event/), in PR [#1286](https://github.com/Microsoft/BotFramework-WebChat/pull/1286) ## [4.2.0] - 2018-12-11 ### Added diff --git a/README.md b/README.md index af523b175d..67dbb35a84 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ npm run prepublishOnly | [`15.a.backchannel-piggyback-on-outgoing-activities`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/15.a.backchannel-piggyback-on-outgoing-activities) | Advanced tutorial: Demonstrates how to add custom data to every outgoing activities. | [Demo](https://microsoft.github.io/BotFramework-WebChat/15.a.backchannel-piggyback-on-outgoing-activities) | | [`15.b.incoming-activity-event`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/15.a.incoming-activity-event) | Advanced tutorial: Demonstrates how to forward all incoming activities to a JavaScript event for further processing. | [Demo](https://microsoft.github.io/BotFramework-WebChat/15.a.incoming-activity-event) | | [`15.c.programmatic-post-activity`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/15.c.programmatic-post-activity) | Advanced tutorial: Demonstrates how to send a message programmatically. | [Demo](https://microsoft.github.io/BotFramework-WebChat/15.c.programmatic-post-activity) | +| [`15.d.backchannel-send-welcome-event`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/15.d.backchannel-send-welcome-event) | Advanced tutorial: Demonstrates how to send welcome event with client capabilities such as browser language. | [Demo](https://microsoft.github.io/BotFramework-WebChat/15.d.backchannel-send-welcome-event) | # Contributions diff --git a/packages/component/src/BasicTranscript.js b/packages/component/src/BasicTranscript.js index e8f7645263..e38d88719d 100644 --- a/packages/component/src/BasicTranscript.js +++ b/packages/component/src/BasicTranscript.js @@ -110,7 +110,10 @@ const BasicTranscript = ({ role="listitem" > { activityRenderer({ activity, showTimestamp })(({ attachment }) => attachmentRenderer({ activity, attachment })) } - { activity.channelData && activity.channelData.speak && } + { + // TODO: [P2] We should use core/definitions/speakingActivity for this predicate instead + activity.channelData && activity.channelData.speak && + } ); }) diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js index eec02f387e..6e2ce35249 100644 --- a/packages/component/src/Composer.js +++ b/packages/component/src/Composer.js @@ -15,6 +15,7 @@ import { disconnect, markActivity, postActivity, + sendEvent, sendFiles, sendMessage, sendPostBack, @@ -44,6 +45,7 @@ const EMPTY_ARRAY = []; const DISPATCHERS = { markActivity, postActivity, + sendEvent, sendFiles, sendMessage, sendPostBack, diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index dff3d18037..f14858a526 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -29,7 +29,7 @@ class Dictation extends React.Component { props.stopDictate(); if (transcript) { - props.setSendBox(transcript, 'speech'); + props.setSendBox(transcript); props.submitSendBox('speech'); props.startSpeakingActivity(); } @@ -45,7 +45,7 @@ class Dictation extends React.Component { // This is for two purposes: // 1. Set send box will also trigger send typing // 2. If the user cancelled out, the interim result will be in the send box so the user can update it before send - props.setSendBox(interims.join(' '), 'speech'); + props.setSendBox(interims.join(' ')); } handleError(event) { diff --git a/packages/component/src/SendBox/TextBox.js b/packages/component/src/SendBox/TextBox.js index 0733eac360..c57b3c1b0d 100644 --- a/packages/component/src/SendBox/TextBox.js +++ b/packages/component/src/SendBox/TextBox.js @@ -26,7 +26,7 @@ const connectSendTextBox = (...selectors) => connectToWebChat( disabled, language, onChange: ({ target: { value } }) => { - setSendBox(value, 'keyboard'); + setSendBox(value); }, onSubmit: event => { event.preventDefault(); @@ -36,7 +36,7 @@ const connectSendTextBox = (...selectors) => connectToWebChat( if (sendBoxValue) { scrollToEnd(); - submitSendBox('keyboard'); + submitSendBox(); } }, value: sendBoxValue diff --git a/packages/core/src/actions/connect.js b/packages/core/src/actions/connect.js index 45ac4111ac..0c6f680ea2 100644 --- a/packages/core/src/actions/connect.js +++ b/packages/core/src/actions/connect.js @@ -1,6 +1,7 @@ const CONNECT = 'DIRECT_LINE/CONNECT'; const CONNECT_PENDING = `${ CONNECT }_PENDING`; const CONNECT_REJECTED = `${ CONNECT }_REJECTED`; +const CONNECT_FULFILLING = `${ CONNECT }_FULFILLING`; const CONNECT_FULFILLED = `${ CONNECT }_FULFILLED`; export default function ({ directLine, userID }) { @@ -14,5 +15,6 @@ export { CONNECT, CONNECT_PENDING, CONNECT_REJECTED, + CONNECT_FULFILLING, CONNECT_FULFILLED } diff --git a/packages/core/src/actions/postActivity.js b/packages/core/src/actions/postActivity.js index 9fb666f3fd..89375e4d9b 100644 --- a/packages/core/src/actions/postActivity.js +++ b/packages/core/src/actions/postActivity.js @@ -5,9 +5,10 @@ export const POST_ACTIVITY_FULFILLED = `${ POST_ACTIVITY }_${ FULFILLED }`; export const POST_ACTIVITY_PENDING = `${ POST_ACTIVITY }_${ PENDING }`; export const POST_ACTIVITY_REJECTED = `${ POST_ACTIVITY }_${ REJECTED }`; -export default function (activity) { +export default function (activity, method = 'keyboard') { return { type: POST_ACTIVITY, + meta: { method }, payload: { activity } }; } diff --git a/packages/core/src/actions/sendEvent.js b/packages/core/src/actions/sendEvent.js new file mode 100644 index 0000000000..c9f7b76d5d --- /dev/null +++ b/packages/core/src/actions/sendEvent.js @@ -0,0 +1,10 @@ +const SEND_EVENT = 'WEB_CHAT/SEND_EVENT'; + +export default function sendEvent(name, value) { + return { + type: SEND_EVENT, + payload: { name, value } + }; +} + +export { SEND_EVENT } diff --git a/packages/core/src/actions/sendMessage.js b/packages/core/src/actions/sendMessage.js index cb937ec1ec..a96f51740d 100644 --- a/packages/core/src/actions/sendMessage.js +++ b/packages/core/src/actions/sendMessage.js @@ -1,9 +1,9 @@ const SEND_MESSAGE = 'WEB_CHAT/SEND_MESSAGE'; -export default function sendMessage(text, via) { +export default function sendMessage(text, method) { return { type: SEND_MESSAGE, - payload: { text, via } + payload: { method, text } }; } diff --git a/packages/core/src/actions/setSendBox.js b/packages/core/src/actions/setSendBox.js index 4c34c74557..806e4a58b7 100644 --- a/packages/core/src/actions/setSendBox.js +++ b/packages/core/src/actions/setSendBox.js @@ -1,9 +1,9 @@ const SET_SEND_BOX = 'WEB_CHAT/SET_SEND_BOX'; -export default function (text, via) { +export default function (text) { return { type: SET_SEND_BOX, - payload: { text, via } + payload: { text } }; } diff --git a/packages/core/src/actions/submitSendBox.js b/packages/core/src/actions/submitSendBox.js index a008ea0770..aaec313686 100644 --- a/packages/core/src/actions/submitSendBox.js +++ b/packages/core/src/actions/submitSendBox.js @@ -1,9 +1,9 @@ const SUBMIT_SEND_BOX = 'WEB_CHAT/SUBMIT_SEND_BOX'; -export default function submitSendBox(via) { +export default function submitSendBox(method = 'keyboard') { return { type: SUBMIT_SEND_BOX, - payload: { via } + payload: { method } }; } diff --git a/packages/core/src/definitions/activityFromBot.js b/packages/core/src/definitions/activityFromBot.js new file mode 100644 index 0000000000..8570b01d2d --- /dev/null +++ b/packages/core/src/definitions/activityFromBot.js @@ -0,0 +1 @@ +export default activity => activity && activity.from.role === 'bot' diff --git a/packages/core/src/sagas/definition/speakableActivity.js b/packages/core/src/definitions/speakableActivity.js similarity index 52% rename from packages/core/src/sagas/definition/speakableActivity.js rename to packages/core/src/definitions/speakableActivity.js index 502fbcbb42..6a3605ca37 100644 --- a/packages/core/src/sagas/definition/speakableActivity.js +++ b/packages/core/src/definitions/speakableActivity.js @@ -1,8 +1,7 @@ -export default function (activity, userID) { +export default function (activity) { return ( activity && activity.from - && activity.from.id !== userID && activity.type === 'message' ); } diff --git a/packages/core/src/definitions/speakingActivity.js b/packages/core/src/definitions/speakingActivity.js new file mode 100644 index 0000000000..ac9e1e203a --- /dev/null +++ b/packages/core/src/definitions/speakingActivity.js @@ -0,0 +1,3 @@ +export default function (activity) { + return activity.channelData && activity.channelData.speak; +} diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 333dc309ba..bc00f61782 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -3,6 +3,7 @@ import createStore from './createStore'; import disconnect from './actions/disconnect'; import markActivity from './actions/markActivity'; import postActivity from './actions/postActivity'; +import sendEvent from './actions/sendEvent'; import sendFiles from './actions/sendFiles'; import sendMessage from './actions/sendMessage'; import sendPostBack from './actions/sendPostBack'; @@ -31,6 +32,7 @@ export { disconnect, markActivity, postActivity, + sendEvent, sendFiles, sendMessage, sendPostBack, diff --git a/packages/core/src/sagas.js b/packages/core/src/sagas.js index 7f7fedc687..bc064e8b52 100644 --- a/packages/core/src/sagas.js +++ b/packages/core/src/sagas.js @@ -4,16 +4,19 @@ import clearSuggestedActionsOnPostActivitySaga from './sagas/clearSuggestedActio import connectionStatusUpdateSaga from './sagas/connectionStatusUpdateSaga'; import connectSaga from './sagas/connectSaga'; import incomingActivitySaga from './sagas/incomingActivitySaga'; -import incomingTypingSaga from './sagas/incomingTypingSaga'; -import markActivityForSpeakSaga from './sagas/markActivityForSpeakSaga'; +import markActivityForSpeakOnIncomingActivityFromOthersSaga from './sagas/markActivityForSpeakOnIncomingActivityFromOthersSaga'; +import markAllAsSpokenOnStopSpeakActivitySaga from './sagas/markAllAsSpokenOnStopSpeakActivitySaga'; import postActivitySaga from './sagas/postActivitySaga'; +import removeIncomingTypingAfterIntervalSaga from './sagas/removeIncomingTypingAfterIntervalSaga'; +import sendEventToPostActivitySaga from './sagas/sendEventToPostActivitySaga'; import sendFilesToPostActivitySaga from './sagas/sendFilesToPostActivitySaga'; import sendMessageToPostActivitySaga from './sagas/sendMessageToPostActivitySaga'; import sendPostBackToPostActivitySaga from './sagas/sendPostBackToPostActivitySaga'; import sendTypingOnSetSendBoxSaga from './sagas/sendTypingOnSetSendBoxSaga'; import startDictateAfterSpeakActivitySaga from './sagas/startDictateAfterSpeakActivitySaga'; -import stopDictateOnCardAction from './sagas/stopDictateOnCardAction'; -import stopSpeakActivityOnInputSaga from './sagas/stopSpeakActivityOnInputSaga'; +import startSpeakActivityOnPostActivitySaga from './sagas/startSpeakActivityOnPostActivitySaga'; +import stopDictateOnCardActionSaga from './sagas/stopDictateOnCardActionSaga'; +import stopSpeakingActivityOnInputSaga from './sagas/stopSpeakingActivityOnInputSaga'; import submitSendBoxSaga from './sagas/submitSendBoxSaga'; export default function* () { @@ -21,15 +24,18 @@ export default function* () { yield fork(connectionStatusUpdateSaga); yield fork(connectSaga); yield fork(incomingActivitySaga); - yield fork(incomingTypingSaga); - yield fork(markActivityForSpeakSaga); + yield fork(markActivityForSpeakOnIncomingActivityFromOthersSaga); + yield fork(markAllAsSpokenOnStopSpeakActivitySaga); yield fork(postActivitySaga); + yield fork(removeIncomingTypingAfterIntervalSaga); + yield fork(sendEventToPostActivitySaga); yield fork(sendFilesToPostActivitySaga); yield fork(sendMessageToPostActivitySaga); yield fork(sendPostBackToPostActivitySaga); yield fork(sendTypingOnSetSendBoxSaga); yield fork(startDictateAfterSpeakActivitySaga); - yield fork(stopDictateOnCardAction); - yield fork(stopSpeakActivityOnInputSaga); + yield fork(startSpeakActivityOnPostActivitySaga); + yield fork(stopDictateOnCardActionSaga); + yield fork(stopSpeakingActivityOnInputSaga); yield fork(submitSendBoxSaga); } diff --git a/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js b/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js index e92722e7f9..ccd8a57e8a 100644 --- a/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js +++ b/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js @@ -1,17 +1,27 @@ import { put, - take + takeEvery } from 'redux-saga/effects'; -import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; import whileConnected from './effects/whileConnected'; + +import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; import setSuggestedActions from '../actions/setSuggestedActions'; export default function* () { - yield whileConnected(function* () { - for (;;) { - yield take(({ payload, type }) => type === POST_ACTIVITY_PENDING && payload.activity.type === 'message'); - yield put(setSuggestedActions()); - } - }); + yield whileConnected(clearSuggestedActionsOnPostActivity); +} + +function* clearSuggestedActionsOnPostActivity() { + yield takeEvery( + ({ payload, type }) => ( + type === POST_ACTIVITY_PENDING + && payload.activity.type === 'message' + ), + clearSuggestedActions + ); +} + +function* clearSuggestedActions() { + yield put(setSuggestedActions()); } diff --git a/packages/core/src/sagas/connectSaga.js b/packages/core/src/sagas/connectSaga.js index 9e49ae1449..ec12bc8127 100644 --- a/packages/core/src/sagas/connectSaga.js +++ b/packages/core/src/sagas/connectSaga.js @@ -18,6 +18,7 @@ import { CONNECT, CONNECT_PENDING, CONNECT_REJECTED, + CONNECT_FULFILLING, CONNECT_FULFILLED } from '../actions/connect'; @@ -83,6 +84,7 @@ function* connectSaga(directLine, userID) { try { try { yield callUntil(connectionStatusQueue.shift, [], connectionStatus => connectionStatus === ONLINE); + yield put({ type: CONNECT_FULFILLING, meta, payload: { directLine } }); yield put({ type: CONNECT_FULFILLED, meta, payload: { directLine } }); } catch (err) { yield put({ type: CONNECT_REJECTED, error: true, meta, payload: err }); diff --git a/packages/core/src/sagas/connectionStatusUpdateSaga.js b/packages/core/src/sagas/connectionStatusUpdateSaga.js index f53c3bd716..927ac0b2cc 100644 --- a/packages/core/src/sagas/connectionStatusUpdateSaga.js +++ b/packages/core/src/sagas/connectionStatusUpdateSaga.js @@ -9,10 +9,12 @@ import connectionStatusUpdate from '../actions/connectionStatusUpdate'; import setReferenceGrammarID from '../actions/setReferenceGrammarID'; export default function* () { - yield whileConnected(function* (directLine) { - yield observeEach(directLine.connectionStatus$, function* (connectionStatus) { - yield put(connectionStatusUpdate(connectionStatus)); - yield put(setReferenceGrammarID(directLine.referenceGrammarId)); - }); + yield whileConnected(observeConnectionStatus); +} + +function* observeConnectionStatus(directLine) { + yield observeEach(directLine.connectionStatus$, function* (connectionStatus) { + yield put(connectionStatusUpdate(connectionStatus)); + yield put(setReferenceGrammarID(directLine.referenceGrammarId)); }); } diff --git a/packages/core/src/sagas/effects/whileConnected.js b/packages/core/src/sagas/effects/whileConnected.js index 95baeb9450..7adb6c73f2 100644 --- a/packages/core/src/sagas/effects/whileConnected.js +++ b/packages/core/src/sagas/effects/whileConnected.js @@ -5,13 +5,13 @@ import { take } from 'redux-saga/effects'; -import { CONNECT_FULFILLED } from '../../actions/connect'; +import { CONNECT_FULFILLING } from '../../actions/connect'; import { DISCONNECT_FULFILLED } from '../../actions/disconnect'; export default function (fn) { return call(function* () { for (;;) { - const { meta: { userID }, payload: { directLine } } = yield take(CONNECT_FULFILLED); + const { meta: { userID }, payload: { directLine } } = yield take(CONNECT_FULFILLING); const task = yield fork(fn, directLine, userID); yield take(DISCONNECT_FULFILLED); diff --git a/packages/core/src/sagas/effects/whileSpeakIncomingActivity.js b/packages/core/src/sagas/effects/whileSpeakIncomingActivity.js new file mode 100644 index 0000000000..a9bf568468 --- /dev/null +++ b/packages/core/src/sagas/effects/whileSpeakIncomingActivity.js @@ -0,0 +1,22 @@ +import { + call, + cancel, + fork, + take +} from 'redux-saga/effects'; + +import { START_SPEAKING_ACTIVITY } from '../../actions/startSpeakingActivity'; +import { STOP_SPEAKING_ACTIVITY } from '../../actions/stopSpeakingActivity'; + +export default function (fn) { + return call(function* () { + for (;;) { + yield take(START_SPEAKING_ACTIVITY); + + const task = yield fork(fn); + + yield take(STOP_SPEAKING_ACTIVITY); + yield cancel(task); + } + }); +} diff --git a/packages/core/src/sagas/incomingActivitySaga.js b/packages/core/src/sagas/incomingActivitySaga.js index 3e5118416c..6a4fd759a6 100644 --- a/packages/core/src/sagas/incomingActivitySaga.js +++ b/packages/core/src/sagas/incomingActivitySaga.js @@ -3,55 +3,57 @@ import { select } from 'redux-saga/effects'; -import observeEach from './effects/observeEach'; -import whileConnected from './effects/whileConnected'; +import updateIn from 'simple-update-in'; import incomingActivity from '../actions/incomingActivity'; import setSuggestedActions from '../actions/setSuggestedActions'; -function last(array, predicate) { - for (let i = array.length - 1; i >= 0; i--) { - const item = array[i]; +import activityFromBot from '../definitions/activityFromBot'; - if (predicate.call(array, item)) { - return item; - } - } -} +import observeEach from './effects/observeEach'; +import whileConnected from './effects/whileConnected'; + +import { ofType as activitiesOfType } from '../selectors/activities'; export default function* () { - yield whileConnected(function* (directLine, userID) { - yield observeEach(directLine.activity$, function* (activity) { - activity = { ...activity }; - - // Patch activity.from.role to make sure its either "bot", "user", or "channel" - if (!activity.from) { - activity.from = { role: 'channel' }; - } else if (!activity.from.role) { - if (activity.from.id === userID) { - activity.from.role = 'user'; - } else if (activity.from.id) { - activity.from.role = 'bot'; - } else { - activity.from.role = 'channel'; - } - } - - yield put(incomingActivity(activity)); - - // Update suggested actions - const activities = yield select(({ activities }) => activities); - const lastMessageActivity = last(activities, ({ type }) => type === 'message'); - - if ( - lastMessageActivity - && lastMessageActivity.from - && lastMessageActivity.from.role === 'bot' - ) { - const { suggestedActions: { actions } = {} } = lastMessageActivity; - - yield put(setSuggestedActions(actions)); - } - }); + yield whileConnected(observeActivity); +} + +function* observeActivity(directLine, userID) { + yield observeEach(directLine.activity$, function* (activity) { + activity = patchActivityWithFromRole(activity, userID); + + yield put(incomingActivity(activity)); + + // Update suggested actions + const messageActivities = yield select(activitiesOfType('message')); + const lastMessageActivity = messageActivities[messageActivities.length - 1]; + + if (activityFromBot(lastMessageActivity)) { + const { suggestedActions: { actions } = {} } = lastMessageActivity; + + yield put(setSuggestedActions(actions)); + } }); } + +function patchActivityWithFromRole(activity, userID) { + // Some activities, such as "ConversationUpdate", does not have "from" defined. + // And although "role" is defined in Direct Line spec, it was not sent over the wire. + // We normalize the activity here to simplify null-check and logic later. + + // Patch activity.from.role to make sure its either "bot", "user", or "channel" + if (!activity.from) { + activity = updateIn(activity, ['from', 'role'], () => 'channel'); + } else if (!activity.from.role) { + if (activity.from.id === userID) { + activity = updateIn(activity, ['from', 'role'], () => 'user'); + } else if (activity.from.id) { + activity = updateIn(activity, ['from', 'role'], () => 'bot'); + } else { + activity = updateIn(activity, ['from', 'role'], () => 'channel'); + } + } + + return activity; +} diff --git a/packages/core/src/sagas/markActivityForSpeakOnIncomingActivityFromOthersSaga.js b/packages/core/src/sagas/markActivityForSpeakOnIncomingActivityFromOthersSaga.js new file mode 100644 index 0000000000..50c2f0ff1e --- /dev/null +++ b/packages/core/src/sagas/markActivityForSpeakOnIncomingActivityFromOthersSaga.js @@ -0,0 +1,33 @@ +import { + put, + takeEvery +} from 'redux-saga/effects'; + +import speakableActivity from '../definitions/speakableActivity'; + +import whileConnected from './effects/whileConnected'; +import whileSpeakIncomingActivity from './effects/whileSpeakIncomingActivity'; + +import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; +import markActivity from '../actions/markActivity'; + +export default function* () { + yield whileConnected(function* (_, userID) { + yield whileSpeakIncomingActivity(markActivityForSpeakOnIncomingActivityFromOthers.bind(null, userID)); + }); +} + +function* markActivityForSpeakOnIncomingActivityFromOthers(userID) { + yield takeEvery( + ({ payload, type }) => ( + type === INCOMING_ACTIVITY + && speakableActivity(payload.activity) + && payload.activity.from.id !== userID + ), + markActivityForSpeak + ); +} + +function* markActivityForSpeak({ payload: { activity } }) { + yield put(markActivity(activity, 'speak', true)); +} diff --git a/packages/core/src/sagas/markActivityForSpeakSaga.js b/packages/core/src/sagas/markActivityForSpeakSaga.js deleted file mode 100644 index e1c50f81eb..0000000000 --- a/packages/core/src/sagas/markActivityForSpeakSaga.js +++ /dev/null @@ -1,39 +0,0 @@ -import { - cancel, - fork, - put, - take -} from 'redux-saga/effects'; - -import speakableActivity from './definition/speakableActivity'; -import whileConnected from './effects/whileConnected'; - -import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; -import { START_SPEAKING_ACTIVITY } from '../actions/startSpeakingActivity'; -import { STOP_SPEAKING_ACTIVITY } from '../actions/stopSpeakingActivity'; -import markActivity from '../actions/markActivity'; - -export default function* () { - yield whileConnected(function* (_, userID) { - for (;;) { - yield take(START_SPEAKING_ACTIVITY); - - const task = yield fork(markActivityForSpeakSaga, userID); - - yield take(STOP_SPEAKING_ACTIVITY); - yield cancel(task); - } - }); -} - -function* markActivityForSpeakSaga(userID) { - for (;;) { - const { payload: { activity } } = yield take( - ({ payload: { activity } = {}, type }) => - type === INCOMING_ACTIVITY - && speakableActivity(activity, userID) - ); - - yield put(markActivity(activity, 'speak', true)); - } -} diff --git a/packages/core/src/sagas/markAllAsSpokenOnStopSpeakActivitySaga.js b/packages/core/src/sagas/markAllAsSpokenOnStopSpeakActivitySaga.js new file mode 100644 index 0000000000..84f9617830 --- /dev/null +++ b/packages/core/src/sagas/markAllAsSpokenOnStopSpeakActivitySaga.js @@ -0,0 +1,24 @@ +import { + put, + select, + takeEvery +} from 'redux-saga/effects'; + +import { STOP_SPEAKING_ACTIVITY } from '../actions/stopSpeakingActivity'; +import markActivity from '../actions/markActivity'; + +import speakingActivity from '../definitions/speakingActivity'; + +import { of as activitiesOf } from '../selectors/activities'; + +export default function* () { + yield takeEvery(STOP_SPEAKING_ACTIVITY, markAllAsSpoken); +} + +function* markAllAsSpoken() { + const speakingActivities = yield select(activitiesOf(speakingActivity)); + + for (let activity of speakingActivities) { + yield put(markActivity(activity, 'speak', false)); + } +} diff --git a/packages/core/src/sagas/postActivitySaga.js b/packages/core/src/sagas/postActivitySaga.js index 75f44aad46..8218c93a3c 100644 --- a/packages/core/src/sagas/postActivitySaga.js +++ b/packages/core/src/sagas/postActivitySaga.js @@ -2,20 +2,22 @@ import { all, call, cancelled, - fork, put, race, select, - take + take, + takeEvery } from 'redux-saga/effects'; -import sleep from '../utils/sleep'; - import observeOnce from './effects/observeOnce'; import whileConnected from './effects/whileConnected'; +import languageSelector from '../selectors/language'; +import sendTimeoutSelector from '../selectors/sendTimeout'; + import deleteKey from '../utils/deleteKey'; import getTimestamp from '../utils/getTimestamp'; +import sleep from '../utils/sleep'; import uniqueID from '../utils/uniqueID'; import { @@ -29,16 +31,16 @@ import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; export default function* () { yield whileConnected(function* (directLine, userID) { - for (let numActivitiesPosted = 0;; numActivitiesPosted++) { - const action = yield take(POST_ACTIVITY); + let numActivitiesPosted = 0; - yield fork(postActivity, directLine, userID, numActivitiesPosted, action); - } + yield takeEvery(POST_ACTIVITY, function* (action) { + yield* postActivity(directLine, userID, numActivitiesPosted++, action); + }); }); } -function* postActivity(directLine, userID, numActivitiesPosted, { payload: { activity } }) { - const locale = yield select(({ language }) => language); +function* postActivity(directLine, userID, numActivitiesPosted, { meta: { method }, payload: { activity } }) { + const locale = yield select(languageSelector); const { attachments, channelData: { clientActivityID = uniqueID() } = {} } = activity; activity = { @@ -72,9 +74,9 @@ function* postActivity(directLine, userID, numActivitiesPosted, { payload: { act }]; } - const meta = { clientActivityID }; + const meta = { clientActivityID, method }; - yield put({ type: POST_ACTIVITY_PENDING, payload: { activity }, meta }); + yield put({ type: POST_ACTIVITY_PENDING, meta, payload: { activity } }); try { // Quirks: We might receive INCOMING_ACTIVITY before the postActivity call completed @@ -96,7 +98,7 @@ function* postActivity(directLine, userID, numActivitiesPosted, { payload: { act // - Direct Line service only respond on HTTP after bot respond to Direct Line // - Activity may take too long time to echo back - const sendTimeout = yield select(({ sendTimeout }) => sendTimeout); + const sendTimeout = yield select(sendTimeoutSelector); const { send: { echoBack } } = yield race({ send: all({ diff --git a/packages/core/src/sagas/incomingTypingSaga.js b/packages/core/src/sagas/removeIncomingTypingAfterIntervalSaga.js similarity index 50% rename from packages/core/src/sagas/incomingTypingSaga.js rename to packages/core/src/sagas/removeIncomingTypingAfterIntervalSaga.js index 4df8196c62..953c615ab5 100644 --- a/packages/core/src/sagas/incomingTypingSaga.js +++ b/packages/core/src/sagas/removeIncomingTypingAfterIntervalSaga.js @@ -11,15 +11,17 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -function isTypingActivity({ type, payload }) { - return type === INCOMING_ACTIVITY && payload.activity.type === 'typing'; -} - export default function* () { - yield takeLatest(isTypingActivity, function* ({ payload: { activity } }) { - const id = activity.id; + yield takeLatest( + ({ type, payload }) => ( + type === INCOMING_ACTIVITY + && payload.activity.type === 'typing' + ), + removeActivityAfterInterval + ); +} - yield call(sleep, 5000); - yield put(deleteActivity(id)); - }); +function* removeActivityAfterInterval({ payload: { activity: { id } } }) { + yield call(sleep, 5000); + yield put(deleteActivity(id)); } diff --git a/packages/core/src/sagas/sendEventToPostActivitySaga.js b/packages/core/src/sagas/sendEventToPostActivitySaga.js new file mode 100644 index 0000000000..442f7522c7 --- /dev/null +++ b/packages/core/src/sagas/sendEventToPostActivitySaga.js @@ -0,0 +1,31 @@ +import { + put, + takeEvery +} from 'redux-saga/effects'; + +import whileConnected from './effects/whileConnected'; + +import { SEND_EVENT } from '../actions/sendEvent'; +import postActivity from '../actions/postActivity'; + +export default function* () { + yield whileConnected(sendEventToPostActivity); +} + +function* sendEventToPostActivity() { + yield takeEvery( + ({ payload, type }) => ( + type === SEND_EVENT + && payload.name + ), + postActivityWithEvent + ); +} + +function* postActivityWithEvent({ payload: { name, value } }) { + yield put(postActivity({ + name, + type: 'event', + value + })); +} diff --git a/packages/core/src/sagas/sendFilesToPostActivitySaga.js b/packages/core/src/sagas/sendFilesToPostActivitySaga.js index 2612b99fbc..e16414eafa 100644 --- a/packages/core/src/sagas/sendFilesToPostActivitySaga.js +++ b/packages/core/src/sagas/sendFilesToPostActivitySaga.js @@ -1,6 +1,6 @@ import { put, - take + takeEvery } from 'redux-saga/effects'; import mime from 'mime'; @@ -9,30 +9,33 @@ import whileConnected from './effects/whileConnected'; import { SEND_FILES } from '../actions/sendFiles'; import postActivity from '../actions/postActivity'; -import stopSpeakingActivity from '../actions/stopSpeakingActivity'; const getType = mime.getType.bind(mime); export default function* () { - yield whileConnected(function* () { - for (;;) { - const { payload: { files } } = yield take(SEND_FILES); + yield whileConnected(sendFilesToPostActivity); +} - if (files.length) { - yield put(postActivity({ - attachments: [].map.call(files, file => ({ - contentType: getType(file.name) || 'application/octet-stream', - contentUrl: file.url, - name: file.name - })), - channelData: { - attachmentSizes: [].map.call(files, file => file.size) - }, - type: 'message' - })); +function* sendFilesToPostActivity() { + yield takeEvery( + ({ payload, type }) => ( + type === SEND_FILES + && payload.files.length + ), + postActivityWithFiles + ); +} - yield put(stopSpeakingActivity()); - } - } - }); +function* postActivityWithFiles({ payload: { files } }) { + yield put(postActivity({ + attachments: [].map.call(files, ({ name, url }) => ({ + contentType: getType(name) || 'application/octet-stream', + contentUrl: url, + name: name + })), + channelData: { + attachmentSizes: [].map.call(files, ({ size }) => size) + }, + type: 'message' + })); } diff --git a/packages/core/src/sagas/sendMessageToPostActivitySaga.js b/packages/core/src/sagas/sendMessageToPostActivitySaga.js index 2c63b3ca39..1f6f244ce7 100644 --- a/packages/core/src/sagas/sendMessageToPostActivitySaga.js +++ b/packages/core/src/sagas/sendMessageToPostActivitySaga.js @@ -1,33 +1,28 @@ import { put, - take + takeEvery } from 'redux-saga/effects'; import whileConnected from './effects/whileConnected'; import { SEND_MESSAGE } from '../actions/sendMessage'; import postActivity from '../actions/postActivity'; -import startSpeakingActivity from '../actions/startSpeakingActivity'; -import stopSpeakingActivity from '../actions/stopSpeakingActivity'; export default function* () { - yield whileConnected(function* () { - for (;;) { - const { payload: { text, via } } = yield take(SEND_MESSAGE); + yield whileConnected(sendMessageToPostActivity); +} - if (text) { - yield put(postActivity({ - text, - textFormat: 'plain', - type: 'message' - })); +function* sendMessageToPostActivity() { + yield takeEvery(({ payload, type }) => ( + type === SEND_MESSAGE + && payload.text + ), postActivityWithMessage); +} - if (via === 'speech') { - yield put(startSpeakingActivity()); - } else { - yield put(stopSpeakingActivity()); - } - } - } - }); +function* postActivityWithMessage({ payload: { method, text } }) { + yield put(postActivity({ + text, + textFormat: 'plain', + type: 'message' + }, method)); } diff --git a/packages/core/src/sagas/sendPostBackToPostActivitySaga.js b/packages/core/src/sagas/sendPostBackToPostActivitySaga.js index 4e45e952a1..958997407b 100644 --- a/packages/core/src/sagas/sendPostBackToPostActivitySaga.js +++ b/packages/core/src/sagas/sendPostBackToPostActivitySaga.js @@ -1,28 +1,33 @@ import { put, - take + takeEvery } from 'redux-saga/effects'; -import whileConnected from './effects/whileConnected'; - import { SEND_POST_BACK } from '../actions/sendPostBack'; import postActivity from '../actions/postActivity'; +import whileConnected from './effects/whileConnected'; export default function* () { - yield whileConnected(function* () { - for (;;) { - const { payload: { value } } = yield take(SEND_POST_BACK); + yield whileConnected(sendPostBackToPostActivity); +} + +function* sendPostBackToPostActivity() { + yield takeEvery( + ({ payload, type }) => ( + type === SEND_POST_BACK + && payload.value + ), + postActivityWithPostBack + ); +} - if (value) { - yield put(postActivity({ - channelData: { - postBack: true - }, - text: typeof value === 'string' ? value : undefined, - type: 'message', - value: typeof value !== 'string' ? value : undefined - })); - } - } - }); +function* postActivityWithPostBack({ payload: { value } }) { + yield put(postActivity({ + channelData: { + postBack: true + }, + text: typeof value === 'string' ? value : undefined, + type: 'message', + value: typeof value !== 'string' ? value : undefined + })); } diff --git a/packages/core/src/sagas/sendTypingOnSetSendBoxSaga.js b/packages/core/src/sagas/sendTypingOnSetSendBoxSaga.js index 49f98664e5..e22015a74b 100644 --- a/packages/core/src/sagas/sendTypingOnSetSendBoxSaga.js +++ b/packages/core/src/sagas/sendTypingOnSetSendBoxSaga.js @@ -7,11 +7,14 @@ import { takeLatest } from 'redux-saga/effects'; -import whileConnected from './effects/whileConnected'; - import { SET_SEND_BOX } from '../actions/setSendBox'; import { SET_SEND_TYPING } from '../actions/setSendTyping'; import postActivity from '../actions/postActivity'; + +import whileConnected from './effects/whileConnected'; + +import sendTypingSelector from '../selectors/sendTyping'; + import sleep from '../utils/sleep'; const SEND_INTERVAL = 3000; @@ -21,32 +24,38 @@ function takeSendTyping(value) { } export default function* () { - yield whileConnected(function* () { - const sendTyping = yield select(({ sendTyping }) => sendTyping); - - if (!sendTyping) { - yield takeSendTyping(true); - } - - for (;;) { - let lastSend = 0; - const task = yield takeLatest(SET_SEND_BOX, function* ({ payload: { text } }) { - if (text) { - const interval = SEND_INTERVAL - Date.now() + lastSend; + yield whileConnected(sendTypingOnSetSendBox); +} - if (interval > 0) { - yield call(sleep, interval); - } +function* sendTypingOnSetSendBox() { + const sendTyping = yield select(sendTypingSelector); + + if (!sendTyping) { + yield takeSendTyping(true); + } + + for (;;) { + let lastSend = 0; + const task = yield takeLatest( + ({ payload, type }) => ( + type === SET_SEND_BOX + && payload.text + ), + function* () { + const interval = SEND_INTERVAL - Date.now() + lastSend; + + if (interval > 0) { + yield call(sleep, interval); + } - yield put(postActivity({ type: 'typing' })); + yield put(postActivity({ type: 'typing' })); - lastSend = Date.now(); - } - }); + lastSend = Date.now(); + } + ); - yield takeSendTyping(false); - yield cancel(task); - yield takeSendTyping(true); - } - }); + yield takeSendTyping(false); + yield cancel(task); + yield takeSendTyping(true); + } } diff --git a/packages/core/src/sagas/startDictateAfterSpeakActivitySaga.js b/packages/core/src/sagas/startDictateAfterSpeakActivitySaga.js index 0aa39ba5e4..2800c66b7c 100644 --- a/packages/core/src/sagas/startDictateAfterSpeakActivitySaga.js +++ b/packages/core/src/sagas/startDictateAfterSpeakActivitySaga.js @@ -1,38 +1,50 @@ import { - cancel, - fork, put, select, - take + takeEvery } from 'redux-saga/effects'; -import whileConnected from './effects/whileConnected'; - import { MARK_ACTIVITY } from '../actions/markActivity'; -import { START_SPEAKING_ACTIVITY } from '../actions/startSpeakingActivity'; -import { STOP_SPEAKING_ACTIVITY } from '../actions/stopSpeakingActivity'; import startDictate from '../actions/startDictate'; -export default function* () { - yield whileConnected(function* (_, userID) { - for (;;) { - yield take(START_SPEAKING_ACTIVITY); +import speakingActivity from '../definitions/speakingActivity'; + +import whileConnected from './effects/whileConnected'; +import whileSpeakIncomingActivity from './effects/whileSpeakIncomingActivity'; - const task = yield fork(startDictateAfterSpeakActivitySaga, userID); +import { ofID as activitiesOfID } from '../selectors/activities'; - yield take(STOP_SPEAKING_ACTIVITY); - yield cancel(task); - } +export default function* () { + yield whileConnected(function* () { + yield whileSpeakIncomingActivity(startDictateAfterSpeakActivity); }); } -function* startDictateAfterSpeakActivitySaga() { - for (;;) { - const { payload: { activityID } } = yield take(({ payload, type }) => type === MARK_ACTIVITY && payload.name === 'speak' && payload.value === false); - const activities = yield select(({ activities }) => activities); +function* startDictateAfterSpeakActivity() { + yield takeEvery( + ({ payload, type }) => ( + type === MARK_ACTIVITY + && payload.name === 'speak' + && payload.value === false + ), + startDictateAfterAllActivitiesSpoken + ); +} + +function* startDictateAfterAllActivitiesSpoken({ payload: { activityID } }) { + const activities = yield select(activitiesOfID(activityID)); + const [spokenActivity] = activities; - if (!activities.some(activity => activity.id !== activityID && activity.channelData && activity.channelData.speak === true)) { - yield put(startDictate()); - } + if ( + spokenActivity + && spokenActivity.inputHint !== 'ignoringInput' + // Checks if there are no more activities that will be synthesis + && !activities.some( + activity => activity.id !== activityID && speakingActivity(activity) + ) + ) { + // We honor input hint based on this article + // https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-add-input-hints?view=azure-bot-service-4.0&tabs=cs + yield put(startDictate()); } } diff --git a/packages/core/src/sagas/startSpeakActivityOnPostActivitySaga.js b/packages/core/src/sagas/startSpeakActivityOnPostActivitySaga.js new file mode 100644 index 0000000000..075e1e9aef --- /dev/null +++ b/packages/core/src/sagas/startSpeakActivityOnPostActivitySaga.js @@ -0,0 +1,26 @@ +import { + put, + takeEvery +} from 'redux-saga/effects'; + +import whileConnected from './effects/whileConnected'; + +import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; +import startSpeakingActivity from '../actions/startSpeakingActivity'; + +export default function* () { + yield whileConnected(startSpeakActivityOnPostActivity); +} + +function* startSpeakActivityOnPostActivity() { + yield takeEvery( + ({ meta, payload, type }) => ( + type === POST_ACTIVITY_PENDING + && meta.method === 'speech' + && payload.activity.type === 'message' + ), + function* () { + yield put(startSpeakingActivity()); + } + ); +} diff --git a/packages/core/src/sagas/stopDictateOnCardAction.js b/packages/core/src/sagas/stopDictateOnCardAction.js deleted file mode 100644 index 63ba7aab58..0000000000 --- a/packages/core/src/sagas/stopDictateOnCardAction.js +++ /dev/null @@ -1,27 +0,0 @@ -import { - put, - take -} from 'redux-saga/effects'; - -import whileConnected from './effects/whileConnected'; - -import stopDictate from '../actions/stopDictate'; -import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; - -export default function* () { - yield whileConnected(function* () { - for (;;) { - // TODO: [P2] We should stop speech input when the user click on anything on a card, including open URL which doesn't generate postActivity - // This functionality was not implemented in v3 - - yield take( - // Currently, there are no actions that are related to card input - // For now, we are using POST_ACTIVITY of a "message" activity - // In the future, if we have an action for card input, we should use that instead - ({ payload, type }) => type === POST_ACTIVITY_PENDING && payload.activity.type === 'message' - ); - - yield put(stopDictate()); - } - }); -} diff --git a/packages/core/src/sagas/stopDictateOnCardActionSaga.js b/packages/core/src/sagas/stopDictateOnCardActionSaga.js new file mode 100644 index 0000000000..8e8e90abe6 --- /dev/null +++ b/packages/core/src/sagas/stopDictateOnCardActionSaga.js @@ -0,0 +1,28 @@ +import { + put, + takeEvery +} from 'redux-saga/effects'; + +import whileConnected from './effects/whileConnected'; + +import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; +import stopDictate from '../actions/stopDictate'; + +export default function* () { + yield whileConnected(stopDictateOnCardAction); +} + +function* stopDictateOnCardAction() { + // TODO: [P2] We should stop speech input when the user click on anything on a card, including open URL which doesn't generate postActivity + // This functionality was not implemented in v3 + + yield takeEvery( + // Currently, there are no actions that are related to card input + // For now, we are using POST_ACTIVITY of a "message" activity + // In the future, if we have an action for card input, we should use that instead + ({ payload, type }) => type === POST_ACTIVITY_PENDING && payload.activity.type === 'message', + function* () { + yield put(stopDictate()); + } + ); +} diff --git a/packages/core/src/sagas/stopSpeakActivityOnInputSaga.js b/packages/core/src/sagas/stopSpeakActivityOnInputSaga.js deleted file mode 100644 index 511c5a4db4..0000000000 --- a/packages/core/src/sagas/stopSpeakActivityOnInputSaga.js +++ /dev/null @@ -1,39 +0,0 @@ -import { - put, - select, - take -} from 'redux-saga/effects'; - -import whileConnected from './effects/whileConnected'; - -import markActivity from '../actions/markActivity'; -import stopSpeakingActivity from '../actions/stopSpeakingActivity'; -import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; -import { SET_SEND_BOX } from '../actions/setSendBox'; -import { START_DICTATE } from '../actions/startDictate'; - -export default function* () { - yield whileConnected(function* () { - for (;;) { - yield take( - ({ payload, type }) => type === START_DICTATE - || (type === SET_SEND_BOX && payload.text && payload.via !== 'speech') - - // We want to stop speaking activity when the user click on a card action - // But currently there are no actions generated out of a card action - // So, right now, we are using best-effort by listening to POST_ACTIVITY_PENDING with a "message" event - || (type === POST_ACTIVITY_PENDING && payload.activity.type === 'message') - ); - - yield put(stopSpeakingActivity()); - - const activities = yield select(({ activities }) => activities); - - for (let activity of activities) { - if (activity.channelData && activity.channelData.speak) { - yield put(markActivity(activity, 'speak', false)); - } - } - } - }); -} diff --git a/packages/core/src/sagas/stopSpeakingActivityOnInputSaga.js b/packages/core/src/sagas/stopSpeakingActivityOnInputSaga.js new file mode 100644 index 0000000000..13c747a974 --- /dev/null +++ b/packages/core/src/sagas/stopSpeakingActivityOnInputSaga.js @@ -0,0 +1,41 @@ +import { + put, + takeEvery +} from 'redux-saga/effects'; + +import whileConnected from './effects/whileConnected'; + +import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; +import { SET_SEND_BOX } from '../actions/setSendBox'; +import { START_DICTATE } from '../actions/startDictate'; +import stopSpeakingActivity from '../actions/stopSpeakingActivity'; + +export default function* () { + yield whileConnected(stopSpeakingActivityOnInput); +} + +function* stopSpeakingActivityOnInput() { + yield takeEvery( + ({ meta, payload, type }) => ( + type === START_DICTATE + + || ( + type === SET_SEND_BOX + && payload.text + ) + + // We want to stop speaking activity when the user click on a card action + // But currently there are no actions generated out of a card action + // So, right now, we are using best-effort by listening to POST_ACTIVITY_PENDING with a "message" event + // We filter out speech because we will call startSpeakingActivity() for POST_ACTIVITY_PENDING dispatched by speech + || ( + type === POST_ACTIVITY_PENDING + && meta.method !== 'speech' + && payload.activity.type === 'message' + ) + ), + function* () { + yield put(stopSpeakingActivity()); + } + ); +} diff --git a/packages/core/src/sagas/submitSendBoxSaga.js b/packages/core/src/sagas/submitSendBoxSaga.js index d57952a229..cf08e5df4f 100644 --- a/packages/core/src/sagas/submitSendBoxSaga.js +++ b/packages/core/src/sagas/submitSendBoxSaga.js @@ -1,25 +1,28 @@ import { put, select, - take + takeEvery } from 'redux-saga/effects'; -import whileConnected from './effects/whileConnected'; - import { SUBMIT_SEND_BOX } from '../actions/submitSendBox'; import sendMessage from '../actions/sendMessage'; import setSendBox from '../actions/setSendBox'; +import whileConnected from './effects/whileConnected'; + +import sendBoxValueSelector from '../selectors/sendBoxValue'; + export default function* () { - yield whileConnected(function* () { - for (;;) { - const { payload: { via } } = yield take(SUBMIT_SEND_BOX); - const sendBoxValue = yield select(({ sendBoxValue }) => sendBoxValue); + yield whileConnected(submitSendBox); +} + +function* submitSendBox() { + yield takeEvery(SUBMIT_SEND_BOX, function* ({ payload: { method } }) { + const sendBoxValue = yield select(sendBoxValueSelector); - if (sendBoxValue) { - yield put(sendMessage(sendBoxValue, via)); - yield put(setSendBox('', 'keyboard')); - } + if (sendBoxValue) { + yield put(sendMessage(sendBoxValue, method)); + yield put(setSendBox('')); } }); } diff --git a/packages/core/src/selectors/activities.js b/packages/core/src/selectors/activities.js new file mode 100644 index 0000000000..033507d02b --- /dev/null +++ b/packages/core/src/selectors/activities.js @@ -0,0 +1,8 @@ +const activities = ({ activities }) => activities + +const of = predicate => state => activities(state).filter(predicate) +const ofID = targetID => of(({ id }) => id === targetID); +const ofType = targetType => of(({ type }) => type === targetType); + +export default activities +export { of, ofID, ofType } diff --git a/packages/core/src/selectors/language.js b/packages/core/src/selectors/language.js new file mode 100644 index 0000000000..a0805cacab --- /dev/null +++ b/packages/core/src/selectors/language.js @@ -0,0 +1 @@ +export default ({ language }) => language diff --git a/packages/core/src/selectors/sendBoxValue.js b/packages/core/src/selectors/sendBoxValue.js new file mode 100644 index 0000000000..1856b80846 --- /dev/null +++ b/packages/core/src/selectors/sendBoxValue.js @@ -0,0 +1 @@ +export default ({ sendBoxValue }) => sendBoxValue diff --git a/packages/core/src/selectors/sendTimeout.js b/packages/core/src/selectors/sendTimeout.js new file mode 100644 index 0000000000..12b4383623 --- /dev/null +++ b/packages/core/src/selectors/sendTimeout.js @@ -0,0 +1 @@ +export default ({ sendTimeout }) => sendTimeout diff --git a/packages/core/src/selectors/sendTyping.js b/packages/core/src/selectors/sendTyping.js new file mode 100644 index 0000000000..c90c9813cb --- /dev/null +++ b/packages/core/src/selectors/sendTyping.js @@ -0,0 +1 @@ +export default ({ sendTyping }) => sendTyping diff --git a/packages/playground/src/index.js b/packages/playground/src/index.js index b7e7a62dea..4756f8148f 100644 --- a/packages/playground/src/index.js +++ b/packages/playground/src/index.js @@ -28,7 +28,14 @@ window.addEventListener('keydown', event => { }); store = createStore( - onErrorResumeNext(() => JSON.parse(window.sessionStorage.getItem(REDUX_STORE_KEY))) + onErrorResumeNext(() => JSON.parse(window.sessionStorage.getItem(REDUX_STORE_KEY))), + ({ dispatch }) => next => action => { + if (action.type === 'DIRECT_LINE/CONNECT_FULFILLED') { + dispatch({ type: 'WEB_CHAT/SEND_EVENT', payload: { name: 'webchat/join', value: { language: window.navigator.language } } }); + } + + return next(action); + } ); store.subscribe(() => { diff --git a/samples/05.c.presentation-mode-styling/index.html b/samples/05.c.presentation-mode-styling/index.html index 81dc342ede..4c050797eb 100644 --- a/samples/05.c.presentation-mode-styling/index.html +++ b/samples/05.c.presentation-mode-styling/index.html @@ -30,8 +30,17 @@ const res = await fetch('https://webchat-mockbot.azurewebsites.net/directline/token', { method: 'POST' }); const { token } = await res.json(); + const store = window.WebChat.createStore({}, ({ dispatch }) => next => action => { + // The following code is for convenience only. It not required for production. + if (action.type === 'DIRECT_LINE/CONNECT_FULFILLED') { + dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'card inputs' } }); - const store = window.WebChat.createStore(); + // Although suggested actions were sent from the bot, we hide the send box, thus, all suggested actions are also hidden + dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'suggested-actions' } }); + } + + return next(action); + }); window.WebChat.renderWebChat({ directLine: window.WebChat.createDirectLine({ token }), @@ -41,9 +50,6 @@ hideSendBox: true } }, document.getElementById('webchat')); - - setTimeout(() => store.dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'card inputs' } }), 1000); - setTimeout(() => store.dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'suggested-actions' } }), 1500); })().catch(err => console.error(err)); diff --git a/samples/07.customization-timestamp-grouping/index.html b/samples/07.customization-timestamp-grouping/index.html index b4311e7389..db77c6e263 100644 --- a/samples/07.customization-timestamp-grouping/index.html +++ b/samples/07.customization-timestamp-grouping/index.html @@ -87,8 +87,14 @@ const res = await fetch('https://webchat-mockbot.azurewebsites.net/directline/token', { method: 'POST' }); const { token } = await res.json(); + const store = window.WebChat.createStore({}, ({ dispatch }) => next => action => { + // The following code is for convenience only. It not required for production. + if (action.type === 'DIRECT_LINE/CONNECT_FULFILLED') { + dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'timestamp' } }); + } - const store = window.WebChat.createStore(); + return next(action); + }); window.WebChat.renderWebChat({ directLine: window.WebChat.createDirectLine({ token }), @@ -101,9 +107,8 @@ store }, document.getElementById('webchat')); - // The following code is for convenient when trying out this sample, not required for production. + // The following code is for convenience only. It not required for production. document.querySelector('#webchat > *').focus(); - setTimeout(() => store.dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'timestamp' } }), 1000); })().catch(err => console.error(err)); diff --git a/samples/11.customization-redux-actions/index.html b/samples/11.customization-redux-actions/index.html index 9523f0e31f..8e56685c57 100644 --- a/samples/11.customization-redux-actions/index.html +++ b/samples/11.customization-redux-actions/index.html @@ -35,8 +35,7 @@ const store = window.WebChat.createStore( {}, ({ dispatch }) => next => action => { - // TODO: [P4] Investigate why we need to wait until DIRECT_LINE/CONNECTION_STATUS_UPDATE, instead of DIRECT_LINE/CONNECT_FULFILLED. - if (action.type === 'DIRECT_LINE/CONNECTION_STATUS_UPDATE' && action.payload.connectionStatus === 2) { + if (action.type === 'DIRECT_LINE/CONNECT_FULFILLED') { // After connected, we will send a message by dispatching a Redux action. dispatch({ type: 'WEB_CHAT/SEND_MESSAGE', payload: { text: 'sample:backchannel' } }); } else if (action.type === 'DIRECT_LINE/INCOMING_ACTIVITY') { diff --git a/samples/15.d.backchannel-send-welcome-event/index.html b/samples/15.d.backchannel-send-welcome-event/index.html new file mode 100644 index 0000000000..785644fef0 --- /dev/null +++ b/samples/15.d.backchannel-send-welcome-event/index.html @@ -0,0 +1,59 @@ + + + + Web Chat: Send welcome event + + + + + +
+ + +