From d65e70e7e7363cf4d8373a398f72a5b9377d4573 Mon Sep 17 00:00:00 2001 From: Karel Tucek Date: Thu, 30 Jul 2026 17:01:55 +0200 Subject: [PATCH] ConversationContext: initial implementation. --- README.md | 2 + readme/architecture.md | 234 ++++++++++++++++++ readme/configuration.md | 17 ++ readme/usage.md | 5 + readme/webchat-context.md | 43 ++++ src/app.tsx | 103 ++++++-- src/components/question-dialog.tsx | 58 +++++ src/config/config.ts | 5 + src/models/enums/webchat-context-kind.ts | 8 + .../context/webchat-context-interface.ts | 25 ++ ...at-conversation-start-payload-interface.ts | 14 ++ .../context/webchat-context-service.ts | 116 +++++++++ .../conversation/conversation-service.tsx | 72 ++++-- src/models/services/store/store-service.ts | 45 +++- src/spinoco-webchat-plugin.tsx | 3 + src/styles/app.scss | 4 + src/styles/components/popover.scss | 2 +- src/styles/components/question-dialog.scss | 74 ++++++ 18 files changed, 793 insertions(+), 37 deletions(-) create mode 100644 readme/architecture.md create mode 100644 readme/webchat-context.md create mode 100644 src/components/question-dialog.tsx create mode 100644 src/models/enums/webchat-context-kind.ts create mode 100644 src/models/interfaces/context/webchat-context-interface.ts create mode 100644 src/models/interfaces/conversation/chat-conversation-start-payload-interface.ts create mode 100644 src/models/services/context/webchat-context-service.ts create mode 100644 src/styles/components/question-dialog.scss diff --git a/README.md b/README.md index ceb5233..eb2c4cb 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,5 @@ See [examples](https://spinoco.github.io/webchat-plugin/). - Lear how to [use](readme/usage.md) webchat plugin - Lear how to [configure](readme/configuration.md) webchat plugin - Lear how to [develop](readme/development.md) features for webchat plugin +- Lear how to [pass webchat context](readme/webchat-context.md) in the webchat plugin +- Lear how this repository is [structured](readme/architecture.md) and how the plugin works diff --git a/readme/architecture.md b/readme/architecture.md new file mode 100644 index 0000000..3468016 --- /dev/null +++ b/readme/architecture.md @@ -0,0 +1,234 @@ +# Architecture + +How this repository is put together and how the plugin works at runtime. + +## What is built here + +Two artifacts come out of one source tree: + +| Artifact | Built by | Entry | Purpose | +|---|---|---|---| +| **The plugin** | `npm run build:plugin` (`vite.config.plugin.ts`) | `src/spinoco-webchat-plugin.tsx` | Single js file dropped on a customer page. Css is inlined into the js (`vite-plugin-css-injected-by-js`), so there is nothing else to load. | +| **The showcase** | `npm run build:web` (`vite.config.web.ts`) | `index.html` + `examples/*.html` | Github pages site that demonstrates the themes, served from . | + +The plugin is a wrapper around **`botframework-webchat`** (Microsoft Bot Framework Web Chat) that +- brands it from a json configuration, +- hosts it in a trigger / window / popover shell of our own, +- and speaks to the Spinoco backend through **DirectLine**. + +```mermaid +flowchart LR + page["Customer page
with the plugin host element"] --> plugin["spinoco-webchat-plugin.js"] + plugin --> shell["Own shell
trigger, header, popover,
question dialog, feedback"] + plugin --> webchat["botframework-webchat"] + webchat --> dl["DirectLine"] + dl --> azure["Azure Bot Services"] + azure --> spinoco["Spinoco backend"] + plugin -. fetches .-> conf["configuration json
named after the client id"] +``` + +## Repository layout + +| Path | Content | +|---|---| +| `src/spinoco-webchat-plugin.tsx` | Bootstrap: reads the host element, loads the configuration, wires the services, mounts `App`. | +| `src/app.tsx` | The whole ui state machine: trigger, chat window, popover, question dialog, feedback form. | +| `src/components/` | Presentational components (`trigger`, `header`, `popover`, `question-dialog`, `feedback-form/*`, `icons`). | +| `src/middlewares/` | `botframework-webchat` middlewares (avatars, typing indicator) + `device-query` (fullscreen detection). | +| `src/models/services/` | All behaviour that is not rendering, see [Services](#services). | +| `src/models/interfaces/configuration/` | Typed shape of the customer configuration json. | +| `src/models/interfaces/`, `models/dtos/`, `models/enums/` | Data shapes, value objects and enums. | +| `src/models/styles/` | `create*CssProperties` / `create*CssVariables` - configuration to inline styles and css variables. | +| `src/styles/` | Scss: `app.scss` (breakpoints, `rem()`, imports) + one partial per component + `features/` toggles. | +| `src/config/config.ts` | Non-configurable constants: class names, data attribute names, style fallbacks, api urls. | +| `src/config/style-options-config.ts` | Defaults handed to `botframework-webchat` `styleOptions`. | +| `public/*.json` | Example configurations (`basic`, `slevomat`, `border-only`, `lottie`, `mockbot`). | +| `examples/*.html` | Showcase pages, one per example configuration. | +| `readme/` | `usage.md` (host page integration), `configuration.md` (every config key), `development.md`, this file. | + +## Bootstrap + +```mermaid +sequenceDiagram + participant page as Customer page + participant boot as spinoco-webchat-plugin.tsx + participant dom as ChatDomService + participant rules as RuleService + participant app as App + page->>boot: script tag loaded + boot->>dom: read the plugin host element + dom-->>boot: client id or config url, customer, avatars, popover, locale + boot->>boot: fetch configuration json + boot->>rules: mayDisplayForCurrentDomain + rules-->>boot: false means warn and stop + boot->>boot: no directLine secret means mockbot + boot->>app: render App with the services + app->>app: useEffect - context, auto open, popover, url navigation +``` + +- The host element **must** carry `data-client-id` (configuration is fetched from `${VITE_WEBCHAT_API_URL}/.json`) or `data-config-url`. Otherwise the bootstrap throws. +- `window.spinocoWebchatPlugin` is the `GlobalEventService` instance - the public api of the plugin for the host page. + +## Services + +All services are constructed once in the bootstrap and handed to `App` as props. + +| Service | Responsibility | +|---|---| +| `ChatDomService` (`DomService`) | The only place that touches the host element: data attributes, `getWindow()`. Produces `CustomerDto`, `BotDto`, `PopoverDto`. | +| `ConversationService` | Owns the DirectLine. Starts a conversation (restoring the stored id), replaces it, ends the previous one, recovers from a dead conversation. | +| `StoreService` | Owns the `botframework-webchat` redux store and its middleware - everything we inject into or read from the activity stream. | +| `ChatStorage` (`Storage`) | Typed `localStorage` access, keys in `ChatStorageKeys`. | +| `LocaleService` | Locale for `botframework-webchat`: `data-locale`, else `navigator.languages`, else `en`. | +| `RuleService` | Domain allow / deny list from `configuration.rules` - decides whether the plugin renders at all. | +| `UrlNavigationService` | Hash commands (`#sp-webchat;open`), on load and on `hashchange`. | +| `WebchatContextService` | Structured context in `?webchat_context=` (base64url json), see [usage.md](usage.md). | +| `GlobalEventService` | Callback bridge between the host page (`window.spinocoWebchatPlugin`) and `App`. | + +## What DirectLine is + +DirectLine is the channel that a browser client uses to talk to a bot hosted in Azure Bot +Services. It is a small http protocol - a conversation is opened, activities (messages, events, +typing notifications) are posted into it and received back from it. Spinoco is not addressed +directly: the chat gateway of a Spinoco instance sits behind that bot, so from the plugin the +whole backend is just "the bot on the other end of the DirectLine". + +- The protocol is implemented by `botframework-directlinejs` (a transitive dependency). +- We never call it ourselves - `createDirectLine()` from `botframework-webchat` builds the object in `ConversationService`, and that object is handed to `ReactWebChat` as its `directLine` prop. Everything after that happens inside `botframework-webchat`. +- Transport is a websocket when the browser has one, with http polling as a fallback. + +What `ConversationService` passes to it: + +| Option | Value here | Meaning | +|---|---|---| +| `secret` | `configuration.directLine.secret` | Pairs the plugin with the Chat gateway of one Spinoco instance. It is part of the configuration json, so it is visible to anyone on the page - it grants nothing but the ability to talk to that bot. | +| `conversationId` | stored id, or `undefined` | Given, the previous conversation is resumed. Missing, a brand new one is opened and DirectLine assigns the id. | +| `watermark` | `"0"` | Position in the activity stream to receive from. Zero means from the very beginning, which is what replays the transcript when a conversation is resumed. | +| `domain` | `europe` / `india` / unset | Regional endpoint. Unset means the global `directline.botframework.com`. The region also shows up in the conversation id (`...-eu`, `...-us`). | + +```mermaid +sequenceDiagram + participant cs as ConversationService + participant dl as DirectLine + participant bot as Azure Bot Services + cs->>dl: createDirectLine with secret, watermark, conversation id + alt no stored conversation id + dl->>bot: POST conversations + bot-->>dl: new conversation id + else stored conversation id + dl->>bot: GET conversations by id from watermark + bot-->>dl: the activities so far + end + dl-->>cs: connectionStatus stream, id is stored in localStorage + dl->>bot: POST activities - messages and events like webchat/join + bot-->>dl: activities of the bot over the websocket +``` + +- The **conversation id is the identity of the chat**. Keeping it in `localStorage` is the whole mechanism behind "the conversation survives navigation and reloads", and dropping it is how a conversation is replaced. +- `connectionStatus$` is the only part of the object the plugin subscribes to - to learn the assigned id and to notice that the line is up. +- `end()` terminates the line; the plugin calls it when it replaces a conversation, so the old line stops receiving anything. +- With `useMockbot` (no secret configured) a token for Microsoft's public mockbot is fetched instead and the stored id is ignored - every reload starts a fresh conversation against the demo bot. + +## Conversation lifecycle + +A conversation is the pair *(DirectLine, redux store)*. The store holds the transcript, so a new +conversation always needs a new store - and the chat component has to be re-mounted for it, which +is what the `generation` counter in `App` is for. + +```mermaid +stateDiagram-v2 + [*] --> Closed + Closed --> Loading: openChat - trigger, embedded,
stored state or url context + Loading --> Opened: connection fulfilled
plus the scroll animation delay + Opened --> Closed: header close + Opened --> Loading: replaceConversation
question dialog confirmed + Loading --> Loading: window error
conversation not found +``` + +- **Start / restore** - `startConversation()` creates the DirectLine with the stored `conversationId`; the id is (re)stored from `connectionStatus$`, but only while that DirectLine is still the current one. +- **Replace** - `replaceConversation()` ends the current DirectLine, drops the stored id and starts a new one. `App` recreates the store first, so the transcript of the old conversation goes away with it. +- **Recover** - a `window` error with `Conversation not found` / `Token not valid for this conversation` triggers the same replacement. +- **Mockbot** - when there is no `directLine.secret`, a token is fetched from the public mockbot and the stored conversation id is ignored. Handy for development without a Spinoco instance. + +## Activity flow + +Everything we add to or fix in the activity stream lives in the `StoreService` middleware. + +```mermaid +flowchart TD + subgraph store["StoreService middleware"] + connect["connect fulfilled"] --> join["send webchat/join
value.contact holds the customer data
value.payload holds the start payload"] + post["post activity"] --> uri["channelData.webPageUri
set to the current page url"] + incoming["incoming activity"] --> role["fix from.role of own attachments
that are read from history"] + feedback["feedback action"] --> form["open the feedback form"] + end + join --> backend["Spinoco backend"] + uri --> backend +``` + +- `webchat/join` is how the backend learns who is on the other side (`value.contact`) and, when the conversation was started from a url context, which workflow to start (`value.payload`, mirrors the backend `ChatConversationStartPayload`). +- The start payload is bound to the store it was created with, so a conversation that is being replaced can never send it. + +## State and storage + +| Where | Value | Notes | +|---|---|---| +| `AppState` (`App`) | `loading` / `loaded` / `popover` / `feedback` | Which overlay is on screen. | +| `ChatState` (`App`) | `closed` / `loading` / `opened` | Visibility of the chat window, mirrored into storage. | +| `localStorage swp-conversation-id` | DirectLine conversation id | Lets the conversation survive navigation and reloads. | +| `localStorage swp-chat-state` | Last `ChatState` | The window re-opens on the next page unless the device is fullscreen (mobile). | +| `localStorage swp-app-state` | - | Declared in `ChatStorageKeys` but never written, dead key. | + +## Styling + +Three layers, in this order of preference: + +```mermaid +flowchart LR + conf["configuration json"] --> vars["createWrapperCssVariables
css variables on the plugin root"] + conf --> inline["create CssProperties helpers
inline styles per component"] + conf --> opts["createStyleOptions
options for botframework-webchat"] + vars --> scss["scss partials"] + inline --> dom["component markup"] + opts --> webchat["botframework-webchat internals"] +``` + +- **Css variables** for anything shared - they are set once on the plugin root and consumed from scss. This is what a new component should reach for first, so it follows the theme for free, see [configuration.md](configuration.md) for the ones that carry the configuration. +- **Inline styles** (`create*CssProperties`) where a value belongs to one element only, or where scss cannot express it. +- **`styleOptions`** for everything inside `botframework-webchat` (bubbles, send box, avatars, root size). +- Class names are prefixed `swp-` and declared in `config.classes` when they are referenced from tsx. +- Scss conventions: one partial per component imported from `src/styles/app.scss`, `rem()` helper, `@include tablet / mobile / low-height-screen` breakpoints, stylelint with `sass-guidelines` + rational property order. +- Optional looks are `features/*.scss`, toggled by `configuration.features` through `createChatBoxWrapperClasses`. + +## Host page integration points + +| Mechanism | Used for | +|---|---| +| `data-*` attributes on the host element | Client id / config url, customer identity, avatars, popover texts, locale. | +| `window.spinocoWebchatPlugin` | `openChat()`, `showPopover(...)`, `showFeedback()`. | +| `#sp-webchat;` hash | `open` - opening the chat from a plain link. | +| `?webchat_context=` | Starting a conversation with a hinted workflow and parameters. | + +See [usage.md](usage.md) for the details of each. + +## Build, ci and deployment + +| Command | Result | +|---|---| +| `npm run dev` | Vite dev server on `:4444`, serves the showcase pages from source. | +| `npm run build` | `build:web` then `build:plugin`, both into `dist/` (`emptyOutDir: false` on the second, so it adds to the first). | +| `npm run lint` | `eslint src --max-warnings=0` + `stylelint src/styles/**/*.scss`. | + +- `dist/assets/*` is the showcase, `dist/dist/spinoco-webchat-plugin.js` is the plugin itself. +- `.github/workflows/github-actions.yml`: install -> lint -> build -> deploy to github pages, `main` and `staging` branches have their own environments. `.gitlab-ci.yml` mirrors install / lint / build. +- Base path is `/webchat-plugin` in production builds and is injected into the html through `vite-plugin-ejs` (`<%= basePath %>`). + +## Gotchas + +- **`--legacy-peer-deps` is mandatory** - `vite-plugin-ejs` demands a newer vite than the one pinned here, so a plain `npm install` / `npm ci` fails. +- **Tailwind does not reach the plugin** - `tailwind.config.cjs` scans only `index.html` and `examples/*.html`, so utility classes written in `src/*.tsx` (`Popover`, `FeedbackForm`) are not in the built css. New components should use scss, not tailwind. +- **The chat window has no size of its own** - it is sized by the mounted `botframework-webchat` (`styleOptions.rootWidth/rootHeight`, default 400x500). Anything drawn before the chat mounts has to bring its own dimensions (see the loader), or wait for it to mount (see the question dialog). +- **The transcript can only be dropped by recreating the store** - the `botframework-webchat` activities reducer never clears on reconnect. +- **`React.StrictMode` doubles the mount effects in development** - the initial `openChat()` runs twice and two conversations are created. Production is unaffected. +- **The feedback form is a demo** - the instance id is hardcoded in `StoreService` (`TODO`) and the configuration is fetched from `VITE_FEEDBACK_API_URL`. +- **There are no automated tests** in the repository; `lint` and the type check (`tsc` as part of the build) are the only gates. diff --git a/readme/configuration.md b/readme/configuration.md index ad841d8..a205c10 100644 --- a/readme/configuration.md +++ b/readme/configuration.md @@ -28,6 +28,23 @@ These properties are available in root of the configuration document | borderColor | string | `--swp-color-primary` | Color of the border (i.e. in input element) | | subtle | string | `--swp-color-secondary` | Color of the text that shall not be highlighted i.e. Placeholder text, message Timestamp | +### Css variables published by the plugin + +The base properties above, and the radius of the chat window, are published as css variables on the +root of the plugin. They may be referenced from the configuration (i.e. +`borderBottom: 1px solid var(--border-color)`) and they are what the parts of the plugin that have +no configuration of their own are styled with. + +| Variable | Holds | +|-----------------------------|----------------------------------------------| +| `--swp-color-primary` | `primaryColor` | +| `--swp-color-secondary` | `secondaryColor` | +| `--swp-color-primary-hover` | `primaryColorHover`, falls back to `primaryColor` | +| `--border-color` | `borderColor`, falls back to `primaryColor` | +| `--wrapper-border-radius` | `root.borderRadius` | + +Individual parts of the plugin declare further variables of their own on top of these. + ### Variables For complex configurations, variables are useful way how to ensure consistent look and feel across the chat window. diff --git a/readme/usage.md b/readme/usage.md index 4182d1f..fb78fcc 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -45,6 +45,11 @@ for example, when customer clicks on a link. You can use the following code: Once user clicks on the link, chat window will be opened. +## How to start a chat with a structured context + +A link may carry the workflow to start, the question to ask and parameters for the workflow, see +[webchat context](webchat-context.md). + ### How to open popover - Call method bellow from javascript. You can test it from browser console. diff --git a/readme/webchat-context.md b/readme/webchat-context.md new file mode 100644 index 0000000..91fab1d --- /dev/null +++ b/readme/webchat-context.md @@ -0,0 +1,43 @@ +# How to start a chat with a structured context + +The page may be entered with the ```webchat_context``` query parameter, holding base64url encoded +json: + +```javascript +{ + wfId: "2c1f5b9e4a7d8c30" // workflow to start, verified by the backend, optional + , question: "Start a new chat about order A-4711?" + , kind: "YesNo" // the only kind so far + , params: { orderId: "A-4711" } // handed over to the workflow, optional + , yesLabel: "Start a new chat" // optional, defaults to Yes + , noLabel: "Keep the current one" // optional, defaults to No +} +``` + +With no conversation running, the chat opens and starts the conversation of the context right away. +With one running, the question is asked in the chat window first - dismissed nothing happens, +confirmed the running conversation is replaced by a new one. The parameter is then removed from the +url, so the user is not asked again on a reload. + +Mind that ```btoa``` alone breaks on diacritics, the json has to be encoded as utf-8 first. + +## How to test it + +On the [dev server](development.md). The mockbot stores no conversation id, so seed one to get the +question asked - open the example, run the snippet in the console of the browser, open the example +again with the context: + +``` +http://localhost:4444/webchat-plugin/examples/mockbot.html +``` + +```javascript +localStorage.setItem("swp-conversation-id", "test-conversation") +``` + +``` +http://localhost:4444/webchat-plugin/examples/mockbot.html?webchat_context=eyJ3ZklkIjoiMmMxZjViOWU0YTdkOGMzMCIsInF1ZXN0aW9uIjoiU3RhcnQgYSBuZXcgY2hhdCBhYm91dCBvcmRlciBBLTQ3MTE_Iiwia2luZCI6Illlc05vIiwicGFyYW1zIjp7Im9yZGVySWQiOiJBLTQ3MTEifSwieWVzTGFiZWwiOiJTdGFydCBhIG5ldyBjaGF0Iiwibm9MYWJlbCI6IktlZXAgdGhlIGN1cnJlbnQgb25lIn0 +``` + +Confirming the question drops the seeded id and the mockbot stores none of its own, so seed it again +for another round. ```localStorage.clear()``` resets everything. diff --git a/src/app.tsx b/src/app.tsx index da69f5c..da02fb4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ConfigurationInterface } from "./models/interfaces/configuration/configuration-interface"; import ReactWebChat from "botframework-webchat"; import { DirectLine } from "botframework-directlinejs"; +import { Store } from "redux"; import { createStyleOptions } from "./models/styles/create-style-options"; import { botTypingIndicatorMiddleware } from "./middlewares/bot-typing-indicator-middleware"; import { Header } from "./components/header"; @@ -27,6 +28,10 @@ import { ChatState } from "./models/enums/chat-state"; import { createChatBoxLoaderWrapperCssVariables } from "./models/styles/create-chat-box-loader-wrapper-css-variables"; import { ChatStorage } from "./models/services/storage/chat-storage"; import { UrlNavigationService } from "./models/services/dom/url-navigation-service"; +import { WebchatContextService } from "./models/services/context/webchat-context-service"; +import { WebchatContextInterface } from "./models/interfaces/context/webchat-context-interface"; +import { ChatConversationStartPayloadInterface } from "./models/interfaces/conversation/chat-conversation-start-payload-interface"; +import { QuestionDialog } from "./components/question-dialog"; interface AppProps { chatStorage: ChatStorage; @@ -39,6 +44,7 @@ interface AppProps { storeService: StoreService; globalEventService: GlobalEventService; urlNavigationService: UrlNavigationService; + webchatContextService: WebchatContextService; isFullScreen: boolean; } @@ -47,18 +53,46 @@ interface PopoverInterface { buttonLabel?: string; } +interface ConversationInterface { + /** Direct line of the conversation. */ + directLine: DirectLine; + + /** Store that holds the state (ie. the transcript) of the conversation. */ + store: Store; + + /** + * Distinguishes the conversations from each other, so the chat is re-mounted whenever the + * conversation is replaced and does not carry over anything of the previous one. + */ + generation: number; +} + export const App: React.FC = (props) => { const [appState, setAppState] = useState(AppState.Loading); const [chatState, setChatState] = useState(ChatState.Closed); - const [directLine, setDirectLine] = useState(); + const [conversation, setConversation] = useState(); const [popover, setPopover] = useState(); const [hasConversationStarted, setHasConversationStarted] = useState(false); const [feedbackConfiguration, setFeedbackConfiguration] = useState(); + // context that was handed over in the url, that the user has to confirm before we act on it + const [contextToConfirm, setContextToConfirm] = useState(); + + // the configuration and the bot do not change once the plugin is loaded, while a new identity + // of these props re-renders the whole chat - which every state of this component would do + const styleOptions = useMemo(() => createStyleOptions(props.configuration), []); + const avatarMiddleware = useMemo(() => createAvatarMiddleware(props.bot), []); + props.conversationService.onDirectLineCreated = (directLine) => { - setDirectLine(directLine); + // the store belongs to the conversation, so both are handed over to the chat at once, + // otherwise the chat could render the store of one conversation with the direct line of another + setConversation((current) => ({ + directLine, + store: props.storeService.store, + generation: (current?.generation ?? 0) + 1, + })); }; props.storeService.onConversationLoaded = () => { @@ -66,9 +100,21 @@ export const App: React.FC = (props) => { setChatState(ChatState.Opened); }; - const openChat = async () => { + /** + * Opens the chat on a conversation. + * + * The conversation that is already running is resumed, unless it is to be replaced - then it + * is dropped together with its transcript and a brand new one is started in its place. + */ + const openChat = async (startPayload?: ChatConversationStartPayloadInterface, replaceRunning = false) => { setChatState(ChatState.Loading); - await props.conversationService.startConversation(); + if (startPayload) { + // the payload belongs to the conversation we are about to start, so it goes with its store + props.storeService.recreateStore(startPayload); + } + await (replaceRunning + ? props.conversationService.replaceConversation() + : props.conversationService.startConversation()); setHasConversationStarted(true); }; @@ -108,13 +154,26 @@ export const App: React.FC = (props) => { }; useEffect(() => { + const context = props.webchatContextService.getContext(); + // there is no conversation to be replaced, so the conversation of the context may be + // started right away, otherwise the user has to confirm that we may replace it + const hasConversation = props.conversationService.hasStoredConversation(); + const startPayload = + context && !hasConversation ? props.webchatContextService.createStartPayload(context) : undefined; + + if (context && hasConversation) { + setContextToConfirm(context); + } + // we are opening chat on the new page only if it was opened before and if the chat is not in fullscreen mode const shallOpenChat = !props.isFullScreen && props.chatStorage.getChatState() == ChatState.Opened; - if (shallOpenChat || props.configuration.features?.embedded) { - openChat(); + // the question of a context is asked inside the chat window, so the window is opened either way + if (shallOpenChat || props.configuration.features?.embedded || context) { + openChat(startPayload); } - if (props.popover.shouldShowPopover()) { + // the popover must not compete with the question we are about to ask the user + if (!context && props.popover.shouldShowPopover()) { props.globalEventService.showPopover( props.popover.label as string, props.popover.buttonLabel, @@ -169,24 +228,25 @@ export const App: React.FC = (props) => { }} /> - {directLine && ( + {conversation && ( )} {chatState === ChatState.Loading && (
= (props) => {
)} + + {/* the dialog covers the chat, so it is shown once the chat gives the window its size */} + {contextToConfirm && conversation && ( + { + const startPayload = props.webchatContextService.createStartPayload(contextToConfirm); + setContextToConfirm(undefined); + openChat(startPayload, true); + }} + onNo={() => setContextToConfirm(undefined)} + /> + )} {appState === AppState.Feedback && feedbackConfiguration && ( diff --git a/src/components/question-dialog.tsx b/src/components/question-dialog.tsx new file mode 100644 index 0000000..376614d --- /dev/null +++ b/src/components/question-dialog.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useRef } from "react"; +import { config } from "../config/config"; + +const QUESTION_ELEMENT_ID = "swp-question-dialog-question"; + +interface QuestionDialogProps { + question: string; + /** Label of the confirming button, defaults to the one from the configuration. */ + yesLabel?: string; + /** Label of the dismissing button, defaults to the one from the configuration. */ + noLabel?: string; + onYes: () => void; + onNo: () => void; +} + +/** + * Dialog that asks the user a question that is answered by yes / no. + * + * It is displayed over the chat window it is rendered in. Styled by + * `styles/components/question-dialog.scss`, which takes the primary color and the radius of the + * plugin from its css variables, so the dialog needs no style properties of its own. + */ +export const QuestionDialog: React.FC = ({ question, yesLabel, noLabel, onYes, onNo }) => { + const confirmButton = useRef(null); + + // the dialog interrupts the user, so it takes the focus over and can be dismissed by escape + useEffect(() => { + confirmButton.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onNo(); + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + return ( +
+
+

{question}

+ +
+ + + +
+
+
+ ); +}; diff --git a/src/config/config.ts b/src/config/config.ts index b6ee02a..32994ee 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,6 +5,7 @@ export const config = { groupTimestamp: 3, // timestamp grouping https://microsoft.github.io/BotFramework-WebChat/05.custom-components/a.timestamp-grouping/?ts=default defaultLanguage: "en", wrapperElementHtmlId: "spinoco-webchat-plugin", + contextQueryParam: "webchat_context", attributes: { clientId: "data-client-id", configUrl: "data-config-url", @@ -28,6 +29,10 @@ export const config = { delay: "data-popover-delay", }, }, + questionDialog: { + yesLabel: "Yes", + noLabel: "No", + }, feedbackApiUrl: import.meta.env.VITE_FEEDBACK_API_URL, classes: { chatWrapper: "swp-chat-wrapper", diff --git a/src/models/enums/webchat-context-kind.ts b/src/models/enums/webchat-context-kind.ts new file mode 100644 index 0000000..f625388 --- /dev/null +++ b/src/models/enums/webchat-context-kind.ts @@ -0,0 +1,8 @@ +/** + * Kind of the interaction that shall be performed with the user, when a webchat context + * is handed over to the plugin. + */ +export enum WebchatContextKind { + /** Ask the question of the context and let the user answer it with yes / no. */ + YesNo = "YesNo", +} diff --git a/src/models/interfaces/context/webchat-context-interface.ts b/src/models/interfaces/context/webchat-context-interface.ts new file mode 100644 index 0000000..9ec1091 --- /dev/null +++ b/src/models/interfaces/context/webchat-context-interface.ts @@ -0,0 +1,25 @@ +import { WebchatContextKind } from "../../enums/webchat-context-kind"; + +/** + * Structured context that may be handed over to the plugin in the url of the page, + * see the `WebchatContextService`. + */ +export interface WebchatContextInterface { + /** Workflow that we are hinted to start. Optional, the backend verifies it before it starts it. */ + wfId?: string; + + /** Question that is presented to the user, already localised by the producer of the context. */ + question: string; + + /** Kind of the interaction that shall be performed with the user. */ + kind: WebchatContextKind; + + /** Rich parameters that shall be handed over to the started workflow. */ + params?: Record; + + /** Label of the confirming button, already localised by the producer of the context. */ + yesLabel?: string; + + /** Label of the dismissing button, already localised by the producer of the context. */ + noLabel?: string; +} diff --git a/src/models/interfaces/conversation/chat-conversation-start-payload-interface.ts b/src/models/interfaces/conversation/chat-conversation-start-payload-interface.ts new file mode 100644 index 0000000..6d2b651 --- /dev/null +++ b/src/models/interfaces/conversation/chat-conversation-start-payload-interface.ts @@ -0,0 +1,14 @@ +/** + * Structured data that is handed over to the backend when a conversation is started. + * + * This mirrors its backend counterpart + * `spinoco.services.communication.messages.ChatConversationStartPayload`, + * the naming of the fields must be kept in sync with it. + */ +export interface ChatConversationStartPayloadInterface { + /** Workflow that we were hinted to start. The backend verifies it before it starts it. */ + hintedWfId?: string; + + /** Rich parameters that are handed over to the workflow. */ + params?: Record; +} diff --git a/src/models/services/context/webchat-context-service.ts b/src/models/services/context/webchat-context-service.ts new file mode 100644 index 0000000..7f0bbcc --- /dev/null +++ b/src/models/services/context/webchat-context-service.ts @@ -0,0 +1,116 @@ +import { config } from "../../../config/config"; +import { WebchatContextKind } from "../../enums/webchat-context-kind"; +import { WebchatContextInterface } from "../../interfaces/context/webchat-context-interface"; +import { ChatConversationStartPayloadInterface } from "../../interfaces/conversation/chat-conversation-start-payload-interface"; +import { DomService } from "../dom/dom-service"; + +/** + * This service is used to read the structured context that may be handed over to the page + * that hosts the chat. + * + * The context is passed in the `webchat_context` query parameter as base64url encoded json: + * + * ``` + * webchat_context=base64url({ + * wfId: "..." // optional + * , question: "You sure you want to start newChat?" + * , kind: "YesNo" + * , params: {} + * }) + * ``` + * + * Once the context is read, the parameter is removed from the url of the page, so the user + * won't be asked again when the page is reloaded. + */ +export class WebchatContextService { + private readonly window: Window; + private context?: WebchatContextInterface; + private read = false; + + constructor(domService: DomService) { + this.window = domService.getWindow(); + } + + /** + * The context that was handed over in the url of the page, if any. + * + * The very first call reads the context from the url and removes it from there, + * any subsequent call returns the same context again. + */ + getContext(): WebchatContextInterface | undefined { + if (!this.read) { + this.read = true; + this.context = this.readFromUrl(); + } + + return this.context; + } + + /** + * Creates the payload that is sent to the backend when the conversation of the given + * context is started. + */ + createStartPayload(context: WebchatContextInterface): ChatConversationStartPayloadInterface { + return { + hintedWfId: context.wfId, + params: context.params, + }; + } + + /** Reads and validates the context from the url of the page, removing it from the url. */ + private readFromUrl(): WebchatContextInterface | undefined { + const url = new URL(this.window.location.href); + const encoded = url.searchParams.get(config.chat.contextQueryParam); + + if (!encoded) { + return undefined; + } + + this.removeFromUrl(url); + + return this.parse(encoded); + } + + /** Removes the context parameter from the url of the page, keeping the rest of the url intact. */ + private removeFromUrl(url: URL): void { + try { + url.searchParams.delete(config.chat.contextQueryParam); + this.window.history.replaceState(this.window.history.state, "", url.toString()); + } catch (error) { + console.warn("Failed to remove the webchat context from the url", error); + } + } + + /** Decodes the context and verifies that we can actually work with it. */ + private parse(encoded: string): WebchatContextInterface | undefined { + let parsed: Partial; + + try { + parsed = JSON.parse(this.decodeBase64Url(encoded)); + } catch (error) { + console.warn("Failed to decode the webchat context, ignoring it", error); + return undefined; + } + + if (typeof parsed?.question !== "string" || parsed.question.length === 0) { + console.warn("The webchat context has no question, ignoring it", parsed); + return undefined; + } + + if (!Object.values(WebchatContextKind).includes(parsed.kind as WebchatContextKind)) { + console.warn(`The webchat context is of an unknown kind '${parsed.kind}', ignoring it`, parsed); + return undefined; + } + + return { ...parsed, question: parsed.question, kind: parsed.kind as WebchatContextKind }; + } + + /** Decodes base64url (RFC 4648, section 5) encoded utf-8 string. */ + private decodeBase64Url(encoded: string): string { + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (base64.length % 4)) % 4; + const binary = this.window.atob(base64 + "=".repeat(padding)); + + return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))); + } +} diff --git a/src/models/services/conversation/conversation-service.tsx b/src/models/services/conversation/conversation-service.tsx index f129b6d..da2fa90 100644 --- a/src/models/services/conversation/conversation-service.tsx +++ b/src/models/services/conversation/conversation-service.tsx @@ -6,33 +6,61 @@ import { config } from "../../../config/config"; export class ConversationService { private chatStorage: ChatStorage; - private readonly directLine: DirectLineInterface; + + /** Configuration the direct lines are created from, ie. the secret and the domain. */ + private readonly directLineConfiguration: DirectLineInterface; + public onDirectLineCreated?: (directLine: DirectLine) => void = undefined; - public constructor(chatStorage: ChatStorage, directLine: DirectLineInterface) { + /** + * The direct line of the conversation that is currently running, if any. + * + * It is held to be able to end it when the conversation is replaced, and to tell whether a + * direct line that reports something is still the one we are running. + */ + private currentDirectLine?: DirectLine; + + /** + * Subscription to the connection status of the currently running conversation. + * + * Ending a direct line does not complete its connection status, so the subscription has to + * be released by hand when the conversation is replaced. + */ + private currentSubscription?: { unsubscribe: () => void }; + + public constructor(chatStorage: ChatStorage, directLineConfiguration: DirectLineInterface) { this.chatStorage = chatStorage; - this.directLine = directLine; + this.directLineConfiguration = directLineConfiguration; this.attachEvents(); } + /** Yields to true when there is a conversation that was started before and may be resumed. */ + public hasStoredConversation(): boolean { + return this.chatStorage.getConversationId() !== undefined; + } + /** * Load conversation from storage or create new one. */ public async startConversation(): Promise { - const conversationId = this.directLine.useMockbot ? undefined : this.chatStorage.getConversationId(); - const secret = this.directLine.useMockbot ? await this.getTokenFromMockbot() : this.directLine.secret; + const useMockbot = this.directLineConfiguration.useMockbot; + const conversationId = useMockbot ? undefined : this.chatStorage.getConversationId(); + const secret = useMockbot ? await this.getTokenFromMockbot() : this.directLineConfiguration.secret; let domain = undefined; - if (this.directLine.domain === "europe") { + if (this.directLineConfiguration.domain === "europe") { domain = "https://europe.directline.botframework.com/v3/directline"; - } else if (this.directLine.domain === "india") { + } else if (this.directLineConfiguration.domain === "india") { domain = "https://india.directline.botframework.com/v3/directline"; } const directLine: DirectLine = createLine({ secret, watermark: "0", conversationId, domain }); + this.currentDirectLine = directLine; - directLine.connectionStatus$.subscribe(() => { + this.currentSubscription = directLine.connectionStatus$.subscribe(() => { const conversationId = directLine["conversationId"]; - if (conversationId && !this.directLine.useMockbot) { + // the conversation may have been replaced in the meantime, in such case its id + // must not be stored anymore, as it would overwrite the id of the current one + if (conversationId && !useMockbot && this.currentDirectLine === directLine) { this.chatStorage.setConversationId(conversationId); } }); @@ -42,6 +70,18 @@ export class ConversationService { } } + /** + * Replaces the conversation that is currently running with a brand new one. + * + * The stored conversation id is dropped, so the direct line creates (and stores) a new + * conversation instead of loading the previous one. + */ + public async replaceConversation(): Promise { + this.endCurrentConversation(); + this.chatStorage.clear(); + await this.startConversation(); + } + /** * Load token from mockbot api. */ @@ -60,16 +100,16 @@ export class ConversationService { event.error?.response?.error?.message === "Conversation not found" || event.error?.response?.error?.message === "Token not valid for this conversation" ) { - await this.resetConversation(); + await this.replaceConversation(); } }); } - /** - * Removes stored conversation and starts new one. - */ - private async resetConversation(): Promise { - this.chatStorage.clear(); - await this.startConversation(); + /** Releases the conversation that is currently running, so it does not receive anything anymore. */ + private endCurrentConversation(): void { + this.currentSubscription?.unsubscribe(); + this.currentSubscription = undefined; + this.currentDirectLine?.end(); + this.currentDirectLine = undefined; } } diff --git a/src/models/services/store/store-service.ts b/src/models/services/store/store-service.ts index 8e3a55a..4d483e0 100644 --- a/src/models/services/store/store-service.ts +++ b/src/models/services/store/store-service.ts @@ -3,20 +3,52 @@ import { PostActivityAction } from "botframework-webchat-core/src/actions/postAc import { Store } from "redux"; import { LocaleService } from "../locale/locale-service"; import { ChatDomService } from "../dom/chat-dom-service"; +import { ChatConversationStartPayloadInterface } from "../../interfaces/conversation/chat-conversation-start-payload-interface"; const SCROLL_ANIMATION_DELAY = 850; export class StoreService { private localeService: LocaleService; private domService: ChatDomService; - public readonly store: Store; + private currentStore?: Store; public onConversationLoaded?: () => void = undefined; public onFeedback?: (feedbackInstanceId: string) => void = undefined; constructor(localeService: LocaleService, domService: ChatDomService) { this.localeService = localeService; this.domService = domService; - this.store = createStore( + } + + /** + * Store that backs the conversation that is currently running. + * + * It is created when it is asked for the first time, so the pages where the chat is never + * opened do not pay for it. + */ + public get store(): Store { + return (this.currentStore ??= this.createStore()); + } + + /** + * Creates a store for a brand new conversation, dropping any state (ie. the transcript) + * of the conversation that was running so far. + * + * The structured data of the conversation, if any, is bound to that very store, so it is + * handed over to the backend with the `webchat/join` event of that conversation only. + * + * As the store is bound to the chat component, the component has to be re-mounted with + * the new store, see the `App`. + */ + public recreateStore(startPayload: ChatConversationStartPayloadInterface): void { + this.currentStore = this.createStore(startPayload); + } + + private createStore(startPayload?: ChatConversationStartPayloadInterface): Store { + // the payload belongs to the start of the conversation only, so it is dropped once it is + // sent and is not repeated when the very same conversation reconnects + let pendingStartPayload = startPayload; + + return createStore( {}, ({ dispatch }: { dispatch: (props: object) => void }) => (next: (action: unknown) => void) => @@ -29,12 +61,13 @@ export class StoreService { if (action.type === "DIRECT_LINE/CONNECT_FULFILLED") { // trigger welcome message when connection is fulfilled console.log("Connected to bot"); + const contact = this.domService.getCustomerObject(); + const value = pendingStartPayload ? { contact, payload: pendingStartPayload } : { contact }; + pendingStartPayload = undefined; + dispatch({ type: "WEB_CHAT/SEND_EVENT", - payload: { - name: "webchat/join", - value: { contact: this.domService.getCustomerObject() }, - }, + payload: { name: "webchat/join", value }, }); // triggers conversation loaded event (serve to hide initial scrolling) when connection is fulfilled diff --git a/src/spinoco-webchat-plugin.tsx b/src/spinoco-webchat-plugin.tsx index f5ce761..9c3494e 100644 --- a/src/spinoco-webchat-plugin.tsx +++ b/src/spinoco-webchat-plugin.tsx @@ -13,6 +13,7 @@ import { GlobalEventService } from "./models/services/global-event-service/globa import "./styles/app.scss"; import { UrlNavigationService } from "./models/services/dom/url-navigation-service"; import { RuleService } from "./models/services/rule-service"; +import { WebchatContextService } from "./models/services/context/webchat-context-service"; import { isFullScreen } from "./middlewares/device-query"; declare global { @@ -28,6 +29,7 @@ const createWithConfigUrl = (url: string) => { const storeService = new StoreService(localeService, chatDomService); const globalEventService = (window.spinocoWebchatPlugin = new GlobalEventService()); const urlNavigationService = new UrlNavigationService(globalEventService, chatDomService); + const webchatContextService = new WebchatContextService(chatDomService); fetch(`${url}`) .then((response) => response.json()) @@ -54,6 +56,7 @@ const createWithConfigUrl = (url: string) => { configuration={configuration} globalEventService={globalEventService} urlNavigationService={urlNavigationService} + webchatContextService={webchatContextService} isFullScreen={isFullScreen()} /> , diff --git a/src/styles/app.scss b/src/styles/app.scss index b742c97..13c2f56 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -22,6 +22,9 @@ $mobile-breakpoint: "384px"; @return $remValue; } +// shadow of the cards that float above the page or above the chat +$card-box-shadow: rgba(100, 100, 111, .2) 0 .7rem 2.9rem 0; + @import "components/base"; @import "components/avatar"; @@ -33,6 +36,7 @@ $mobile-breakpoint: "384px"; @import "components/typing-indicator"; @import "components/wrapper"; @import "components/popover"; +@import "components/question-dialog"; @import "features/box-shadow"; @import "features/bubble-single-border"; diff --git a/src/styles/components/popover.scss b/src/styles/components/popover.scss index 0fee6bb..4507c67 100644 --- a/src/styles/components/popover.scss +++ b/src/styles/components/popover.scss @@ -9,7 +9,7 @@ overflow: hidden; - box-shadow: rgba(100, 100, 111, .2) 0 .7rem 2.9rem 0; + box-shadow: $card-box-shadow; pointer-events: all; diff --git a/src/styles/components/question-dialog.scss b/src/styles/components/question-dialog.scss new file mode 100644 index 0000000..a4d712f --- /dev/null +++ b/src/styles/components/question-dialog.scss @@ -0,0 +1,74 @@ +#spinoco-webchat-plugin { + // the dialog covers the chat window it is rendered in, so the user has to answer the + // question before they can go on with the conversation. It is left below the header, which + // has a z-index of its own, so only the conversation is dimmed by it. + .swp-question-dialog { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: center; + + padding: rem(20); + + background-color: rgba(0, 0, 0, .35); + + &-card { + width: 100%; + max-width: rem(320); + padding: rem(15); + + color: #232323; + + background-color: #FFFFFF; + border-radius: var(--wrapper-border-radius); + box-shadow: $card-box-shadow; + + h3 { + font-size: initial; + } + } + + &-actions { + display: flex; + + margin-top: rem(15); + + button { + flex: 1 1 0; + + padding: rem(8) rem(16); + + font-weight: 700; + + border-radius: rem(999); + + + button { + margin-left: rem(10); + } + } + } + + &-dismiss { + color: var(--swp-color-primary); + + background-color: transparent; + border: rem(1) solid var(--swp-color-primary); + } + + &-confirm { + color: #FFFFFF; + + background-color: var(--swp-color-primary); + + &:hover { + background-color: var(--swp-color-primary-hover); + transform: none; + } + } + } +}