diff --git a/.meteor/packages b/.meteor/packages index fe41447..8b344b0 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -17,3 +17,4 @@ momentjs collectionFS imagemagick loadpicker +page-js-ie-support diff --git a/client/helpers/router.js b/client/helpers/router.js index e069684..7132d7b 100644 --- a/client/helpers/router.js +++ b/client/helpers/router.js @@ -9,6 +9,9 @@ Meteor.Router.add({ } } }, + '/mailbox/compose': 'compose', + '/mailbox': 'mailbox', + '/settings': 'settings', '/profile': { as: 'profile', to: 'profile', @@ -20,7 +23,6 @@ Meteor.Router.add({ to: 'profile', and: function(id){ Session.set('currentUserProfile', id);} }, - '/settings': 'settings', '*': 'p404' }); diff --git a/client/main.js b/client/main.js index 76e8b4d..f46dc57 100644 --- a/client/main.js +++ b/client/main.js @@ -1,13 +1,25 @@ -Meteor.subscribe('questions'); +// All my data Meteor.subscribe('myData', function(){ Session.set('settings', Meteor.user().settings||{}); }); +// My own picture. Meteor.subscribe('myPictures'); +// Online friends widget. Meteor.subscribe('myOnlineFriends'); +// The list of my friends. +Meteor.subscribe('myFriends'); + +// My conversations +Meteor.subscribe('myConversations'); + + +// When visiting someone's profile Deps.autorun(function () { userProfileHandle = Meteor.subscribe("oneUserProfile", Session.get("currentUserProfile")); userPictureHandle = Meteor.subscribe("oneUserPictures", Session.get("currentUserProfile")); }); +// Questions for the profile form. +Meteor.subscribe('questions'); diff --git a/client/views/includes/header.html b/client/views/includes/header.html index 5049d27..8ab5354 100644 --- a/client/views/includes/header.html +++ b/client/views/includes/header.html @@ -10,6 +10,9 @@
  • Settings
  • +
  • + +
  • {{/if}} diff --git a/client/views/mailbox/compose/compose.html b/client/views/mailbox/compose/compose.html new file mode 100644 index 0000000..6814d2c --- /dev/null +++ b/client/views/mailbox/compose/compose.html @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/client/views/mailbox/compose/compose.js b/client/views/mailbox/compose/compose.js new file mode 100644 index 0000000..424df9e --- /dev/null +++ b/client/views/mailbox/compose/compose.js @@ -0,0 +1,21 @@ +Template.compose.helpers({ + friendsList: function(){ + var me = Meteor.users.findOne(Meteor.userId()); + return Meteor.users.find({_id : {$in : me.friends}}, {reactive: false}); + } +}); + +Template.compose.events({ + 'submit form': function(e){ + e.preventDefault(); + var values = { + to: $('select[name="to"]').val(), + body: $('textarea[name="body"]').val() + }; + if(values.to && values.body){ + Meteor.call('sendMessage', values, function(err, res){ + console.log(err, res); + }) + } + } +}); \ No newline at end of file diff --git a/client/views/mailbox/mailbox.html b/client/views/mailbox/mailbox.html new file mode 100644 index 0000000..9752a0e --- /dev/null +++ b/client/views/mailbox/mailbox.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/client/views/mailbox/mailbox.js b/client/views/mailbox/mailbox.js new file mode 100644 index 0000000..ab5f2d7 --- /dev/null +++ b/client/views/mailbox/mailbox.js @@ -0,0 +1,9 @@ +Template.mailbox.helpers({ + conversations: function(){ + return Conversations.find({owner: Meteor.userId()}); + }, + userInfo: function(){ + + return this.with; + } +}); \ No newline at end of file diff --git a/lib/collections/activities.js b/lib/collections/activities.js new file mode 100644 index 0000000..b9e6f01 --- /dev/null +++ b/lib/collections/activities.js @@ -0,0 +1,2 @@ +// Activities between users +Activities = new Meteor.Collection('activities'); \ No newline at end of file diff --git a/lib/collections/conversations.js b/lib/collections/conversations.js new file mode 100644 index 0000000..8adf4f6 --- /dev/null +++ b/lib/collections/conversations.js @@ -0,0 +1,15 @@ +Conversations = new Meteor.Collection('conversations'); + +// Record who's talking with who, and the last message. +// All managed server side with message posting. +Conversations.allow({ + insert: function(userId, doc){ + return false; + }, + update: function(userId, doc){ + return false; + }, + remove: function(){ + return false; + } +}); \ No newline at end of file diff --git a/lib/collections/message.js b/lib/collections/message.js new file mode 100644 index 0000000..23e466f --- /dev/null +++ b/lib/collections/message.js @@ -0,0 +1,38 @@ +Messages = new Meteor.Collection('messages'); + +Messages.allow({ + insert: function(userId, doc){ + // Check that the message is emitted by userId + if(!(doc.from === userId)){ + return false; + } + + // Check that the emitter exists + var target ; + if(!(target = Meteor.user.findOne(doc.to))){ + return false; + } + // Check that the emitter is not blacklisted to the receiver. + if(_.contains(target.blacklist, userId)){ + return false; + } + + // check that the message is not empty + if(doc.body.length == 0){ + return false; + } + + // All good. Let's go. + return true; + }, + update: function(userId, fields){ + return userId == fields.to; // Can only update message /received/ + } +}); + +Messages.deny({ + update: function(userId, fields){ + // only field that can be updated is the 'viewed' field. + return (_.without(fields, 'viewed')).length > 0; + } +}); \ No newline at end of file diff --git a/lib/collections/pictures.js b/lib/collections/pictures.js new file mode 100644 index 0000000..128b2b5 --- /dev/null +++ b/lib/collections/pictures.js @@ -0,0 +1,69 @@ +Pictures = new CollectionFS('pictures', {autopublish: false}); + +var isValidImage = function(type){ + return _.contains(['image/png','image/jpeg','image/gif'], type); +}; + +Pictures.allow({ + insert: function(userId, myFile) { + var valid = true; + // Is a valid image + valid = valid && isValidImage(myFile.contentType); + // Is not too big. + valid = valid && myFile.length < (this.maxFileSize || 1024*1024); + // User quota is ok. + valid = valid && (!this.maxFilePerUser || (this.maxFilePerUser == -1) || Pictures.find({owner: userId}).count() < this.maxFilePerUser); + // User owns the file -- don't know how it could not, but that's in the doc. + valid = valid && userId && myFile.owner === userId; + return valid; + }, + update: function(userId, file, fields, modifier) { + var valid = true, authorized = this.authorizedFields || []; + // Fields are authorized for modification + if(authorized){ + valid = valid && _.every(fields, function(f){_.contains(authorized, f)}); + } + // Check that user owns the image... + valid = valid && file.owner === userId; + + return valid; + }, + remove: function(userId, files) { return false; } +}); + +Pictures.fileHandlers({ + save: function(options){ + if (options.fileRecord.length > 5000000 || !isValidImage(options.fileRecord.contentType)){ + return null; + } + return { blob: options.blob, fileRecord: options.fileRecord }; + }, + thumbnail50x50: function(options){ + if (isValidImage(options.fileRecord.contentType)){ + var destination = options.destination(); + Imagemagick.resize({ + srcData: options.blob, + dstPath: destination.serverFilename, + width: 50, + height: 50 + }); + return destination.fileData; + } else { + return null; + } + }, + thumbnail150x150: function(options){ + if (isValidImage(options.fileRecord.contentType)){ + var destination = options.destination(); + Imagemagick.resize({ + srcData: options.blob, + dstPath: destination.serverFilename, + width: 150, + height: 150 + }); + return destination.fileData; + } else { + return null; + } +} +}); \ No newline at end of file diff --git a/lib/collections/questions.js b/lib/collections/questions.js new file mode 100644 index 0000000..0907519 --- /dev/null +++ b/lib/collections/questions.js @@ -0,0 +1,3 @@ +// Questions for user profile. +Questions = new Meteor.Collection('questions'); + diff --git a/main.config.js b/main.config.js index 4822c7e..0d1fbc0 100644 --- a/main.config.js +++ b/main.config.js @@ -10,6 +10,18 @@ if(Meteor.isClient){ filePickerKey = "Av2HCAqJSM2aHdX5yKTZtz"; } +/* + Messages + */ +// Duration to measure velocity (default 2 minutes). +//Messages.velocityCaliber = 60*1000*2; +Messages.velocityCaliber = 30*1000; +// If target user is online, how many messages per velocityCaliber millisecond can the emitter send +Messages.onlineMaxVelocity = 5; +// If target is offline +Messages.offlineMaxVelocity = 5; +// Cooldown penalty (def: 1 minute) +Messages.cooldownPenalty = 10*1000; /* User posted Pictures. diff --git a/server/fixtures.js b/server/fixtures.js index c2bb87c..aadc150 100644 --- a/server/fixtures.js +++ b/server/fixtures.js @@ -46,4 +46,58 @@ if(Questions.find().count() == 0){ required: true }); } - +if(Meteor.users.find({}).count() <= 2){ + Meteor.users.insert({ + "createdAt": 1372216131137, + "emails": [ + { + "address": "whatever", + "verified": false + } + ], + "friends": [ + "L7SLCm9mJyetnb3oD" + ], + "invisible": false, + "lastseen": 1373524657763, + "online": 1, + "profile": { + "dob": "08-11-1982", + "gender": "Female", + "name": "Fake User 1", + "online": 0 + }, + "services": { + }, + "settings": { + "invisible": false + }, + "visible": 1 + }); + Meteor.users.insert({ + "createdAt": 1372216131137, + "emails": [ + { + "address": "whatever2", + "verified": false + } + ], + "friends": [ + "L7SLCm9mJyetnb3oD" + ], + "invisible": false, + "lastseen": 1373524657763, + "online": 1, + "profile": { + "dob": "08-11-1992", + "gender": "Male", + "name": "Fake User 2", + "online": 0 + }, + "services": { }, + "settings": { + "invisible": false + }, + "visible": 1 + }); +} diff --git a/server/messages.js b/server/messages.js new file mode 100644 index 0000000..a27a64c --- /dev/null +++ b/server/messages.js @@ -0,0 +1,103 @@ +Meteor.methods({ + sendMessage: function(doc){ + + // Check that the emitter exists + var target ; + if(!(target = Meteor.users.findOne(doc.to))){ + throw new Meteor.Error(404, 'User not found'); + } + // Check that the emitter is not blacklisted by the receiver. + if(_.contains(target.blacklist, Meteor.userId())){ + throw new Meteor.Error(300, 'Permission Denied'); + } + + // Sender + var user = Meteor.users.findOne(Meteor.userId()); + + // Check if the user is under a cooldown penalty. + var cooldown = user.cooldown; + + if(cooldown && cooldown > new Date().getTime()){ + //Add an extra cooldown. + Meteor.users.update(Meteor.userId(), { $inc : {cooldown: Messages.cooldownPenalty} }); + throw new Meteor.Error(300, 'Posting too fast, wait ' + moment.duration(cooldown+Messages.cooldownPenalty-new Date().getTime()).humanize()); + } + + // Check user message sending velocity. + var velocity = target.profile.online ? Messages.onlineMaxVelocity : Messages.offlineMaxVelocity; + + var messages = Messages.find({from: Meteor.userId(), sent: {$gt: new Date().getTime() - Messages.velocityCaliber}}); + + if(messages.count() > velocity){ // Posting too fast + // Give a cooldown penalty + Meteor.users.update(Meteor.userId(), {$set : { cooldown: new Date().getTime()+Messages.cooldownPenalty }}); + throw new Meteor.Error(300, 'Posting too fast, wait ' + moment.duration(Messages.cooldownPenalty).humanize()); + } + + // All good, let's send that message + // Reset potential user penalty + Meteor.users.update(Meteor.userId(), {$set: {cooldown: 0}}); + + doc.sent = new Date().getTime(); + doc.viewed = 0; + doc.from = Meteor.userId(); + + var message = _.pick(doc, ['body','sent','from','to', 'viewed']); + + var id = Messages.insert(message); + // Create the conversations between those two uses if it does not exist already. + // apparently upsert is fucked up with Meteor, so that's the best I could find. + if(Conversations.findOne({ + owner: Meteor.userId(), + with: target._id + })){ + Conversations.update( + { + owner: Meteor.userId(), + with: target._id + },{ $set: { + timestamp: new Date().getTime(), + lastMessageId: id, + lastMessage: doc.body, + lastMessageFrom: Meteor.userId() + } + }); + } else { + Conversations.insert({ + timestamp: new Date().getTime(), + owner: Meteor.userId(), + with: target._id, + lastMessageId: id, + lastMessage: doc.body, + lastMessageFrom: Meteor.userId() + }); + } + if(Conversations.findOne({ + with: Meteor.userId(), + owner: target._id + })){ + Conversations.update( + { + with: Meteor.userId(), + owner: target._id + },{ $set: { + timestamp: new Date().getTime(), + lastMessageId: id, + lastMessage: doc.body, + lastMessageFrom: Meteor.userId() + } + }); + } else { + Conversations.insert({ + timestamp: new Date().getTime(), + with: Meteor.userId(), + owner: target._id, + lastMessageId: id, + lastMessage: doc.body, + lastMessageFrom: Meteor.userId() + }); + } + + return true; + } +}); \ No newline at end of file diff --git a/server/publications.js b/server/publications.js index 9b4eca1..4d8618c 100644 --- a/server/publications.js +++ b/server/publications.js @@ -15,6 +15,61 @@ Meteor.publish("myData", function () { ); }); +//Meteor.publish("myConversations", function() { +// Meteor.publishWithRelations({ +// handle: this, +// collection: Conversations, +// filter: {owner: this.userId}, +// mappings: [{ +// key: 'with', +// collection: Meteor.users +// }] +// }); +//}); +// +//Meteor.publish("myConversations", function(limit, skip){ +// // We need basic information about the people we're talking to. +// var self = this, conversationsHandle = null, profileHandle = []; +// +// function publishProfile(conversation){ +//// console.log('conversation', conversation); +// var profile = Meteor.users.find(conversation.with); +// profileHandle[conversation._id] = profile.observe({ +// added: function(profile){ +// self.added(profile); +// } +// }); +// } +// conversationsHandle = Conversations.find({}, {limit: limit, skip: skip}).observe({ +//// conversationsHandle = Conversations.find({owner: this.userId}, {limit: limit, skip: skip}).observe({ +// added: function(conversation){ +//// publishProfile(conversation); +// self.added(conversation); +// } +// +// }); +// +// self.ready(); +// +// self.onStop(function(){ +// conversationsHandle.stop(); +// _.each(profileHandle, function(p){ p.stop()}); +// }); +//}); + +Meteor.publish("myConversations", function(limit, skip){ + return Conversations.find({owner: this.userId}, {limit: limit, skip: skip}); +}); + +Meteor.publish("myMessages", function(limit, skip){ + return Messages.find({ $or: { to: this.userId, from: this.userId}}, {limit: limit, skip: skip}); +}); + +Meteor.publish("myFriends", function(limit, skip){ + var me = Meteor.users.findOne(this.userId); + return Meteor.users.find({_id : {$in : me.friends}}, {fields: { profile: 1 } }); +}); + // Also maintains user online/offline status Meteor.publish("myOnlineFriends", function(){ var friends = Meteor.users.findOne(this.userId).friends || []; diff --git a/server/users.js b/server/users.js index 709aa43..8aca32f 100644 --- a/server/users.js +++ b/server/users.js @@ -31,7 +31,6 @@ Meteor.methods({ if(!valid){ throw new Meteor.Error(300, 'errors.not_saved'); } else { - // Account only visible if at least those two information are made available. var visible = 0; if(cleaned.name && cleaned.gender){ diff --git a/smart.json b/smart.json index 3aa5be8..a7e8be8 100644 --- a/smart.json +++ b/smart.json @@ -10,6 +10,7 @@ "momentjs": {}, "collectionFS": {}, "imagemagick": {}, - "loadpicker": {} + "loadpicker": {}, + "publish-with-relations": {} } } diff --git a/smart.lock b/smart.lock index 98e16a7..f85b0f3 100644 --- a/smart.lock +++ b/smart.lock @@ -12,7 +12,8 @@ "momentjs": {}, "collectionFS": {}, "imagemagick": {}, - "loadpicker": {} + "loadpicker": {}, + "publish-with-relations": {} }, "packages": { "accounts-ui-bootstrap-dropdown": { @@ -22,8 +23,8 @@ }, "router": { "git": "https://github.com/tmeasday/meteor-router.git", - "tag": "v0.5.1", - "commit": "32e377c7703bb119acccc859fc7296882903cbe7" + "tag": "v0.5.2", + "commit": "45af76a97624f141e4a61298d5a049e6adbb631c" }, "i18n": { "git": "https://github.com/jerico-dev/meteor-i18n.git", @@ -50,6 +51,11 @@ "tag": "v1.0.0", "commit": "9f8aa83427b21e2d6279db3f018900bf09560352" }, + "publish-with-relations": { + "git": "https://github.com/erundook/meteor-publish-with-relations.git", + "tag": "v0.1.5", + "commit": "98213e9f056b139597c99fb9ec2884ae6be76d91" + }, "page-js-ie-support": { "git": "https://github.com/tmeasday/meteor-page-js-ie-support.git", "tag": "v1.3.5",