diff --git a/README.md b/README.md index 75cf50a..5cda3fc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # conversations-web-chat-plugin -This an embeddable chat widget for Twilio Conversations. It uses the Paste design system for the UI and the Conversations SDK for chat. When a new user open the chat, it authenticates them anonymously, creates a conversation and adds them to it. To keep the context for returning users, it stores the user's `uuid` and `conversation_sid` in local storage. +This an embeddable chat widget for Twilio Conversations. It uses the [Paste](https://paste.twilio.design/) design system for the UI and the [Conversations](https://www.twilio.com/en-us/messaging/apis/conversations-api) JS SDK for chat. When a new user opens the chat, it authenticates them anonymously, creates a conversation and adds them to it. To keep the context for returning users, it stores the user's `uuid` and `conversation_sid` in local storage. ## Authentication -The Conversations SDK requires an access token signed with a Twilio API Key Secret in order to initialize and connect to Conversations. This token should created in a backend service to keep the secret secure. We do this with a [Twilio Function](https://www.twilio.com/docs/serverless/functions-assets/functions): +The Conversations SDK requires an valid access token created with a Twilio API Key Secret in order to initialize and connect to Conversations. This token should created in a backend service to keep the secret secure. We do this with a [Twilio Function](https://www.twilio.com/docs/serverless/functions-assets/functions): ```javascript const headers = { @@ -46,9 +46,11 @@ exports.handler = function(context, event, callback) { Conversations SDK token require a unique user `identity`. Since chat widget users are generally anonymous, this widget generates a `uuid` for each user and sends that as the `identity`. +Create a `.env` file and set `REACT_APP_TOKEN_SERVICE_URL=https://your-endpoint-example.com/chat-widget`. + ## Chatbots -You can optionally connect chatbot to talk to users. Conversations Webhooks are the best way to power this type of automation. Optionally, you can also connect to Twilio Studio Flows for drag and drop chatbot workflows. +You can optionally connect chatbot to talk to users. Conversations Webhooks are the best way to power this type of automation. Optionally, you can also connect to Twilio Studio Flows for drag and drop workflows. 1. Create a Global or Service Scoped Webhook that triggers `onConversationAdded` events. Set the target to a Twilio Funcion (or your own backend service) for the next step. @@ -79,4 +81,4 @@ exports.handler = function(context, event, callback) { }; ``` -3. Respond to incoming messages via the Converations API or a Studio Flow. If using the API, the [onMessageAdded event](https://www.twilio.com/docs/conversations/conversations-webhooks#onmessageadded) will have all the context you need to respond. For Studio, the "Incoming Conversation" trigger is a great place to start. \ No newline at end of file +3. Respond to incoming messages via the Converations API or a Studio Flow. If using the API, the [onMessageAdded event](https://www.twilio.com/docs/conversations/conversations-webhooks#onmessageadded) will have all the context you need to respond. For Studio, use the "Incoming Conversation" trigger and the the Send and Wait for Reply widgets to start. diff --git a/package.json b/package.json index 33f45a9..3f564cc 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/uuid": "^9.0.0", "babel-loader": "^9.1.2", "css-loader": "^6.7.3", + "dotenv": "^16.3.1", "file-loader": "^6.2.0", "react-refresh": "^0.14.0", "resolve-url-loader": "^5.0.0", diff --git a/src/App.tsx b/src/App.tsx index 111f22e..6fc8d37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,22 +5,11 @@ import { SkeletonLoader } from "@twilio-paste/skeleton-loader"; import { ChatDialog } from "./components/ChatDialog"; import { CustomerChatLog } from "./components/CustomerChatLog"; -import { useConversations } from './components/hooks'; - - - export const App = () => { - const conversation = useConversations() return ( - {conversation ? ( - - ) : ( - - - - )} + ); diff --git a/src/components/ChatDialog.tsx b/src/components/ChatDialog.tsx index 6155df7..f5aa878 100644 --- a/src/components/ChatDialog.tsx +++ b/src/components/ChatDialog.tsx @@ -7,24 +7,23 @@ import { MinimizableDialogContent, useMinimizableDialogState } from "@twilio-paste/minimizable-dialog"; -import { HEADER_TEXT } from "../config"; +import { HEADER_TEXT, DEFAULT_OPEN } from "../config"; import { ChatIcon } from "@twilio-paste/icons/esm/ChatIcon"; export const ChatDialog: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const dialog = useMinimizableDialogState({ visible: true }); + const dialog = useMinimizableDialogState({ visible: DEFAULT_OPEN }); return ( - {dialog.visible ? null : ( + {dialog.visible ? + {HEADER_TEXT} + {children} + : ( )} - - {HEADER_TEXT} - {children} - ); }; diff --git a/src/components/CustomerChatLog.tsx b/src/components/CustomerChatLog.tsx index dc72a7d..f57dd0f 100644 --- a/src/components/CustomerChatLog.tsx +++ b/src/components/CustomerChatLog.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { ChatLogger } from "@twilio-paste/chat-log"; import { ChatComposer } from "@twilio-paste/chat-composer"; import { Box } from "@twilio-paste/box"; +import { SkeletonLoader } from "@twilio-paste/skeleton-loader"; import { $getRoot, ClearEditorPlugin, @@ -13,9 +14,7 @@ import { } from "@twilio-paste/lexical-library"; import { SendButtonPlugin } from "./SendButtonPlugin"; -import { Conversation} from "@twilio/conversations"; -import { useMessages } from "./hooks"; - +import { useMessages, useConversations } from "./hooks"; const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => { const [editor] = useLexicalComposerContext(); @@ -40,12 +39,13 @@ const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => }; -export const CustomerChatLog: React.FC<{ conversation: Conversation }> = ({ conversation }) => { +export const CustomerChatLog: React.FC = () => { const [message, setMessage] = React.useState(""); const [mounted, setMounted] = React.useState(false); const loggerRef = React.useRef(null); const scrollerRef = React.useRef(null); + const conversation = useConversations(); const chats = useMessages(conversation); useEffect(() => { @@ -65,6 +65,13 @@ export const CustomerChatLog: React.FC<{ conversation: Conversation }> = ({ conv }); }; + if (!conversation) { + return ( + + + + );} + return ( diff --git a/src/components/hooks.tsx b/src/components/hooks.tsx index 749c87e..eebc384 100644 --- a/src/components/hooks.tsx +++ b/src/components/hooks.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import axios from "axios"; import { v4 as uuid } from "uuid"; import { Client, Conversation, Message } from "@twilio/conversations"; @@ -10,48 +10,60 @@ import React from "react"; export const useConversations = () => { const [conversation, setConversation] = useState() const [client, setClient] = useState() + const tokenUrl = process.env.REACT_APP_TOKEN_SERVICE_URL let connected = false - const tokenUrl = '' useEffect(() => { const initialize = async () => { - //use uuid as identity for the token - let token = (await axios.get(tokenUrl + '?identity=' + uid, {})).data - let client = new Client(token) - setClient(client) + try { + + // Get or create and set the user's uuid + const uid = localStorage.getItem('webchat-uuid') || uuid(); + localStorage.setItem('webchat-uuid', uid) - } - //retrieving unique id per browser - //this functions as a pseudoanonymous identifier for the human on the other end of the webchat - //create if it doesn't exist - let uid = localStorage.getItem('webchat-uuid') - if (!(uid)) { - uid = uuid() - localStorage.setItem('webchat-uuid', uid) + // Use the uuid as the user's identity for the token + const response = await axios.get(`${tokenUrl}?identity=${uid}`) + const token = response.data + const newClient = new Client(token) + setClient(newClient) + } catch (error) { + console.error('Error initializing client:', error); + } } initialize(); }, []) - // Set the conversations if there is one, otherwise creates a new conversation. - client?.on('connectionStateChanged', (state) => { - if (state == 'connected' && (!(connected))) { - const getConversation = async () => { - let sid = localStorage.getItem('webchat-sid') - let conversation - if (!(sid)) { - conversation = await client.createConversation() - await conversation.join() - localStorage.setItem('webchat-sid', conversation.sid) - } else { - conversation = await client.getConversationBySid(sid) - } - setConversation(conversation) + + useEffect(() => { + if (!client) return; + + const handleConnectionStateChanged = async (state: string) => { + try { + if (state == 'connected' && !connected) { + let sid = localStorage.getItem('webchat-sid') + let conversation + if (!(sid)) { + conversation = await client.createConversation() + await conversation.join() + localStorage.setItem('webchat-sid', conversation.sid) + } else { + conversation = await client.getConversationBySid(sid) + } + setConversation(conversation) + } + connected = true; + } catch (error) { + console.error('Error handling connection state change:', error); } - connected = true - getConversation() } - }); + + client.on('connectionStateChanged', handleConnectionStateChanged); + + return () => { + client.off('connectionStateChanged', handleConnectionStateChanged); + } + }, [client]); return conversation; } @@ -72,24 +84,23 @@ const chatBuilder = (message: Message): PartialIDChat => { } } -export const useMessages = (conversation: Conversation) => { +export const useMessages = (conversation: Conversation | undefined) => { const { chats, push } = useChatLogger(); const getDateTime = () => { - const storedDate = localStorage.getItem('chatStartDate'); - if (storedDate) { - return format(new Date(storedDate), CHAT_START_DATE_FORMAT); - } else { - const now = new Date(); - localStorage.setItem('chatStartDate', now.toString()); - return format(now, CHAT_START_DATE_FORMAT); + let storedDate = localStorage.getItem('chatStartDate'); + if (!storedDate) { + storedDate = new Date().toString(); + localStorage.setItem('chatStartDate', storedDate); } - }; + return format(new Date(storedDate), CHAT_START_DATE_FORMAT); + }; - const dateTime = getDateTime(); + const dateTime = useMemo(() => getDateTime(), []); useEffect(() => { - + if (!conversation) return; + push({ content: ( @@ -101,25 +112,37 @@ export const useMessages = (conversation: Conversation) => { }); const getHistory = async () => { - let paginator = await conversation.getMessages(undefined, undefined, 'forward') - let more: boolean - let history: Message[] = [] - do { - paginator.items.forEach(message => { - history.push(message) - }) - more = paginator.hasNextPage - if (more) { paginator.nextPage() } - } while (more) - history.forEach(message => { - push(chatBuilder(message)) - }) + try { + let paginator = await conversation.getMessages(undefined, undefined, 'forward') + let history: Message[] = [] + do { + history = [ ...history, ...paginator.items] + + if (paginator.hasNextPage) { + paginator = await paginator.nextPage() + } + } while (paginator.hasNextPage) + + history.forEach(message => { + push(chatBuilder(message)) + }) + } catch (error) { + console.error("Error fetching message history:", error); + } } getHistory(); - conversation.on('messageAdded', message => { + + const messageAddedHandler = (message: Message) => { push(chatBuilder(message)) - }) - }, []) + } + + conversation.on('messageAdded', messageAddedHandler); + + return () => { + conversation.off('messageAdded', messageAddedHandler) + } + + }, [conversation]) return chats } diff --git a/src/config/index.ts b/src/config/index.ts index 77235ca..ace01f8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,5 @@ +export const DEFAULT_OPEN = false; + export const HEADER_TEXT = 'Live chat'; export const CHAT_START_DATE_FORMAT = 'MMM dd hh:mm a' diff --git a/webpack.config.js b/webpack.config.js index 692e86e..c74a1ea 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,58 +1,74 @@ const path = require('path'); +const webpack = require('webpack'); +const dotenv = require('dotenv'); const isDevelopment = process.env.NODE_ENV !== 'production'; -module.exports = { - mode: isDevelopment ? 'development' : 'production', - devServer: { - hot: true, - static: { - directory: path.join(__dirname, 'public'), - }, - compress: true, - port: 9000, - }, - entry: './src/index.tsx', - resolve: { - extensions: ['.tsx', '.ts', '...'] - }, - output: { - path: path.resolve(__dirname, 'public'), - filename: 'main.bundle.js' - }, - module: { - rules: [ - { - test: /\.m?[jt]sx?$/, - exclude: /node_modules/, - use: { - loader: 'babel-loader', - } - }, - { - test: /\.s[ac]ss$/i, - use: [ - 'style-loader', - 'css-loader', - 'resolve-url-loader', - { - loader: 'sass-loader', +module.exports = () =>{ + + const env = dotenv.config().parsed; + + const envKeys = Object.keys(env).reduce((prev, next) => { + prev[`process.env.${next}`] = JSON.stringify(env[next]); + return prev; + }, {}); + + + return { + mode: isDevelopment ? 'development' : 'production', + devServer: { + hot: true, + static: { + directory: path.join(__dirname, 'public'), + }, + compress: true, + port: 9000, + }, + entry: './src/index.tsx', + resolve: { + extensions: ['.tsx', '.ts', '...'] + }, + output: { + path: path.resolve(__dirname, 'public'), + filename: 'main.bundle.js' + }, + plugins: [ + new webpack.DefinePlugin(envKeys), + ], + module: { + rules: [ + { + test: /\.m?[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + } + }, + { + test: /\.s[ac]ss$/i, + use: [ + 'style-loader', + 'css-loader', + 'resolve-url-loader', + { + loader: 'sass-loader', + options: { + implementation: require("sass"), + sourceMap: true + } + } + ] + }, + { + test: /\.(woff(2)?|ttf|otf|svg|eot)$/i, + use: { + loader: 'file-loader', options: { - implementation: require("sass"), - sourceMap: true + name: '[name].[ext]', + outputPath: 'fonts/' } } - ] - }, - { - test: /\.(woff(2)?|ttf|otf|svg|eot)$/i, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - outputPath: 'fonts/' - } } - } - ] + ] + } } } \ No newline at end of file