define([ '/api/config', '/api/broadcast', '/common/common-util.js', '/common/common-hash.js', '/common/common-realtime.js', '/common/common-messaging.js', '/common/notify.js', '/common/outer/mailbox-handlers.js', '/bower_components/chainpad-netflux/chainpad-netflux.js', '/bower_components/chainpad-crypto/crypto.js', ], function (Config, BCast, Util, Hash, Realtime, Messaging, Notify, Handlers, CpNetflux, Crypto) { var Mailbox = {}; var TYPES = [ 'notifications', 'supportadmin', 'support', 'broadcast' ]; var BLOCKING_TYPES = [ ]; var BROADCAST_CHAN = '000000000000000000000000000000000'; // Admin channel, 33 characters var initializeMailboxes = function (ctx, mailboxes) { if (!mailboxes['notifications'] && ctx.loggedIn) { mailboxes.notifications = { channel: Hash.createChannelId(), lastKnownHash: '', viewed: [] }; ctx.pinPads([mailboxes.notifications.channel], function (res) { if (res.error) { console.error(res); } }); } if (!mailboxes['support'] && ctx.loggedIn) { mailboxes.support = { channel: Hash.createChannelId(), lastKnownHash: '', viewed: [] }; ctx.pinPads([mailboxes.support.channel], function (res) { if (res.error) { console.error(res); } }); } if (!mailboxes['broadcast']) { mailboxes.broadcast = { channel: BROADCAST_CHAN, lastKnownHash: BCast.lastBroadcastHash, decrypted: true, viewed: [] }; } }; /* proxy.mailboxes = { friends: { channel: '', lastKnownHash: '', viewed: [] } }; */ var isMessageNew = function (hash, m) { return (m.viewed || []).indexOf(hash) === -1 && hash !== m.lastKnownHash; }; var showMessage = function (ctx, type, msg, cId, cb) { ctx.emit('MESSAGE', { type: type, content: msg }, cId ? [cId] : ctx.clients, cb); }; var hideMessage = function (ctx, type, hash, clients) { ctx.emit('VIEWED', { type: type, hash: hash }, clients || ctx.clients); }; var getMyKeys = function (ctx) { var proxy = ctx.store && ctx.store.proxy; if (!proxy.curvePrivate || !proxy.curvePublic) { return; } return { curvePrivate: proxy.curvePrivate, curvePublic: proxy.curvePublic }; }; // Send a message to someone else var sendTo = Mailbox.sendTo = function (ctx, type, msg, user, _cb) { user = user || {}; var cb = _cb || function (obj) { if (obj && obj.error) { console.error(obj.error); } }; if (!Crypto.Mailbox) { return void cb({error: "chainpad-crypto is outdated and doesn't support mailboxes."}); } var anonRpc = Util.find(ctx, [ 'store', 'anon_rpc', ]); if (!anonRpc) { return void cb({error: "anonymous rpc session not ready"}); } // Broadcast mailbox doesn't use encryption. Sending messages there is restricted // to admins in the server directly var crypto = { encrypt: function (x) { return x; } }; var channel = BROADCAST_CHAN; var obj = { uid: Util.uid(), // add uid at the beginning to have a unique server hash type: type, content: msg }; if (!/^BROADCAST/.test(type)) { var keys = getMyKeys(ctx); if (!keys) { return void cb({error: "missing asymmetric encryption keys"}); } if (!user || !user.channel || !user.curvePublic) { return void cb({error: "no notification channel"}); } channel = user.channel; crypto = Crypto.Mailbox.createEncryptor(keys); // Always send your data if (typeof(msg) === "object" && !msg.user) { var myData = Messaging.createData(ctx.store.proxy, false); msg.user = myData; } obj = { type: type, content: msg }; } var text = JSON.stringify(obj); var ciphertext = crypto.encrypt(text, user.curvePublic); // If we've sent this message to one of our teams' mailbox, we may want to "dismiss" it // automatically if (user.viewed) { var team = Util.find(ctx, ['store', 'proxy', 'teams', user.viewed]); if (team) { var hash = ciphertext.slice(0,64); var viewed = Util.find(team, ['keys', 'mailbox', 'viewed']); if (Array.isArray(viewed)) { viewed.push(hash); } } } anonRpc.send("WRITE_PRIVATE_MESSAGE", [ channel, ciphertext ], function (err /*, response */) { if (err) { return void cb({ error: err, }); } return void cb({ hash: ciphertext.slice(0,64) }); }); }; // Mark a message as read var dismiss = function (ctx, data, cId, cb) { var type = data.type; var hash = data.hash; var box = ctx.boxes[type]; if (!box) { return void cb({error: 'NOT_LOADED'}); } var m = box.data || {}; // If the hash in in our history, get the index from the history: // - if the index is 0, we can change our lastKnownHash // - otherwise, just push to view var idx = box.history.indexOf(hash); if (idx !== -1) { if (idx === 0) { m.lastKnownHash = hash; box.history.shift(); } else if (m.viewed.indexOf(hash) === -1) { m.viewed.push(hash); } } // Clear data in memory if needed // Check the "viewed" array to see if we're able to bump lastKnownhash more var sliceIdx; var lastKnownHash; var toForget = []; box.history.some(function (hash, i) { // naming here is confusing... isViewed implies it's a boolean // when in fact it's an index var isViewed = m.viewed.indexOf(hash); // iterate over your history until you hit an element you haven't viewed if (isViewed === -1) { return true; } // update the index that you'll use to slice off viewed parts of history sliceIdx = i + 1; // keep track of which hashes you should remove from your 'viewed' array toForget.push(hash); // prevent fetching dismissed messages on (re)connect lastKnownHash = hash; }); // remove all elements in 'toForget' from the 'viewed' array in one step m.viewed = m.viewed.filter(function (hash) { return toForget.indexOf(hash) === -1; }); if (sliceIdx) { box.history = box.history.slice(sliceIdx); m.lastKnownHash = lastKnownHash; } // Make sure we remove data about dismissed messages Object.keys(box.content).forEach(function (h) { if (box.history.indexOf(h) === -1 || m.viewed.indexOf(h) !== -1) { Handlers.remove(ctx, box, box.content[h], h); delete box.content[h]; } }); Realtime.whenRealtimeSyncs(ctx.store.realtime, function () { cb(); hideMessage(ctx, type, hash, ctx.clients.filter(function (clientId) { return clientId !== cId; })); }); }; var leaveChannel = function (ctx, type, cb) { var box = ctx.boxes[type]; if (!box) { return void cb(); } if (!box.cpNf || typeof(box.cpNf.stop) !== "function") { return void cb('EINVAL'); } box.cpNf.stop(); Object.keys(box.content).forEach(function (h) { Handlers.remove(ctx, box, box.content[h], h); hideMessage(ctx, type, h, ctx.clients); }); delete ctx.boxes[type]; }; var openChannel = function (ctx, type, m, onReady, opts) { opts = opts || {}; var box = ctx.boxes[type] = { channel: m.channel, type: type, queue: [], // Store the messages to send when the channel is ready history: [], // All the hashes loaded from the server in corretc order content: {}, // Content of the messages that should be displayed sendMessage: function (msg) { // To send a message to our box // Always send your data if (typeof(msg) === "object" && !msg.user) { var myData = Messaging.createData(ctx.store.proxy, false); msg.user = myData; } try { msg = JSON.stringify(msg); } catch (e) { console.error(e); } box.queue.push(msg); }, data: m }; if (!Crypto.Mailbox) { return void console.error("chainpad-crypto is outdated and doesn't support mailboxes."); } var keys = m.keys || getMyKeys(ctx); if (!keys && !m.decrypted) { return void console.error("missing asymmetric encryption keys"); } var crypto = m.decrypted ? { encrypt: function (x) { return x; }, decrypt: function (x) { return x; } } : Crypto.Mailbox.createEncryptor(keys); box.encryptor = crypto; var cfg = { network: ctx.store.network, channel: m.channel, noChainPad: true, crypto: crypto, owners: type === 'broadcast' ? [] : (opts.owners || [ctx.store.proxy.edPublic]), lastKnownHash: m.lastKnownHash }; cfg.onConnectionChange = function () {}; // Allow reconnections in chainpad-netflux cfg.onConnect = function (wc, sendMessage) { // Send a message to our box? // NOTE: we use our own curvePublic so that we can decrypt our own message :) box.sendMessage = function (_msg, cb) { cb = cb || function () {}; var msg; try { msg = JSON.stringify(_msg); } catch (e) { console.error(e); } sendMessage(msg, function (err, hash) { if (err) { return void console.error(err); } box.history.push(hash); _msg.ctime = +new Date(); box.content[hash] = _msg; var message = { msg: _msg, hash: hash }; showMessage(ctx, type, message); cb(hash); }, keys.curvePublic); }; box.queue.forEach(function (msg) { box.sendMessage(msg); }); box.queue = []; }; var lastReceivedHash; // Don't send a duplicate of the last known hash on reconnect box.onMessage = cfg.onMessage = function (msg, user, vKey, isCp, hash, author, data) { if (hash === m.lastKnownHash) { return; } if (hash === lastReceivedHash) { return; } var time = data && data.time; lastReceivedHash = hash; try { msg = JSON.parse(msg); } catch (e) { console.error(e); } if (author) { msg.author = author; } box.history.push(hash); if (isMessageNew(hash, m)) { // Message should be displayed var message = { msg: msg, hash: hash }; var notify = box.ready; Handlers.add(ctx, box, message, function (dismissed, toDismiss) { if (toDismiss) { // List of other messages to remove dismiss(ctx, toDismiss, '', function () { console.log('Notification handled automatically'); }); } if (dismissed) { // This message should be removed dismiss(ctx, { type: type, hash: hash }, '', function () { console.log('Notification handled automatically'); }); return; } msg.ctime = time || 0; box.content[hash] = msg; showMessage(ctx, type, message, null, function (obj) { if (!obj || !obj.msg || !notify) { return; } Notify.system(undefined, obj.msg); }); }); } else { // Message has already been viewed by the user if (Object.keys(box.content).length === 0) { // If nothing is displayed yet, we can bump our lastKnownHash and remove this hash // from our "viewed" array m.lastKnownHash = hash; box.history = []; var idxViewed = m.viewed.indexOf(hash); if (idxViewed !== -1) { m.viewed.splice(idxViewed, 1); } } } }; cfg.onReady = function () { // Clean the "viewed" array: make sure all the "viewed" hashes are // in history var toClean = []; m.viewed.forEach(function (h, i) { if (box.history.indexOf(h) === -1) { toClean.push(i); } }); for (var i = toClean.length-1; i>=0; i--) { m.viewed.splice(toClean[i], 1); } // Listen for changes in the "viewed" and lastKnownHash values var view = function (h) { Handlers.remove(ctx, box, box.content[h], h); delete box.content[h]; hideMessage(ctx, type, h); }; ctx.store.proxy.on('change', ['mailboxes', type], function (o, n, p) { if (p[2] === 'lastKnownHash') { // Hide everything up to this hash var sliceIdx; box.history.some(function (h, i) { sliceIdx = i + 1; view(h); if (h === n) { return true; } }); box.history = box.history.slice(sliceIdx); } if (p[2] === 'viewed') { // Hide this message view(n); } }); box.ready = true; // Continue onReady(); }; box.cpNf = CpNetflux.start(cfg); }; var initializeHistory = function (ctx) { var network = ctx.store.network; network.on('message', function (msg, sender) { if (sender !== network.historyKeeper) { return; } var parsed = JSON.parse(msg); if (!/HISTORY_RANGE/.test(parsed[0])) { return; } var txid = parsed[1]; var req = ctx.req[txid]; if (!req) { return; } var type = parsed[0]; var _msg = parsed[2]; var box = req.box; if (type === 'HISTORY_RANGE') { if (!Array.isArray(_msg)) { return; } var message; if (req.box.type === 'broadcast') { message = Util.tryParse(_msg[4]); } else { try { var decrypted = box.encryptor.decrypt(_msg[4]); message = JSON.parse(decrypted.content); message.author = decrypted.author; } catch (e) { console.log(e); } } ctx.emit('HISTORY', { txid: txid, time: _msg[5], message: message, hash: _msg[4].slice(0,64) }, [req.cId]); } else if (type === 'HISTORY_RANGE_END') { ctx.emit('HISTORY', { txid: txid, complete: true }, [req.cId]); delete ctx.req[txid]; } }); }; var loadHistory = function (ctx, clientId, data, cb) { var box = ctx.boxes[data.type]; if (!box) { return void cb({error: 'ENOENT'}); } var msg = [ 'GET_HISTORY_RANGE', box.channel, { from: data.lastKnownHash, count: data.count, txid: data.txid } ]; if (data.type === 'broadcast') { msg = [ 'GET_HISTORY_RANGE', box.channel, { to: data.lastKnownHash, txid: data.txid } ]; } ctx.req[data.txid] = { cId: clientId, box: box }; var network = ctx.store.network; network.sendto(network.historyKeeper, JSON.stringify(msg)).then(function () { }, function (err) { console.error(err); }); }; var subscribe = function (ctx, data, cId, cb) { // Get existing notifications Object.keys(ctx.boxes).forEach(function (type) { Object.keys(ctx.boxes[type].content).forEach(function (h) { var message = { msg: ctx.boxes[type].content[h], hash: h }; showMessage(ctx, type, message, cId); }); }); // Subscribe to new notifications var idx = ctx.clients.indexOf(cId); if (idx === -1) { ctx.clients.push(cId); } cb(); }; var removeClient = function (ctx, cId) { var idx = ctx.clients.indexOf(cId); ctx.clients.splice(idx, 1); }; Mailbox.init = function (cfg, waitFor, emit) { var mailbox = {}; var store = cfg.store; var mailboxes = store.proxy.mailboxes = store.proxy.mailboxes || {}; var ctx = { Store: cfg.Store, store: store, pinPads: cfg.pinPads, updateMetadata: cfg.updateMetadata, updateDrive: cfg.updateDrive, mailboxes: mailboxes, emit: emit, clients: [], boxes: {}, req: {}, loggedIn: store.loggedIn && store.proxy.edPublic }; initializeMailboxes(ctx, mailboxes); if (ctx.loggedIn) { initializeHistory(ctx); } Object.keys(mailboxes).forEach(function (key) { if (TYPES.indexOf(key) === -1) { return; } var m = mailboxes[key]; if (BLOCKING_TYPES.indexOf(key) === -1) { openChannel(ctx, key, m, function () { //console.log(key + ' mailbox is ready'); }); } else { openChannel(ctx, key, m, waitFor(function () { //console.log(key + ' mailbox is ready'); })); } }); if (ctx.loggedIn) { Object.keys(store.proxy.teams || {}).forEach(function (teamId) { var team = store.proxy.teams[teamId]; if (!team) { return; } var teamMailbox = team.keys.mailbox || {}; if (!teamMailbox.channel) { return; } var opts = { owners: [Util.find(team, ['keys', 'drive', 'edPublic'])] }; openChannel(ctx, 'team-'+teamId, teamMailbox, function () { //console.log('Mailbox team', teamId); }, opts); }); } mailbox.post = function (box, type, content) { var b = ctx.boxes[box]; if (!b) { return; } b.sendMessage({ type: type, content: content, sender: store.proxy.curvePublic }); }; mailbox.open = function (key, m, cb, team, opts) { if (TYPES.indexOf(key) === -1 && !team) { return; } openChannel(ctx, key, m, cb, opts); }; mailbox.close = function (key, cb) { leaveChannel(ctx, key, cb); }; mailbox.dismiss = function (data, cb) { dismiss(ctx, data, '', cb); }; mailbox.sendTo = function (type, msg, user, cb) { if (!ctx.loggedIn) { return void cb({error:'NOT_LOGGED_IN'}); } sendTo(ctx, type, msg, user, cb); }; mailbox.removeClient = function (clientId) { removeClient(ctx, clientId); }; mailbox.execCommand = function (clientId, obj, cb) { var cmd = obj.cmd; var data = obj.data; if (cmd === 'SUBSCRIBE') { return void subscribe(ctx, data, clientId, cb); } if (cmd === 'DISMISS') { return void dismiss(ctx, data, clientId, cb); } if (cmd === 'SENDTO') { return void sendTo(ctx, data.type, data.msg, data.user, cb); } if (cmd === 'LOAD_HISTORY') { return void loadHistory(ctx, clientId, data, cb); } }; return mailbox; }; return Mailbox; });