Implemented basic chat

This commit is contained in:
Chris Blanchard 2015-07-21 15:10:24 +01:00
parent 00339aefaf
commit 4a21328691
14 changed files with 722 additions and 84 deletions

View file

@ -23,6 +23,7 @@ npm start
- Admin tools
- Add a backend datastore to persist gather data and messages
- Add sounds
- Add WebRTC for internal voice comms
## License

View file

@ -1,5 +1,9 @@
"use strict";
var fs = require("fs");
var path = require("path");
var chatController = require(path.join(__dirname, "../lib/chat/controller"));
module.exports = function (io) {
var root = io.of("/");
var authorised = io.of("/authorised");
@ -11,7 +15,8 @@ module.exports = function (io) {
username: "Chris (" + socket.id.slice(0,5) + ")",
steamId: "11111111",
email: "cablanchard@gmail.com",
bans: []
bans: [],
avatar: "http://www.ensl.org/local/avatars/6359.jpg"
};
next();
});
@ -27,6 +32,9 @@ module.exports = function (io) {
});
};
// Activate chat controller on root namespace
chatController(root);
io.on('connection', function (socket) {
refreshGatherers();
@ -37,7 +45,3 @@ module.exports = function (io) {
});
});
};
// socket.on('my other event', function (data) {
// console.log(data);
// });

17
lib/chat/Author.js Normal file
View file

@ -0,0 +1,17 @@
"use strict";
// Method which caches a single instance for each author
var Authors = (function () {
var authors = {};
return function (author) {
if (authors[author.id]) {
return authors[author.id]
} else {
return authors[author.id] = author;
}
};
})();
module.exports = Authors;

33
lib/chat/Chatroom.js Normal file
View file

@ -0,0 +1,33 @@
"use strict";
var Message = require("./message");
function Chatroom (o) {
if (!(this instanceof Chatroom)) {
return new Chatroom(o);
}
this.messages = [];
};
Chatroom.prototype.createMessage = function (options, callback) {
var message = new Message({
author: options.author,
content: options.content
})
this.messages.push(message);
if (callback) {
return callback(message);
}
};
Chatroom.prototype.retrieveMessages = function (n) {
return this.messages
.slice(this.messages.length - n, this.messages.length)
.map(function (message) {
return message.toJson();
});
};
module.exports = Chatroom;

22
lib/chat/Message.js Normal file
View file

@ -0,0 +1,22 @@
"use strict";
var Author = require("./Author");
function Message (o) {
this.author = Author(o.author);
this.content = o.content;
this.createdAt = new Date();
};
Message.prototype.toJson = function () {
return {
author: {
username: this.author.username,
avatar: this.author.avatar
},
content: this.content,
createdAt: this.createdAt
}
};
module.exports = Message;

39
lib/chat/controller.js Normal file
View file

@ -0,0 +1,39 @@
"use strict";
/*
* Chatroom Controller
*
* Server API
* message:new - New message needs to be displayed
* message:refresh - Reload all messages
*
* Client API
* message:new - New message has been created
* message:refresh - Retrieve most recent messages
*
*/
var chatroom = require("./chatroom")();
module.exports = function (namespace) {
var broadcastUpdate = function (message) {
namespace.emit("message:new", message.toJson());
};
namespace.on('connection', function (socket) {
socket.on('message:new', function (data) {
chatroom.createMessage({
author: socket._user,
content: data.content
}, broadcastUpdate);
});
socket.on('message:refresh', function () {
socket.emit("message:refresh", {
chatHistory: chatroom.retrieveMessages(20)
});
});
});
};

View file

View file

