mubot-server
Version:
A server for mubot
513 lines (511 loc) • 22.1 kB
JavaScript
'use strict';
// Three dependencies.
angular.module('myApp', ['angularMoment', 'monospaced.elastic', 'ngDisableScroll'])
.config(['$locationProvider', '$compileProvider', ($locationProvider, $compileProvider) => {
// optimizations.
$compileProvider.commentDirectivesEnabled(false);
$compileProvider.cssClassDirectivesEnabled(false);
$compileProvider.debugInfoEnabled(false);
// ensure html5 is required
$locationProvider.html5Mode({ enabled: true, requireBase: false })
}])
// Here we explicitly require injections (array format) such that during minitifcation the implicit calls are not needed.
.controller('mainController', ['$scope', '$http', '$location', '$timeout', '$window', 'socket', ($scope, $http, $location, $timeout, $window, socket) => {
$scope.pmRightOffSet = function(index) { return $window.innerWidth >= 460 ? ((index) * 310 + 160 + 'px') : '85px' }
// Start variable declarations.
$scope.usersOnline = {}; $scope.data = {}; $scope.replies = {}; $scope.posts = {};
// The users state which can be a post id, "home", "wallet", or "profile".
$scope.state = (document.cookie.split('state=')[1] || 'home').split(';')[0];
// Hack to pad the replies container bellow the selected post.
$scope.loaded = {};
// Post id's that are awaiting a marking reason.
$scope.messageNeeded = {};
// Withdrawel information. {amount: Number, address: String}
$scope.withdraw = {};
// Keep track of whoes typing. {[user.username]: Timer}
const TYPING_TIMER = {};
// Keep track of the last time user sent a pm. {[user.username]: Date}
const LAST_PM_TIME = {};
// trans = { txid: String, amount: Number, address: String, type: "withdraw/deposit" }
socket.emit('transactions', {}, trans => $scope.trans = trans);
// Load all posts and create shortcuts.
$http.get('/bitmark/api/posts').then(res => {
for(let i = 0, l = res.data.length; i < l; ++i) {
let post = res.data[i];
$scope.posts[post._id] = post;
$scope.replies[post.replyto] ? $scope.replies[post.replyto].push(post) : $scope.replies[post.replyto] = [post]
}
});
// Load user specific data.
$http.get('/bitmark/api/info').then(res => {
$scope.pms = res.data.pms || [];
delete res.data.pms; // Makes things cleaner.
delete res.data._id; delete res.data.__v;
// I merge in awaitingPms: {} because mongoose returns nothing upon empty obj.
Object.assign($scope.data, {awaitingPms: {}}, res.data);
var PM_WINDOWS = $scope.pms.length;
var PM_WIDTH = 300, SPACING = 10, USERLIST_WIDTH = 150;
for(let i = 0, l = PM_WINDOWS; i < l; ++i) {
if(PM_WINDOWS * (PM_WIDTH + SPACING) + USERLIST_WIDTH > $window.innerWidth) {
if($scope.pms.length !== 1) // Try to leave just one pm available (or its useless, or needs to be moved to a seperate page)
$scope.pms.shift()
}
}
for(let i = 0, l = PM_WINDOWS; i < l; ++i) {
let pm = $scope.pms[i];
if(pm.maximized && Object.keys($scope.data.awaitingPms).includes(pm.username)) {
$timeout(() => {
socket.emit('private message read', pm);
delete $scope.data.awaitingPms[pm.username]
}, 5000)
}
// Request pm data, I merge in exists: true here so that the backend knows this window
// isn't being opened. Its just being loaded, minimized windows are requested too.
socket.emit('private message open', Object.assign($scope.pms[i], {exists: true}))
}
if($scope.state.length === 24) {
// If they are visiting a post then remove notifications on that post.
for(let i = 0, l = $scope.data.notifications.length; i < l; ++i) {
if($scope.state === $scope.data.notifications[i].id) {
$http.get('/bitmark/api/delete_notification/' + $scope.state);
$scope.data.notifications.splice(i, 1);
break
}
}
}
// Define an amount propery on our notifications array, it loops through all the notifications adding up their amount feild. returning the total.
Object.defineProperty($scope.data.notifications, 'amount', { get: function(){ var n=0; for(let i=0, l=this.length; i<l; ++i) n+=this[i].amount; return n } });
// Here we use _amount so as to not collide with a potential user named amount. (_ at the start of names is disallowed).
Object.defineProperty($scope.data.awaitingPms, '_amount', { get: function(){ var n=0; for(let i=0, k=Object.keys(this), l=k.length; i<l; ++i) n+=this[k[i]]; return n } });
$scope.data.awaitingPms._amount && alert('You have ' + $scope.data.awaitingPms._amount + ' pm(s) awaiting you. \n\nThis message will be replaced by the UI Soon =).')
});
// Request the users online.
socket.emit('users online', {}, usersOnline => {
delete usersOnline[$scope.data.username];
$scope.usersOnline = usersOnline;
var amount = 0;
for(let i = 0, keys = Object.keys(usersOnline), l = keys.length; i < l; i++) {
if(usersOnline[keys[i]].status !== 'offline') amount++
}
$scope.amountOnline = amount
});
$scope.withdrawMarks = (amount, toAddress) => {
if(!$scope.withdraw.amount || !$scope.withdraw.address) return;
socket.emit('withdraw', {amount: amount, toAddress: toAddress});
$scope.withdraw.amount = '';
$scope.withdraw.address = ''
};
// Create a heart beat monitor to see if users are still really online.
var status, recentlyAway, away, gone, offline;
$window.onbeforeunload = () => socket.emit('status update', 'offline');
$window.onmousemove = $window.onkeydown = $window.onfocus = _.throttle(()=> {
if(status === 'online') return;
socket.emit('status update', status = 'online');
$timeout.cancel(recentlyAway); $timeout.cancel(away); $timeout.cancel(gone); $timeout.cancel(offline);
recentlyAway = $timeout(()=> socket.emit('status update', status = 'recently-away'), 330000);
away = $timeout(()=> socket.emit('status update', status = 'away'), 700000);
gone = $timeout(()=> socket.emit('status update', status = 'gone'), 1990000);
offline = $timeout(()=> socket.emit('status update', status = 'offline'), 8000000)
}, 500);
$window.onblur = () => $timeout(() => {
if(status === 'away') return;
$timeout.cancel(recentlyAway); $timeout.cancel(away);
socket.emit("status update", status = 'away')
}, 700);
socket.on('status update', user => {
if(!$scope.usersOnline[user.username]) return;
user.status === 'offline' && --$scope.amountOnline;
user.status === 'online' && $scope.usersOnline[user.username].status === 'offline' && ++$scope.amountOnline;
$scope.usersOnline[user.username].status = user.status
});
// If the script has been disconnected for multiple attempts reload the script to ensure no data loss.
socket.on('reconnect', attempts => attempts > 1 && ($window.location.href = '/bitmark'));
// Process the new post.
socket.on('new post', post => {
$scope.posts[post._id] = post;
$scope.replies[post.replyto] ? $scope.replies[post.replyto].unshift(post) : $scope.replies[post.replyto] = [post];
if(post.ismarking) {
$scope.usersOnline[post.replyto_user] && ++$scope.usersOnline[post.replyto_user].balance;
post.username !== $scope.data.username && --$scope.usersOnline[post.username].balance;
$scope.posts[post.replyto] && ++$scope.posts[post.replyto].marks
}
// Post is replying to user.
if(post.replyto_user === $scope.data.username) {
// Post is also marking user
if(post.ismarking) ++$scope.data.balance;
// no message so assume its a marking a pm.
if(post.ismarking && !post.message) {
for(let i = 0, l = $scope.pms.length; i < l; ++i) {
// Post is marking user and user has pm window open with
// the user creating the marking.
if($scope.pms[i].username === post.username) {
let msgs = $scope.pms[i].history;
for(let i = 0, l = msgs.length; i < l; ++i) {
// Post is marking users pm.
if(post.replyto === msgs[i]._id) {
msgs[i].ismarked = true;
++$scope.data.balance;
return
}
}
}
}
}
// Loop through user's notifications to check if post already has notifications.
for(let i = 0, l = $scope.data.notifications.length; i < l; ++i) {
// User already has notifications about this post, increment amount.
if(post.replyto === $scope.data.notifications[i].id) return ++$scope.data.notifications[i].amount
}
// User doesnt have notifications about this post, add it.
$scope.data.notifications.push({id: post.replyto, amount: 1, message: post.message})
}
});
socket.on('deposit', data => {
if(data.username === $scope.data.username) {
data.date = new Date;
data.type = 'deposit';
delete data.username;
$scope.trans.unshift(data);
return $scope.data.balance += data.amount
}
$scope.usersOnline[data.username].balace += data.amount
});
socket.on('withdraw', data => {
if(data.username === $scope.data.username) {
data.date = new Date;
data.type = 'withdraw';
delete data.username;
$scope.trans.push(data);
return $scope.data.balance -= data.amount
}
$scope.usersOnline[data.username].balace -= data.amount
});
socket.on('private message data', populatePmWindow);
socket.on('user logged in', user => $scope.usersOnline[user.username] = user && ++$scope.amountOnline);
socket.on('user logged out', user => delete $scope.usersOnline[user] && --$scope.amountOnline);
socket.on('error', _=>0);
$scope.notificationClicked = notification => {
$scope.select({'_id' : notification.id });
for(let i = 0, l = $scope.data.notifications.length; i < l; ++i) {
if(notification.id === $scope.data.notifications[i].id) {
$scope.data.notifications.splice(i, 1);
break
}
}
$http.get('/bitmark/api/delete_notification/' + notification.id)
};
// If the marking reason got blurred, wait and see if it gets canceled. If not mark it.
$scope.reasonBlur = post => {
$timeout(() => {
$scope.messageNeeded[post._id] && $scope.mark(false, post)
}, 200)
};
$scope.closePmWindow = pm => {
for(let i = 0, l = $scope.pms.length; i < l; ++i) {
if(pm === $scope.pms[i]) {
$scope.pms.splice(i, 1);
break
}
}
socket.emit('private message close', pm)
};
// Checks if the pms fit on the screen.
$(window).on('resize', _.throttle(() => {
//$scope.$apply(() => {
const PM_WINDOWS = $scope.pms.length;
const PM_WIDTH = 300, SPACING = 10, USERLIST_WIDTH = 150;
if(PM_WINDOWS * (PM_WIDTH + SPACING) + USERLIST_WIDTH > $window.innerWidth) {
// pms dont fit, remove one.
//AxE
if($scope.pms.length === 1) {
return; // Try to leave just one pm available (or its useless, or needs to be moved to a seperate page)
}
$scope.pms.shift()
}
//})
}, 200));
// return true if we need to sip msg.
$scope.skipSameMin = () => $scope.skipSameMins && $scope.skipSameMins--;
// The getSameMin function groups all the msgs with the same min into 1 block then
// it sets skipSameMins to the amount of msgs it grouped, so we can skip displaying them.
$scope.getSameMin = (history, index) => {
var results = [];
var i = index;
// While the next history element has the same minnute. So i represents, relative to index, the amount found.
while(history[i+1] && history[i].sender === history[i+1].sender && $scope.sameMin(history[i].date, history[i+1].date)) {
results.push(history[i++].message + '\n')
}
if(i > index) {
// The above while loop doesnt add the last i.
results.push(history[i].message);
// Make sure to skip over all the entries with same min
// Since we are grouping them up into one next command.
$scope.skipSameMins = results.length - 1;
return results.join('')
} else {
// The next message is a diff min. so return msg alone.
return history[index].message
}
};
// Returns true if prev and cur message have same day/min respectivly.
$scope.sameDay = (prev, cur) => new Date(prev).getDay() === new Date(cur).getDay();
$scope.sameMin = (prev, cur) => prev - cur < 60000;
$scope.togglePmWindow = pm => {
// If pm window is open and dontMinimize is true return. (user clicked settings)
if(pm.maximized && $scope.data.dontMinimize) return $scope.data.dontMinimize = false;
pm.maximized = !pm.maximized;
pm.maximized && delete $scope.data.awaitingPms[pm.username];
socket.emit('private message toggle', pm)
};
function populatePmWindow(pms) {
if(!pms[0]) return;
for(let i = 0, l = $scope.pms.length; i < l; ++i) {
let pm = $scope.pms[i];
// Loop through current pms and update only the right pm window.
if(pms[0].receiver === pm.username || pms[0].sender === pm.username) {
pm.history = pms
}
}
}
// Throttle here to improve performance if typing is nonstop.
$scope.startedTyping = _.throttle(pm => {
// pm.username is the person receiving the pm.
socket.emit('private_messages typing', {user: pm.username, started: true})
}, 500);
socket.on('private_messages typing', data => {
for(let i = 0, l = $scope.pms.length; i < l; ++i) {
let pm = $scope.pms[i];
if(data.user === pm.username) {
// They finished typing.
if(data.finished) {
pm.istyping = false;
$timeout.cancel(TYPING_TIMER[pm.username])
} else {
// They started typing.
pm.istyping = true;
$timeout.cancel(TYPING_TIMER[pm.username]);
TYPING_TIMER[pm.username] = $timeout(() => pm.istyping = false, 3750)
}
return
}
}
});
$scope.openPmWindow = user => {
const PM_WIDTH = 300, SPACING = 10, USERLIST_WIDTH = 150;
const PM_WINDOWS = $scope.pms.length;
for(let i = 0; i < PM_WINDOWS; ++i) {
let pm = $scope.pms[i];
if(user.username === pm.username) {
// The window exists, and is mazimized, so remove its notifications before we toggle it.
pm.maximized && delete $scope.data.awaitingPms[pm.username];
$scope.togglePmWindow(pm);
return
}
}
// Check if the new pm window we are adding will fit on the screen.
if((PM_WINDOWS+1) * (PM_WIDTH + SPACING) + USERLIST_WIDTH > $window.innerWidth) {
$scope.pms.shift()
}
let pm = { text: '', history: [], maximized: true, istyping: false, username: user.username };
$scope.pms.push(pm);
socket.emit('private message open', pm)
};
$scope.markPm = pm => {
socket.emit('mark private message', pm, marked => marked && (pm.ismarked = true) && --$scope.data.balance)
};
$scope.mark = ($event, post) => {
$event && $event.stopPropagation();
// Dont allow self marks, and make sure they have available funds.
if($scope.data.username === post.username || !$scope.data.balance) return;
// Toggle reason textarea.
$scope.messageNeeded[post._id] = !$scope.messageNeeded[post._id];
// Return requesting a reason if its toggled on.
if($scope.messageNeeded[post._id]) return;
// Reason was provided, request the marking.
post.marking_msg = $scope.data.marking_msg;
$scope.data.marking_msg = '';
$http.post('/bitmark/api/mark/', post).then(()=> --$scope.data.balance)
};
$scope.select = post => {
if($scope.replyto === post._id) return;
for(let i = 0, l = $scope.data.notifications.length; i < l; ++i) {
if(post._id === $scope.data.notifications[i].id) {
$http.get('/bitmark/api/delete_notification/' + post._id);
$scope.data.notifications.splice(i, 1);
break
}
}
$scope.replyto = $scope.state = post._id;
document.cookie = 'state=' + post._id + '; expires=Thu, 01 Jan 2222 00:00:01 GMT; path=/;';
post.scrollto = document.documentElement.scrollTop || document.body.scrollTop;
$scope.updateState('/bitmark/post/', post._id, post.scrollto)
};
$scope.updateState = (path, state, scroll) => {
scroll = scroll || 0;
if(state === 'home' && $scope.replyto) state = $scope.replyto;
$location.old = $location.state() || {};
$location.new = {
replyto: $scope.replyto,
state: state,
scrollto: scroll,
// This little algo keeps track of the users original scroll position
// should the user ever hit the big red X indicating back to home.
allscrollto: !$location.old.replyto ? scroll : $location.old.allscrollto
};
if($location.new.state === $location.old.state) return;
state === 'home' && (state = '');
$location.state($location.new).path(path + state)
};
// Watch for location changes so we can apply state accordingly
$scope.$on('$locationChangeSuccess', (_, __, ___, ____, oldstate) => {
var newstate = $location.state();
// The first request wont have newstate.
if(!newstate) return $scope.replyto = $scope.state.length === 24 ? $scope.state : '';
$scope.replyto = newstate.replyto;
$scope.state = newstate.state;
document.cookie = 'state=' + newstate.state + '; expires=Thu, 01 Jan 2222 00:00:01 GMT; path=/;';
if(!oldstate) oldstate = {};
$timeout(() => $window.scrollTo(0, !newstate.replyto ? newstate.allscrollto : oldstate.scrollto || 0), 0)
});
$scope.postGlow = post => {
return { 'box-shadow': '0 0 ' + post.marks / 2 + 'px purple' }
};
$scope.mergeStyles = styles => {
var obj = {};
for(let i = 0, l = styles.length; i < l; ++i) {
Object.assign(obj, styles[i])
}
return obj
};
$scope.back = ($event, post) => {
$event.stopPropagation();
document.cookie = 'state=' + post.replyto + '; expires=Thu, 01 Jan 2222 00:00:01 GMT; path=/;';
if (post.replyto) {
$scope.state = post.replyto;
$scope.replyto = post.replyto;
$scope.updateState('/bitmark/post/', post.replyto)
} else {
$scope.replyto = '';
$scope.updateState('/bitmark/', 'home')
}
};
socket.on('new private message', pm => {
var found = false;
for(let i = 0, l = $scope.pms.length; i < l; ++i) {
if(pm.sender === $scope.pms[i].username || pm.receiver === $scope.pms[i].username) {
$scope.pms[i].history.push(pm);
$scope.pms[i].maximized && (found = true)
}
}
if(!found) {
let awaiting = $scope.data.awaitingPms;
let name = pm.sender;
if(!LAST_PM_TIME[name]) {
LAST_PM_TIME[name] = new Date;
awaiting[name] = awaiting[name] || 0;
++awaiting[name];
return
}
if(Date.now() - LAST_PM_TIME[name] > 60000) ++awaiting[name];
LAST_PM_TIME[name] = new Date
}
});
$scope.createPm = ($event, pm) => {
$event.preventDefault();
// Used to indicate the pm is new.
var sameMin = false;
var last;
if(last = pm.history[pm.history.length - 1]) {
sameMin = Date.now() - last.date > 60000
}
socket.emit('new private message', {message: pm.text, username: pm.username, sameMin: sameMin});
pm.text = ''
};
var tries = 0;
$scope.create = $event => {
// In case of error try again. Every once and a while theres
// an error here, idk why, but this solves the problem.
if(!$scope.data.message.trim()) return;
$event.preventDefault();
$scope.data.message = '';
$event.target.blur();
$event.target.rows = 1;
$http.post('/bitmark/api/create', { 'message': $scope.data.message, 'replyto': $scope.replyto }).then(res => {
// Post created successfully.
}).catch(err => {
// Error creating, try again.
++tries < 3 && $scope.create($event)
})
};
$scope.cancelMarking = ($event, post) => {
$event.stopPropagation();
$scope.messageNeeded[post._id] = false;
$scope.data.marking_msg = ''
};
$scope.logout = () => {
socket.emit('log out');
document.cookie = 'login-cookie' + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/;';
document.cookie = 'state' + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/;';
$window.location.href = '/bitmark'
};
}])
.filter('reverse', () => {
return items => {
if(items) return items.slice().reverse()
}
})
.directive('autoFocus', () => {
return {
link: {
post: (scope, element) => element[0].focus()
}
}
})
.directive('autoscroll', () => {
return {
restrict: 'A',
link: (scope, elem, attr) => {
scope.$watch(() => {
return elem[0].scrollHeight
}, (newVal, oldVal) => {
// This directive is just for pms and
// 275px is our pm initial size, so skip it.
if (!newVal || newVal === '275px') return;
elem[0].scrollTop = elem[0].scrollHeight
})
}
}
})
.directive('loaded', () => {
var navBarLoaded = false;
return {
restrict: 'A',
link: (scope, elem, attr) => {
scope.$watch(() => {
try { return document.getElementById(attr.loaded).scrollHeight } catch(e) { return }
}, (newVal, oldVal) => {
// newVal - oldVal == 42 is a hack to fix bug: (textarea is sending 2 scrollheight updates when its drawn)
if(!newVal || newVal - oldVal == 42 || (attr.loaded === 'navbar' && navBarLoaded)) return;
if(!navBarLoaded) navBarLoaded = true;
elem[0].style.paddingTop = newVal + 10 + 'px'
})
}
}
})
.factory('socket', ['$rootScope', function ($rootScope) {
const socket = io('/bitmark');
return {
on: function (eventName, callback) {
socket.on(eventName, function() {
const args = arguments;
$rootScope.$apply(() => callback.apply(socket, args))
})
},
emit: function (eventName, data, callback) {
socket.emit(eventName, data, function() {
const args = arguments;
callback && $rootScope.$apply(() => callback.apply(socket, args))
})
}
}
}]);