diff --git a/customize.dist/src/less2/main.less b/customize.dist/src/less2/main.less index ab75575c8..a81b13e82 100644 --- a/customize.dist/src/less2/main.less +++ b/customize.dist/src/less2/main.less @@ -30,6 +30,7 @@ body.cp-app-code { @import "../../../code/app-code.less"; } body.cp-app-slide { @import "../../../slide/app-slide.less"; } body.cp-app-file { @import "../../../file/app-file.less"; } body.cp-app-filepicker { @import "../../../filepicker/app-filepicker.less"; } +body.cp-app-contacts { @import "../../../contacts/app-contacts.less"; } //body.cp-app-poll { @import "../../../poll/app-poll.less"; } body.cp-app-whiteboard { @import "../../../whiteboard/app-whiteboard.less"; } diff --git a/customize.dist/translations/messages.js b/customize.dist/translations/messages.js index 924b112af..524ceb3e3 100644 --- a/customize.dist/translations/messages.js +++ b/customize.dist/translations/messages.js @@ -159,7 +159,9 @@ define(function () { out.or = 'or'; out.tags_title = "Tags"; - out.tags_add = "Update this pad's tags"; + out.tags_add = "Update this page's tags"; + out.tags_searchHint = "Find files by their tags by searching in your CryptDrive"; + out.tags_duplicate = "Duplicate tag: {0}"; out.slideOptionsText = "Options"; @@ -723,6 +725,7 @@ define(function () { out.tips.drive = "Logged in users can organize their files in their CryptDrive, accessible from the CryptPad icon at the top left of all pads."; out.tips.profile = "Registered users can create a profile from the user menu in the top right."; out.tips.avatars = "You can upload an avatar in your profile. People will see it when you collaborate in a pad."; + out.tips.tags = "You can hashtag pads, and then search by tag in your CryptDrive."; out.feedback_about = "If you're reading this, you were probably curious why CryptPad is requesting web pages when you perform certain actions"; out.feedback_privacy = "We care about your privacy, and at the same time we want CryptPad to be very easy to use. We use this file to figure out which UI features matter to our users, by requesting it along with a parameter specifying which action was taken."; diff --git a/www/common/common-interface.js b/www/common/common-interface.js index f85aec474..fee97a837 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -177,7 +177,10 @@ define([ var input = dialog.textInput(); var tagger = dialog.frame([ - dialog.message(Messages.tags_add), + dialog.message([ + Messages.tags_add, + h('p', Messages.tags_searchHint) + ]), input, dialog.nav(), ]); diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index f50bc1de2..322a1c73a 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -17,6 +17,7 @@ define([ var Cryptget; var sframeChan; var FilePicker; + var Messenger; nThen(function (waitFor) { // Load #2, the loading screen is up so grab whatever you need... @@ -27,13 +28,15 @@ define([ '/common/cryptget.js', '/common/sframe-channel.js', '/filepicker/main.js', + '/common/common-messenger.js', ], waitFor(function (_CpNfOuter, _Cryptpad, _Crypto, _Cryptget, SFrameChannel, - _FilePicker) { + _FilePicker, _Messenger) { CpNfOuter = _CpNfOuter; Cryptpad = _Cryptpad; Crypto = _Crypto; Cryptget = _Cryptget; FilePicker = _FilePicker; + Messenger = _Messenger; if (localStorage.CRYPTPAD_URLARGS !== ApiConfig.requireConf.urlArgs) { console.log("New version, flushing cache"); @@ -424,6 +427,106 @@ define([ cfg.addRpc(sframeChan, Cryptpad); } + if (cfg.messaging) { + var messenger = Messenger.messenger(Cryptpad); + + sframeChan.on('Q_CONTACTS_GET_FRIEND_LIST', function (data, cb) { + messenger.getFriendList(function (e, keys) { + cb({ + error: e, + data: keys, + }); + }); + }); + sframeChan.on('Q_CONTACTS_GET_MY_INFO', function (data, cb) { + messenger.getMyInfo(function (e, info) { + cb({ + error: e, + data: info, + }); + }); + }); + sframeChan.on('Q_CONTACTS_GET_FRIEND_INFO', function (curvePublic, cb) { + messenger.getFriendInfo(curvePublic, function (e, info) { + cb({ + error: e, + data: info, + }); + }); + }); + + sframeChan.on('Q_CONTACTS_OPEN_FRIEND_CHANNEL', function (curvePublic, cb) { + messenger.openFriendChannel(curvePublic, function (e) { + cb({ error: e, }); + }); + }); + + sframeChan.on('Q_CONTACTS_GET_STATUS', function (curvePublic, cb) { + messenger.getStatus(curvePublic, function (e, online) { + cb({ + error: e, + data: online, + }); + }); + }); + + sframeChan.on('Q_CONTACTS_GET_MORE_HISTORY', function (opt, cb) { + messenger.getMoreHistory(opt.curvePublic, opt.sig, opt.count, function (e, history) { + cb({ + error: e, + data: history, + }); + }); + }); + + sframeChan.on('Q_CONTACTS_SEND_MESSAGE', function (opt, cb) { + messenger.sendMessage(opt.curvePublic, opt.content, function (e) { + cb({ + error: e, + }); + }); + }); + sframeChan.on('Q_CONTACTS_SET_CHANNEL_HEAD', function (opt, cb) { + messenger.setChannelHead(opt.curvePublic, opt.sig, function (e) { + cb({ + error: e + }); + }); + }); + + messenger.on('message', function (message) { + sframeChan.event('EV_CONTACTS_MESSAGE', message); + }); + messenger.on('join', function (curvePublic, channel) { + sframeChan.event('EV_CONTACTS_JOIN', { + curvePublic: curvePublic, + channel: channel, + }); + }); + messenger.on('leave', function (curvePublic, channel) { + sframeChan.event('EV_CONTACTS_LEAVE', { + curvePublic: curvePublic, + channel: channel, + }); + }); + messenger.on('update', function (info, curvePublic) { + sframeChan.event('EV_CONTACTS_UPDATE', { + curvePublic: curvePublic, + info: info, + }); + }); + messenger.on('friend', function (curvePublic) { + sframeChan.event('EV_CONTACTS_FRIEND', { + curvePublic: curvePublic, + }); + }); + messenger.on('unfriend', function (curvePublic) { + sframeChan.event('EV_CONTACTS_UNFRIEND', { + curvePublic: curvePublic, + }); + }); + } + sframeChan.ready(); Cryptpad.reportAppUsage(); diff --git a/www/common/sframe-messenger-inner.js b/www/common/sframe-messenger-inner.js new file mode 100644 index 000000000..c0dced050 --- /dev/null +++ b/www/common/sframe-messenger-inner.js @@ -0,0 +1,110 @@ +define([], function () { + var MI = {}; + + MI.create = function (sFrameChan) { + var messenger = {}; + + var _handlers = { + message: [], + join: [], + leave: [], + update: [], + friend: [], + unfriend: [] + }; + + messenger.on = function (key, f) { + if (!_handlers[key]) { throw new Error('invalid event'); } + _handlers[key].push(f); + }; + + sFrameChan.on('EV_CONTACTS_MESSAGE', function (data) { + _handlers.message.forEach(function (f) { + f(data); + }); + }); + sFrameChan.on('EV_CONTACTS_JOIN', function (data) { + _handlers.join.forEach(function (f) { + f(data.curvePublic, data.channel); + }); + }); + sFrameChan.on('EV_CONTACTS_LEAVE', function (data) { + _handlers.leave.forEach(function (f) { + f(data.curvePublic, data.channel); + }); + }); + sFrameChan.on('EV_CONTACTS_UPDATE', function (data) { + _handlers.update.forEach(function (f) { + f(data.info, data.curvePublic); + }); + }); + sFrameChan.on('EV_CONTACTS_FRIEND', function (data) { + _handlers.friend.forEach(function (f) { + f(data.curvePublic); + }); + }); + sFrameChan.on('EV_CONTACTS_UNFRIEND', function (data) { + _handlers.unfriend.forEach(function (f) { + f(data.curvePublic); + }); + }); + + /*** QUERIES ***/ + messenger.getFriendList = function (cb) { + sFrameChan.query('Q_CONTACTS_GET_FRIEND_LIST', null, function (err, data) { + cb(err || data.error, data.data); + }); + }; + messenger.getMyInfo = function (cb) { + sFrameChan.query('Q_CONTACTS_GET_MY_INFO', null, function (err, data) { + cb(err || data.error, data.data); + }); + }; + messenger.getFriendInfo = function (curvePublic, cb) { + sFrameChan.query('Q_CONTACTS_GET_FRIEND_INFO', curvePublic, function (err, data) { + cb(err || data.error, data.data); + //cb({ error: err, data: data, }); + }); + }; + messenger.openFriendChannel = function (curvePublic, cb) { + sFrameChan.query('Q_CONTACTS_OPEN_FRIEND_CHANNEL', curvePublic, function (err, data) { + cb(err || data.error); + }); + }; + messenger.getStatus = function (curvePublic, cb) { + sFrameChan.query('Q_CONTACTS_GET_STATUS', curvePublic, function (err, data) { + cb(err || data.error, data.data); + }); + }; + + messenger.getMoreHistory = function (curvePublic, sig, count, cb) { + sFrameChan.query('Q_CONTACTS_GET_MORE_HISTORY', { + curvePublic: curvePublic, + sig: sig, + count: count + }, function (err, data) { + cb(err || data.error, data.data); + }); + }; + messenger.sendMessage = function (curvePublic, content, cb) { + sFrameChan.query('Q_CONTACTS_SEND_MESSAGE', { + content: content, + curvePublic: curvePublic, + }, function (err, data) { + cb(err || data.error); + }); + }; + messenger.setChannelHead = function (curvePublic, sig, cb) { + sFrameChan.query('Q_CONTACTS_SET_CHANNEL_HEAD', { + curvePublic: curvePublic, + sig: sig, + }, function (e, data) { + cb(e || data.error); + }); + }; + + return messenger; + }; + + return MI; +}); diff --git a/www/common/sframe-messenger-outer.js b/www/common/sframe-messenger-outer.js new file mode 100644 index 000000000..476e60b52 --- /dev/null +++ b/www/common/sframe-messenger-outer.js @@ -0,0 +1,677 @@ +define([ + 'jquery', + '/bower_components/chainpad-crypto/crypto.js', + '/common/curve.js', + '/common/common-hash.js', +], function ($, Crypto, Curve, Hash) { + 'use strict'; + var Msg = { + inputs: [], + }; + + var Types = { + message: 'MSG', + update: 'UPDATE', + unfriend: 'UNFRIEND', + mapId: 'MAP_ID', + mapIdAck: 'MAP_ID_ACK' + }; + + var clone = function (o) { + return JSON.parse(JSON.stringify(o)); + }; + + // TODO + // - mute a channel (hide notifications or don't open it?) + var createData = Msg.createData = function (proxy, hash) { + return { + channel: hash || Hash.createChannelId(), + displayName: proxy['cryptpad.username'], + profile: proxy.profile && proxy.profile.view, + edPublic: proxy.edPublic, + curvePublic: proxy.curvePublic, + avatar: proxy.profile && proxy.profile.avatar + }; + }; + + var getFriend = function (proxy, pubkey) { + if (pubkey === proxy.curvePublic) { + var data = createData(proxy); + delete data.channel; + return data; + } + return proxy.friends ? proxy.friends[pubkey] : undefined; + }; + + var getFriendList = Msg.getFriendList = function (proxy) { + if (!proxy.friends) { proxy.friends = {}; } + return proxy.friends; + }; + + var eachFriend = function (friends, cb) { + Object.keys(friends).forEach(function (id) { + if (id === 'me') { return; } + cb(friends[id], id, friends); + }); + }; + + Msg.getFriendChannelsList = function (proxy) { + var list = []; + eachFriend(proxy, function (friend) { + list.push(friend.channel); + }); + return list; + }; + + var msgAlreadyKnown = function (channel, sig) { + return channel.messages.some(function (message) { + return message[0] === sig; + }); + }; + + Msg.messenger = function (common) { + var messenger = { + handlers: { + message: [], + join: [], + leave: [], + update: [], + friend: [], + unfriend: [], + }, + range_requests: {}, + }; + + var eachHandler = function (type, g) { + messenger.handlers[type].forEach(g); + }; + + messenger.on = function (type, f) { + var stack = messenger.handlers[type]; + if (!Array.isArray(stack)) { + return void console.error('unsupported message type'); + } + if (typeof(f) !== 'function') { + return void console.error('expected function'); + } + stack.push(f); + }; + + var channels = messenger.channels = {}; + + var joining = {}; + + // declare common variables + var network = common.getNetwork(); + var proxy = common.getProxy(); + var realtime = common.getRealtime(); + Msg.hk = network.historyKeeper; + var friends = getFriendList(proxy); + + var getChannel = function (curvePublic) { + var friend = friends[curvePublic]; + if (!friend) { return; } + var chanId = friend.channel; + if (!chanId) { return; } + return channels[chanId]; + }; + + var initRangeRequest = function (txid, curvePublic, sig, cb) { + messenger.range_requests[txid] = { + messages: [], + cb: cb, + curvePublic: curvePublic, + sig: sig, + }; + }; + + var getRangeRequest = function (txid) { + return messenger.range_requests[txid]; + }; + + var deleteRangeRequest = function (txid) { + delete messenger.range_requests[txid]; + }; + + messenger.getMoreHistory = function (curvePublic, hash, count, cb) { + if (typeof(cb) !== 'function') { return; } + + if (typeof(hash) !== 'string') { + // FIXME hash is not necessarily defined. + // What does this mean? + console.error("not sure what to do here"); + return; + } + + var chan = getChannel(curvePublic); + if (typeof(chan) === 'undefined') { + console.error("chan is undefined. we're going to have a problem here"); + return; + } + + var txid = common.uid(); + initRangeRequest(txid, curvePublic, hash, cb); + var msg = [ 'GET_HISTORY_RANGE', chan.id, { + from: hash, + count: count, + txid: txid, + } + ]; + + network.sendto(network.historyKeeper, JSON.stringify(msg)).then(function () { + }, function (err) { + throw new Error(err); + }); + }; + + var getCurveForChannel = function (id) { + var channel = channels[id]; + if (!channel) { return; } + return channel.curve; + }; + + messenger.getChannelHead = function (curvePublic, cb) { + var friend = friends[curvePublic]; + if (!friend) { return void cb('NO_SUCH_FRIEND'); } + cb(void 0, friend.lastKnownHash); + }; + + messenger.setChannelHead = function (curvePublic, hash, cb) { + var friend = friends[curvePublic]; + if (!friend) { return void cb('NO_SUCH_FRIEND'); } + friend.lastKnownHash = hash; + cb(); + }; + + // Id message allows us to map a netfluxId with a public curve key + var onIdMessage = function (msg, sender) { + var channel; + var isId = Object.keys(channels).some(function (chanId) { + if (channels[chanId].userList.indexOf(sender) !== -1) { + channel = channels[chanId]; + return true; + } + }); + + if (!isId) { return; } + + var decryptedMsg = channel.encryptor.decrypt(msg); + + if (decryptedMsg === null) { + return void console.error("Failed to decrypt message"); + } + + if (!decryptedMsg) { + console.error('decrypted message was falsey but not null'); + return; + } + + var parsed; + try { + parsed = JSON.parse(decryptedMsg); + } catch (e) { + console.error(decryptedMsg); + return; + } + if (parsed[0] !== Types.mapId && parsed[0] !== Types.mapIdAck) { return; } + + // check that the responding peer's encrypted netflux id matches + // the sender field. This is to prevent replay attacks. + if (parsed[2] !== sender || !parsed[1]) { return; } + channel.mapId[sender] = parsed[1]; + eachHandler('join', function (f) { + f(parsed[1], channel.id); + }); + + if (parsed[0] !== Types.mapId) { return; } // Don't send your key if it's already an ACK + // Answer with your own key + var rMsg = [Types.mapIdAck, proxy.curvePublic, channel.wc.myID]; + var rMsgStr = JSON.stringify(rMsg); + var cryptMsg = channel.encryptor.encrypt(rMsgStr); + network.sendto(sender, cryptMsg); + }; + + var orderMessages = function (curvePublic, new_messages /*, sig */) { + var channel = getChannel(curvePublic); + var messages = channel.messages; + + // TODO improve performance, guarantee correct ordering + new_messages.reverse().forEach(function (msg) { + messages.unshift(msg); + }); + }; + + var removeFromFriendList = function (curvePublic, cb) { + if (!proxy.friends) { return; } + var friends = proxy.friends; + delete friends[curvePublic]; + common.whenRealtimeSyncs(realtime, cb); + }; + + var pushMsg = function (channel, cryptMsg) { + var msg = channel.encryptor.decrypt(cryptMsg); + var sig = cryptMsg.slice(0, 64); + if (msgAlreadyKnown(channel, sig)) { return; } + + var parsedMsg = JSON.parse(msg); + var curvePublic; + if (parsedMsg[0] === Types.message) { + // TODO validate messages here + var res = { + type: parsedMsg[0], + sig: sig, + author: parsedMsg[1], + time: parsedMsg[2], + text: parsedMsg[3], + // this makes debugging a whole lot easier + curve: getCurveForChannel(channel.id), + }; + + channel.messages.push(res); + eachHandler('message', function (f) { + f(res); + }); + + return true; + } + if (parsedMsg[0] === Types.update) { + if (parsedMsg[1] === proxy.curvePublic) { return; } + curvePublic = parsedMsg[1]; + var newdata = parsedMsg[3]; + var data = getFriend(proxy, parsedMsg[1]); + var types = []; + Object.keys(newdata).forEach(function (k) { + if (data[k] !== newdata[k]) { + types.push(k); + data[k] = newdata[k]; + } + }); + + eachHandler('update', function (f) { + f(clone(newdata), curvePublic); + }); + return; + } + if (parsedMsg[0] === Types.unfriend) { + curvePublic = parsedMsg[1]; + delete friends[curvePublic]; + + removeFromFriendList(parsedMsg[1], function () { + channel.wc.leave(Types.unfriend); + eachHandler('unfriend', function (f) { + f(curvePublic); + }); + }); + return; + } + }; + + /* Broadcast a display name, profile, or avatar change to all contacts + */ + + // TODO send event... + messenger.updateMyData = function () { + var friends = getFriendList(proxy); + var mySyncData = friends.me; + var myData = createData(proxy); + if (!mySyncData || mySyncData.displayName !== myData.displayName + || mySyncData.profile !== myData.profile + || mySyncData.avatar !== myData.avatar) { + delete myData.channel; + Object.keys(channels).forEach(function (chan) { + var channel = channels[chan]; + + if (!channel) { + return void console.error('NO_SUCH_CHANNEL'); + } + + + var msg = [Types.update, myData.curvePublic, +new Date(), myData]; + var msgStr = JSON.stringify(msg); + var cryptMsg = channel.encryptor.encrypt(msgStr); + channel.wc.bcast(cryptMsg).then(function () { + // TODO send event + //channel.refresh(); + }, function (err) { + console.error(err); + }); + }); + eachHandler('update', function (f) { + f(myData, myData.curvePublic); + }); + friends.me = myData; + } + }; + + var onChannelReady = function (chanId) { + var cb = joining[chanId]; + if (typeof(cb) !== 'function') { + return void console.error('channel ready without callback'); + } + delete joining[chanId]; + return cb(); + }; + + var onDirectMessage = function (common, msg, sender) { + if (sender !== Msg.hk) { return void onIdMessage(msg, sender); } + var parsed = JSON.parse(msg); + + if (/HISTORY_RANGE/.test(parsed[0])) { + //console.log(parsed); + var txid = parsed[1]; + var req = getRangeRequest(txid); + var type = parsed[0]; + if (!req) { + return void console.error("received response to unknown request"); + } + + if (type === 'HISTORY_RANGE') { + req.messages.push(parsed[2]); + } else if (type === 'HISTORY_RANGE_END') { + // process all the messages (decrypt) + var curvePublic = req.curvePublic; + var channel = getChannel(curvePublic); + + var decrypted = req.messages.map(function (msg) { + if (msg[2] !== 'MSG') { return; } + try { + return { + d: JSON.parse(channel.encryptor.decrypt(msg[4])), + sig: msg[4].slice(0, 64), + }; + } catch (e) { + console.log('failed to decrypt'); + return null; + } + }).filter(function (decrypted) { + return decrypted; + }).map(function (O) { + return { + type: O.d[0], + sig: O.sig, + author: O.d[1], + time: O.d[2], + text: O.d[3], + curve: curvePublic, + }; + }); + + orderMessages(curvePublic, decrypted, req.sig); + req.cb(void 0, decrypted); + return deleteRangeRequest(txid); + } else { + console.log(parsed); + } + return; + } + + if ((parsed.validateKey || parsed.owners) && parsed.channel) { + return; + } + if (parsed.state && parsed.state === 1 && parsed.channel) { + if (channels[parsed.channel]) { + // parsed.channel is Ready + // channel[parsed.channel].ready(); + channels[parsed.channel].ready = true; + onChannelReady(parsed.channel); + var updateTypes = channels[parsed.channel].updateOnReady; + if (updateTypes) { + + //channels[parsed.channel].updateUI(updateTypes); + } + } + return; + } + var chan = parsed[3]; + if (!chan || !channels[chan]) { return; } + pushMsg(channels[chan], parsed[4]); + }; + + var onMessage = function (msg, sender, chan) { + if (!channels[chan.id]) { return; } + + var isMessage = pushMsg(channels[chan.id], msg); + if (isMessage) { + if (channels[chan.id].wc.myID !== sender) { + // Don't notify for your own messages + //channels[chan.id].notify(); + } + //channels[chan.id].refresh(); + // TODO emit message event + } + }; + + // listen for messages... + network.on('message', function(msg, sender) { + onDirectMessage(common, msg, sender); + }); + + messenger.removeFriend = function (curvePublic, cb) { + if (typeof(cb) !== 'function') { throw new Error('NO_CALLBACK'); } + var data = getFriend(proxy, curvePublic); + + if (!data) { + // friend is not valid + console.error('friend is not valid'); + return; + } + + var channel = channels[data.channel]; + if (!channel) { + return void cb("NO_SUCH_CHANNEL"); + } + + if (!network.webChannels.some(function (wc) { + return wc.id === channel.id; + })) { + console.error('bad channel: ', curvePublic); + } + + var msg = [Types.unfriend, proxy.curvePublic, +new Date()]; + var msgStr = JSON.stringify(msg); + var cryptMsg = channel.encryptor.encrypt(msgStr); + + // TODO emit remove_friend event? + try { + channel.wc.bcast(cryptMsg).then(function () { + delete friends[curvePublic]; + delete channels[curvePublic]; + common.whenRealtimeSyncs(realtime, function () { + cb(); + }); + }, function (err) { + console.error(err); + cb(err); + }); + } catch (e) { + cb(e); + } + }; + + var getChannelMessagesSince = function (chan, data, keys) { + console.log('Fetching [%s] messages since [%s]', data.curvePublic, data.lastKnownHash || ''); + var cfg = { + validateKey: keys.validateKey, + owners: [proxy.edPublic, data.edPublic], + lastKnownHash: data.lastKnownHash + }; + var msg = ['GET_HISTORY', chan.id, cfg]; + network.sendto(network.historyKeeper, JSON.stringify(msg)) + .then($.noop, function (err) { + throw new Error(err); + }); + }; + + var openFriendChannel = function (data, f) { + var keys = Curve.deriveKeys(data.curvePublic, proxy.curvePrivate); + var encryptor = Curve.createEncryptor(keys); + network.join(data.channel).then(function (chan) { + var channel = channels[data.channel] = { + id: data.channel, + sending: false, + friendEd: f, + keys: keys, + curve: data.curvePublic, + encryptor: encryptor, + messages: [], + wc: chan, + userList: [], + mapId: {}, + send: function (payload, cb) { + if (!network.webChannels.some(function (wc) { + if (wc.id === channel.wc.id) { return true; } + })) { + return void cb('NO_SUCH_CHANNEL'); + } + + var msg = [Types.message, proxy.curvePublic, +new Date(), payload]; + var msgStr = JSON.stringify(msg); + var cryptMsg = channel.encryptor.encrypt(msgStr); + + channel.wc.bcast(cryptMsg).then(function () { + pushMsg(channel, cryptMsg); + cb(); + }, function (err) { + cb(err); + }); + } + }; + chan.on('message', function (msg, sender) { + onMessage(msg, sender, chan); + }); + + var onJoining = function (peer) { + if (peer === Msg.hk) { return; } + if (channel.userList.indexOf(peer) !== -1) { return; } + + channel.userList.push(peer); + var msg = [Types.mapId, proxy.curvePublic, chan.myID]; + var msgStr = JSON.stringify(msg); + var cryptMsg = channel.encryptor.encrypt(msgStr); + network.sendto(peer, cryptMsg); + }; + chan.members.forEach(function (peer) { + if (peer === Msg.hk) { return; } + if (channel.userList.indexOf(peer) !== -1) { return; } + channel.userList.push(peer); + }); + chan.on('join', onJoining); + chan.on('leave', function (peer) { + var curvePublic = channel.mapId[peer]; + var i = channel.userList.indexOf(peer); + while (i !== -1) { + channel.userList.splice(i, 1); + i = channel.userList.indexOf(peer); + } + // update status + if (!curvePublic) { return; } + eachHandler('leave', function (f) { + f(curvePublic, channel.id); + }); + }); + + // FIXME don't subscribe to the channel implicitly + getChannelMessagesSince(chan, data, keys); + }, function (err) { + console.error(err); + }); + }; + + messenger.getFriendList = function (cb) { + var friends = proxy.friends; + if (!friends) { return void cb(void 0, []); } + + cb(void 0, Object.keys(proxy.friends).filter(function (k) { + return k !== 'me'; + })); + }; + + messenger.openFriendChannel = function (curvePublic, cb) { + if (typeof(curvePublic) !== 'string') { return void cb('INVALID_ID'); } + if (typeof(cb) !== 'function') { throw new Error('expected callback'); } + + var friend = clone(friends[curvePublic]); + if (typeof(friend) !== 'object') { + return void cb('NO_FRIEND_DATA'); + } + var channel = friend.channel; + if (!channel) { return void cb('E_NO_CHANNEL'); } + joining[channel] = cb; + openFriendChannel(friend, curvePublic); + }; + + messenger.sendMessage = function (curvePublic, payload, cb) { + var channel = getChannel(curvePublic); + if (!channel) { return void cb('NO_CHANNEL'); } + if (!network.webChannels.some(function (wc) { + if (wc.id === channel.wc.id) { return true; } + })) { + return void cb('NO_SUCH_CHANNEL'); + } + + var msg = [Types.message, proxy.curvePublic, +new Date(), payload]; + var msgStr = JSON.stringify(msg); + var cryptMsg = channel.encryptor.encrypt(msgStr); + + channel.wc.bcast(cryptMsg).then(function () { + pushMsg(channel, cryptMsg); + cb(); + }, function (err) { + cb(err); + }); + }; + + messenger.getStatus = function (curvePublic, cb) { + var channel = getChannel(curvePublic); + if (!channel) { return void cb('NO_SUCH_CHANNEL'); } + var online = channel.userList.some(function (nId) { + return channel.mapId[nId] === curvePublic; + }); + cb(void 0, online); + }; + + messenger.getFriendInfo = function (curvePublic, cb) { + setTimeout(function () { + var friend = friends[curvePublic]; + if (!friend) { return void cb('NO_SUCH_FRIEND'); } + // this clone will be redundant when ui uses postmessage + cb(void 0, clone(friend)); + }); + }; + + messenger.getMyInfo = function (cb) { + cb(void 0, { + curvePublic: proxy.curvePublic, + displayName: common.getDisplayName(), + }); + }; + + // TODO listen for changes to your friend list + // emit 'update' events for clients + + //var update = function (curvePublic + proxy.on('change', ['friends'], function (o, n, p) { + var curvePublic; + if (o === undefined) { + // new friend added + curvePublic = p.slice(-1)[0]; + eachHandler('friend', function (f) { + f(curvePublic, clone(n)); + }); + return; + } + + console.error(o, n, p); + }).on('remove', ['friends'], function (o, p) { + eachHandler('unfriend', function (f) { + f(p[1]); // TODO + }); + }); + + Object.freeze(messenger); + + return messenger; + }; + + return Msg; +}); diff --git a/www/common/sframe-protocol.js b/www/common/sframe-protocol.js index e4a6d7c02..34655fcd6 100644 --- a/www/common/sframe-protocol.js +++ b/www/common/sframe-protocol.js @@ -137,6 +137,23 @@ define({ // Put one or more entries to the cache which will go in localStorage. // Cache is wiped after each new release 'EV_CACHE_PUT': true, + + // Contacts + 'EV_CONTACTS_MESSAGE': true, + 'EV_CONTACTS_JOIN': true, + 'EV_CONTACTS_LEAVE': true, + 'EV_CONTACTS_UPDATE': true, + 'EV_CONTACTS_FRIEND': true, + 'EV_CONTACTS_UNFRIEND': true, + 'Q_CONTACTS_GET_FRIEND_LIST': true, + 'Q_CONTACTS_GET_MY_INFO': true, + 'Q_CONTACTS_GET_FRIEND_INFO': true, + 'Q_CONTACTS_OPEN_FRIEND_CHANNEL': true, + 'Q_CONTACTS_GET_STATUS': true, + 'Q_CONTACTS_GET_MORE_HISTORY': true, + 'Q_CONTACTS_SEND_MESSAGE': true, + 'Q_CONTACTS_SET_CHANNEL_HEAD': true, + // Put one or more entries to the localStore which will go in localStorage. 'EV_LOCALSTORE_PUT': true, // Put one entry in the parent sessionStorage diff --git a/www/contacts/app-contacts.less b/www/contacts/app-contacts.less new file mode 100644 index 000000000..3c40bc59e --- /dev/null +++ b/www/contacts/app-contacts.less @@ -0,0 +1,261 @@ +@import (once) "../../customize/src/less2/include/browser.less"; +@import (once) "../../customize/src/less2/include/toolbar.less"; +@import (once) "../../customize/src/less2/include/markdown.less"; +@import (once) '../../customize/src/less2/include/fileupload.less'; +@import (once) '../../customize/src/less2/include/alertify.less'; +//@import (once) '../../customize/src/less/mixins.less'; +//@import (once) '../../customize/src/less/variables.less"; + +@import (once) '../../customize/src/less2/include/avatar.less'; + + +.toolbar_main(); +.fileupload_main(); +.alertify_main(); + +// body +&.cp-app-contacts { + @keyframes example { + 0% { + background: rgba(0,0,0,0.1); + } + 50% { + background: rgba(0,0,0,0.3); + } + 100% { + background: rgba(0,0,0,0.1); + } + } + + display: flex; + flex-flow: column; + + background-color: red !important; + @button-border: 2px; + @bg-color: @colortheme_friends-bg; + @color: @colortheme_friends-color; + #app { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + min-height: 0; + &.ready { + background-size: cover; + background-position: center; + } + } + + #toolbar { + display: flex; // We need this to remove a 3px border at the bottom of the toolbar + } + + .cryptpad-toolbar { + padding: 0px; + display: inline-block; + } + + #friendList { + width: 350px; + height: 100%; + background-color: lighten(@bg-color, 10%); + overflow-y: auto; + .friend { + background: rgba(0,0,0,0.1); + padding: 5px; + margin: 10px; + cursor: pointer; + .right-col { + margin-left: 5px; + display: flex; + flex-flow: column; + } + &:hover { + background-color: rgba(0,0,0,0.3); + } + &.notify { + animation: example 2s ease-in-out infinite; + } + } + } + + #friendList .friend, #messaging .cp-avatar { + .avatar_main(30px); + &.cp-avatar { + display: flex; + } + cursor: pointer; + color: @color; + media-tag { + img { + color: #000; + } + } + media-tag, .default { + margin-right: 5px; + } + .status { + width: 5px; + display: inline-block; + position: absolute; + right: 0; + top: 0; + bottom: 0; + opacity: 0.7; + background-color: #777; + &.online { + background-color: green; + } + &.offline { + background-color: red; + } + } + } + + #friendList { + .friend { + position: relative; + } + .remove { + cursor: pointer; + width: 20px; + &:hover { + color: darken(@color, 20%); + } + } + } + + .placeholder (@color: #bbb) { + &::-webkit-input-placeholder { /* WebKit, Blink, Edge */ + color: @color; + } + &:-moz-placeholder { /* Mozilla Firefox 4 to 18 */ + color: @color; + opacity: 1; + } + &::-moz-placeholder { /* Mozilla Firefox 19+ */ + color: @color; + opacity: 1; + } + &:-ms-input-placeholder { /* Internet Explorer 10-11 */ + color: @color; + } + &::-ms-input-placeholder { /* Microsoft Edge */ + color: @color; + } + } + + #messaging { + flex: 1; + height: 100%; + background-color: lighten(@bg-color, 20%); + min-width: 0; + + .info { + padding: 20px; + } + .header { + background-color: lighten(@bg-color, 15%); + padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + height: 50px; + + .hover () { + height: 100%; + line-height: 30px; + padding: 10px; + &:hover { + background-color: rgba(50,50,50,0.3); + } + } + + .avatar, + .right-col { + flex:1 1 auto; + } + .remove-history { + .hover; + } + .cp-avatar { + margin: 10px; + } + .more-history { + //display: none; + .hover; + &.faded { + color: darken(@bg-color, 5%); + } + } + } + .chat { + height: 100%; + display: flex; + flex-flow: column; + .messages { + padding: 0 20px; + margin: 10px 0; + flex: 1; + overflow-x: auto; + .message { + & > div { + padding: 0 10px; + } + .content { + overflow: hidden; + word-wrap: break-word; + &> * { + margin: 0; + } + } + .date { + display: none; + font-style: italic; + } + .sender { + margin-top: 10px; + font-weight: bold; + background-color: rgba(0,0,0,0.1); + } + } + } + } + .input { + background-color: lighten(@bg-color, 15%); + height: auto; + min-height: 50px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 75px; + textarea { + margin: 5px 0; + padding: 0 10px; + border: none; + height: 50px; + flex: 1; + background-color: darken(@bg-color, 10%); + color: @color; + resize: none; + line-height: 50px; + overflow-y: auto; + .placeholder(#bbb); + &[disabled=true] { + .placeholder(#999); + } + &:placeholder-shown { line-height: 50px; } + } + button { + height: 50px; + border-radius: 0; + border: none; + background-color: darken(@bg-color, 15%); + &:hover { + background-color: darken(@bg-color, 20%); + } + } + } + } +} + diff --git a/www/contacts/index.html b/www/contacts/index.html index a72a3c60b..e3f7eacc4 100644 --- a/www/contacts/index.html +++ b/www/contacts/index.html @@ -1,16 +1,17 @@ - +