@ -2,7 +2,10 @@ var GatherCounter = React.createClass({
render: function () {
return (
<li>
<a href="#"><i className="fa fa-users fa-fw"></i> Gatherers ({this.props.count})</a>
<a href="#">
<i className="fa fa-users fa-fw"></i> Gatherers
<span className="badge add-left"> {this.props.count} </span>
</a>
</li>
);
}
@ -43,6 +46,146 @@ var GathererMenu = React.createClass({
}
});
var Chatroom = React.createClass({
componentDidMount: function () {
var self = this;
var TIMER_INTERVAL = 60000; // Every minute
socket.on("message:new", function (data) {
var history = self.props.history;
history.push(data);
self.setProps({
history: history
});
self.scrollToBottom();
});
// Message History Retrieved
socket.on("message:refresh", function (data) {
self.setProps({
history: data.chatHistory
});
self.scrollToBottom();
});
socket.emit("message:refresh", {});
self.timer = setInterval(function () {
self.refs.messages.refreshTime();
}, TIMER_INTERVAL);
},
componentDidUnmount: function () {
clearInterval(this.timer);
},
sendMessage: function (message) {
socket.emit("newMessage", {message: message});
},
scrollToBottom: function () {
var node = React.findDOMNode(this.refs.messageContainer);
console.log(node)
node.scrollTop = node.scrollHeight;
},
render: function () {
var messages = this.props.history.map(function (message) {
return (
<ChatMessage
avatar={message.author.avatar}
username={message.author.username}
content={message.content}
ref="messages"
createdAt={message.createdAt} />
);
});
return (
<div className="panel panel-default">
<div className="panel-heading">Gather Chat</div>
<div className="panel-body">
<ul className="chat" id="chatmessages" ref="messageContainer">
{messages}
</ul>
</div>
<div className="panel-footer">
<MessageBar />
</div>
</div>
);
}
});
var ChatMessage = React.createClass({
getInitialState: function () {
return {
timeAgo: $.timeago(this.props.createdAt)
}
},
refreshTime: function () {
var self = this;
self.setState({
timeAgo: $.timeago(self.props.createdAt)
});
},
render: function () {
return (
<li className="left clearfix">
<span className="chat-img pull-left">
<img
src={this.props.avatar}
alt="User Avatar"
height="40"
className="img-circle" />
</span>
<div className="chat-body clearfix">
<div className="header">
<strong className="primary-font">{this.props.username}</strong>
<small className="pull-right text-muted">
<i className="fa fa-clock-o fa-fw"></i> {this.state.timeAgo}
</small>
</div>
<p>{this.props.content}</p>
</div>
</li>
);
}
});
var MessageBar = React.createClass({
sendMessage: function (content) {
socket.emit("message:new", {
content: content
});
},
handleSubmit: function (e) {
e.preventDefault();
var content = React.findDOMNode(this.refs.content).value.trim();
if (!content) return;
React.findDOMNode(this.refs.content).value = '';
this.sendMessage(content);
return;
},
render: function () {
return (
<form onSubmit={this.handleSubmit} >
<div className="input-group">
<input
id="btn-input"
type="text"
className="form-control"
ref="content"
placeholder="Be polite please..." />
<span className="input-group-btn">
<input
type="submit"
className="btn btn-primary"
id="btn-chat"
value="Send" />
</span>
</div>
</form>
);
}
});
React.render(<GathererMenu count={0} gatherers={[]} />, document.getElementById('side-menu'));
React.render(<Chatroom history={[]}/>, document.getElementById('chatroom'));

View file

@ -10,7 +10,7 @@
"scripts": {
"test": "NODE_ENV=test mocha spec/",
"start": "node index.js",
"compile:dev:react": "node_modules/react-tools/bin/jsx --watch -x jsx lib/react/ public/js/",
"compile:dev:react": "node_modules/react-tools/bin/jsx --watch --source-map-inline -x jsx lib/react/ public/js/",
"compile:react": "node_modules/react-tools/bin/jsx -x jsx lib/react/ public/js/",
"dev": "nodemon index.js"
},

View file

@ -0,0 +1,13 @@
#chatroom {
margin-top: 20px;
}
.add-left {
margin-left: 10px;
}
#chatmessages {
max-height: 70%;
min-height: 70%;
overflow-y: scroll;
}

File diff suppressed because one or more lines are too long

221
public/js/timeago.jquery.js Normal file
View file

