Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 1 addition & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box position="absolute" bottom="space70" right="space70">
<ChatDialog>
{conversation ? (
<CustomerChatLog conversation={conversation} />
) : (
<Box padding={"space30"}>
<SkeletonLoader borderRadius={"borderRadius0"} />
</Box>
)}
<CustomerChatLog />
</ChatDialog>
</Box>
);
Expand Down
13 changes: 6 additions & 7 deletions src/components/ChatDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<MinimizableDialogContainer state={dialog}>
{dialog.visible ? null : (
{dialog.visible ? <MinimizableDialog aria-label={HEADER_TEXT}>
<MinimizableDialogHeader>{HEADER_TEXT}</MinimizableDialogHeader>
<MinimizableDialogContent>{children}</MinimizableDialogContent>
</MinimizableDialog> : (
<MinimizableDialogButton variant="primary" size="circle">
<ChatIcon decorative={false} title="Chat" />
</MinimizableDialogButton>
)}
<MinimizableDialog aria-label={HEADER_TEXT}>
<MinimizableDialogHeader>{HEADER_TEXT}</MinimizableDialogHeader>
<MinimizableDialogContent>{children}</MinimizableDialogContent>
</MinimizableDialog>
</MinimizableDialogContainer>
);
};
15 changes: 11 additions & 4 deletions src/components/CustomerChatLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);

const conversation = useConversations();
const chats = useMessages(conversation);

useEffect(() => {
Expand All @@ -65,6 +65,13 @@ export const CustomerChatLog: React.FC<{ conversation: Conversation }> = ({ conv
});
};

if (!conversation) {
return (
<Box padding={"space30"}>
<SkeletonLoader borderRadius={"borderRadius0"} />
</Box>
);}

return (
<Box minHeight="size50" display="grid" gridTemplateRows="1fr auto">
<Box ref={scrollerRef} overflowX="hidden" overflowY="scroll" maxHeight="size50" tabIndex={0}>
Expand Down
139 changes: 81 additions & 58 deletions src/components/hooks.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,48 +10,60 @@ import React from "react";
export const useConversations = () => {
const [conversation, setConversation] = useState<Conversation>()
const [client, setClient] = useState<Client>()
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;
}
Expand All @@ -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: (
<ChatBookend>
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading