use Chatbox in embeddable popup window

This commit is contained in:
Sharon Kennedy 2020-02-01 00:30:58 -05:00
parent 2e6151c048
commit ed68d08f9c
12 changed files with 564 additions and 167 deletions

187
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -114,9 +114,11 @@
"webpack-serve": "3.2.0"
},
"dependencies": {
"matrix-js-sdk": "^4.0.0",
"prop-types": "^15.6.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-transition-group": "^4.0.0"
"react-transition-group": "^4.0.0",
"uuidv4": "^6.0.2"
}
}

View File

@ -5,12 +5,10 @@
</head>
<body>
<p>Note: Widget rendered in bottom-right of this screen.</p>
<script src="./widget.js"></script>
<script>
EmbeddableWidget.mount({
bodyText: 'Body'
});
EmbeddableWidget.mount();
</script>
</body>
</html>

View File

@ -1,20 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Embeddable Widget documentation</title>
<title>Embeddable Chatbox Demo</title>
</head>
<body>
<h1>Embeddable Widget documentation</h1>
<ul>
<li><a href="./blank.html">Widget on a blank page</a></li>
<li><a href="./widget.js">Latest version of the widget</a></li>
<li><a id="bookmarklet">Bookmarklet (Bookmark it for easy use)</a></li>
<li><a href="./bookmarklet.js">Bookmarklet Code</a></li>
<script>
var bookmarklet = "var s= document.createElement('script'); s.setAttribute('src', '"+window.location.href+"bookmarklet.js'); s.setAttribute('crossorigin', 'anonymous'); document.body.appendChild(s);"
bookmarklet = '(function(){'+ bookmarklet +'})();'
document.querySelector('a#bookmarklet').setAttribute("href", "javascript:" + encodeURIComponent(bookmarklet));
</script>
</ul>
<p style="font-family:sans-serif; padding: 3rem 5rem;">Look down!</p>
<script src="./widget.js"></script>
<script>
EmbeddableWidget.mount();
</script>
</body>
</html>

100
src/components/_chat.scss Normal file
View File

@ -0,0 +1,100 @@
@import url('https://fonts.googleapis.com/css?family=Assistant&display=swap');
#ocrcc-chatbox {
font-family: $theme-font;
display: flex;
flex-direction: column;
min-height: 50vh;
outline: 1px solid $theme-color;
outline-offset: -1px;
.message-window {
background-color: $light-color;
flex: 1 0 auto;
padding: 0.5rem;
height: 300px;
max-height: 100%;
overflow: auto;
display: flex;
flex-direction: column-reverse;
}
.message {
.sender {
font-weight: bold;
color: $theme-color;
}
.text {
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
width: fit-content;
}
&.from-me {
display: flex;
justify-content: flex-end;
.text {
background-color: $theme-color;
color: $white;
border-radius: 15px 15px 0 15px;
margin-left: 10%;
}
}
&.from-support {
display: flex;
justify-content: flex-start;
.text {
background-color: $white;
color: $dark-color;
border: 1px solid $theme-color;
border-radius: 15px 15px 15px 0;
margin-right: 10%;
}
}
}
.input-window {
flex: 0 0 auto;
form {
display: flex;
align-items: center;
margin-bottom: 0;
}
input[type="text"] {
font-size: 1rem;
padding: 0.5rem 1rem;
border: none;
flex: 1 0 auto;
border: none;
outline: 1px solid $theme-color;
outline-offset: -1px;
color: $dark-color;
font-family: $theme-font;
&:focus {
border: none;
}
}
input[type="submit"] {
background-color: $theme-color;
height: 100%;
padding: 0.5rem 1rem;
font-size: 1rem;
color: $white;
font-weight: bold;
border: none;
font-family: $theme-font;
cursor: pointer;
}
}
}

View File

