Multiuser chat built with React and Firebase. Note that this is on the Firebase free plan, so if more than 50 users are on it things may go south.
A Pen by Brad Daily on CodePen.
Multiuser chat built with React and Firebase. Note that this is on the Firebase free plan, so if more than 50 users are on it things may go south.
A Pen by Brad Daily on CodePen.
<div id="app"></div> | |
<!-- | |
This demo shows just how fast you can get up and running with React and Firebase. We have a nicely equipped chat in ~ 300 lines of generousely spaced and commented ES6 JavaScript. | |
Dependencies: | |
React | |
Firebase | |
moment | |
markdown-it | |
--> |
/** | |
* Build up Firebase references | |
*/ | |
const _fbBase = new Firebase('https://amber-heat-810.firebaseio.com'); | |
const _fbMessages = _fbBase.child('messages'); | |
const _fbUsers = _fbBase.child('users'); | |
/** | |
* Local cache of users in a Map | |
* Used to track who is here. | |
*/ | |
const _users = new Map(); | |
/** | |
* mardown-it instance to parse chat messages | |
*/ | |
const _md = window.markdownit({ | |
linkify: true, | |
}); | |
/** | |
* Message Component | |
* Renders an individual message or event in the list | |
*/ | |
class Message extends React.Component { | |
render() { | |
/* | |
* Run message through markdown-it. | |
* dangerouslySetInnerHTML is necessary b/c React escapes HTML by defaukt | |
*/ | |
const messageText = <div dangerouslySetInnerHTML={{__html: _md.render(this.props.message.text)}} />; | |
/** | |
* Event messages | |
* User has joined/left room | |
*/ | |
if (this.props.message.type === 'event') { | |
// Buggy, removing for now. | |
return null; | |
// return <div className="message message-event"> | |
// {messageText} | |
// </div>; | |
} | |
return <div className="message"> | |
<img width="48" height="48" src={this.props.message.user_avatar} /> | |
<div className="message-text"> | |
<div className="message-username"> | |
<a target="_blank" href={'https://twitter.com/' + this.props.message.username}>{'@' + this.props.message.username}</a> | |
{' '} | |
<span className="message-time"> | |
{moment(this.props.message.timestamp).format('h:mma')} | |
</span> | |
</div> | |
{messageText} | |
</div> | |
</div>; | |
} | |
} | |
/** | |
* MessageList Component | |
* Renders the list of chat messages | |
*/ | |
class MessageList extends React.Component { | |
/** | |
* Anytime new data comes in, scroll to bottom | |
* TODO: Could be more intelligent here and only | |
* scroll when user is not browsing back up the list. | |
*/ | |
componentDidUpdate() { | |
let list = this.refs.messageList.getDOMNode(); | |
list.scrollTop = list.scrollHeight; | |
} | |
render() { | |
return <div ref="messageList" className="message-list"> | |
{this.props.messages.map((message, index) => <Message | |
key={index} | |
message={message} | |
currentUser={message.userID === this.props.currentUserId} />)} | |
</div>; | |
} | |
} | |
class UserList extends React.Component { | |
render() { | |
return <div className="user-list-container"> | |
<h2>{this.props.users.length ? this.props.users.length : 'No'} user{this.props.users.length !== 1 ? 's' : ''} chatting</h2> | |
<div className="user-list"> | |
{this.props.users.map((user) => <UserListItem user={user} />)} | |
</div> | |
</div>; | |
} | |
} | |
class UserListItem extends React.Component { | |
render() { | |
return <div className="user-list-item"> | |
<img width="24" height="24" src={this.props.user.twitter.cachedUserProfile.profile_image_url_https} /> | |
{this.props.user._isCurrentUser ? 'You' : '@' + this.props.user.twitter.username} | |
</div>; | |
} | |
} | |
/** | |
* MessageForm Component | |
* Form input for adding new messages | |
*/ | |
class MessageForm extends React.Component { | |
componentDidMount() { | |
this.refs.messageText.getDOMNode().focus(); | |
} | |
/** | |
* Submit the new Message | |
* @param {object} event - onsubmit event | |
*/ | |
_onSubmit(event) { | |
event.preventDefault(); | |
const user = _users.get(this.props.userId); | |
const message = this.refs.messageText.getDOMNode().value.trim(); | |
if (!message.length) { | |
return; | |
} | |
_fbMessages.push({ | |
type: "userMessage", | |
user_avatar: user.twitter.cachedUserProfile.profile_image_url_https, | |
username: user.twitter.username, | |
text: this.refs.messageText.getDOMNode().value, | |
timestamp: +new Date, | |
}); | |
event.target.reset(); | |
} | |
render() { | |
return <form onSubmit={this._onSubmit.bind(this)}> | |
<input ref="messageText" type="text" placeholder="Type your message. Be nice!" /> | |
</form>; | |
} | |
} | |
/** | |
* RegisterForm Component | |
* Renders the registration form when a user enters the chat window | |
*/ | |
class RegisterForm extends React.Component { | |
render() { | |
return <form onSubmit={this.props.onSubmit}> | |
<button type="submit">Login with Twitter</button> | |
</form>; | |
} | |
} | |
/** | |
* Application Component | |
* Main component | |
*/ | |
class Application extends React.Component { | |
constructor() { | |
this._messages = []; | |
this._username = null; | |
this.state = { | |
messages: [], | |
user: null, | |
users: [], | |
userId: null, | |
joinError: null, | |
}; | |
if (location.href.indexOf('fullpage') !== -1) { | |
this.state.joinError = 'View this pen in the Editor in order to join the chat. Codepen\'s fullpage view blocks popups, so Twitter authentication does not work.'; | |
} | |
this._join = this._join.bind(this); | |
this._createUsersArray = this._createUsersArray.bind(this); | |
} | |
componentWillMount() { | |
/** | |
* onAuth fires anytime the auth status of this user changes | |
*/ | |
_fbBase.onAuth((authData) => { | |
if (!authData) { | |
return; | |
} | |
this._username = authData.twitter.username; | |
_fbUsers.push(authData); | |
this.setState({ | |
user: this._username, | |
joinError: null, | |
}); | |
}); | |
/** | |
* Faster to call once.value, then listen to | |
* new children after that | |
*/ | |
_fbMessages.orderByKey().once('value', (dataSnapshot) => { | |
dataSnapshot.forEach((snapshot) => { | |
this._messages.push(snapshot.val()); | |
}); | |
this.setState({messages: this._messages}); | |
const last = this._messages[this._messages.length - 1]; | |
_fbMessages | |
.orderByChild('timestamp') | |
.startAt(last.timestamp + 1) | |
.on('child_added', (snapshot) => { | |
this._messages.push(snapshot.val()); | |
this.setState({messages: this._messages}); | |
}); | |
}); | |
_fbUsers.on('child_added', (snapshot) => { | |
const key = snapshot.key(); | |
const userObj = snapshot.val(); | |
_users.set(key, snapshot.val()); | |
this._createUsersArray(); | |
if (userObj.twitter.username === this._username) { | |
this.setState({userId: key}); | |
const disconnectListener = _fbUsers.child(key); | |
disconnectListener.onDisconnect().remove(); | |
} | |
}); | |
_fbUsers.on('child_removed', (snapshot) => { | |
_users.delete(snapshot.key()); | |
this._createUsersArray(); | |
}); | |
} | |
_createUsersArray() { | |
let users = []; | |
_users.forEach((user, key) => { | |
user._isCurrentUser = user.twitter.username === this._username; | |
users.push(user) | |
}); | |
this.setState({users}); | |
} | |
/** | |
* Add the user to the chat | |
* @param {object} event - the onsubmit event | |
*/ | |
_join(event) { | |
event.preventDefault(); | |
_fbBase.authWithOAuthPopup("twitter", (error, authData) => { | |
if (error) { | |
this.setState({joinError: `Error authenticating with Twitter: ${error.message}`}); | |
} else { | |
_fbMessages.push({ | |
type: "event", | |
text: `@${authData.twitter.username} has joined the room`, | |
}); | |
} | |
}, { | |
remember: "sessionOnly" | |
}); | |
} | |
render() { | |
return <div className="wrapper"> | |
<h1> | |
CHATPEN | |
<div className="header-info"> | |
{this.state.user ? | |
`Chatting as @${this.state.user}` : | |
<RegisterForm | |
ref="registerForm" | |
onSubmit={this._join} />} | |
</div> | |
</h1> | |
<div className="main-window"> | |
<div className="message-list-container"> | |
<MessageList | |
messages={this.state.messages} | |
currentUserId={this.state.userId} /> | |
{this.state.userId ? | |
<MessageForm userId={this.state.userId} /> : null} | |
</div> | |
<UserList users={this.state.users} /> | |
</div> | |
{this.state.joinError ? | |
<p className="register-error">{this.state.joinError}</p> : null} | |
</div>; | |
} | |
} | |
/** | |
* Get this party started! | |
*/ | |
React.render(<Application />, document.getElementById('app')); |
* | |
box-sizing: border-box | |
html, body, #app | |
height: 100% | |
overflow: hidden | |
body | |
font-family: "Open Sans" | |
div.wrapper | |
display: flex | |
flex-flow: column | |
height: 100% | |
div.main-window | |
display: flex | |
flex: 1 | |
div.user-list-container | |
flex: 1 | |
display: flex | |
flex-flow: column | |
border-left: 1px solid #ddd | |
h2 | |
padding: 8px | |
font-size: 12px | |
text-align: center | |
color: darken(white, 50%) | |
border-bottom: 1px solid darken(white, 4%) | |
.user-list | |
flex: 1 | |
overflow-y: auto | |
.user-list-item | |
display: flex | |
align-items: center | |
font-size: 12px | |
img | |
margin: 5px | |
border-radius: 50% | |
div.message-list-container | |
background: darken(white, 4%) | |
font-size: 13px | |
flex: 2 | |
display: flex | |
flex-flow: column | |
.message-list | |
flex: 1 | |
overflow-y: auto | |
.message | |
display: flex | |
align-items: center | |
padding: 0 10px | |
img | |
border-radius: 50% | |
.message-text | |
display: flex | |
flex-flow: column | |
flex: 3 | |
line-height: 1.3em | |
padding: 10px | |
strong | |
font-weight: bold | |
em | |
font-style: italic | |
a | |
color: #336633 | |
.message-username | |
font-weight: bold | |
font-size: 14px | |
margin-bottom: 4px | |
a | |
text-decoration: none | |
color: black | |
span.message-time | |
font-weight: normal | |
font-size: 12px | |
color: #999 | |
.message-event | |
display: flex | |
justify-content: center | |
text-align: center | |
color: #aaa | |
width: 100% | |
font-size: 12px | |
padding: 10px | |
.register-error | |
background: red | |
padding: 10px 10px 10px 15px | |
font-size: 12px | |
color: white | |
form | |
display: flex | |
justify-content: center | |
input | |
flex: 4 | |
border: 0 | |
padding: 10px | |
border-top: 1px solid #ddd | |
&:focus | |
outline: 0 | |
button | |
border: 0 | |
background: #55acee | |
color: white | |
font-size: 12px | |
padding: 10px 10px 10px 34px | |
font-family: 'Helvetica Neue' | |
border-radius: 5px | |
cursor: pointer | |
font-weight: bold | |
background-image: url(//g.twimg.com/Twitter_logo_white.png) | |
background-position: 10px 50% | |
background-size: auto 14px | |
background-repeat: no-repeat | |
.wrapper > h1 | |
padding: 20px | |
background: #222 | |
color: #eee | |
display: flex | |
justify-content: space-between | |
align-items: center | |
font-weight: bold | |
.header-info | |
font-size: 11px | |
color: #ddd | |
font-weight: normal |