├── .auto-changelog ├── .eslintrc.js ├── .github └── workflows │ ├── main.yaml │ └── manual.yaml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierrc.js ├── .storybook ├── decorators │ ├── ThemeDecorator.tsx │ └── TockContextDecorator.tsx ├── main.ts ├── preview.ts └── webpack.config.js ├── .swcrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── changelog-template.hbs ├── package.json ├── rollup.config.mjs ├── src ├── MessageMetadata.tsx ├── PostInitContext.ts ├── TockAccessibility.ts ├── TockContext.tsx ├── TockLocalStorage.ts ├── TockOptions.ts ├── TockState.tsx ├── components │ ├── Card │ │ ├── Card.stories.tsx │ │ ├── Card.tsx │ │ └── index.ts │ ├── Carousel │ │ ├── Carousel.stories.tsx │ │ ├── Carousel.tsx │ │ ├── hooks │ │ │ ├── useArrowVisibility.ts │ │ │ ├── useCarousel.ts │ │ │ ├── useMeasures.ts │ │ │ └── useRefs.ts │ │ └── index.ts │ ├── Chat │ │ ├── Chat.stories.tsx │ │ ├── Chat.tsx │ │ └── index.ts │ ├── ChatInput │ │ ├── ChatInput.stories.tsx │ │ ├── ChatInput.tsx │ │ └── index.ts │ ├── Container │ │ ├── Container.tsx │ │ └── index.ts │ ├── Conversation │ │ ├── Conversation.stories.tsx │ │ ├── Conversation.tsx │ │ ├── hooks │ │ │ ├── useMessageCounter.ts │ │ │ └── useScrollBehaviour.ts │ │ └── index.ts │ ├── Image │ │ ├── Image.stories.tsx │ │ ├── Image.tsx │ │ └── index.ts │ ├── InlineQuickReplyList │ │ ├── InlineQuickReplyList.stories.tsx │ │ ├── InlineQuickReplyList.tsx │ │ ├── hooks │ │ │ ├── useButtonMeasures.ts │ │ │ ├── useButtonRefs.ts │ │ │ └── useCarouselQuickReply.ts │ │ └── index.ts │ ├── Loader │ │ ├── Loader.stories.tsx │ │ ├── Loader.tsx │ │ └── index.ts │ ├── MessageBot │ │ ├── MessageBot.stories.tsx │ │ ├── MessageBot.tsx │ │ └── index.ts │ ├── MessageUser │ │ ├── MessageUser.stories.tsx │ │ ├── MessageUser.tsx │ │ └── index.ts │ ├── QuickReply │ │ ├── QuickReply.tsx │ │ ├── QuickReplyImage.tsx │ │ └── index.ts │ ├── QuickReplyList │ │ ├── QuickReplyList.stories.tsx │ │ ├── QuickReplyList.tsx │ │ └── index.ts │ ├── buttons │ │ ├── ButtonList │ │ │ ├── ButtonList.stories.tsx │ │ │ ├── ButtonList.tsx │ │ │ └── index.ts │ │ ├── PostBackButton │ │ │ ├── PostBackButton.stories.tsx │ │ │ ├── PostBackButton.tsx │ │ │ └── index.ts │ │ └── UrlButton │ │ │ ├── UrlButton.stories.tsx │ │ │ ├── UrlButton.tsx │ │ │ └── index.ts │ └── widgets │ │ ├── DefaultWidget │ │ ├── DefaultWidget.tsx │ │ └── index.ts │ │ └── ProductWidget │ │ ├── Product.ts │ │ ├── ProductWidget.tsx │ │ └── index.ts ├── index.ts ├── model │ ├── buttons.ts │ ├── messages.ts │ └── responses.ts ├── network │ ├── TockEventSource.ts │ └── TockNetworkContext.tsx ├── renderChat.tsx ├── settings │ ├── ButtonRenderers.ts │ ├── RendererRegistry.tsx │ ├── RendererSettings.tsx │ ├── TockSettings.tsx │ └── TockSettingsContext.tsx ├── styles │ ├── createTheme.ts │ ├── defaultTheme.ts │ ├── overrides.ts │ ├── palette.ts │ ├── sizing.ts │ ├── theme.ts │ ├── tockThemeButtonStyle.ts │ ├── tockThemeCardStyle.ts │ ├── tockThemeInputStyle.ts │ ├── tockThemeProvider.ts │ └── typography.ts ├── useLocalTools.ts ├── useTock.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.auto-changelog: -------------------------------------------------------------------------------- 1 | { 2 | "commitLimit": false, 3 | "ignoreCommitPattern": "^(build|chore|ci|docs|refactor|style|test|Initial.*|Merge.*)", 4 | "output": "CHANGELOG.md", 5 | "template": "changelog-template.hbs", 6 | "unreleased": true 7 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2017, // Allows for the parsing of modern ECMAScript features 5 | sourceType: 'module', // Allows for the use of imports 6 | ecmaFeatures: { 7 | jsx: true, // Allows for the parsing of JSX 8 | }, 9 | }, 10 | settings: { 11 | react: { 12 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 13 | }, 14 | }, 15 | extends: [ 16 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 17 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 18 | 'plugin:storybook/recommended', 19 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 20 | ], 21 | 22 | rules: { 23 | '@emotion/pkg-renaming': 'error', 24 | '@emotion/no-vanilla': 'error', 25 | '@emotion/import-from-emotion': 'error', 26 | '@emotion/styled-import': 'error', 27 | // Modern JSX transform does not require explicit React import 28 | 'react/jsx-uses-react': 'off', 29 | 'react/react-in-jsx-scope': 'off', 30 | // Ignore the css property added to React components by Emotion 31 | 'react/no-unknown-property': ['error', { ignore: ['css'] }], 32 | '@typescript-eslint/ban-types': [ 33 | 'error', 34 | { 35 | extendDefaults: true, 36 | types: { 37 | '{}': false, 38 | }, 39 | }, 40 | ], 41 | '@typescript-eslint/no-unused-vars': [ 42 | 'error', 43 | { ignoreRestSiblings: true }, 44 | ], 45 | }, 46 | plugins: ['@emotion'], 47 | }; 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | repository_dispatch: 5 | types: publish 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Get tag number 12 | id: get_tag 13 | run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3) 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - name: Cache node modules 20 | uses: actions/cache@v1 21 | env: 22 | cache-name: cache-node-modules 23 | with: 24 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 25 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-build-${{ env.cache-name }}- 28 | ${{ runner.os }}-build- 29 | ${{ runner.os }}- 30 | - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 31 | - run: yarn install 32 | - run: git config --local user.email "action@github.com" 33 | - run: git config --local user.name "GitHub Action" 34 | #- run: yarn config set version-sign-git-tag true 35 | - run: | 36 | if [[ ${{ github.event.inputs.version }} == *"-"* ]]; then 37 | yarn publish --new-version ${{ github.event.inputs.version }} --tag=beta 38 | else 39 | yarn publish --new-version ${{ github.event.inputs.version }} 40 | fi 41 | env: 42 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 43 | - run: git push 44 | -------------------------------------------------------------------------------- /.github/workflows/manual.yaml: -------------------------------------------------------------------------------- 1 | name: Manual Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release: "X.Y.Z", or "X.Y.Z-beta" for beta' 8 | required: true 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | filter: 'blob:none' 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | registry-url: https://registry.npmjs.org/ 22 | - name: Cache node modules 23 | uses: actions/cache@v4 24 | env: 25 | cache-name: cache-node-modules 26 | with: 27 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 28 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-build-${{ env.cache-name }}- 31 | ${{ runner.os }}-build- 32 | ${{ runner.os }}- 33 | - run: yarn install 34 | - run: git config --local user.email "action@github.com" 35 | - run: git config --local user.name "GitHub Action" 36 | #- run: yarn config set version-sign-git-tag true 37 | - run: | 38 | if [[ ${{ github.event.inputs.version }} == *"-"* ]]; then 39 | yarn publish --new-version ${{ github.event.inputs.version }} --tag=beta 40 | else 41 | yarn publish --new-version ${{ github.event.inputs.version }} 42 | fi 43 | env: 44 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 45 | - run: git push --tags 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | build/ 64 | 65 | .idea 66 | *.iml 67 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2 7 | }; 8 | -------------------------------------------------------------------------------- /.storybook/decorators/ThemeDecorator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from '@emotion/react'; 3 | import { createTheme } from '../../src'; 4 | import { Overrides } from '../../src/styles/overrides'; 5 | import { Palette } from '../../src/styles/palette'; 6 | import { Sizing } from '../../src/styles/sizing'; 7 | import { Typography } from '../../src/styles/typography'; 8 | 9 | export const palettes: Record> = { 10 | default: {}, 11 | alternative: { 12 | text: { 13 | user: '#fad390', 14 | bot: '#f8c291', 15 | card: '#6a89cc', 16 | input: '#82ccdd', 17 | }, 18 | background: { 19 | user: '#e58e26', 20 | bot: 'var(--test, #b71540)', 21 | card: '#0c2461', 22 | input: '#0a3d62', 23 | inputDisabled: '#079992', 24 | }, 25 | }, 26 | }; 27 | 28 | export const sizings: Record> = { 29 | default: {}, 30 | alternative: { 31 | loaderSize: '20px', 32 | borderRadius: '4em', 33 | conversation: { 34 | width: '400px', 35 | }, 36 | }, 37 | }; 38 | 39 | export const typographies: Record> = { 40 | default: {}, 41 | alternative: { 42 | fontFamily: 'cursive', 43 | fontSize: '1em', 44 | } 45 | }; 46 | 47 | export const overrides: Record = { 48 | default: undefined, 49 | alternative: { 50 | buttons: { 51 | buttonList: ` 52 | display: flex; 53 | flex-direction: column; 54 | align-items: stretch; 55 | `, 56 | buttonContainer: ` 57 | margin: 0.5em; 58 | `, 59 | postbackButton: ` 60 | width: 100%; 61 | &:hover { 62 | filter: drop-shadow(0 0 0.1em red); 63 | } 64 | `, 65 | urlButton: ` 66 | width: 100%; 67 | box-sizing: border-box; 68 | filter: hue-rotate(180deg); 69 | border-radius: 50%; 70 | &:hover { 71 | filter: hue-rotate(250deg) drop-shadow(0 0 0.1em blue); 72 | } 73 | ` 74 | }, 75 | }, 76 | 'quickReply for all buttons': { 77 | quickReply: ` 78 | box-shadow: 10px 5px 5px red; 79 | ` 80 | }, 81 | }; 82 | 83 | const ThemeDecorator = (Story: () => JSX.Element, context: any) => { 84 | const theme = createTheme({ 85 | palette: palettes[context.globals.palette], 86 | sizing: sizings[context.globals.sizing], 87 | typography: typographies[context.globals.typography], 88 | overrides: overrides[context.globals.overrides], 89 | }); 90 | return ( 91 | 92 | 93 | 94 | ); 95 | }; 96 | 97 | export default ThemeDecorator; 98 | -------------------------------------------------------------------------------- /.storybook/decorators/TockContextDecorator.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'react'; 2 | import TockContext from '../../src/TockContext'; 3 | 4 | const TockContextDecorator = (Story: () => JSX.Element) => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default TockContextDecorator; 11 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-webpack5'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | 6 | addons: [ 7 | '@storybook/addon-links', 8 | '@storybook/addon-essentials', 9 | '@storybook/addon-interactions', 10 | ], 11 | 12 | framework: { 13 | name: '@storybook/react-webpack5', 14 | options: { 15 | strictMode: true, 16 | fastRefresh: true, 17 | builder: { 18 | useSWC: true, 19 | }, 20 | }, 21 | }, 22 | 23 | docs: { 24 | autodocs: true, 25 | }, 26 | 27 | core: { 28 | builder: '@storybook/builder-webpack5' 29 | } 30 | }; 31 | export default config; 32 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import ThemeDecorator, { overrides, palettes, sizings, typographies } from './decorators/ThemeDecorator'; 3 | import TockContextDecorator from "./decorators/TockContextDecorator"; 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | actions: { argTypesRegex: '^on[A-Z].*' }, 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/, 12 | }, 13 | }, 14 | }, 15 | decorators: [ 16 | ThemeDecorator, 17 | TockContextDecorator, 18 | ], 19 | globalTypes: { 20 | palette: { 21 | description: 'Global color palette for components', 22 | defaultValue: 'default', 23 | toolbar: { 24 | title: 'Palette', 25 | items: [...Object.keys(palettes)] 26 | } 27 | }, 28 | sizing: { 29 | description: 'Global sizing for components', 30 | defaultValue: 'default', 31 | toolbar: { 32 | title: 'Sizing', 33 | items: [...Object.keys(sizings)] 34 | } 35 | }, 36 | typography: { 37 | description: 'Global typography for components', 38 | defaultValue: 'default', 39 | toolbar: { 40 | title: 'Typography', 41 | items: [...Object.keys(typographies)] 42 | } 43 | }, 44 | overrides: { 45 | description: 'Global overrides for components', 46 | defaultValue: 'default', 47 | toolbar: { 48 | title: 'Overrides', 49 | items: [...Object.keys(overrides)] 50 | } 51 | } 52 | } 53 | }; 54 | 55 | export default preview; 56 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | }); 5 | config.resolve.extensions.push('.ts', '.tsx'); 6 | return config; 7 | }; 8 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "transform": { 4 | "react": { 5 | "runtime": "automatic", 6 | "importSource": "@emotion/react" 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | #### [v23.9.2](https://github.com/theopenconversationkit/tock-react-kit/compare/v23.9.1...v23.9.2) 6 | 7 | - fix #160: add standalone output, update dependencies, reformat [`#162`](https://github.com/theopenconversationkit/tock-react-kit/pull/162) 8 | - fix #160: add standalone output, update dependencies, reformat (#162) [`#160`](https://github.com/theopenconversationkit/tock-react-kit/issues/160) 9 | 10 | #### [v23.9.1](https://github.com/theopenconversationkit/tock-react-kit/compare/v23.9.0...v23.9.1) 11 | 12 | > 23 February 2024 13 | 14 | #### [v23.9.0](https://github.com/theopenconversationkit/tock-react-kit/compare/v22.3.1-2...v23.9.0) 15 | 16 | > 1 February 2024 17 | 18 | #### [v22.3.1-2](https://github.com/theopenconversationkit/tock-react-kit/compare/v22.3.1-1...v22.3.1-2) 19 | 20 | > 19 December 2023 21 | 22 | #### [v22.3.1-1](https://github.com/theopenconversationkit/tock-react-kit/compare/v22.3.1-0...v22.3.1-1) 23 | 24 | > 12 October 2022 25 | 26 | #### [v22.3.1-0](https://github.com/theopenconversationkit/tock-react-kit/compare/v22.3.0...v22.3.1-0) 27 | 28 | > 29 September 2022 29 | 30 | #### [v22.3.0](https://github.com/theopenconversationkit/tock-react-kit/compare/v21.9.1-1...v22.3.0) 31 | 32 | > 8 April 2022 33 | 34 | #### [v21.9.1-1](https://github.com/theopenconversationkit/tock-react-kit/compare/v21.9.1-0...v21.9.1-1) 35 | 36 | > 4 April 2022 37 | 38 | #### [v21.9.1-0](https://github.com/theopenconversationkit/tock-react-kit/compare/v21.9.0...v21.9.1-0) 39 | 40 | > 23 March 2022 41 | 42 | ### [v21.9.0](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.9.1...v21.9.0) 43 | 44 | > 7 October 2021 45 | 46 | #### [v20.9.1](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.4...v20.9.1) 47 | 48 | > 26 November 2020 49 | 50 | #### [v20.3.4](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.2...v20.3.4) 51 | 52 | > 2 September 2020 53 | 54 | #### [v20.3.2](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.1...v20.3.2) 55 | 56 | > 20 August 2020 57 | 58 | #### [v20.3.1](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.1-1...v20.3.1) 59 | 60 | > 17 August 2020 61 | 62 | #### [v20.3.1-1](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.1-0...v20.3.1-1) 63 | 64 | > 23 April 2020 65 | 66 | #### [v20.3.1-0](https://github.com/theopenconversationkit/tock-react-kit/compare/v20.3.0...v20.3.1-0) 67 | 68 | > 23 April 2020 69 | 70 | #### v20.3.0 71 | 72 | > 15 April 2020 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## How to add a feature or fix a bug 4 | 5 | 1. [Create an issue](https://github.com/theopenconversationkit/tock-react-kit/issues/new) 6 | 2. Create a pull request linked to this issue 7 | - All commits have to be [signed](https://help.github.com/en/github/authenticating-to-github/managing-commit-signature-verification) 8 | - Please squash your commits before submitting the PR 9 | - Recommended format for the branch : "\[ticketId]-\[ticket title]" 10 | - Recommended format for the commit message: 11 | - *"resolves #\[ticketId] \[ticket title]" adds a feature* 12 | - *"fixes #\[ticketId] \[ticket title]" fixes a bug* 13 | 3. Before merging, the PR should be reviewed by at least two of these developers: 14 | * [@tiska](https://github.com/Tiska) 15 | * [@elebescond](https://github.com/elebescond) 16 | * [@phurytw](https://github.com/phurytw) 17 | * [@vsct-jburet](https://github.com/vsct-jburet) 18 | * [@MaximeLeFrancois](https://github.com/MaximeLeFrancois) 19 | * [@delphes99](https://github.com/delphes99) 20 | * [@Fabilin](https://github.com/Fabilin) 21 | 22 | # GITHUB ACTION FOR PUBLISHING 23 | 24 | ## Build, tag and publish new version 25 | 26 | 1. Ensure a milestone exists for the new version (https://github.com/theopenconversationkit/tock-react-kit/milestones) 27 | and that all relevant issues are assigned to it 28 | 2. Run the [manual release workflow](https://github.com/theopenconversationkit/tock-react-kit/actions/workflows/manual.yaml) 29 | with the new version number 30 | 3. Create a [GitHub release](https://github.com/theopenconversationkit/tock-react-kit/releases) using the tag created by the workflow 31 | 32 | ### Versioning 33 | 34 | Our versioning is based on [Semantic Versioning](https://semver.org), with some tweaks to tie the version to our release schedule: 35 | - The major and minor components are repurposed to denote respectively the release year and month of the current major version. 36 | We release two major versions per year, one in March and one in September. 37 | - The patch version component keeps the same meaning as in semver. We release patch versions at any time. 38 | - The additional labels for pre-release and build metadata keep the same meaning as in semver 39 | 40 | For example: 41 | - 23.3.0 is the major version released in March 2023. The next major version is 23.9.0, released in September of the same year. 42 | - 23.9.2 is the second patch version for the 23.9 major, and may be released any time between September 2023 and March 2024. 43 | - 24.9.0-beta.1 is the first beta for the upcoming 24.9.0 major version. 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /changelog-template.hbs: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | {{#each releases}} 6 | {{#if href}} 7 | ###{{#unless major}}#{{/unless}} [{{title}}]({{href}}) 8 | {{else}} 9 | #### {{title}} 10 | {{/if}} 11 | 12 | {{#if tag}} 13 | > {{niceDate}} 14 | {{/if}} 15 | 16 | {{#if summary}} 17 | {{summary}} 18 | {{/if}} 19 | 20 | {{#each merges}} 21 | - {{#if commit.breaking}}**Breaking change:** {{/if}}{{message}}{{#if href}} [`#{{id}}`]({{href}}){{/if}} 22 | {{/each}} 23 | {{#each fixes}} 24 | - {{#if commit.breaking}}**Breaking change:** {{/if}}{{commit.subject}}{{#each fixes}}{{#if href}} [`#{{id}}`]({{href}}){{/if}}{{/each}} 25 | {{/each}} 26 | {{#each commits}} 27 | - {{#if breaking}}**Breaking change:** {{/if}}{{subject}}{{#if href}} [`{{shorthash}}`]({{href}}){{/if}} 28 | {{/each}} 29 | 30 | {{/each}} 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tock-react-kit", 3 | "version": "23.9.2", 4 | "description": "React UI library for Tock Node chatbots", 5 | "main": "build/tock-react-kit.umd.js", 6 | "module": "build/tock-react-kit.esm.js", 7 | "types": "build/tock-react-kit.d.ts", 8 | "author": "François Nguyen ", 9 | "homepage": "https://github.com/theopenconversationkit/tock-react-kit", 10 | "license": "MIT", 11 | "engines": { 12 | "node": ">=20.10.0" 13 | }, 14 | "files": [ 15 | "build/tock-react-kit.d.ts", 16 | "build/tock-react-kit.esm.js", 17 | "build/tock-react-kit.esm.js.map", 18 | "build/tock-react-kit-standalone.umd.js", 19 | "build/tock-react-kit-standalone.esm.js" 20 | ], 21 | "scripts": { 22 | "storybook": "storybook dev -p 6006", 23 | "build-storybook": "storybook build", 24 | "build": "rollup -c --environment BUILD:production", 25 | "watch-build": "rollup -w -c --environment BUILD:development", 26 | "lint": "eslint 'src/**/*.{ts,tsx}' --fix --quiet", 27 | "prepare": "rollup -c --environment BUILD:production", 28 | "version": "auto-changelog -p && git add CHANGELOG.md", 29 | "test": "echo 'Tests coming soon ...'", 30 | "test-release": "np --preview", 31 | "release": "np" 32 | }, 33 | "dependencies": { 34 | "deepmerge": "^4.2.2", 35 | "linkify-html": "^3.0.5", 36 | "linkifyjs": "^3.0.5", 37 | "polished": "^3.6.5", 38 | "react-feather": "^2.0.8", 39 | "styled-tools": "^1.7.2" 40 | }, 41 | "devDependencies": { 42 | "@emotion/eslint-plugin": "^11.11.0", 43 | "@emotion/react": "^11.11.3", 44 | "@emotion/styled": "^11.11.0", 45 | "@rollup/plugin-commonjs": "^25.0.7", 46 | "@rollup/plugin-node-resolve": "^15.2.3", 47 | "@rollup/plugin-replace": "^5.0.5", 48 | "@rollup/plugin-terser": "^0.4.4", 49 | "@rollup/plugin-typescript": "^11.1.6", 50 | "@storybook/addon-essentials": "^7.6.17", 51 | "@storybook/addon-interactions": "^7.6.17", 52 | "@storybook/addon-links": "^7.6.17", 53 | "@storybook/addons": "^7.6.17", 54 | "@storybook/blocks": "^7.6.17", 55 | "@storybook/builder-webpack5": "^7.6.17", 56 | "@storybook/react": "^7.6.17", 57 | "@storybook/react-webpack5": "^7.6.17", 58 | "@storybook/testing-library": "^0.2.0", 59 | "@types/node": "^20.11.20", 60 | "@types/react-dom": "^18.2.19", 61 | "@typescript-eslint/eslint-plugin": "^7.0.2", 62 | "@typescript-eslint/parser": "^7.0.2", 63 | "auto-changelog": "^2.4.0", 64 | "eslint": "^8.56.0", 65 | "eslint-config-prettier": "^9.1.0", 66 | "eslint-plugin-prettier": "^5.1.3", 67 | "eslint-plugin-react": "^7.33.2", 68 | "eslint-plugin-storybook": "^0.8.0", 69 | "husky": "^9.0.11", 70 | "np": "^9.2.0", 71 | "prettier": "^3.2.5", 72 | "react": "^18.2.0", 73 | "react-dom": "^18.2.0", 74 | "rollup": "^4.12.0", 75 | "rollup-plugin-dts": "^6.1.0", 76 | "rollup-plugin-sourcemaps": "^0.6.3", 77 | "storybook": "^7.6.17", 78 | "type-fest": "^4.10.3", 79 | "typescript": "^5.3.3" 80 | }, 81 | "peerDependencies": { 82 | "@emotion/react": "^11.0.0", 83 | "@emotion/styled": "^11.0.0", 84 | "react": ">=17.0", 85 | "react-dom": ">=17.0" 86 | }, 87 | "resolutions": { 88 | "jackspeak": "2.1.1", 89 | "string-width": "^4" 90 | }, 91 | "auto-changelog": { 92 | "includeBranch": [ 93 | "master" 94 | ] 95 | }, 96 | "publishConfig": { 97 | "registry": "https://registry.npmjs.org" 98 | }, 99 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 100 | } 101 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import replace from '@rollup/plugin-replace'; 4 | import dts from 'rollup-plugin-dts'; 5 | import terser from "@rollup/plugin-terser"; 6 | import typescript from '@rollup/plugin-typescript'; 7 | 8 | export default [ 9 | { 10 | input: 'src/index.ts', 11 | external: [ 12 | 'react', 13 | 'react/jsx-runtime', 14 | 'react-dom', 15 | '@emotion/react', 16 | '@emotion/react/jsx-runtime', 17 | '@emotion/styled', 18 | ], 19 | output: [ 20 | { 21 | file: 'build/tock-react-kit.esm.js', 22 | format: 'esm', 23 | sourcemap: true, 24 | }, 25 | ], 26 | plugins: [ 27 | resolve(), 28 | commonjs(), 29 | typescript(), 30 | replace({ 31 | 'process.env.NODE_ENV': JSON.stringify('production'), 32 | }), 33 | ], 34 | }, 35 | { 36 | input: 'src/index.ts', 37 | output: [ 38 | { 39 | file: 'build/tock-react-kit-standalone.umd.js', 40 | format: 'umd', 41 | name: 'TockReact', 42 | sourcemap: true, 43 | }, 44 | { 45 | file: 'build/tock-react-kit-standalone.esm.js', 46 | format: 'esm', 47 | sourcemap: true, 48 | }, 49 | ], 50 | plugins: [ 51 | resolve(), 52 | commonjs(), 53 | typescript(), 54 | replace({ 55 | 'process.env.NODE_ENV': JSON.stringify('production'), 56 | }), 57 | terser(), 58 | ], 59 | }, 60 | { 61 | // path to your declaration files root 62 | input: 'src/index.ts', 63 | output: [{ file: 'build/tock-react-kit.d.ts', format: 'es' }], 64 | plugins: [dts()], 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/MessageMetadata.tsx: -------------------------------------------------------------------------------- 1 | import { Context, createContext, useContext } from 'react'; 2 | 3 | const MessageMetadataCtx: Context> = createContext({}); 4 | 5 | export const MessageMetadataContext = MessageMetadataCtx.Provider; 6 | 7 | /** 8 | * Returns the metadata associated with the surrounding message 9 | */ 10 | export const useMessageMetadata = (): Record => { 11 | return useContext(MessageMetadataCtx); 12 | }; 13 | -------------------------------------------------------------------------------- /src/PostInitContext.ts: -------------------------------------------------------------------------------- 1 | import { UseLocalTools } from './useLocalTools'; 2 | import { Message } from './model/messages'; 3 | import { QuickReply } from './model/buttons'; 4 | 5 | export interface TockHistoryData { 6 | readonly messages: Message[]; 7 | readonly quickReplies: QuickReply[]; 8 | } 9 | 10 | export default interface PostInitContext extends UseLocalTools { 11 | /** 12 | * The full chat history at the time the Chat component is initialized, which includes messages from local storage 13 | * and/or from TockContext, or null if there is no chat history at all. 14 | */ 15 | readonly history: TockHistoryData | null; 16 | /** 17 | * Sends a regular text message as if typed by a user. The message will be visible in the chat. 18 | * @param message an arbitrary string 19 | */ 20 | readonly sendMessage: (message: string) => Promise; 21 | /** 22 | * Sends a payload to the backend as if a button was triggered. 23 | * @param payload a string representing a TOCK intent name, followed by URL-like query parameters 24 | */ 25 | readonly sendPayload: (payload: string) => Promise; 26 | readonly clearMessages: () => void; 27 | } 28 | -------------------------------------------------------------------------------- /src/TockAccessibility.ts: -------------------------------------------------------------------------------- 1 | export interface TockAccessibility { 2 | input?: InputAccessibility; 3 | carousel?: CarouselAccessibility; 4 | qrCarousel?: QRCarouselAccessibility; 5 | } 6 | 7 | export interface CarouselAccessibility { 8 | roleDescription?: string; 9 | slideRoleDescription?: string; 10 | previousButtonLabel?: string; 11 | nextButtonLabel?: string; 12 | } 13 | 14 | export interface QRCarouselAccessibility { 15 | previousButtonLabel?: string; 16 | nextButtonLabel?: string; 17 | } 18 | 19 | export interface InputAccessibility { 20 | sendButtonLabel?: string; 21 | clearButtonLabel?: string; 22 | } 23 | 24 | export default TockAccessibility; 25 | -------------------------------------------------------------------------------- /src/TockContext.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, ReactNode, useReducer } from 'react'; 2 | import deepmerge from 'deepmerge'; 3 | import { retrieveUserId } from './utils'; 4 | import TockSettings, { 5 | defaultSettings, 6 | TockOptionalSettings, 7 | } from './settings/TockSettings'; 8 | import { TockNetworkContext } from './network/TockNetworkContext'; 9 | import { tockReducer, TockStateContext, TockStateDispatch } from './TockState'; 10 | import { TockSettingsContext } from './settings/TockSettingsContext'; 11 | 12 | const TockContext: (props: { 13 | children?: ReactNode; 14 | endpoint?: string; // will be required in a future release 15 | settings?: TockOptionalSettings; 16 | }) => JSX.Element = ({ 17 | children, 18 | endpoint, 19 | settings = {}, 20 | }: { 21 | children?: ReactNode; 22 | endpoint?: string; 23 | settings?: TockOptionalSettings; 24 | }): JSX.Element => { 25 | const mergedSettings = deepmerge(defaultSettings, { 26 | endpoint, 27 | ...settings, 28 | }) as TockSettings; 29 | const [state, dispatch] = useReducer(tockReducer, { 30 | quickReplies: [], 31 | messages: [], 32 | userId: retrieveUserId(mergedSettings.localStorage.prefix), 33 | loading: false, 34 | sseInitializing: false, 35 | metadata: {}, 36 | error: false, 37 | }); 38 | return ( 39 | 40 | 41 | 42 | {endpoint ? ( 43 | 44 | {children} 45 | 46 | ) : ( 47 | children 48 | )} 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default TockContext; 56 | -------------------------------------------------------------------------------- /src/TockLocalStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated configure local storage in TockSettings instead 3 | */ 4 | export interface TockLocalStorage { 5 | enable?: boolean; 6 | maxNumberMessages?: number; 7 | } 8 | 9 | export default TockLocalStorage; 10 | -------------------------------------------------------------------------------- /src/TockOptions.ts: -------------------------------------------------------------------------------- 1 | import TockAccessibility from './TockAccessibility'; 2 | import TockLocalStorage from './TockLocalStorage'; 3 | import { TockOptionalSettings } from './settings/TockSettings'; 4 | import PostInitContext from './PostInitContext'; 5 | 6 | export interface TockOptions extends TockOptionalSettings { 7 | // a callback that will be executed once the chat is able to send and receive messages 8 | afterInit?: (context: PostInitContext) => Promise; 9 | // An initial message to send to the backend to trigger a welcome sequence 10 | // This message will be sent after the afterInit callback runs 11 | openingMessage?: string; 12 | // An optional function supplying extra HTTP headers for chat requests. 13 | // Extra headers must be explicitly allowed by the server's CORS settings. 14 | extraHeadersProvider?: () => Promise>; 15 | timeoutBetweenMessage?: number; 16 | widgets?: { [id: string]: (props: unknown) => JSX.Element }; 17 | disableSse?: boolean; 18 | accessibility?: TockAccessibility; 19 | localStorageHistory?: TockLocalStorage; 20 | } 21 | 22 | export default TockOptions; 23 | -------------------------------------------------------------------------------- /src/TockState.tsx: -------------------------------------------------------------------------------- 1 | import { Context, createContext, Dispatch, Reducer, useContext } from 'react'; 2 | import { QuickReply } from './model/buttons'; 3 | import { Message } from './model/messages'; 4 | 5 | export const TockStateContext: Context = createContext< 6 | TockState | undefined 7 | >(undefined); 8 | export const TockStateDispatch: Context | undefined> = 9 | createContext | undefined>(undefined); 10 | 11 | export const useTockState: () => TockState = () => { 12 | const state: TockState | undefined = useContext(TockStateContext); 13 | if (!state) { 14 | throw new Error('useTockState must be used in a TockContext'); 15 | } 16 | return state; 17 | }; 18 | 19 | export const useTockDispatch: () => Dispatch = () => { 20 | const dispatch: Dispatch | undefined = 21 | useContext(TockStateDispatch); 22 | if (!dispatch) { 23 | throw new Error('useTockDispatch must be used in a TockContext'); 24 | } 25 | return dispatch; 26 | }; 27 | 28 | export interface TockState { 29 | quickReplies: QuickReply[]; 30 | messages: Message[]; 31 | userId: string; 32 | loading: boolean; 33 | sseInitializing: boolean; 34 | metadata: Record; 35 | error: boolean; 36 | } 37 | 38 | export interface TockAction { 39 | type: 40 | | 'SET_QUICKREPLIES' 41 | | 'ADD_MESSAGE' 42 | | 'SET_METADATA' 43 | | 'SET_LOADING' 44 | | 'SET_SSE_INITIALIZING' 45 | | 'CLEAR_MESSAGES' 46 | | 'SET_ERROR'; 47 | quickReplies?: QuickReply[]; 48 | messages?: Message[]; 49 | loading?: boolean; 50 | sseInitializing?: boolean; 51 | metadata?: Record; 52 | error?: boolean; 53 | } 54 | 55 | export const tockReducer: Reducer = ( 56 | state: TockState, 57 | action: TockAction, 58 | ): TockState => { 59 | switch (action.type) { 60 | case 'SET_QUICKREPLIES': 61 | if (action.quickReplies) { 62 | return { 63 | ...state, 64 | quickReplies: action.quickReplies, 65 | error: !!action.error, 66 | }; 67 | } 68 | break; 69 | case 'ADD_MESSAGE': 70 | if (action.messages) { 71 | return { 72 | ...state, 73 | messages: [...state.messages, ...action.messages], 74 | error: !!action.error, 75 | }; 76 | } 77 | break; 78 | case 'SET_LOADING': 79 | if (action.loading != undefined) { 80 | return { 81 | ...state, 82 | loading: action.loading, 83 | }; 84 | } 85 | break; 86 | case 'SET_SSE_INITIALIZING': 87 | if (action.sseInitializing != undefined) { 88 | return { 89 | ...state, 90 | sseInitializing: action.sseInitializing, 91 | error: !!action.error, 92 | }; 93 | } 94 | break; 95 | case 'CLEAR_MESSAGES': 96 | if (state.messages) { 97 | return { 98 | ...state, 99 | messages: [], 100 | }; 101 | } 102 | break; 103 | case 'SET_METADATA': 104 | if (action.metadata != undefined) { 105 | return { 106 | ...state, 107 | metadata: action.metadata, 108 | }; 109 | } 110 | break; 111 | case 'SET_ERROR': 112 | return { 113 | ...state, 114 | error: !!action.error, 115 | }; 116 | default: 117 | break; 118 | } 119 | return state; 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/Card/Card.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { action } from '@storybook/addon-actions'; 4 | import Card from './Card'; 5 | 6 | const onButtonClick = action('buttonClick'); 7 | 8 | const meta: Meta = { 9 | component: Card, 10 | tags: ['autodocs'], 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Default: Story = { 17 | args: { 18 | title: 'Card title', 19 | subTitle: 'Card subtitle', 20 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 21 | onAction: onButtonClick, 22 | }, 23 | }; 24 | 25 | export const WithoutSubtitle: Story = { 26 | name: 'Without subtitle', 27 | args: { 28 | title: 'Card title', 29 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 30 | onAction: onButtonClick, 31 | }, 32 | }; 33 | 34 | export const WithButtons: Story = { 35 | name: 'With buttons', 36 | args: { 37 | title: 'Card title', 38 | subTitle: 'Card subtitle', 39 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 40 | onAction: onButtonClick, 41 | buttons: [ 42 | { 43 | label: 'Website', 44 | url: 'https://doc.tock.ai', 45 | }, 46 | { 47 | label: 'GitHub', 48 | url: 'https://github.com/theopenconversationkit', 49 | }, 50 | ], 51 | }, 52 | }; 53 | 54 | export const WithAlternativeDescription: Story = { 55 | name: 'With alternative description', 56 | args: { 57 | title: 'Card title', 58 | subTitle: 'Card subtitle', 59 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 60 | imageAlternative: 'Image of the Tock icon', 61 | onAction: onButtonClick, 62 | buttons: [ 63 | { 64 | label: 'Website', 65 | url: 'https://doc.tock.ai', 66 | }, 67 | { 68 | label: 'GitHub', 69 | url: 'https://github.com/theopenconversationkit', 70 | }, 71 | ], 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 3 | import { theme } from 'styled-tools'; 4 | 5 | import { Button as ButtonData } from '../../model/buttons'; 6 | import '../../styles/theme'; 7 | import UrlButton from '../buttons/UrlButton'; 8 | import PostBackButton from '../buttons/PostBackButton'; 9 | import { css, Interpolation, Theme } from '@emotion/react'; 10 | import { 11 | useImageRenderer, 12 | useTextRenderer, 13 | } from '../../settings/RendererSettings'; 14 | 15 | export const CardOuter: StyledComponent< 16 | DetailedHTMLProps, HTMLDivElement> 17 | > = styled.div` 18 | max-width: ${theme('sizing.conversation.width')}; 19 | width: 100%; 20 | `; 21 | 22 | export const CardContainer: StyledComponent< 23 | DetailedHTMLProps, HTMLElement> 24 | > = styled.article` 25 | padding: 0.5em; 26 | background: ${theme('palette.background.card')}; 27 | color: ${theme('palette.text.card')}; 28 | border-radius: ${theme('sizing.borderRadius')}; 29 | border: 2px solid ${theme('palette.text.card')}; 30 | width: 20em; 31 | 32 | ${theme('overrides.card.cardContainer')}; 33 | `; 34 | 35 | const CardTitle: StyledComponent< 36 | DetailedHTMLProps, HTMLSpanElement> 37 | > = styled.h3` 38 | margin: 0.5em 0; 39 | font-size: 1.5em; 40 | font-weight: bold; 41 | 42 | ${theme('overrides.card.cardTitle')}; 43 | `; 44 | 45 | const CardSubTitle: StyledComponent< 46 | DetailedHTMLProps, HTMLParagraphElement> 47 | > = styled.p` 48 | margin: 0.5em 0; 49 | font-size: 1em; 50 | font-weight: bold; 51 | 52 | ${theme('overrides.card.cardSubTitle')}; 53 | `; 54 | 55 | const ButtonList: StyledComponent< 56 | DetailedHTMLProps, HTMLUListElement> 57 | > = styled.ul` 58 | margin: 0.5em 0; 59 | list-style: none; 60 | padding: 0.5em 0; 61 | 62 | ${theme('overrides.buttons.buttonList')}; 63 | ${theme('overrides.card.buttonList')}; 64 | 65 | & > li { 66 | padding: 0; 67 | margin: 0 0.5em; 68 | display: inline-block; 69 | 70 | ${theme('overrides.buttons.buttonContainer')}; 71 | ${theme('overrides.card.buttonContainer')}; 72 | } 73 | `; 74 | 75 | const cardButtonBaseStyle = (theme: Theme) => css` 76 | border: 2px solid ${theme.palette.text.card}; 77 | border-radius: ${theme.sizing.borderRadius}; 78 | 79 | color: ${theme.palette.text.card}; 80 | 81 | &:hover, 82 | &:focus, 83 | &:active { 84 | color: ${theme.palette.background.card}; 85 | background: ${theme.palette.text.card}; 86 | } 87 | 88 | margin: 0.25em 0; 89 | `; 90 | 91 | const cardImageCss: Interpolation = [ 92 | css` 93 | max-width: 100%; 94 | max-height: 100%; 95 | `, 96 | (theme) => theme.overrides?.card?.cardImage, 97 | ]; 98 | 99 | const urlButtonStyle: Interpolation = [ 100 | cardButtonBaseStyle, 101 | (theme) => theme.overrides?.buttons?.urlButton, 102 | (theme) => theme.overrides?.card?.cardButton, 103 | ]; 104 | 105 | const postBackButtonStyle: Interpolation = [ 106 | cardButtonBaseStyle, 107 | (theme) => theme.overrides?.buttons?.postbackButton, 108 | (theme) => theme.overrides?.card?.cardButton, 109 | ]; 110 | 111 | export interface CardProps { 112 | title: string; 113 | subTitle?: string; 114 | imageUrl?: string; 115 | imageAlternative?: string; 116 | buttons?: ButtonData[]; 117 | roleDescription?: string; 118 | isHidden?: boolean; 119 | onAction: (button: ButtonData) => void; 120 | } 121 | 122 | const Card = ({ 123 | title, 124 | subTitle, 125 | imageUrl, 126 | imageAlternative, 127 | buttons, 128 | isHidden = false, 129 | onAction, 130 | }: CardProps) => { 131 | const ImageRenderer = useImageRenderer('card'); 132 | const HtmlRenderer = useTextRenderer('htmlPhrase'); 133 | const renderButton = (button: ButtonData, index: number) => ( 134 | // having the default index-based key is fine since we do not reorder buttons 135 |
  • 136 | {'url' in button ? ( 137 | 142 | ) : ( 143 | 150 | )} 151 |
  • 152 | ); 153 | return ( 154 | 155 | 156 | {imageUrl && ( 157 | 162 | )} 163 | 164 | 165 | 166 | {subTitle && ( 167 | 168 | 169 | 170 | )} 171 | {Array.isArray(buttons) && buttons.length > 0 && ( 172 | {buttons.map(renderButton)} 173 | )} 174 | 175 | 176 | ); 177 | }; 178 | 179 | export default Card; 180 | -------------------------------------------------------------------------------- /src/components/Card/index.ts: -------------------------------------------------------------------------------- 1 | import Card from './Card'; 2 | 3 | export * from './Card'; 4 | export default Card; 5 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { action } from '@storybook/addon-actions'; 4 | import { Button, UrlButton } from '../../model/buttons'; 5 | import Carousel from './Carousel'; 6 | import Card, { CardProps } from '../Card'; 7 | 8 | const onButtonClick = action('buttonClick'); 9 | 10 | const CARD_COUNT = 30; 11 | const cards: CardProps[] = Array.from(Array(CARD_COUNT)).map((_, i) => ({ 12 | title: `Card #${i}`, 13 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 14 | onAction: onButtonClick, 15 | })); 16 | 17 | const cardsWithButtons: CardProps[] = Array.from(Array(CARD_COUNT)).map( 18 | (_, i) => ({ 19 | title: `Card #${i}`, 20 | imageUrl: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 21 | onAction: onButtonClick, 22 | buttons: Array.from( 23 | { length: i % 10 }, 24 | (_, j) => new UrlButton(`Website ${j}`, 'https://sncf.com'), 25 | ) as Button[], 26 | }), 27 | ); 28 | 29 | const meta: Meta = { 30 | component: Carousel, 31 | argTypes: { 32 | children: { 33 | control: false, 34 | }, 35 | }, 36 | tags: ['autodocs'], 37 | }; 38 | 39 | export default meta; 40 | type Story = StoryObj; 41 | 42 | export const Default: Story = { 43 | args: { 44 | children: cards.map((props: CardProps) => ( 45 | 46 | )), 47 | }, 48 | }; 49 | 50 | export const Empty: Story = { 51 | args: { 52 | children: [], 53 | }, 54 | }; 55 | 56 | export const WithButtons: Story = { 57 | name: 'With buttons', 58 | args: { 59 | children: cardsWithButtons.map((props: CardProps) => ( 60 | 61 | )), 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | cloneElement, 3 | DetailedHTMLProps, 4 | HTMLAttributes, 5 | ReactElement, 6 | } from 'react'; 7 | import { ArrowLeftCircle, ArrowRightCircle } from 'react-feather'; 8 | import { useTheme } from '@emotion/react'; 9 | import styled, { StyledComponent } from '@emotion/styled'; 10 | import { prop } from 'styled-tools'; 11 | import useCarousel from './hooks/useCarousel'; 12 | import useArrowVisibility from './hooks/useArrowVisibility'; 13 | import TockAccessibility from 'TockAccessibility'; 14 | import TockTheme from '../../styles/theme'; 15 | 16 | const ButtonContainer: StyledComponent< 17 | DetailedHTMLProps, HTMLDivElement> 18 | > = styled.div` 19 | width: 100%; 20 | position: relative; 21 | overflow-x: auto; 22 | `; 23 | 24 | const ItemContainer: StyledComponent< 25 | DetailedHTMLProps, HTMLUListElement> 26 | > = styled.ul` 27 | display: flex; 28 | overflow: auto; 29 | -webkit-overflow-scrolling: touch; 30 | justify-content: start; 31 | scroll-behavior: smooth; 32 | touch-action: pan-x pan-y; 33 | position: relative; 34 | padding: 0; 35 | list-style: none; 36 | &::-webkit-scrollbar { 37 | display: none; 38 | } 39 | scrollbar-width: none; 40 | ${prop('theme.overrides.carouselContainer', '')} 41 | 42 | & > li, & > * { 43 | margin-left: 1em; 44 | margin-right: 1em; 45 | 46 | ${prop('theme.overrides.carouselItem', '')} 47 | } 48 | `; 49 | 50 | const Previous: StyledComponent< 51 | DetailedHTMLProps, HTMLButtonElement> 52 | > = styled.button` 53 | position: absolute; 54 | margin: auto; 55 | left: 0; 56 | top: 0; 57 | bottom: 0; 58 | padding: 1em; 59 | background: color-mix( 60 | in srgb, 61 | ${(props) => props.theme.palette.background.bot} 15%, 62 | transparent 63 | ); 64 | backdrop-filter: blur(5px); 65 | display: flex; 66 | justify-content: center; 67 | align-items: center; 68 | border: none; 69 | width: 4em; 70 | height: 4em; 71 | border-radius: 50%; 72 | 73 | cursor: pointer; 74 | z-index: 5; 75 | 76 | & svg { 77 | stroke: ${prop('theme.palette.background.bot')}; 78 | } 79 | 80 | &:hover, 81 | &:focus { 82 | svg { 83 | stroke: ${prop('theme.palette.text.bot')}; 84 | } 85 | } 86 | 87 | ${prop('theme.overrides.carouselArrow', '')}; 88 | `; 89 | 90 | const Next: StyledComponent< 91 | DetailedHTMLProps, HTMLButtonElement> 92 | > = styled.button` 93 | position: absolute; 94 | margin: auto; 95 | right: 0; 96 | top: 0; 97 | bottom: 0; 98 | padding: 1em; 99 | background: color-mix( 100 | in srgb, 101 | ${(props) => props.theme.palette.background.bot} 15%, 102 | transparent 103 | ); 104 | backdrop-filter: blur(3px); 105 | display: flex; 106 | justify-content: center; 107 | align-items: center; 108 | border: none; 109 | width: 4em; 110 | height: 4em; 111 | border-radius: 50%; 112 | 113 | cursor: pointer; 114 | z-index: 5; 115 | 116 | & svg { 117 | stroke: ${prop('theme.palette.background.bot')}; 118 | } 119 | 120 | &:hover, 121 | &:focus { 122 | svg { 123 | stroke: ${prop('theme.palette.text.bot')}; 124 | } 125 | } 126 | 127 | ${prop('theme.overrides.carouselArrow', '')}; 128 | `; 129 | 130 | const Carousel: (props: { 131 | children?: ReactElement[]; 132 | accessibility?: TockAccessibility; 133 | }) => JSX.Element = ({ 134 | children, 135 | accessibility, 136 | }: { 137 | children?: ReactElement[]; 138 | accessibility?: TockAccessibility; 139 | }) => { 140 | const theme: TockTheme = useTheme(); 141 | 142 | const [ref, previous, next] = useCarousel(children?.length); 143 | const [leftVisible, rightVisible] = useArrowVisibility( 144 | ref.container, 145 | ref.items.map((item) => item.refObject), 146 | ); 147 | 148 | return ( 149 | 150 | {leftVisible && ( 151 | 152 | 160 | 161 | )} 162 | 170 | {children?.map((child, i) => { 171 | const cardRef = ref.items[i].refObject; 172 | return ( 173 |
  • 183 | {cloneElement( 184 | child, 185 | { 186 | isHidden: ref.items[i].isHidden, 187 | }, 188 | undefined, 189 | )} 190 |
  • 191 | ); 192 | })} 193 |
    194 | {rightVisible && ( 195 | 196 | 204 | 205 | )} 206 |
    207 | ); 208 | }; 209 | 210 | export default Carousel; 211 | -------------------------------------------------------------------------------- /src/components/Carousel/hooks/useArrowVisibility.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState, useEffect, useCallback } from 'react'; 2 | 3 | export default function useArrowVisibility( 4 | ref: RefObject, 5 | itemRefs: RefObject[], 6 | ): [boolean, boolean] { 7 | const [visibility, setVisibility] = useState<[boolean, boolean]>([ 8 | false, 9 | true, 10 | ]); 11 | 12 | const computeVisibility = useCallback(() => { 13 | if (!ref.current) return; 14 | const { scrollLeft = 0, clientWidth = 0, scrollWidth = 0 } = ref.current; 15 | const leftVisibility = scrollLeft > 0; 16 | const rightVisibility = scrollLeft + clientWidth < scrollWidth; 17 | if (visibility[0] !== leftVisibility || visibility[1] !== rightVisibility) { 18 | setVisibility([leftVisibility, rightVisibility]); 19 | } 20 | }, [ref.current, visibility, setVisibility]); 21 | 22 | useEffect(computeVisibility, [itemRefs]); 23 | 24 | useEffect(() => { 25 | if (!ref.current) return; 26 | ref.current.addEventListener('resize', computeVisibility); 27 | ref.current.addEventListener('scroll', computeVisibility); 28 | return () => { 29 | ref.current?.removeEventListener('resize', computeVisibility); 30 | ref.current?.removeEventListener('scroll', computeVisibility); 31 | }; 32 | }, [ref.current, visibility]); 33 | 34 | return visibility; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Carousel/hooks/useCarousel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RefObject, 3 | useRef, 4 | useCallback, 5 | useEffect, 6 | useState, 7 | Dispatch, 8 | SetStateAction, 9 | } from 'react'; 10 | import useRefs from './useRefs'; 11 | import useMeasures, { Measure } from './useMeasures'; 12 | 13 | type CarouselItem = { 14 | refObject: RefObject; 15 | isHidden: boolean; 16 | setIsHidden: Dispatch>; 17 | }; 18 | 19 | type CarouselReturn = [ 20 | { 21 | container: RefObject; 22 | items: CarouselItem[]; 23 | }, 24 | () => void, 25 | () => void, 26 | ]; 27 | 28 | function getMeanX( 29 | previous: Measure | undefined, 30 | target: Measure | undefined, 31 | ): number { 32 | if (!previous || !target) return 0; 33 | return Math.round((previous.x + previous.width + target.x) / 2); 34 | } 35 | 36 | function setHiddenItems( 37 | measures: Measure[], 38 | carouselItems: CarouselItem[], 39 | targetIndex: number, 40 | width: number, 41 | ) { 42 | if (width !== 0) { 43 | carouselItems.forEach((item) => { 44 | const offsetLeftItem = item.refObject.current?.offsetLeft || 0; 45 | 46 | if ( 47 | offsetLeftItem < measures[targetIndex].x || 48 | offsetLeftItem + (item.refObject.current?.offsetWidth || 0) > 49 | measures[targetIndex].x + width 50 | ) { 51 | item.setIsHidden(true); 52 | } else { 53 | item.setIsHidden(false); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | function scrollStep( 60 | direction: 'NEXT' | 'PREVIOUS', 61 | container: HTMLElement | null, 62 | measures: Measure[], 63 | carouselItems: CarouselItem[], 64 | ) { 65 | if (!container) return; 66 | const x = container.scrollLeft; 67 | const width = container.clientWidth; 68 | 69 | if (direction === 'NEXT') { 70 | const targetIndex = measures.findIndex( 71 | (measure) => measure.x + measure.width > x + width, 72 | ); 73 | container.scrollLeft = getMeanX( 74 | measures[targetIndex], 75 | measures[targetIndex - 1], 76 | ); 77 | setHiddenItems(measures, carouselItems, targetIndex, width); 78 | } else { 79 | const firstLeftHidden = measures 80 | .slice() 81 | .reverse() 82 | .find((measure) => measure.x < x); 83 | if (!firstLeftHidden) return; 84 | 85 | const targetIndex = measures.findIndex( 86 | (measure) => 87 | firstLeftHidden.x - measure.x < width - firstLeftHidden.width, 88 | ); 89 | container.scrollLeft = getMeanX( 90 | measures[targetIndex - 1], 91 | measures[targetIndex], 92 | ); 93 | setHiddenItems(measures, carouselItems, targetIndex, width); 94 | } 95 | } 96 | 97 | export default function useCarousel(itemCount = 0): CarouselReturn { 98 | const containerRef = useRef(null); 99 | const itemRefs = useRefs(itemCount); 100 | const measures = useMeasures(itemRefs); 101 | const carouselItems: CarouselItem[] = Array.from( 102 | Array(itemCount), 103 | ).map((_, i) => { 104 | const [isHidden, setIsHidden] = useState(false); 105 | return Object.create({ refObject: itemRefs[i++], isHidden, setIsHidden }); 106 | }); 107 | 108 | const previous = useCallback( 109 | () => scrollStep('PREVIOUS', containerRef.current, measures, carouselItems), 110 | [containerRef, measures], 111 | ); 112 | 113 | const next = useCallback( 114 | () => scrollStep('NEXT', containerRef.current, measures, carouselItems), 115 | [containerRef, measures], 116 | ); 117 | 118 | useEffect(() => { 119 | if (measures !== undefined && measures.length !== 0) { 120 | setHiddenItems( 121 | measures, 122 | carouselItems, 123 | 0, 124 | (containerRef.current as HTMLElement | null)?.clientWidth || 0, 125 | ); 126 | } 127 | }, [measures]); 128 | 129 | return [ 130 | { 131 | container: containerRef, 132 | items: carouselItems, 133 | }, 134 | previous, 135 | next, 136 | ]; 137 | } 138 | -------------------------------------------------------------------------------- /src/components/Carousel/hooks/useMeasures.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from 'react'; 2 | 3 | export interface Measure { 4 | width: number; 5 | height: number; 6 | x: number; 7 | y: number; 8 | } 9 | 10 | const measureElement = (element: HTMLElement | null): Measure => ({ 11 | width: element?.clientWidth || 0, 12 | height: element?.clientHeight || 0, 13 | x: element?.offsetLeft || 0, 14 | y: element?.offsetTop || 0, 15 | }); 16 | 17 | export default function useMeasures(refs: RefObject[]): Measure[] { 18 | const [measures, setMeasures] = useState([]); 19 | 20 | useEffect(() => { 21 | const nextMeasures = refs.map((ref) => measureElement(ref.current)); 22 | setMeasures(nextMeasures); 23 | }, [refs]); 24 | 25 | return measures; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Carousel/hooks/useRefs.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState, useEffect, createRef } from 'react'; 2 | 3 | type Refs = RefObject[]; 4 | 5 | export default function useRefs(count: number): Refs { 6 | const [refs, setRefs] = useState([]); 7 | 8 | useEffect(() => { 9 | const initialRefs = Array.from(Array(count)).map(() => 10 | createRef(), 11 | ); 12 | setRefs(initialRefs); 13 | }, []); 14 | 15 | useEffect(() => { 16 | setRefs((refs) => 17 | Array.from(Array(count)).map((_, i) => refs[i] || createRef()), 18 | ); 19 | }, [count]); 20 | 21 | return refs; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Carousel/index.ts: -------------------------------------------------------------------------------- 1 | import Carousel from './Carousel'; 2 | 3 | export default Carousel; 4 | -------------------------------------------------------------------------------- /src/components/Chat/Chat.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ReactNode } from 'react'; 4 | import styled from '@emotion/styled'; 5 | 6 | import ProductWidget from '../widgets/ProductWidget'; 7 | import Chat from './Chat'; 8 | import { useExampleMessages } from '../Conversation/Conversation.stories'; 9 | 10 | const Wrapper = ({ children }: { children: ReactNode }) => { 11 | useExampleMessages(); 12 | return children as JSX.Element; 13 | }; 14 | 15 | const ModalContainer = styled.div` 16 | position: fixed; 17 | top: 50%; 18 | left: 50%; 19 | background-color: rgba(12, 12, 12, 0.65); 20 | transform: translate(-50%, -50%); 21 | width: 800px; 22 | height: 600px; 23 | padding: 1rem; 24 | max-height: 100vh; 25 | max-width: 100vw; 26 | border: 1px solid black; 27 | `; 28 | 29 | const FullscreenContainer = styled.div` 30 | position: fixed; 31 | top: 0; 32 | left: 0; 33 | right: 0; 34 | bottom: 0; 35 | `; 36 | 37 | const meta: Meta = { 38 | component: Chat, 39 | tags: ['autodocs'], 40 | title: 'Chat app', 41 | args: { 42 | widgets: { 43 | ProductWidget, 44 | }, 45 | }, 46 | }; 47 | 48 | export default meta; 49 | export type Story = StoryObj; 50 | 51 | export const Empty: Story = { 52 | decorators: [ 53 | (Story) => ( 54 | 55 | 56 | 57 | ), 58 | ], 59 | }; 60 | 61 | export const DefaultFullScreen: Story = { 62 | name: 'Default full screen', 63 | decorators: [ 64 | (Story) => ( 65 | 66 | 67 | 68 | 69 | 70 | ), 71 | ], 72 | }; 73 | 74 | export const DefaultModal: Story = { 75 | name: 'Default modal', 76 | decorators: [ 77 | (Story) => ( 78 | 79 | 80 | 81 | 82 | 83 | ), 84 | ], 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/Chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, useEffect } from 'react'; 2 | import useTock, { UseTock } from '../../useTock'; 3 | import ChatInput from '../ChatInput'; 4 | import Container from '../Container'; 5 | import Conversation from '../Conversation'; 6 | import TockAccessibility from '../../TockAccessibility'; 7 | import type TockLocalStorage from 'TockLocalStorage'; 8 | import PostInitContext from '../../PostInitContext'; 9 | 10 | export interface ChatProps { 11 | /** @deprecated endpoint should be configured in {@link TockSettings} */ 12 | endPoint?: string; 13 | referralParameter?: string; 14 | timeoutBetweenMessage?: number; 15 | /** A callback that will be executed once the chat is able to send and receive messages */ 16 | afterInit?: (tock: PostInitContext) => void | Promise; 17 | /** An initial message to send to the backend to trigger a welcome sequence. 18 | This message will be sent after the {@link afterInit} callback runs */ 19 | openingMessage?: string; 20 | /** A registry of custom widget factories */ 21 | widgets?: { [id: string]: (props: unknown) => JSX.Element }; 22 | /** @deprecated configure extra headers through {@link NetworkSettings} instead */ 23 | extraHeadersProvider?: () => Promise>; 24 | /** @deprecated configure SSE through {@link NetworkSettings} instead */ 25 | disableSse?: boolean; 26 | accessibility?: TockAccessibility; 27 | /** @deprecated configure local message history through {@link LocalStorageSettings} instead */ 28 | localStorageHistory?: TockLocalStorage; 29 | } 30 | 31 | const Chat: (props: ChatProps) => JSX.Element = ({ 32 | endPoint, 33 | referralParameter, 34 | timeoutBetweenMessage = 700, 35 | afterInit, 36 | openingMessage, 37 | widgets = {}, 38 | extraHeadersProvider = undefined, 39 | disableSse = false, 40 | accessibility = {}, 41 | localStorageHistory = {}, 42 | }: ChatProps) => { 43 | const { 44 | messages, 45 | quickReplies, 46 | loading, 47 | sendMessage, 48 | sendQuickReply, 49 | sendAction, 50 | sendReferralParameter, 51 | sendOpeningMessage, 52 | sendPayload, 53 | loadHistory, 54 | sseInitPromise, 55 | sseInitializing, 56 | clearMessages, 57 | error, 58 | }: UseTock = useTock( 59 | endPoint, 60 | extraHeadersProvider, 61 | disableSse, 62 | localStorageHistory, 63 | ); 64 | 65 | useEffect(() => { 66 | // When the chat gets initialized for the first time, process optional referral|opening message 67 | sseInitPromise.then(async () => { 68 | const history = loadHistory(); 69 | 70 | if (afterInit) { 71 | await afterInit({ 72 | history, 73 | clearMessages, 74 | sendMessage, 75 | sendPayload, 76 | }); 77 | } 78 | 79 | if (referralParameter) { 80 | await sendReferralParameter(referralParameter); 81 | } 82 | 83 | if (!history && messages.length === 0 && openingMessage) { 84 | await sendOpeningMessage(openingMessage); 85 | } 86 | }); 87 | }, [openingMessage, referralParameter]); 88 | 89 | return ( 90 | 91 | 102 | 108 | 109 | ); 110 | }; 111 | 112 | export default Chat; 113 | -------------------------------------------------------------------------------- /src/components/Chat/index.ts: -------------------------------------------------------------------------------- 1 | import Chat from './Chat'; 2 | 3 | export default Chat; 4 | -------------------------------------------------------------------------------- /src/components/ChatInput/ChatInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { action } from '@storybook/addon-actions'; 4 | import ChatInput from './ChatInput'; 5 | 6 | const meta: Meta = { 7 | component: ChatInput, 8 | tags: ['autodocs'], 9 | }; 10 | 11 | export default meta; 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = { 15 | args: { 16 | onSubmit: action('message'), 17 | clearMessages: action('clear'), 18 | }, 19 | }; 20 | 21 | export const Disabled: Story = { 22 | args: { 23 | ...Default.args, 24 | disabled: true, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/ChatInput/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { 3 | DetailedHTMLProps, 4 | FormEvent, 5 | FormHTMLAttributes, 6 | HTMLAttributes, 7 | InputHTMLAttributes, 8 | useState, 9 | } from 'react'; 10 | import { Send, Trash2 } from 'react-feather'; 11 | import { theme } from 'styled-tools'; 12 | import TockAccessibility from 'TockAccessibility'; 13 | 14 | const InputOuterContainer: StyledComponent< 15 | DetailedHTMLProps, HTMLFormElement> 16 | > = styled.form` 17 | max-width: ${theme('sizing.conversation.width')}; 18 | width: 100%; 19 | position: relative; 20 | margin: 0.5em auto; 21 | display: flex; 22 | align-items: center; 23 | ${theme('overrides.chatInput.container', '')} 24 | `; 25 | 26 | const Input: StyledComponent< 27 | DetailedHTMLProps, HTMLInputElement> 28 | > = styled.input` 29 | width: 100%; 30 | height: 2em; 31 | flex: 1; 32 | border-radius: ${theme('sizing.borderRadius')}; 33 | padding: 0.5em 3em 0.5em 1em; 34 | 35 | background: ${theme('palette.background.input')}; 36 | color: ${theme('palette.text.input')}; 37 | 38 | border: none; 39 | 40 | font-family: inherit; 41 | font-size: inherit; 42 | 43 | &.disabled-input { 44 | background: ${theme('palette.background.inputDisabled')}; 45 | } 46 | 47 | ${theme('overrides.chatInput.input', '')} 48 | `; 49 | 50 | const SubmitIcon: StyledComponent< 51 | DetailedHTMLProps, HTMLButtonElement> 52 | > = styled.button` 53 | position: absolute; 54 | background: none; 55 | border: none; 56 | border-radius: 50%; 57 | right: calc(${theme('typography.fontSize')} * 2); 58 | flex: 0; 59 | cursor: pointer; 60 | height: 100%; 61 | width: calc(${theme('typography.fontSize')} * 3); 62 | & svg { 63 | stroke: ${theme('palette.background.bot')}; 64 | fill: ${theme('palette.text.bot')}; 65 | } 66 | 67 | & > svg { 68 | position: relative; 69 | top: 0; 70 | right: 0; 71 | height: 80%; 72 | 73 | &:hover, 74 | &:focus { 75 | stroke: ${theme('palette.text.bot')}; 76 | fill: ${theme('palette.background.bot')}; 77 | } 78 | } 79 | ${theme('overrides.chatInput.icon', '')} 80 | `; 81 | 82 | const ClearIcon: StyledComponent< 83 | DetailedHTMLProps, HTMLButtonElement> 84 | > = styled.button` 85 | position: absolute; 86 | background: none; 87 | border: none; 88 | border-radius: 50%; 89 | right: 0; 90 | flex: 0; 91 | cursor: pointer; 92 | height: 100%; 93 | width: calc(${theme('typography.fontSize')} * 3); 94 | & svg { 95 | stroke: ${theme('palette.background.bot')}; 96 | fill: ${theme('palette.text.bot')}; 97 | } 98 | 99 | & > svg { 100 | position: relative; 101 | top: 0; 102 | right: 0; 103 | 104 | &:hover, 105 | &:focus { 106 | stroke: ${theme('palette.text.bot')}; 107 | fill: ${theme('palette.background.bot')}; 108 | } 109 | } 110 | ${theme('overrides.chatInput.icon', '')} 111 | `; 112 | 113 | export interface ChatInputProps { 114 | disabled?: boolean; 115 | onSubmit: (message: string) => void; 116 | accessibility?: TockAccessibility; 117 | clearMessages: () => void; 118 | } 119 | 120 | const ChatInput: (props: ChatInputProps) => JSX.Element = ({ 121 | disabled, 122 | onSubmit, 123 | accessibility, 124 | clearMessages, 125 | }: ChatInputProps): JSX.Element => { 126 | const [value, setValue] = useState(''); 127 | const submit = (event: FormEvent) => { 128 | event.preventDefault(); 129 | if (value) { 130 | onSubmit(value); 131 | setValue(''); 132 | } 133 | }; 134 | 135 | return ( 136 | 137 | setValue(value)} 142 | /> 143 | 144 | 150 | 151 | 152 | 162 | 163 | 164 | ); 165 | }; 166 | 167 | export default ChatInput; 168 | -------------------------------------------------------------------------------- /src/components/ChatInput/index.ts: -------------------------------------------------------------------------------- 1 | import ChatInput from './ChatInput'; 2 | 3 | export * from './ChatInput'; 4 | export default ChatInput; 5 | -------------------------------------------------------------------------------- /src/components/Container/Container.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 3 | import { prop } from 'styled-tools'; 4 | 5 | const Container: StyledComponent< 6 | DetailedHTMLProps, HTMLDivElement> 7 | > = styled.div` 8 | width: 100%; 9 | height: 100%; 10 | display: flex; 11 | flex-direction: column; 12 | font-family: ${prop('theme.typography.fontFamily')}; 13 | font-size: ${prop('theme.typography.fontSize')}; 14 | 15 | & > *:first-child { 16 | flex: 1; 17 | overflow-y: auto; 18 | ::-webkit-scrollbar { 19 | width: 0; /* Remove scrollbar space */ 20 | background: transparent; /* Optional: just make scrollbar invisible */ 21 | } 22 | } 23 | 24 | & > *:not(:first-child) { 25 | flex: unset; 26 | } 27 | 28 | & * { 29 | font: inherit; 30 | } 31 | 32 | ${prop('theme.overrides.chat', '')}; 33 | `; 34 | 35 | export default Container; 36 | -------------------------------------------------------------------------------- /src/components/Container/index.ts: -------------------------------------------------------------------------------- 1 | import Container from './Container'; 2 | 3 | export default Container; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/Conversation.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import useTock, { UseTock } from '../../useTock'; 6 | import { PostBackButton, UrlButton } from '../../model/buttons'; 7 | import { MessageType } from '../../model/messages'; 8 | import Product from '../widgets/ProductWidget/Product'; 9 | import Conversation from './Conversation'; 10 | 11 | export const useExampleMessages = () => { 12 | const tock: UseTock = useTock(''); 13 | const { 14 | addMessage, 15 | addCard, 16 | addCarousel, 17 | addWidget, 18 | setQuickReplies, 19 | addImage, 20 | } = tock; 21 | useEffect(() => { 22 | const product: Product = { 23 | name: 'Product name', 24 | description: 'product description', 25 | price: 99.9, 26 | }; 27 | addWidget({ 28 | data: product, 29 | type: 'ProductWidget', 30 | }); 31 | addWidget({ 32 | data: { 33 | title: 'unknown Widget', 34 | }, 35 | type: 'unknownWidget', 36 | }); 37 | addMessage('Hello! 😊', 'user'); 38 | addMessage( 39 | 'Hello! I am a chatbot 🤖
    I am powered ⚙️ by Tock! 💡', 40 | 'bot', 41 | ); 42 | addMessage('How are you doing?', 'user'); 43 | addMessage('I am doing great thank you!', 'bot'); 44 | addMessage('What can you do?', 'user'); 45 | addMessage('So far I can:', 'bot'); 46 | addMessage('Send a card with an image...', 'bot'); 47 | addCard( 48 | 'The Open Conversation Kit', 49 | 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 50 | 'Build assistants & chatbots with ease', 51 | ); 52 | addMessage('Or without an image...', 'bot'); 53 | addCard( 54 | 'The Open Conversation Kit', 55 | undefined, 56 | '

    Key1: Value1

    Key2: Value2

    Key3: Value3

    Key4: Value4

    ', 57 | ); 58 | addMessage('Or with a url button', 'bot'); 59 | addCard('The Open Conversation Kit', undefined, '

    Some text

    ', [ 60 | new UrlButton('Website', 'https://sncf.com'), 61 | ]); 62 | addMessage('Or a carousel with two cards', 'bot'); 63 | addCarousel([ 64 | { 65 | title: 'SNCF', 66 | imageUrl: 67 | 'https://www.sncf.com/sites/default/files/styles/media_crop_4_3_paragraphe_50_50/public/2019-07/Train-spe%CC%81cial_Femme-en-or.jpg', 68 | type: MessageType.card, 69 | buttons: [ 70 | new UrlButton('Website', 'https://sncf.com', undefined, 'popup'), 71 | ], 72 | }, 73 | { 74 | title: 'Popup', 75 | type: MessageType.card, 76 | buttons: [ 77 | new UrlButton( 78 | 'Popup', 79 | 'https://example.com', 80 | undefined, 81 | 'popup', 82 | 'width=420,height=250', 83 | ), 84 | ], 85 | }, 86 | ]); 87 | addMessage('With one card', 'bot'); 88 | addCarousel([ 89 | { 90 | title: 'SNCF', 91 | imageUrl: 92 | 'https://www.sncf.com/sites/default/files/styles/media_crop_4_3_paragraphe_50_50/public/2019-07/Train-spe%CC%81cial_Femme-en-or.jpg', 93 | type: MessageType.card, 94 | buttons: [new UrlButton('Website', 'https://sncf.com')], 95 | }, 96 | ]); 97 | addMessage('With 4 cards', 'bot'); 98 | addCarousel([ 99 | { 100 | title: 'SNCF', 101 | imageUrl: 102 | 'https://www.sncf.com/sites/default/files/styles/media_crop_4_3_paragraphe_50_50/public/2019-07/Train-spe%CC%81cial_Femme-en-or.jpg', 103 | type: MessageType.card, 104 | buttons: [new UrlButton('Website', 'https://sncf.com')], 105 | }, 106 | { 107 | title: 'OUI.sncf', 108 | imageUrl: 109 | 'https://www.oui.sncf/sites/all/modules/custom/vsct_feature_seo/images/oui-fb.jpg', 110 | type: MessageType.card, 111 | buttons: [new UrlButton('Website', 'https://sncf.com')], 112 | }, 113 | { 114 | title: 'TGV inOUI', 115 | imageUrl: 116 | 'https://www.sncf.com/sites/default/files/styles/crop_header_edito/public/2018-10/Resized_20180920_135209_921.jpg', 117 | type: MessageType.card, 118 | buttons: [new UrlButton('Website', 'https://sncf.com')], 119 | }, 120 | { 121 | title: 'Transilien', 122 | imageUrl: 123 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Z50000_12dec2009_IMG_6241.jpg/1920px-Z50000_12dec2009_IMG_6241.jpg', 124 | type: MessageType.card, 125 | buttons: [new UrlButton('Website', 'https://sncf.com')], 126 | }, 127 | ]); 128 | addMessage('Message with url button', 'bot', [ 129 | new UrlButton('Url Website', 'https://sncf.com'), 130 | ]); 131 | addMessage('Message with postback button with image url', 'bot', [ 132 | new PostBackButton( 133 | 'Post back Website', 134 | '', 135 | 'https://www.sncf.com/themes/sncfcom/img/favicon-32x32.png', 136 | ), 137 | ]); 138 | addMessage('Message with postback button', 'bot', [ 139 | new PostBackButton('Post back Website', ''), 140 | ]); 141 | addMessage('Message with postback button with image url', 'bot', [ 142 | new PostBackButton( 143 | 'Post back Website', 144 | '', 145 | 'https://www.sncf.com/themes/sncfcom/img/favicon-32x32.png', 146 | ), 147 | ]); 148 | addMessage('Send an image clickable...', 'bot'); 149 | addImage( 150 | 'Image 👺', 151 | 'https://www.sncf.com/sites/default/files/styles/crop_header_edito/public/2018-10/Resized_20180920_135209_921.jpg', 152 | ); 153 | 154 | setQuickReplies([ 155 | { 156 | payload: '', 157 | label: 'QR with payload', 158 | }, 159 | { 160 | label: 'Nlp QR', 161 | }, 162 | { 163 | label: 'QR with NLP', 164 | nlpText: 'nlp text', 165 | }, 166 | { 167 | label: 'QR with image Url', 168 | imageUrl: 'https://www.sncf.com/themes/sncfcom/img/favicon-32x32.png', 169 | }, 170 | ]); 171 | }, []); 172 | }; 173 | 174 | const useRenderProps = () => { 175 | useExampleMessages(); 176 | return useTock(''); 177 | }; 178 | 179 | const Wrapper = ({ 180 | children, 181 | }: { 182 | children: (tock: UseTock) => JSX.Element; 183 | }): JSX.Element => { 184 | const tock = useRenderProps(); 185 | return children(tock); 186 | }; 187 | 188 | const meta: Meta = { 189 | component: Conversation, 190 | tags: ['autodocs'], 191 | excludeStories: ['useExampleMessages'], 192 | }; 193 | 194 | export default meta; 195 | type Story = StoryObj; 196 | 197 | export const WithMessages: Story = { 198 | name: 'With messages', 199 | args: { 200 | messageDelay: 500, 201 | }, 202 | // eslint-disable-next-line react/prop-types,react/display-name 203 | render: ({ messageDelay }) => ( 204 | 205 | {({ messages, loading, quickReplies, sendAction, sendQuickReply }) => ( 206 | 215 | )} 216 | 217 | ), 218 | }; 219 | -------------------------------------------------------------------------------- /src/components/Conversation/Conversation.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes, RefObject } from 'react'; 2 | import styled from '@emotion/styled'; 3 | import { useTheme } from '@emotion/react'; 4 | 5 | import DefaultWidget from '../widgets/DefaultWidget'; 6 | import MessageBot from '../MessageBot'; 7 | import MessageUser from '../MessageUser'; 8 | import Card from '../Card'; 9 | import Carousel from '../Carousel'; 10 | import Loader from '../Loader'; 11 | import Image from '../Image'; 12 | import QuickReplyList from '../QuickReplyList'; 13 | import InlineQuickReplyList from '../InlineQuickReplyList'; 14 | import useMessageCounter from './hooks/useMessageCounter'; 15 | import useScrollBehaviour from './hooks/useScrollBehaviour'; 16 | import TockTheme from '../../styles/theme'; 17 | 18 | import TockAccessibility from '../../TockAccessibility'; 19 | import { Button, QuickReply } from '../../model/buttons'; 20 | import type { 21 | Card as ICard, 22 | Carousel as ICarousel, 23 | Image as IImage, 24 | Message, 25 | MessageType, 26 | TextMessage, 27 | Widget, 28 | } from '../../model/messages'; 29 | import { MessageMetadataContext } from '../../MessageMetadata'; 30 | import { useTockSettings } from '../../settings/TockSettingsContext'; 31 | 32 | const ConversationOuterContainer = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | `; 36 | 37 | const ConversationInnerContainer = styled.ul` 38 | padding: 0; 39 | margin: 0; 40 | flex-grow: 1; 41 | flex-shrink: 1; 42 | list-style: none; 43 | overflow-y: scroll; 44 | scroll-behavior: smooth; 45 | scrollbar-width: none; 46 | ::-webkit-scrollbar { 47 | display: none; 48 | } 49 | `; 50 | 51 | const ConversationItemLi = styled.li` 52 | width: 100%; 53 | display: flex; 54 | flex-direction: row; 55 | justify-content: center; 56 | margin: 0.5em 0; 57 | `; 58 | 59 | interface RenderOptions { 60 | widgets: { [id: string]: (props: unknown) => JSX.Element }; 61 | onAction: (button: Button) => void; 62 | } 63 | 64 | const renderWidget = (widget: Widget, options: RenderOptions) => { 65 | const WidgetRenderer = 66 | options.widgets?.[widget.widgetData.type] ?? DefaultWidget; 67 | return ; 68 | }; 69 | 70 | const renderMessage = (message: TextMessage, options: RenderOptions) => { 71 | if (message.author === 'bot') { 72 | return ; 73 | } 74 | return {message.message}; 75 | }; 76 | 77 | const renderImage = (image: IImage) => { 78 | return ; 79 | }; 80 | 81 | const renderCard = (card: ICard, options: RenderOptions) => ( 82 | 83 | ); 84 | 85 | const renderCarousel = ( 86 | carousel: ICarousel, 87 | options: RenderOptions, 88 | accessibility?: TockAccessibility, 89 | ) => ( 90 | 91 | {carousel.cards.map((card: ICard, index: number) => ( 92 | 93 | ))} 94 | 95 | ); 96 | 97 | interface Renderer { 98 | ( 99 | message: Message, 100 | options: RenderOptions, 101 | accessibility?: TockAccessibility, 102 | ): JSX.Element; 103 | } 104 | 105 | const MESSAGE_RENDERER: { 106 | [key in MessageType]: Renderer; 107 | } = { 108 | widget: renderWidget, 109 | message: renderMessage, 110 | card: renderCard, 111 | carousel: renderCarousel, 112 | image: renderImage, 113 | }; 114 | 115 | type Props = DetailedHTMLProps< 116 | HTMLAttributes, 117 | HTMLDivElement 118 | > & { 119 | messages: Message[]; 120 | messageDelay: number; 121 | widgets?: { [id: string]: (props: unknown) => JSX.Element }; 122 | loading?: boolean; 123 | error?: boolean; 124 | quickReplies: QuickReply[]; 125 | onAction: (button: Button) => void; 126 | onQuickReplyClick: (button: Button) => void; 127 | accessibility?: TockAccessibility; 128 | }; 129 | 130 | const Conversation = ({ 131 | messages, 132 | messageDelay, 133 | loading = false, 134 | error = false, 135 | onAction, 136 | widgets = {}, 137 | onQuickReplyClick, 138 | quickReplies, 139 | accessibility, 140 | ...rest 141 | }: Props): JSX.Element => { 142 | if (messages && messages.length !== 0) { 143 | const displayableMessageCount = useMessageCounter( 144 | messages, 145 | messageDelay, 146 | (message) => 'author' in message && message.author === 'user', 147 | ); 148 | const theme: TockTheme = useTheme(); 149 | const displayableMessages = messages.slice(0, displayableMessageCount); 150 | const scrollContainer: RefObject = useScrollBehaviour([ 151 | displayableMessages, 152 | ]); 153 | const ErrorMessageRenderer = 154 | useTockSettings().renderers.messageRenderers.error; 155 | const renderMessage = (message: Message, index: number) => { 156 | const render: Renderer = MESSAGE_RENDERER[message.type]; 157 | if (!render) return null; 158 | return ( 159 | 163 | 164 | {render( 165 | message, 166 | { 167 | widgets, 168 | onAction, 169 | }, 170 | accessibility, 171 | )} 172 | 173 | 174 | ); 175 | }; 176 | 177 | return ( 178 | 184 | 185 | {displayableMessages.map(renderMessage)} 186 | {loading && } 187 | {error && ErrorMessageRenderer && } 188 | 189 | {!loading && 190 | displayableMessageCount === messages.length && 191 | (theme.inlineQuickReplies ? ( 192 | 197 | ) : ( 198 | 202 | ))} 203 | 204 | ); 205 | } else { 206 | return ( 207 | 213 | ); 214 | } 215 | }; 216 | 217 | export default Conversation; 218 | -------------------------------------------------------------------------------- /src/components/Conversation/hooks/useMessageCounter.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Message } from '../../../model/messages'; 3 | 4 | export default function useMessageCounter( 5 | messages: Message[], 6 | delay: number, 7 | skipDelay: (message: Message) => boolean, 8 | ): number { 9 | const [counter, setCounter] = useState(0); 10 | const targetValue = messages.length; 11 | const shouldDisplayNow = (m: Message) => m.alreadyDisplayed || skipDelay(m); 12 | const advance = () => { 13 | setCounter((c) => { 14 | // always increment the counter by at least one 15 | do { 16 | messages[c].alreadyDisplayed = true; 17 | c++; 18 | } while (c < targetValue && shouldDisplayNow(messages[c])); 19 | return c; 20 | }); 21 | }; 22 | 23 | if (counter > targetValue) { 24 | setCounter(targetValue); 25 | } else if (counter < targetValue && shouldDisplayNow(messages[counter])) { 26 | advance(); 27 | } 28 | 29 | useEffect(() => { 30 | if (counter < targetValue) { 31 | const id = setTimeout(advance, delay); 32 | return () => clearTimeout(id); 33 | } 34 | return; 35 | }, [counter, targetValue]); 36 | 37 | return counter; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Conversation/hooks/useScrollBehaviour.ts: -------------------------------------------------------------------------------- 1 | import { useRef, RefObject, useEffect, DependencyList } from 'react'; 2 | 3 | export default function useScrollBehaviour( 4 | deps: DependencyList, 5 | ): RefObject { 6 | const container: RefObject = useRef(null); 7 | useEffect(() => { 8 | const { current } = container; 9 | if (!current) return; 10 | current.scrollTop = current.scrollHeight; 11 | return; 12 | }, deps); 13 | return container; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Conversation/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Conversation'; 2 | export { default as useMessageCounter } from './hooks/useMessageCounter'; 3 | -------------------------------------------------------------------------------- /src/components/Image/Image.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Image from './Image'; 4 | import { TockContext, useMessageMetadata } from '../../index'; 5 | import { MessageMetadataContext } from '../../MessageMetadata'; 6 | 7 | const meta: Meta = { 8 | component: Image, 9 | tags: ['autodocs'], 10 | }; 11 | 12 | export default meta; 13 | type Story = StoryObj; 14 | 15 | export const Default: Story = { 16 | args: { 17 | title: 'TOCK Logo', 18 | url: 'https://avatars0.githubusercontent.com/u/48585267?s=200&v=4', 19 | }, 20 | }; 21 | 22 | export const WithDescription: Story = { 23 | args: { 24 | ...Default.args, 25 | alternative: 'Image of the Tock icon', 26 | }, 27 | }; 28 | 29 | /** 30 | * Uses message metadata to determine image width and height 31 | */ 32 | export const WithCustomRendering: Story = { 33 | render: () => ( 34 | { 39 | const [width, height] = useMessageMetadata() 40 | ['DIMENSIONS']?.split('x') 41 | ?.map((d) => +d); 42 | return ( 43 | {alt} 50 | ); 51 | }, 52 | }, 53 | }, 54 | }} 55 | > 56 | 57 | 61 | 62 | 63 | ), 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { forwardRef, DetailedHTMLProps, HTMLAttributes } from 'react'; 3 | import { prop } from 'styled-tools'; 4 | import '../../styles/theme'; 5 | import { css, Interpolation, Theme } from '@emotion/react'; 6 | import { useImageRenderer } from '../../settings/RendererSettings'; 7 | 8 | const ImageOuter: StyledComponent< 9 | DetailedHTMLProps, HTMLDivElement> 10 | > = styled.div` 11 | max-width: ${prop('theme.sizing.conversation.width')}; 12 | width: 100%; 13 | `; 14 | 15 | export const ImageContainer: StyledComponent< 16 | DetailedHTMLProps, HTMLDivElement> 17 | > = styled.div` 18 | padding: 0.5em; 19 | background: ${prop('theme.palette.background.card')}; 20 | color: ${prop('theme.palette.text.card')}; 21 | border-radius: ${prop('theme.sizing.borderRadius')}; 22 | border: 2px solid ${prop('theme.palette.text.card')}; 23 | width: 20em; 24 | 25 | ${prop('theme.overrides.card.cardContainer', '')}; 26 | `; 27 | 28 | const normalImageCss: Interpolation = [ 29 | css` 30 | max-width: 100%; 31 | max-height: 100%; 32 | `, 33 | (theme) => theme.overrides?.card?.cardImage, 34 | ]; 35 | 36 | export interface ImageProps { 37 | title?: string; 38 | url?: string; 39 | alternative?: string; 40 | } 41 | 42 | const Image = forwardRef(function imageRender( 43 | { url: src, alternative: alt }: ImageProps, 44 | ref, 45 | ) { 46 | const ImageRenderer = useImageRenderer('standalone'); 47 | return ( 48 | 49 | 50 | {src && ( 51 | 52 | 53 | 54 | )} 55 | 56 | 57 | ); 58 | }); 59 | 60 | export default Image; 61 | -------------------------------------------------------------------------------- /src/components/Image/index.ts: -------------------------------------------------------------------------------- 1 | import Image from './Image'; 2 | 3 | export * from './Image'; 4 | export default Image; 5 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/InlineQuickReplyList.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import InlineQuickReplyList from './InlineQuickReplyList'; 4 | 5 | const meta: Meta = { 6 | component: InlineQuickReplyList, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | export const MultipleQRs: Story = { 14 | name: 'Multiple QRs', 15 | args: { 16 | onItemClick: Function.bind(null), 17 | items: [ 18 | { label: 'Inline Quick Reply 1' }, 19 | { label: 'Inline Quick Reply 2' }, 20 | { label: 'Inline Quick Reply 3' }, 21 | { 22 | label: 'Inline QR With Image', 23 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 24 | }, 25 | { label: 'Inline Quick Reply 5' }, 26 | { label: 'Inline Quick Reply 6' }, 27 | { label: 'Inline Quick Reply 7' }, 28 | { label: 'Inline Quick Reply 8' }, 29 | { label: 'Inline Quick Reply 9' }, 30 | { label: 'Inline Quick Reply 10' }, 31 | { label: 'Inline Quick Reply 11' }, 32 | { label: 'Inline Quick Reply 12' }, 33 | { label: 'Inline Quick Reply 13' }, 34 | ], 35 | }, 36 | }; 37 | 38 | export const SingleQr: Story = { 39 | name: 'Single QR', 40 | args: { 41 | onItemClick: Function.bind(null), 42 | items: [{ label: 'Inline Quick Reply' }], 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/InlineQuickReplyList.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 2 | import { ArrowLeftCircle, ArrowRightCircle } from 'react-feather'; 3 | import { useTheme } from '@emotion/react'; 4 | import styled, { StyledComponent } from '@emotion/styled'; 5 | import { prop } from 'styled-tools'; 6 | import '../../styles/theme'; 7 | import TockTheme from '../../styles/theme'; 8 | import { Button } from '../../model/buttons'; 9 | import useCarouselQuickReply from './hooks/useCarouselQuickReply'; 10 | import useArrowVisibility from '../Carousel/hooks/useArrowVisibility'; 11 | import QuickReply from '../QuickReply/QuickReply'; 12 | import TockAccessibility from 'TockAccessibility'; 13 | 14 | const InlineQuickReplyListContainer: StyledComponent< 15 | DetailedHTMLProps, HTMLUListElement> 16 | > = styled.ul` 17 | display: flex; 18 | overflow: auto; 19 | -webkit-overflow-scrolling: touch; 20 | justify-content: start; 21 | scroll-behavior: smooth; 22 | touch-action: pan-x pan-y; 23 | align-items: center; 24 | position: relative; 25 | &::-webkit-scrollbar { 26 | display: none; 27 | } 28 | scrollbar-width: none; 29 | margin: 0.5em auto; 30 | 31 | & > * { 32 | flex-shrink: 0; 33 | } 34 | `; 35 | 36 | const Previous: StyledComponent< 37 | DetailedHTMLProps, HTMLButtonElement> 38 | > = styled.button` 39 | position: absolute; 40 | margin: auto; 41 | left: 0; 42 | top: 0; 43 | bottom: 0; 44 | padding: 1em; 45 | background: color-mix( 46 | in srgb, 47 | ${(props) => props.theme.palette.background.bot} 10%, 48 | transparent 49 | ); 50 | backdrop-filter: blur(1px); 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | border: none; 55 | width: 3em; 56 | height: 3em; 57 | border-radius: 50%; 58 | 59 | cursor: pointer; 60 | z-index: 5; 61 | 62 | & svg { 63 | stroke: ${prop('theme.palette.background.bot')}; 64 | } 65 | 66 | &:hover, 67 | &:focus { 68 | svg { 69 | stroke: ${prop('theme.palette.text.bot')}; 70 | } 71 | } 72 | 73 | ${prop('theme.overrides.quickReplyArrow', '')}; 74 | `; 75 | 76 | const Next: StyledComponent< 77 | DetailedHTMLProps, HTMLButtonElement> 78 | > = styled.button` 79 | position: absolute; 80 | margin: auto; 81 | right: 0; 82 | top: 0; 83 | bottom: 0; 84 | padding: 1em; 85 | background: color-mix( 86 | in srgb, 87 | ${(props) => props.theme.palette.background.bot} 10%, 88 | transparent 89 | ); 90 | backdrop-filter: blur(1px); 91 | display: flex; 92 | justify-content: center; 93 | align-items: center; 94 | border: none; 95 | width: 3em; 96 | height: 3em; 97 | border-radius: 50%; 98 | 99 | cursor: pointer; 100 | z-index: 5; 101 | 102 | & svg { 103 | stroke: ${prop('theme.palette.background.bot')}; 104 | } 105 | 106 | &:hover, 107 | &:focus { 108 | svg { 109 | stroke: ${prop('theme.palette.text.bot')}; 110 | } 111 | } 112 | 113 | ${prop('theme.overrides.quickReplyArrow', '')}; 114 | `; 115 | 116 | const InlineQuickReplyListOuterContainer: StyledComponent< 117 | DetailedHTMLProps, HTMLDivElement> 118 | > = styled.div` 119 | position: relative; 120 | `; 121 | type Props = { 122 | items: Button[]; 123 | onItemClick: (button: Button) => void; 124 | accessibility?: TockAccessibility; 125 | }; 126 | 127 | const InlineQuickReplyList = ({ 128 | items, 129 | onItemClick, 130 | accessibility, 131 | }: Props): JSX.Element => { 132 | const theme: TockTheme = useTheme(); 133 | const [ref, previous, next] = useCarouselQuickReply( 134 | items?.length, 135 | ); 136 | 137 | const [leftVisible, rightVisible] = useArrowVisibility( 138 | ref.container, 139 | ref.items, 140 | ); 141 | 142 | return ( 143 | 144 | {leftVisible && ( 145 | 146 | 153 | 154 | )} 155 | 156 | {items.map((child, index) => ( 157 | 163 | ))} 164 | 165 | {rightVisible && ( 166 | 167 | 173 | 174 | )} 175 | 176 | ); 177 | }; 178 | 179 | export default InlineQuickReplyList; 180 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/hooks/useButtonMeasures.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from 'react'; 2 | 3 | export interface Measure { 4 | width: number; 5 | height: number; 6 | x: number; 7 | y: number; 8 | } 9 | 10 | const measureElement = (element: HTMLButtonElement | null): Measure => ({ 11 | width: element?.offsetWidth || element?.clientWidth || 0, 12 | height: element?.offsetHeight || element?.clientHeight || 0, 13 | x: element?.offsetLeft || 0, 14 | y: element?.offsetTop || 0, 15 | }); 16 | 17 | export default function useButtonMeasures( 18 | refs: RefObject[], 19 | ): Measure[] { 20 | const [measures, setMeasures] = useState([]); 21 | 22 | useEffect(() => { 23 | const nextMeasures = refs.map((ref) => { 24 | return measureElement(ref.current); 25 | }); 26 | setMeasures(nextMeasures); 27 | }, [refs]); 28 | 29 | return measures; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/hooks/useButtonRefs.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState, useEffect, createRef } from 'react'; 2 | 3 | type Refs = RefObject[]; 4 | 5 | export default function useButtonRefs(count: number): Refs { 6 | const [refs, setRefs] = useState([]); 7 | 8 | useEffect(() => { 9 | const initialRefs = Array.from(Array(count)).map(() => 10 | createRef(), 11 | ); 12 | setRefs(initialRefs); 13 | }, []); 14 | 15 | useEffect(() => { 16 | setRefs((refs) => 17 | Array.from({ length: count }, (_, i) => refs[i] || createRef()), 18 | ); 19 | }, [count]); 20 | 21 | return refs; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/hooks/useCarouselQuickReply.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useRef, useCallback } from 'react'; 2 | import useButtonRefs from './useButtonRefs'; 3 | import useButtonMeasures, { Measure } from './useButtonMeasures'; 4 | 5 | type CarouselQuickReplyReturn = [ 6 | { 7 | container: RefObject; 8 | items: RefObject[]; 9 | }, 10 | () => void, 11 | () => void, 12 | ]; 13 | 14 | function getMeanX( 15 | previous: Measure | undefined, 16 | target: Measure | undefined, 17 | ): number { 18 | if (!previous || !target) return 0; 19 | return Math.round((previous.x + previous.width + target.x) / 2); 20 | } 21 | 22 | function scrollStep( 23 | direction: 'NEXT' | 'PREVIOUS', 24 | container: HTMLButtonElement | null, 25 | measures: Measure[], 26 | ) { 27 | if (!container) return; 28 | const x = container.scrollLeft; 29 | const width = container.clientWidth; 30 | 31 | if (direction === 'NEXT') { 32 | const targetIndex = measures.findIndex( 33 | (measure) => measure.x + measure.width > x + width, 34 | ); 35 | 36 | container.scrollLeft = getMeanX( 37 | measures[targetIndex], 38 | measures[targetIndex - 1], 39 | ); 40 | } else { 41 | const firstLeftHidden = measures 42 | .slice() 43 | .reverse() 44 | .find((measure) => measure.x < x); 45 | if (!firstLeftHidden) return; 46 | 47 | const targetIndex = measures.findIndex( 48 | (measure) => 49 | firstLeftHidden.x - measure.x < width - firstLeftHidden.width, 50 | ); 51 | container.scrollLeft = getMeanX( 52 | measures[targetIndex - 1], 53 | measures[targetIndex], 54 | ); 55 | } 56 | } 57 | 58 | export default function useCarouselQuickReply( 59 | itemCount = 0, 60 | ): CarouselQuickReplyReturn { 61 | const containerRef = useRef(null); 62 | const itemRefs = useButtonRefs(itemCount); 63 | const measures = useButtonMeasures(itemRefs); 64 | 65 | const previous = useCallback( 66 | () => scrollStep('PREVIOUS', containerRef.current, measures), 67 | [containerRef, measures], 68 | ); 69 | 70 | const next = useCallback( 71 | () => scrollStep('NEXT', containerRef.current, measures), 72 | [containerRef, measures], 73 | ); 74 | 75 | return [ 76 | { 77 | container: containerRef, 78 | items: itemRefs, 79 | }, 80 | previous, 81 | next, 82 | ]; 83 | } 84 | -------------------------------------------------------------------------------- /src/components/InlineQuickReplyList/index.ts: -------------------------------------------------------------------------------- 1 | import InlineQuickReplyList from './InlineQuickReplyList'; 2 | 3 | export default InlineQuickReplyList; 4 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import Loader from './Loader'; 3 | 4 | const meta: Meta = { 5 | component: Loader, 6 | tags: ['autodocs'], 7 | }; 8 | 9 | export default meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = {}; 13 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 3 | import { keyframes } from '@emotion/react'; 4 | import { Keyframes } from '@emotion/serialize'; 5 | import { prop } from 'styled-tools'; 6 | 7 | const LoaderContainer: StyledComponent< 8 | DetailedHTMLProps, HTMLDivElement> 9 | > = styled.div` 10 | width: 100%; 11 | max-width: ${prop('theme.sizing.conversation.width')}; 12 | `; 13 | 14 | const BulletList = styled.div` 15 | display: inline-block; 16 | color: ${prop('theme.palette.text.bot')}; 17 | padding: 0.5em 1.5em; 18 | margin-left: 1em; 19 | white-space: pre-line; 20 | border-radius: ${prop('theme.sizing.borderRadius')}; 21 | border-bottom-left-radius: 0; 22 | 23 | ${prop('theme.overrides.messageBot', '')} 24 | `; 25 | 26 | const beat: Keyframes = keyframes` 27 | 50% { 28 | transform: scale(0.75); 29 | opacity: 0.2; 30 | } 31 | 100% { 32 | transform: scale(1); 33 | opacity: 1; 34 | } 35 | `; 36 | 37 | const Bullet = styled.div<{ 'data-rank': number }>` 38 | display: inline-block; 39 | background-color: ${prop('theme.palette.text.bot')}; 40 | width: ${prop('theme.sizing.loaderSize')}; 41 | height: ${prop('theme.sizing.loaderSize')}; 42 | margin: 0.5em 0.5em 0.5em 0; 43 | border-radius: 50%; 44 | animation: ${beat} 0.7s linear 45 | ${(props) => (props['data-rank'] % 2 ? '0s' : '0.35s')} infinite normal both 46 | running; 47 | `; 48 | 49 | const Loader: () => JSX.Element = () => ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | 59 | export default Loader; 60 | -------------------------------------------------------------------------------- /src/components/Loader/index.ts: -------------------------------------------------------------------------------- 1 | import Loader from './Loader'; 2 | 3 | export default Loader; 4 | -------------------------------------------------------------------------------- /src/components/MessageBot/MessageBot.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { MessageType } from '../../model/messages'; 4 | import MessageBot from './MessageBot'; 5 | import { TockContext } from '../../index'; 6 | 7 | const message = 'A bot message'; 8 | 9 | const simpleHtml = '

    A formatted bot message

    '; 10 | 11 | const html = ` 12 | Hello user! 13 |

    This is how I display:

    14 |
      15 |
    • a html link to the Tock Documentation Page
    • 16 |
    • a clickable string url to github.com/theopenconversationkit/tock-react-kit
    • 17 |
    • a phone number link 0612345678, but not a number string link +33612345678
    • 18 |
    • an e-mail link to a tock contact and a string email link to tock@yopmail.com
    • 19 |
    `; 20 | 21 | const meta: Meta = { 22 | component: MessageBot, 23 | tags: ['autodocs'], 24 | args: { 25 | onAction: Function.bind(null), 26 | }, 27 | }; 28 | 29 | export default meta; 30 | type Story = StoryObj; 31 | 32 | export const Default: Story = { 33 | args: { 34 | message: { 35 | author: 'bot', 36 | message: message, 37 | type: MessageType.message, 38 | buttons: [], 39 | }, 40 | onAction: Function.bind(null), 41 | }, 42 | }; 43 | 44 | export const WithBasicFormatting: Story = { 45 | name: 'With basic formatting', 46 | args: { 47 | message: { 48 | author: 'bot', 49 | message: simpleHtml, 50 | type: MessageType.message, 51 | buttons: [], 52 | }, 53 | }, 54 | }; 55 | 56 | export const WithHtmlContent: Story = { 57 | name: 'With HTML content', 58 | args: { 59 | message: { 60 | author: 'bot', 61 | message: html, 62 | type: MessageType.message, 63 | buttons: [], 64 | }, 65 | }, 66 | }; 67 | 68 | /** 69 | * Converts :) emoticons into emojis with custom ARIA labels 70 | */ 71 | export const WithCustomRendering: Story = { 72 | args: { 73 | message: { 74 | author: 'bot', 75 | message: 'Hello :)', 76 | type: MessageType.message, 77 | buttons: [], 78 | }, 79 | }, 80 | render: (args) => ( 81 | { 86 | const split = text.split(':)'); 87 | return split.reduce( 88 | (acc, s, i) => 89 | acc.length 90 | ? [ 91 | ...acc, 92 | 93 | 🙂 94 | , 95 | s, 96 | ] 97 | : [s], 98 | [], 99 | ); 100 | }, 101 | }, 102 | }, 103 | }} 104 | > 105 | 106 | 107 | ), 108 | }; 109 | -------------------------------------------------------------------------------- /src/components/MessageBot/MessageBot.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { theme } from 'styled-tools'; 3 | import { Button } from '../../model/buttons'; 4 | import { TextMessage } from '../../model/messages'; 5 | import ButtonList from '../buttons/ButtonList'; 6 | 7 | import '../../styles/theme'; 8 | import { useTextRenderer } from '../../settings/RendererSettings'; 9 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 10 | 11 | export const MessageContainer: StyledComponent< 12 | DetailedHTMLProps, HTMLDivElement> 13 | > = styled.div` 14 | width: 100%; 15 | max-width: ${theme('sizing.conversation.width')}; 16 | 17 | p { 18 | margin: 0; 19 | } 20 | `; 21 | 22 | export const Message: StyledComponent< 23 | DetailedHTMLProps, HTMLDivElement> 24 | > = styled.div` 25 | display: inline-block; 26 | background: ${theme('palette.background.bot')}; 27 | color: ${theme('palette.text.bot')}; 28 | padding: 0.5em 1.5em; 29 | margin-left: 1em; 30 | white-space: pre-line; 31 | border-radius: ${theme('sizing.borderRadius')}; 32 | border-bottom-left-radius: 0; 33 | 34 | ${theme('overrides.messageBot')} 35 | `; 36 | 37 | export interface MessageProps { 38 | message: TextMessage; 39 | onAction: (button: Button) => void; 40 | } 41 | 42 | const MessageBot: (props: MessageProps) => JSX.Element = ({ 43 | message: { buttons, message = '' }, 44 | onAction, 45 | }: MessageProps) => { 46 | const HtmlRenderer = useTextRenderer('html'); 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | {Array.isArray(buttons) && buttons.length > 0 && ( 54 | 55 | )} 56 | 57 | ); 58 | }; 59 | 60 | export default MessageBot; 61 | -------------------------------------------------------------------------------- /src/components/MessageBot/index.ts: -------------------------------------------------------------------------------- 1 | import MessageBot from './MessageBot'; 2 | 3 | export * from './MessageBot'; 4 | export default MessageBot; 5 | -------------------------------------------------------------------------------- /src/components/MessageUser/MessageUser.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import MessageUser from './MessageUser'; 3 | 4 | const meta: Meta = { 5 | component: MessageUser, 6 | tags: ['autodocs'], 7 | }; 8 | 9 | export default meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = { 13 | args: { 14 | children: 'A user message', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/MessageUser/MessageUser.tsx: -------------------------------------------------------------------------------- 1 | import styled, { StyledComponent } from '@emotion/styled'; 2 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 3 | import { theme } from 'styled-tools'; 4 | import { MessageContainer as BotMessageContainer } from '../MessageBot'; 5 | 6 | import { useTextRenderer } from '../../settings/RendererSettings'; 7 | 8 | const MessageContainer: StyledComponent< 9 | DetailedHTMLProps, HTMLDivElement> 10 | > = styled(BotMessageContainer)` 11 | text-align: right; 12 | `; 13 | 14 | const Message: StyledComponent< 15 | DetailedHTMLProps, HTMLDivElement> 16 | > = styled.div` 17 | display: inline-block; 18 | background: ${theme('palette.background.user')}; 19 | color: ${theme('palette.text.user')}; 20 | padding: 0.5em 1.5em; 21 | margin-right: 1em; 22 | border-radius: ${theme('sizing.borderRadius')}; 23 | border-bottom-right-radius: 0; 24 | 25 | ${theme('overrides.messageUser')} 26 | `; 27 | 28 | type Props = { 29 | children: string; 30 | }; 31 | 32 | const MessageUser = ({ children }: Props): JSX.Element => { 33 | const TextRenderer = useTextRenderer('userContent'); 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default MessageUser; 44 | -------------------------------------------------------------------------------- /src/components/MessageUser/index.ts: -------------------------------------------------------------------------------- 1 | import MessageUser from './MessageUser'; 2 | 3 | export default MessageUser; 4 | -------------------------------------------------------------------------------- /src/components/QuickReply/QuickReply.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { css, Interpolation, SerializedStyles, Theme } from '@emotion/react'; 3 | import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from 'react'; 4 | 5 | import { QuickReply as QuickReplyData } from '../../model/buttons'; 6 | 7 | import QuickReplyImage from './QuickReplyImage'; 8 | import { 9 | useButtonRenderer, 10 | useTextRenderer, 11 | } from '../../settings/RendererSettings'; 12 | 13 | const QuickReplyButtonContainer = styled.li` 14 | list-style: none; 15 | `; 16 | 17 | export const baseButtonStyle = css` 18 | background: none; 19 | padding: 0.5em 1em; 20 | display: inline-block; 21 | 22 | cursor: pointer; 23 | font-family: inherit; 24 | font-size: inherit; 25 | `; 26 | 27 | export const quickReplyStyle: (theme: Theme) => SerializedStyles = ( 28 | theme, 29 | ) => css` 30 | margin: 0 0.5em; 31 | border: 2px solid ${theme.palette.background.bot}; 32 | border-radius: ${theme.sizing.borderRadius}; 33 | 34 | color: ${theme.palette.background.bot}; 35 | 36 | &:hover, 37 | &:focus, 38 | &:active { 39 | border-color: ${theme.palette.text.bot}; 40 | color: ${theme.palette.text.bot}; 41 | background: ${theme.palette.background.bot}; 42 | } 43 | `; 44 | 45 | const qrButtonCss: Interpolation = [ 46 | baseButtonStyle, 47 | quickReplyStyle, 48 | (theme) => theme.overrides?.quickReply, 49 | ]; 50 | 51 | interface Props 52 | extends DetailedHTMLProps< 53 | HTMLAttributes, 54 | HTMLButtonElement 55 | > { 56 | buttonData: QuickReplyData; 57 | } 58 | 59 | const QuickReply = React.forwardRef( 60 | ({ buttonData, ...rest }: Props, ref: RefObject) => { 61 | const TextRenderer = useTextRenderer('default'); 62 | const ButtonRenderer = useButtonRenderer('quickReply'); 63 | return ( 64 | 65 | 71 | {buttonData.imageUrl && } 72 | 73 | 74 | 75 | ); 76 | }, 77 | ); 78 | 79 | QuickReply.displayName = 'QuickReply'; 80 | 81 | export default QuickReply; 82 | -------------------------------------------------------------------------------- /src/components/QuickReply/QuickReplyImage.tsx: -------------------------------------------------------------------------------- 1 | import { css, Interpolation, Theme } from '@emotion/react'; 2 | import { useImageRenderer } from '../../settings/RendererSettings'; 3 | 4 | const qrImageStyle: Interpolation = [ 5 | css` 6 | margin-right: inherit; 7 | max-width: 15px; 8 | max-height: 15px; 9 | vertical-align: middle; 10 | `, 11 | (theme) => theme.overrides?.quickReplyImage, 12 | ]; 13 | 14 | interface Props { 15 | src: string; 16 | } 17 | 18 | const QuickReplyImage = ({ src }: Props): JSX.Element => { 19 | const ImageRenderer = useImageRenderer('buttonIcon'); 20 | return ; 21 | }; 22 | 23 | export default QuickReplyImage; 24 | -------------------------------------------------------------------------------- /src/components/QuickReply/index.ts: -------------------------------------------------------------------------------- 1 | export { default, baseButtonStyle } from './QuickReply'; 2 | -------------------------------------------------------------------------------- /src/components/QuickReplyList/QuickReplyList.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import QuickReplyList from './QuickReplyList'; 4 | 5 | const meta: Meta = { 6 | component: QuickReplyList, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | export const MultipleQRs: Story = { 14 | name: 'Multiple QRs', 15 | args: { 16 | items: [ 17 | { label: 'Quick Reply 1' }, 18 | { label: 'Quick Reply 2' }, 19 | { 20 | label: 'Quick Reply with image', 21 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 22 | }, 23 | { label: 'Quick Reply 4' }, 24 | { label: 'Quick Reply 5' }, 25 | ], 26 | onItemClick: Function.bind(null), 27 | }, 28 | }; 29 | 30 | export const SingleQr: Story = { 31 | name: 'Single QR', 32 | args: { 33 | onItemClick: Function.bind(null), 34 | items: [{ label: 'Quick Reply' }], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/QuickReplyList/QuickReplyList.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes, useCallback } from 'react'; 2 | import { css, SerializedStyles, Theme } from '@emotion/react'; 3 | import styled, { StyledComponent } from '@emotion/styled'; 4 | 5 | import { Button } from '../../model/buttons'; 6 | import QuickReply from '../QuickReply/QuickReply'; 7 | import '../../styles/theme'; 8 | 9 | export const baseButtonListStyle: (props: { 10 | theme: Theme; 11 | }) => SerializedStyles = ({ theme }) => css` 12 | max-width: ${theme.sizing.conversation.width}; 13 | margin: 0 auto; 14 | text-align: left; 15 | 16 | overflow: auto; 17 | -webkit-overflow-scrolling: touch; 18 | scroll-behavior: smooth; 19 | touch-action: pan-x pan-y; 20 | &::-webkit-scrollbar { 21 | display: none; 22 | } 23 | padding: 0.5em 1em; 24 | `; 25 | 26 | const QuickReplyListContainer: StyledComponent< 27 | DetailedHTMLProps, HTMLUListElement> 28 | > = styled.ul` 29 | ${baseButtonListStyle}; 30 | 31 | & > li { 32 | display: inline-block; 33 | } 34 | `; 35 | 36 | const QuickReplyListOuterContainer: StyledComponent< 37 | DetailedHTMLProps, HTMLDivElement> 38 | > = styled.div``; 39 | 40 | type Props = { 41 | items: Button[]; 42 | onItemClick: (button: Button) => void; 43 | }; 44 | 45 | const QuickReplyList: (props: Props) => JSX.Element = ({ 46 | items, 47 | onItemClick, 48 | }: Props) => { 49 | const renderItem = useCallback( 50 | (item: Button, index: number) => ( 51 | 56 | ), 57 | [onItemClick], 58 | ); 59 | 60 | return ( 61 | 62 | {items.map(renderItem)} 63 | 64 | ); 65 | }; 66 | 67 | export default QuickReplyList; 68 | -------------------------------------------------------------------------------- /src/components/QuickReplyList/index.ts: -------------------------------------------------------------------------------- 1 | import QuickReplyList from './QuickReplyList'; 2 | 3 | export default QuickReplyList; 4 | -------------------------------------------------------------------------------- /src/components/buttons/ButtonList/ButtonList.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import ButtonList from '.'; 4 | 5 | const meta: Meta = { 6 | component: ButtonList, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | export const MultipleButtons: Story = { 14 | args: { 15 | items: [ 16 | { label: 'Button 1' }, 17 | { label: 'Button 2' }, 18 | { label: 'URL Button', url: 'https://doc.tock.ai' }, 19 | { label: 'Button 4' }, 20 | { 21 | label: 'Button with image', 22 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 23 | }, 24 | { 25 | label: 'URL Button with image', 26 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 27 | url: 'https://doc.tock.ai', 28 | }, 29 | ], 30 | onItemClick: Function.bind(null), 31 | }, 32 | }; 33 | 34 | export const SingleButton: Story = { 35 | args: { 36 | onItemClick: Function.bind(null), 37 | items: [{ label: 'Button 1' }], 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/buttons/ButtonList/ButtonList.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes } from 'react'; 2 | import styled, { StyledComponent } from '@emotion/styled'; 3 | import { theme } from 'styled-tools'; 4 | 5 | import { Button } from '../../../model/buttons'; 6 | import PostBackButton from '../PostBackButton'; 7 | import UrlButton from '../UrlButton'; 8 | import { baseButtonListStyle } from '../../QuickReplyList/QuickReplyList'; 9 | 10 | import '../../../styles/theme'; 11 | 12 | const ButtonListContainer: StyledComponent< 13 | DetailedHTMLProps, HTMLUListElement> 14 | > = styled.ul` 15 | ${baseButtonListStyle} 16 | ${theme('overrides.buttons.buttonList')} 17 | 18 | & > li { 19 | display: inline-block; 20 | ${theme('overrides.buttons.buttonContainer')} 21 | } 22 | `; 23 | 24 | const ButtonListOuterContainer: StyledComponent< 25 | DetailedHTMLProps, HTMLDivElement> 26 | > = styled.div``; 27 | 28 | type Props = { 29 | items: Button[]; 30 | onItemClick: (button: Button) => void; 31 | }; 32 | 33 | export const ButtonList: (props: Props) => JSX.Element = ({ 34 | items, 35 | onItemClick, 36 | }: Props) => { 37 | const renderItem = (item: Button, index: number) => ( 38 | // having the default index-based key is fine since we do not reorder buttons 39 |
  • 40 | {'url' in item ? ( 41 | 42 | ) : ( 43 | 47 | )} 48 |
  • 49 | ); 50 | 51 | return ( 52 | 53 | 54 | {items.map(renderItem)} 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/buttons/ButtonList/index.ts: -------------------------------------------------------------------------------- 1 | export { ButtonList as default } from './ButtonList'; 2 | -------------------------------------------------------------------------------- /src/components/buttons/PostBackButton/PostBackButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import PostBackButton from './index'; 3 | 4 | const meta: Meta = { 5 | component: PostBackButton, 6 | tags: ['autodocs'], 7 | }; 8 | 9 | export default meta; 10 | type Story = StoryObj; 11 | 12 | export const SimplePostback: Story = { 13 | name: 'PostBack Button', 14 | args: { 15 | buttonData: { 16 | label: 'Help', 17 | }, 18 | }, 19 | }; 20 | 21 | export const WithImage: Story = { 22 | name: 'PostBack Button with image', 23 | args: { 24 | buttonData: { 25 | label: 'Help', 26 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/buttons/PostBackButton/PostBackButton.tsx: -------------------------------------------------------------------------------- 1 | import { quickReplyStyle } from '../../QuickReply/QuickReply'; 2 | import { Interpolation, Theme } from '@emotion/react'; 3 | import { baseButtonStyle } from '../../QuickReply'; 4 | import { DetailedHTMLProps, HTMLAttributes, JSX } from 'react'; 5 | import QuickReplyImage from '../../QuickReply/QuickReplyImage'; 6 | import { 7 | useButtonRenderer, 8 | useTextRenderer, 9 | } from '../../../settings/RendererSettings'; 10 | import { PostBackButtonData } from '../../../index'; 11 | 12 | const postBackButtonCss: Interpolation = [ 13 | baseButtonStyle, 14 | [ 15 | quickReplyStyle, 16 | // Fall back to historical quick reply override if the new postback button override is not used 17 | (theme) => 18 | theme.overrides?.buttons?.postbackButton ?? theme?.overrides?.quickReply, 19 | ], 20 | ]; 21 | 22 | interface Props 23 | extends DetailedHTMLProps< 24 | HTMLAttributes, 25 | HTMLButtonElement 26 | > { 27 | buttonData: PostBackButtonData; 28 | customStyle?: Interpolation; 29 | tabIndex?: 0 | -1; 30 | } 31 | 32 | export const PostBackButton = ({ 33 | buttonData, 34 | customStyle, 35 | ...rest 36 | }: Props): JSX.Element => { 37 | // Allow custom override for the Card's button styling 38 | const css = customStyle ? [baseButtonStyle, customStyle] : postBackButtonCss; 39 | const TextRenderer = useTextRenderer('default'); 40 | const ButtonRenderer = useButtonRenderer('postback'); 41 | return ( 42 | 43 | {buttonData.imageUrl && } 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/buttons/PostBackButton/index.ts: -------------------------------------------------------------------------------- 1 | export { PostBackButton as default } from './PostBackButton'; 2 | -------------------------------------------------------------------------------- /src/components/buttons/UrlButton/UrlButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import UrlButton from './index'; 3 | 4 | const meta: Meta = { 5 | component: UrlButton, 6 | tags: ['autodocs'], 7 | }; 8 | 9 | export default meta; 10 | type Story = StoryObj; 11 | 12 | export const SimpleUrl: Story = { 13 | name: 'URL Button', 14 | args: { 15 | buttonData: { 16 | label: 'TOCK', 17 | url: 'https://doc.tock.ai', 18 | }, 19 | }, 20 | }; 21 | 22 | export const WithImage: Story = { 23 | name: 'URL Button with image', 24 | args: { 25 | buttonData: { 26 | label: 'TOCK', 27 | url: 'https://doc.tock.ai', 28 | imageUrl: 'https://doc.tock.ai/tock/assets/images/logo.svg', 29 | }, 30 | }, 31 | }; 32 | 33 | export const WithTarget: Story = { 34 | name: 'URL Button with _self target', 35 | args: { 36 | buttonData: { 37 | label: 'TOCK', 38 | url: 'https://doc.tock.ai', 39 | target: '_self', 40 | }, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/buttons/UrlButton/UrlButton.tsx: -------------------------------------------------------------------------------- 1 | import { quickReplyStyle } from '../../QuickReply/QuickReply'; 2 | import { css, Interpolation, Theme } from '@emotion/react'; 3 | import { baseButtonStyle } from '../../QuickReply'; 4 | import QuickReplyImage from '../../QuickReply/QuickReplyImage'; 5 | import { 6 | useButtonRenderer, 7 | useTextRenderer, 8 | } from '../../../settings/RendererSettings'; 9 | import { UrlButton as UrlButtonData } from '../../../model/buttons'; 10 | import { MouseEventHandler } from 'react'; 11 | 12 | type Props = { 13 | customStyle?: Interpolation; 14 | buttonData: UrlButtonData; 15 | tabIndex?: 0 | -1; 16 | }; 17 | 18 | const baseUrlButtonCss = css([ 19 | baseButtonStyle, 20 | css` 21 | text-align: center; 22 | text-decoration: none; 23 | `, 24 | ]); 25 | 26 | const defaultUrlButtonCss: Interpolation = [ 27 | baseUrlButtonCss, 28 | quickReplyStyle, 29 | // Fall back to historical quick reply override if the new url button override is not used 30 | (theme) => theme.overrides?.buttons?.urlButton || theme.overrides?.quickReply, 31 | ]; 32 | 33 | export const UrlButton: (props: Props) => JSX.Element = ({ 34 | buttonData, 35 | customStyle, 36 | tabIndex, 37 | }: Props) => { 38 | const css = customStyle 39 | ? [baseUrlButtonCss, customStyle] 40 | : defaultUrlButtonCss; 41 | const TextRenderer = useTextRenderer('default'); 42 | const ButtonRenderer = useButtonRenderer('url'); 43 | const onClick: MouseEventHandler | undefined = buttonData.windowFeatures 44 | ? (e) => { 45 | e.preventDefault(); 46 | window.open( 47 | buttonData.url, 48 | buttonData.target, 49 | buttonData.windowFeatures, 50 | ); 51 | } 52 | : undefined; 53 | return ( 54 | 62 | {buttonData.imageUrl && } 63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/buttons/UrlButton/index.ts: -------------------------------------------------------------------------------- 1 | export { UrlButton as default } from './UrlButton'; 2 | -------------------------------------------------------------------------------- /src/components/widgets/DefaultWidget/DefaultWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Message, MessageContainer } from '../../MessageBot'; 2 | 3 | const DefaultWidget: (props: Record) => JSX.Element = ( 4 | props, 5 | ) => { 6 | return ( 7 | 8 | 9 |
    {JSON.stringify(props)}
    10 |
    11 |
    12 | ); 13 | }; 14 | 15 | export default DefaultWidget; 16 | -------------------------------------------------------------------------------- /src/components/widgets/DefaultWidget/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultWidget from './DefaultWidget'; 2 | 3 | export default DefaultWidget; 4 | -------------------------------------------------------------------------------- /src/components/widgets/ProductWidget/Product.ts: -------------------------------------------------------------------------------- 1 | type Product = { 2 | name: string; 3 | description: string; 4 | price: number; 5 | }; 6 | 7 | export default Product; 8 | -------------------------------------------------------------------------------- /src/components/widgets/ProductWidget/ProductWidget.tsx: -------------------------------------------------------------------------------- 1 | import Product from './Product'; 2 | import { Message, MessageContainer } from '../../MessageBot'; 3 | 4 | const ProductWidget: (props: Product) => JSX.Element = ({ 5 | name, 6 | description, 7 | price, 8 | }: Product) => ( 9 | 10 | 11 |

    product: {name}

    12 |

    {description}

    13 |

    {price}

    14 |
    15 |
    16 | ); 17 | 18 | export default ProductWidget; 19 | -------------------------------------------------------------------------------- /src/components/widgets/ProductWidget/index.ts: -------------------------------------------------------------------------------- 1 | import ProductWidget from './ProductWidget'; 2 | 3 | export default ProductWidget; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ThemeProvider } from './styles/tockThemeProvider'; 2 | export { default as Card, CardContainer, CardOuter } from './components/Card'; 3 | export { default as Carousel } from './components/Carousel'; 4 | export { default as Chat } from './components/Chat'; 5 | export { default as ChatInput } from './components/ChatInput'; 6 | export { default as Container } from './components/Container'; 7 | export { default as Loader } from './components/Loader'; 8 | export { 9 | default as Conversation, 10 | useMessageCounter, 11 | } from './components/Conversation'; 12 | export { default as Image } from './components/Image'; 13 | export { 14 | default as MessageBot, 15 | MessageContainer, 16 | Message, 17 | } from './components/MessageBot'; 18 | export { default as PostbackButton } from './components/buttons/PostBackButton'; 19 | export { default as UrlButton } from './components/buttons/UrlButton'; 20 | export { default as MessageUser } from './components/MessageUser'; 21 | export { default as QuickReply } from './components/QuickReply'; 22 | export { default as QuickReplyList } from './components/QuickReplyList'; 23 | export { default as InlineQuickReplyList } from './components/InlineQuickReplyList'; 24 | export { renderChat } from './renderChat'; 25 | export { default as TockContext } from './TockContext'; 26 | export { default as useTock } from './useTock'; 27 | export { default as createTheme } from './styles/createTheme'; 28 | export { useMessageMetadata, MessageMetadataContext } from './MessageMetadata'; 29 | export { useImageRenderer, useTextRenderer } from './settings/RendererSettings'; 30 | export { useTockSettings } from './settings/TockSettingsContext'; 31 | export type { 32 | Button as ButtonData, 33 | PostBackButton as PostBackButtonData, 34 | UrlButton as UrlButtonData, 35 | QuickReply as QuickReplyData, 36 | } from './model/buttons'; 37 | export { MessageType } from './model/messages'; 38 | export type { 39 | Card as CardData, 40 | Carousel as CarouselData, 41 | Image as ImageData, 42 | Message as MessageData, 43 | TextMessage as TextMessageData, 44 | Widget as WidgetData, 45 | WidgetPayload, 46 | } from './model/messages'; 47 | export type { 48 | default as PostInitContext, 49 | TockHistoryData, 50 | } from './PostInitContext'; 51 | export type { default as TockTheme } from './styles/theme'; 52 | export type { default as TockOptions } from './TockOptions'; 53 | export type { 54 | default as TockSettings, 55 | LocalStorageSettings, 56 | } from './settings/TockSettings'; 57 | export type { 58 | ImageRenderer, 59 | TextRenderer, 60 | RendererSettings, 61 | ImageRenderers, 62 | MessageRenderers, 63 | TextRenderers, 64 | } from './settings/RendererSettings'; 65 | export type { 66 | ButtonRenderers, 67 | ButtonRenderer, 68 | BaseButtonRendererProps, 69 | UrlButtonRenderer, 70 | UrlButtonRendererProps, 71 | } from './settings/ButtonRenderers'; 72 | -------------------------------------------------------------------------------- /src/model/buttons.ts: -------------------------------------------------------------------------------- 1 | export class QuickReply { 2 | label: string; 3 | payload?: string; 4 | nlpText?: string; 5 | imageUrl?: string; 6 | 7 | constructor( 8 | label: string, 9 | payload: string, 10 | nlpText?: string, 11 | imageUrl?: string, 12 | ) { 13 | this.label = label; 14 | this.payload = payload; 15 | this.nlpText = nlpText; 16 | this.imageUrl = imageUrl; 17 | } 18 | } 19 | 20 | export class PostBackButton { 21 | label: string; 22 | payload?: string; 23 | imageUrl?: string; 24 | style?: string; 25 | 26 | constructor( 27 | label: string, 28 | payload: string, 29 | imageUrl?: string, 30 | style?: string, 31 | ) { 32 | this.label = label; 33 | this.payload = payload; 34 | this.imageUrl = imageUrl; 35 | this.style = style; 36 | } 37 | } 38 | 39 | export class UrlButton { 40 | label: string; 41 | url: string; 42 | imageUrl?: string; 43 | target?: string; 44 | windowFeatures?: string; 45 | style?: string; 46 | 47 | constructor( 48 | label: string, 49 | url: string, 50 | imageUrl?: string, 51 | target?: string, 52 | windowFeatures?: string, 53 | style?: string, 54 | ) { 55 | this.label = label; 56 | this.url = url; 57 | this.imageUrl = imageUrl; 58 | this.target = target; 59 | this.windowFeatures = windowFeatures; 60 | this.style = style; 61 | } 62 | } 63 | 64 | export type Button = QuickReply | PostBackButton | UrlButton; 65 | -------------------------------------------------------------------------------- /src/model/messages.ts: -------------------------------------------------------------------------------- 1 | import { Button } from './buttons'; 2 | 3 | export type Message = TextMessage | Card | Carousel | Widget | Image; 4 | 5 | export enum MessageType { 6 | message = 'message', 7 | card = 'card', 8 | carousel = 'carousel', 9 | widget = 'widget', 10 | image = 'image', 11 | } 12 | 13 | interface MessageBase { 14 | type: MessageType; 15 | alreadyDisplayed?: boolean; 16 | metadata?: Record; 17 | } 18 | 19 | export interface TextMessage extends MessageBase { 20 | author: 'bot' | 'user'; 21 | message: string; 22 | type: MessageType.message; 23 | buttons?: Button[]; 24 | } 25 | 26 | export interface Card extends MessageBase { 27 | imageUrl?: string; 28 | imageAlternative?: string; 29 | title: string; 30 | subTitle?: string; 31 | buttons?: Button[]; 32 | type: MessageType.card; 33 | } 34 | 35 | export interface Carousel extends MessageBase { 36 | cards: Card[]; 37 | type: MessageType.carousel; 38 | } 39 | 40 | export interface Widget extends MessageBase { 41 | widgetData: WidgetPayload; 42 | type: MessageType.widget; 43 | } 44 | 45 | export interface WidgetPayload { 46 | data: Record; 47 | type: string; 48 | } 49 | 50 | export interface Image extends MessageBase { 51 | url?: string; 52 | title: string; 53 | type: MessageType.image; 54 | alternative?: string; 55 | } 56 | -------------------------------------------------------------------------------- /src/model/responses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface specified by [tock-bot-connector-web-model](https://github.com/theopenconversationkit/tock/tree/master/bot/connector-web-model/src/main/kotlin/ai/tock/bot/connector/web/WebConnectorResponse.kt) 3 | */ 4 | export interface BotConnectorResponse { 5 | responses: BotConnectorMessage[]; 6 | metadata: Record; 7 | } 8 | 9 | export interface BotConnectorMessage { 10 | version: '1'; 11 | 12 | text?: string; 13 | buttons?: BotConnectorButton[]; 14 | card?: BotConnectorCard; 15 | carousel?: BotConnectorCarousel; 16 | widget?: BotConnectorWidget; 17 | image?: BotConnectorImage; 18 | deepLink?: string; 19 | } 20 | 21 | export interface BotConnectorCard { 22 | title?: string; 23 | subTitle?: string; 24 | file?: WebMediaFile; 25 | buttons: BotConnectorButton[]; 26 | } 27 | 28 | export interface WebMediaFile { 29 | url: string; 30 | name: string; 31 | type: string; 32 | description?: string; 33 | } 34 | 35 | export interface BotConnectorCarousel { 36 | cards: BotConnectorCard[]; 37 | } 38 | 39 | export interface BotConnectorWidget { 40 | data: unknown; 41 | } 42 | 43 | export interface BotConnectorImage { 44 | file: WebMediaFile; 45 | title: string; 46 | } 47 | 48 | export type BotConnectorButton = 49 | | BotConnectorQuickReply 50 | | BotConnectorPostbackButton 51 | | BotConnectorUrlButton; 52 | 53 | interface BotConnectorUrlButton { 54 | type: 'web_url' | undefined; 55 | title: string; 56 | url: string; 57 | imageUrl?: string; 58 | target?: string; 59 | windowFeatures?: string; 60 | style?: string; 61 | } 62 | 63 | interface BotConnectorPostbackButton { 64 | type: 'postback'; 65 | title: string; 66 | payload: string; 67 | imageUrl: string; 68 | } 69 | 70 | interface BotConnectorQuickReply { 71 | type: 'quick_reply'; 72 | title: string; 73 | payload: string; 74 | nlpText: string; 75 | imageUrl: string; 76 | } 77 | -------------------------------------------------------------------------------- /src/network/TockEventSource.ts: -------------------------------------------------------------------------------- 1 | import { BotConnectorResponse } from '../model/responses'; 2 | 3 | const INITIAL_RETRY_DELAY = 0; 4 | const RETRY_DELAY_INCREMENT = 1000; 5 | const MAX_RETRY_DELAY = 15000; 6 | 7 | enum SseStatus { 8 | /** 9 | * The server is not answering, or answering with a 1XX, 3XX, 429, or 5XX HTTP status code 10 | */ 11 | SERVER_UNAVAILABLE = -1, 12 | /** 13 | * The server is answering with a 4XX HTTP status code, except 429 (rate limit) 14 | */ 15 | UNSUPPORTED = 0, 16 | /** 17 | * The server is answering with a 2XX HTTP status code 18 | */ 19 | SUPPORTED = 1, 20 | } 21 | 22 | async function getSseStatus(url: string) { 23 | try { 24 | const response = await fetch(url); 25 | if (response.ok) { 26 | return SseStatus.SUPPORTED; 27 | } else if ( 28 | response.status >= 400 && 29 | response.status < 500 && 30 | response.status !== 429 31 | ) { 32 | return SseStatus.UNSUPPORTED; 33 | } else { 34 | return SseStatus.SERVER_UNAVAILABLE; 35 | } 36 | } catch (_) { 37 | return SseStatus.SERVER_UNAVAILABLE; 38 | } 39 | } 40 | 41 | export class TockEventSource { 42 | private initialized: boolean; 43 | private eventSource: EventSource | null; 44 | private retryDelay: number; 45 | onResponse: (botResponse: BotConnectorResponse) => void; 46 | onStateChange: (state: number) => void; 47 | 48 | constructor() { 49 | this.initialized = false; 50 | this.retryDelay = INITIAL_RETRY_DELAY; 51 | } 52 | 53 | isInitialized(): boolean { 54 | return this.initialized; 55 | } 56 | 57 | /** 58 | * Opens an SSE connection to the given web connector endpoint 59 | * 60 | * @param endpoint the base endpoint URL, to which '/sse' will be added to form the full SSE endpoint URL 61 | * @param userId the locally-generated userId (will be ignored if the backend relies on cookies instead) 62 | * @returns a promise that gets resolved when the connection is open 63 | * and gets rejected if the connection fails or this event source is closed 64 | */ 65 | open(endpoint: string, userId: string): Promise { 66 | this.onStateChange(EventSource.CONNECTING); 67 | const url = `${endpoint}/sse?userid=${userId}`; 68 | return new Promise((resolve, reject): void => { 69 | this.tryOpen(url, resolve, reject); 70 | }); 71 | } 72 | 73 | private tryOpen(url: string, resolve: () => void, reject: () => void) { 74 | this.eventSource = new EventSource(url); 75 | this.eventSource.addEventListener('open', () => { 76 | this.onStateChange(EventSource.OPEN); 77 | this.initialized = true; 78 | this.retryDelay = INITIAL_RETRY_DELAY; 79 | resolve(); 80 | }); 81 | this.eventSource.addEventListener('error', () => { 82 | this.eventSource?.close(); 83 | this.retry(url, reject, resolve); 84 | }); 85 | this.eventSource.addEventListener('message', (e) => { 86 | this.onResponse(JSON.parse(e.data)); 87 | }); 88 | } 89 | 90 | private retry(url: string, reject: () => void, resolve: () => void) { 91 | const retryDelay = this.retryDelay; 92 | this.retryDelay = Math.min( 93 | MAX_RETRY_DELAY, 94 | retryDelay + RETRY_DELAY_INCREMENT, 95 | ); 96 | setTimeout(async () => { 97 | switch (await getSseStatus(url)) { 98 | case SseStatus.UNSUPPORTED: 99 | reject(); 100 | this.close(); 101 | break; 102 | case SseStatus.SUPPORTED: 103 | this.tryOpen(url, resolve, reject); 104 | break; 105 | case SseStatus.SERVER_UNAVAILABLE: 106 | this.retry(url, reject, resolve); 107 | break; 108 | } 109 | }, retryDelay); 110 | } 111 | 112 | close() { 113 | this.eventSource?.close(); 114 | this.eventSource = null; 115 | this.initialized = false; 116 | this.onStateChange(EventSource.CLOSED); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/network/TockNetworkContext.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, PropsWithChildren } from 'react'; 2 | import { useTock0, UseTockContext } from '../useTock'; 3 | import TockSettings from '../settings/TockSettings'; 4 | 5 | type Props = PropsWithChildren<{ 6 | endpoint: string; 7 | settings: TockSettings; 8 | }>; 9 | 10 | export const TockNetworkContext = ({ 11 | children, 12 | endpoint, 13 | settings, 14 | }: Props): JSX.Element => { 15 | const tock = useTock0(endpoint, settings); 16 | return ( 17 | {children} 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/renderChat.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from '@emotion/react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import Chat from './components/Chat'; 4 | import TockContext from './TockContext'; 5 | import TockTheme from './styles/theme'; 6 | import defaultTheme from './styles/defaultTheme'; 7 | import TockOptions from './TockOptions'; 8 | import { default as createTheme } from './styles/createTheme'; 9 | import { TockOptionalSettings } from './settings/TockSettings'; 10 | 11 | export const renderChat: ( 12 | container: HTMLElement, 13 | endpoint: string, 14 | referralParameter: string, 15 | theme: TockTheme, 16 | options: TockOptions, 17 | ) => void = ( 18 | container: HTMLElement, 19 | endpoint: string, 20 | referralParameter?: string, 21 | theme: TockTheme = defaultTheme, 22 | { 23 | locale, 24 | localStorage = {}, 25 | renderers, 26 | network = {}, 27 | ...options 28 | }: TockOptions = {}, 29 | ): void => { 30 | if (typeof localStorage === 'boolean') { 31 | throw new Error( 32 | 'Enabling local storage history through the localStorage option is now unsupported, use localStorageHistory.enable instead', 33 | ); 34 | } 35 | 36 | if (options.localStorageHistory?.enable) { 37 | localStorage.enableMessageHistory = true; 38 | } 39 | if (options.localStorageHistory?.maxNumberMessages) { 40 | localStorage.maxMessageCount = 41 | options.localStorageHistory.maxNumberMessages; 42 | } 43 | if (options.disableSse) { 44 | network.disableSse = true; 45 | } 46 | if (options.extraHeadersProvider) { 47 | network.extraHeadersProvider = options.extraHeadersProvider; 48 | } 49 | const settings: TockOptionalSettings = { 50 | locale, 51 | localStorage, 52 | network, 53 | renderers, 54 | }; 55 | 56 | createRoot(container).render( 57 | 58 | 59 | 60 | 61 | , 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/settings/ButtonRenderers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnchorHTMLAttributes, 3 | ButtonHTMLAttributes, 4 | ComponentType, 5 | DetailedHTMLProps, 6 | ReactNode, 7 | } from 'react'; 8 | import { 9 | Button as ButtonData, 10 | PostBackButton as PostBackButtonData, 11 | QuickReply as QuickReplyData, 12 | UrlButton as UrlButtonData, 13 | UrlButton, 14 | } from '../model/buttons'; 15 | 16 | import { RendererRegistry } from './RendererRegistry'; 17 | 18 | export interface BaseButtonRendererProps { 19 | buttonData: B; 20 | /** 21 | * The default content for the button, based on the {@link #buttonData} 22 | */ 23 | children: ReactNode; 24 | } 25 | 26 | export type ButtonRendererProps = DetailedHTMLProps< 27 | ButtonHTMLAttributes, 28 | HTMLButtonElement 29 | > & 30 | BaseButtonRendererProps; 31 | 32 | export type ButtonRenderer< 33 | B extends ButtonData, 34 | P extends BaseButtonRendererProps = ButtonRendererProps, 35 | > = ComponentType

    ; 36 | 37 | export type UrlButtonRendererProps = BaseButtonRendererProps & 38 | DetailedHTMLProps, HTMLAnchorElement>; 39 | 40 | export type UrlButtonRenderer = ButtonRenderer< 41 | UrlButtonData, 42 | UrlButtonRendererProps 43 | >; 44 | 45 | export type PostBackButtonRenderer = ButtonRenderer; 46 | 47 | export type QuickReplyButtonRenderer = ButtonRenderer; 48 | 49 | export interface ButtonRenderers extends RendererRegistry { 50 | default: ButtonRenderer; 51 | url: UrlButtonRenderer; 52 | postback?: PostBackButtonRenderer; 53 | quickReply?: QuickReplyButtonRenderer; 54 | } 55 | -------------------------------------------------------------------------------- /src/settings/RendererRegistry.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | export interface RendererRegistry { 4 | default: NonNullable>; 5 | } 6 | -------------------------------------------------------------------------------- /src/settings/RendererSettings.tsx: -------------------------------------------------------------------------------- 1 | import { AriaAttributes, ComponentType, DOMAttributes } from 'react'; 2 | import { useTockSettings } from './TockSettingsContext'; 3 | import { ButtonRenderers } from './ButtonRenderers'; 4 | import { RendererRegistry } from './RendererRegistry'; 5 | 6 | export interface TextRendererProps { 7 | text: string; 8 | } 9 | 10 | /** 11 | * Renders text into React content. 12 | * 13 | *

    A renderer can be restricted in the kind of HTML nodes it emits depending on the 14 | * context in which it is invoked. Most text renderers should only emit phrasing content that 15 | * is also non-interactive. However, some contexts allow interactive phrasing content, 16 | * or even any flow content. 17 | * 18 | *

    Some renderers are expected to handle rich text, that is text that already contains HTML formatting. 19 | * Such rich text renderers may strip HTML tags or attributes that are deemed dangerous to add to the DOM. 20 | * 21 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#flow_content flow content 22 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content phrasing content 23 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#interactive_content interactable content 24 | * @see https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html dangers of arbitrary HTML rendering 25 | */ 26 | export type TextRenderer = ComponentType; 27 | 28 | export interface TextRenderers extends RendererRegistry { 29 | /** 30 | * Renders regular text in the form of non-interactive phrasing content 31 | */ 32 | default: TextRenderer; 33 | /** 34 | * Renders HTML-formatted text in the form of flow content 35 | */ 36 | html: TextRenderer; 37 | /** 38 | * Renders HTML-formatted text in the form of phrasing content 39 | */ 40 | htmlPhrase: TextRenderer; 41 | /** 42 | * Renders text written by a user 43 | * 44 | *

    If left unspecified, falls back to {@link #default} 45 | */ 46 | userContent?: TextRenderer; 47 | } 48 | 49 | export const useTextRenderer = (name: keyof TextRenderers): TextRenderer => { 50 | const textRenderers = useTockSettings().renderers.textRenderers; 51 | return getRendererOrDefault('TextRenderer', textRenderers, name); 52 | }; 53 | 54 | export interface ImageRendererProps 55 | extends AriaAttributes, 56 | DOMAttributes { 57 | src?: string; 58 | alt?: string; 59 | className?: string; 60 | } 61 | 62 | export type ImageRenderer = ComponentType; 63 | 64 | export interface ImageRenderers extends RendererRegistry { 65 | default: ImageRenderer; 66 | standalone?: ImageRenderer; 67 | card?: ImageRenderer; 68 | buttonIcon?: ImageRenderer; 69 | } 70 | 71 | export interface MessageRenderers { 72 | error?: ComponentType; 73 | } 74 | 75 | export interface RendererSettings { 76 | buttonRenderers: ButtonRenderers; 77 | imageRenderers: ImageRenderers; 78 | messageRenderers: MessageRenderers; 79 | textRenderers: TextRenderers; 80 | } 81 | 82 | export const useImageRenderer = (name: keyof ImageRenderers): ImageRenderer => { 83 | const imageRenderers = useTockSettings().renderers.imageRenderers; 84 | return getRendererOrDefault('ImageRenderer', imageRenderers, name); 85 | }; 86 | 87 | export const useButtonRenderer = ( 88 | name: K, 89 | ): undefined extends ButtonRenderers[K] 90 | ? NonNullable | ButtonRenderers['default'] 91 | : ButtonRenderers[K] => { 92 | const buttonRenderers = useTockSettings().renderers.buttonRenderers; 93 | return getRendererOrDefault('ButtonRenderer', buttonRenderers, name); 94 | }; 95 | 96 | function getRendererOrDefault< 97 | R extends RendererRegistry, 98 | K extends keyof R & string, 99 | V = undefined extends R[K] ? NonNullable | R['default'] : R[K], 100 | >(type: string, renderers: R, name: K): V { 101 | return (getRenderer(type, renderers, name) ?? 102 | getRenderer(type, renderers, 'default')) as V; 103 | } 104 | 105 | function getRenderer( 106 | type: string, 107 | renderers: R, 108 | name: K, 109 | ): R[K] { 110 | const renderer = renderers[name] as ComponentType & R[K]; 111 | if (renderer && !renderer.displayName) { 112 | // giving the renderer a pretty name like "ImageRenderer(standalone)" 113 | renderer.displayName = `${type}(${renderer.name?.length ? renderer.name : name})`; 114 | } 115 | return renderer; 116 | } 117 | -------------------------------------------------------------------------------- /src/settings/TockSettings.tsx: -------------------------------------------------------------------------------- 1 | import { RendererSettings } from './RendererSettings'; 2 | import linkifyHtml from 'linkify-html'; 3 | import { PartialDeep } from 'type-fest'; 4 | 5 | export interface LocalStorageSettings { 6 | prefix: string; 7 | enableMessageHistory: boolean; 8 | maxMessageCount: number; 9 | } 10 | 11 | export interface NetworkSettings { 12 | disableSse: boolean; 13 | extraHeadersProvider?: () => Promise>; 14 | } 15 | 16 | export default interface TockSettings { 17 | endpoint?: string; // will be required in a future release 18 | locale?: string; 19 | localStorage: LocalStorageSettings; 20 | network: NetworkSettings; 21 | renderers: RendererSettings; 22 | } 23 | 24 | export type TockOptionalSettings = Omit, 'endpoint'>; 25 | 26 | export const defaultSettings: TockSettings = { 27 | localStorage: { 28 | prefix: '', 29 | enableMessageHistory: false, 30 | maxMessageCount: 10, 31 | }, 32 | network: { 33 | disableSse: false, 34 | }, 35 | renderers: { 36 | buttonRenderers: { 37 | default({ buttonData, children, ...rest }) { 38 | return ; 39 | }, 40 | url({ buttonData, children, ...rest }) { 41 | return {children}; 42 | }, 43 | }, 44 | imageRenderers: { 45 | default({ src, alt, ...props }) { 46 | return {alt}; 47 | }, 48 | }, 49 | messageRenderers: {}, 50 | textRenderers: { 51 | default({ text }) { 52 | return text; 53 | }, 54 | html({ text }) { 55 | return

    ; 56 | }, 57 | htmlPhrase({ text }) { 58 | return ; 59 | }, 60 | }, 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/settings/TockSettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import { Context, createContext, useContext } from 'react'; 2 | import TockSettings from './TockSettings'; 3 | 4 | export const TockSettingsContext: Context = 5 | createContext(undefined); 6 | 7 | export const useTockSettings: () => TockSettings = () => { 8 | const settings = useContext(TockSettingsContext); 9 | if (!settings) { 10 | throw new Error('useTockSettings must be used in a TockContext'); 11 | } 12 | return settings; 13 | }; 14 | -------------------------------------------------------------------------------- /src/styles/createTheme.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import TockTheme, { TockThemeOptions } from './theme'; 3 | import defaultTheme from './defaultTheme'; 4 | import { PartialDeep } from 'type-fest'; 5 | 6 | export default function createTockTheme( 7 | theme: PartialDeep = {}, 8 | ): T { 9 | return deepmerge(defaultTheme as Partial, theme as Partial); 10 | } 11 | -------------------------------------------------------------------------------- /src/styles/defaultTheme.ts: -------------------------------------------------------------------------------- 1 | import { readableColor } from 'polished'; 2 | import TockTheme from './theme'; 3 | 4 | const defaultTheme: TockTheme = { 5 | palette: { 6 | text: { 7 | user: 'black', 8 | bot: 'white', 9 | card: 'black', 10 | input: 'black', 11 | }, 12 | background: { 13 | user: readableColor('black'), 14 | bot: readableColor('white'), 15 | card: readableColor('black'), 16 | input: readableColor('black'), 17 | inputDisabled: '#b6b4b4', 18 | }, 19 | }, 20 | sizing: { 21 | loaderSize: '8px', 22 | borderRadius: '1em', 23 | conversation: { 24 | width: '720px', 25 | }, 26 | }, 27 | typography: { 28 | fontFamily: 'Segoe UI, Arial, Helvetica, sans-serif', 29 | fontSize: '16px', 30 | }, 31 | }; 32 | 33 | export default defaultTheme; 34 | -------------------------------------------------------------------------------- /src/styles/overrides.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation } from '@emotion/react'; 2 | import TockThemeButtonStyle from './tockThemeButtonStyle'; 3 | import TockThemeCardStyle from './tockThemeCardStyle'; 4 | import TockThemeInputStyle from './tockThemeInputStyle'; 5 | 6 | export interface Overrides { 7 | buttons?: Partial; 8 | card?: Partial; 9 | chatInput?: Partial; 10 | carouselContainer: Interpolation; 11 | carouselItem: Interpolation; 12 | carouselArrow: Interpolation; 13 | messageBot: Interpolation; 14 | messageUser: Interpolation; 15 | quickReply: Interpolation; 16 | quickReplyImage: Interpolation; 17 | chat: Interpolation; 18 | quickReplyArrow: Interpolation; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/palette.ts: -------------------------------------------------------------------------------- 1 | interface BackgroundPalette { 2 | user: string; 3 | bot: string; 4 | card: string; 5 | input: string; 6 | inputDisabled: string; 7 | } 8 | 9 | interface TextPalette { 10 | user: string; 11 | bot: string; 12 | card: string; 13 | input: string; 14 | } 15 | 16 | export interface Palette { 17 | background: BackgroundPalette; 18 | text: TextPalette; 19 | } 20 | 21 | export type PaletteOptions = { 22 | background?: Partial; 23 | text?: Partial; 24 | }; 25 | -------------------------------------------------------------------------------- /src/styles/sizing.ts: -------------------------------------------------------------------------------- 1 | interface Shape { 2 | width: string; 3 | } 4 | 5 | export interface Sizing { 6 | loaderSize: string; 7 | borderRadius: string; 8 | conversation: Shape; 9 | } 10 | 11 | export type SizingOptions = Partial; 12 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { Overrides } from './overrides'; 2 | import { Palette, PaletteOptions } from './palette'; 3 | import { Sizing, SizingOptions } from './sizing'; 4 | import { Typography, TypographyOptions } from './typography'; 5 | import { Theme } from '@emotion/react'; 6 | 7 | declare module '@emotion/react' { 8 | export interface Theme { 9 | palette: Palette; 10 | sizing: Sizing; 11 | typography: Typography; 12 | overrides?: Overrides; 13 | inlineQuickReplies?: boolean; 14 | } 15 | } 16 | 17 | type TockTheme = Theme; 18 | export default TockTheme; 19 | 20 | export type TockThemeOptions = { 21 | palette?: PaletteOptions; 22 | sizing?: SizingOptions; 23 | typography?: TypographyOptions; 24 | overrides?: Overrides; 25 | inlineQuickReplies?: boolean; 26 | }; 27 | -------------------------------------------------------------------------------- /src/styles/tockThemeButtonStyle.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation } from '@emotion/react'; 2 | 3 | export default interface TockThemeButtonStyle { 4 | buttonContainer: Interpolation; 5 | buttonList: Interpolation; 6 | postbackButton: Interpolation; 7 | urlButton: Interpolation; 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/tockThemeCardStyle.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation } from '@emotion/react'; 2 | 3 | export default interface TockThemeCardStyle { 4 | cardContainer: Interpolation; 5 | cardTitle: Interpolation; 6 | cardSubTitle: Interpolation; 7 | cardImage: Interpolation; 8 | cardButton: Interpolation; 9 | buttonList: Interpolation; 10 | buttonContainer: Interpolation; 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/tockThemeInputStyle.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation } from '@emotion/react'; 2 | 3 | export default interface TockThemeInputStyle { 4 | container: Interpolation; 5 | input: Interpolation; 6 | icon: Interpolation; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/tockThemeProvider.ts: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, ThemeProviderProps } from '@emotion/react'; 2 | import * as React from 'react'; 3 | import deepmerge from 'deepmerge'; 4 | import TockTheme from './theme'; 5 | import { default as createTheme } from './createTheme'; 6 | 7 | export default function TockThemeProvider( 8 | props: ThemeProviderProps, 9 | ): React.ReactElement { 10 | const theme = props.theme as TockTheme; 11 | if (!theme.overrides) { 12 | console.warn( 13 | '[Theme deprecated] You seem providing a deprecated theme.\n Since version 20.3.4 you must provide a theme build by using "createTheme" function and the new TockTheme interface.', 14 | ); 15 | return ThemeProvider( 16 | deepmerge( 17 | { theme: createTheme({}) as unknown as Partial }, 18 | { ...props }, 19 | ), 20 | ); 21 | } else { 22 | return ThemeProvider(props); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/typography.ts: -------------------------------------------------------------------------------- 1 | export interface Typography { 2 | fontSize: string; 3 | fontFamily: string; 4 | } 5 | 6 | export type TypographyOptions = Partial; 7 | -------------------------------------------------------------------------------- /src/useLocalTools.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, useCallback } from 'react'; 2 | import { retrievePrefixedLocalStorageKey } from './utils'; 3 | import { useTockSettings } from './settings/TockSettingsContext'; 4 | import { TockAction, useTockDispatch } from './TockState'; 5 | 6 | export interface UseLocalTools { 7 | clearMessages: () => void; 8 | } 9 | 10 | const useLocalTools: (localStorage?: boolean) => UseLocalTools = ( 11 | localStorage, 12 | ) => { 13 | const { localStorage: localStorageSettings } = useTockSettings(); 14 | const localStorageEnabled = 15 | localStorage ?? localStorageSettings.enableMessageHistory; 16 | const localStoragePrefix = localStorageSettings.prefix; 17 | 18 | const dispatch: Dispatch = useTockDispatch(); 19 | 20 | const clearMessages: () => void = useCallback(() => { 21 | if (localStorageEnabled) { 22 | const messageHistoryLSKeyName = retrievePrefixedLocalStorageKey( 23 | localStoragePrefix, 24 | 'tockMessageHistory', 25 | ); 26 | const quickReplyHistoryLSKeyName = retrievePrefixedLocalStorageKey( 27 | localStoragePrefix, 28 | 'tockQuickReplyHistory', 29 | ); 30 | window.localStorage.removeItem(messageHistoryLSKeyName); 31 | window.localStorage.removeItem(quickReplyHistoryLSKeyName); 32 | } 33 | dispatch({ 34 | type: 'CLEAR_MESSAGES', 35 | }); 36 | }, [localStorageEnabled, localStoragePrefix]); 37 | return { 38 | clearMessages, 39 | }; 40 | }; 41 | 42 | export default useLocalTools; 43 | -------------------------------------------------------------------------------- /src/useTock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | Dispatch, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useRef, 8 | } from 'react'; 9 | import { 10 | TockAction, 11 | TockState, 12 | useTockDispatch, 13 | useTockState, 14 | } from './TockState'; 15 | import { useTockSettings } from './settings/TockSettingsContext'; 16 | import useLocalTools, { UseLocalTools } from './useLocalTools'; 17 | import type TockLocalStorage from 'TockLocalStorage'; 18 | import { retrievePrefixedLocalStorageKey, storageAvailable } from './utils'; 19 | import { TockHistoryData } from './PostInitContext'; 20 | import { Button, PostBackButton, QuickReply, UrlButton } from './model/buttons'; 21 | import { 22 | Card, 23 | Carousel, 24 | Image, 25 | Message, 26 | MessageType, 27 | TextMessage, 28 | Widget, 29 | WidgetPayload, 30 | } from './model/messages'; 31 | import { 32 | BotConnectorButton, 33 | BotConnectorCard, 34 | BotConnectorImage, 35 | BotConnectorResponse, 36 | } from './model/responses'; 37 | import TockSettings from './settings/TockSettings'; 38 | import { TockEventSource } from './network/TockEventSource'; 39 | 40 | export interface UseTock { 41 | messages: Message[]; 42 | quickReplies: QuickReply[]; 43 | loading: boolean; 44 | error: boolean; 45 | addMessage: ( 46 | message: string, 47 | author: 'bot' | 'user', 48 | buttons?: Button[], 49 | ) => void; 50 | sendMessage: (message: string) => Promise; 51 | clearMessages: () => void; 52 | addCard: ( 53 | title: string, 54 | imageUrl?: string, 55 | subTitle?: string, 56 | buttons?: { label: string; url?: string }[], 57 | ) => void; 58 | addImage: (title: string, url?: string) => void; 59 | addCarousel: (cards: Card[]) => void; 60 | addWidget: (widgetData: WidgetPayload) => void; 61 | setQuickReplies: (quickReplies: QuickReply[]) => void; 62 | sendQuickReply: (button: Button) => Promise; 63 | sendAction: (button: Button) => Promise; 64 | sendReferralParameter: (referralParameter: string) => Promise; 65 | sendOpeningMessage: (msg: string) => Promise; 66 | sendPayload: (payload: string) => Promise; 67 | loadHistory: () => TockHistoryData | null; 68 | /** 69 | * @deprecated use {@link loadHistory} instead of reimplementing history parsing 70 | */ 71 | addHistory: ( 72 | history: Array, 73 | quickReplyHistory: Array, 74 | ) => void; 75 | sseInitPromise: Promise; 76 | sseInitializing: boolean; 77 | } 78 | 79 | function mapButton(button: BotConnectorButton): Button { 80 | if (button.type === 'postback') { 81 | return new PostBackButton(button.title, button.payload, button.imageUrl); 82 | } else if (button.type === 'quick_reply') { 83 | return new QuickReply( 84 | button.title, 85 | button.payload, 86 | button.nlpText, 87 | button.imageUrl, 88 | ); 89 | } else { 90 | return new UrlButton( 91 | button.title, 92 | button.url, 93 | button.imageUrl, 94 | button.target, 95 | button.windowFeatures, 96 | button.style, 97 | ); 98 | } 99 | } 100 | 101 | function mapCard(card: BotConnectorCard): Card { 102 | return { 103 | title: card.title, 104 | subTitle: card.subTitle, 105 | imageUrl: card.file?.url, 106 | imageAlternative: card?.file?.description ?? card.title, 107 | buttons: card.buttons.map(mapButton), 108 | type: MessageType.card, 109 | } as Card; 110 | } 111 | 112 | function mapImage(image: BotConnectorImage): Image { 113 | return { 114 | title: image.title, 115 | url: image.file?.url, 116 | alternative: image.file?.description ?? image.file?.name, 117 | type: MessageType.image, 118 | } as Image; 119 | } 120 | 121 | const FINISHED_PROCESSING = -1; 122 | 123 | export const useTock0: ( 124 | tockEndPoint: string, 125 | settings: TockSettings, 126 | extraHeadersProvider?: () => Promise>, 127 | disableSse?: boolean, 128 | localStorageHistory?: TockLocalStorage, 129 | ) => UseTock = ( 130 | tockEndPoint: string, 131 | { locale, localStorage: localStorageSettings, network: networkSettings }, 132 | extraHeadersProvider?: () => Promise>, 133 | disableSseArg?: boolean, 134 | localStorageHistoryArg?: TockLocalStorage, 135 | ) => { 136 | const { 137 | messages, 138 | quickReplies, 139 | userId, 140 | loading, 141 | sseInitializing, 142 | error, 143 | }: TockState = useTockState(); 144 | const dispatch: Dispatch = useTockDispatch(); 145 | const localStorageEnabled = 146 | localStorageHistoryArg?.enable ?? localStorageSettings.enableMessageHistory; 147 | const localStorageMaxMessages = 148 | localStorageHistoryArg?.maxNumberMessages ?? 149 | localStorageSettings.maxMessageCount; 150 | const localStoragePrefix = localStorageSettings.prefix; 151 | const disableSse = disableSseArg ?? networkSettings.disableSse; 152 | const { clearMessages }: UseLocalTools = useLocalTools(localStorageEnabled); 153 | const handledResponses = useRef>({}); 154 | const afterInit = useRef(() => {}); 155 | const afterInitPromise = useRef( 156 | new Promise((resolve) => { 157 | afterInit.current = resolve; 158 | }), 159 | ); 160 | const sseSource = useRef(new TockEventSource()); 161 | 162 | const startLoading: () => void = useCallback(() => { 163 | dispatch({ 164 | type: 'SET_LOADING', 165 | loading: true, 166 | }); 167 | }, [dispatch]); 168 | 169 | const stopLoading: () => void = useCallback(() => { 170 | dispatch({ 171 | type: 'SET_LOADING', 172 | loading: false, 173 | }); 174 | }, [dispatch]); 175 | 176 | const recordResponseToLocalSession: (message: Message) => void = useCallback( 177 | (message: Message) => { 178 | const messageHistoryLSKeyName = retrievePrefixedLocalStorageKey( 179 | localStoragePrefix, 180 | 'tockMessageHistory', 181 | ); 182 | 183 | const savedHistory = window.localStorage.getItem(messageHistoryLSKeyName); 184 | let history: Message[]; 185 | if (!savedHistory) { 186 | history = []; 187 | } else { 188 | history = JSON.parse(savedHistory); 189 | } 190 | if (history.length >= localStorageMaxMessages) { 191 | history.splice(0, history.length - localStorageMaxMessages + 1); 192 | } 193 | history.push(message); 194 | window.localStorage.setItem( 195 | messageHistoryLSKeyName, 196 | JSON.stringify(history), 197 | ); 198 | }, 199 | [localStoragePrefix, localStorageMaxMessages], 200 | ); 201 | 202 | const handleBotResponse: (botResponse: BotConnectorResponse) => void = 203 | useCallback( 204 | ({ responses, metadata }) => { 205 | dispatch({ 206 | type: 'SET_METADATA', 207 | metadata: metadata || {}, 208 | }); 209 | 210 | if (Array.isArray(responses) && responses.length > 0) { 211 | const lastMessage = responses[responses.length - 1]; 212 | const quickReplies = (lastMessage.buttons || []) 213 | .filter((button) => button.type === 'quick_reply') 214 | .map(mapButton); 215 | dispatch({ 216 | type: 'SET_QUICKREPLIES', 217 | quickReplies, 218 | }); 219 | if (localStorageEnabled) { 220 | const quickReplyHistoryLSKeyName = retrievePrefixedLocalStorageKey( 221 | localStoragePrefix, 222 | 'tockQuickReplyHistory', 223 | ); 224 | window.localStorage.setItem( 225 | quickReplyHistoryLSKeyName, 226 | JSON.stringify(quickReplies), 227 | ); 228 | } 229 | dispatch({ 230 | type: 'ADD_MESSAGE', 231 | messages: responses.flatMap((response) => { 232 | const { text, card, carousel, widget, image, buttons } = response; 233 | let message: Message; 234 | if (widget) { 235 | message = { 236 | widgetData: widget, 237 | type: MessageType.widget, 238 | } as Widget; 239 | } else if (text) { 240 | message = { 241 | author: 'bot', 242 | message: text, 243 | type: MessageType.message, 244 | buttons: (buttons || []) 245 | .filter((button) => button.type !== 'quick_reply') 246 | .map(mapButton), 247 | } as TextMessage; 248 | } else if (card) { 249 | message = mapCard(card); 250 | } else if (image) { 251 | message = mapImage(image); 252 | } else if (carousel) { 253 | message = { 254 | cards: carousel.cards.map(mapCard), 255 | type: MessageType.carousel, 256 | } as Carousel; 257 | } else { 258 | console.error('Unsupported bot response', response); 259 | return []; 260 | } 261 | 262 | message.metadata = metadata; 263 | 264 | if (localStorageEnabled) { 265 | recordResponseToLocalSession(message); 266 | } 267 | return [message]; 268 | }), 269 | }); 270 | } 271 | }, 272 | [dispatch], 273 | ); 274 | 275 | const setProcessedMessageCount = useCallback( 276 | (responseId: string, newCount: number) => { 277 | handledResponses.current[responseId] = newCount; 278 | const handledResponsesLSKeyName = retrievePrefixedLocalStorageKey( 279 | localStoragePrefix, 280 | 'tockHandledResponses', 281 | ); 282 | 283 | window.localStorage.setItem( 284 | handledResponsesLSKeyName, 285 | JSON.stringify(handledResponses.current), 286 | ); 287 | }, 288 | [localStoragePrefix], 289 | ); 290 | 291 | const handlePostBotResponse: (botResponse: BotConnectorResponse) => void = 292 | useCallback( 293 | (botResponse) => { 294 | const responseId = botResponse.metadata?.['RESPONSE_ID']; 295 | if (!responseId && !sseSource.current.isInitialized()) { 296 | // no identifier and SSE enabled -> always discard POST response, handle with SSE 297 | // no identifier and SSE disabled -> always handle here 298 | handleBotResponse(botResponse); 299 | } else { 300 | const processedMessageCount = 301 | handledResponses.current[responseId] ?? 0; 302 | if (processedMessageCount >= 0) { 303 | handleBotResponse( 304 | processedMessageCount > 0 // did we already receive messages for this response through SSE? 305 | ? { 306 | ...botResponse, 307 | // only add messages that have not been processed from SSE 308 | responses: botResponse.responses.slice( 309 | processedMessageCount, 310 | ), 311 | } 312 | : botResponse, 313 | ); 314 | // mark as processed through POST - no further messages should be handled for this response 315 | setProcessedMessageCount(responseId, FINISHED_PROCESSING); 316 | } else { 317 | console.warn( 318 | 'Bot POST request yielded the same response twice, discarding', 319 | botResponse, 320 | ); 321 | } 322 | } 323 | }, 324 | [setProcessedMessageCount, handleBotResponse], 325 | ); 326 | 327 | const handleSseBotResponse: (botResponse: BotConnectorResponse) => void = ( 328 | botResponse, 329 | ) => { 330 | const responseId = botResponse.metadata?.['RESPONSE_ID']; 331 | if (!responseId) { 332 | // no identifier -> always handle with SSE 333 | handleBotResponse(botResponse); 334 | } else { 335 | const processedMessageCount = handledResponses.current[responseId] ?? 0; 336 | if (processedMessageCount >= 0) { 337 | handleBotResponse(botResponse); 338 | setProcessedMessageCount( 339 | responseId, 340 | processedMessageCount + botResponse.responses.length, 341 | ); 342 | } 343 | } 344 | }; 345 | 346 | const addMessage: ( 347 | message: string, 348 | author: 'bot' | 'user', 349 | buttons?: Button[], 350 | ) => void = useCallback( 351 | (message: string, author: 'bot' | 'user', buttons?: Button[]) => 352 | dispatch({ 353 | type: 'ADD_MESSAGE', 354 | messages: [ 355 | { 356 | author, 357 | message, 358 | type: MessageType.message, 359 | buttons: buttons, 360 | } as TextMessage, 361 | ], 362 | }), 363 | [], 364 | ); 365 | 366 | const addImage: (title: string, url: string) => void = useCallback( 367 | (title: string, url: string) => 368 | dispatch({ 369 | type: 'ADD_MESSAGE', 370 | messages: [ 371 | { 372 | title, 373 | url, 374 | type: MessageType.image, 375 | } as Image, 376 | ], 377 | }), 378 | [], 379 | ); 380 | 381 | const handleError: (error: unknown) => void = ({ error }) => { 382 | console.error(error); 383 | stopLoading(); 384 | setQuickReplies([]); 385 | if (localStorage) { 386 | window.localStorage.setItem('tockQuickReplyHistory', ''); 387 | } 388 | dispatch({ 389 | type: 'SET_ERROR', 390 | error: true, 391 | loading: false, 392 | }); 393 | }; 394 | 395 | const getExtraHeaders: () => Promise> = 396 | extraHeadersProvider ?? (async () => ({})); 397 | 398 | const sendMessage: ( 399 | message: string, 400 | payload?: string, 401 | displayMessage?: boolean, 402 | ) => Promise = useCallback( 403 | async (message: string, payload?: string, displayMessage = true) => { 404 | if (displayMessage) { 405 | const messageToDispatch = { 406 | author: 'user', 407 | message, 408 | type: MessageType.message, 409 | } as TextMessage; 410 | if (localStorageEnabled) { 411 | recordResponseToLocalSession(messageToDispatch); 412 | } 413 | dispatch({ 414 | type: 'ADD_MESSAGE', 415 | messages: [messageToDispatch], 416 | }); 417 | } 418 | startLoading(); 419 | const body = payload 420 | ? { 421 | payload, 422 | userId, 423 | locale, 424 | } 425 | : { 426 | query: message, 427 | userId, 428 | locale, 429 | }; 430 | 431 | return fetch(tockEndPoint, { 432 | body: JSON.stringify(body), 433 | method: 'POST', 434 | headers: { 435 | 'Content-Type': 'application/json', 436 | ...(await getExtraHeaders()), 437 | }, 438 | }) 439 | .then((res) => res.json()) 440 | .then(handlePostBotResponse, handleError) 441 | .finally(stopLoading); 442 | }, 443 | [locale], 444 | ); 445 | 446 | const sendReferralParameter: (referralParameter: string) => Promise = 447 | useCallback((referralParameter: string) => { 448 | startLoading(); 449 | return fetch(tockEndPoint, { 450 | body: JSON.stringify({ 451 | ref: referralParameter, 452 | userId, 453 | locale, 454 | }), 455 | method: 'POST', 456 | headers: { 457 | 'Content-Type': 'application/json', 458 | }, 459 | }) 460 | .then((res) => res.json()) 461 | .then(handlePostBotResponse, handleError) 462 | .finally(stopLoading); 463 | }, []); 464 | 465 | const sendQuickReply: (button: Button) => Promise = ( 466 | button: Button, 467 | ) => { 468 | if (button instanceof UrlButton) { 469 | console.warn( 470 | 'Using sendQuickReply for links is deprecated; please use the dedicated UrlButton component', 471 | button, 472 | ); 473 | window.open(button.url, '_blank'); 474 | return Promise.resolve(); 475 | } else if (button.payload) { 476 | setQuickReplies([]); 477 | if (localStorageEnabled) { 478 | recordResponseToLocalSession({ 479 | author: 'user', 480 | message: button.label, 481 | type: MessageType.message, 482 | }); 483 | } 484 | addMessage(button.label, 'user'); 485 | startLoading(); 486 | return sendPayload(button.payload); 487 | } else { 488 | if (button instanceof QuickReply && button.nlpText) { 489 | return sendMessage(button.nlpText); 490 | } 491 | return sendMessage(button.label); 492 | } 493 | }; 494 | 495 | function sendPayload(payload?: string) { 496 | return fetch(tockEndPoint, { 497 | body: JSON.stringify({ 498 | payload, 499 | userId, 500 | locale, 501 | }), 502 | method: 'POST', 503 | headers: { 504 | 'Content-Type': 'application/json', 505 | }, 506 | }) 507 | .then((res) => res.json()) 508 | .then(handlePostBotResponse, handleError) 509 | .finally(stopLoading); 510 | } 511 | 512 | const sendAction: (button: Button) => Promise = (button: Button) => { 513 | if (button instanceof UrlButton) { 514 | console.warn( 515 | 'Using sendAction for links is deprecated; please use the dedicated UrlButton component', 516 | button, 517 | ); 518 | window.open(button.url, '_blank'); 519 | } else { 520 | return sendMessage(button.label, button.payload); 521 | } 522 | return Promise.resolve(); 523 | }; 524 | 525 | // Sends an initial message to the backend, to trigger a welcome message 526 | const sendOpeningMessage: (msg: string) => Promise = (msg) => 527 | sendMessage(msg, undefined, false); 528 | 529 | const addCard: ( 530 | title: string, 531 | imageUrl?: string, 532 | subTitle?: string, 533 | buttons?: Button[], 534 | ) => void = useCallback( 535 | (title: string, imageUrl?: string, subTitle?: string, buttons?: Button[]) => 536 | dispatch({ 537 | type: 'ADD_MESSAGE', 538 | messages: [ 539 | { 540 | title, 541 | imageUrl, 542 | subTitle, 543 | buttons, 544 | type: MessageType.card, 545 | }, 546 | ], 547 | }), 548 | [], 549 | ); 550 | 551 | const addCarousel: (cards: Card[]) => void = useCallback( 552 | (cards: Card[]) => 553 | dispatch({ 554 | type: 'ADD_MESSAGE', 555 | messages: [ 556 | { 557 | type: MessageType.carousel, 558 | cards, 559 | }, 560 | ], 561 | }), 562 | [], 563 | ); 564 | 565 | const addWidget: (widgetData: WidgetPayload) => void = useCallback( 566 | (widgetData: WidgetPayload) => 567 | dispatch({ 568 | type: 'ADD_MESSAGE', 569 | messages: [ 570 | { 571 | type: MessageType.widget, 572 | widgetData, 573 | }, 574 | ], 575 | }), 576 | [], 577 | ); 578 | 579 | const setQuickReplies: (quickReplies: QuickReply[]) => void = useCallback( 580 | (quickReplies: QuickReply[]) => 581 | dispatch({ 582 | type: 'SET_QUICKREPLIES', 583 | quickReplies, 584 | }), 585 | [], 586 | ); 587 | 588 | const onSseStateChange: (state: number) => void = useCallback( 589 | (state: number) => 590 | dispatch({ 591 | type: 'SET_SSE_INITIALIZING', 592 | sseInitializing: state === EventSource.CONNECTING, 593 | }), 594 | [], 595 | ); 596 | 597 | useEffect(() => { 598 | sseSource.current.onStateChange = onSseStateChange; 599 | sseSource.current.onResponse = handleSseBotResponse; 600 | }, [handleSseBotResponse, onSseStateChange]); 601 | 602 | useEffect(() => { 603 | if (disableSse || !tockEndPoint.length) { 604 | afterInit.current(); 605 | } else { 606 | // Trigger afterInit regardless of whether the SSE call succeeded or failed 607 | // (it is valid for the backend to refuse SSE connections, but we still attempt to connect by default) 608 | sseSource.current 609 | .open(tockEndPoint, userId) 610 | .catch((e) => console.error(e)) 611 | .finally(afterInit.current); 612 | } 613 | return () => sseSource.current.close(); 614 | }, [disableSse, tockEndPoint]); 615 | 616 | const addHistory: ( 617 | messageHistory: Array, 618 | quickReplyHistory: Array, 619 | ) => void = useCallback( 620 | (history: Array, quickReplyHistory: Array) => { 621 | dispatch({ 622 | type: 'ADD_MESSAGE', 623 | messages: history.map((message: Message) => { 624 | message.alreadyDisplayed = true; 625 | return message; 626 | }), 627 | }); 628 | setQuickReplies(quickReplyHistory); 629 | stopLoading(); 630 | }, 631 | [], 632 | ); 633 | 634 | const loadHistory: () => TockHistoryData | null = () => { 635 | // If not first time, return existing messages 636 | if (messages.length) { 637 | return { 638 | messages, 639 | quickReplies, 640 | }; 641 | } 642 | 643 | const messageHistoryLSKey = retrievePrefixedLocalStorageKey( 644 | localStoragePrefix, 645 | 'tockMessageHistory', 646 | ); 647 | const quickReplyHistoryLSKey = retrievePrefixedLocalStorageKey( 648 | localStoragePrefix, 649 | 'tockQuickReplyHistory', 650 | ); 651 | const handledResponsesLSKeyName = retrievePrefixedLocalStorageKey( 652 | localStoragePrefix, 653 | 'tockHandledResponses', 654 | ); 655 | 656 | const serializedHistory = 657 | storageAvailable('localStorage') && localStorageEnabled 658 | ? window.localStorage.getItem(messageHistoryLSKey) 659 | : undefined; 660 | 661 | if (serializedHistory) { 662 | const messages = JSON.parse(serializedHistory); 663 | const quickReplies = JSON.parse( 664 | window.localStorage.getItem(quickReplyHistoryLSKey) || '[]', 665 | ); 666 | addHistory(messages, quickReplies); 667 | handledResponses.current = { 668 | ...JSON.parse( 669 | window.localStorage.getItem(handledResponsesLSKeyName) || '{}', 670 | ), 671 | ...handledResponses.current, 672 | }; 673 | return { messages, quickReplies }; 674 | } 675 | 676 | return null; 677 | }; 678 | 679 | return { 680 | messages, 681 | quickReplies, 682 | loading, 683 | error, 684 | clearMessages, 685 | addCard, 686 | addCarousel, 687 | addMessage, 688 | addImage, 689 | addWidget, 690 | sendMessage, 691 | setQuickReplies, 692 | sendQuickReply, 693 | sendAction, 694 | sendReferralParameter, 695 | sendOpeningMessage, 696 | sendPayload, 697 | addHistory, 698 | loadHistory, 699 | sseInitPromise: afterInitPromise.current, 700 | sseInitializing, 701 | }; 702 | }; 703 | 704 | export const UseTockContext = createContext(undefined); 705 | 706 | export default ( 707 | tockEndPoint?: string, 708 | extraHeadersProvider?: () => Promise>, 709 | disableSse?: boolean, 710 | localStorageHistory?: TockLocalStorage, 711 | ) => { 712 | const contextTock = useContext(UseTockContext); 713 | const settings = useTockSettings(); 714 | if (contextTock != null) { 715 | return contextTock; 716 | } 717 | if (settings.endpoint == null && tockEndPoint == null) { 718 | throw new Error('TOCK endpoint must be provided in TockContext'); 719 | } else if (settings.endpoint == null) { 720 | console.warn( 721 | 'Passing TOCK endpoint as argument to TockChat or useTock is deprecated; please set it in TockContext instead.', 722 | ); 723 | } 724 | return contextTock 725 | ? contextTock 726 | : useTock0( 727 | (tockEndPoint ?? settings.endpoint)!, 728 | settings, 729 | extraHeadersProvider, 730 | disableSse, 731 | localStorageHistory, 732 | ); 733 | }; 734 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieves persisted user id. 3 | */ 4 | export const retrieveUserId: (localStoragePrefix?: string) => string = ( 5 | localStoragePrefix?: string, 6 | ) => { 7 | const userIdLSKeyName = retrievePrefixedLocalStorageKey( 8 | localStoragePrefix, 9 | 'userId', 10 | ); 11 | return fromLocalStorage(userIdLSKeyName, () => { 12 | const date = Date.now().toString(36); 13 | const randomNumber = Math.random().toString(36).substr(2, 5); 14 | return (date + randomNumber).toUpperCase(); 15 | }); 16 | }; 17 | 18 | /** 19 | * Retrieves and returns an object from the local storage if found. 20 | * If the value is not found it will save an initialValue before returning it. 21 | * @param key - key in local storage 22 | * @param computeInitialValue - function to create an initial value if the object is not found 23 | */ 24 | export const fromLocalStorage: ( 25 | key: string, 26 | computeInitialValue: () => T, 27 | ) => T = (key: string, computeInitialValue: () => T) => { 28 | try { 29 | const item = window.localStorage.getItem(key); 30 | if (item) { 31 | return JSON.parse(item); 32 | } else { 33 | const initialValue = computeInitialValue(); 34 | window.localStorage.setItem(key, JSON.stringify(initialValue)); 35 | return initialValue; 36 | } 37 | } catch (error) { 38 | console.log(error); 39 | return computeInitialValue(); 40 | } 41 | }; 42 | 43 | /** 44 | * Detects whether localStorage is both supported and available. 45 | * @param type - Storage type 46 | * @returns true - if locale storage is available on the user's browser 47 | */ 48 | export const storageAvailable: ( 49 | type: 'localStorage' | 'sessionStorage', 50 | ) => boolean = (type) => { 51 | let storage: Storage | undefined = undefined; 52 | try { 53 | storage = window[type]; 54 | const x = '__storage_test__'; 55 | storage.setItem(x, x); 56 | storage.removeItem(x); 57 | return true; 58 | } catch (e) { 59 | return ( 60 | e instanceof DOMException && 61 | // everything except Firefox 62 | (e.code === 22 || 63 | // Firefox 64 | e.code === 1014 || 65 | // test name field too, because code might not be present 66 | // everything except Firefox 67 | e.name === 'QuotaExceededError' || 68 | // Firefox 69 | e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && 70 | // acknowledge QuotaExceededError only if there's something already stored 71 | storage?.length !== 0 72 | ); 73 | } 74 | }; 75 | 76 | /** 77 | * Retrieves a localstorage variable name, prefixed if a storagePrefix is defined. 78 | * @param localStoragePrefix - storage key prefix if defined 79 | * @param key - key in local storage 80 | * @returns string - the local storage key name, possibly prefixed 81 | */ 82 | export const retrievePrefixedLocalStorageKey: ( 83 | localStoragePrefix: string | undefined, 84 | key: string, 85 | ) => string = (localStoragePrefix: string | undefined, key: string) => { 86 | if (localStoragePrefix?.trim().length) { 87 | return `${localStoragePrefix.trim()}_${key}`; 88 | } 89 | return key; 90 | }; 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build/lib", 4 | "module": "ES2020", 5 | "target": "es6", 6 | "lib": ["es6", "es7", "es2017", "dom"], 7 | "sourceMap": true, 8 | "inlineSources": true, 9 | "allowJs": false, 10 | "jsx": "react-jsx", 11 | "jsxImportSource": "@emotion/react", 12 | "moduleResolution": "node", 13 | "rootDirs": ["src", "stories"], 14 | "baseUrl": "src", 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "noUnusedLocals": true, 21 | "declaration": true, 22 | "allowSyntheticDefaultImports": true, 23 | "experimentalDecorators": true, 24 | "emitDecoratorMetadata": true, 25 | "esModuleInterop": true, 26 | }, 27 | "include": ["src/**/*"], 28 | "exclude": ["node_modules", "build", "scripts"] 29 | } 30 | --------------------------------------------------------------------------------