├── .circleci └── config.yml ├── .env ├── .env.example ├── .eslintrc.cjs ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── gh-deploy.yml │ ├── package-publish.yml │ ├── pr-comment-bot.yml │ ├── pr-size-diff.yml │ └── self-service-publish.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .prettierrc ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __visual_tests__ ├── __snapshots__ │ └── workflow-tests.spec.ts │ │ ├── 100-1-chromium-darwin.png │ │ ├── 100-1-firefox-darwin.png │ │ ├── 100-2-chromium-darwin.png │ │ ├── 100-2-firefox-darwin.png │ │ ├── 100-3-chromium-darwin.png │ │ ├── 100-3-firefox-darwin.png │ │ ├── 100-4-chromium-darwin.png │ │ ├── 100-4-firefox-darwin.png │ │ ├── 101-1-chromium-darwin.png │ │ ├── 101-1-firefox-darwin.png │ │ ├── 102-1-chromium-darwin.png │ │ ├── 102-1-firefox-darwin.png │ │ ├── 103-1-chromium-darwin.png │ │ ├── 103-1-firefox-darwin.png │ │ ├── 103-2-chromium-darwin.png │ │ ├── 103-2-firefox-darwin.png │ │ ├── 103-3-chromium-darwin.png │ │ ├── 103-3-firefox-darwin.png │ │ ├── 103-4-chromium-darwin.png │ │ ├── 103-4-firefox-darwin.png │ │ ├── 103-5-chromium-darwin.png │ │ ├── 103-5-firefox-darwin.png │ │ ├── 103-6-chromium-darwin.png │ │ ├── 103-6-firefox-darwin.png │ │ ├── 104-1-chromium-darwin.png │ │ ├── 104-1-firefox-darwin.png │ │ ├── 104-2-chromium-darwin.png │ │ ├── 104-2-firefox-darwin.png │ │ ├── 104-3-chromium-darwin.png │ │ ├── 104-3-firefox-darwin.png │ │ ├── 104-4-chromium-darwin.png │ │ ├── 104-4-firefox-darwin.png │ │ ├── 104-5-chromium-darwin.png │ │ ├── 104-5-firefox-darwin.png │ │ ├── 104-6-chromium-darwin.png │ │ └── 104-6-firefox-darwin.png ├── const.ts ├── utils │ ├── localStorageUtils.ts │ ├── requestUtils.ts │ └── testUtils.ts └── workflow-tests.spec.ts ├── custom-session-guide.md ├── index.html ├── js-example.html ├── package.json ├── packages └── self-service │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── embed-example.html │ ├── index.html │ ├── package.json │ ├── playground │ ├── chatbot-404.png │ └── index.html │ ├── scripts │ ├── build.js │ ├── generateIndex.js │ └── getWidgetVersion.js │ ├── src │ ├── App.tsx │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── playwright.config.ts ├── release-guide.md ├── screenshot ├── logo.png └── workflow-guide.png ├── scripts ├── build-check-ci.sh └── prebuild.mjs ├── src ├── App.tsx ├── __tests__ │ └── utils │ │ └── index.test.ts ├── colors.ts ├── components │ ├── AdminMessage.tsx │ ├── BotMessageBottom.tsx │ ├── BotMessageFeedback.tsx │ ├── BotMessageWithBodyInput.tsx │ ├── BotProfileImage.tsx │ ├── CurrentUserMessage.tsx │ ├── CustomMessage.tsx │ ├── CustomMessageBody.tsx │ ├── CustomTypingIndicatorBubble.tsx │ ├── ErrorContainer.tsx │ ├── FileMessage.tsx │ ├── LoadingScreen.tsx │ ├── MessageComponent.tsx │ ├── MessageDataContent.tsx │ ├── MyMessageStatus.tsx │ ├── ParsedBotMessageBody.tsx │ ├── SourceContainer.tsx │ ├── SuggestedRepliesContainer.tsx │ ├── TokensBody.tsx │ ├── UserMessageWithBodyInput.tsx │ ├── chat │ │ ├── context │ │ │ └── ChatProvider.tsx │ │ ├── hooks │ │ │ ├── useBotStudioView.tsx │ │ │ ├── useTypingTargetMessageId.ts │ │ │ └── useWidgetChatHandlers.ts │ │ ├── index.tsx │ │ └── ui │ │ │ ├── ChatHeader.tsx │ │ │ ├── ChatInput.tsx │ │ │ ├── ChatMessageList.tsx │ │ │ └── index.tsx │ ├── markdown.scss │ ├── messages │ │ ├── CarouselMessage.tsx │ │ ├── FallbackUserMessage.tsx │ │ ├── FormMessage │ │ │ ├── FormInput.tsx │ │ │ └── index.tsx │ │ └── OutgoingFileMessage.tsx │ ├── ui │ │ ├── AlertModal.tsx │ │ ├── BetaLogo.tsx │ │ ├── CodeBlock.tsx │ │ ├── FileViewer.tsx │ │ ├── PoweredByBanner.tsx │ │ ├── SnapCarousel │ │ │ └── index.tsx │ │ └── WidgetButton.tsx │ └── widget │ │ ├── ChatAiWidget.tsx │ │ ├── ProviderContainer.tsx │ │ ├── WidgetToggleButton.tsx │ │ ├── WidgetWindow.tsx │ │ └── WidgetWindowFullScreen.tsx ├── const.ts ├── context │ ├── ConstantContext.tsx │ ├── WidgetSettingContext.tsx │ └── WidgetStateContext.tsx ├── css │ └── index.css ├── custom.d.ts ├── foundation │ ├── colors │ │ ├── css.ts │ │ ├── palette.ts │ │ └── theme.ts │ ├── components │ │ ├── DateSeparator │ │ │ ├── css.ts │ │ │ └── index.tsx │ │ ├── FrozenBanner │ │ │ ├── css.ts │ │ │ └── index.tsx │ │ ├── Icon │ │ │ └── index.tsx │ │ ├── InfiniteMessageList │ │ │ ├── css.ts │ │ │ └── index.tsx │ │ ├── Label │ │ │ ├── css.ts │ │ │ └── index.tsx │ │ ├── Loader │ │ │ └── index.tsx │ │ ├── Placeholder │ │ │ ├── Placeholder.error.tsx │ │ │ ├── Placeholder.loading.tsx │ │ │ ├── Placeholder.noChannels.tsx │ │ │ ├── Placeholder.noMessages.tsx │ │ │ ├── PlaceholderCommon.tsx │ │ │ ├── css.ts │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── ScrollToBottomButton │ │ │ ├── css.ts │ │ │ └── index.tsx │ │ └── TypingBubble │ │ │ ├── css.ts │ │ │ └── index.tsx │ ├── hooks │ │ ├── useLocalProps.ts │ │ └── usePartialState.ts │ ├── resolveSize.ts │ └── types.ts ├── hooks │ ├── useAssignGlobalFunction.ts │ ├── useAutoDismissMobileKeyboardHandler.ts │ ├── useBlockWhileBotResponding.ts │ ├── useMobileView.ts │ ├── usePrevious.ts │ ├── useResetHistoryOnConnected.ts │ ├── useStyledComponentsTarget.ts │ ├── useThrottle.ts │ ├── useWidgetAutoOpen.ts │ └── useWidgetInactivityTimeout.ts ├── icons │ ├── ic-bot-filled.svg │ ├── ic-bot-outlined.svg │ ├── ic-chevron-down.svg │ ├── ic-chevron-left.svg │ ├── ic-chevron-right.svg │ ├── ic-close.svg │ ├── ic-collapse.svg │ ├── ic-ellipsis.svg │ ├── ic-error.svg │ ├── ic-expand.svg │ ├── ic-message.svg │ ├── ic-open.svg │ ├── ic-refresh.svg │ ├── ic-spinner.svg │ └── sendbird-logo.svg ├── index.ts ├── libs │ ├── api │ │ └── widgetSetting.ts │ └── storage │ │ └── widgetSessionCache.ts ├── main.tsx ├── styled-components.d.ts ├── theme.ts ├── tools │ └── hooks │ │ └── useDragDropFiles.tsx ├── types.ts ├── utils │ ├── getImageAspectRatio.ts │ ├── index.ts │ ├── messageExtension.ts │ ├── messageTimestamp.ts │ └── messages.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.pages.ts ├── vite.config.ts ├── vitest.config.ts ├── wyw-in-js.config.cjs └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # Vite prefix is required for Vite to load the env variables 2 | # Plz modify below two env variables on your needs 3 | VITE_CHAT_WIDGET_APP_ID=AE8F7EEA-4555-4F86-AD8B-5E0BD86BFE67 4 | VITE_CHAT_WIDGET_BOT_ID=khan-academy-bot 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Vite prefix is required for Vite to load the env variables 2 | # Sendbird App ID 3 | # VITE_CHAT_WIDGET_APP_ID= 4 | # VITE_CHAT_WIDGET_BOT_ID= 5 | 6 | # Only for internal use 7 | # VITE_CHAT_AI_WIDGET_KEY= 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, // This fixes issue when running lint fix command: https://stackoverflow.com/questions/55060228/eslint-couldnt-find-the-plugin-eslint-plugin-typescript-eslint 3 | env: { browser: true, es2020: true }, 4 | parser: '@typescript-eslint/parser', 5 | parserOptions: { 6 | ecmaVersion: 'latest', 7 | sourceType: 'module', 8 | ecmaFeatures: { jsx: true }, 9 | }, 10 | plugins: ['import', 'styled-components-a11y'], 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'plugin:react/recommended', 15 | 'plugin:react-hooks/recommended', 16 | 'plugin:jsx-a11y/recommended', 17 | 'plugin:import/recommended', 18 | 'prettier', 19 | ], 20 | rules: { 21 | 'react/prop-types': 'off', 22 | 'react-hooks/rules-of-hooks': 'error', 23 | 'react-hooks/exhaustive-deps': 'off', 24 | // suppress errors for missing 'import React' in files 25 | 'react/react-in-jsx-scope': 'off', 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | 'import/order': [ 28 | 'error', 29 | { 30 | groups: [ 31 | ['builtin'], 32 | ['external'], 33 | 'internal', 34 | ['parent', 'sibling', 'index'], 35 | ], 36 | 'newlines-between': 'always', 37 | alphabetize: { order: 'asc', caseInsensitive: true }, 38 | }, 39 | ], 40 | 'styled-components-a11y/control-has-associated-label': [ 41 | 2, 42 | { 43 | 'labelAttributes': ['label'], 44 | 'depth': 3, 45 | 'ignoreElements': [ 46 | 'input', 47 | 'textarea', 48 | ], 49 | 'includeRoles': ['button'] 50 | }, 51 | ], 52 | '@typescript-eslint/no-unused-vars': [ 53 | 1, 54 | { 55 | vars: 'all', 56 | varsIgnorePattern: '^_', 57 | args: 'after-used', 58 | argsIgnorePattern: '^_', 59 | }, 60 | ], 61 | }, 62 | settings: { 63 | react: { 64 | version: 'detect', 65 | }, 66 | 'import/resolver': { 67 | typescript:{}, 68 | node:{ 69 | extensions: [ 70 | '.js', 71 | '.jsx', 72 | '.ts', 73 | '.tsx', 74 | '.d.ts', 75 | ], 76 | } 77 | }, 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changes 2 | - 3 | 4 | ticket: [] 5 | 6 | ## Additional Notes 7 | - 8 | 9 | ## Checklist 10 | Before requesting a code review, please check the following: 11 | - [ ] **[Required]** CI has passed all checks. 12 | - [ ] **[Required]** A self-review has been conducted to ensure there are no minor mistakes. 13 | - [ ] **[Required]** Unnecessary comments/debugging code have been removed. 14 | - [ ] **[Required]** All requirements specified in the ticket have been accurately implemented. 15 | - [ ] Ensure the ticket has been updated with the sprint, status, and story points. 16 | -------------------------------------------------------------------------------- /.github/workflows/gh-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Github pages 2 | 3 | on: 4 | push: 5 | branches: ["develop"] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | # Single deploy job since we're just deploying 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 🛎️ 25 | uses: actions/checkout@v4 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | submodules: 'recursive' 29 | fetch-depth: 0 30 | - name: Enable corepack 31 | run: corepack enable 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: 18.x 35 | cache: 'yarn' 36 | - name: Install and Build 🔧 37 | run: | 38 | yarn install 39 | cp .env .env.production 40 | echo "VITE_CHAT_AI_WIDGET_KEY=${{ secrets.chat_ai_widget_key }}" >> .env.production 41 | yarn build:pages 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v5 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: 'dist' 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v4 51 | -------------------------------------------------------------------------------- /.github/workflows/pr-comment-bot.yml: -------------------------------------------------------------------------------- 1 | name: PR comment bot 2 | on: 3 | issue_comment: 4 | types: [created] 5 | jobs: 6 | pr-comment: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | # see list of commands: https://github.com/sendbird/release-automation-action#commands 11 | - uses: sendbird/release-automation-action@latest 12 | with: 13 | gh_token: ${{ secrets.GITHUB_TOKEN }} 14 | circleci_token: ${{ secrets.CIRCLECI_API_TOKEN }} 15 | product: 'chat-ai-widget' 16 | platform: 'js' 17 | framework: 'react' 18 | product_jira_project_key: 'AC' 19 | product_jira_version_prefix: 'js_chat_ai_widget' 20 | changelog_file: 'CHANGELOG.md' 21 | -------------------------------------------------------------------------------- /.github/workflows/pr-size-diff.yml: -------------------------------------------------------------------------------- 1 | name: PR size diff 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | submodules: 'recursive' 14 | fetch-depth: 0 15 | - name: Enable corepack 16 | run: corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 18.x 20 | cache: 'yarn' 21 | - uses: preactjs/compressed-size-action@v2 22 | with: 23 | pattern: "./dist/**/*.{js,css}" 24 | exclude: "{**/*.d.ts,**/node_modules/**}" 25 | strip-hash: "\\w+-([a-zA-Z0-9_-]{8})\\.js$" 26 | install-script: "yarn install --immutable" 27 | clean-script: "install:deps" 28 | -------------------------------------------------------------------------------- /.github/workflows/self-service-publish.yml: -------------------------------------------------------------------------------- 1 | name: self-service build and publish 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | types: 8 | - closed 9 | 10 | jobs: 11 | deploy: 12 | if: github.event.pull_request.merged == true 13 | runs-on: ubuntu-latest 14 | env: 15 | DEFAULT_BRANCH: develop 16 | steps: 17 | - name: Check if the source branch is a release branch 18 | run: | 19 | PR_BRANCH="${{ github.event.pull_request.head.ref }}" 20 | echo "Source branch is $PR_BRANCH" 21 | if [[ "$PR_BRANCH" == release/* ]]; then 22 | echo "The source branch is a release branch, proceed with deployment." 23 | curl -d '{"branch": "${{ env.DEFAULT_BRANCH }}", "parameters": {"run_deploy_prod": true}}' \ 24 | -H 'Content-Type: application/json' \ 25 | -H 'Circle-Token: ${{ secrets.CIRCLECI_API_TOKEN }}' \ 26 | -X POST https://circleci.com/api/v2/project/gh/sendbird/chat-ai-widget/pipeline 27 | else 28 | echo "The source branch is not a release branch, skipping deployment." 29 | exit 0 30 | fi 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 11 | .pnp.* 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | 19 | node_modules 20 | css 21 | dist 22 | dist-ssr 23 | *.local 24 | 25 | .env.production 26 | .npmrc 27 | 28 | # Editor directories and files 29 | .vscode/* 30 | !.vscode/extensions.json 31 | .idea 32 | .DS_Store 33 | *.suo 34 | *.ntvs* 35 | *.njsproj 36 | *.sln 37 | *.sw? 38 | /test-results/ 39 | /playwright-report/ 40 | /blob-report/ 41 | /playwright/.cache/ 42 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "packages/uikit"] 2 | path = packages/uikit 3 | url = git@github.com:sendbird/sendbird-uikit-react.git 4 | branch = ai-widget/experimental 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .idea 3 | src -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "preserve", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "arrowParens": "always" 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sendbird 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 | -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-1-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-1-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-1-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-1-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-2-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-2-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-2-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-2-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-3-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-3-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-3-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-3-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-4-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-4-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-4-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-4-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-5-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-5-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-5-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-5-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-6-chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-6-chromium-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-6-firefox-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/__visual_tests__/__snapshots__/workflow-tests.spec.ts/104-6-firefox-darwin.png -------------------------------------------------------------------------------- /__visual_tests__/const.ts: -------------------------------------------------------------------------------- 1 | export const AppId = process.env.SNAPSHOT_TEST_APP_ID; 2 | export const BotId = process.env.SNAPSHOT_TEST_BOT_ID; 3 | export const ApiToken = process.env.SNAPSHOT_TEST_API_TOKEN; 4 | 5 | export const ApiHost = `https://api-${AppId}.sendbird.com`; 6 | 7 | export const TestUrl = `http://localhost:5173/chat-ai-widget/?app_id=${AppId}&bot_id=${BotId}&snapshot=true`; 8 | 9 | export const WidgetComponentIds = { 10 | WIDGET: '#aichatbot-widget-window', 11 | WIDGET_BUTTON: '#aichatbot-widget-button', 12 | MESSAGE_INPUT: '#sendbird-message-input-text-field', 13 | SUGGESTED_REPLIES_OPTIONS: '.sendbird-suggested-replies__option', 14 | BUTTON: 'button.sendbird-button--primary', 15 | INPUT: '.sendbird-input__input', 16 | CHIPS_CONTAINER: '.sendbird-form-chip__container', 17 | FORM: '#aichatbot-widget-form', 18 | MARKDOWN: '.widget-markdown', 19 | }; 20 | -------------------------------------------------------------------------------- /__visual_tests__/utils/localStorageUtils.ts: -------------------------------------------------------------------------------- 1 | import { type Page } from '@playwright/test'; 2 | 3 | export const getKey = (appId: string, botId: string) => { 4 | return `@sendbird/chat-ai-widget/${appId}/${botId}`; 5 | }; 6 | 7 | export type WidgetSessionCache = { 8 | userId: string; 9 | channelUrl: string; 10 | }; 11 | 12 | export async function getWidgetSessionCache( 13 | page: Page, 14 | { appId, botId }: { appId: string; botId: string }, 15 | ): Promise { 16 | const value = await page.evaluate(({ key }) => localStorage.getItem(key), { key: getKey(appId, botId) }); 17 | if (value) { 18 | try { 19 | return JSON.parse(value); 20 | } catch { 21 | return null; 22 | } 23 | } else { 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /__visual_tests__/utils/requestUtils.ts: -------------------------------------------------------------------------------- 1 | import { ApiHost, ApiToken } from '../const'; 2 | 3 | interface RequestParams { 4 | url: string; 5 | headers?: object; 6 | data?: object; 7 | } 8 | 9 | function createQueryString(params: any): string { 10 | const items: string[] = []; 11 | for (const key in params) { 12 | items.push(`${key}=${encodeURIComponent(params[key])}`); 13 | } 14 | return items.join('&'); 15 | } 16 | 17 | function createHeaders(): object { 18 | return { 19 | 'Api-Token': ApiToken, 20 | 'Content-Type': 'application/json', 21 | }; 22 | } 23 | 24 | async function requestDelete(requestParams: RequestParams) { 25 | const response = await fetch(`${ApiHost}${requestParams.url}?${createQueryString(requestParams.data)}`, { 26 | method: 'DELETE', 27 | headers: createHeaders() as Headers, 28 | body: JSON.stringify(requestParams.data) || null, 29 | }); 30 | return await response.json(); 31 | } 32 | 33 | export async function deleteChannel(channelUrl: string): Promise { 34 | return await requestDelete({ 35 | url: `/v3/group_channels/${encodeURIComponent(channelUrl)}`, 36 | }); 37 | } 38 | 39 | export async function deleteUser(userId: string): Promise { 40 | return await requestDelete({ 41 | url: `/v3/users/${encodeURIComponent(userId)}`, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /__visual_tests__/utils/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page } from '@playwright/test'; 2 | 3 | import { getWidgetSessionCache } from './localStorageUtils'; 4 | import { deleteChannel, deleteUser } from './requestUtils'; 5 | import { AppId, BotId, TestUrl, WidgetComponentIds } from '../const'; 6 | 7 | export async function assertScreenshot(page: Page, screenshotName: string, browserName: string) { 8 | const name = `${screenshotName}.${browserName}.${process.platform}.png`; // Include the browser and OS architecture info in the filename 9 | await expect(page.locator(WidgetComponentIds.WIDGET)).toHaveScreenshot(name, { 10 | omitBackground: false, 11 | maxDiffPixelRatio: 0.01, // Need this because Sendbird logo is slightly differently rendered in CI. 12 | }); 13 | } 14 | 15 | export async function loadWidget(page: Page, testUrl = TestUrl) { 16 | await page.goto(testUrl); 17 | const widgetWindow = page.locator(WidgetComponentIds.WIDGET_BUTTON); 18 | await widgetWindow.waitFor({ state: 'visible' }); 19 | 20 | await page.click(WidgetComponentIds.WIDGET_BUTTON); 21 | // NOTE: below fails sometimes in CI. 22 | const replies = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); 23 | await replies.waitFor({ state: 'visible' }); 24 | } 25 | 26 | export async function sendTextMessage(page: Page, text: string, waitTime = 1000) { 27 | const input = page.locator(WidgetComponentIds.MESSAGE_INPUT); 28 | await input.fill(text); 29 | await input.press('Enter'); 30 | await page.waitForTimeout(waitTime); 31 | } 32 | 33 | export async function clickNthChip(page: Page, nth: number) { 34 | const chipContainer = page.locator(WidgetComponentIds.CHIPS_CONTAINER); 35 | await chipContainer.locator(':scope > *').nth(nth).click(); 36 | } 37 | 38 | export async function deleteTestResources(page: Page) { 39 | if (AppId && BotId) { 40 | const cachedSession = await getWidgetSessionCache(page, { 41 | appId: AppId, 42 | botId: BotId, 43 | }); 44 | if (cachedSession) { 45 | try { 46 | await deleteChannel(cachedSession.channelUrl); 47 | await deleteUser(cachedSession.userId); 48 | } catch (e) { 49 | console.error('## deleteTestResources failed: ', e); 50 | } 51 | } 52 | } 53 | } 54 | 55 | export function sleep(ms: number) { 56 | return new Promise((resolve) => setTimeout(resolve, ms)); 57 | } 58 | -------------------------------------------------------------------------------- /custom-session-guide.md: -------------------------------------------------------------------------------- 1 | # Custom Session Configuration Guide 2 | 3 | This guide provides detailed instructions on configuring a custom session handler for the `ChatAiWidget` component, particularly useful for integrating authentication with your own API. 4 | 5 | ## Prerequisites 6 | 7 | Before you can manage sessions independently, you must obtain session tokens using the Sendbird Platform API. For detailed instructions on preparing to use the Platform API, please refer to the [official documentation](https://sendbird.com/docs/chat/platform-api/v3/prepare-to-use-api). 8 | 9 | ## Step-by-Step Guide 10 | 11 | ### Defining a Function to Issue Session Tokens 12 | 13 | Define a function to issue session tokens. The following TypeScript code is an example; in practice, you should issue session tokens from your server. For more information, please see the [Sendbird documentation](https://sendbird.com/docs/chat/platform-api/v3/user/managing-session-tokens/issue-a-session-token). 14 | 15 | **Note:** You must [create a Sendbird user](https://sendbird.com/docs/chat/platform-api/v3/user/creating-users/create-a-user) before issuing a token. 16 | 17 | ```ts 18 | const APP_ID = "YOUR_APP_ID"; 19 | const API_TOKEN = "YOUR_API_TOKEN"; 20 | 21 | const issueSessionToken = async ( 22 | userId: string, 23 | expiryDuration = 10 * 60 * 1000 24 | ): Promise => { 25 | const url = `https://api-${APP_ID}.sendbird.com/v3/users/${userId}/token`; 26 | const response = await fetch(url, { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/json; charset=utf8", 30 | "Api-Token": API_TOKEN, 31 | }, 32 | body: JSON.stringify({ expires_at: Date.now() + expiryDuration }), 33 | }); 34 | 35 | const data = await response.json(); 36 | if (response.ok) { 37 | return data.token as string; 38 | } else { 39 | throw new Error("Failed to issue a session token"); 40 | } 41 | }; 42 | ``` 43 | 44 | ### Defining a Session Configuration Function 45 | 46 | ```tsx 47 | const USER_ID = "USER_ID"; 48 | 49 | const configureSession = () => ({ 50 | onSessionTokenRequired: (resolve, reject) => { 51 | // Action to take when a session token is required 52 | issueSessionToken(USER_ID) 53 | .then((token) => resolve(token)) 54 | .catch((err) => reject(err)); 55 | }, 56 | onSessionRefreshed: () => { 57 | // Action to take when session is refreshed 58 | }, 59 | onSessionError: (err) => { 60 | // Action to take when session encounters an error 61 | }, 62 | onSessionClosed: () => { 63 | // Action to take when session is closed 64 | }, 65 | }); 66 | ``` 67 | 68 | ### Passing Session Configuration Data to the Widget 69 | 70 | Pass the user ID, session token, and session configuration function to the widget. The widget will now manage the session for the specified user ID, issuing session tokens as needed. 71 | 72 | ```tsx 73 | const App = () => { 74 | const [sessionToken, setSessionToken] = useState(null); 75 | 76 | useEffect(() => { 77 | issueSessionToken(USER_ID).then(token => setSessionToken(token)); 78 | }, [USER_ID]); 79 | 80 | if (!sessionToken) return null; 81 | 82 | return ( 83 | 91 | ); 92 | }; 93 | 94 | export default App; 95 | ``` 96 | 97 | ## Further Information 98 | 99 | For additional details on setting up authentication with your own API, please consult the following resources: 100 | 101 | - [JavaScript SDK Overview](https://sendbird.com/docs/chat/sdk/v4/javascript/overview) 102 | - [Connecting to the Sendbird server using a user ID and token](https://sendbird.com/docs/chat/sdk/v4/javascript/application/authenticating-a-user/authentication#2-connect-to-the-sendbird-server-with-a-user-id-and-a-token) 103 | - [Setting a session handler](https://sendbird.com/docs/chat/sdk/v4/javascript/application/authenticating-a-user/authentication#2-set-a-session-handler) 104 | 105 | - [Platform API Overview](https://sendbird.com/docs/chat/platform-api/v3/overview) 106 | - [Preparing to use the API](https://sendbird.com/docs/chat/platform-api/v3/prepare-to-use-api) 107 | - [Issuing a session token](https://sendbird.com/docs/chat/platform-api/v3/user/managing-session-tokens/issue-a-session-token) 108 | 109 | ## Real World Example 110 | 111 | For practical implementation, you can refer to this sample repository which demonstrates the integration of a custom session handler with the `ChatAiWidget` component. This example provides a hands-on approach to understand how the concepts outlined in this guide can be applied in a real-world scenario. 112 | 113 | [View the example repository on GitHub](https://github.com/sendbird/chat-ai-widget-session-sample) 114 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Sendbird AI ChatBot 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 88 | 89 | 90 |
91 | Sendbird Logo 92 |

