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;
+ }
+ }
+ }
+}