@ -0,0 +1,48 @@
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
width: 2.5em;
height: 2.5em;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation: load7 1.8s infinite ease-in-out;
animation: load7 1.8s infinite ease-in-out;
}
.loader {
color: $teal; /*purple*/
font-size: 10px;
margin: 1rem auto;
margin-bottom: 2rem;
position: relative;
text-indent: -9999em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
.loader:before,
.loader:after {
content: '';
position: absolute;
top: 0;
}
.loader:before {
left: -3.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.loader:after {
left: 3.5em;
}
@keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}

View File

@ -0,0 +1,8 @@
$light-color: #FFF8F0;
$dark-color: #22333B;
$yellow: #FFFACD;
$teal: #008080;
$white: #ffffff;
$highlight-color: $yellow;
$theme-color: $teal;
$theme-font: 'Assistant', 'Helvetica', sans-serif;

203
src/components/chatbox.jsx Normal file
View File

@ -0,0 +1,203 @@
import React from "react"
import PropTypes from "prop-types"
import * as sdk from "matrix-js-sdk";
import {uuid} from "uuidv4"
import Message from "./message";
const MATRIX_SERVER_ADDRESS = "https://matrix.rhok.space"
const FACILITATOR_USERNAME = "@anonymouscat:rhok.space"
const CHATROOM_NAME = "Support Chat"
class ChatBox extends React.Component {
constructor(props) {
super(props)
const client = sdk.createClient(MATRIX_SERVER_ADDRESS)
this.state = {
client: client,
ready: false,
rooms: { chunk: [] },
access_token: null,
user_id: null,
messages: [],
inputValue: "",
}
this.chatboxInput = React.createRef();
}
leaveRoom = () => {
if (this.state.room_id) {
this.state.client.leave(this.state.room_id).then(data => {
console.log("Left room", data)
})
}
}
createRoom = () => {
const currentDate = new Date()
const chatDate = currentDate.toLocaleDateString()
const chatTime = currentDate.toLocaleTimeString()
return this.state.client.createRoom({
room_alias_name: `private-support-chat-${uuid()}`,
invite: [FACILITATOR_USERNAME], // TODO: create bot user to add
visibility: 'private',
name: `${chatDate} - ${CHATROOM_NAME} - started at ${chatTime}`
}).then(data => {
this.setState({ room_id: data.room_id })
})
}
sendMessage = () => {
const content = {
"body": this.state.inputValue,
"msgtype": "m.text"
};
this.state.client.sendEvent(this.state.room_id, "m.room.message", content, "").then((res) => {
this.setState({ inputValue: "" })
this.chatboxInput.current.focus()
}).catch((err) => {
console.log(err);
})
}
componentDidMount() {
// empty registration request to get session
this.state.client.registerRequest({}).then(data => {
console.log("Empty registration request to get session", data)
}).catch(err => {
// actual registration request with randomly generated username and password
const username = uuid()
const password = uuid()
this.state.client.registerRequest({
auth: {session: err.data.session, type: "m.login.dummy"},
inhibit_login: false,
password: password,
username: username,
x_show_msisdn: true,
}).then(data => {
console.log("Registered user", data)
this.setState({
access_token: data.access_token,
user_id: data.user_id,
username: username,
client: sdk.createClient({
baseUrl: MATRIX_SERVER_ADDRESS,
accessToken: data.access_token,
userId: data.user_id
})
})
this.state.client.setDisplayName("Anonymous")
}).catch(err => {
console.log("Registration error", err)
})
})
}
componentDidUpdate(prevProps, prevState) {
if (prevState.client !== this.state.client) {
this.state.client.startClient()
this.state.client.once('sync', (state, prevState, res) => {
if (state === "PREPARED") {
this.setState({ ready: true })
}
});
this.state.client.on("Room.timeline", (event, room, toStartOfTimeline) => {
if (event.getType() === "m.room.message") {
const messages = [...this.state.messages]
messages.push(event)
this.setState({ messages })
}
});
}
if (prevProps.status !== "entered" && this.props.status === "entered") {
this.chatboxInput.current.focus()
}
}
componentWillUnmount() {
this.leaveRoom();
}
handleInputChange = e => {
this.setState({ inputValue: e.currentTarget.value })
}
handleSubmit = e => {
e.preventDefault()
if (!Boolean(this.state.inputValue)) return null;
if (!this.state.room_id) {
return this.createRoom().then(this.sendMessage)
}
this.sendMessage()
}
render() {
const { ready, messages, inputValue, user_id } = this.state;
const { opened, handleToggleOpen } = this.props;
if (!ready) {
return (
<div className="loader">loading...</div>
)
}
return (
<div id="ocrcc-chatbox">
<div className="widget-header">
<div className="widget-header-title">
Support Chat
</div>
<button
type="button"
className={`widget-header-icon`}
onClick={handleToggleOpen}
onKeyPress={handleToggleOpen}
>
<span className={`arrow ${opened ? "opened" : "closed"}`}></span>
</button>
</div>
<div className="message-window">
<div className="messages">
{
messages.map((message, index) => {
return(
<Message key={message.event.event_id} message={message} user_id={user_id} />
)
})
}
</div>
</div>
<div className="input-window">
<form onSubmit={this.handleSubmit}>
<input
type="text"
onChange={this.handleInputChange}
value={inputValue}
autoFocus={true}
ref={this.chatboxInput}
/>
<input type="submit" value="Send" />
</form>
</div>
</div>
);
}
};
ChatBox.propTypes = {
}
ChatBox.defaultProps = {
}
export default ChatBox;

View File

@ -0,0 +1,14 @@
import React from "react"
import PropTypes from "prop-types"
const Message = ({ message, user_id }) => {
const fromMe = message.sender.userId === user_id;
return (
<div className={`message ${fromMe ? "from-me" : "from-support"}`}>
<div className="text">{ message.event.content.body }</div>
</div>
)
}
export default Message;

View File

@ -1,12 +1,15 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Transition } from 'react-transition-group';
import Chatbox from './chatbox';
import './widget.scss';
class Widget extends Component {
state = {
opened: false,
showDock: true,
constructor(props) {
super(props);
this.state = {
opened: false,
showDock: true,
};
}
handleToggleOpen = () => {
@ -28,71 +31,39 @@ class Widget extends Component {
});
}
renderBody = () => {
const { showDock } = this.state;
if (!showDock) return '';
return (
<button
type="button"
className="dock"
onClick={this.handleToggleOpen}
onKeyPress={this.handleToggleOpen}
>
^ OPEN ^
</button>
);
}
render() {
const { opened } = this.state;
const body = this.renderBody();
const { bodyText, headerText, footerText } = this.props;
const { opened, showDock } = this.state;
return (
<div className="docked-widget">
<Transition in={opened} timeout={250} onExited={this.handleWidgetExit}>
{status => (
{(status) => (
<div className={`widget widget-${status}`}>
<div className="widget-header">
<div className="widget-header-title">
{headerText}
</div>
<button
type="button"
className="widget-header-icon"
onClick={this.handleToggleOpen}
onKeyPress={this.handleToggleOpen}
>
X
</button>
</div>
<div className="widget-body">
{bodyText}
</div>
<div className="widget-footer">
{footerText}
</div>
<Chatbox handleToggleOpen={this.handleToggleOpen} opened={opened} status={status} />
</div>
)}
</Transition>
{body}
{showDock && (
<button
type="button"
className="dock"
onClick={this.handleToggleOpen}
onKeyPress={this.handleToggleOpen}
>
<span>Open support chat</span>
<span className={`arrow ${opened ? 'opened' : 'closed'}`}></span>
</button>
)}
</div>
);
}
}
Widget.propTypes = {
headerText: PropTypes.string,
bodyText: PropTypes.string,
footerText: PropTypes.string,
};
Widget.defaultProps = {
headerText: 'Header',
bodyText: 'Body',
footerText: 'Footer',
};
export default Widget;

View File

@ -1,3 +1,7 @@
@import "variables";
@import "loader";
@import "chat";
@keyframes slideInUp {
from {
transform: translate3d(0, 100%, 0);
@ -26,7 +30,6 @@
position: fixed;
bottom: 0px;
right: 10px;
width: 200px;
z-index: 9999;
}
@ -34,17 +37,24 @@
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
width: 180px;
border: 1px solid grey;
background: white;
justify-content: space-between;
padding: 0.5rem 1rem;
width: 400px;
max-width: calc(100vw - 10px);
background: $theme-color;
color: $white;
font-family: $theme-font;
font-size: 1rem;
border: none;
color: $white;
font-size: 1rem;
line-height: 1;
}
.widget {
width: 200px;
border: 1px solid grey;
width: 400px;
max-width: calc(100vw - 10px);
border-bottom: none;
animation-duration: 0.2s;
animation-fill-mode: forwards;
@ -65,14 +75,11 @@
}
&-header {
height: 30px;
line-height: 30px;
background: lightgrey;
color: grey;
padding-left: 10px;
background: $theme-color;
color: $white;
padding: 0.5rem 1rem;
display: flex;
align-items: stretch;
align-items: center;
&-title {
display: flex;
@ -84,7 +91,8 @@
display: flex;
align-items: center;
justify-content: center;
padding: .75rem;
background: transparent;
border: none;
}
}
&-body {
@ -98,3 +106,31 @@
padding-left: 10px;
}
}
.arrow {
transform: rotateX(0deg);
transition: all 0.5s linear;
color: $white;
font-size: 1rem;
line-height: 1;
&.opened {
transform: rotateX(180deg);
}
&.closed {
transform: rotateX(0deg) translateY(25%);
}
}
@media screen and (max-width: 400px){
.docked-widget {
right: 0;
left: 0;
}
.dock, .widget {
max-width: 100vw;
}
}

View File

@ -7,7 +7,7 @@ export default class EmbeddableWidget {
static el;
static mount({ parentElement = null, ...props } = {}) {
const component = <Widget {...props} />;
const component = <Widget {...props} />; // eslint-disable-line
function doRender() {
if (EmbeddableWidget.el) {