AI Chatbot Demo

93 |
94 |
95 |

How to Integrate the Sendbird AI Chatbot

96 |

Once you have your Sendbird account and Application ID, you can install the Sendbird AI Chatbot Widget using npm:

97 |
npm install @sendbird/chat-ai-widget
98 |

Then follow the code snippet below to integrate the Sendbird AI Chatbot in your application:

99 |
import { ChatAiWidget } from "@sendbird/chat-ai-widget";
100 | import "@sendbird/chat-ai-widget/dist/style.css";
101 | 
102 | const App = () => {
103 |   return (
104 |     <ChatAiWidget
105 |       applicationId = "AE8F7EEA-4555-4F86-AD8B-5E0BD86BFE67"
106 |       botId = "khan-academy-bot"
107 |     />
108 |   );
109 | };
110 | export default App;
111 | 
112 |

If you need the Sendbird Application ID and Bot ID, you can visit the Sendbird AI Chatbot Tutorial for detailed instructions.

113 |
114 | 115 |
116 |
117 |
118 | 119 | 120 | -------------------------------------------------------------------------------- /js-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | chat-ai-widget Example 21 | 22 | 23 | 24 |
25 | 26 | 27 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sendbird/chat-ai-widget", 3 | "version": "1.9.7", 4 | "description": "Sendbird Chat AI Widget,\n Detailed documentation can be found at https://github.com/sendbird/chat-ai-widget#readme", 5 | "main": "./dist/index.umd.js", 6 | "module": "./dist/index.es.js", 7 | "types": "./dist/src/index.d.ts", 8 | "type": "module", 9 | "files": [ 10 | "dist", 11 | "README.md" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/sendbird/chat-ai-widget.git" 16 | }, 17 | "scripts": { 18 | "install:deps": "git submodule update --init --recursive && yarn install", 19 | "dev": "vite", 20 | "prebuild": "rm -rf ./dist && mv .env .env_temp || true", 21 | "postbuild": "mv .env_temp .env || true", 22 | "build": "yarn prebuild && tsc-silent -p './tsconfig.json' --suppress @ && vite build && yarn postbuild", 23 | "build:npm": "node scripts/prebuild.mjs && yarn build", 24 | "build:pages": "rm -rf ./dist && tsc-silent -p './tsconfig.json' --suppress @ && vite build --config vite.config.pages.ts", 25 | "format": "yarn prettier:fix && yarn lint:fix", 26 | "format:check": "yarn prettier src __visual_tests__ --check && yarn eslint src __visual_tests__", 27 | "lint:fix": "yarn eslint src __visual_tests__ --fix", 28 | "prettier:fix": "yarn prettier src __visual_tests__ --write", 29 | "preview": "vite preview", 30 | "test": "vitest run" 31 | }, 32 | "dependencies": { 33 | "styled-components": "^5.3.11" 34 | }, 35 | "devDependencies": { 36 | "@linaria/atomic": "^6.2.0", 37 | "@linaria/core": "^6.2.0", 38 | "@linaria/react": "^6.2.1", 39 | "@playwright/test": "^1.48.1", 40 | "@types/dompurify": "^3.0.5", 41 | "@types/node": "^22.7.9", 42 | "@types/react": "^18.0.37", 43 | "@types/react-dom": "^18.0.11", 44 | "@types/styled-components": "^5.1.26", 45 | "@typescript-eslint/eslint-plugin": "^5.60.1", 46 | "@typescript-eslint/parser": "^5.60.1", 47 | "@vitejs/plugin-react": "^4.3.4", 48 | "@wyw-in-js/babel-preset": "^0.5.3", 49 | "@wyw-in-js/vite": "^0.5.3", 50 | "date-fns": "^3.6.0", 51 | "eslint": "^8.44.0", 52 | "eslint-config-prettier": "^8.8.0", 53 | "eslint-import-resolver-typescript": "^3.6.1", 54 | "eslint-plugin-import": "^2.29.1", 55 | "eslint-plugin-jsx-a11y": "^6.7.1", 56 | "eslint-plugin-react": "^7.32.2", 57 | "eslint-plugin-react-hooks": "^4.6.0", 58 | "eslint-plugin-styled-components-a11y": "^2.1.32", 59 | "jsdom": "^24.1.0", 60 | "markdown-to-jsx": "^7.7.0", 61 | "prettier": "^3.3.3", 62 | "react": "^18.2.0", 63 | "react-dom": "^18.2.0", 64 | "rollup-plugin-terser": "^7.0.2", 65 | "rollup-plugin-visualizer": "^5.12.0", 66 | "ts-pattern": "^5.1.1", 67 | "tsc-silent": "^1.2.2", 68 | "typescript": "5.0.2", 69 | "vite": "^6.0.11", 70 | "vite-plugin-dts": "^4.4.0", 71 | "vite-plugin-svgr": "^4.3.0", 72 | "vitest": "^3.0.5" 73 | }, 74 | "peerDependencies": { 75 | "date-fns": "^3.6.0", 76 | "react": "^16.8.6 || ^17.0.0 || ^18.0.0", 77 | "react-dom": "^16.8.6 || ^17.0.0 || ^18.0.0" 78 | }, 79 | "peerDependenciesMeta": { 80 | "date-fns": { 81 | "optional": true 82 | } 83 | }, 84 | "workspaces": [ 85 | "packages/*" 86 | ], 87 | "packageManager": "yarn@4.2.2", 88 | "resolutions": { 89 | "rollup": "^4.22.4" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/self-service/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['../../.eslintrc.cjs'], 3 | } 4 | -------------------------------------------------------------------------------- /packages/self-service/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env 26 | -------------------------------------------------------------------------------- /packages/self-service/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/packages/self-service/README.md -------------------------------------------------------------------------------- /packages/self-service/embed-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 12 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/self-service/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/self-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "self-service", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "node scripts/build.js", 9 | "preview": "vite preview", 10 | "lint": "npx eslint src", 11 | "lint:fix": "npm run lint -- --fix", 12 | "prettier": "npx prettier src --check", 13 | "prettier:fix": "npm run prettier -- --write", 14 | "format": "npm run prettier:fix && npm run lint:fix" 15 | }, 16 | "dependencies": { 17 | "@sendbird/chat-ai-widget": "1.9.7", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.37", 23 | "@types/react-dom": "^18.0.11", 24 | "@vitejs/plugin-react": "^4.2.1", 25 | "eslint": "^8.38.0", 26 | "eslint-plugin-react-refresh": "^0.3.4", 27 | "typescript": "^5.0.2", 28 | "vite": "^5.2.10", 29 | "vite-plugin-css-injected-by-js": "^3.4.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/self-service/playground/chatbot-404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/packages/self-service/playground/chatbot-404.png -------------------------------------------------------------------------------- /packages/self-service/scripts/build.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { build } from 'vite'; 4 | 5 | import { generateIndexFile } from './generateIndex.js'; 6 | import { getWidgetVersion } from './getWidgetVersion.js'; 7 | 8 | const version = getWidgetVersion(); 9 | 10 | const run = async () => { 11 | try { 12 | console.log('[SELF-SERVICE] Cleaning up dist folder'); 13 | fs.rmdirSync('dist', { recursive: true }); 14 | } catch { 15 | // ignore 16 | } 17 | 18 | console.log('[SELF-SERVICE] Build started for version:', version); 19 | await build({ configFile: 'vite.config.ts', logLevel: 'info' }); 20 | 21 | console.log('[SELF-SERVICE] Generate index files'); 22 | generateIndexFile(version); 23 | }; 24 | 25 | run(); 26 | -------------------------------------------------------------------------------- /packages/self-service/scripts/generateIndex.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | export function generateIndexFile(version) { 4 | if (!version) { 5 | console.error('Error: No version found for @sendbird/chat-ai-widget. Please check the package-lock.json file.'); 6 | process.exit(1); 7 | } 8 | 9 | const content = `import('./${version}/output.js').then(() => console.log("AI chatbot module has been successfully loaded"));`; 10 | 11 | fs.writeFileSync('dist/index.js', content); 12 | fs.cpSync('playground', 'dist/playground', { recursive: true }); 13 | } 14 | -------------------------------------------------------------------------------- /packages/self-service/scripts/getWidgetVersion.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | const packageJson = JSON.parse(fs.readFileSync('../../package.json', 'utf8')); 4 | 5 | /** 6 | * @return {string} v0.0.0 format 7 | * */ 8 | export function getWidgetVersion() { 9 | const version = process.argv[2]; 10 | 11 | if (!version.startsWith('version=') || version === 'version=') { 12 | return `v${packageJson.version}`; 13 | } 14 | 15 | return version.split('version=')[1]; 16 | } 17 | 18 | console.log('widget version:', getWidgetVersion()); 19 | -------------------------------------------------------------------------------- /packages/self-service/src/App.tsx: -------------------------------------------------------------------------------- 1 | import '@sendbird/chat-ai-widget/dist/style.css'; 2 | import './index.css'; 3 | import { 4 | ChatAiWidget, 5 | ChatAiWidgetConfigs, 6 | widgetServiceName, 7 | } from '@sendbird/chat-ai-widget'; 8 | 9 | type AppId = string; 10 | type BotId = string; 11 | type ChatbotWindow = typeof window & { 12 | // Available configs are defined in the ChatAiWidget component 13 | chatbotConfig?: [AppId, BotId, ChatAiWidgetConfigs]; 14 | }; 15 | 16 | const [appId, botId, configs] = (window as ChatbotWindow).chatbotConfig ?? []; 17 | 18 | function App() { 19 | const { serviceName, ...restConfigs } = configs ?? {}; 20 | return ( 21 | 29 | ); 30 | } 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /packages/self-service/src/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * The following CSS rule has been added to address a specific behavior mentioned in the following Slack thread: 3 | * @link https://sendbird.slack.com/archives/C0585965FFA/p1708514883414479?thread_ts=1708492072.804859&cid=C0585965FFA 4 | */ 5 | #aichatbot a:empty, 6 | #aichatbot ul:empty, 7 | #aichatbot dl:empty, 8 | #aichatbot div:empty, 9 | #aichatbot section:empty, 10 | #aichatbot article:empty, 11 | #aichatbot p:empty, 12 | #aichatbot h1:empty, 13 | #aichatbot h2:empty, 14 | #aichatbot h3:empty, 15 | #aichatbot h4:empty, 16 | #aichatbot h5:empty, 17 | #aichatbot h6:empty { 18 | display: block !important; 19 | } 20 | -------------------------------------------------------------------------------- /packages/self-service/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import App from './App.tsx'; 5 | 6 | ReactDOM.createRoot(document.getElementById('aichatbot') as HTMLElement).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /packages/self-service/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/self-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/self-service/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/self-service/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; 4 | 5 | import { getWidgetVersion } from './scripts/getWidgetVersion'; 6 | 7 | const version = getWidgetVersion(); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [react(), cssInjectedByJsPlugin({ styleId: 'sendbird-css-inject-id' })], 12 | build: { 13 | outDir: `./dist/${version}`, 14 | rollupOptions: { 15 | output: { 16 | manualChunks: undefined, 17 | entryFileNames: `output.js`, 18 | chunkFileNames: `[hash].js`, 19 | assetFileNames: `[name].[ext]`, 20 | }, 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, devices} from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: './__visual_tests__', 16 | snapshotPathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}', // Refer to: https://playwright.dev/docs/next/api/class-testproject#test-project-snapshot-path-template 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: 0, // process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: undefined, // process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: [ 27 | ['junit', { outputFile: 'results.xml' }], 28 | ['html'] 29 | ], 30 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 31 | use: { 32 | /* Base URL to use in actions like `await page.goto('/')`. */ 33 | // baseURL: 'http://127.0.0.1:3000', 34 | 35 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 36 | trace: 'on-first-retry', 37 | }, 38 | 39 | /* Configure projects for major browsers */ 40 | projects: [ 41 | { 42 | name: 'chromium', 43 | use: { ...devices['Desktop Chrome'] }, // Note we cannot use ...devices['Desktop Chrome'] because the name varies between devices and in CircleCI environment. CI test will fail because of this. 44 | }, 45 | 46 | { 47 | name: 'firefox', 48 | use: { ...devices['Desktop Firefox'] }, // Note we cannot use ...devices['Desktop Firefox'] because the name varies between devices and in CircleCI environment. CI test will fail because of this. 49 | }, 50 | 51 | // { 52 | // name: 'webkit', 53 | // use: { ...devices['Desktop Safari'] }, 54 | // }, 55 | 56 | /* Test against mobile viewports. */ 57 | // { 58 | // name: 'Mobile Chrome', 59 | // use: { ...devices['Pixel 5'] }, 60 | // }, 61 | // { 62 | // name: 'Mobile Safari', 63 | // use: { ...devices['iPhone 12'] }, 64 | // }, 65 | 66 | /* Test against branded browsers. */ 67 | // { 68 | // name: 'Microsoft Edge', 69 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 70 | // }, 71 | // { 72 | // name: 'Google Chrome', 73 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 74 | // }, 75 | ], 76 | 77 | /* Run your local dev server before starting the tests */ 78 | webServer: { 79 | command: 'yarn dev', 80 | url: 'http://localhost:5173/chat-ai-widget/', 81 | reuseExistingServer: !process.env.CI, 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /release-guide.md: -------------------------------------------------------------------------------- 1 | # Release Steps 2 | 3 | ## Step 0 - Setup (manual handling needed) 4 | 1. Create a new branch for the release, its format should be: `release/v{X.X.X}`. 5 | 2. Update the `version` field in `package.json`. 6 | 3. Write a CHANGELOG for this release. 7 | 4. Commit the changes, push them to remote, and create a Pull Request. 8 | 5. Comment `/bot create ticket` on the GitHub PR to automatically generate a release ticket. 9 | 6. Obtain approval from managers for the ticket before proceeding to the next step. 10 | 11 | ## Step 1 - Publish a new chat-ai-widget package (using automated workflow) 12 | 1. Navigate to Actions -> [Publish Workflow](./.github/workflows/package-publish.yml) in the GitHub repository. 13 | 2. Change the target branch to the release branch created in Step 0. 14 | 3. Enter the target version (e.g., 1.3.1) in the version field, and specify `rc` / `alpha` / `beta` for the `npm_tag` field if necessary. 15 | workflow-guide 16 | 4. Hit "Run workflow" button. 17 | 5. Once all the steps in the workflow are successfully completed: 18 | - The build output will be published to npm. (if `npm_tag` is provided, we stop the workflow from here) 19 | - A commit will be pushed to the release PR created in Step 0. This commit includes: 20 | - `@sendbird/chat-ai-widget` dependency version updated in `/packages/*`. 21 | - A new tag(`v{version}`) will be pushed to the origin ~~to trigger the self-service script deployment.~~ 22 | 23 | ## Step 2 - Publish a new self-service script (using automated workflow) 24 | 1. Merge the PR created in Step 0. 25 | 2. When the release branch is merged into the default branch, the [self-service-publish](./.github/workflows/self-service-publish.yml) workflow will deploy the self-service script. 26 | - Check the progress in [Circle CI dashboard](https://app.circleci.com/pipelines/github/sendbird/chat-ai-widget). 27 | 28 | ### Want to publish `@sendbird/chat-ai-widget` manually? 29 | 1. Update the `version` field in `package.json`. 30 | 2. Run `yarn build:npm` in the directory root. 31 | - Make sure you have `.env.production` which contains `VITE_CHAT_AI_WIDGET_KEY=...`. 32 | - `VITE_CHAT_AI_WIDGET_KEY` can be found in 1Password under the entry `CHAT_AI_WIDGET_KEY`. 33 | 3. Run `npm publish` (with `--tag rc / alpha / beta` depending on your need). 34 | -------------------------------------------------------------------------------- /screenshot/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/screenshot/logo.png -------------------------------------------------------------------------------- /screenshot/workflow-guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendbird/chat-ai-widget/a1e5053460e725068334c9a4f5b08998b452e628/screenshot/workflow-guide.png -------------------------------------------------------------------------------- /scripts/build-check-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eo pipefail 2 | 3 | # Due to differences in the tsconfig settings, there are many errors in the UIKit side. 4 | # Temporarily, filtering the path of the files where errors occurred to display the error messages. 5 | if yarn tsc --noEmit 2>&1 | grep -E "^src/"; then 6 | echo "TypeScript errors found in the src/ directory." 7 | exit 1 8 | else 9 | echo "No TypeScript errors in the src/ directory." 10 | fi 11 | -------------------------------------------------------------------------------- /scripts/prebuild.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | const keys = { 4 | VITE_CHAT_AI_WIDGET_KEY:'VITE_CHAT_AI_WIDGET_KEY', 5 | VITE_CHAT_WIDGET_APP_ID:'VITE_CHAT_WIDGET_APP_ID', 6 | VITE_CHAT_WIDGET_BOT_ID:'VITE_CHAT_WIDGET_BOT_ID', 7 | } 8 | 9 | const env = { 10 | prod: asFullPath( '../.env.production'), 11 | }; 12 | 13 | function asFullPath(path){ 14 | return new URL(path, import.meta.url).pathname; 15 | } 16 | 17 | function buildEnvs(envString) { 18 | return envString 19 | .split('\n') 20 | .filter((it) => it.startsWith('VITE')) 21 | .reduce((obj, it) => { 22 | const [key, value] = it.split('='); 23 | obj[key] = value; 24 | return obj; 25 | }, {}); 26 | } 27 | 28 | function run() { 29 | if (!fs.existsSync(env.prod)) { 30 | throw new Error('.env.production is required to publish npm'); 31 | } 32 | 33 | const prod = fs.readFileSync(env.prod, 'utf8'); 34 | const prodEnv = buildEnvs(prod); 35 | 36 | if (!prodEnv[keys.VITE_CHAT_AI_WIDGET_KEY]) { 37 | throw new Error(`${keys.VITE_CHAT_AI_WIDGET_KEY} is required to publish npm. please check 1password`); 38 | } 39 | 40 | if (prodEnv[keys.VITE_CHAT_WIDGET_APP_ID] || prodEnv[keys.VITE_CHAT_WIDGET_BOT_ID]) { 41 | throw new Error(`Do not include ${keys.VITE_CHAT_WIDGET_APP_ID} and ${keys.VITE_CHAT_WIDGET_BOT_ID} to .env.production`); 42 | } 43 | } 44 | 45 | run(); 46 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import ChatAiWidget, { ChatAiWidgetProps } from './components/widget/ChatAiWidget'; 2 | 3 | const App = (props: ChatAiWidgetProps) => { 4 | return ; 5 | }; 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /src/__tests__/utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import { asSafeURL } from '../../utils'; 2 | 3 | describe('asSafeURL', () => { 4 | test('should return the same URL if it is already safe', () => { 5 | expect(asSafeURL('http://example.com')).toBe('http://example.com'); 6 | expect(asSafeURL('https://example.com')).toBe('https://example.com'); 7 | 8 | expect(asSafeURL('https://example.com/path/path2')).toBe('https://example.com/path/path2'); 9 | expect(asSafeURL('https://example.com?test=%')).toBe('https://example.com?test=%'); 10 | }); 11 | 12 | test('should return a safe URL if it is not safe', () => { 13 | expect(asSafeURL('javascript:alert(1)')).toBe('#'); 14 | expect(asSafeURL('javascript%3Aalert%281%29')).toBe('#'); 15 | expect(asSafeURL('data:text/html;base64,ABCDE==')).toBe('#'); 16 | }); 17 | 18 | test('should append a https:// protocol to the URL if it is missing', () => { 19 | expect(asSafeURL('example.com')).toBe('https://example.com'); 20 | 21 | expect(asSafeURL('example.com/path/path2')).toBe('https://example.com/path/path2'); 22 | expect(asSafeURL('example.com?test=%')).toBe('https://example.com?test=%'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | function hexToRgb(value: string): [number, number, number] { 2 | value = value.replace('#', ''); 3 | const lv = value.length; 4 | const chunkSize = lv / 3; 5 | return [ 6 | parseInt(value.slice(0, chunkSize), 16), 7 | parseInt(value.slice(chunkSize, 2 * chunkSize), 16), 8 | parseInt(value.slice(2 * chunkSize), 16), 9 | ]; 10 | } 11 | 12 | function rgbToHex(rgb: [number, number, number]): string { 13 | return `#${rgb.map((c) => c.toString(16).padStart(2, '0')).join('')}`; 14 | } 15 | 16 | function rgbToHsl(r: number, g: number, b: number): number[] { 17 | // Convert to values between 0 and 1 18 | r /= 255; 19 | g /= 255; 20 | b /= 255; 21 | 22 | // Find maximum and minimum of RGB 23 | const max = Math.max(r, g, b); 24 | const min = Math.min(r, g, b); 25 | const d = max - min; 26 | 27 | // Initialize hue, saturation, lightness 28 | let h = 0; 29 | let s = 0; 30 | const l = (max + min) / 2; 31 | 32 | // Calculate saturation 33 | if (max !== min) { 34 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 35 | } 36 | 37 | // Calculate hue 38 | if (max === r) { 39 | h = (g - b) / d + (g < b ? 6 : 0); 40 | } else if (max === g) { 41 | h = (b - r) / d + 2; 42 | } else if (max === b) { 43 | h = (r - g) / d + 4; 44 | } 45 | 46 | h /= 6; 47 | 48 | return [h, s, l]; 49 | } 50 | 51 | function hslToRgb(h: number, s: number, l: number): [number, number, number] { 52 | let r, g, b; 53 | 54 | if (s === 0) { 55 | r = g = b = l; 56 | } else { 57 | const hue2rgb = function hue2rgb(p: number, q: number, t: number) { 58 | if (t < 0) t += 1; 59 | if (t > 1) t -= 1; 60 | if (t < 1 / 6) return p + (q - p) * 6 * t; 61 | if (t < 1 / 2) return q; 62 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 63 | return p; 64 | }; 65 | 66 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 67 | const p = 2 * l - q; 68 | r = hue2rgb(p, q, h + 1 / 3); 69 | g = hue2rgb(p, q, h); 70 | b = hue2rgb(p, q, h - 1 / 3); 71 | } 72 | 73 | return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 74 | } 75 | 76 | function adjustColor(color: string, lightnessFactor: number, saturationFactor: number): string { 77 | const [r, g, b] = hexToRgb(color); 78 | const [h, s, l] = rgbToHsl(r, g, b); 79 | const newL = Math.max(0, Math.min(1, l * lightnessFactor)); 80 | const newS = Math.max(0, Math.min(1, s * saturationFactor)); 81 | const [newR, newG, newB] = hslToRgb(h, newS, newL); 82 | return rgbToHex([Math.round(newR), Math.round(newG), Math.round(newB)]); 83 | } 84 | 85 | const getColorFactor = (variant: string, theme: string): [number, number] => { 86 | if (variant === '500') return [0.6, 1.2]; 87 | if (variant === '400') return [0.85, 1.1]; 88 | if (variant === '200') return theme === 'dark' ? [1.1, 0.95] : [1.5, 0.9]; 89 | if (variant === '100') return theme === 'dark' ? [1.2, 0.9] : [1.75, 0.8]; 90 | return [1, 1]; 91 | }; 92 | export function generateColorVariants( 93 | baseColor: string, 94 | theme = 'light', 95 | ): { 96 | [key: string]: string; 97 | } { 98 | const variants: { [key: string]: string } = {}; 99 | variants['500'] = adjustColor(baseColor, ...getColorFactor('500', theme)); 100 | variants['400'] = adjustColor(baseColor, ...getColorFactor('400', theme)); 101 | variants['300'] = baseColor; 102 | variants['200'] = adjustColor(baseColor, ...getColorFactor('200', theme)); 103 | variants['100'] = adjustColor(baseColor, ...getColorFactor('100', theme)); 104 | return variants; 105 | } 106 | 107 | export function getColorBasedOnSaturation(hex: string, alpha?: number) { 108 | const threshold = 149; 109 | const r = Number(`0x${hex[1]}${hex[2]}`); 110 | const g = Number(`0x${hex[3]}${hex[4]}`); 111 | const b = Number(`0x${hex[5]}${hex[6]}`); 112 | const calc = r * 0.299 + g * 0.587 + b * 0.114; 113 | if (alpha) { 114 | const val = calc > threshold ? 0 : 255; 115 | return `rgba(${val}, ${val}, ${val}, ${alpha})`; 116 | } 117 | return calc > threshold ? '#000000' : '#ffffff'; 118 | } 119 | 120 | export function generateCSSVariables(accentColor: string, themeType: string): Record { 121 | const colorVariants = generateColorVariants(accentColor, themeType); 122 | 123 | return Object.keys(colorVariants).reduce((acc: Record, key: string) => { 124 | const cssVariable = `--sendbird-${themeType}-primary-${key}`; 125 | acc[cssVariable] = colorVariants[parseInt(key)]; 126 | return acc; 127 | }, {}); 128 | } 129 | -------------------------------------------------------------------------------- /src/components/AdminMessage.tsx: -------------------------------------------------------------------------------- 1 | import { AdminMessage as ChatAdminMessage } from '@sendbird/chat/message'; 2 | import styled from 'styled-components'; 3 | 4 | const Root = styled.div` 5 | display: flex; 6 | justify-content: center; 7 | align-items: end; 8 | margin-bottom: 6px; 9 | flex-wrap: wrap-reverse; 10 | gap: 8px; 11 | `; 12 | 13 | const BodyContainer = styled.div` 14 | max-width: calc(100% - 90px); 15 | font-size: 11px; 16 | width: fit-content; 17 | font-weight: normal; 18 | font-stretch: normal; 19 | font-style: normal; 20 | line-height: 1.43; 21 | letter-spacing: normal; 22 | `; 23 | 24 | const BodyComponent = styled.div` 25 | color: #808080; 26 | max-width: 600px; 27 | display: flex; 28 | flex-direction: column; 29 | align-items: flex-start; 30 | padding: 8px 12px; 31 | gap: 12px; 32 | border-radius: 16px; 33 | white-space: pre-wrap; 34 | `; 35 | 36 | const TextComponent = styled.div` 37 | white-space: pre-line; 38 | `; 39 | 40 | type Props = { 41 | message: ChatAdminMessage; 42 | }; 43 | 44 | export default function AdminMessage(props: Props) { 45 | const { message } = props; 46 | 47 | return ( 48 | 49 | 50 | 51 | {message.message} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/BotMessageBottom.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { useConstantState } from '../context/ConstantContext'; 4 | 5 | const Text = styled.div` 6 | color: ${({ theme }) => theme.textColor.incomingMessage}; 7 | font-weight: 700; 8 | font-size: 14px; 9 | line-height: 20px; 10 | `; 11 | 12 | const BottomComponent = styled.div` 13 | width: 100%; 14 | position: relative; 15 | `; 16 | 17 | const TextContainer = styled.div` 18 | //border-radius: 0 0 16px 16px; 19 | display: flex; 20 | justify-content: flex-start; 21 | gap: 7.5px; 22 | align-items: center; 23 | width: 100%; 24 | padding: 12px 0 4px; 25 | `; 26 | 27 | const Delimiter = styled.div` 28 | position: absolute; 29 | width: calc(100% + 24px); 30 | transform: translateX(-12px); 31 | border-top: 1px solid var(--sendbird-light-onlight-04); 32 | `; 33 | 34 | export default function BotMessageBottom() { 35 | const { messageBottomContent } = useConstantState(); 36 | // const [showInfoBox, setShowInfoBox] = useState(true); 37 | 38 | return ( 39 | <> 40 | 41 | 42 | 43 | {messageBottomContent.text} 44 | {/* setShowInfoBox(true)} 46 | onMouseLeave={() => setShowInfoBox(false)} 47 | > 48 | */} 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/BotMessageWithBodyInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import BotProfileImage from './BotProfileImage'; 5 | import { useChatContext } from './chat/context/ChatProvider'; 6 | import { DefaultSentTime, FullBodyContainer, WideSentTime } from './MessageComponent'; 7 | import { useConstantState } from '../context/ConstantContext'; 8 | import { Label } from '../foundation/components/Label'; 9 | import { formatCreatedAtToAMPM } from '../utils/messageTimestamp'; 10 | 11 | const Root = styled.span` 12 | display: flex; 13 | flex-direction: row; 14 | align-items: flex-end; 15 | gap: 8px; 16 | position: relative; 17 | `; 18 | 19 | const Sender = styled.div` 20 | padding: 0 0 4px 12px; 21 | text-align: start; 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | white-space: nowrap; 25 | width: 246px; 26 | `; 27 | 28 | const Content = styled.div` 29 | flex: 1; 30 | display: flex; 31 | align-items: end; 32 | gap: 4px; 33 | `; 34 | 35 | const EmptyImageContainer = styled.div` 36 | width: 28px; 37 | `; 38 | 39 | type Props = { 40 | createdAt?: number; 41 | messageData?: string; 42 | bodyComponent: ReactNode; 43 | chainTop?: boolean; 44 | chainBottom?: boolean; 45 | messageFeedback?: ReactNode; 46 | wideContainer?: boolean; 47 | }; 48 | 49 | // TODO: When changing the layout, it should be modified to apply flexibly. 50 | const HEIGHTS = { 51 | FEEDBACK: 40, 52 | TIMESTAMP: 18, 53 | }; 54 | 55 | export default function BotMessageWithBodyInput(props: Props) { 56 | const { botUser } = useChatContext(); 57 | const { botStudioEditProps, dateLocale, stringSet } = useConstantState(); 58 | 59 | const { createdAt, bodyComponent, chainTop, chainBottom, messageFeedback, wideContainer = false } = props; 60 | 61 | const profilePaddingBottom = (messageFeedback ? HEIGHTS.FEEDBACK : 0) + (wideContainer ? HEIGHTS.TIMESTAMP : 0); 62 | 63 | const nonChainedMessage = chainTop == null && chainBottom == null; 64 | const displaySender = nonChainedMessage || chainTop; 65 | const displayProfileImage = nonChainedMessage || chainBottom; 66 | const { nickname } = botStudioEditProps?.botInfo ?? {}; 67 | const botNickname = nickname ?? botUser?.nickname; 68 | 69 | return ( 70 | 71 | {displayProfileImage ? ( 72 |
73 | 74 |
75 | ) : ( 76 | 77 | )} 78 | 79 | {displaySender && ( 80 | 81 | 84 | 85 | )} 86 | 87 | {bodyComponent} 88 | {!wideContainer && !!createdAt && ( 89 | 90 | {formatCreatedAtToAMPM(createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} 91 | 92 | )} 93 | 94 | {wideContainer && !!createdAt && ( 95 | 96 | {formatCreatedAtToAMPM(createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} 97 | 98 | )} 99 | {displayProfileImage && messageFeedback} 100 | 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/components/BotProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@linaria/react'; 2 | import { useTheme } from 'styled-components'; 3 | 4 | import { useChatContext } from './chat/context/ChatProvider'; 5 | import { getColorBasedOnSaturation } from '../colors'; 6 | import { useConstantState } from '../context/ConstantContext'; 7 | import { themedColors } from '../foundation/colors/css'; 8 | import BotFilledIcon from '../icons/ic-bot-filled.svg'; 9 | 10 | function isMaybeFavicon(url: string) { 11 | if (url.length < 4) return false; 12 | const fileName = url.substring(url.lastIndexOf('/') + 1); 13 | return fileName.startsWith('fav_'); 14 | } 15 | 16 | const FaviconContainer = styled.div<{ size: number }>` 17 | width: ${({ size }) => `${size}px`}; 18 | height: ${({ size }) => `${size}px`}; 19 | border-radius: 50%; 20 | box-sizing: border-box; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | border: 1px solid ${themedColors.bg2}; 25 | `; 26 | 27 | const FaviconImage = styled.img<{ size: number }>` 28 | width: ${({ size }) => `${size}px`}; 29 | height: ${({ size }) => `${size}px`}; 30 | box-sizing: border-box; 31 | object-fit: contain; 32 | padding: 19%; 33 | `; 34 | 35 | const IconContainer = styled.span<{ backgroundColor: string; size: number }>` 36 | width: ${({ size }) => `${size}px`}; 37 | height: ${({ size }) => `${size}px`}; 38 | background: ${({ backgroundColor }) => backgroundColor}; 39 | box-sizing: border-box; 40 | padding: 6px; 41 | border-radius: 50%; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | }`; 46 | 47 | const Icon = styled(BotFilledIcon)<{ fill: string }>` 48 | path { 49 | fill: ${({ fill }) => fill}; 50 | } 51 | `; 52 | 53 | function BotProfileImage({ size }: { size: number }) { 54 | const theme = useTheme(); 55 | 56 | const { botStudioEditProps } = useConstantState(); 57 | const { botUser } = useChatContext(); 58 | const { botInfo } = botStudioEditProps ?? {}; 59 | 60 | const profileUrl = botInfo?.profileUrl ?? botUser?.profileUrl; 61 | 62 | if (profileUrl) { 63 | if (isMaybeFavicon(profileUrl)) { 64 | return ( 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | return {'bot; 72 | } 73 | 74 | return ( 75 | 76 | 77 | 78 | ); 79 | } 80 | 81 | export default BotProfileImage; 82 | -------------------------------------------------------------------------------- /src/components/CurrentUserMessage.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@linaria/react'; 2 | import { UserMessage } from '@sendbird/chat/message'; 3 | 4 | import { BodyContainer, BodyComponent } from './MessageComponent'; 5 | import MyMessageStatus from './MyMessageStatus'; 6 | import { useConstantState } from '../context/ConstantContext'; 7 | 8 | const Root = styled.div<{ enableEmojiFeedback: boolean }>` 9 | display: flex; 10 | justify-content: flex-end; 11 | align-items: end; 12 | gap: 4px; 13 | `; 14 | 15 | type Props = { 16 | message: UserMessage; 17 | }; 18 | 19 | export default function CurrentUserMessage(props: Props) { 20 | const { enableEmojiFeedback, dateLocale } = useConstantState(); 21 | const { message } = props; 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 |
{message.message}
29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/CustomMessageBody.tsx: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | import styled from 'styled-components'; 3 | 4 | const Root = styled.div` 5 | display: flex; 6 | align-items: flex-start; 7 | `; 8 | 9 | const Text = styled.span` 10 | width: 100%; 11 | text-align: start; 12 | white-space: pre-line; 13 | word-break: break-word; 14 | line-height: 1.43; 15 | 16 | padding: 8px 12px; 17 | gap: 8px; 18 | border-radius: 16px; 19 | background-color: ${({ theme }) => theme.bgColor.incomingMessage}; 20 | &:hover { 21 | background-color: ${({ theme }) => theme.bgColor.hover.incomingMessage}; 22 | } 23 | `; 24 | 25 | interface Props { 26 | message: string; 27 | } 28 | 29 | export default function CustomMessageBody(props: Props) { 30 | const { message } = props; 31 | const sanitizedMessage = DOMPurify.sanitize(message); 32 | 33 | return ( 34 | 35 | {sanitizedMessage} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/CustomTypingIndicatorBubble.tsx: -------------------------------------------------------------------------------- 1 | import BotProfileImage from './BotProfileImage'; 2 | import { TypingBubble } from '../foundation/components/TypingBubble'; 3 | 4 | function CustomTypingIndicatorBubble() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | 13 | export default CustomTypingIndicatorBubble; 14 | -------------------------------------------------------------------------------- /src/components/ErrorContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { Label } from '../foundation/components/Label'; 4 | import ErrorIcon from '../icons/ic-error.svg'; 5 | 6 | const Container = styled.div` 7 | width: 100%; 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | z-index: 100; 14 | background-color: ${({ theme }) => theme.bgColor.loadingScreen}; 15 | `; 16 | 17 | const Error = styled(ErrorIcon)` 18 | path { 19 | fill: ${({ theme }) => theme.textColor.errorMessage}; 20 | } 21 | `; 22 | 23 | const ErrorMessage = styled(Label)` 24 | margin-top: 16px; 25 | text-align: center; 26 | color: ${({ theme }) => theme.textColor.errorMessage}; 27 | `; 28 | 29 | export default function ErrorContainer({ errorMessage }: { errorMessage: string }) { 30 | return ( 31 | 32 | 33 | 34 | {errorMessage || 'Something went wrong'} 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/FileMessage.tsx: -------------------------------------------------------------------------------- 1 | import '../css/index.css'; 2 | import { FileMessage as ChatFileMessage } from '@sendbird/chat/message'; 3 | import { useState } from 'react'; 4 | 5 | import { isImageMessage, isVideoMessage } from '@uikit/utils'; 6 | 7 | import { useChatContext } from './chat/context/ChatProvider'; 8 | import { FileViewer } from './ui/FileViewer'; 9 | 10 | type Props = { 11 | message: ChatFileMessage; 12 | }; 13 | 14 | export default function FileMessage(props: Props) { 15 | const { message } = props; 16 | const { scrollSource } = useChatContext(); 17 | const [showFileViewer, setShowFileViewer] = useState(false); 18 | 19 | // const root = document.getElementById('aichatbot-widget-window'); 20 | 21 | /** 22 | * Currently only video and image file messages will be sent. 23 | * TODO: In the future, we may support other file types. When we do, we need to update the logic. 24 | */ 25 | return ( 26 |
27 | {isVideoMessage(message) && ( 28 | 32 | )} 33 | {isImageMessage(message) && ( 34 | // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions 35 | {''} scrollSource.scrollPubSub.publish('scrollToBottom', {})} 40 | onClick={() => setShowFileViewer(true)} 41 | /> 42 | )} 43 | {showFileViewer && setShowFileViewer(false)} />} 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | import SpinnerIcon from '../icons/ic-spinner.svg'; 4 | 5 | const spinner = keyframes` 6 | 0% { 7 | transform: rotate(0deg); 8 | } 9 | 100% { 10 | transform: rotate(360deg); 11 | } 12 | `; 13 | 14 | const Container = styled.div` 15 | width: 100%; 16 | height: 100%; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | z-index: 100; 21 | background-color: ${({ theme }) => theme.bgColor.loadingScreen}; 22 | `; 23 | 24 | const IconContainer = styled.div` 25 | display: grid; 26 | justify-content: center; 27 | align-items: center; 28 | height: 70px; 29 | width: 70px; 30 | animation: ${spinner} 1.5s linear infinite; 31 | svg { 32 | path { 33 | fill: ${({ theme }) => theme.accentColor}; 34 | } 35 | } 36 | `; 37 | 38 | export default function LoadingScreen() { 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/MessageComponent.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SentTime = styled.span` 4 | width: fit-content; 5 | color: ${({ theme }) => theme.textColor.sentTime}; 6 | font-size: 12px; 7 | line-height: 1; 8 | white-space: nowrap; 9 | `; 10 | 11 | export const DefaultSentTime = styled(SentTime)` 12 | margin-bottom: 2px; 13 | `; 14 | 15 | export const WideSentTime = styled(SentTime)` 16 | margin-top: 4px; 17 | display: block; 18 | height: 14px; 19 | `; 20 | 21 | export const BodyContainer = styled.div` 22 | font-size: 14px; 23 | color: ${({ theme }) => theme.textColor.incomingMessage}; 24 | max-width: calc(100% - 36px); 25 | font-weight: normal; 26 | font-stretch: normal; 27 | font-style: normal; 28 | line-height: 1.43; 29 | letter-spacing: normal; 30 | `; 31 | 32 | export const FullBodyContainer = styled(BodyContainer)` 33 | flex: 1; 34 | `; 35 | 36 | export const BodyComponent = styled.div` 37 | background-color: ${({ theme }) => theme.bgColor.outgoingMessage}; 38 | color: ${({ theme }) => theme.textColor.outgoingMessage}; 39 | max-width: 600px; 40 | display: flex; 41 | flex-direction: column; 42 | align-items: flex-start; 43 | padding: 8px 12px; 44 | gap: 12px; 45 | border-radius: 16px; 46 | white-space: pre-wrap; 47 | word-break: break-word; 48 | `; 49 | -------------------------------------------------------------------------------- /src/components/MyMessageStatus.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | import { SendableMessage } from '@sendbird/chat/lib/__definition'; 3 | import { SendingStatus } from '@sendbird/chat/message'; 4 | import { Locale } from 'date-fns'; 5 | import { useTheme } from 'styled-components'; 6 | 7 | import { DefaultSentTime } from './MessageComponent'; 8 | import { useConstantState } from '../context/ConstantContext'; 9 | import { Icon } from '../foundation/components/Icon'; 10 | import { Loader } from '../foundation/components/Loader'; 11 | import { formatCreatedAtToAMPM } from '../utils/messageTimestamp'; 12 | 13 | interface MyMessageStatusProps { 14 | message: SendableMessage; 15 | dateLocale: Locale; 16 | } 17 | 18 | export default function MyMessageStatus(props: MyMessageStatusProps) { 19 | const { message, dateLocale } = props; 20 | const { stringSet } = useConstantState(); 21 | const theme = useTheme(); 22 | 23 | switch (message.sendingStatus) { 24 | case SendingStatus.PENDING: 25 | return ( 26 | 27 | 28 | 29 | ); 30 | case SendingStatus.FAILED: 31 | return ( 32 |
33 | 34 |
35 | ); 36 | default: 37 | return ( 38 | 39 | {formatCreatedAtToAMPM(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} 40 | 41 | ); 42 | } 43 | } 44 | 45 | const sendbirdLoader = css` 46 | margin-bottom: 2px; 47 | width: 16px; 48 | height: 16px; 49 | `; 50 | -------------------------------------------------------------------------------- /src/components/ParsedBotMessageBody.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | import { lazy, Suspense } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { Source } from './SourceContainer'; 6 | import { Token } from '../utils'; 7 | 8 | const TokensBody = lazy(() => import('./TokensBody')); 9 | 10 | type Props = { 11 | text: string; 12 | tokens: Token[]; 13 | sources?: Source[]; 14 | }; 15 | 16 | const textContainerStyle = css` 17 | word-break: break-word; 18 | white-space: pre-wrap; 19 | padding: 0 12px; // apply side padding of the bubble 20 | `; 21 | 22 | const Container = styled.div` 23 | padding: 8px 0; // Bubble top and bottom padding. Side padding is applied for token containers. 24 | border-radius: 16px; 25 | overflow: auto; 26 | background-color: ${({ theme }) => theme.bgColor.incomingMessage}; 27 | `; 28 | 29 | /** 30 | * Parses bot message text to process code snippets within the text. 31 | * @param props 32 | * @constructor 33 | */ 34 | export default function ParsedBotMessageBody(props: Props) { 35 | const { text, tokens, sources } = props; 36 | 37 | return ( 38 | 39 | {text}}> 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/SourceContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import OpenIcon from '../icons/ic-open.svg'; 4 | 5 | const Root = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | align-items: flex-start; 9 | padding: 8px 0; 10 | gap: 4px; 11 | width: 100%; 12 | `; 13 | 14 | const RootTitle = styled.div` 15 | font-weight: 700; 16 | font-size: 14px; 17 | line-height: 20px; 18 | color: ${({ theme }) => theme.textColor.incomingMessage}; 19 | padding-bottom: 4px; 20 | `; 21 | 22 | const SourceTitle = styled.a` 23 | font-weight: 400; 24 | font-size: 14px; 25 | line-height: 20px; 26 | letter-spacing: -0.1px; 27 | color: ${({ theme }) => theme.textColor.incomingMessage}; 28 | width: fit-content; 29 | block-size: fit-content; 30 | `; 31 | 32 | const SourceItem = styled.div` 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: center; 36 | width: 100%; 37 | gap: 16px; 38 | `; 39 | 40 | const IconContainer = styled.a` 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | width: 15px; 45 | padding: 0 1px; 46 | 47 | svg { 48 | path { 49 | fill: ${({ theme }) => theme.textColor.incomingMessage}; 50 | } 51 | } 52 | `; 53 | 54 | export interface Source { 55 | source: string; 56 | title: string; 57 | description: string; 58 | language: string; 59 | source_type?: string; 60 | } 61 | 62 | type Props = { 63 | sources: Source[]; 64 | }; 65 | 66 | export default function SourceContainer(props: Props) { 67 | const { sources } = props; 68 | const source: Source = sources[0]; 69 | 70 | return ( 71 | 72 | Source 73 | 74 |
75 | 76 | {source.title} 77 | 78 |
79 | 80 | 81 | 82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/SuggestedRepliesContainer.tsx: -------------------------------------------------------------------------------- 1 | import type { UserMessageCreateParams } from '@sendbird/chat/message'; 2 | 3 | import SuggestedReplies from '@uikit/modules/GroupChannel/components/SuggestedReplies'; 4 | 5 | interface Props { 6 | replies?: string[]; 7 | type?: 'horizontal' | 'vertical'; 8 | sendUserMessage?: (params: UserMessageCreateParams) => void; 9 | } 10 | 11 | const SuggestedRepliesContainer = ({ replies = [], type = 'vertical', sendUserMessage }: Props) => { 12 | if (replies.length <= 0) return null; 13 | return ( 14 |
15 | sendUserMessage?.({ message })} 18 | type={type} 19 | /> 20 |
21 | ); 22 | }; 23 | 24 | export default SuggestedRepliesContainer; 25 | -------------------------------------------------------------------------------- /src/components/TokensBody.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | import DOMPurify from 'dompurify'; 3 | import Markdown from 'markdown-to-jsx'; 4 | import styled from 'styled-components'; 5 | 6 | import BotMessageBottom from './BotMessageBottom'; 7 | import SourceContainer, { Source } from './SourceContainer'; 8 | import { CodeBlock } from './ui/CodeBlock'; 9 | import { useConstantState } from '../context/ConstantContext'; 10 | import { Token, TokenType } from '../utils'; 11 | 12 | import './markdown.scss'; 13 | 14 | type TokensBodyProps = { 15 | tokens: Token[]; 16 | sources?: Source[]; 17 | className?: string; 18 | }; 19 | 20 | const BlockContainer = styled.div` 21 | width: 100%; 22 | /* 23 | Note this was added because following element doest not have top margin due to it being the first element 24 | of its markdown div. 25 | */ 26 | margin: 0.5em 0; 27 | `; 28 | 29 | export default function TokensBody({ tokens, sources, className }: TokensBodyProps) { 30 | const { enableSourceMessage } = useConstantState(); 31 | 32 | return ( 33 | <> 34 | {tokens.map((token: Token, i) => { 35 | // Normal text part of the message. 36 | if (token.type === TokenType.string) { 37 | return ( 38 |
39 | ( 45 | 46 | {children} 47 | 48 | ), 49 | }, 50 | // Note that this is to remove text-align: right by the library. 51 | th: { 52 | component: ({ children, ...props }) => ( 53 | 54 | {children} 55 | 56 | ), 57 | }, 58 | a: { 59 | component: ({ children, ...props }) => ( 60 | 61 | {children} 62 | 63 | ), 64 | }, 65 | }, 66 | }} 67 | > 68 | {DOMPurify.sanitize(token.value)} 69 | 70 |
71 | ); 72 | } 73 | // Code part of the message. 74 | return ( 75 | 76 | 77 | 78 | ); 79 | })} 80 | {sources && sources.length > 0 && enableSourceMessage ? ( 81 |
86 | 87 | 88 |
89 | ) : null} 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/components/UserMessageWithBodyInput.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '@sendbird/chat'; 2 | import { UserMessage } from '@sendbird/chat/message'; 3 | import { Locale } from 'date-fns'; 4 | import { ReactNode } from 'react'; 5 | import styled from 'styled-components'; 6 | 7 | import Avatar from '@uikit/ui/Avatar'; 8 | 9 | import { SentTime } from './MessageComponent'; 10 | import { useConstantState } from '../context/ConstantContext'; 11 | import { Label } from '../foundation/components/Label'; 12 | import { formatCreatedAtToAMPM } from '../utils/messageTimestamp'; 13 | 14 | const Root = styled.div` 15 | display: flex; 16 | align-items: flex-end; 17 | flex-wrap: wrap; 18 | gap: 8px; 19 | position: relative; 20 | `; 21 | 22 | const Sender = styled(Label)` 23 | margin: 0 0 4px 12px; 24 | text-align: start; 25 | `; 26 | 27 | interface BodyContainerProps { 28 | maxWidth?: string; 29 | } 30 | 31 | const BodyContainer = styled.div` 32 | font-size: 14px; 33 | color: ${({ theme }) => theme.textColor.incomingMessage}; 34 | max-width: calc(100% - 96px); 35 | font-weight: normal; 36 | font-stretch: normal; 37 | font-style: normal; 38 | line-height: 1.43; 39 | letter-spacing: normal; 40 | `; 41 | 42 | const Content = styled.div` 43 | display: flex; 44 | align-items: end; 45 | gap: 4px; 46 | `; 47 | 48 | type Props = { 49 | user: User; 50 | message: UserMessage; 51 | bodyComponent: ReactNode; 52 | chainTop?: boolean; 53 | chainBottom?: boolean; 54 | isFormMessage?: boolean; 55 | locale?: Locale; 56 | }; 57 | 58 | const EmptyImageContainer = styled.div` 59 | width: 28px; 60 | `; 61 | 62 | export default function UserMessageWithBodyInput(props: Props) { 63 | const { user, message, bodyComponent, chainTop, chainBottom, locale } = props; 64 | const { stringSet } = useConstantState(); 65 | 66 | const nonChainedMessage = chainTop == null && chainBottom == null; 67 | const displayProfileImage = nonChainedMessage || chainBottom; 68 | const displaySender = nonChainedMessage || chainTop; 69 | 70 | return ( 71 | 72 | {displayProfileImage ? ( 73 |
74 | 75 |
76 | ) : ( 77 | 78 | )} 79 | 80 | {displaySender && ( 81 | 82 | {user.nickname} 83 | 84 | )} 85 | 86 | {bodyComponent} 87 | {!!message?.createdAt && ( 88 | 89 | {formatCreatedAtToAMPM(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, locale)} 90 | 91 | )} 92 | 93 | 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/chat/context/ChatProvider.tsx: -------------------------------------------------------------------------------- 1 | import { SendbirdChatWith, SendbirdError, SendbirdErrorCode, User } from '@sendbird/chat'; 2 | import { GroupChannel, GroupChannelModule } from '@sendbird/chat/groupChannel'; 3 | import { useGroupChannelMessages } from '@sendbird/uikit-tools'; 4 | import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'; 5 | 6 | import { useMessageListScroll } from '@uikit/modules/GroupChannel/context/hooks/useMessageListScroll'; 7 | 8 | import { useConstantState } from '../../../context/ConstantContext'; 9 | import { useWidgetSetting } from '../../../context/WidgetSettingContext'; 10 | import { Placeholder } from '../../../foundation/components/Placeholder'; 11 | import { clearWidgetSessionCache } from '../../../libs/storage/widgetSessionCache'; 12 | import { useWidgetChatHandlers, WidgetChatHandlers } from '../hooks/useWidgetChatHandlers'; 13 | 14 | export interface WidgetStringSet { 15 | ERR_CHANNEL_FETCH: string; 16 | } 17 | 18 | export interface ChatContextType { 19 | sdk: SendbirdChatWith<[GroupChannelModule]> | null; 20 | channel: GroupChannel | null; 21 | botUser?: User; 22 | dataSource: ReturnType; 23 | scrollSource: ReturnType; 24 | 25 | stringSet: WidgetStringSet; 26 | handlers: WidgetChatHandlers; 27 | } 28 | 29 | const ChatContext = createContext(null); 30 | 31 | export interface ChatContainerProps { 32 | sdk: SendbirdChatWith<[GroupChannelModule]> | null; 33 | channelUrl: string; 34 | stringSet: WidgetStringSet; 35 | } 36 | 37 | export const ChatContainer = (props: PropsWithChildren) => { 38 | const { sdk, channelUrl, stringSet, children } = props; 39 | const { applicationId: appId, botId } = useConstantState(); 40 | const { resetSession } = useWidgetSetting(); 41 | 42 | const [channel, setChannel] = useState(null); 43 | const [errorMessage, setErrorMessage] = useState(null); 44 | 45 | const scrollSource = useMessageListScroll('smooth'); 46 | const onScrollToBottom = () => setTimeout(() => scrollSource.scrollPubSub.publish('scrollToBottom', {}), 25); 47 | const handlers = useWidgetChatHandlers({ onScrollToBottom }); 48 | 49 | // NOTE: sdk and channel are nullable, but useGroupChannelMessages can handle it even if types are not. 50 | const dataSource = useGroupChannelMessages(sdk as SendbirdChatWith<[GroupChannelModule]>, channel as GroupChannel, { 51 | shouldCountNewMessages: () => false, 52 | onChannelDeleted: () => clearWidgetSessionCache({ appId, botId }), 53 | onMessagesReceived: onScrollToBottom, 54 | onMessagesUpdated: onScrollToBottom, 55 | }); 56 | 57 | useEffect(() => { 58 | if (!sdk?.groupChannel) return; 59 | 60 | setChannel(null); 61 | setErrorMessage(null); 62 | 63 | sdk.groupChannel 64 | .getChannel(channelUrl) 65 | .then(setChannel) 66 | .catch((error: SendbirdError) => { 67 | if (error.code === SendbirdErrorCode.NOT_FOUND_IN_DATABASE || error.code === SendbirdErrorCode.NON_AUTHORIZED) { 68 | resetSession(); 69 | } else { 70 | setErrorMessage(stringSet.ERR_CHANNEL_FETCH); 71 | } 72 | }); 73 | }, [sdk, channelUrl]); 74 | 75 | if (errorMessage) return ; 76 | 77 | return ( 78 | it.userId === botId)} 81 | dataSource={dataSource} 82 | scrollSource={scrollSource} 83 | handlers={handlers} 84 | {...props} 85 | > 86 | {children} 87 | 88 | ); 89 | }; 90 | 91 | interface ChatProviderProps extends ChatContainerProps { 92 | channel: GroupChannel | null; 93 | botUser?: User; 94 | dataSource: ReturnType; 95 | scrollSource: ReturnType; 96 | handlers: WidgetChatHandlers; 97 | } 98 | 99 | export const ChatProvider = (props: PropsWithChildren) => { 100 | return {props.children}; 101 | }; 102 | 103 | export const useChatContext = () => { 104 | const context = useContext(ChatContext); 105 | if (!context) throw new Error('useChatContext must be used within ChatProvider'); 106 | return context; 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/chat/hooks/useBotStudioView.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | import { isSameDay } from 'date-fns'; 3 | 4 | import { useConstantState } from '../../../context/ConstantContext'; 5 | import { DateSeparator } from '../../../foundation/components/DateSeparator'; 6 | import { parseTextMessage, Token } from '../../../utils'; 7 | import { messageExtension } from '../../../utils/messageExtension'; 8 | import { getBotWelcomeMessages, shouldFilterOutMessage } from '../../../utils/messages'; 9 | import BotMessageWithBodyInput from '../../BotMessageWithBodyInput'; 10 | import ParsedBotMessageBody from '../../ParsedBotMessageBody'; 11 | import SuggestedRepliesContainer from '../../SuggestedRepliesContainer'; 12 | import { useChatContext } from '../context/ChatProvider'; 13 | 14 | export const useBotStudioView = () => { 15 | const { botStudioEditProps = {}, botId, replacementTextList, stringSet, dateLocale } = useConstantState(); 16 | const { dataSource, handlers } = useChatContext(); 17 | const { suggestedRepliesDirection, welcomeMessages = [] } = botStudioEditProps; 18 | const { messages } = dataSource; 19 | 20 | const originalWMs = getBotWelcomeMessages(messages, botId); 21 | 22 | const firstUserMsg = messages[originalWMs.length + 1]; 23 | const firstOriginalWM = originalWMs[0] ?? firstUserMsg; 24 | 25 | return { 26 | /** 27 | * Returns a list of messages filtered according to business requirements. 28 | */ 29 | filteredMessages: messages.filter((it) => { 30 | // Removes messages based on hardcoded rules. 31 | if (shouldFilterOutMessage(it)) return false; 32 | // If live edit is required, removes welcome messages. 33 | if (welcomeMessages.length > 0) return !messageExtension.isBotWelcomeMsg(it, botId); 34 | return true; 35 | }), 36 | /** 37 | * Determines whether to display the DateSeparator in the data list by comparing it with the welcome messages from Bot Studio. 38 | */ 39 | shouldShowOriginalDate: (index: number) => { 40 | if (index > 0) return true; 41 | if (welcomeMessages.length === 0) return true; 42 | return firstUserMsg && !isSameDay(firstUserMsg.createdAt, firstOriginalWM?.createdAt); 43 | }, 44 | /** 45 | * Renders the list of welcome messages from Bot Studio. 46 | */ 47 | renderBotStudioWelcomeMessages: () => { 48 | if (welcomeMessages.length === 0) return null; 49 | 50 | return ( 51 | <> 52 | 58 | {welcomeMessages.map((msg, index) => { 59 | if ('message' in msg) { 60 | const text = msg.message; 61 | const tokens: Token[] = parseTextMessage(text, replacementTextList); 62 | 63 | const noUserInputs = originalWMs.length === messages.length; 64 | const isLastMessage = index === welcomeMessages.length - 1; 65 | 66 | return ( 67 |
68 | } 72 | createdAt={firstOriginalWM?.createdAt} 73 | /> 74 | 75 | {noUserInputs && isLastMessage && ( 76 | { 80 | dataSource 81 | .sendUserMessage(params, handlers.onAfterSendMessage) 82 | .then(handlers.onAfterSendMessage); 83 | }} 84 | /> 85 | )} 86 |
87 | ); 88 | } else { 89 | // TODO: support file message in the future. 90 | return <>; 91 | } 92 | })} 93 | 94 | ); 95 | }, 96 | }; 97 | }; 98 | 99 | const dateSeparatorMargin = css` 100 | margin: 8px 0; 101 | padding: 0 16px; 102 | `; 103 | -------------------------------------------------------------------------------- /src/components/chat/hooks/useTypingTargetMessageId.ts: -------------------------------------------------------------------------------- 1 | import { SendingStatus } from '@sendbird/chat/message'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { useWidgetSession } from '../../../context/WidgetSettingContext'; 5 | import { isSentBy } from '../../../utils/messages'; 6 | import { useChatContext } from '../context/ChatProvider'; 7 | 8 | /** 9 | * If the updated last message was sent by the current user, indicate a typing bubble for the sent message. 10 | * If the updated last message is pending or failed and was sent by the current user, or if it was sent by the bot, deactivate the typing bubble. 11 | */ 12 | export const useTypingTargetMessageId = () => { 13 | const { userId } = useWidgetSession(); 14 | const { channel, dataSource, scrollSource } = useChatContext(); 15 | const [messageId, setMessageId] = useState(-1); 16 | const lastMessage = dataSource.messages[dataSource.messages.length - 1]; 17 | 18 | useEffect(() => { 19 | if (lastMessage) { 20 | const shouldActivateSpinner = 21 | isSentBy(lastMessage, userId) && 22 | (lastMessage.isUserMessage() || lastMessage.isFileMessage()) && 23 | lastMessage.sendingStatus === SendingStatus.SUCCEEDED && 24 | channel?.memberCount === 2; 25 | 26 | setMessageId(shouldActivateSpinner ? lastMessage.messageId : -1); 27 | setTimeout(() => scrollSource.scrollPubSub.publish('scrollToBottom', {}), 150); 28 | } 29 | }, [lastMessage?.messageId]); 30 | 31 | // useGroupChannelHandler(sdk, { 32 | // onTypingStatusUpdated: (it) => { 33 | // if (it.url === channel?.url) { 34 | // const shouldActivateSpinner = it.getTypingUsers().find((it) => it.userId === botId); 35 | // setMessageId(shouldActivateSpinner ? lastMessage.messageId : -1); 36 | // setTimeout(() => scrollSource.scrollPubSub.publish('scrollToBottom', {}), 150); 37 | // } 38 | // }, 39 | // }); 40 | 41 | return messageId; 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/chat/hooks/useWidgetChatHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { FileMessageCreateParams, UserMessageCreateParams } from '@sendbird/chat/message'; 2 | import { useRef } from 'react'; 3 | 4 | import { useConstantState } from '../../../context/ConstantContext'; 5 | import { getImageAspectRatioMetaArray } from '../../../utils/getImageAspectRatio'; 6 | 7 | export interface WidgetChatHandlers { 8 | onBeforeSendMessage: (params: T) => Promise; 9 | onAfterSendMessage: () => void; 10 | } 11 | 12 | export const useWidgetChatHandlers = (params: { onScrollToBottom: () => void }) => { 13 | const { botStudioEditProps } = useConstantState(); 14 | const aiAttributesRef = useRef(botStudioEditProps?.aiAttributes); 15 | aiAttributesRef.current = botStudioEditProps?.aiAttributes; 16 | 17 | return { 18 | onBeforeSendMessage: async (params: T) => { 19 | const metaArray = await getImageAspectRatioMetaArray(params); 20 | if (aiAttributesRef.current) { 21 | return { 22 | ...params, 23 | metaArrays: metaArray ? [metaArray] : undefined, 24 | data: JSON.stringify({ ai_attrs: aiAttributesRef.current }), 25 | }; 26 | } else { 27 | return { 28 | ...params, 29 | metaArrays: metaArray ? [metaArray] : undefined, 30 | }; 31 | } 32 | }, 33 | onAfterSendMessage: params.onScrollToBottom, 34 | } satisfies WidgetChatHandlers; 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/chat/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import useSendbirdStateContext from '@uikit/hooks/useSendbirdStateContext'; 4 | 5 | import { ChatContainer } from './context/ChatProvider'; 6 | import { ChatUI } from './ui'; 7 | import { useConstantState } from '../../context/ConstantContext'; 8 | import { useWidgetSession, useWidgetSetting } from '../../context/WidgetSettingContext'; 9 | import { useAssignGlobalFunction } from '../../hooks/useAssignGlobalFunction'; 10 | import useAutoDismissMobileKeyboardHandler from '../../hooks/useAutoDismissMobileKeyboardHandler'; 11 | import { useResetHistoryOnConnected } from '../../hooks/useResetHistoryOnConnected'; 12 | import { useWidgetInactivityTimeout } from '../../hooks/useWidgetInactivityTimeout'; 13 | 14 | const Chat = ({ fullscreen = false }: { fullscreen?: boolean }) => { 15 | const { stores } = useSendbirdStateContext(); 16 | const { locale } = useConstantState(); 17 | const widgetSetting = useWidgetSetting(); 18 | const widgetSession = useWidgetSession(); 19 | 20 | // Initialize the manual session if channelUrl is not set. 21 | useEffect(() => { 22 | if (widgetSetting.initialized && stores.sdkStore.initialized) { 23 | if (widgetSession.strategy === 'manual' && !widgetSession.channelUrl) { 24 | widgetSetting.initManualSession(stores.sdkStore.sdk); 25 | } 26 | } 27 | }, [ 28 | widgetSetting.initialized, 29 | widgetSession.strategy, 30 | widgetSession.channelUrl, 31 | stores.sdkStore.sdk, 32 | stores.sdkStore.initialized, 33 | ]); 34 | 35 | // Set locale for chatbot 36 | useEffect(() => { 37 | if (locale && stores.sdkStore.initialized && stores.sdkStore.sdk) { 38 | stores.sdkStore.sdk.setLocaleForChatbot(locale); 39 | } 40 | }, [locale, stores.sdkStore.initialized, stores.sdkStore.sdk]); 41 | 42 | return ( 43 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | const HeadlessForHooks = ({ fullscreen }: { fullscreen: boolean }) => { 57 | useAssignGlobalFunction(); 58 | useResetHistoryOnConnected(); 59 | useWidgetInactivityTimeout(fullscreen); 60 | useAutoDismissMobileKeyboardHandler(); 61 | 62 | return null; 63 | }; 64 | 65 | export default Chat; 66 | -------------------------------------------------------------------------------- /src/components/chat/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from '@linaria/core'; 2 | 3 | import { ChatHeader } from './ChatHeader'; 4 | import { ChatInput } from './ChatInput'; 5 | import { ChatMessageList } from './ChatMessageList'; 6 | import { themedColorVars } from '../../../foundation/colors/css'; 7 | import { useDragDropArea } from '../../../tools/hooks/useDragDropFiles'; 8 | import { PoweredByBanner } from '../../ui/PoweredByBanner'; 9 | 10 | type Props = { 11 | fullscreen: boolean; 12 | }; 13 | export const ChatUI = ({ fullscreen }: Props) => { 14 | const dragHandlers = useDragDropArea(); 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | const container = css` 26 | font-family: var(--sendbird-font-family-default); 27 | height: 100%; 28 | width: 100%; 29 | display: flex; 30 | flex-direction: column; 31 | flex: 1; 32 | .sendbird-theme--light & { 33 | background-color: var(--sendbird-light-background-50); 34 | } 35 | .sendbird-theme--dark & { 36 | background-color: var(--sendbird-dark-background-700); 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /src/components/messages/FallbackUserMessage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | interface FallbackUserMessageProps { 4 | text: string; 5 | } 6 | 7 | const Container = styled.div` 8 | position: relative; 9 | display: inline-block; 10 | box-sizing: border-box; 11 | padding: 8px 12px; 12 | border-radius: 16px; 13 | background-color: ${({ theme }) => theme.bgColor.incomingMessage}; 14 | `; 15 | 16 | const Label = styled.div` 17 | color: ${({ theme }) => theme.textColor.placeholder}; 18 | font-size: 14px; 19 | font-style: normal; 20 | font-weight: 400; 21 | line-height: 20px; 22 | `; 23 | 24 | export default function FallbackUserMessage({ text }: FallbackUserMessageProps) { 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ui/AlertModal.tsx: -------------------------------------------------------------------------------- 1 | import MessageFeedbackFailedModal from '@uikit/ui/MessageFeedbackFailedModal'; 2 | 3 | import { elementIds } from '../../const'; 4 | 5 | interface Props { 6 | message: string; 7 | onClose: () => void; 8 | } 9 | 10 | // TODO: Remove UIKit 11 | export const AlertModal = ({ message, onClose }: Props) => { 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/ui/BetaLogo.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const BetaLogo = styled.div` 4 | padding: 4px 6px; 5 | border: 1px solid #1870f3; 6 | border-radius: 100px; 7 | font-weight: 700; 8 | font-size: 11px; 9 | line-height: 12px; 10 | color: #1870f3; 11 | `; 12 | 13 | export default BetaLogo; 14 | -------------------------------------------------------------------------------- /src/components/ui/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Token } from '../../utils'; 5 | 6 | /* For the copy button positioning */ 7 | const CodeContainer = styled.div` 8 | position: relative; 9 | padding: 20px; 10 | background: var(--sendbird-dark-background-600); 11 | `; 12 | 13 | const CodeContent = styled.div` 14 | color: #f8f8f8; 15 | overflow-x: auto; 16 | white-space: pre; 17 | `; 18 | 19 | const Line = styled.div` 20 | display: table-row; 21 | `; 22 | 23 | const LineNumber = styled.span` 24 | display: table-cell; 25 | text-align: end; 26 | padding-inline-end: 10px; 27 | user-select: none; 28 | opacity: 0.5; 29 | `; 30 | 31 | const LineContent = styled.span` 32 | display: table-cell; 33 | `; 34 | 35 | const CopyButton = styled.button` 36 | position: absolute; 37 | top: 8px; 38 | inset-inline-end: 12px; 39 | display: flex; 40 | flex-wrap: wrap; 41 | justify-content: center; 42 | align-items: center; 43 | background: #000; 44 | margin-top: 3px; 45 | border-radius: 4px; 46 | height: 26px; 47 | width: 26px; 48 | padding: 2px; 49 | `; 50 | 51 | export const CodeBlock = ({ token }: { token: Token }) => { 52 | const [isCopied, setIsCopied] = useState(false); 53 | const handleCopy = async () => { 54 | try { 55 | await navigator.clipboard.writeText(code); 56 | setIsCopied(true); 57 | setTimeout(() => setIsCopied(false), 2000); // Reset icon after 2 seconds 58 | } catch (err) { 59 | console.error('Failed to copy!', err); 60 | } 61 | }; 62 | 63 | const code = token.value; 64 | const parsedCodeBlock = code.split('\n').map((line, index) => ( 65 | 66 | {index + 1} 67 | {line} 68 | 69 | )); 70 | 71 | return ( 72 | 73 | { 75 | handleCopy(); 76 | }} 77 | > 78 | {isCopied ? '✅' : '📋'} 79 | 80 | {parsedCodeBlock} 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/ui/FileViewer.tsx: -------------------------------------------------------------------------------- 1 | import { FileMessage } from '@sendbird/chat/message'; 2 | 3 | import { FileViewerView } from '@uikit/modules/GroupChannel/components/FileViewer/FileViewerView'; 4 | 5 | interface Props { 6 | message: FileMessage; 7 | onClose: () => void; 8 | } 9 | // TODO: Remove UIKit 10 | export const FileViewer = ({ message, onClose }: Props) => { 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/ui/PoweredByBanner.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import useSendbirdStateContext from '@uikit/hooks/useSendbirdStateContext'; 4 | 5 | import { useConstantState } from '../../context/ConstantContext'; 6 | import SendbirdLogo from '../../icons/sendbird-logo.svg'; 7 | import { hideChatBottomBanner } from '../../utils'; 8 | 9 | export function PoweredByBanner() { 10 | const store = useSendbirdStateContext(); 11 | const sdk = store.stores.sdkStore.sdk; 12 | 13 | if (hideChatBottomBanner(sdk)) { 14 | return null; 15 | } 16 | 17 | return ; 18 | } 19 | 20 | const InnerContainer = styled.div<{ chatBottomBackgroundColor?: string }>` 21 | padding-bottom: 12px; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | background: ${({ theme, chatBottomBackgroundColor }) => chatBottomBackgroundColor || theme.bgColor.bottomBanner}; 26 | color: ${({ theme }) => theme.textColor.bottomBanner.poweredBy}; 27 | flex-wrap: wrap; 28 | font-size: 13px; 29 | 30 | svg { 31 | path { 32 | fill: ${({ theme }) => theme.textColor.bottomBanner.logo}; 33 | } 34 | } 35 | `; 36 | 37 | const Highlighter = styled.a` 38 | color: white; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | `; 43 | 44 | function Banner() { 45 | const { chatBottomContent } = useConstantState(); 46 | 47 | return ( 48 | 49 | {chatBottomContent?.text}   Powered by  50 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/ui/SnapCarousel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useContext, useMemo, useRef, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { noop } from '../../../utils'; 5 | 6 | const Container = styled.div({ 7 | // overscrollBehavior: 'none' // it prevents scroll-y in carousel view 8 | display: 'flex', 9 | flexDirection: 'row', 10 | scrollSnapType: 'x mandatory', 11 | overflowY: 'scroll', 12 | gap: 12, 13 | scrollPadding: 0, 14 | paddingInlineStart: 0, 15 | scrollbarWidth: 'none', 16 | userSelect: 'none', 17 | '::-webkit-scrollbar': { 18 | display: 'none', 19 | }, 20 | }); 21 | 22 | const ItemContainer = styled.div<{ focused: boolean }>(({ theme, focused }) => ({ 23 | display: 'flex', 24 | flexShrink: 0, 25 | scrollSnapAlign: 'start', 26 | flexDirection: 'column', 27 | borderRadius: 16, 28 | overflow: 'hidden', 29 | border: `1px solid ${theme.borderColor.carouselItem}`, 30 | backgroundColor: theme.bgColor.carouselItem, 31 | boxSizing: 'border-box', 32 | cursor: focused ? 'pointer' : 'auto', 33 | })); 34 | 35 | type SnapCarouselProps = { 36 | width?: number | string; 37 | height?: number | string; 38 | children: React.ReactNode; 39 | gap?: number; 40 | startPadding?: number; 41 | endPadding?: number; 42 | style?: React.CSSProperties; 43 | renderButtons?: (props: { activeIndex: number; onClickPrev(): void; onClickNext(): void }) => ReactNode; 44 | }; 45 | 46 | const Context = createContext<{ 47 | activeIndex: number; 48 | scrollTo: (index: number) => void; 49 | }>({ activeIndex: 0, scrollTo: noop }); 50 | 51 | export const SnapCarousel = ({ 52 | gap = 0, 53 | startPadding = 0, 54 | endPadding = 0, 55 | style, 56 | children, 57 | renderButtons, 58 | }: SnapCarouselProps) => { 59 | const ref = useRef(null); 60 | const [activeIndex, setActiveState] = useState(0); 61 | 62 | const direction = (ref.current ? getComputedStyle(ref.current).direction : 'ltr') as 'rtl' | 'ltr'; 63 | const itemLength = React.Children.toArray(children).length; 64 | const itemWidth = useMemo(() => { 65 | const total = ref.current?.scrollWidth ?? 0; 66 | return (total - (startPadding + endPadding + gap * (itemLength - 1))) / itemLength; 67 | }, [ref.current?.scrollWidth, itemLength, gap, startPadding, endPadding]); 68 | 69 | const onScroll = (e: React.UIEvent) => { 70 | const idx = Math.round(e.currentTarget.scrollLeft / itemWidth) * (direction === 'ltr' ? 1 : -1); 71 | if (idx !== activeIndex) setActiveState(idx); 72 | }; 73 | 74 | const scrollTo = (index: number) => { 75 | if (ref.current) { 76 | const nextIdx = Math.min(Math.max(0, index), itemLength - 1); 77 | ref.current.scroll({ left: nextIdx * itemWidth * (direction === 'ltr' ? 1 : -1), behavior: 'smooth' }); 78 | } 79 | }; 80 | 81 | return ( 82 | 83 | 94 | {React.Children.map(children, (child, index) => { 95 | if (React.isValidElement(child)) { 96 | return React.cloneElement(child, { ...child.props, index }); 97 | } 98 | return null; 99 | })} 100 | 101 | {renderButtons?.({ 102 | activeIndex, 103 | onClickPrev() { 104 | scrollTo(activeIndex - 1); 105 | }, 106 | onClickNext() { 107 | scrollTo(activeIndex + 1); 108 | }, 109 | })} 110 | 111 | ); 112 | }; 113 | 114 | type SnapCarouselItemProps = { 115 | children: React.ReactNode; 116 | width?: number | string; 117 | height?: number | string; 118 | onClick?: () => void; 119 | index?: number; 120 | }; 121 | 122 | SnapCarousel.Item = function Item({ children, onClick, height, width, index = 0 }: SnapCarouselItemProps) { 123 | const { activeIndex } = useContext(Context); 124 | const focused = index === activeIndex; 125 | return ( 126 | focused && onClick?.()} role={'button'} style={{ width, height }}> 127 | {children} 128 | 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/ui/WidgetButton.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { getColorBasedOnSaturation } from '../../colors'; 4 | import { elementIds } from '../../const'; 5 | import BotOutlinedIcon from '../../icons/ic-bot-outlined.svg'; 6 | import ChevronDownIcon from '../../icons/ic-chevron-down.svg'; 7 | 8 | const buttonEffect = css` 9 | &:hover { 10 | transition: transform 250ms cubic-bezier(0.33, 0, 0, 1); 11 | transform: scale(1.1); 12 | } 13 | &:active { 14 | transform: scale(0.8); 15 | } 16 | `; 17 | 18 | const ButtonContainer = styled.button<{ 19 | backgroundColor: string; 20 | animated: boolean; 21 | }>` 22 | position: relative; 23 | padding: 0; 24 | width: 48px; 25 | height: 48px; 26 | background: ${({ backgroundColor }) => backgroundColor}; 27 | border-radius: 50%; 28 | transition: all 0.3s cubic-bezier(0.31, -0.105, 0.43, 1.4); 29 | border: none; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | box-shadow: 34 | 0px 16px 24px 2px rgba(33, 33, 33, 0.12), 35 | 0px 6px 30px 5px rgba(33, 33, 33, 0.08), 36 | 0px 6px 10px -5px rgba(33, 33, 33, 0.04); 37 | 38 | span { 39 | width: 100%; 40 | height: 100%; 41 | border-radius: 50%; 42 | overflow: hidden; 43 | transition: 44 | transform 0.16s linear, 45 | opacity 0.08s linear, 46 | scale 0.16s linear; 47 | user-select: none; 48 | display: flex; 49 | justify-content: center; 50 | align-items: center; 51 | 52 | svg { 53 | width: 32px; 54 | height: 32px; 55 | path { 56 | fill: ${({ backgroundColor }) => getColorBasedOnSaturation(backgroundColor)}; 57 | } 58 | } 59 | img { 60 | width: 100%; 61 | height: 100%; 62 | object-fit: cover; 63 | user-select: none; 64 | -webkit-user-select: none; 65 | -moz-user-select: none; 66 | -ms-user-select: none; 67 | -webkit-user-drag: none; 68 | &[data-svg='true'] { 69 | width: 32px; 70 | height: 32px; 71 | filter: ${({ backgroundColor }) => { 72 | return getColorBasedOnSaturation(backgroundColor) === '#ffffff' 73 | ? 'grayscale(100%) brightness(2000%)' 74 | : 'grayscale(100%) invert(100%) saturate(0%) brightness(0%) contrast(1000%)'; 75 | }}; 76 | } 77 | } 78 | } 79 | ${({ animated }) => animated && buttonEffect} 80 | `; 81 | 82 | type IconWrapperProps = { 83 | isOpen: boolean; 84 | animated: boolean; 85 | }; 86 | 87 | const IconWrapper = styled.span` 88 | position: absolute; 89 | `; 90 | 91 | const OpenIconWrapper = styled(IconWrapper)` 92 | opacity: ${({ isOpen }) => (isOpen ? 0 : 1)}; 93 | transform: ${({ animated, isOpen }) => { 94 | return animated && (isOpen ? 'rotate(-90deg) scale(0)' : 'rotate(0deg)'); 95 | }}; 96 | `; 97 | const CloseIconWrapper = styled(IconWrapper)` 98 | scale: ${({ isOpen }) => (isOpen ? 1 : 0)}; 99 | transform: ${({ animated, isOpen }) => { 100 | return animated && (isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'); 101 | }}; 102 | `; 103 | 104 | const Icon = { 105 | Open: (props: { url?: string }) => { 106 | const { url } = props; 107 | if (url) return {'widget-toggle-button'}; 108 | return ; 109 | }, 110 | Close: () => , 111 | }; 112 | 113 | export interface WidgetButtonProps { 114 | isOpen: boolean; 115 | accentColor: string; 116 | imageUrl?: string; 117 | onClick?: () => void; 118 | className?: string; 119 | animated?: boolean; 120 | dir?: 'ltr' | 'rtl'; 121 | } 122 | 123 | export const WidgetButton = ({ 124 | isOpen, 125 | imageUrl, 126 | accentColor, 127 | onClick, 128 | className, 129 | animated = true, 130 | dir, 131 | }: WidgetButtonProps) => { 132 | return ( 133 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /src/components/widget/ChatAiWidget.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { StringSet } from '@uikit/ui/Label/stringSet'; 4 | 5 | import ProviderContainer from './ProviderContainer'; 6 | import WidgetToggleButton from './WidgetToggleButton'; 7 | import WidgetWindow from './WidgetWindow'; 8 | import { type Constant, elementIds, WIDGET_WINDOW_Z_INDEX } from '../../const'; 9 | import { useConstantState } from '../../context/ConstantContext'; 10 | import { useWidgetState } from '../../context/WidgetStateContext'; 11 | import useMobileView from '../../hooks/useMobileView'; 12 | import { useWidgetAutoOpen } from '../../hooks/useWidgetAutoOpen'; 13 | import { isMobile } from '../../utils'; 14 | import Chat from '../chat'; 15 | 16 | const MobileContainer = styled.div<{ width: number }>` 17 | position: fixed; 18 | z-index: ${WIDGET_WINDOW_Z_INDEX}; 19 | top: 0; 20 | inset-inline-start: 0; 21 | width: ${({ width }) => `${width}px`}; 22 | height: 100%; 23 | overflow: hidden; 24 | background-color: white; 25 | `; 26 | 27 | const DesktopComponent = () => { 28 | const { isVisible } = useWidgetState(); 29 | useWidgetAutoOpen(); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | {isVisible && } 37 | 38 | ); 39 | }; 40 | 41 | const MobileComponent = () => { 42 | const { isOpen, isVisible } = useWidgetState(); 43 | const { dir } = useConstantState(); 44 | const { width: mobileContainerWidth } = useMobileView(); 45 | 46 | return ( 47 | <> 48 | 54 | 55 | 56 | {isVisible && !isOpen && } 57 | 58 | ); 59 | }; 60 | 61 | export interface ChatAiWidgetProps extends Omit, 'stringSet'> { 62 | applicationId: string; 63 | botId: string; 64 | hashedKey?: string; 65 | stringSet?: Partial; 66 | } 67 | 68 | export default function ChatAiWidget(props: ChatAiWidgetProps) { 69 | return ( 70 | 71 | {isMobile(props.deviceType) ? : } 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/widget/WidgetToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { MAX_Z_INDEX } from '../../const'; 4 | import { useConstantState } from '../../context/ConstantContext'; 5 | import { useWidgetSetting } from '../../context/WidgetSettingContext'; 6 | import { useWidgetState } from '../../context/WidgetStateContext'; 7 | import { WidgetButton, WidgetButtonProps } from '../ui/WidgetButton'; 8 | 9 | const FloatingWidgetButton = styled(WidgetButton)` 10 | && { 11 | position: fixed; 12 | z-index: ${MAX_Z_INDEX}; 13 | bottom: 24px; 14 | right: unset; 15 | inset-inline-end: 24px; 16 | } 17 | `; 18 | 19 | export default function WidgetToggleButton() { 20 | const { botStyle } = useWidgetSetting(); 21 | const { dir, renderWidgetToggleButton } = useConstantState(); 22 | const { isOpen, setIsOpen } = useWidgetState(); 23 | 24 | const toggleButtonProps: WidgetButtonProps = { 25 | dir, 26 | isOpen, 27 | onClick: () => setIsOpen(!isOpen), 28 | accentColor: botStyle.accentColor, 29 | imageUrl: botStyle.toggleButtonUrl, 30 | }; 31 | 32 | if (typeof renderWidgetToggleButton === 'function') { 33 | return renderWidgetToggleButton(toggleButtonProps); 34 | } 35 | 36 | return ; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/widget/WidgetWindow.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { elementIds, WIDGET_WINDOW_Z_INDEX } from '../../const'; 4 | import { useConstantState } from '../../context/ConstantContext'; 5 | import { useWidgetState } from '../../context/WidgetStateContext'; 6 | 7 | const StyledWidgetWindowWrapper = styled.div<{ 8 | isOpen: boolean; 9 | isExpanded: boolean; 10 | }>` 11 | background: ${({ theme }) => theme.bgColor.base}; 12 | display: flex; 13 | overscroll-behavior: none; 14 | -webkit-overflow-scrolling: auto; 15 | position: fixed; 16 | bottom: 84px; 17 | inset-inline-end: 20px; 18 | height: 640px; 19 | min-height: 80px; 20 | width: 400px; 21 | max-width: 80vw; 22 | max-height: 80vh; 23 | box-shadow: 24 | 0px 16px 24px 2px rgba(33, 33, 33, 0.12), 25 | 0px 6px 30px 5px rgba(33, 33, 33, 0.08), 26 | 0px 6px 10px -5px rgba(33, 33, 33, 0.04); 27 | border-radius: 16px; 28 | overflow: hidden; 29 | transition: 30 | width 200ms ease 0s, 31 | height 200ms ease 0s, 32 | max-height 200ms ease 0s, 33 | transform 150ms cubic-bezier(0, 1.2, 1, 1) 0s, 34 | opacity 83ms ease-out 0s; 35 | transform: scale(0.15); 36 | opacity: 0; 37 | transform-origin: right bottom; 38 | [dir='rtl'] &:not([dir='ltr']), 39 | &[dir='rtl'] { 40 | transform-origin: left bottom; 41 | } 42 | 43 | ${({ isOpen }) => { 44 | return ( 45 | isOpen && 46 | css` 47 | z-index: ${WIDGET_WINDOW_Z_INDEX}; 48 | pointer-events: all; 49 | transform: scale(1); 50 | opacity: 1; 51 | transition: 52 | width 200ms ease 0s, 53 | height 200ms ease 0s, 54 | max-height 200ms ease 0s, 55 | transform 300ms cubic-bezier(0, 1.2, 1, 1) 0s, 56 | opacity 83ms ease-out 0s; 57 | ` 58 | ); 59 | }} 60 | 61 | ${({ isExpanded }) => 62 | isExpanded && 63 | css` 64 | width: 743px; 65 | height: 723px; 66 | `} 67 | `; 68 | 69 | const WidgetWindow = ({ children }: { children: React.ReactNode }) => { 70 | const { dir } = useConstantState(); 71 | const { isVisible, isOpen, isExpanded } = useWidgetState(); 72 | 73 | return ( 74 | 80 | {children} 81 | 82 | ); 83 | }; 84 | 85 | export default WidgetWindow; 86 | -------------------------------------------------------------------------------- /src/components/widget/WidgetWindowFullScreen.tsx: -------------------------------------------------------------------------------- 1 | import { ChatAiWidgetProps } from './ChatAiWidget'; 2 | import ProviderContainer from './ProviderContainer'; 3 | import { elementIds } from '../../const'; 4 | import Chat from '../chat'; 5 | 6 | /** 7 | * NOTE: External purpose only. 8 | * Do not use this component directly. Use Chat instead for internal use. 9 | */ 10 | function WidgetWindowFullScreen(props: ChatAiWidgetProps) { 11 | return ( 12 | 13 |
17 | 18 |
19 |
20 | ); 21 | } 22 | 23 | export default WidgetWindowFullScreen; 24 | -------------------------------------------------------------------------------- /src/context/WidgetStateContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from 'react'; 2 | 3 | import { useConstantState } from './ConstantContext'; 4 | 5 | /** 6 | * Controls the open state of the widget. 7 | */ 8 | interface OpenState { 9 | isOpen: boolean; 10 | setIsOpen: (value: boolean) => void; 11 | } 12 | 13 | /** 14 | * Controls the visible state of the widget. 15 | */ 16 | interface VisibleState { 17 | isVisible: boolean; 18 | setIsVisible: (value: boolean) => void; 19 | } 20 | 21 | /** 22 | * Controls the expand state of the widget. 23 | */ 24 | interface ExpandState { 25 | isExpanded: boolean; 26 | setIsExpanded: (value: boolean) => void; 27 | } 28 | 29 | const WidgetStateContext = createContext<(OpenState & VisibleState & ExpandState) | null>(null); 30 | 31 | export const WidgetStateProvider = ({ children }: React.PropsWithChildren) => { 32 | const { widgetOpenState, onWidgetOpenStateChange, enableHideWidgetForDeactivatedUser, callbacks } = 33 | useConstantState(); 34 | 35 | const [isOpen, setIsOpen] = useState(false); 36 | const [isExpanded, setIsExpanded] = useState(false); 37 | const [isVisible, setIsVisible] = useState(!enableHideWidgetForDeactivatedUser); 38 | 39 | const isOpenControlled = typeof widgetOpenState === 'boolean' && typeof onWidgetOpenStateChange === 'function'; 40 | 41 | return ( 42 | { 46 | if (isOpenControlled) onWidgetOpenStateChange({ value }); 47 | else setIsOpen(value); 48 | }, 49 | isVisible, 50 | setIsVisible, 51 | isExpanded, 52 | setIsExpanded: (value) => { 53 | setIsExpanded(value); 54 | callbacks?.onWidgetExpandStateChange?.(value); 55 | }, 56 | }} 57 | > 58 | {children} 59 | 60 | ); 61 | }; 62 | 63 | export const useWidgetState = () => { 64 | const context = useContext(WidgetStateContext); 65 | if (context === null) { 66 | throw new Error('useWidgetState must be used within an WidgetStateProvider'); 67 | } 68 | return context; 69 | }; 70 | -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * NOTE: Since we are not using Shadow DOM, all written styles will affect the client's code. 3 | * Therefore, until Shadow DOM is introduced, do not write styles in a general manner. 4 | * Example: #root { ... }, input { ... } 5 | */ 6 | .sendbird-modal__content { 7 | width: calc(100% - 20px); 8 | max-width: calc(100% - 80px); 9 | } 10 | 11 | .sendbird-fileviewer { 12 | outline: none; 13 | } 14 | 15 | .sendbird-ai-widget-file-message-root { 16 | width: 100%; 17 | } 18 | 19 | .sendbird-ai-widget-file-message { 20 | border-radius: 16px; 21 | width: 100%; 22 | cursor: pointer; 23 | } 24 | 25 | .sendbird-word__url { 26 | font-weight: 700; 27 | color: inherit; 28 | text-decoration: underline; 29 | } 30 | 31 | .sendbird-message-content__middle__body-container__feedback-buttons-container { 32 | margin-top: 4px; 33 | position: relative; 34 | display: flex; 35 | gap: 4px; 36 | } 37 | 38 | .sendbird-modal-root { 39 | position: absolute; 40 | z-index: 2147483647; 41 | } 42 | 43 | .sendbird-suggested-replies { 44 | margin: 0 !important; 45 | padding: 8px 0 4px 0; 46 | } 47 | 48 | .sendbird-form-chip__container { 49 | display: flex; 50 | width: 100%; 51 | align-items: flex-start; 52 | gap: 4px; 53 | flex-wrap: wrap; 54 | } 55 | 56 | .sendbird-form-chip { 57 | padding: 0 12px; 58 | } 59 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | const ReactComponent: React.FC>; 4 | export default ReactComponent; 5 | } 6 | -------------------------------------------------------------------------------- /src/foundation/colors/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const themedColors = { 4 | onbackground1: 'var(--sb-on-bg-1)', 5 | onbackground2: 'var(--sb-on-bg-2)', 6 | onbackground3: 'var(--sb-on-bg-3)', 7 | onbackground4: 'var(--sb-on-bg-4)', 8 | bg1: 'var(--sb-bg-1)', 9 | bg2: 'var(--sb-bg-2)', 10 | oncontent1: 'var(--sb-on-content-1)', 11 | oncontent2: 'var(--sb-on-content-2)', 12 | oncontent_inverse1: 'var(--sb-on-content-inverse-1)', 13 | oncontent_inverse2: 'var(--sb-on-content-inverse-2)', 14 | primary: 'var(--sb-primary)', 15 | error: 'var(--sb-error)', 16 | secondary: 'var(--sb-secondary)', 17 | }; 18 | 19 | export const themedColorVars = css` 20 | .sendbird-theme--light & { 21 | --sb-on-bg-1: var(--sendbird-light-onlight-01); 22 | --sb-on-bg-2: var(--sendbird-light-onlight-02); 23 | --sb-on-bg-3: var(--sendbird-light-onlight-03); 24 | --sb-on-bg-4: var(--sendbird-light-onlight-04); 25 | --sb-bg-1: var(--sendbird-light-background-50); 26 | --sb-bg-2: var(--sendbird-light-background-100); 27 | --sb-on-content-1: var(--sendbird-light-ondark-01); 28 | --sb-on-content-2: var(--sendbird-light-ondark-02); 29 | --sb-on-content-inverse-1: var(--sendbird-light-onlight-01); 30 | --sb-on-content-inverse-2: var(--sendbird-light-onlight-02); 31 | --sb-primary: var(--sendbird-light-primary-300); 32 | --sb-secondary: var(--sendbird-light-secondary-300); 33 | --sb-error: var(--sendbird-light-error-300); 34 | } 35 | .sendbird-theme--dark & { 36 | --sb-on-bg-1: var(--sendbird-dark-ondark-01); 37 | --sb-on-bg-2: var(--sendbird-dark-ondark-02); 38 | --sb-on-bg-3: var(--sendbird-dark-ondark-03); 39 | --sb-on-bg-4: var(--sendbird-dark-ondark-04); 40 | --sb-bg-1: var(--sendbird-dark-background-600); 41 | --sb-bg-2: var(--sendbird-dark-background-500); 42 | --sb-on-content-1: var(--sendbird-dark-onlight-01); 43 | --sb-on-content-2: var(--sendbird-dark-onlight-02); 44 | --sb-on-content-inverse-1: var(--sendbird-dark-ondark-01); 45 | --sb-on-content-inverse-2: var(--sendbird-dark-ondark-02); 46 | --sb-primary: var(--sendbird-dark-primary-200); 47 | --sb-secondary: var(--sendbird-dark-secondary-200); 48 | --sb-error: var(--sendbird-dark-error-200); 49 | } 50 | `; 51 | 52 | /** 53 | * To use this CSS, please add colorSetClassName to the className. 54 | * */ 55 | export const textColors = { 56 | onbackground1: css` 57 | color: var(--sb-on-bg-1); 58 | `, 59 | onbackground2: css` 60 | color: var(--sb-on-bg-2); 61 | `, 62 | onbackground3: css` 63 | color: var(--sb-on-bg-3); 64 | `, 65 | onbackground4: css` 66 | color: var(--sb-on-bg-4); 67 | `, 68 | oncontent1: css` 69 | color: var(--sb-on-content-1); 70 | `, 71 | oncontent2: css` 72 | color: var(--sb-on-content-2); 73 | `, 74 | oncontent_inverse1: css` 75 | color: var(--sb-on-content-inverse-1); 76 | `, 77 | oncontent_inverse2: css` 78 | color: var(--sb-on-content-inverse-2); 79 | `, 80 | primary: css` 81 | color: var(--sb-primary); 82 | `, 83 | error: css` 84 | color: var(--sb-error); 85 | `, 86 | secondary: css` 87 | color: var(--sb-secondary); 88 | `, 89 | }; 90 | 91 | /** 92 | * To use this CSS, please add colorSetClassName to the className. 93 | * */ 94 | export const bgColors = { 95 | onbackground1: css` 96 | background-color: var(--sb-on-bg-1); 97 | `, 98 | onbackground2: css` 99 | background-color: var(--sb-on-bg-2); 100 | `, 101 | onbackground3: css` 102 | background-color: var(--sb-on-bg-3); 103 | `, 104 | onbackground4: css` 105 | background-color: var(--sb-on-bg-4); 106 | `, 107 | oncontent1: css` 108 | background-color: var(--sb-on-content-1); 109 | `, 110 | primary: css` 111 | background-color: var(--sb-primary); 112 | `, 113 | error: css` 114 | background-color: var(--sb-error); 115 | `, 116 | secondary: css` 117 | background-color: var(--sb-secondary); 118 | `, 119 | }; 120 | -------------------------------------------------------------------------------- /src/foundation/colors/palette.ts: -------------------------------------------------------------------------------- 1 | // * Palette 2 | // :root { 3 | // --sendbird-primary-500: #491389; 4 | // --sendbird-primary-400: #6211c8; 5 | // --sendbird-primary-300: #742ddd; 6 | // --sendbird-primary-200: #c2a9fa; 7 | // --sendbird-primary-100: #dbd1ff; 8 | // 9 | // --sendbird-secondary-500: #066858; 10 | // --sendbird-secondary-400: #027d69; 11 | // --sendbird-secondary-300: #259c72; 12 | // --sendbird-secondary-200: #69c085; 13 | // --sendbird-secondary-100: #a8e2ab; 14 | // 15 | // --sendbird-information-100: #adc9ff; 16 | // --sendbird-highlight-100: #fff2b6; 17 | // 18 | // --sendbird-error-500: #9d091e; 19 | // --sendbird-error-400: #bf0711; 20 | // --sendbird-error-300: #de360b; 21 | // --sendbird-error-200: #f66161; 22 | // --sendbird-error-100: #fdaaaa; 23 | // 24 | // --sendbird-background-700: #000000; 25 | // --sendbird-background-600: #161616; 26 | // --sendbird-background-500: #2C2C2C; 27 | // --sendbird-background-400: #393939; 28 | // --sendbird-background-300: #bdbdbd; 29 | // --sendbird-background-200: #e0e0e0; 30 | // --sendbird-background-100: #eeeeee; 31 | // --sendbird-background-50: #FFFFFF; 32 | // 33 | // --sendbird-overlay-01: rgba(0, 0, 0, 0.55); 34 | // --sendbird-overlay-02: rgba(0, 0, 0, 0.32); 35 | // 36 | // --sendbird-onlight-01: rgba(0, 0, 0, 0.88); 37 | // --sendbird-onlight-02: rgba(0, 0, 0, 0.50); 38 | // --sendbird-onlight-03: rgba(0, 0, 0, 0.38); 39 | // --sendbird-onlight-04: rgba(0, 0, 0, 0.12); 40 | // 41 | // --sendbird-ondark-01: rgba(255, 255, 255, 0.88); 42 | // --sendbird-ondark-02: rgba(255, 255, 255, 0.50); 43 | // --sendbird-ondark-03: rgba(255, 255, 255, 0.38); 44 | // --sendbird-ondark-04: rgba(255, 255, 255, 0.12); 45 | // 46 | // --sendbird-shadow-01: 0 1px 5px 0 rgba(33, 34, 66, 0.04), 0 0 3px 0 rgba(0, 0, 0, 0.08), 0 2px 1px 0 rgba(0, 0, 0, 0.12); 47 | // --sendbird-shadow-02: 0 3px 5px -3px rgba(33, 34, 66, 0.04), 0 3px 14px 2px rgba(0, 0, 0, 0.08), 0 8px 10px 1px rgba(0, 0, 0, 0.12); 48 | // --sendbird-shadow-03: 0 6px 10px -5px rgba(0, 0, 0, 0.04), 0 6px 30px 5px rgba(0, 0, 0, 0.08), 0 16px 24px 2px rgba(0, 0, 0, 0.12); 49 | // --sendbird-shadow-04: 0 9px 15px -7px rgba(0, 0, 0, 0.04), 0 9px 46px 8px rgba(0, 0, 0, 0.08), 0 24px 38px 3px rgba(0, 0, 0, 0.12); 50 | // 51 | // --sendbird-shadow-05: 0 2px 8px 0 rgba(0, 0, 0, 0.08), 0 4px 6px 0 rgba(0, 0, 0, 0.12); 52 | // 53 | // --sendbird-shadow-message-input: 0 1px 5px 0 rgba(33, 34, 66, 0.12), 0 0 1px 0 rgba(33, 34, 66, 0.16), 0 2px 1px 0 rgba(33, 34, 66, 0.08), 0 1px 5px 0 rgba(0, 0, 0, 0.12); 54 | // } 55 | -------------------------------------------------------------------------------- /src/foundation/components/DateSeparator/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const dateSeparatorContainer = css` 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | `; 8 | 9 | export const dateSeparatorLabel = css` 10 | margin: 0 16px; 11 | display: flex; 12 | white-space: nowrap; 13 | `; 14 | -------------------------------------------------------------------------------- /src/foundation/components/DateSeparator/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/atomic'; 2 | import { styled } from '@linaria/react'; 3 | import { format } from 'date-fns/format'; 4 | import type { Locale } from 'date-fns/locale'; 5 | 6 | import { dateSeparatorContainer, dateSeparatorLabel } from './css'; 7 | import { bgColors, themedColorVars } from '../../colors/css'; 8 | import { useLocalProps } from '../../hooks/useLocalProps'; 9 | import { SBUFoundationProps } from '../../types'; 10 | import { Label } from '../Label'; 11 | 12 | type Props = SBUFoundationProps<{ 13 | /** date or timestamp */ 14 | date?: Date | number; 15 | /** locale for date-fns */ 16 | locale?: Locale; 17 | /** format string for date-fns */ 18 | formatString?: string; 19 | 20 | separatorColor?: keyof typeof bgColors | string; 21 | }>; 22 | export const DateSeparator = ({ 23 | className, 24 | children, 25 | locale, 26 | date = Date.now(), 27 | formatString = 'MMMM dd, yyyy', 28 | separatorColor, 29 | testId = 'sendbird-date-separator', 30 | }: Props) => { 31 | const localProps = useLocalProps({ testId }); 32 | const colorClassName = separatorColor ? bgColors[separatorColor as keyof typeof bgColors] : bgColors.onbackground4; 33 | 34 | return ( 35 |
36 | 37 |
38 | {children ?? ( 39 | 42 | )} 43 |
44 | 45 |
46 | ); 47 | }; 48 | 49 | const Separator = styled.div<{ color?: string }>` 50 | border: none; 51 | height: 1px; 52 | display: inline-block; 53 | width: 100%; 54 | ${({ color }) => (color ? `background-color: ${color};` : '')} 55 | `; 56 | -------------------------------------------------------------------------------- /src/foundation/components/FrozenBanner/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const frozenBannerContainer = css` 4 | height: 32px; 5 | border-radius: 4px; 6 | box-sizing: border-box; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | background-color: var(--sendbird-light-information-100); 11 | `; 12 | -------------------------------------------------------------------------------- /src/foundation/components/FrozenBanner/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | import React from 'react'; 3 | 4 | import { frozenBannerContainer } from './css'; 5 | import { useLocalProps } from '../../hooks/useLocalProps'; 6 | import { SBUFoundationProps } from '../../types'; 7 | import { Label } from '../Label'; 8 | 9 | type Props = SBUFoundationProps<{ 10 | label?: string; 11 | }>; 12 | export const FrozenBanner = ({ 13 | className, 14 | label = 'Channel frozen', 15 | testId = 'sendbird-frozen-banner', 16 | }: Props): React.ReactElement => { 17 | const localProps = useLocalProps({ testId }); 18 | return ( 19 |
20 | 21 |
22 | ); 23 | }; 24 | 25 | export default FrozenBanner; 26 | -------------------------------------------------------------------------------- /src/foundation/components/InfiniteMessageList/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const infiniteListContainer = css` 4 | display: flex; 5 | position: relative; 6 | flex: 1; 7 | overflow: hidden; 8 | `; 9 | 10 | export const infiniteListInner = css` 11 | display: flex; 12 | flex: 1; 13 | flex-direction: column; 14 | overflow-y: auto; 15 | `; 16 | 17 | export const infiniteListOverlayContainer = css` 18 | position: absolute; 19 | top: 0; 20 | bottom: 0; 21 | left: 0; 22 | right: 0; 23 | pointer-events: none; 24 | `; 25 | export const infiniteListOverlay = css` 26 | pointer-events: auto; 27 | `; 28 | -------------------------------------------------------------------------------- /src/foundation/components/Label/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | // const mobileMixin = (styles: string) => css` 4 | // .sendbird--mobile-mode & { 5 | // ${styles} 6 | // } 7 | // `; 8 | // 9 | // const sendbirdLabelMobile = css` 10 | // ${mobileMixin(` 11 | // -webkit-user-select: none; 12 | // -webkit-touch-callout: none; 13 | // `)}; 14 | // `; 15 | 16 | export const labelStyles = { 17 | base: css` 18 | font-stretch: normal; 19 | font-style: normal; 20 | letter-spacing: normal; 21 | font-family: var(--sendbird-font-family-default); 22 | `, 23 | lg: css` 24 | letter-spacing: normal; 25 | `, 26 | sm: css` 27 | letter-spacing: -0.2px; 28 | `, 29 | h1: css` 30 | font-size: 20px; 31 | font-weight: 600; 32 | line-height: 1.4; 33 | `, 34 | h2: css` 35 | font-size: 18px; 36 | font-weight: 600; 37 | line-height: normal; 38 | `, 39 | subtitle1: css` 40 | font-size: 16px; 41 | font-weight: normal; 42 | line-height: 1.38; 43 | `, 44 | subtitle2: css` 45 | font-size: 14px; 46 | font-weight: 500; 47 | line-height: 1.14; 48 | `, 49 | body1: css` 50 | font-size: 14px; 51 | line-height: 1.43; 52 | `, 53 | body2: css` 54 | font-size: 12px; 55 | font-weight: normal; 56 | line-height: 1.33; 57 | `, 58 | button1: css` 59 | font-size: 14px; 60 | font-weight: 600; 61 | line-height: 1.43; 62 | `, 63 | button2: css` 64 | font-size: 14px; 65 | font-weight: normal; 66 | line-height: 1.43; 67 | `, 68 | button3: css` 69 | font-size: 14px; 70 | font-weight: 500; 71 | line-height: 1.43; 72 | `, 73 | caption1: css` 74 | font-size: 14px; 75 | font-weight: 600; 76 | line-height: 1.43; 77 | `, 78 | caption2: css` 79 | font-size: 12px; 80 | font-weight: bold; 81 | line-height: 1; 82 | `, 83 | caption3: css` 84 | font-size: 12px; 85 | font-weight: normal; 86 | line-height: 1; 87 | `, 88 | }; 89 | -------------------------------------------------------------------------------- /src/foundation/components/Label/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/atomic'; 2 | import React, { ElementType, HTMLAttributes } from 'react'; 3 | 4 | import { labelStyles } from './css'; 5 | import { textColors, themedColorVars } from '../../colors/css'; 6 | import { useLocalProps } from '../../hooks/useLocalProps'; 7 | import { SBUFoundationProps } from '../../types'; 8 | 9 | type AsProp = { 10 | as?: C; 11 | }; 12 | type PropsToOmit = keyof (P & AsProp); 13 | type PolymorphicComponentProps> = Props & 14 | AsProp & 15 | Omit, PropsToOmit>; 16 | 17 | type HTMLProps = PolymorphicComponentProps>; 18 | 19 | type Props = SBUFoundationProps< 20 | HTMLProps & { 21 | type?: 22 | | 'h1' 23 | | 'h2' 24 | | 'subtitle1' 25 | | 'subtitle2' 26 | | 'body1' 27 | | 'body2' 28 | | 'button1' 29 | | 'button2' 30 | | 'button3' 31 | | 'caption1' 32 | | 'caption2' 33 | | 'caption3'; 34 | color?: keyof typeof textColors | string; 35 | } 36 | >; 37 | 38 | export const Label = ({ 39 | as, 40 | type, 41 | color, 42 | style, 43 | className, 44 | testId = 'sendbird-label', 45 | ...props 46 | }: Props) => { 47 | const Component = as || 'span'; 48 | const localProps = useLocalProps({ testId }); 49 | const typoClassNames = type ? typo[type] : []; 50 | const colorClassName = color ? textColors[color as keyof typeof textColors] : undefined; 51 | return ( 52 | 58 | ); 59 | }; 60 | const typo = { 61 | h1: [labelStyles.h1, labelStyles.base, labelStyles.lg], 62 | h2: [labelStyles.h2, labelStyles.base, labelStyles.lg], 63 | subtitle1: [labelStyles.subtitle1, labelStyles.base, labelStyles.lg], 64 | subtitle2: [labelStyles.subtitle2, labelStyles.base, labelStyles.lg], 65 | body1: [labelStyles.body1, labelStyles.base, labelStyles.sm], 66 | body2: [labelStyles.body2, labelStyles.base, labelStyles.sm], 67 | button1: [labelStyles.button1, labelStyles.base, labelStyles.sm], 68 | button2: [labelStyles.button2, labelStyles.base, labelStyles.sm], 69 | button3: [labelStyles.button3, labelStyles.base, labelStyles.sm], 70 | caption1: [labelStyles.caption1, labelStyles.base, labelStyles.sm], 71 | caption2: [labelStyles.caption2, labelStyles.base, labelStyles.sm], 72 | caption3: [labelStyles.caption3, labelStyles.base, labelStyles.sm], 73 | }; 74 | -------------------------------------------------------------------------------- /src/foundation/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | import { styled } from '@linaria/react'; 3 | 4 | import { useLocalProps } from '../../hooks/useLocalProps'; 5 | import { resolveSize } from '../../resolveSize'; 6 | import { SBUFoundationProps } from '../../types'; 7 | import { Icon } from '../Icon'; 8 | 9 | type Props = SBUFoundationProps<{ 10 | size?: string | number; 11 | }>; 12 | export const Loader = ({ className, children, size = 26, testId = 'sendbird-loader' }: Props) => { 13 | const localProps = useLocalProps({ testId }); 14 | return ( 15 | 16 | {children ?? } 17 | 18 | ); 19 | }; 20 | 21 | const Container = styled.div<{ size: string | number }>` 22 | display: inline-block; 23 | animation: 1s infinite linear; 24 | animation-name: rotate; 25 | 26 | width: ${resolveSize}; 27 | height: ${resolveSize}; 28 | 29 | @keyframes rotate { 30 | from { 31 | transform: rotate(0); 32 | } 33 | to { 34 | transform: rotate(360deg); 35 | } 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/Placeholder.error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { actionButtonContainer } from './css'; 4 | import PlaceholderCommon, { PlaceholderCommonProps } from './PlaceholderCommon'; 5 | import { Icon } from '../Icon'; 6 | import { Label } from '../Label'; 7 | 8 | type Props = Omit & { 9 | action?: () => void; 10 | actionLabel?: string; 11 | }; 12 | 13 | const PlaceholderError = ({ label = 'Something went wrong', action, actionLabel = 'Retry', ...props }: Props) => { 14 | return ( 15 | 16 | {action && } 17 | 18 | ); 19 | }; 20 | 21 | const RetryButton = ({ label, onClick }: { label: string; onClick: React.MouseEventHandler }) => { 22 | return ( 23 | 29 | ); 30 | }; 31 | 32 | export default PlaceholderError; 33 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/Placeholder.loading.tsx: -------------------------------------------------------------------------------- 1 | import { PlaceholderProps } from './types'; 2 | import { Icon } from '../Icon'; 3 | import { Loader } from '../Loader'; 4 | 5 | const PlaceholderLoading = ({ iconSize = 48, className }: PlaceholderProps) => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default PlaceholderLoading; 14 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/Placeholder.noChannels.tsx: -------------------------------------------------------------------------------- 1 | import PlaceholderCommon, { PlaceholderCommonProps } from './PlaceholderCommon'; 2 | 3 | const PlaceholderNoChannels = ({ label = 'No channels', ...props }: Omit) => { 4 | return ; 5 | }; 6 | 7 | export default PlaceholderNoChannels; 8 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/Placeholder.noMessages.tsx: -------------------------------------------------------------------------------- 1 | import PlaceholderCommon, { PlaceholderCommonProps } from './PlaceholderCommon'; 2 | 3 | const PlaceholderNoMessages = ({ label = 'No messages', ...props }: Omit) => { 4 | return ; 5 | }; 6 | 7 | export default PlaceholderNoMessages; 8 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/PlaceholderCommon.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | 3 | import { placeholderBody } from './css'; 4 | import { PlaceholderProps } from './types'; 5 | import { Icon, IconType } from '../Icon'; 6 | import { Label } from '../Label'; 7 | 8 | export interface PlaceholderCommonProps extends PlaceholderProps { 9 | icon: IconType; 10 | label?: string; 11 | } 12 | 13 | const PlaceholderCommon = ({ iconSize = 64, icon, className, label, children }: PlaceholderCommonProps) => { 14 | return ( 15 |
16 | 17 | 20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | export default PlaceholderCommon; 26 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const placeholderContainer = css` 4 | position: relative; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | width: 100%; 9 | height: 100%; 10 | `; 11 | 12 | export const placeholderBody = css` 13 | display: flex; 14 | flex-direction: column; 15 | min-height: 104px; 16 | align-items: center; 17 | gap: 20px; 18 | `; 19 | 20 | export const actionButtonContainer = css` 21 | all: unset; 22 | display: flex; 23 | cursor: pointer; 24 | gap: 4px; 25 | `; 26 | -------------------------------------------------------------------------------- /src/foundation/components/Placeholder/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | export type PlaceholderProps = { 4 | className?: string; 5 | iconSize?: string | number; 6 | children?: ReactNode; 7 | }; 8 | -------------------------------------------------------------------------------- /src/foundation/components/ScrollToBottomButton/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const buttonContainer = css` 4 | all: unset; 5 | cursor: pointer; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | width: 40px; 10 | height: 40px; 11 | border-radius: 24px; 12 | 13 | .sendbird-theme--light & { 14 | box-shadow: var(--sendbird-light-shadow-05); 15 | background-color: var(--sendbird-light-background-50); 16 | &:hover { 17 | background-color: var(--sendbird-light-background-100); 18 | } 19 | &:active { 20 | background-color: var(--sendbird-light-background-200); 21 | } 22 | } 23 | .sendbird-theme--dark & { 24 | box-shadow: var(--sendbird-light-shadow-05); 25 | background-color: var(--sendbird-dark-background-400); 26 | &:hover { 27 | background-color: var(--sendbird-dark-background-500); 28 | } 29 | &:active { 30 | background-color: var(--sendbird-dark-background-700); 31 | } 32 | } 33 | 34 | &:focus { 35 | outline: none; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/foundation/components/ScrollToBottomButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | 3 | import { buttonContainer } from './css'; 4 | import { useLocalProps } from '../../hooks/useLocalProps'; 5 | import { SBUFoundationProps } from '../../types'; 6 | import { Icon } from '../Icon'; 7 | 8 | type Props = SBUFoundationProps<{ 9 | onClick: () => void; 10 | }>; 11 | export const ScrollToBottomButton = ({ className, onClick, testId = 'sendbird-scroll-to-bottom-button' }: Props) => { 12 | const localProps = useLocalProps({ testId }); 13 | 14 | return ( 15 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/foundation/components/TypingBubble/css.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@linaria/core'; 2 | 3 | export const typingDotContainer = css` 4 | align-items: center; 5 | border-radius: 100px; 6 | display: flex; 7 | gap: 6px; 8 | justify-content: center; 9 | padding: 16px 12px; 10 | `; 11 | 12 | export const typingDot = css` 13 | animation: blink 1.4s infinite; 14 | animation-fill-mode: both; 15 | border-radius: 50%; 16 | height: 8px; 17 | width: 8px; 18 | @keyframes blink { 19 | 0% { 20 | opacity: 0.12; 21 | transform: scale(1); 22 | } 23 | 21.43% { 24 | opacity: 0.38; 25 | transform: scale(1.2); 26 | } 27 | 42.86% { 28 | opacity: 0.12; 29 | transform: scale(1); 30 | } 31 | 100% { 32 | opacity: 0.12; 33 | transform: scale(1); 34 | } 35 | } 36 | &:nth-child(1) { 37 | animation-delay: 0.4s; 38 | } 39 | &:nth-child(2) { 40 | animation-delay: 0.6s; 41 | } 42 | &:nth-child(3) { 43 | animation-delay: 0.8s; 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /src/foundation/components/TypingBubble/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from '@linaria/core'; 2 | import { useTheme } from 'styled-components'; 3 | 4 | import { typingDot, typingDotContainer } from './css'; 5 | import { useLocalProps } from '../../hooks/useLocalProps'; 6 | import { SBUFoundationProps } from '../../types'; 7 | 8 | export const TypingBubble = ({ className, testId }: SBUFoundationProps) => { 9 | const theme = useTheme(); 10 | const localProps = useLocalProps({ testId }); 11 | return ( 12 |
17 | {[0, 1, 2].map((it) => ( 18 | 19 | ))} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/foundation/hooks/useLocalProps.ts: -------------------------------------------------------------------------------- 1 | import { SBUFoundationProps } from '../types'; 2 | 3 | function deundefined(object: T): T { 4 | return Object.entries(object).reduce((acc, [key, value]) => { 5 | if (value !== undefined && value !== null) { 6 | acc[key as keyof T] = value; 7 | } 8 | return acc; 9 | }, {} as T); 10 | } 11 | 12 | type Params = Pick; 13 | export function useLocalProps(props: T) { 14 | const { testId } = props; 15 | return deundefined({ 'data-testid': testId }); 16 | // return useMemo(() => deundefined({ 'data-testid': testId }), [testId]); 17 | } 18 | -------------------------------------------------------------------------------- /src/foundation/hooks/usePartialState.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react'; 2 | 3 | export const usePartialState = (initial: S) => useReducer((p: S, s: Partial) => ({ ...p, ...s }), initial); 4 | -------------------------------------------------------------------------------- /src/foundation/resolveSize.ts: -------------------------------------------------------------------------------- 1 | export const resolveSize = (props: { size: string | number }) => { 2 | const s = props.size; 3 | return typeof s === 'number' ? `${s}px` : s; 4 | }; 5 | -------------------------------------------------------------------------------- /src/foundation/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type SBUFoundationProps> = T & { 4 | className?: string; 5 | children?: ReactNode; 6 | testId?: string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/hooks/useAssignGlobalFunction.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | 3 | import { useConstantState } from '../context/ConstantContext'; 4 | import { useWidgetState } from '../context/WidgetStateContext'; 5 | import { clearWidgetSessionCache } from '../libs/storage/widgetSessionCache'; 6 | 7 | declare global { 8 | interface Window { 9 | sbWidget: { 10 | open: () => void; 11 | close: () => void; 12 | clearCache: () => void; 13 | }; 14 | } 15 | } 16 | 17 | /** 18 | * The useAssignGlobalFunction hook adds the sendbirdWidget object to the global window object. 19 | * The sendbirdWidget object contains open, close, and clearCache methods, 20 | * allowing control of the widget state and cache clearing from a non-React environment. 21 | */ 22 | export function useAssignGlobalFunction() { 23 | const { applicationId: appId, botId } = useConstantState(); 24 | const { setIsOpen } = useWidgetState(); 25 | 26 | useLayoutEffect(() => { 27 | window.sbWidget = { 28 | open: () => setIsOpen(true), 29 | close: () => setIsOpen(false), 30 | clearCache: () => clearWidgetSessionCache({ appId, botId }), 31 | }; 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/useAutoDismissMobileKeyboardHandler.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { isIOSMobile } from '../utils'; 4 | 5 | const INPUT_ELEMENT_SELECTOR = '.sendbird-message-input'; 6 | const SEND_BUTTON_SELECTOR = '.sendbird-message-input--send'; 7 | 8 | function useAutoDismissMobileKeyboardHandler(): void { 9 | const addedButtons = useRef([]); 10 | 11 | useEffect(() => { 12 | const handleDismissKeyboard = (): void => { 13 | setTimeout(() => { 14 | if (document.activeElement instanceof HTMLElement) { 15 | // blur the active element(send button) to dismiss the keyboard on mobile 16 | document.activeElement.blur(); 17 | } 18 | }, 200); 19 | }; 20 | 21 | const handleKeyDown = (event: KeyboardEvent): void => { 22 | if ( 23 | event.key === 'Enter' && 24 | // TODO: Pressing Enter key on Android keyboard does't trigger the sending message event 25 | // but carriage return event is fired instead which is a different behavior from UIKit React. 26 | // Need to find a way to handle this case. 27 | isIOSMobile 28 | ) { 29 | handleDismissKeyboard(); 30 | } 31 | }; 32 | 33 | const observerCallback = (mutations: MutationRecord[]): void => { 34 | mutations.forEach((mutation) => { 35 | if (mutation.type === 'childList') { 36 | mutation.addedNodes.forEach((node) => { 37 | if (node.nodeType === Node.ELEMENT_NODE && (node as Element).matches(SEND_BUTTON_SELECTOR)) { 38 | (node as HTMLElement).removeEventListener('click', handleDismissKeyboard); 39 | (node as HTMLElement).addEventListener('click', handleDismissKeyboard); 40 | // Store added node for later removal 41 | addedButtons.current.push(node as HTMLElement); 42 | } 43 | }); 44 | } 45 | }); 46 | }; 47 | 48 | const observerRef = new MutationObserver(observerCallback); 49 | const config = { childList: true, subtree: true }; 50 | 51 | const inputElement = document.querySelector(INPUT_ELEMENT_SELECTOR); 52 | if (inputElement) { 53 | observerRef.observe(inputElement, config); 54 | inputElement.removeEventListener('keydown', handleKeyDown); 55 | inputElement.addEventListener('keydown', handleKeyDown); 56 | } else { 57 | console.warn('Input element not found for mutation observer'); 58 | } 59 | 60 | return () => { 61 | observerRef.disconnect(); 62 | addedButtons.current.forEach((button) => button.removeEventListener('click', handleDismissKeyboard)); 63 | if (inputElement) { 64 | inputElement.removeEventListener('keydown', handleKeyDown); 65 | } 66 | }; 67 | }, []); 68 | } 69 | 70 | export default useAutoDismissMobileKeyboardHandler; 71 | -------------------------------------------------------------------------------- /src/hooks/useBlockWhileBotResponding.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@sendbird/chat'; 2 | import { BaseMessage } from '@sendbird/chat/message'; 3 | import { useEffect, useRef, useState } from 'react'; 4 | 5 | import { isSendableMessage } from '@uikit/utils'; 6 | 7 | import { useConstantState } from '../context/ConstantContext'; 8 | import { useWidgetSession } from '../context/WidgetSettingContext'; 9 | import { messageExtension } from '../utils/messageExtension'; 10 | import { isSentBy } from '../utils/messages'; 11 | 12 | interface UseDisableInputUntilReplyProps { 13 | lastMessage?: BaseMessage | null; 14 | botUser?: User; 15 | } 16 | 17 | /** 18 | * When current user sends a message, message input is disabled until bot reply is received and finished streaming. 19 | */ 20 | export const useBlockWhileBotResponding = ({ lastMessage, botUser }: UseDisableInputUntilReplyProps) => { 21 | const { userId: currentUserId } = useWidgetSession(); 22 | const { messageInputControls } = useConstantState(); 23 | const blockInputWhileBotResponding = messageInputControls?.blockWhileBotResponding; 24 | const timerRef = useRef | undefined>(undefined); 25 | const [isMessageInputDisabled, setIsMessageInputDisabled] = useState(false); 26 | 27 | const clearAndUnblock = () => { 28 | if (timerRef.current) { 29 | clearTimeout(timerRef.current); 30 | } 31 | setIsMessageInputDisabled(false); 32 | }; 33 | 34 | const setTimerAndBlock = () => { 35 | if (typeof blockInputWhileBotResponding === 'number') { 36 | timerRef.current = setTimeout(() => { 37 | setIsMessageInputDisabled(false); 38 | }, blockInputWhileBotResponding); 39 | } 40 | setIsMessageInputDisabled(true); 41 | }; 42 | 43 | useEffect(() => { 44 | if (!blockInputWhileBotResponding || !currentUserId || !botUser || !lastMessage) return; 45 | if (isSendableMessage(lastMessage) && isSentBy(lastMessage, currentUserId)) { 46 | if (lastMessage.sendingStatus === 'pending') { 47 | setTimerAndBlock(); 48 | } else if (lastMessage.sendingStatus === 'failed') { 49 | clearAndUnblock(); 50 | } 51 | } else if (isSentBy(lastMessage, botUser.userId)) { 52 | const isStreaming = messageExtension.isStreaming(lastMessage); 53 | if (!isStreaming) { 54 | clearAndUnblock(); 55 | } 56 | } 57 | }, [currentUserId, botUser?.userId, lastMessage, blockInputWhileBotResponding]); 58 | 59 | return isMessageInputDisabled; 60 | }; 61 | -------------------------------------------------------------------------------- /src/hooks/useMobileView.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useMemo } from 'react'; 2 | 3 | import { useConstantState } from '../context/ConstantContext'; 4 | import { useWidgetState } from '../context/WidgetStateContext'; 5 | 6 | export default function useMobileView() { 7 | const { isMobileView } = useConstantState(); 8 | const { isOpen: isWidgetOpen } = useWidgetState(); 9 | const [dimensions, setDimensions] = useState({ 10 | width: window.innerWidth, 11 | height: window.innerHeight, 12 | }); 13 | useEffect(() => { 14 | const handleResize = () => { 15 | setDimensions({ 16 | width: window.innerWidth, 17 | height: window.innerHeight, 18 | }); 19 | }; 20 | 21 | window.addEventListener('resize', handleResize); 22 | return () => { 23 | window.removeEventListener('resize', handleResize); 24 | }; 25 | }, []); 26 | 27 | // disable body scroll when MobileContainer is open 28 | useEffect(() => { 29 | const originalPosition = document.body.style.position; 30 | let originalTop = document.body.style.top; 31 | 32 | function setToOriginalPosition() { 33 | document.body.style.position = originalPosition; 34 | document.body.style.top = originalTop; 35 | 36 | if (originalPosition === 'fixed') { 37 | window.scrollTo(0, parseInt(originalTop || '0') * -1); 38 | } 39 | } 40 | if (isWidgetOpen && isMobileView) { 41 | originalTop = `${window.scrollY}px`; 42 | 43 | document.body.style.position = 'fixed'; 44 | document.body.style.top = `-${originalTop}`; 45 | document.body.style.width = '100%'; 46 | } else { 47 | setToOriginalPosition(); 48 | } 49 | return () => { 50 | setToOriginalPosition(); 51 | }; 52 | }, [isWidgetOpen, isMobileView]); 53 | 54 | return useMemo( 55 | () => ({ 56 | width: dimensions.width, 57 | height: dimensions.height, 58 | }), 59 | [], 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(value: any) { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }); 9 | 10 | return ref.current; // Return the previous value 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useResetHistoryOnConnected.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useChatContext } from '../components/chat/context/ChatProvider'; 4 | import { useConstantState } from '../context/ConstantContext'; 5 | 6 | export function useResetHistoryOnConnected() { 7 | const { enableResetHistoryOnConnect } = useConstantState(); 8 | const { sdk, channel, dataSource } = useChatContext(); 9 | 10 | useEffect(() => { 11 | if (enableResetHistoryOnConnect && channel && sdk && dataSource.initialized) { 12 | (async () => { 13 | await Promise.allSettled([sdk.clearCachedMessages([channel.url]), channel.resetMyHistory()]); 14 | await dataSource.refresh(); 15 | })(); 16 | } 17 | }, [enableResetHistoryOnConnect, channel?.url, sdk, dataSource.initialized]); 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useStyledComponentsTarget.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from 'react'; 2 | import { version } from 'styled-components/package.json'; 3 | 4 | const StyledId = 'sendbird-css-inject-id'; 5 | 6 | function isSCTarget(node: Node): node is HTMLStyleElement { 7 | return node instanceof HTMLStyleElement && node.getAttribute('data-styled-version') === version; 8 | } 9 | 10 | /** 11 | * This hook observes mutations in the document's head 12 | * When styled-components, which has already been initialized, is re-added to the head, for example `document.head.innerHTML += ''`, the styles may not render correctly. 13 | * Therefore, the target is moved to the body tag. 14 | * Similarly, the issue could also rise in below cases and the hook handles them accordingly: 15 | * - If styles are removed from , re-add to head if head exists or switch to . 16 | * This is a short-term solution, and in the long run, we plan to remove styled-components altogether. 17 | * */ 18 | export function useStyledComponentsTarget() { 19 | const [target, setTarget] = useState(document.head); 20 | 21 | useLayoutEffect(() => { 22 | const handleRemovedStyle = (styleElement: HTMLElement) => { 23 | if (styleElement && styleElement.parentElement !== document.body) { 24 | if (document.head) { 25 | console.warn('[useStyledComponentsTarget]: Head exists, re-adding style element ${StyledId} to .'); 26 | document.head.appendChild(styleElement); 27 | setTarget(document.head); 28 | } else { 29 | console.warn('[useStyledComponentsTarget]: Head missing, moving style element ${StyledId} to .'); 30 | document.body.appendChild(styleElement); 31 | setTarget(document.body); 32 | } 33 | } 34 | }; 35 | 36 | const observer = new MutationObserver((mutations) => { 37 | mutations.forEach((mutation) => { 38 | // Handle added nodes 39 | Array.from(mutation.addedNodes).forEach((node) => { 40 | if (isSCTarget(node)) { 41 | console.warn('[useStyledComponentsTarget]: Styled Components styles re-injected, switching to '); 42 | setTarget(document.body); 43 | } 44 | }); 45 | 46 | // Handle removed nodes 47 | Array.from(mutation.removedNodes).forEach((node) => { 48 | if (isSCTarget(node)) { 49 | console.warn('[useStyledComponentsTarget]: Styled Components styles removed, switching to '); 50 | setTarget(document.body); 51 | } else if (node instanceof HTMLElement && node.id === StyledId) { 52 | handleRemovedStyle(node); 53 | } 54 | }); 55 | }); 56 | }); 57 | 58 | observer.observe(document.head, { childList: true }); 59 | 60 | return () => observer.disconnect(); 61 | }, []); 62 | 63 | return target; 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useThrottle.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export function useThrottle any>(func: T, delay: number): T { 4 | const timeoutRef = useRef(null); 5 | const functionArgsRef = useRef([]); 6 | 7 | useEffect(() => { 8 | if (timeoutRef.current) { 9 | clearTimeout(timeoutRef.current); 10 | } 11 | 12 | timeoutRef.current = setTimeout(() => { 13 | if (functionArgsRef.current.length > 0) { 14 | func(...functionArgsRef.current); 15 | functionArgsRef.current = []; 16 | timeoutRef.current = null; 17 | } 18 | }, delay); 19 | 20 | return () => { 21 | if (timeoutRef.current) { 22 | clearTimeout(timeoutRef.current); 23 | timeoutRef.current = null; 24 | } 25 | }; 26 | }, [func, delay]); 27 | 28 | return ((...args: any[]) => { 29 | functionArgsRef.current = args; 30 | }) as T; 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useWidgetAutoOpen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { useConstantState } from '../context/ConstantContext'; 4 | import { useWidgetSetting } from '../context/WidgetSettingContext'; 5 | import { useWidgetState } from '../context/WidgetStateContext'; 6 | 7 | export function useWidgetAutoOpen() { 8 | const { isMobileView } = useConstantState(); 9 | const { isOpen, setIsOpen } = useWidgetState(); 10 | const { botStyle } = useWidgetSetting(); 11 | 12 | const timer = useRef>(); 13 | 14 | if (isOpen && timer.current) { 15 | clearTimeout(timer.current); 16 | timer.current = undefined; 17 | } 18 | 19 | useEffect(() => { 20 | if (botStyle.autoOpen) { 21 | timer.current = setTimeout(() => { 22 | if (!isMobileView) setIsOpen(true); 23 | }, 100); 24 | } 25 | return () => { 26 | if (timer.current) { 27 | clearTimeout(timer.current); 28 | timer.current = undefined; 29 | } 30 | }; 31 | }, [botStyle.autoOpen]); 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useWidgetInactivityTimeout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import useSendbirdStateContext from '@uikit/hooks/useSendbirdStateContext'; 4 | 5 | import { useWidgetState } from '../context/WidgetStateContext'; 6 | 7 | const WS_IDLE_TIMEOUT = 1000 * 60; 8 | 9 | /** 10 | * This hook disconnects the websocket connection 11 | * when the widget has not been opened for a certain amount of time. 12 | */ 13 | export function useWidgetInactivityTimeout(fullscreen: boolean) { 14 | const { isOpen } = useWidgetState(); 15 | const store = useSendbirdStateContext(); 16 | const disconnectTimeout = useRef | null>(null); 17 | const { sdk, initialized } = store.stores.sdkStore; 18 | 19 | useEffect(() => { 20 | if (fullscreen || !sdk || !initialized) return; 21 | 22 | if (isOpen) { 23 | if (sdk.connectionState === 'CLOSED') { 24 | sdk.reconnect(); 25 | } 26 | 27 | if (disconnectTimeout.current) { 28 | clearTimeout(disconnectTimeout.current); 29 | disconnectTimeout.current = null; 30 | } 31 | } else { 32 | disconnectTimeout.current = setTimeout(() => { 33 | sdk.disconnectWebSocket(); 34 | }, WS_IDLE_TIMEOUT); 35 | } 36 | }, [fullscreen, sdk, initialized, isOpen]); 37 | } 38 | -------------------------------------------------------------------------------- /src/icons/ic-bot-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/ic-bot-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/icons/ic-chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/ic-chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/icons/ic-chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/icons/ic-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/ic-collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/ic-ellipsis.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/icons/ic-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/icons/ic-expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/ic-message.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/icons/ic-open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icons/ic-refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/ic-spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/sendbird-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ChatAiWidget } from './components/widget/ChatAiWidget'; 2 | export { type ProviderContainerProps as ChatAiWidgetConfigs } from './components/widget/ProviderContainer'; 3 | export { default as ChatWindow } from './components/widget/WidgetWindowFullScreen'; 4 | export { widgetServiceName } from './const'; 5 | export { clearWidgetSessionCache, clearCache } from './libs/storage/widgetSessionCache'; 6 | 7 | export { WidgetButton } from './components/ui/WidgetButton'; 8 | 9 | export type * from './types'; 10 | -------------------------------------------------------------------------------- /src/libs/storage/widgetSessionCache.ts: -------------------------------------------------------------------------------- 1 | import { localStorageHelper } from '../../utils'; 2 | 3 | const WIDGET_SESSION_PREFIX = '@sendbird/chat-ai-widget'; 4 | const getKey = (appId: string, botId: string) => { 5 | return `${WIDGET_SESSION_PREFIX}/${appId}/${botId}`; 6 | }; 7 | 8 | export type WidgetSessionCache = { 9 | strategy: 'auto' | 'manual'; 10 | userId: string; 11 | channelUrl: string; 12 | expireAt: number; 13 | sessionToken?: string; 14 | }; 15 | 16 | export function getWidgetSessionCache(params: { appId: string; botId: string }): WidgetSessionCache | null { 17 | const key = getKey(params.appId, params.botId); 18 | const value = localStorageHelper().getItem(key); 19 | try { 20 | if (value) { 21 | // For cache of users before the update, there is no 'strategy'. 22 | // Therefore, 'auto' is set as the default value. 23 | return { strategy: 'auto', ...JSON.parse(value) }; 24 | } 25 | return null; 26 | } catch { 27 | return null; 28 | } 29 | } 30 | 31 | export function saveWidgetSessionCache(params: { appId: string; botId: string; data: WidgetSessionCache }) { 32 | const key = getKey(params.appId, params.botId); 33 | const value = JSON.stringify(params.data); 34 | localStorageHelper().setItem(key, value); 35 | } 36 | 37 | /** 38 | * Call this function if the bot has been deleted. 39 | * Otherwise, users may join channels where the bot does not exist. 40 | * */ 41 | export function clearWidgetSessionCache(params: { appId: string; botId: string }) { 42 | const localStorageKey = getKey(params.appId, params.botId); 43 | localStorageHelper().deleteItem(localStorageKey); 44 | } 45 | 46 | /** 47 | * @deprecated Use `clearWidgetSessionCache` instead. 48 | * */ 49 | export const clearCache = clearWidgetSessionCache; 50 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import App from './App'; 5 | 6 | const WidgetApp = () => { 7 | const urlParams = new URLSearchParams(window.location.search); 8 | const appId = urlParams.get('app_id') ?? import.meta.env.VITE_CHAT_WIDGET_APP_ID; 9 | const botId = urlParams.get('bot_id') ?? import.meta.env.VITE_CHAT_WIDGET_BOT_ID; 10 | const isSnapshot = urlParams.get('snapshot') === 'true'; 11 | 12 | const locale = urlParams.get('locale') ?? undefined; 13 | const region = urlParams.get('region') ?? undefined; 14 | 15 | function getHost(region?: string) { 16 | if (region && region.startsWith('no')) { 17 | return { apiHost: `https://api-${region}.sendbirdtest.com`, wsHost: `wss://ws-${region}.sendbirdtest.com` }; 18 | } 19 | return { 20 | apiHost: region ? `https://api-cf-${region}.sendbird.com` : undefined, 21 | wsHost: undefined, 22 | }; 23 | } 24 | 25 | if (!appId || !botId) { 26 | return null; 27 | } 28 | 29 | const host = getHost(region); 30 | return ( 31 | 47 | ); 48 | }; 49 | 50 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 51 | 52 | 53 | , 54 | ); 55 | -------------------------------------------------------------------------------- /src/styled-components.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | import { type CommonTheme } from './theme'; 3 | declare module 'styled-components' { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 5 | export interface DefaultTheme extends CommonTheme {} 6 | } 7 | -------------------------------------------------------------------------------- /src/tools/hooks/useDragDropFiles.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, DragEvent, useContext, useEffect, useRef } from 'react'; 2 | 3 | import { noop } from '../../utils'; 4 | 5 | type Unsubscribe = () => void; 6 | 7 | interface DragDropContextProps { 8 | onDrop: (e: DragEvent) => void; 9 | subscribe: (fn: (files: File[]) => void) => Unsubscribe; 10 | } 11 | const DragDropContext = createContext({ 12 | onDrop: noop, 13 | subscribe: () => noop, 14 | }); 15 | 16 | interface DragDropContextProviderProps { 17 | children?: ReactNode; 18 | } 19 | export const DragDropProvider = ({ children }: DragDropContextProviderProps) => { 20 | const subscribers = useRef(new Set<(files: File[]) => void>()); 21 | 22 | return ( 23 | { 26 | e.preventDefault(); 27 | if (e.dataTransfer?.files) { 28 | const files = Array.from(e.dataTransfer.files); 29 | if (files.length > 0) { 30 | subscribers.current.forEach((fn) => fn(files)); 31 | } 32 | } 33 | }, 34 | subscribe: (fn: (files: File[]) => void) => { 35 | subscribers.current.add(fn); 36 | 37 | return () => subscribers.current.delete(fn); 38 | }, 39 | }} 40 | > 41 | {children} 42 | 43 | ); 44 | }; 45 | 46 | type UseDragDropFiles = { 47 | onDropFiles: (files: File[]) => void; 48 | }; 49 | export const useDragDropFiles = ({ onDropFiles }: UseDragDropFiles) => { 50 | const { subscribe } = useContext(DragDropContext); 51 | useEffect(() => { 52 | return subscribe(onDropFiles); 53 | }, [onDropFiles]); 54 | }; 55 | export const useDragDropArea = () => { 56 | const { onDrop } = useContext(DragDropContext); 57 | return { onDrop, onDragOver: (e: DragEvent) => e.preventDefault() }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SendbirdChatAICallbacks { 2 | onViewDetailClick?: (data: FunctionCallData) => void; 3 | /** 4 | * @private Callback to be called when the widget expand state changes. 5 | */ 6 | onWidgetExpandStateChange?: (isExpanded: boolean) => void; 7 | onWidgetSettingFailure?: (error: Error) => void; 8 | } 9 | 10 | export interface FunctionCallRequest { 11 | headers: object; 12 | method: string; 13 | query_params: object; 14 | request_body: object; 15 | url: string; 16 | } 17 | 18 | export interface FunctionCallData { 19 | name: string; 20 | request: FunctionCallRequest; 21 | response_text: string; 22 | status_code: number; 23 | } 24 | 25 | export interface WidgetCarouselItem { 26 | title: string; 27 | url: string; 28 | featured_image: string; 29 | } 30 | 31 | export interface FunctionCallAdapterParams { 32 | name: string; 33 | request: FunctionCallRequest; 34 | response: unknown; 35 | } 36 | 37 | export interface FunctionCallAdapter { 38 | (params: FunctionCallAdapterParams): T; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/getImageAspectRatio.ts: -------------------------------------------------------------------------------- 1 | import { UserMessageCreateParams, FileMessageCreateParams, MessageMetaArray } from '@sendbird/chat/message'; 2 | 3 | export function getImageAspectRatio(file: File) { 4 | return new Promise((resolve) => { 5 | const img = new Image(); 6 | 7 | img.onload = function () { 8 | const width = img.width; 9 | const height = img.height; 10 | 11 | URL.revokeObjectURL(img.src); 12 | resolve(width / height); 13 | }; 14 | 15 | img.src = URL.createObjectURL(file); 16 | }); 17 | } 18 | 19 | export async function getImageAspectRatioMetaArray(params: FileMessageCreateParams | UserMessageCreateParams) { 20 | if ('file' in params && params.file instanceof File && params.file.type.startsWith('image/')) { 21 | const ratio = await getImageAspectRatio(params.file); 22 | return new MessageMetaArray({ 23 | key: META_ARRAY_ASPECT_RATIO_KEY, 24 | value: [`${ratio}`], 25 | }); 26 | } 27 | } 28 | 29 | export const META_ARRAY_ASPECT_RATIO_KEY = 'KEY_IMG_ASPECT_RATIO'; 30 | -------------------------------------------------------------------------------- /src/utils/messageExtension.ts: -------------------------------------------------------------------------------- 1 | import { BaseMessage } from '@sendbird/chat/message'; 2 | 3 | import { extractUrls } from './index'; 4 | import { jsonParseSafely } from './messages'; 5 | import { FunctionCallAdapterParams, FunctionCallData, WidgetCarouselItem } from '../types'; 6 | 7 | export const messageExtension = { 8 | isStreaming(message: BaseMessage) { 9 | const data = jsonParseSafely(message.data); 10 | if (typeof data === 'object') { 11 | return Boolean(data['stream']); 12 | } else { 13 | return false; 14 | } 15 | }, 16 | isBotWelcomeMsg(message: BaseMessage, botId: string | null) { 17 | if ((message.isUserMessage() || message.isFileMessage()) && message.sender.userId === botId) { 18 | const data = jsonParseSafely(message.data); 19 | // Note: respond_mesg_id and stream is only set when the bot message is a response to a user message. 20 | return !data?.respond_mesg_id && !data?.stream; 21 | } 22 | 23 | return false; 24 | }, 25 | isInputDisabled(message: BaseMessage | null) { 26 | return !!message?.extendedMessagePayload?.disable_chat_input; 27 | }, 28 | commerceShopItems: { 29 | isValid(message: BaseMessage): boolean { 30 | return ((message.extendedMessagePayload?.commerce_shop_items ?? []) as unknown[]).length > 0; 31 | }, 32 | getItems(message: BaseMessage): WidgetCarouselItem[] { 33 | return (message.extendedMessagePayload?.commerce_shop_items ?? []) as WidgetCarouselItem[]; 34 | }, 35 | getValidItems(message: BaseMessage): WidgetCarouselItem[] { 36 | if (!message.isUserMessage()) return []; 37 | const urls = extractUrls(message.message); 38 | return this.getItems(message) 39 | .filter((it) => urls.includes(it.url)) 40 | .sort((a, b) => urls.indexOf(a.url) - urls.indexOf(b.url)); 41 | }, 42 | }, 43 | functionCalls: { 44 | parse(message: BaseMessage) { 45 | const data = jsonParseSafely(message.data); 46 | return data.function_calls ?? []; 47 | }, 48 | isFunctionCall(obj: unknown): obj is FunctionCallData { 49 | return !!obj && typeof obj === 'object' && 'name' in obj && 'request' in obj && 'response_text' in obj; 50 | }, 51 | getAdapterParams(message: BaseMessage): FunctionCallAdapterParams[] { 52 | const functionCalls = this.parse(message); 53 | return functionCalls.filter(this.isFunctionCall).map((fn: FunctionCallData) => ({ 54 | name: fn.name, 55 | request: fn.request, 56 | response: jsonParseSafely(fn.response_text), 57 | })); 58 | }, 59 | }, 60 | }; 61 | 62 | // const mock = [ 63 | // { 64 | // title: 'Peanut Butter Spread Simply Crunchy, 16oz (454g)', 65 | // url: 'https://www.sendbird.com/ai-widget', 66 | // featured_image:'https://source.unsplash.com/random', 67 | // }, 68 | // { 69 | // title: 'Pure Avocado Oil, 16.9 fl oz (320 ml)', 70 | // url: 'https://www.sendbird.com', 71 | // featured_image: 'https://source.unsplash.com/random', 72 | // }, 73 | // { 74 | // title: 'Organic Dried Cranberries, With Organic Apple Juice, 5 oz (142 g)', 75 | // url: 'https://www.sendbird.com', 76 | // featured_image: 'https://source.unsplash.com/random', 77 | // }, 78 | // { 79 | // title: 80 | // 'Long title random product with a lot of text to test the overflow and ellipsis', 81 | // url: 'https://www.sendbird.com', 82 | // featured_image: 'https://source.unsplash.com/random', 83 | // }, 84 | // ]; 85 | -------------------------------------------------------------------------------- /src/utils/messageTimestamp.ts: -------------------------------------------------------------------------------- 1 | import { format, Locale } from 'date-fns'; 2 | import { enUS } from 'date-fns/locale'; 3 | 4 | /** 5 | * createdAt is in UTC. Convert to local time using toLocaleString. 6 | * Note that returned time is computed based on the running app's region but not the injected locale value. 7 | * So result varies depending on the location of the running app. 8 | */ 9 | export function formatCreatedAtToAMPM(createdAt: number, formatString = 'p', locale: Locale = enUS) { 10 | const time = format(createdAt || 0, formatString, { locale }); 11 | return time; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/messages.ts: -------------------------------------------------------------------------------- 1 | import { BaseMessage } from '@sendbird/chat/message'; 2 | import { isSameMinute } from 'date-fns'; 3 | 4 | import { messageExtension } from './messageExtension'; 5 | 6 | export const getMessageGrouping = ( 7 | curr: BaseMessage, 8 | prev?: BaseMessage, 9 | next?: BaseMessage, 10 | enableMessageGrouping = true, 11 | ): [boolean, boolean] => { 12 | if (!enableMessageGrouping) { 13 | return [true, true]; 14 | } 15 | if (!curr.isUserMessage() && !curr.isFileMessage()) { 16 | return [false, false]; 17 | } 18 | 19 | const getTop = () => { 20 | if (!prev || (!prev.isUserMessage() && !prev.isFileMessage())) return true; 21 | const isSameSender = prev.sender.userId === curr.sender.userId; 22 | const isSameGroup = isSameMinute(prev.createdAt, curr.createdAt); 23 | return !isSameSender || !isSameGroup; 24 | }; 25 | 26 | const getBottom = () => { 27 | if (!next || (!next.isUserMessage() && !next.isFileMessage())) return true; 28 | const isSameSender = next.sender.userId === curr.sender.userId; 29 | const isSameGroup = isSameMinute(next.createdAt, curr.createdAt); 30 | return !isSameSender || !isSameGroup; 31 | }; 32 | 33 | return [getTop(), getBottom()]; 34 | }; 35 | 36 | export function getBotWelcomeMessages(messages: BaseMessage[], botUserId: string | null) { 37 | return messages.filter((it) => messageExtension.isBotWelcomeMsg(it, botUserId)); 38 | } 39 | 40 | export function isSentBy(message: BaseMessage, userId?: string | null) { 41 | return getSenderUserIdFromMessage(message) === userId; 42 | } 43 | 44 | export function jsonParseSafely(messageData: string) { 45 | try { 46 | return JSON.parse(messageData === '' ? '{}' : messageData); 47 | } catch (error) { 48 | return {}; 49 | } 50 | } 51 | 52 | export function getSenderUserIdFromMessage(message?: BaseMessage | null): string | undefined { 53 | if (!message) return undefined; 54 | 55 | if (message.isUserMessage()) return message.sender.userId; 56 | if (message.isFileMessage()) return message.sender.userId; 57 | if (message.isMultipleFilesMessage()) return message.sender.userId; 58 | 59 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 60 | // @ts-ignore 61 | return message?.sender?.userId ?? undefined; 62 | } 63 | 64 | export function shouldFilterOutMessage(message: BaseMessage) { 65 | if (message.isAdminMessage()) { 66 | return message.message === "The channel's custom_type was updated."; 67 | } 68 | return false; 69 | } 70 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "outDir": "dist", 9 | "declaration": true, 10 | 11 | /* Node.js module resolution */ 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": false, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "allowSyntheticDefaultImports": true, 24 | "paths": { 25 | "@uikit/*": ["./packages/uikit/src/*"], 26 | } 27 | }, 28 | "include": [ 29 | "src", 30 | "./src/custom.d.ts", 31 | "./src/styled-components.d.ts", 32 | "__visual_tests__", 33 | ], 34 | "exclude": ["node_modules"], 35 | "references": [{ "path": "./tsconfig.node.json" }] 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.pages.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import react from '@vitejs/plugin-react'; 4 | import wyw from '@wyw-in-js/vite'; 5 | import { defineConfig } from 'vite'; 6 | import svgr from 'vite-plugin-svgr'; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | wyw({ 12 | include: ['**/*.{ts,tsx}'], 13 | babelOptions: { 14 | presets: ['@babel/preset-typescript', '@babel/preset-react', '@wyw-in-js/babel-preset'], 15 | }, 16 | }), 17 | svgr({ 18 | include: '**/*.svg', 19 | svgrOptions: { 20 | exportType: 'default', 21 | }, 22 | }), 23 | ], 24 | // to point to correct path for gh-pages 25 | base: '/chat-ai-widget', 26 | resolve: { 27 | alias: [ 28 | { 29 | find: '@uikit', 30 | replacement: path.resolve(__dirname, 'packages/uikit/src'), 31 | }, 32 | ], 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import * as path from 'node:path'; 3 | 4 | import react from '@vitejs/plugin-react'; 5 | import wyw from '@wyw-in-js/vite'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import { visualizer } from 'rollup-plugin-visualizer'; 8 | import { defineConfig } from 'vite'; 9 | import dts from 'vite-plugin-dts'; 10 | import svgr from 'vite-plugin-svgr'; 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | server: { 15 | fs: { 16 | strict: false, 17 | }, 18 | }, 19 | plugins: [ 20 | react(), 21 | wyw({ 22 | include: ['**/*.{ts,tsx}'], 23 | babelOptions: { 24 | presets: ['@babel/preset-typescript', '@babel/preset-react', '@wyw-in-js/babel-preset'], 25 | }, 26 | }), 27 | svgr({ 28 | include: '**/*.svg', 29 | svgrOptions: { 30 | exportType: 'default', 31 | }, 32 | }), 33 | dts(), 34 | visualizer({ 35 | filename: './dist/report.html', 36 | brotliSize: true, 37 | }), 38 | ], 39 | resolve: { 40 | alias: [ 41 | { 42 | find: '@uikit', 43 | replacement: path.resolve(__dirname, 'packages/uikit/src'), 44 | }, 45 | ], 46 | }, 47 | // to point to correct path for gh-pages 48 | base: '/chat-ai-widget', 49 | build: { 50 | lib: { 51 | entry: resolve('src', 'index.ts'), 52 | name: 'ChatAiWidget', 53 | formats: ['es', 'umd'], 54 | fileName: (format) => `index.${format}.js`, 55 | cssFileName: 'style', 56 | }, 57 | rollupOptions: { 58 | plugins: [terser()], 59 | external: ['react', 'react-dom'], 60 | output: { 61 | globals: { 62 | react: 'React', 63 | 'react-dom': 'ReactDOM', 64 | }, 65 | }, 66 | }, 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'jsdom', // Use jsdom environment for browser-like testing 8 | include: ['src/**/*.test.ts'], // Specify the test files pattern 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /wyw-in-js.config.cjs: -------------------------------------------------------------------------------- 1 | // const prefixes = ['@raw-', '@use-']; 2 | // const duplicates = {}; 3 | 4 | module.exports = { 5 | classNameSlug: (hash, title) => { 6 | return hash; 7 | // let shouldCheckDuplicates = false; 8 | // let className = hash; 9 | // 10 | // for (let i = 0; i < prefixes.length; i++) { 11 | // if (title.startsWith(prefixes[i])) { 12 | // className = title.replace(prefixes[i], prefixes[i] === '@label-' ? 'sendbird-label--color-' : ''); 13 | // shouldCheckDuplicates = true; 14 | // break; 15 | // } 16 | // } 17 | // 18 | // if (shouldCheckDuplicates) { 19 | // if (duplicates[className]) { 20 | // // print error message in shell with bold text.. 21 | // console.log(`\n\x1b[1m\x1b[33m[ERROR] Duplicate className: ${className} for ${title}\x1b[0m\n`); 22 | // throw new Error(`Duplicate className: ${className}`); 23 | // } else { 24 | // duplicates[className] = true; 25 | // } 26 | // } 27 | // 28 | // return className; 29 | }, 30 | }; 31 | --------------------------------------------------------------------------------