Updated chat look and feel

Changed to simple entry list
This commit is contained in:
Michael H. Arieli 2018-11-24 18:55:27 -08:00
parent a1e4ab78cd
commit 24eddf083f
6 changed files with 279 additions and 127 deletions

View File

@ -1,14 +1,15 @@
import * as constants from '../constants.js' import * as constants from '../constants.js'
import _ from 'underscore' import _ from 'underscore'
export function addMessage ({ userId, message, timestamp }) { export function addMessage ({ userId, message, timestamp, image }) {
return { return {
type: constants.MESSAGE_ADD, type: constants.MESSAGE_ADD,
payload: { payload: {
id: _.uniqueId('chat'), id: _.uniqueId('chat'),
userId, userId,
message, message,
timestamp timestamp,
image
} }
} }
} }

View File

@ -1,11 +1,12 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import socket from '../socket.js' import moment from 'moment'
export const MessagePropTypes = PropTypes.shape({ export const MessagePropTypes = PropTypes.shape({
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
message: PropTypes.string.isRequired, message: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired timestamp: PropTypes.string.isRequired,
image: PropTypes.string
}) })
export default class Chat extends React.PureComponent { export default class Chat extends React.PureComponent {
@ -16,6 +17,43 @@ export default class Chat extends React.PureComponent {
document.getElementById('chat').classList.remove('show') document.getElementById('chat').classList.remove('show')
document.querySelector('.toolbar .chat').classList.remove('on') document.querySelector('.toolbar .chat').classList.remove('on')
} }
scrollToBottom = () => {
// this.chatScroll.scrollTop = this.chatScroll.scrollHeight
const duration = 300
const start = this.chatScroll.scrollTop
const end = this.chatScroll.scrollHeight
const change = end - start
const increment = 20
const easeInOut = (currentTime, start, change, duration) => {
currentTime /= duration / 2
if (currentTime < 1) {
return change / 2 * currentTime * currentTime + start
}
currentTime -= 1
return -change / 2 * (currentTime * (currentTime - 2) - 1) + start
}
const animate = elapsedTime => {
elapsedTime += increment
const position = easeInOut(elapsedTime, start, change, duration)
this.chatScroll.scrollTop = position
if (elapsedTime < duration) {
setTimeout(() => {
animate(elapsedTime)
}, increment)
}
}
animate(0)
}
componentDidMount () {
this.scrollToBottom()
}
componentDidUpdate () {
this.scrollToBottom()
}
render () { render () {
const { messages } = this.props const { messages } = this.props
return ( return (
@ -28,23 +66,37 @@ export default class Chat extends React.PureComponent {
</div> </div>
<div className="chat-title">Chat</div> <div className="chat-title">Chat</div>
</div> </div>
<div className="chat-content"> <div className="chat-content" ref={div => { this.chatScroll = div }}>
{messages.length ? ( {messages.length ? (
messages.map((message, i) => ( messages.map((message, i) => (
<div key={i} <div key={i} className="chat-item">
className={ <div className="chat-item-label" />
message.userId === socket.id <div className="chat-item-icon">
? 'chat-bubble alt' {message.image ? (
: 'chat-bubble' <div className="profile-image-component
} profile-image-component-circle">
> <div className="profile-image-component-image">
<div className="txt"> <img src={message.image} />
<p className="name">{message.userId}</p> </div>
<p className="message">{message.message}</p> </div>
<span className="timestamp">{message.timestamp}</span> ) : (
<div className="profile-image-component
profile-image-component-circle">
<div className="profile-image-component-initials">
{message.userId.substr(0, 2).toUpperCase()}
</div>
</div>
)}
</div>
<div className="chat-item-details">
<div className="chat-item-date">
{moment(message.timestamp).fromNow()}
</div>
</div>
<div className="chat-item-content">
{message.message}
</div> </div>
<div className="arrow" />
</div> </div>
)) ))
) : ( ) : (

View File

@ -1,6 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import moment from 'moment'
import socket from '../socket.js' import socket from '../socket.js'
export default class Input extends React.PureComponent { export default class Input extends React.PureComponent {
@ -24,7 +23,7 @@ export default class Input extends React.PureComponent {
this.submit() this.submit()
} }
handleKeyPress = e => { handleKeyPress = e => {
if (e.key === 'Enter') { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
this.submit() this.submit()
} }
@ -37,8 +36,21 @@ export default class Input extends React.PureComponent {
sendMessage(message) sendMessage(message)
const userId = socket.id const userId = socket.id
const timestamp = moment().format('ddd, D MMM HH:mm a') const timestamp = new Date()
const payload = { userId, message, timestamp } let image = null
// take snapshoot
try {
const video = document.getElementById(`video-${userId}`)
const canvas = document.createElement('canvas')
canvas.height = video.videoHeight
canvas.width = video.videoWidth
const avatar = canvas.getContext('2d')
avatar.drawImage(video, 0, 0, canvas.width, canvas.height)
image = canvas.toDataURL()
} catch (e) {}
const payload = { userId, message, timestamp, image }
socket.emit('new_message', payload) socket.emit('new_message', payload)
} }
this.setState({ message: '' }) this.setState({ message: '' })
@ -46,15 +58,18 @@ export default class Input extends React.PureComponent {
render () { render () {
const { message } = this.state const { message } = this.state
return ( return (
<form className="input" onSubmit={this.handleSubmit}> <form className="chat-footer" onSubmit={this.handleSubmit}>
<input <textarea
className="input"
onChange={this.handleChange} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
placeholder="Enter your message..." placeholder="Enter your message..."
type="text" type="text"
value={message} value={message}
/> />
<input type="submit" value="Send" /> <button type="submit" className="send">
<span className="material-icons">send</span>
</button>
</form> </form>
) )
} }

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import { MediaStream } from '../window.js' import { MediaStream } from '../window.js'
import socket from '../socket.js'
export const StreamPropType = PropTypes.shape({ export const StreamPropType = PropTypes.shape({
mediaStream: PropTypes.instanceOf(MediaStream).isRequired, mediaStream: PropTypes.instanceOf(MediaStream).isRequired,
@ -48,6 +49,7 @@ export default class Video extends React.PureComponent {
return ( return (
<div className={className}> <div className={className}>
<video <video
id={`video-${socket.id}`}
autoPlay autoPlay
onClick={this.handleClick} onClick={this.handleClick}
onLoadedMetadata={this.play} onLoadedMetadata={this.play}

View File

@ -26,7 +26,7 @@ describe('components/Input', () => {
let input let input
beforeEach(() => { beforeEach(() => {
sendMessage.mockClear() sendMessage.mockClear()
input = node.querySelector('input') input = node.querySelector('textarea')
TestUtils.Simulate.change(input, { TestUtils.Simulate.change(input, {
target: { value: message } target: { value: message }
}) })

View File

@ -1,26 +1,4 @@
.input {
position: absolute;
right: 10px;
bottom: 30px;
z-index: 2;
width: calc(100% - 30px);
input {
box-shadow: 0px 0px 5px black;
background-color: #333;
border: none;
color: #ccc;
padding: 0.5rem;
font-family: $font-monospace;
}
input[type="text"] {
width: 85%;
}
}
#chat { #chat {
background-color: #f2f2f2;
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
@ -80,14 +58,14 @@
} }
.chat-content { .chat-content {
background-color: #999; background-color: #f2f2f2;
bottom: 0; bottom: 0;
left: 0; left: 0;
overflow-y: auto; overflow-y: auto;
position: absolute; position: absolute;
right: 0; right: 0;
top: 52px; top: 52px;
padding: 20px; bottom: 52px;
} }
.chat-empty { .chat-empty {
@ -100,7 +78,6 @@
-ms-transform: translateY(-50%); -ms-transform: translateY(-50%);
-webkit-transform: translateY(-50%); -webkit-transform: translateY(-50%);
transform: translateY(-50%); transform: translateY(-50%);
}
.chat-empty-icon { .chat-empty-icon {
font-size: 88px; font-size: 88px;
@ -110,88 +87,193 @@
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
} }
}
.chat-bubble { .chat-item {
max-width: 240px; background-color: #fff;
height: auto; box-shadow: 0 1px 1.5px 0 rgba(0, 0, 0, 0.12), 0 1px 6px rgba(0, 0, 0, 0.12);
display: block; line-height: 20px;
background: #f5f5f5; min-height: 80px;
border-radius: 4px; padding: 15px 15px 15px 80px;
box-shadow: 0px 0px 5px black;
position: relative; position: relative;
margin: 0 0 25px; border-top: 1px solid #e9e9e9;
&.alt { .chat-item-label {
margin: 0 0 25px 60px; display: none;
} }
&:last-child { .chat-item-icon {
margin-bottom: 80px; height: 50px;
} left: 15px;
.txt {
padding: 8px 55px 8px 14px;
.name {
font-weight: 600;
font-size: 12px;
margin: 0 0 4px;
color: #3498db;
span {
font-weight: normal;
color: #b3b3b3;
}
&.alt {
color: #2ecc71;
}
}
.message {
font-size: 12px;
margin: 0;
color: #2b2b2b;
}
.timestamp {
font-size: 11px;
position: absolute; position: absolute;
top: 15px;
width: 50px;
}
.chat-item-details {
float: right;
text-align: right;
min-width: 30px;
min-height: 30px;
padding: 0 0 0 10px;
.chat-item-date {
color: #888;
font-size: 12px;
}
}
.chat-item-content {
color: #363636;
font-size: 13px;
font-weight: 400;
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
> {
a, span a {
color: #363636;
font-weight: 700;
text-decoration: none;
}
}
}
&.is-clickable {
cursor: pointer;
&:hover {
background-color: #ebf2fe;
}
}
&.is-labeled {
border-top: none;
margin-top: 40px;
.chat-item-label {
color: #888;
display: block;
font-size: 13px;
font-weight: 700;
left: 0;
line-height: 20px;
padding: 10px;
position: absolute;
top: -40px;
}
}
}
.chat-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52px;
background: #fff;
box-shadow: 0 1px 1.5px 0 rgba(0, 0, 0, 0.12), 0 1px 6px rgba(0, 0, 0, 0.12);
border-top: 1px solid #e9e9e9;
.input {
height: 52px;
background: #fff;
border: none;
width: calc(100% - 52px);
position: absolute;
left: 0;
top: 0;
padding: 10px;
resize: none;
overflow: scroll;
font-weight: 300;
&:focus {
outline: none;
}
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
//gotta hide windows scrollbars
&::-webkit-scrollbar {
width: 0 !important
}
}
.send {
position: absolute;
height: 32px;
width: 32px;
border-radius: 50%;
border: 0;
background: #000;
color: #fff;
bottom: 8px; bottom: 8px;
right: 10px; right: 8px;
text-transform: uppercase; color: #999 padding: 4px 6px 0;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
}
}
} }
} }
.arrow { .profile-image-component {
width: 100%;
height: 100%;
overflow: hidden;
text-align: center;
background-color: #ccc;
color: #fff;
position: relative;
font-size: 0;
line-height: 0;
.profile-image-component-icon, .profile-image-component-initials {
left: 0;
position: absolute; position: absolute;
width: 0; right: 0;
bottom:42px; top: 50%;
left: -16px; -ms-transform: translateY(-50%);
height: 0; -webkit-transform: translateY(-50%);
transform: translateY(-50%);
}
&:after { .profile-image-component-icon {
content: ""; font-size: 22px;
font-weight: 400;
}
.profile-image-component-initials {
font-size: 16px;
font-weight: 500;
vertical-align: middle;
}
.profile-image-component-image {
height: 100%;
left: 0;
overflow: hidden;
position: absolute; position: absolute;
border: 0 solid transparent; top: 0;
border-top: 9px solid #f5f5f5; width: 100%;
border-radius: 0 20px 0;
width: 15px; > img {
height: 30px; height: 100%;
transform: rotate(145deg);
} }
} }
&.alt { &.profile-image-component-circle {
.arrow { border-radius: 50%;
right: -2px;
bottom: 40px;
left: auto;
&:after { .profile-image-component-image {
transform: rotate(45deg) scaleY(-1); border-radius: 50%;
}
}
} }
} }
} }