@ -0,0 +1,221 @@
/**
* Timeago is a jQuery plugin that makes it easy to support automatically
* updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
*
* @name timeago
* @version 1.4.1
* @requires jQuery v1.2.3+
* @author Ryan McGeary
* @license MIT License - http://www.opensource.org/licenses/mit-license.php
*
* For usage and examples, visit:
* http://timeago.yarp.com/
*
* Copyright (c) 2008-2015, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)
*/
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else {
// Browser globals
factory(jQuery);
}
}(function ($) {
$.timeago = function(timestamp) {
if (timestamp instanceof Date) {
return inWords(timestamp);
} else if (typeof timestamp === "string") {
return inWords($.timeago.parse(timestamp));
} else if (typeof timestamp === "number") {
return inWords(new Date(timestamp));
} else {
return inWords($.timeago.datetime(timestamp));
}
};
var $t = $.timeago;
$.extend($.timeago, {
settings: {
refreshMillis: 60000,
allowPast: true,
allowFuture: false,
localeTitle: false,
cutoff: 0,
strings: {
prefixAgo: null,
prefixFromNow: null,
suffixAgo: "ago",
suffixFromNow: "from now",
inPast: 'any moment now',
seconds: "less than a minute",
minute: "about a minute",
minutes: "%d minutes",
hour: "about an hour",
hours: "about %d hours",
day: "a day",
days: "%d days",
month: "about a month",
months: "%d months",
year: "about a year",
years: "%d years",
wordSeparator: " ",
numbers: []
}
},
inWords: function(distanceMillis) {
if(!this.settings.allowPast && ! this.settings.allowFuture) {
throw 'timeago allowPast and allowFuture settings can not both be set to false.';
}
var $l = this.settings.strings;
var prefix = $l.prefixAgo;
var suffix = $l.suffixAgo;
if (this.settings.allowFuture) {
if (distanceMillis < 0) {
prefix = $l.prefixFromNow;
suffix = $l.suffixFromNow;
}
}
if(!this.settings.allowPast && distanceMillis >= 0) {
return this.settings.strings.inPast;
}
var seconds = Math.abs(distanceMillis) / 1000;
var minutes = seconds / 60;
var hours = minutes / 60;
var days = hours / 24;
var years = days / 365;
function substitute(stringOrFunction, number) {
var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;
var value = ($l.numbers && $l.numbers[number]) || number;
return string.replace(/%d/i, value);
}
var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
seconds < 90 && substitute($l.minute, 1) ||
minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
minutes < 90 && substitute($l.hour, 1) ||
hours < 24 && substitute($l.hours, Math.round(hours)) ||
hours < 42 && substitute($l.day, 1) ||
days < 30 && substitute($l.days, Math.round(days)) ||
days < 45 && substitute($l.month, 1) ||
days < 365 && substitute($l.months, Math.round(days / 30)) ||
years < 1.5 && substitute($l.year, 1) ||
substitute($l.years, Math.round(years));
var separator = $l.wordSeparator || "";
if ($l.wordSeparator === undefined) { separator = " "; }
return $.trim([prefix, words, suffix].join(separator));
},
parse: function(iso8601) {
var s = $.trim(iso8601);
s = s.replace(/\.\d+/,""); // remove milliseconds
s = s.replace(/-/,"/").replace(/-/,"/");
s = s.replace(/T/," ").replace(/Z/," UTC");
s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400
s = s.replace(/([\+\-]\d\d)$/," $100"); // +09 -> +0900
return new Date(s);
},
datetime: function(elem) {
var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title");
return $t.parse(iso8601);
},
isTime: function(elem) {
// jQuery's `is()` doesn't play well with HTML5 in IE
return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time");
}
});
// functions that can be called via $(el).timeago('action')
// init is default when no action is given
// functions are called with context of a single element
var functions = {
init: function(){
var refresh_el = $.proxy(refresh, this);
refresh_el();
var $s = $t.settings;
if ($s.refreshMillis > 0) {
this._timeagoInterval = setInterval(refresh_el, $s.refreshMillis);
}
},
update: function(time){
var parsedTime = $t.parse(time);
$(this).data('timeago', { datetime: parsedTime });
if($t.settings.localeTitle) $(this).attr("title", parsedTime.toLocaleString());
refresh.apply(this);
},
updateFromDOM: function(){
$(this).data('timeago', { datetime: $t.parse( $t.isTime(this) ? $(this).attr("datetime") : $(this).attr("title") ) });
refresh.apply(this);
},
dispose: function () {
if (this._timeagoInterval) {
window.clearInterval(this._timeagoInterval);
this._timeagoInterval = null;
}
}
};
$.fn.timeago = function(action, options) {
var fn = action ? functions[action] : functions.init;
if(!fn){
throw new Error("Unknown function name '"+ action +"' for timeago");
}
// each over objects here and call the requested function
this.each(function(){
fn.call(this, options);
});
return this;
};
function refresh() {
//check if it's still visible
if(!$.contains(document.documentElement,this)){
//stop if it has been removed
$(this).timeago("dispose");
return this;
}
var data = prepareData(this);
var $s = $t.settings;
if (!isNaN(data.datetime)) {
if ( $s.cutoff == 0 || Math.abs(distance(data.datetime)) < $s.cutoff) {
$(this).text(inWords(data.datetime));
}
}
return this;
}
function prepareData(element) {
element = $(element);
if (!element.data("timeago")) {
element.data("timeago", { datetime: $t.datetime(element) });
var text = $.trim(element.text());
if ($t.settings.localeTitle) {
element.attr("title", element.data('timeago').datetime.toLocaleString());
} else if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) {
element.attr("title", text);
}
}
return element.data("timeago");
}
function inWords(date) {
return $t.inWords(distance(date));
}
function distance(date) {
return (new Date().getTime() - date.getTime());
}
// fix for IE6 suckage
document.createElement("abbr");
document.createElement("time");
}));

View file

@ -8,8 +8,7 @@
<div class="col-lg-6">
<h3 class="page-header">Current Gather</h3>
</div>
<div class="col-lg-6">
<h3 class="page-header">Chat</h3>
<div class="col-lg-6" id="chatroom">
</div>
</div>
</div>

View file

@ -12,6 +12,7 @@
<link rel="stylesheet" href="/css/app.css">
<script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
<script src="/js/timeago.jquery.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
<script src="/js/socket.io-1.3.5.js"></script>
<script src="/js/theme.js"></script>