├── .meteor ├── .gitignore ├── cordova-plugins ├── release ├── platforms ├── .finished-upgraders ├── .id ├── packages └── versions ├── packages ├── one-modal │ ├── README.md │ ├── lib │ │ └── client │ │ │ ├── one_modal.js │ │ │ └── one_modal.html │ └── package.js ├── npm-container │ ├── .gitignore │ ├── .npm │ │ └── package │ │ │ ├── .gitignore │ │ │ ├── README │ │ │ └── npm-shrinkwrap.json │ ├── versions.json │ ├── index.js │ └── package.js └── .gitignore ├── .gitignore ├── run.sh ├── packages.json ├── client ├── views │ ├── common │ │ ├── loading.html │ │ ├── feedback.html │ │ ├── forgot_password.js │ │ ├── forgot_password.html │ │ ├── nav.js │ │ ├── search.js │ │ ├── infinite_scroll.js │ │ ├── nav.html │ │ └── search.html │ ├── notifications │ │ ├── notification_new_topic.html │ │ ├── notification_new_follower.html │ │ ├── notification_new_reply.html │ │ ├── notification_new_topic.js │ │ ├── notification_item.html │ │ ├── notification_new_follower.js │ │ ├── notification_new_comment.html │ │ ├── notification_new_reply.js │ │ ├── notifications.js │ │ ├── notification_item.js │ │ ├── notification_new_comment.js │ │ └── notifications.html │ ├── profile │ │ ├── profile_following.js │ │ ├── profile_item.js │ │ ├── profile_item.html │ │ ├── profile.html │ │ └── profile.js │ ├── layouts │ │ ├── landing_layout.html │ │ ├── page_layout.js │ │ ├── main_layout.html │ │ └── page_layout.html │ ├── home │ │ ├── home.html │ │ └── home.js │ ├── login │ │ ├── login.js │ │ └── login.html │ ├── comments │ │ ├── comment_row.html │ │ ├── replies.html │ │ ├── new_comment.html │ │ ├── comment.html │ │ └── replies.js │ ├── errors │ │ └── not_found.html │ ├── settings │ │ ├── settings_profile.js │ │ ├── settings_profile.html │ │ ├── settings.js │ │ ├── settings_account.html │ │ ├── settings_account.js │ │ └── settings.html │ ├── topics │ │ ├── topic_item.js │ │ ├── new_topic.html │ │ ├── topic_item.html │ │ ├── new_topic.js │ │ ├── topic.html │ │ └── topic.js │ ├── admin │ │ ├── admin.html │ │ ├── admin.js │ │ ├── flag.js │ │ └── flag.html │ ├── signup │ │ ├── signup.html │ │ └── signup.js │ ├── landing │ │ ├── landing.js │ │ └── landing.html │ └── invite │ │ ├── invite.js │ │ └── invite.html ├── main.html ├── helpers │ ├── helpers.js │ └── config.js ├── main.js └── stylesheets │ ├── icons.less │ ├── main.less │ ├── includes.import.less │ └── comments.import.less ├── public ├── binary-frontpage-web.png ├── binary-web-feature1.png ├── binary-web-feature2.png ├── binary-web-feature3.png ├── binary-web-feature4.png ├── binary-web-feature5.png ├── binary-frontpage-mobile.png └── fonts │ ├── binary-icon-font.eot │ ├── binary-icon-font.ttf │ ├── binary-icon-font.woff │ └── binary-icon-font.svg ├── server ├── templates │ ├── emailWelcome.handlebars │ ├── emailNotification.handlebars │ ├── emailNewUser.handlebars │ ├── emailTemplate.handlebars │ └── emailWrapper.handlebars ├── publications │ ├── current_user.js │ ├── flags_list.js │ ├── topics_list.js │ ├── user_profile.js │ └── single_topic.js ├── config.js ├── invites.js ├── notifications.js ├── email.js ├── admin.js └── users.js ├── collections ├── _common.js ├── invites.js ├── flags.js ├── votes.js ├── comments.js ├── topics.js └── users.js ├── .editorconfig ├── lib ├── routes │ ├── config.js │ ├── home.js │ ├── admin.js │ ├── hooks.js │ ├── profile.js │ ├── topic.js │ └── routes.js ├── users.js ├── permissions.js ├── herald.js └── helpers.js ├── LICENSE.md └── README.md /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/cordova-plugins: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/one-modal/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.1.0.2 2 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /packages/npm-container/.gitignore: -------------------------------------------------------------------------------- 1 | .build* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | settings.json 2 | mup.json 3 | *.txt 4 | -------------------------------------------------------------------------------- /packages/npm-container/.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | meteor --settings settings.json 3 | exit -------------------------------------------------------------------------------- /packages.json: -------------------------------------------------------------------------------- 1 | { 2 | "juice": "0.4.0", 3 | "html-to-text": "0.1.0" 4 | } -------------------------------------------------------------------------------- /client/views/common/loading.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /public/binary-frontpage-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-frontpage-web.png -------------------------------------------------------------------------------- /public/binary-web-feature1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-web-feature1.png -------------------------------------------------------------------------------- /public/binary-web-feature2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-web-feature2.png -------------------------------------------------------------------------------- /public/binary-web-feature3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-web-feature3.png -------------------------------------------------------------------------------- /public/binary-web-feature4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-web-feature4.png -------------------------------------------------------------------------------- /public/binary-web-feature5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-web-feature5.png -------------------------------------------------------------------------------- /public/binary-frontpage-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/binary-frontpage-mobile.png -------------------------------------------------------------------------------- /public/fonts/binary-icon-font.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/fonts/binary-icon-font.eot -------------------------------------------------------------------------------- /public/fonts/binary-icon-font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/fonts/binary-icon-font.ttf -------------------------------------------------------------------------------- /public/fonts/binary-icon-font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erasaur/binary/HEAD/public/fonts/binary-icon-font.woff -------------------------------------------------------------------------------- /server/templates/emailWelcome.handlebars: -------------------------------------------------------------------------------- 1 |

2 |

{{greeting}}

3 | {{#each message}} 4 |

{{this}}

5 | {{/each}} 6 |

7 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_topic.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /server/templates/emailNotification.handlebars: -------------------------------------------------------------------------------- 1 |

2 | {{message}} 3 |

4 |
5 | {{action.message}} 6 |
7 | -------------------------------------------------------------------------------- /server/templates/emailNewUser.handlebars: -------------------------------------------------------------------------------- 1 |

2 | {{name}} just joined Binary! 3 |

4 |
5 | Visit profile 6 |
7 | -------------------------------------------------------------------------------- /client/views/profile/profile_following.js: -------------------------------------------------------------------------------- 1 | Template.profileFollowing.helpers({ 2 | following: function () { 3 | return this.activity && Meteor.users.find({ 4 | '_id': { $in: this.activity.followingUsers } 5 | }); 6 | } 7 | }); -------------------------------------------------------------------------------- /server/templates/emailTemplate.handlebars: -------------------------------------------------------------------------------- 1 |

2 |

{{greeting}}

3 |

{{message}}

4 |

5 |
6 | {{action.message}} 7 |
8 | -------------------------------------------------------------------------------- /collections/_common.js: -------------------------------------------------------------------------------- 1 | afAutoMarkdown = function (forField) { 2 | return function () { 3 | var content = this.field(forField); 4 | if (Meteor.isServer && typeof content.value === 'string') { 5 | return markdownToHTML(content.value); 6 | } 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/.gitignore: -------------------------------------------------------------------------------- 1 | iron-router 2 | blaze-layout 3 | bootstrap-3 4 | marked 5 | easy-search 6 | fast-render 7 | easy-search 8 | /iron-router 9 | /easy-search 10 | /iron-layout 11 | /blaze-layout 12 | /iron-core 13 | /iron-dynamic-template 14 | /iron-router-progress 15 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_follower.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /client/views/layouts/landing_layout.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /packages/npm-container/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | [ 4 | "meteor", 5 | "1.1.3" 6 | ], 7 | [ 8 | "underscore", 9 | "1.0.1" 10 | ] 11 | ], 12 | "pluginDependencies": [], 13 | "toolVersion": "meteor-tool@1.0.36", 14 | "format": "1.0" 15 | } -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_reply.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /client/views/layouts/page_layout.js: -------------------------------------------------------------------------------- 1 | Template.pageBody.helpers({ 2 | hasItems: function () { 3 | if (!this.items) 4 | return; 5 | 6 | if (_.has(this, 'hasItems')) 7 | return this.hasItems; 8 | 9 | return typeof this.items.count === 'function' ? this.items.count() : this.items.length; 10 | } 11 | }); -------------------------------------------------------------------------------- /client/views/notifications/notification_new_topic.js: -------------------------------------------------------------------------------- 1 | Template.notificationNewTopic.helpers({ 2 | author: function () { 3 | var author = this.data.author; 4 | return author && author.name; 5 | }, 6 | topic: function () { 7 | var topic = this.data.topic; 8 | return topic && topic.title; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /packages/one-modal/lib/client/one_modal.js: -------------------------------------------------------------------------------- 1 | OneModal = function (template, options) { 2 | options = _.extend({}, options, { template: template }); 3 | var view = Blaze.renderWithData(Template.oneModal, options, document.body); 4 | $(view.firstNode()).modal('show').on('hidden.bs.modal', function () { 5 | Blaze.remove(view); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | a1np0rof87p37r0e2l 8 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Binary 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/views/notifications/notification_item.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_follower.js: -------------------------------------------------------------------------------- 1 | Template.notificationNewFollower.helpers({ 2 | hasCount: function () { 3 | return this.data.count; 4 | }, 5 | count: function () { 6 | return this.data.count - 1; 7 | }, 8 | follower: function () { 9 | var follower = this.data.follower; 10 | return follower && follower.name; 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | Template.registerHelper('formatDate', function (date) { 2 | return formatDate(date); 3 | }); 4 | Template.registerHelper('formatName', function (userId) { 5 | return getDisplayNameById(userId); 6 | }); 7 | Template.registerHelper('isAdmin', function () { 8 | return isAdmin(Meteor.user()); 9 | }); 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/one-modal/lib/client/one_modal.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /server/publications/current_user.js: -------------------------------------------------------------------------------- 1 | // Publish current user 2 | 3 | Meteor.publish('currentUser', function () { 4 | if (!this.userId) return this.ready(); 5 | 6 | var fields = { 7 | 'invites': 1, 8 | // 'email_hash': 1, 9 | 'stats': 1, 10 | 'activity': 1, 11 | 'isAdmin': 1, 12 | 'flags': 1 13 | }; 14 | return Meteor.users.find(this.userId, { fields: fields }); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/one-modal/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'one-modal', 3 | version: '0.0.1', 4 | summary: 'One Modal Package' 5 | }); 6 | 7 | Package.onUse(function(api) { 8 | api.versionsFrom('1.1.0.2'); 9 | 10 | api.use([ 11 | 'templating', 12 | 'twbs:bootstrap@3.3.4' 13 | ], 'client'); 14 | 15 | api.addFiles('lib/client/one_modal.html'); 16 | api.addFiles('lib/client/one_modal.js'); 17 | 18 | api.export('OneModal', 'client'); 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_comment.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /packages/npm-container/.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /client/views/home/home.html: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /client/helpers/config.js: -------------------------------------------------------------------------------- 1 | // Avatar.options = { 2 | // emailHashProperty: 'email_hash', 3 | // // gravatarDefault: 'identicon', 4 | // // defaultType: 'image' 5 | // }; 6 | 7 | Herald.settings.queueTimer = 300000; // every 5 minutes 8 | Herald.settings.useIronRouter = false; 9 | // Herald.settings.expireAfterSeconds = 864000; // 10 days old 10 | 11 | toastr.options.timeOut = 5000; 12 | toastr.options.showDuration = 300; 13 | toastr.options.hideDuration = 300; 14 | toastr.options.positionClass = 'toast-top-center'; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #editorconfig.org 2 | 3 | root = true 4 | 5 | [*.js] 6 | # Change these settings to your own preference 7 | indent_style = space 8 | indent_size = 2 9 | 10 | # We recommend you to keep these unchanged 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | max_line_length = 80 17 | indent_brace_style = 1TBS 18 | spaces_around_operators = true 19 | quote_type = auto 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /lib/routes/config.js: -------------------------------------------------------------------------------- 1 | subs = new SubsManager({ 2 | // cache recent 50 subscriptions 3 | cacheLimit: 50, 4 | // expire any subscription after 30 minutes 5 | expireIn: 30 6 | }); 7 | 8 | Router.configure({ 9 | layoutTemplate: 'mainLayout', 10 | notFoundTemplate: 'notFound', 11 | progressDelay: 100 // delay before showing IR progress 12 | }); 13 | 14 | subs.subscribe('currentUser'); 15 | 16 | if (Meteor.isServer) { 17 | FastRender.onAllRoutes(function (url) { 18 | this.subscribe('currentUser'); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /client/views/login/login.js: -------------------------------------------------------------------------------- 1 | Template.loginForm.events({ 2 | 'submit #js-login-form': function (event, template) { 3 | event.preventDefault(); 4 | var email = template.find('#js-email').value; 5 | var password = template.find('#js-password').value; 6 | 7 | if (!email || !password) { 8 | toastr.warning(i18n.t('missing_fields')); 9 | return; 10 | } 11 | 12 | Meteor.loginWithPassword(email, password, function (error) { 13 | if (error) toastr.warning(i18n.t('login_error')); 14 | else $('#one-modal').modal('hide'); 15 | }); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /client/views/home/home.js: -------------------------------------------------------------------------------- 1 | Template.home.onCreated(function () { 2 | initInfiniteScroll.call(this, Topics.find({}, { fields: { '_id': 1 } })); 3 | }); 4 | 5 | Template.home.onDestroyed(function () { 6 | stopInfiniteScroll.call(this); 7 | }); 8 | 9 | Template.home.helpers({ 10 | topics: function() { 11 | return Topics.find({}, { sort: { 'score': -1, 'createdAt': -1 } }); 12 | }, 13 | moreTopics: function () { 14 | var controller = getCurrentController(); 15 | return Topics.find({}, { fields: { '_id': 1 } }).count() === controller.state.get('itemsLimit'); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | Session.set('currentTopic'); 2 | Session.setDefault('itemsLimit', 15); 3 | Session.setDefault('currentTab', 'profileComments'); 4 | SessionAmplify.set('showingReplies', []); 5 | 6 | Accounts.onResetPasswordLink(function (token, done) { 7 | Session.set('resetPassword', token); 8 | done(); 9 | }); 10 | 11 | Accounts.onEmailVerificationLink(function (token, done) { 12 | Accounts.verifyEmail(token, function (error) { 13 | if (error) 14 | toastr.warning(i18n.t('error')); 15 | else 16 | toastr.success(i18n.t('email_verified')); 17 | }); 18 | done(); 19 | }); 20 | -------------------------------------------------------------------------------- /client/views/comments/comment_row.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_reply.js: -------------------------------------------------------------------------------- 1 | Template.notificationNewReply.helpers({ 2 | hasCount: function () { 3 | return this.data.count; 4 | }, 5 | count: function () { 6 | return this.data.count - 1; 7 | }, 8 | author: function () { 9 | var author = this.data.author; 10 | return author && author.name; 11 | }, 12 | topicMessage: function () { 13 | var topic = this.data.topic; 14 | if (!topic) return; 15 | return i18n.t('in_topic', { 16 | topic: topic.title, 17 | context: topic.userId === Meteor.userId() && 'owning' || '' 18 | }); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /client/views/comments/replies.html: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /collections/invites.js: -------------------------------------------------------------------------------- 1 | InviteSchema = new SimpleSchema({ 2 | _id: { 3 | type: String, 4 | optional: true 5 | }, 6 | inviterId: { 7 | type: String 8 | }, 9 | invitedEmail: { 10 | type: String, 11 | regEx: SimpleSchema.RegEx.Email 12 | }, 13 | inviteCode: { 14 | type: String 15 | }, 16 | accepted: { 17 | type: Boolean 18 | } 19 | }); 20 | 21 | Invites = new Mongo.Collection('invites'); 22 | Invites.attachSchema(InviteSchema); 23 | 24 | Invites.deny({ 25 | insert: function () { return true; }, 26 | update: function () { return true; }, 27 | remove: function () { return true; } 28 | }); -------------------------------------------------------------------------------- /client/views/notifications/notifications.js: -------------------------------------------------------------------------------- 1 | Template.notifications.helpers({ 2 | hasNotifications: function () { 3 | return !!Herald.collection.find({ 'userId': Meteor.userId() }, { 4 | fields: { '_id': 1 }, sort: { 'timestamp': -1 } 5 | }).count(); 6 | }, 7 | notifications: function () { 8 | return Herald.collection.find({ 'userId': Meteor.userId() }, { 9 | sort: { 'timestamp': -1 } 10 | }); 11 | }, 12 | notificationCount: function () { 13 | return Herald.collection.find({ 'userId': Meteor.userId(), 'read': false }, { 14 | fields: { 'read': 1 } 15 | }).count(); 16 | } 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /client/views/common/feedback.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /client/views/errors/not_found.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /client/views/layouts/main_layout.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /lib/routes/home.js: -------------------------------------------------------------------------------- 1 | HomeController = RouteController.extend({ 2 | subscriptions: function () { 3 | var limit = Meteor.isClient && this.state.get('itemsLimit') || 15; 4 | return subs.subscribe('topicsList', limit); 5 | }, 6 | onRun: function () { 7 | this.state.set('itemsLimit', 15); 8 | this.next(); 9 | }, 10 | action: function () { 11 | if (this.ready()) { 12 | this.render('nav', { to: 'nav' }); 13 | this.render(); 14 | } 15 | }, 16 | data: function () { 17 | return Topics.find({}, { sort: { 'createdAt': -1 } }); 18 | }, 19 | fastRender: true 20 | }); 21 | 22 | Router.route('/', { 23 | name: 'home', 24 | controller: HomeController 25 | }); 26 | -------------------------------------------------------------------------------- /client/views/notifications/notification_item.js: -------------------------------------------------------------------------------- 1 | Template.notificationItem.helpers({ 2 | niceTime: function () { 3 | return moment(this.timestamp).fromNow(); 4 | }, 5 | notificationHTML: function () { 6 | return this.message(); 7 | }, 8 | readClass: function () { 9 | return this.read && 'read'; 10 | } 11 | }); 12 | 13 | Template.notificationItem.events({ 14 | 'click .notification-item': function (event, template) { 15 | var notificationId = this._id; 16 | 17 | Herald.collection.update(notificationId, { 18 | $set: { read: true } 19 | }, 20 | function (error, result) { 21 | if (error) { 22 | console.log(error); 23 | } 24 | }); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /client/views/notifications/notification_new_comment.js: -------------------------------------------------------------------------------- 1 | Template.notificationNewComment.helpers({ 2 | count: function () { 3 | return this.data.count; 4 | }, 5 | subCount: function () { 6 | return this.data.count - 1; 7 | }, 8 | author: function () { 9 | var author = this.data.author; 10 | return author && author.name; 11 | }, 12 | topicMessage: function () { 13 | var topic = this.data.topic; 14 | if (!topic) return; 15 | return i18n.t('in_topic', { 16 | topic: topic.title, 17 | context: topic.userId === Meteor.userId() && 'owning' || '' 18 | }); 19 | }, 20 | followerCourier: function () { 21 | return this.courier === 'newComment.follower'; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /lib/routes/admin.js: -------------------------------------------------------------------------------- 1 | AdminController = RouteController.extend({ 2 | subscriptions: function () { 3 | var limit = Meteor.isClient && this.state.get('itemsLimit') || 15; 4 | return Meteor.subscribe('flagsList', limit); 5 | }, 6 | onBeforeAction: function () { 7 | var userId = Meteor.userId(); 8 | if (!isAdminById(userId)) { 9 | this.redirect('home'); 10 | } 11 | this.next(); 12 | }, 13 | onRun: function () { 14 | this.state.set('itemsLimit', 15); 15 | this.next(); 16 | }, 17 | action: function () { 18 | if (this.ready()) { 19 | this.render('nav', { to: 'nav' }); 20 | this.render(); 21 | } 22 | } 23 | }); 24 | 25 | Router.route('/admin', { 26 | controller: AdminController 27 | }); 28 | -------------------------------------------------------------------------------- /lib/routes/hooks.js: -------------------------------------------------------------------------------- 1 | Router.onBeforeAction(function () { 2 | if (!Meteor.loggingIn() && !Meteor.userId() && Session.get('resetPassword')) { 3 | this.redirect('forgotPassword'); 4 | } 5 | this.next(); 6 | }, { except: ['landing', 'login', 'invite', 'forgotPassword'] }); 7 | 8 | Router.onBeforeAction(function () { 9 | if (Meteor.userId()) { 10 | this.redirect('home'); 11 | } 12 | this.next(); 13 | }, { only: ['login', 'invite'] }); 14 | 15 | Router.onBeforeAction(function () { 16 | if (!this.data()) { 17 | this.layout('mainLayout'); 18 | } else { 19 | this.layout('pageLayout'); 20 | } 21 | this.next(); 22 | }, { only: ['topic', 'comment', 'profile'] }); 23 | 24 | Router.plugin('dataNotFound', { notFoundTemplate: 'notFound' }); 25 | -------------------------------------------------------------------------------- /client/views/comments/new_comment.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /client/views/settings/settings_profile.js: -------------------------------------------------------------------------------- 1 | Template.settingsProfile.helpers({ 2 | settingsTitle: function () { 3 | return i18n.t('edit_profile'); 4 | } 5 | }); 6 | 7 | Template.settingsProfile.events({ 8 | 'submit form': function (event, template) { 9 | var name = template.find('#js-name').value; 10 | var bio = template.find('#js-bio').value; 11 | 12 | Meteor.call('changeProfile', name, bio, function (error, result) { 13 | if (error) { 14 | toastr.warning(i18n.t('use_valid_characters')); 15 | } 16 | }); 17 | }, 18 | 'click #js-cancel-edit': function (event, template) { 19 | var user = Meteor.user(); 20 | if (!user) return; 21 | template.$('#js-name').val(user.profile.name); 22 | template.$('#js-bio').val(user.profile.bio); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /client/views/notifications/notifications.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /server/publications/flags_list.js: -------------------------------------------------------------------------------- 1 | Meteor.publishComposite('flagsList', function (limit) { 2 | check(limit, Match.Integer); 3 | 4 | var userId = this.userId; 5 | return { 6 | find: function () { 7 | if (!userId || !isAdminById(userId)) return this.ready(); 8 | 9 | return Flags.find({}, { limit: limit }); 10 | }, 11 | children: [{ 12 | find: function (flag) { 13 | return Meteor.users.find(flag.userId, { fields: { 14 | 'profile.name': 1, 15 | 'stats.flagsCount': 1 16 | }}); 17 | } 18 | }, { 19 | find: function (flag) { 20 | var collection = { 21 | 'comments': Comments, 22 | 'topics': Topics 23 | }[flag.itemType]; 24 | 25 | return collection && collection.find(flag.itemId); 26 | } 27 | }] 28 | }; 29 | }); 30 | -------------------------------------------------------------------------------- /client/views/topics/topic_item.js: -------------------------------------------------------------------------------- 1 | Template.topicItem.helpers({ 2 | topComment: function () { 3 | var comment = Comments.findOne({ 'topicId': this._id, 'isDeleted': false }, { 4 | sort: { 'upvotes': -1 } 5 | }); 6 | if (!comment) { return; } 7 | comment.isCommentItem = true; 8 | return comment; 9 | } 10 | }); 11 | Template.topicItem.events({ 12 | 'click .js-delete-topic': function (event, template) { 13 | event.preventDefault(); 14 | 15 | if (confirm(i18n.t('are_you_sure', { action: i18n.t('delete_topic') }))) { 16 | Meteor.call('removeTopic', this, function (error) { 17 | if (error) { 18 | if (error.error === 'no-permission') 19 | toastr.warning(i18n.t('no_permission')); 20 | else 21 | toastr.warning(i18n.t('error')); 22 | } 23 | }); 24 | } 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /client/views/profile/profile_item.js: -------------------------------------------------------------------------------- 1 | Template.profileItem.helpers({ 2 | canFollow: function () { 3 | return canFollow(Meteor.user(), this._id); 4 | }, 5 | iconClass: function () { 6 | var user = Meteor.user(); 7 | var follow = { 8 | 'follow': 'js-follow', 9 | 'icon': 'b-icon-plus' 10 | }; 11 | var unfollow = { 12 | 'follow': 'js-unfollow following', 13 | 'icon': 'b-icon-check' 14 | }; 15 | 16 | if (!user || !user.activity || !_.contains(user.activity.followingUsers, this._id)) 17 | return follow; 18 | 19 | return unfollow; 20 | } 21 | }); 22 | 23 | Template.profileItem.events({ 24 | 'click .js-follow': function (event, template) { 25 | Meteor.call('newFollower', this._id); 26 | }, 27 | 'click .js-unfollow': function (event, template) { 28 | Meteor.call('removeFollower', this._id); 29 | } 30 | }); -------------------------------------------------------------------------------- /packages/npm-container/index.js: -------------------------------------------------------------------------------- 1 | Meteor.npmRequire = function(moduleName) { // 79 2 | var module = Npm.require(moduleName); // 80 3 | return module; // 81 4 | }; // 82 5 | // 83 6 | Meteor.require = function(moduleName) { // 84 7 | console.warn('Meteor.require is deprecated. Please use Meteor.npmRequire instead!'); // 85 8 | return Meteor.npmRequire(moduleName); // 86 9 | }; // 87 -------------------------------------------------------------------------------- /client/views/settings/settings_profile.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/views/profile/profile_item.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | -------------------------------------------------------------------------------- /collections/flags.js: -------------------------------------------------------------------------------- 1 | FlagSchema = new SimpleSchema({ 2 | _id: { 3 | type: String, 4 | optional: true 5 | }, 6 | userId: { // user who flagged 7 | type: String 8 | }, 9 | createdAt: { 10 | type: Date 11 | }, 12 | reason: { 13 | type: String, 14 | max: 100 15 | }, 16 | itemId: { // item being flagged 17 | type: String 18 | }, 19 | itemType: { // topic or comment 20 | type: String, 21 | allowedValues: ['topics', 'comments'] 22 | }, 23 | status: { 24 | type: Number, // 0 - pending, 1 - approved 25 | defaultValue: 0, 26 | allowedValues: [0,1] 27 | } 28 | }); 29 | 30 | Flags = new Mongo.Collection('flags'); 31 | Flags.attachSchema(FlagSchema); 32 | 33 | Flags.deny({ 34 | insert: function () { 35 | return true; 36 | }, 37 | update: function () { 38 | return true; 39 | }, 40 | remove: function () { 41 | return true; 42 | } 43 | }); 44 | 45 | -------------------------------------------------------------------------------- /client/views/login/login.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /client/views/topics/new_topic.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /client/views/common/forgot_password.js: -------------------------------------------------------------------------------- 1 | Template.forgotPassword.helpers({ 2 | 'resetPassword': function () { 3 | return Session.get('resetPassword'); 4 | } 5 | }); 6 | 7 | Template.forgotPassword.events({ 8 | 'submit #js-forgot-password-form': function (event, template) { 9 | event.preventDefault(); 10 | var value = template.find('#js-forgot').value; 11 | 12 | if (!value) { 13 | toastr.warning(i18n.t('missing_fields')); 14 | return; 15 | } 16 | 17 | var token = Session.get('resetPassword'); 18 | if (token) { 19 | Accounts.resetPassword(token, value, function (error) { 20 | if (error) { 21 | toastr.warning(i18n.t('error')); 22 | } else { 23 | Session.set('resetPassword'); 24 | Router.go('home'); 25 | } 26 | }); 27 | } else { 28 | Accounts.forgotPassword({ email: value }, function () { 29 | toastr.success(i18n.t('check_email_for_recovery')); 30 | Router.go('landing'); 31 | }); 32 | } 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /client/views/topics/topic_item.html: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Binary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /client/views/common/forgot_password.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /client/views/admin/admin.html: -------------------------------------------------------------------------------- 1 | 32 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | spacebars 7 | less 8 | accounts-base 9 | accounts-password 10 | underscore 11 | meteor-platform 12 | blaze 13 | mrt:animation-helper-velocity 14 | aldeed:collection2 15 | djedi:sanitize-html 16 | mrt:moment 17 | benjaminrh:user-session 18 | mrt:session-amplify 19 | iron:core 20 | iron:dynamic-template 21 | iron:layout 22 | meteorhacks:fast-render 23 | chuangbo:marked 24 | meteorhacks:subs-manager 25 | reactive-var 26 | meteorhacks:kadira 27 | jparker:gravatar 28 | email 29 | cmather:handlebars-server 30 | random 31 | meteorhacks:npm 32 | 33 | 34 | npm-container 35 | kestanous:herald 36 | multiply:iron-router-progress 37 | erasaur:notification-badge 38 | bengott:avatar 39 | reywood:publish-composite 40 | kestanous:herald-email 41 | percolate:velocityjs 42 | dburles:collection-helpers 43 | browser-policy 44 | matteodem:easy-search 45 | 46 | chrismbeckett:toastr 47 | tap:i18n 48 | audit-argument-checks 49 | check 50 | twbs:bootstrap 51 | fortawesome:fontawesome 52 | iron:router 53 | one-modal 54 | sacha:juice 55 | -------------------------------------------------------------------------------- /client/views/admin/admin.js: -------------------------------------------------------------------------------- 1 | Template.admin.onCreated(function () { 2 | initInfiniteScroll.call(this, Flags.find({}, { fields: { '_id': 1 } })); 3 | }); 4 | Template.admin.onDestroyed(function () { 5 | stopInfiniteScroll.call(this); 6 | }); 7 | 8 | Template.admin.helpers({ 9 | flags: function () { 10 | return Flags.find(); 11 | }, 12 | user: function () { 13 | return Meteor.users.findOne(this.userId); 14 | }, 15 | item: function () { 16 | var collection = { 17 | 'comments': Comments, 18 | 'topics': Topics 19 | }; 20 | var item = collection[this.itemType].findOne(this.itemId); 21 | item.isCommentItem = true; 22 | return item; 23 | }, 24 | itemTemplate: function () { 25 | var template = { 26 | 'comments': 'comment', 27 | 'topics': 'topicItem' 28 | }; 29 | return template[this.itemType]; 30 | }, 31 | statusClass: function () { 32 | return this.status === 1 ? 'js-undo-helpful helpful' : 'js-helpful'; 33 | } 34 | }); 35 | 36 | Template.admin.events({ 37 | 'click .js-helpful': function (event, template) { 38 | Meteor.call('changeFlag', this, 1); 39 | }, 40 | 'click .js-undo-helpful': function (event, template) { 41 | Meteor.call('changeFlag', this, 0); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /client/views/admin/flag.js: -------------------------------------------------------------------------------- 1 | Template.flagModal.onCreated(function () { 2 | this._other = new ReactiveVar(false); 3 | }); 4 | 5 | Template.flagModal.helpers({ 6 | otherDisabled: function () { 7 | var template = Template.instance(); 8 | return !template._other.get(); 9 | } 10 | }); 11 | 12 | Template.flagModal.events({ 13 | 'change input[name="flag-option"]': function (event, template) { 14 | template._other.set(event.target.value === 'other'); 15 | }, 16 | 'submit #js-flag-form': function (event, template) { 17 | event.preventDefault(); 18 | 19 | var $form = $(event.target); 20 | var reason = $form.find('input:radio:checked').val(); 21 | if (!reason) return; 22 | 23 | if (reason === 'other') { 24 | reason = $form.find('#js-other-reason').val(); 25 | } 26 | 27 | Meteor.call('newFlag', this._id, this.type, reason, function (error, result) { 28 | if (error) { 29 | if (error.error === 'no-permission') 30 | toastr.warning(i18n.t('please_login')); 31 | else 32 | toastr.warning(i18n.t('missing_fields')); 33 | } else { 34 | toastr.success(i18n.t('thank_you_for_flagging')); 35 | $('#one-modal').modal('hide'); 36 | } 37 | }); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /client/views/settings/settings.js: -------------------------------------------------------------------------------- 1 | Template.settingsBody.onCreated(function () { 2 | this.editing = new ReactiveVar(false); 3 | }); 4 | 5 | Template.settingsBody.helpers({ 6 | editing: function () { 7 | return Template.instance().editing.get(); 8 | } 9 | }); 10 | 11 | Template.settingsBody.events({ 12 | 'focus input': function (event, template) { 13 | template.editing.set(true); 14 | }, 15 | 'click #js-cancel-edit, submit form': function (event, template) { 16 | event.preventDefault(); 17 | template.editing.set(false); 18 | } 19 | }); 20 | 21 | Template.settings.helpers({ 22 | isEnabled: function (option) { 23 | var userId = Meteor.userId(); 24 | return Herald.userPreference(userId, 'email', option) && 'checked' || ''; 25 | } 26 | }); 27 | 28 | Template.settings.events({ 29 | 'change .settings-input input[type="checkbox"]': function (event, template) { 30 | var button = event.currentTarget; 31 | var actionValue = button.getAttribute('data-value'); 32 | var newValue = button.checked; 33 | 34 | Herald.setUserPreference(Meteor.user(), { 'email': newValue }, actionValue); 35 | } 36 | }); 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /server/publications/topics_list.js: -------------------------------------------------------------------------------- 1 | // Publish list of topics (sorted by date) and top comment for each 2 | 3 | Meteor.publishComposite('topicsList', function (limit) { 4 | check(limit, Match.Integer); 5 | 6 | return { 7 | find: function () { 8 | var fields = { 9 | 'title': 1, 10 | 'userId': 1, 11 | 'createdAt': 1, 12 | 'commentsCount': 1, 13 | 'pro': 1, 14 | 'con': 1, 15 | 'score': 1 16 | }; 17 | 18 | return Topics.find({}, { fields: fields, sort: { 'score': -1, 'createdAt': -1 }, limit: limit }); 19 | }, 20 | children: [{ 21 | find: function (topic) { // top comment for each topic 22 | return Comments.find({ 'topicId': topic._id, 'isDeleted': false }, { 23 | sort: { 'upvotes': -1 }, 24 | limit: 1 25 | }); 26 | }, 27 | children: [{ 28 | find: function (comment) { // owner of each top comment 29 | return Meteor.users.find(comment.userId, { fields: { 'profile': 1, 'stats': 1 } }); 30 | } 31 | }] 32 | }, { 33 | find: function (topic) { // owner of each topic 34 | return Meteor.users.find(topic.userId, { fields: { 'profile': 1, 'stats': 1 } }); 35 | } 36 | }] 37 | }; 38 | }); 39 | -------------------------------------------------------------------------------- /client/views/common/nav.js: -------------------------------------------------------------------------------- 1 | Template.nav.events({ 2 | 'click #js-feedback': function (event, template) { 3 | OneModal('feedbackModal'); 4 | }, 5 | 'click #js-invite': function (event, template) { 6 | OneModal('inviteModal'); 7 | }, 8 | 'click #js-create-topic': function (event, template) { 9 | if (Meteor.userId()) { 10 | OneModal('newTopic'); 11 | } else { 12 | OneModal('signupModal', { modalClass: 'modal-sm' }); 13 | } 14 | }, 15 | //prevent page from scrolling when mouse is in notifications box 16 | 'DOMMouseScroll .notifications, mousewheel .notifications': function (event, template) { 17 | var target = event.currentTarget; 18 | var $target = $(target); 19 | var scrollTop = target.scrollTop; 20 | var scrollHeight = target.scrollHeight; 21 | var delta = event.originalEvent.wheelDelta; 22 | var up = delta > 0; 23 | 24 | if (!up && -delta > scrollHeight - target.clientHeight - scrollTop) { 25 | $target.scrollTop(scrollHeight); 26 | event.stopPropagation(); 27 | event.preventDefault(); 28 | event.returnValue = false; 29 | } else if (up && delta > scrollTop) { 30 | $target.scrollTop(0); 31 | event.stopPropagation(); 32 | event.preventDefault(); 33 | event.returnValue = false; 34 | } 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /client/views/topics/new_topic.js: -------------------------------------------------------------------------------- 1 | Template.newTopic.events({ 2 | 'submit #js-create-topic-form': function(event, template) { 3 | event.preventDefault(); 4 | 5 | var $title = template.$('#js-create-title'); 6 | var $description = template.$('#js-create-description'); 7 | 8 | var topic = { 9 | title: $title.val(), 10 | description: $description.val() 11 | }; 12 | 13 | Meteor.call('newTopic', topic, function (error, result) { 14 | if (error) { 15 | if (error.error === 'logged-out') 16 | toastr.warning(i18n.t('please_login')); 17 | else if (error.error === 'wait') 18 | toastr.warning(i18n.t('please_wait', { num: error.reason })); 19 | else if (error.error === 'invalid-content') 20 | toastr.warning(i18n.t('topic_too_short')); 21 | else if (error.error === 'duplicate-content') 22 | toastr.warning(i18n.t('topic_title_exists')); 23 | else 24 | toastr.warning(i18n.t('error')); 25 | } 26 | else { 27 | $title.val(''); 28 | $description.val(''); 29 | 30 | $('#one-modal').modal('hide').on('hidden.bs.modal', function () { 31 | Router.go('topic', { '_id': result }); 32 | }); 33 | } 34 | }); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /lib/routes/profile.js: -------------------------------------------------------------------------------- 1 | ProfileController = RouteController.extend({ 2 | subscriptions: function () { 3 | var limit = Meteor.isClient && this.state.get('itemsLimit') || 15; 4 | 5 | return [ 6 | subs.subscribe('userProfile', this.params._id), 7 | Meteor.subscribe('userComments', this.params._id, limit), 8 | Meteor.subscribe('userTopics', this.params._id, limit) 9 | ]; 10 | }, 11 | onRun: function () { 12 | var query = this.params.query; 13 | var tabs = { 14 | 'comments': 'profileComments', 15 | 'topics': 'profileTopics', 16 | 'followers': 'profileFollowers', 17 | 'following': 'profileFollowing' 18 | }; 19 | 20 | this.state.set('itemsLimit', 15); 21 | this.state.set('currentTab', tabs[query.tab] || 'profileComments'); 22 | this.next(); 23 | }, 24 | action: function () { 25 | if (this.ready()) { 26 | this.render('nav', { to: 'nav' }); 27 | this.render('profileButtons', { to: 'pageButtons' }); 28 | this.render('profileHeader', { to: 'pageHeader' }); 29 | this.render('profileNav', { to: 'pageNav' }); 30 | this.render(); 31 | } 32 | }, 33 | data: function () { 34 | return Meteor.users.findOne(this.params._id); 35 | }, 36 | fastRender: true 37 | }); 38 | 39 | Router.route('/users/:_id', { 40 | name: 'profile', 41 | controller: ProfileController 42 | }); 43 | -------------------------------------------------------------------------------- /lib/users.js: -------------------------------------------------------------------------------- 1 | isAdminById = function (userId) { 2 | var user = Meteor.users.findOne(userId, { fields: { 'isAdmin': 1 } }); 3 | return !!user && isAdmin(user); 4 | }; 5 | isAdmin = function (user) { 6 | user = (typeof user === 'undefined') ? Meteor.user() : user; 7 | return !!user && !!user.isAdmin; 8 | }; 9 | getDisplayName = function (user) { 10 | return user && user.profile && user.profile.name; 11 | }; 12 | getDisplayNameById = function (userId) { 13 | return getDisplayName(Meteor.users.findOne(userId)); 14 | }; 15 | getEmail = function (user) { 16 | return user && user.emails && user.emails[0].address; 17 | }; 18 | findLast = function (user, collection) { 19 | return collection.findOne({ userId: user._id }, { sort: { createdAt: -1 } }); 20 | }; 21 | timeSinceLast = function (user, collection) { 22 | var now = new Date().getTime(); 23 | var last = findLast(user, collection); 24 | if(!last) 25 | return 999; // if this is the user's first post or comment ever, stop here 26 | return Math.abs(Math.floor((now - last.createdAt) / 1000)); 27 | }; 28 | numberOfItemsInPast24Hours = function (user, collection) { 29 | var date = moment(new Date()); 30 | var items = collection.find({ 31 | userId: user._id, 32 | createdAt: { $gte: createdAt.subtract(24, 'hours').valueOf() } 33 | }); 34 | return items.count(); 35 | }; 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | Meteor.startup(function () { 2 | BrowserPolicy.framing.disallow(); 3 | BrowserPolicy.content.allowFontOrigin('fonts.gstatic.com'); 4 | BrowserPolicy.content.allowOriginForAll('fonts.googleapis.com'); 5 | BrowserPolicy.content.disallowInlineScripts(); 6 | 7 | // update scores every minute 8 | Meteor.setInterval(function () { 9 | Topics.find().forEach(function (topic) { 10 | Topics.update(topic._id, { $set: { 11 | score: getTopicScore(topic) 12 | }}); 13 | }); 14 | }, 60 * 1000); 15 | }); 16 | 17 | var emailOptions = function (emailType) { 18 | emailType = 'email_' + emailType; 19 | 20 | this.subject = function (user) { 21 | return i18n.t(emailType + '_subject'); 22 | }; 23 | this.html = function (user, url) { 24 | var properties = { 25 | greeting: i18n.t('greeting', getDisplayName(user)), 26 | message: i18n.t(emailType + '_message'), 27 | action: { 28 | link: url, 29 | message: i18n.t(emailType + '_action') 30 | } 31 | }; 32 | return buildEmailTemplate(Handlebars.templates['emailTemplate'](properties)); 33 | }; 34 | this.text = function (user, url) { 35 | return buildEmailText(this.html); 36 | }; 37 | }; 38 | 39 | Accounts.emailTemplates = { 40 | from: 'Binary ', 41 | siteName: 'Binary', 42 | resetPassword: new emailOptions('resetPassword'), 43 | verifyEmail: new emailOptions('verifyEmail') 44 | }; 45 | -------------------------------------------------------------------------------- /client/views/settings/settings_account.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Binary is a social network for debate, built with [Meteor](http://meteor.com). 2 | 3 | Check us out live at [binary10.co](https://binary10.co). 4 | 5 | ## What the heck is this? 6 | Here's the [simplest explanation](https://binary10.co/landing) we could think of. 7 | 8 | ## Why the heck would I use this? 9 | We designed binary to be potentially beneficial to all sorts of people. In brief, it can be: 10 | 11 | - **For developers.** Say you're making an app and wonder whether users would like a particular feature. Or suppose you've finally decided to settle the debate between vim and emacs. Create a topic on binary and users can vote and discuss the pros/cons! 12 | - **For non-developers.** Debate on all sorts of topics ranging from politics (presidential elections are coming up!) to gaming. Heck you could even ask for [(shoddy) life advice](https://binary10.co/topics/iqw86XJPDuZjW3k7j). 13 | 14 | ## Contributing 15 | We welcome any feedback/contributions! Feel free to drop us a line at `hi@binary10.co` and we'll get back to you within a few days. If you find any bugs, please [open an issue](https://github.com/erasaur/binary/issues). If you have feature requests or ideas, open an issue or send a [pull request](https://github.com/erasaur/binary/pulls) so we can discuss further. 16 | 17 | Binary is based heavily on [Telescope](https://github.com/TelescopeJS/Telescope), currently the most popular open-source Meteor app. If you have any experience contributing or using Telescope, the codebase should be familiar to you. 18 | 19 | ##License 20 | MIT -------------------------------------------------------------------------------- /client/views/comments/comment.html: -------------------------------------------------------------------------------- 1 | 43 | 44 | -------------------------------------------------------------------------------- /client/views/common/search.js: -------------------------------------------------------------------------------- 1 | var searching = new ReactiveVar(false); 2 | var searchTemplate = { 3 | 'topics': 'topicItem', 4 | 'users': 'profileItem' 5 | }; 6 | 7 | var stopSearching = function () { 8 | $('.search-input').val('').blur(); 9 | searching.set(false); 10 | }; 11 | var isSearching = function () { 12 | return searching.get(); 13 | }; 14 | 15 | Template.searchInput.helpers({ 16 | indexes: ['topics', 'users'], 17 | searching: isSearching, 18 | placeholder: function () { 19 | return i18n.t('search_prompt'); 20 | }, 21 | topicModal: function () { 22 | return Meteor.userId() ? '#new-topic-modal' : '#signup-modal'; 23 | } 24 | }); 25 | 26 | Template.mainLayout.helpers({ 27 | searching: isSearching 28 | }); 29 | 30 | Template.pageLayout.helpers({ 31 | searching: isSearching 32 | }); 33 | 34 | Template.nav.events({ 35 | 'click .navbar-brand, click .dropdown-menu a[href]': function (event, template) { 36 | stopSearching(); 37 | } 38 | }); 39 | 40 | Template.searchInput.events({ 41 | 'input .search-input': _.debounce(function (event, template) { 42 | if (event.target.value == '') { 43 | searching.set(false); 44 | } else { 45 | searching.set(true); 46 | } 47 | }, 200), 48 | 'click #js-search-cancel': function (event, template) { 49 | stopSearching(); 50 | }, 51 | 'submit #js-search-form': function (event, template) { 52 | event.preventDefault(); 53 | } 54 | }); 55 | 56 | Template.searchResults.events({ 57 | 'click a[href]': function (event, template) { 58 | stopSearching(); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /client/views/comments/replies.js: -------------------------------------------------------------------------------- 1 | Template.replies.onCreated(function () { 2 | this.subscribe('commentReplies', this.data.id); 3 | }); 4 | 5 | Template.replies.onRendered(function () { 6 | var container = this.firstNode; 7 | container._uihooks = { 8 | insertElement: function (node, next) { 9 | var $node = $(node); 10 | if ($node.hasClass('comment-container')) { 11 | Meteor.setTimeout(function () { 12 | $node.insertBefore(next); 13 | $node.velocity('slideDown', { duration: 200 }); 14 | Meteor.setTimeout(function () { $node.css('opacity', 1); }, 1); 15 | }, 1); 16 | } else { 17 | $node.insertBefore(next); 18 | } 19 | } 20 | } 21 | }); 22 | 23 | Template.replies.helpers({ 24 | hasReplies: function () { 25 | var comment = Comments.findOne(this.id, { fields: { 'replies': 1 } }); 26 | return comment && comment.replies.length; 27 | }, 28 | replies: function () { 29 | var incomingComments = getIncomingComments({ 'replyTo': this.id }); 30 | var comments = getComments({ 'replyTo': this.id }); 31 | 32 | comments = incomingComments.concat(comments); 33 | var pros = [], cons = []; 34 | 35 | _.each(comments, function (comment) { 36 | comment.side === 'pro' ? pros.push(comment) : cons.push(comment); 37 | }); 38 | 39 | var res = [], len = Math.max(pros.length, cons.length), i = -1; 40 | while (++i < len) { 41 | res.push({ 'pros': pros[i], 'cons': cons[i] }); 42 | } 43 | 44 | res.push({ 'bottom': true }); 45 | return res; 46 | } 47 | }); 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /client/views/common/infinite_scroll.js: -------------------------------------------------------------------------------- 1 | function InfiniteScroll (cursor) { 2 | var self = this; 3 | 4 | // observeChanges will fire for initial set, so count can start at 0 5 | self.count = 0; 6 | var cursor = cursor.observeChanges({ 7 | added: function () { 8 | self.count++; 9 | }, 10 | removed: function () { 11 | self.count--; 12 | } 13 | }); 14 | 15 | this.stop = function () { 16 | cursor.stop(); 17 | }; 18 | } 19 | 20 | initInfiniteScroll = function (cursors) { 21 | var self = this; 22 | var cursors = _.isArray(cursors) ? cursors : [cursors]; 23 | var controller = this instanceof Iron.Controller ? this : getCurrentController(); 24 | var limit = this.state || controller.state; 25 | var currentLimit; 26 | 27 | stopInfiniteScroll.call(self); 28 | self._infiniteScroll = []; 29 | 30 | _.each(cursors, function (cursor) { 31 | var obj = new InfiniteScroll(cursor); 32 | self._infiniteScroll.push(obj); 33 | }); 34 | 35 | $(window).on('scroll', _.throttle(function () { 36 | // trigger at 20% above bottom 37 | var target = document.body.offsetHeight * 0.8; 38 | 39 | if (window.innerHeight + window.scrollY >= target) { 40 | _.each(self._infiniteScroll, function (obj) { 41 | currentLimit = limit.get('itemsLimit'); 42 | if (obj.count >= currentLimit) { 43 | limit.set('itemsLimit', currentLimit + 30); //fetch more items from server 44 | } 45 | }); 46 | } 47 | }, 300)); 48 | }; 49 | 50 | stopInfiniteScroll = function () { 51 | $(window).off('scroll'); 52 | _.each(this._infiniteScroll, function (obj) { 53 | obj.stop(); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /client/views/layouts/page_layout.html: -------------------------------------------------------------------------------- 1 | 36 | 37 | 55 | -------------------------------------------------------------------------------- /client/views/signup/signup.html: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | 36 | -------------------------------------------------------------------------------- /client/views/landing/landing.js: -------------------------------------------------------------------------------- 1 | Template.landing.rendered = function () { 2 | var questions = [ 3 | 'Should police be required to wear body cameras?', 4 | 'Should Sony have pulled The Interview from theaters?', 5 | 'Will the Seahawks win Super Bowl 2015?', 6 | 'Rails or Django?', 7 | 'Let\'s settle it: vim or emacs?', 8 | 'Umbrella revolution: good or bad for Hong Kong?', 9 | 'Are investors betting too much on messaging apps?', 10 | 'Should same-sex marriage be legal?', 11 | 'Does God exist?' 12 | ]; 13 | var questionIndex = 0; 14 | var $text = this.$('#landing-image-header'); 15 | var $pro = this.$('#landing-image-pro'); 16 | var $con = this.$('#landing-image-con'); 17 | 18 | // fade content in 19 | this.$('.landing-container').css('opacity', 0).velocity('fadeIn', { duration: 400 }); 20 | this.$('.landing-image').css('opacity', 0).velocity('fadeIn', { duration: 400 }); 21 | 22 | // animate text 23 | function animateText () { 24 | $text.velocity('fadeOut', { duration: 500, complete: function () { 25 | $(this).text(questions[questionIndex]); 26 | $(this).velocity('fadeIn', { duration: 500 }); 27 | questionIndex = questionIndex >= questions.length - 1 ? 0 : questionIndex + 1; 28 | 29 | function rn () { 30 | return parseInt(Math.random()*899, 10) + 100; 31 | } 32 | 33 | $({ pro: 100, con: 100 }).delay(250).animate({ pro: rn(), con: rn() }, { 34 | duration: 1200, 35 | step: _.throttle(function () { 36 | $pro.text(Math.ceil(this.pro)); 37 | $con.text(Math.ceil(this.con)); 38 | }, 100) 39 | }); 40 | }}); 41 | }; 42 | 43 | animateText(); 44 | this._animateHandle = setInterval(animateText, 6000); 45 | }; 46 | 47 | Template.landing.destroyed = function () { 48 | clearInterval(this._animateHandle); 49 | }; 50 | -------------------------------------------------------------------------------- /client/views/signup/signup.js: -------------------------------------------------------------------------------- 1 | Template.signupModal.onCreated(function () { 2 | this._showLogin = new ReactiveVar(false); 3 | }); 4 | 5 | Template.signupModal.helpers({ 6 | showLogin: function () { 7 | var template = Template.instance(); 8 | return template._showLogin && template._showLogin.get(); 9 | } 10 | }); 11 | 12 | Template.signupModal.events({ 13 | 'click #js-show-signup': function (event, template) { 14 | template._showLogin && template._showLogin.set(false); 15 | }, 16 | 'click #js-show-login': function (event, template) { 17 | template._showLogin && template._showLogin.set(true); 18 | }, 19 | 'click #js-show-forgot': function (event, template) { 20 | $('#one-modal').modal('hide').on('hidden.bs.modal', function () { 21 | Router.go('forgotPassword'); 22 | }); 23 | } 24 | }); 25 | 26 | Template.signupForm.events({ 27 | 'submit #js-signup-form': function (event, template) { 28 | event.preventDefault(); 29 | 30 | var email = template.find('#js-create-email').value; 31 | var name = template.find('#js-create-name').value; 32 | var password = template.find('#js-create-password').value; 33 | 34 | Meteor.call('newUser', email, name, password, function (error, result) { 35 | if (error) { 36 | if (error.error === 'invalid-content') { 37 | toastr.warning(i18n.t('use_alphanumeric_name')); 38 | } 39 | else if (error.error === 'weak-password') { 40 | toastr.warning(i18n.t('password_too_short')); 41 | } 42 | else { 43 | toastr.warning(i18n.t('error')); 44 | } 45 | } else { 46 | $('#one-modal').modal('hide'); 47 | 48 | Meteor.loginWithPassword(email, password, function (error) { 49 | if (error) toastr.warning(i18n.t('error')); 50 | else Router.go('home'); 51 | }); 52 | } 53 | }); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /lib/routes/topic.js: -------------------------------------------------------------------------------- 1 | TopicController = RouteController.extend({ 2 | template: 'topic', 3 | subscriptions: function () { 4 | this._runAt = this._runAt || new Date(); 5 | return subs.subscribe('singleTopic', this.params._id, this._runAt); 6 | }, 7 | onRun: function () { 8 | SessionAmplify.set('showingReplies', []); 9 | this.next(); 10 | }, 11 | action: function () { 12 | if (this.ready()) { 13 | this.render('nav', { to: 'nav' }); 14 | this.render('topicButtons', { to: 'pageButtons' }); 15 | this.render('topicHeader', { to: 'pageHeader' }); 16 | this.render('topicNav', { to: 'pageNav' }); 17 | this.render(); 18 | } 19 | }, 20 | data: function () { 21 | return Topics.findOne(this.params._id); 22 | }, 23 | onStop: function () { 24 | var $replyRows = $('.comment-container'); 25 | if ($replyRows.length) { 26 | Blaze.remove(Blaze.getView($replyRows[0])); 27 | $replyRows.remove(); 28 | } 29 | SessionAmplify.set('showingReplies', []); 30 | }, 31 | fastRender: true 32 | }); 33 | 34 | Router.route('/topics/:_id/', { 35 | name: 'topic', 36 | controller: TopicController, 37 | subscriptions: function () { 38 | var topicId = this.params._id; 39 | var limit = Meteor.isClient && this.state.get('itemsLimit') || 15; 40 | return [ 41 | Meteor.subscribe('topicComments', topicId, 'pro', limit), 42 | Meteor.subscribe('topicComments', topicId, 'con', limit) 43 | ]; 44 | }, 45 | onRun: function () { 46 | this._runAt = new Date(); 47 | this.state.set('itemsLimit', 15); 48 | this.next(); 49 | } 50 | }); 51 | 52 | Router.route('/topics/:_id/:commentId', { 53 | name: 'comment', 54 | controller: TopicController, 55 | subscriptions: function () { 56 | return [ 57 | Meteor.subscribe('topicComment', this.params.commentId), 58 | Meteor.subscribe('commentReplies', this.params.commentId) 59 | ]; 60 | } 61 | }); 62 | -------------------------------------------------------------------------------- /client/views/invite/invite.js: -------------------------------------------------------------------------------- 1 | Template.invite.events({ 2 | 'submit #js-signup-form': function (event, template) { 3 | event.preventDefault(); 4 | var name = template.find('#js-create-name').value; 5 | var password = template.find('#js-create-password').value; 6 | var query = getCurrentQuery(); 7 | var inviteCode = query && query.invite_code; 8 | 9 | Meteor.call('newInvitedUser', name, password, inviteCode, function (error, result) { 10 | if (error) { 11 | var niceError = i18n.t('error'); 12 | 13 | if (error.error === 'invalid-invite') { 14 | niceError = i18n.t('invalid_invitation_link'); 15 | } 16 | else if (error.error === 'invalid-content') { 17 | niceError = i18n.t('use_alphanumeric_name'); 18 | } 19 | else if (error.error === 'weak-password') { 20 | niceError = i18n.t('password_too_short'); 21 | } 22 | 23 | toastr.warning(niceError); 24 | } 25 | else { 26 | var email = result; 27 | Meteor.loginWithPassword(email, password, function (error) { 28 | if (error) 29 | toastr.warning(i18n.t('error')); 30 | else 31 | Router.go('home'); 32 | }); 33 | } 34 | }); 35 | } 36 | }); 37 | 38 | Template.inviteModal.events({ 39 | 'submit #js-invite-form': function (event, template) { 40 | event.preventDefault(); 41 | event.stopPropagation(); 42 | 43 | var $email = template.$('#js-invite-email'); 44 | var email = $email.val(); 45 | 46 | if (!email) { 47 | toastr.warning(i18n.t('missing_fields')); 48 | return; 49 | } 50 | 51 | Meteor.call('inviteUser', email, function (error) { 52 | if (error) { 53 | if (error.error === 'no-permission') 54 | toastr.warning(i18n.t('no_more_invites')); 55 | else if (error.error === 'duplicate-content') 56 | toastr.warning(i18n.t('already_invited')); 57 | else 58 | toastr.warning(i18n.t('error')); 59 | } else { 60 | toastr.success(i18n.t('invite_success')); 61 | $email.val(''); 62 | } 63 | }); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /client/views/topics/topic.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | 27 | 40 | 41 | 59 | 60 | -------------------------------------------------------------------------------- /packages/npm-container/package.js: -------------------------------------------------------------------------------- 1 | var path = Npm.require('path'); // 91 2 | var fs = Npm.require('fs'); // 92 3 | // 93 4 | Package.describe({ // 94 5 | summary: 'Contains all your npm dependencies', // 95 6 | version: '1.0.0', // 96 7 | name: 'npm-container' // 97 8 | }); // 98 9 | // 99 10 | var packagesJsonFile = path.resolve('./packages.json'); // 100 11 | try { // 101 12 | var fileContent = fs.readFileSync(packagesJsonFile); // 102 13 | var packages = JSON.parse(fileContent.toString()); // 103 14 | Npm.depends(packages); // 104 15 | } catch(ex) { // 105 16 | console.error('ERROR: packages.json parsing error [ ' + ex.message + ' ]'); // 106 17 | } // 107 18 | // 108 19 | Package.onUse(function(api) { // 109 20 | api.add_files(['index.js', '../../packages.json'], 'server'); // 110 21 | }); // 111 -------------------------------------------------------------------------------- /server/invites.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | validLink: function (inviterId, inviteCode) { 3 | check([inviterId, inviteCode], [String]); 4 | return !!Invites.findOne({ 'inviterId': inviterId, 'inviteCode': inviteCode, 'accepted': false }); 5 | }, 6 | inviteUser: function (email) { 7 | check(email, String); 8 | 9 | var currentUser = Meteor.user(); 10 | 11 | // check that user can invite 12 | if (!canInvite(currentUser)) 13 | throw new Meteor.Error('no-permission', 'This user does not have permission to continue.'); 14 | 15 | // check that invited user doesn't exist 16 | if (Meteor.users.findOne({ 'emails.address': email })) 17 | throw new Meteor.Error('duplicate-content', 'This content already exists.'); 18 | 19 | // check that invited user hasn't been invited 20 | if (Invites.findOne({ 'invitedEmail': email })) 21 | throw new Meteor.Error('duplicate-content', 'This content already exists.'); 22 | 23 | // calculate email hash 24 | var inviterId = currentUser._id; 25 | var inviteCode = Random.id(); 26 | 27 | var invite = { 28 | inviterId: inviterId, 29 | invitedEmail: email, 30 | inviteCode: inviteCode, 31 | accepted: false 32 | }; 33 | 34 | Invites.insert(invite); 35 | 36 | Meteor.users.update(invite.inviterId, { 37 | $inc: { 'invites.inviteCount': -1 }, 38 | $addToSet: { 'invites.invitedEmails': email } 39 | }); 40 | 41 | var emailSubject = i18n.t('email_invite_subject'); 42 | var emailProperties = { 43 | action: { 44 | link: getSiteUrl() + 'invite?inviter_id=' + invite.inviterId + '&invite_code=' + invite.inviteCode, 45 | message: i18n.t('email_invite_action') 46 | }, 47 | message: i18n.t('email_invite_message', { 48 | user: getDisplayName(currentUser), 49 | email: getEmail(currentUser) 50 | }) 51 | }; 52 | 53 | // send email 54 | Meteor.setTimeout(function () { 55 | buildAndSendEmail(email, emailSubject, 'emailNotification', emailProperties); 56 | }, 1); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /client/views/invite/invite.html: -------------------------------------------------------------------------------- 1 | 15 | 16 | 47 | -------------------------------------------------------------------------------- /lib/routes/routes.js: -------------------------------------------------------------------------------- 1 | LandingController = RouteController.extend({ 2 | layoutTemplate: 'landingLayout', 3 | onBeforeAction: function () { 4 | $('body').addClass('landing'); 5 | this.next(); 6 | }, 7 | action: function () { 8 | this.layout('landingLayout'); 9 | this.render(); 10 | }, 11 | onStop: function () { 12 | $('body').removeClass('landing'); 13 | } 14 | }); 15 | Router.route('/landing', { 16 | layoutTemplate: '', 17 | controller: LandingController, 18 | action: function () { 19 | this.layout(''); 20 | this.render(); 21 | } 22 | }); 23 | Router.route('/login', { 24 | controller: LandingController 25 | }); 26 | Router.route('/signup', { 27 | controller: LandingController 28 | }); 29 | Router.route('/forgot', { 30 | name: 'forgotPassword', 31 | controller: LandingController 32 | }); 33 | Router.route('/invite', { 34 | controller: LandingController, 35 | onRun: function () { 36 | this._firstRun = true; 37 | }, 38 | action: function () { 39 | var self = this; 40 | 41 | if (self._firstRun) { 42 | self._firstRun = false; 43 | 44 | var query = self.params.query; 45 | var inviterId = query && query.inviter_id; 46 | var inviteCode = query && query.invite_code; 47 | 48 | Meteor.call('validLink', inviterId, inviteCode, function (error, result) { 49 | if (!result || error) { 50 | // self.layout('mainLayout'); 51 | // self.render('notFound'); 52 | 53 | // rendering notFound link directly doesn't get rid of the 54 | // class we added to body (which changes bg color for landing). 55 | // easiest is to redirect to a notFound route w/o this body class 56 | self.redirect('notFound'); 57 | } else { 58 | self.layout('landingLayout'); 59 | self.render(); 60 | } 61 | }); 62 | } 63 | self.render('loading'); 64 | } 65 | }); 66 | Router.route('/settings', { 67 | name: 'settings', 68 | onBeforeAction: function () { 69 | if (!Meteor.loggingIn() && !Meteor.userId()) { 70 | this.redirect('home'); 71 | } 72 | this.next(); 73 | }, 74 | action: function () { 75 | this.layout('mainLayout'); 76 | this.render(); 77 | } 78 | }); 79 | Router.route('/notFound', { 80 | layoutTemplate: 'mainLayout' 81 | }); 82 | -------------------------------------------------------------------------------- /client/views/common/nav.html: -------------------------------------------------------------------------------- 1 | 30 | 31 | 66 | -------------------------------------------------------------------------------- /lib/permissions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Adopted from TelescopeJS 3 | * 4 | * See: https://github.com/TelescopeJS/Telescope/blob/ba38e2a75b1de3e5a5e5332341a74d5f4424498c/lib/permissions.js 5 | */ 6 | 7 | // XXX improve all permissions. most don't require entire user object, 8 | // and don't need to have separate 'ById' functions, just typecheck params 9 | 10 | // can post topics 11 | canPost = function (user) { 12 | var user = (typeof user === 'undefined') ? Meteor.user() : user; 13 | return !!user; 14 | }; 15 | canPostById = function (userId) { 16 | return userId && canPost(Meteor.users.findOne(userId)); 17 | }; 18 | 19 | // can post comments 20 | canComment = function (user) { 21 | return canPost(user); 22 | }; 23 | canCommentById = function (userId) { 24 | return userId && canComment(Meteor.users.findOne(userId)); 25 | }; 26 | 27 | // can vote comments/topics 28 | canUpvote = function (user, comment) { 29 | if (!canPost(user)) return; 30 | 31 | // no comment will be passed in if checking for topic 32 | return !comment || !_.contains(comment.upvoters, user._id); 33 | }; 34 | canUpvoteById = function (userId) { 35 | return userId && canUpvote(Meteor.users.findOne(userId)); 36 | }; 37 | 38 | // can follow users 39 | canFollow = function (user, followId) { 40 | return canPost(user) && user._id !== followId; 41 | }; 42 | canFollowById = function (userId, followId) { 43 | return userId && canFollow(Meteor.users.findOne(userId), followId); 44 | }; 45 | 46 | // can edit documents 47 | canEdit = function (user, item) { 48 | if (!canPost(user) || !item) return; 49 | return isAdmin(user) || user._id === item.userId; 50 | }; 51 | canEditById = function (userId, item){ 52 | if (!userId || !item) return; 53 | return canEdit(Meteor.users.findOne(userId), item); 54 | }; 55 | 56 | canInvite = function (user) { 57 | if (!user) return; 58 | return isAdmin(user) || user.invites && !!user.invites.inviteCount; 59 | }; 60 | 61 | canFlagComment = function (user, commentId) { 62 | user = _.isString(user) ? Meteor.users.findOne(user, { fields: { 'isAdmin': 1, 'flags.comments': 1 } }) : user; 63 | 64 | if (!user || isAdmin(user)) return; 65 | return user.flags && !_.contains(user.flags.comments, commentId); 66 | }; 67 | 68 | canFlagTopic = function (user, topicId) { 69 | if (!user || isAdmin(user)) return; 70 | return user.flags && !_.contains(user.flags.topics, topicId); 71 | }; 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /client/views/admin/flag.html: -------------------------------------------------------------------------------- 1 | 60 | -------------------------------------------------------------------------------- /client/views/landing/landing.html: -------------------------------------------------------------------------------- 1 | 62 | -------------------------------------------------------------------------------- /client/views/settings/settings_account.js: -------------------------------------------------------------------------------- 1 | function changeEmail (email) { 2 | var user = Meteor.user(); 3 | if (!user) return; 4 | 5 | var currentEmail = user.emails[0].address; 6 | if (currentEmail === email) return; 7 | 8 | Meteor.call('changeEmail', email, function (error, result) { 9 | if (error) { 10 | throw i18n.t('error'); 11 | } else { 12 | toastr.success(i18n.t('check_email_for_verification')); 13 | } 14 | }); 15 | } 16 | 17 | function stopEditing (template) { 18 | var user = Meteor.user(); 19 | if (!user) return; 20 | template.$('#js-email').val(user.emails[0].address); 21 | template.$('#js-password').val(''); 22 | template.$('#js-newPassword').val(''); 23 | changingPassword.set(false); 24 | } 25 | 26 | var changingPassword = new ReactiveVar(false); 27 | 28 | Template.settingsAccount.helpers({ 29 | settingsTitle: function () { 30 | return i18n.t('account_settings'); 31 | }, 32 | changingPassword: function () { 33 | return changingPassword.get(); 34 | } 35 | }); 36 | 37 | Template.settingsAccount.events({ 38 | 'click #js-email-verification': function (event, template) { 39 | Meteor.call('sendVerificationEmail', function (error) { 40 | if (error) 41 | toastr.warning(i18n.t('error')); 42 | else 43 | toastr.success(i18n.t('check_email_for_verification')); 44 | }); 45 | }, 46 | 'click #js-edit-password': function (event, template) { 47 | changingPassword.set(true); 48 | }, 49 | 'click #js-cancel-edit': function (event, template) { 50 | stopEditing(template); 51 | }, 52 | 'submit form': function (event, template) { 53 | var email = template.find('#js-email').value; 54 | 55 | if (!changingPassword.get()) { 56 | try { 57 | changeEmail(email); 58 | return; 59 | } catch (e) { 60 | toastr.warning(e); 61 | } 62 | } 63 | 64 | var oldPassword = template.find('#js-password').value; 65 | var newPassword = template.find('#js-newPassword').value; 66 | 67 | if (newPassword.length < 6) { 68 | toastr.warning(i18n.t('password_too_short')); 69 | $('#js-newPassword').focus(); 70 | return; 71 | } 72 | 73 | Accounts.changePassword(oldPassword, newPassword, function (error) { 74 | if (error) { 75 | toastr.warning(i18n.t('incorrect_password')); 76 | $('#js-password').focus(); 77 | return; 78 | } else { 79 | try { 80 | changeEmail(email); 81 | stopEditing(template); 82 | } catch (e) { 83 | toastr.warning(e); 84 | } 85 | } 86 | }); 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /client/stylesheets/icons.less: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | // @bi-font-path: "/fonts"; 4 | // @bi-css-prefix: bi; 5 | // @{bi-css-prefix} { 6 | // display: inline-block; 7 | // font: normal normal normal 14px/1 "binary-icon-font"; // shortening font declaration 8 | // font-size: inherit; // can't have font-size inherit on line above, so need to override 9 | // text-rendering: auto; // optimizelegibility throws things off #1094 10 | // -webkit-font-smoothing: antialiased; 11 | // -moz-osx-font-smoothing: grayscale; 12 | // } 13 | 14 | // // icon sizes 15 | // .@{bi-css-prefix}-lg { 16 | // font-size: (4em / 3); 17 | // line-height: (3em / 4); 18 | // vertical-align: -15%; 19 | // } 20 | // .@{bi-css-prefix}-2x { font-size: 2em; } 21 | // .@{bi-css-prefix}-3x { font-size: 3em; } 22 | // .@{bi-css-prefix}-4x { font-size: 4em; } 23 | // .@{bi-css-prefix}-5x { font-size: 5em; } 24 | 25 | @font-face { 26 | font-family: "binary-icon-font"; 27 | src:url("fonts/binary-icon-font.eot"); 28 | src:url("fonts/binary-icon-font.eot?#iefix") format("embedded-opentype"), 29 | url("fonts/binary-icon-font.woff") format("woff"), 30 | url("fonts/binary-icon-font.ttf") format("truetype"), 31 | url("fonts/binary-icon-font.svg#binary-icon-font") format("svg"); 32 | font-weight: normal; 33 | font-style: normal; 34 | } 35 | 36 | [data-icon]:before { 37 | font-family: "binary-icon-font" !important; 38 | content: attr(data-icon); 39 | font-style: normal !important; 40 | font-weight: normal !important; 41 | font-variant: normal !important; 42 | text-transform: none !important; 43 | speak: none; 44 | line-height: 1; 45 | -webkit-font-smoothing: antialiased; 46 | -moz-osx-font-smoothing: grayscale; 47 | } 48 | 49 | // [class^="b-"]:before, 50 | // [class*=" b-"]:before // generic selectors are slower 51 | .b-icon-plus, 52 | .b-icon-bubble, 53 | .b-icon-heart, 54 | .b-icon-flag, 55 | .b-icon-logout, 56 | .b-icon-write, 57 | .b-icon-gear, 58 | .b-icon-check, 59 | .b-icon-share { 60 | font-family: "binary-icon-font" !important; 61 | font-style: normal !important; 62 | font-weight: normal !important; 63 | font-variant: normal !important; 64 | text-transform: none !important; 65 | speak: none; 66 | line-height: 1; 67 | -webkit-font-smoothing: antialiased; 68 | -moz-osx-font-smoothing: grayscale; 69 | } 70 | 71 | .b-icon-plus:before { 72 | content: "p"; 73 | } 74 | .b-icon-bubble:before { 75 | content: "b"; 76 | } 77 | .b-icon-heart:before { 78 | content: "h"; 79 | } 80 | .b-icon-flag:before { 81 | content: "f"; 82 | } 83 | .b-icon-logout:before { 84 | content: "l"; 85 | } 86 | .b-icon-write:before { 87 | content: "w"; 88 | } 89 | .b-icon-gear:before { 90 | content: "g"; 91 | } 92 | .b-icon-check:before { 93 | content: "c"; 94 | } 95 | .b-icon-share:before { 96 | content: "s"; 97 | } 98 | -------------------------------------------------------------------------------- /client/views/common/search.html: -------------------------------------------------------------------------------- 1 | 22 | 23 | 69 | -------------------------------------------------------------------------------- /public/fonts/binary-icon-font.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by Fontastic.me 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.0 2 | accounts-password@1.1.1 3 | aldeed:collection2@2.3.3 4 | aldeed:simple-schema@1.3.2 5 | amplify@1.0.0 6 | artwells:queue@0.0.4 7 | audit-argument-checks@1.0.3 8 | autoupdate@1.2.1 9 | base64@1.0.3 10 | bengott:avatar@0.6.0 11 | benjaminrh:user-session@0.2.0 12 | binary-heap@1.0.3 13 | blaze@2.1.2 14 | blaze-tools@1.0.3 15 | boilerplate-generator@1.0.3 16 | browser-policy@1.0.4 17 | browser-policy-common@1.0.3 18 | browser-policy-content@1.0.4 19 | browser-policy-framing@1.0.4 20 | callback-hook@1.0.3 21 | cfs:http-methods@0.0.28 22 | check@1.0.5 23 | chrismbeckett:toastr@2.1.0 24 | chuangbo:cookie@1.1.0 25 | chuangbo:marked@0.3.5 26 | cmather:handlebars-server@2.0.0 27 | coffeescript@1.0.6 28 | dburles:collection-helpers@1.0.3 29 | ddp@1.1.0 30 | deps@1.0.7 31 | djedi:sanitize-html@1.6.1 32 | ejson@1.0.6 33 | email@1.0.6 34 | erasaur:notification-badge@0.0.6 35 | fastclick@1.0.3 36 | fortawesome:fontawesome@4.3.0 37 | geojson-utils@1.0.3 38 | handlebars@1.0.3 39 | html-tools@1.0.4 40 | htmljs@1.0.4 41 | http@1.1.0 42 | id-map@1.0.3 43 | iron:controller@1.0.7 44 | iron:core@1.0.7 45 | iron:dynamic-template@1.0.7 46 | iron:layout@1.0.7 47 | iron:location@1.0.7 48 | iron:middleware-stack@1.0.7 49 | iron:router@1.0.5 50 | iron:url@1.0.7 51 | jparker:crypto-core@0.1.0 52 | jparker:crypto-md5@0.1.1 53 | jparker:gravatar@0.3.1 54 | jquery@1.11.3_2 55 | json@1.0.3 56 | kestanous:herald@1.1.3 57 | kestanous:herald-email@0.4.2 58 | launch-screen@1.0.2 59 | less@1.0.14 60 | livedata@1.0.13 61 | localstorage@1.0.3 62 | logging@1.0.7 63 | matteodem:easy-search@1.5.6 64 | meteor@1.1.6 65 | meteor-platform@1.2.2 66 | meteorhacks:async@1.0.0 67 | meteorhacks:fast-render@2.3.2 68 | meteorhacks:inject-data@1.2.3 69 | meteorhacks:kadira@2.20.1 70 | meteorhacks:meteorx@1.3.1 71 | meteorhacks:npm@1.3.0 72 | meteorhacks:picker@1.0.2 73 | meteorhacks:subs-manager@1.3.0 74 | minifiers@1.1.5 75 | minimongo@1.0.8 76 | mobile-status-bar@1.0.3 77 | mongo@1.1.0 78 | mongo-livedata@1.0.8 79 | mrt:animation-helper-velocity@0.1.5 80 | mrt:moment@2.8.1 81 | mrt:session-amplify@0.1.1 82 | mrt:underscore-string-latest@2.3.3 83 | multiply:iron-router-progress@1.0.1 84 | npm-bcrypt@0.7.8_2 85 | npm-container@1.0.0 86 | observe-sequence@1.0.6 87 | one-modal@0.0.1 88 | ordered-dict@1.0.3 89 | percolate:velocityjs@1.2.1_1 90 | random@1.0.3 91 | reactive-dict@1.1.0 92 | reactive-var@1.0.5 93 | reload@1.1.3 94 | retry@1.0.3 95 | reywood:publish-composite@1.3.6 96 | routepolicy@1.0.5 97 | sacha:juice@0.1.4 98 | service-configuration@1.0.4 99 | session@1.1.0 100 | sewdn:velocityjs@0.8.0 101 | sha@1.0.3 102 | spacebars@1.0.6 103 | spacebars-compiler@1.0.6 104 | srp@1.0.3 105 | stylus@1.0.7 106 | tap:i18n@1.4.1 107 | templating@1.1.1 108 | tracker@1.0.7 109 | twbs:bootstrap@3.3.4 110 | ui@1.0.6 111 | underscore@1.0.3 112 | url@1.0.4 113 | webapp@1.2.0 114 | webapp-hashing@1.0.3 115 | -------------------------------------------------------------------------------- /server/publications/user_profile.js: -------------------------------------------------------------------------------- 1 | // Publish profile page 2 | 3 | Meteor.publishComposite('userProfile', function (userId) { 4 | check(userId, String); 5 | 6 | return { 7 | find: function () { // the user 8 | return Meteor.users.find(userId, { 9 | limit: 1, 10 | fields: { 'profile': 1, 'stats': 1, 'activity': 1 } 11 | }); 12 | }, 13 | children: [{ 14 | find: function (user) { // users following/followed by user 15 | if (!user || !user.activity) return {}; 16 | 17 | var userIds = user.activity.followers.concat(user.activity.followingUsers) || []; 18 | return Meteor.users.find({ '_id': { $in: userIds } }, { 19 | fields: { 'profile': 1, 'stats': 1 } 20 | }); 21 | } 22 | }] 23 | }; 24 | }); 25 | 26 | Meteor.publishComposite('userComments', function (userId, limit) { 27 | check(userId, String); 28 | check(limit, Match.Integer); 29 | 30 | return { 31 | find: function () { 32 | return Comments.find({ 'userId': userId, 'isDeleted': false }, { 33 | sort: { 'createdAt': -1 }, 34 | limit: limit 35 | }); 36 | }, 37 | children: [{ 38 | find: function (comment) { // owners of said comments 39 | return Meteor.users.find(comment.userId, { 40 | limit: 1, 41 | fields: { 'profile': 1, 'stats': 1 } 42 | }); 43 | } 44 | },{ 45 | find: function (comment) { // topics related to said comments 46 | return Topics.find(comment.topicId, { 47 | fields: { '_id': 1, 'title': 1, 'createdAt': 1, 'userId': 1, 'pro': 1, 'con': 1 } 48 | }); 49 | } 50 | }] 51 | }; 52 | }); 53 | 54 | Meteor.publishComposite('userTopics', function (userId, limit) { 55 | check(userId, String); 56 | check(limit, Match.Integer); 57 | 58 | return { 59 | find: function () { // topics created/followed by user 60 | // return Topics.find({ 'userId': userId, 'isDeleted': false }, { 61 | return Topics.find({ 'userId': userId }, { 62 | sort: { 'createdAt': -1 }, 63 | limit: limit, 64 | fields: { '_id': 1, 'title': 1, 'createdAt': 1, 'userId': 1, 'pro': 1, 'con': 1 } 65 | }); 66 | }, 67 | children: [{ 68 | find: function (topic) { // owner of each topic 69 | return Meteor.users.find(topic.userId, { 70 | limit: 1, 71 | fields: { 'profile': 1 } 72 | }); 73 | } 74 | }, { 75 | find: function (topic) { // top comment of each topic 76 | return Comments.find({ 'topicId': topic._id, 'isDeleted': false }, { 77 | 'sortBy': -1, 'limit': 1 78 | }); 79 | }, 80 | children: [{ 81 | find: function (comment) { // owner of each top comment 82 | return Meteor.users.find(comment.userId, { 83 | limit: 1, 84 | fields: { 'profile': 1, 'stats': 1 } 85 | }); 86 | } 87 | }] 88 | }] 89 | }; 90 | }); 91 | 92 | 93 | -------------------------------------------------------------------------------- /client/stylesheets/main.less: -------------------------------------------------------------------------------- 1 | @import "includes.import.less"; 2 | @import "comments.import.less"; 3 | @import "components.import.less"; 4 | 5 | // media queries ------------------------------------- 6 | // @media (min-width: 768px) { 7 | // body { padding-top: @top-padding; } //no top padding when xs 8 | // } 9 | 10 | // general styles ------------------------------------ 11 | 12 | html { 13 | height: 100%; 14 | } 15 | html { 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | font-family: 'Lato', sans-serif; 19 | } 20 | body { 21 | min-height: 100%; 22 | background-color: @color-background; 23 | } 24 | .landing { 25 | height: 100%; 26 | background-color: #fff; 27 | } 28 | .row { 29 | margin: 0; 30 | } 31 | // .col-sm-8 { 32 | // max-width: 1066px; 33 | // } 34 | input, 35 | textarea, 36 | button { 37 | .border-radius(@radius-main); 38 | } 39 | h1, h2, h3 { 40 | font-family: 'Lato', sans-serif; 41 | } 42 | h1, h2 { 43 | font-weight: 300; 44 | } 45 | h2 { 46 | font-size: 1.8em; 47 | } 48 | h3, h4 { 49 | .content-header; 50 | margin-top: 0; 51 | } 52 | h3 { 53 | font-size: @fontSize-larger; 54 | } 55 | 56 | a, 57 | a:link, 58 | a:visited, 59 | a:hover, 60 | a:active { 61 | text-decoration: none; 62 | color: #454545; 63 | } 64 | .action-link:hover, 65 | a.action-group:hover .action-link { 66 | text-decoration: underline; 67 | } 68 | // a:hover, 69 | .icon-group:hover { 70 | // color: black; 71 | color: @color-primary; 72 | } 73 | textarea { 74 | width: 100%; 75 | max-width: 100%; 76 | overflow: auto; 77 | outline: none; 78 | -webkit-box-shadow: none; 79 | -moz-box-shadow: none; 80 | box-shadow: none; 81 | } 82 | ul { 83 | list-style-type: none; 84 | margin: 0; 85 | padding: 0; 86 | } 87 | a, 88 | input, 89 | button { 90 | &:active, 91 | &:focus { outline: none !important; } 92 | } 93 | textarea, 94 | .form-control, 95 | .form-control:focus { 96 | border: none; 97 | box-shadow: none; 98 | background-color: @color-lightGray; 99 | } 100 | .editable { 101 | background: #fff; 102 | border-radius: @radius-main; 103 | outline: none; 104 | padding: @padding-small; 105 | margin-bottom: @padding-small; 106 | min-height: 80px; 107 | 108 | &:after { 109 | top: 10px; 110 | left: 10px; 111 | } 112 | } 113 | .markdown { 114 | strong { 115 | font-weight: bold; 116 | } 117 | em { 118 | font-style: italic; 119 | } 120 | a, 121 | a:link, 122 | a:visited, 123 | a:hover, 124 | a:active { 125 | text-decoration: underline; 126 | } 127 | a:hover { 128 | color: @color-primary; 129 | } 130 | code { 131 | font-family: monospace; 132 | border: 1px solid #ddd; 133 | background-color: #f8f8f8; 134 | border-radius: 3px; 135 | } 136 | pre { 137 | border: 1px solid #ddd; 138 | background-color: #f8f8f8; 139 | overflow-x: scroll; 140 | code { 141 | border: none; 142 | background: none; 143 | } 144 | } 145 | > :last-child { 146 | margin-bottom: 0 !important; 147 | } 148 | } 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /lib/herald.js: -------------------------------------------------------------------------------- 1 | // Meteor.startup(function () { 2 | // Herald.collection.deny({ 3 | // update: !canEditById, 4 | // remove: !canEditById 5 | // }); 6 | // }); 7 | 8 | // Herald.userPreference = function (userId, medium, courier) { 9 | // if (!courier) { 10 | // return Herald.getUserPreference(userId, medium); 11 | // } 12 | 13 | // // courier could be oddly formatted (newComment.topicOwner), so handle separately 14 | // var user = Meteor.users.findOne(userId); 15 | // var preferences = user && user.profile.notifications; 16 | // return preferences && getProperty(preferences.couriers, courier + '.' + medium); 17 | // }; 18 | 19 | var emailRunner = function (user) { 20 | var notification = this; 21 | notification.data.user = user; 22 | Meteor.setTimeout(function () { 23 | var notificationEmail = buildEmailNotification(notification); 24 | sendEmail(user.emails[0].address, notificationEmail.subject, notificationEmail.html); 25 | }, 1); 26 | }; 27 | 28 | var commentCourier = { 29 | media: { 30 | onsite: {}, 31 | email: { 32 | emailRunner: emailRunner 33 | } 34 | }, 35 | transform: { 36 | actionLink: function () { 37 | var topic = this.data.topic; 38 | var comment = this.data.comment; 39 | 40 | return topic && comment && getCommentRoute(topic._id, comment._id); 41 | } 42 | }, 43 | message: { 44 | default: function (user) { 45 | return Blaze.toHTML(Blaze.With(this, function () { 46 | return Template.notificationNewComment; 47 | })); 48 | } 49 | } 50 | }; 51 | 52 | Herald.addCourier('newComment.topicOwner', commentCourier); 53 | Herald.addCourier('newComment.topicFollower', commentCourier); 54 | Herald.addCourier('newComment.follower', commentCourier); 55 | 56 | Herald.addCourier('newReply', { 57 | media: { 58 | onsite: {}, 59 | email: { 60 | emailRunner: emailRunner 61 | } 62 | }, 63 | transform: { 64 | actionLink: function () { 65 | var topic = this.data.topic; 66 | var comment = this.data.comment; 67 | return topic && comment && getCommentRoute(topic._id, comment._id); 68 | } 69 | }, 70 | message: { 71 | default: function (user) { 72 | return Blaze.toHTML(Blaze.With(this, function () { 73 | return Template.notificationNewReply; 74 | })); 75 | } 76 | } 77 | }); 78 | 79 | Herald.addCourier('newTopic', { 80 | media: { 81 | onsite: {}, 82 | email: { 83 | emailRunner: emailRunner 84 | } 85 | }, 86 | transform: { 87 | actionLink: function () { 88 | var topic = this.data.topic; 89 | return topic && getTopicRoute(topic._id); 90 | } 91 | }, 92 | message: { 93 | default: function (user) { 94 | return Blaze.toHTML(Blaze.With(this, function () { 95 | return Template.notificationNewTopic; 96 | })); 97 | } 98 | } 99 | }); 100 | 101 | Herald.addCourier('newFollower', { 102 | media: { 103 | onsite: {}, 104 | email: { 105 | emailRunner: emailRunner 106 | } 107 | }, 108 | transform: { 109 | actionLink: function () { 110 | var follower = this.data.follower; 111 | var userId = Meteor.userId(); 112 | 113 | if (!follower || !userId) return; 114 | return this.data.count ? getProfileRoute(userId) : getProfileRoute(follower._id); 115 | } 116 | }, 117 | message: { 118 | default: function (user) { 119 | return Blaze.toHTML(Blaze.With(this, function () { 120 | return Template.notificationNewFollower; 121 | })); 122 | } 123 | } 124 | }); 125 | 126 | -------------------------------------------------------------------------------- /client/stylesheets/includes.import.less: -------------------------------------------------------------------------------- 1 | // fonts 2 | @fontSize-small: 0.75em; 3 | // @fontSize-base: 1em; 4 | @fontSize-large: 1.2em; 5 | @fontSize-larger: 1.5em; 6 | @fontColor-muted: rgba(0, 0, 0, 0.6); 7 | 8 | // line heights 9 | // @lineHeight-tight 10 | // @lineHeight-base 11 | // @lineHeight-loose 12 | 13 | // paddings 14 | @padding-smaller: 5px; 15 | @padding-small: 10px; 16 | @padding-main: 15px; 17 | @padding-large: 20px; 18 | @padding-larger: 30px; 19 | 20 | // misc 21 | @radius-main: 0.3em; 22 | 23 | // margins 24 | 25 | // colors -------------------------------------------- 26 | 27 | @color-pro0: rgb(153, 218, 213); 28 | @color-pro1: rgb(200, 230, 200); 29 | @color-pro2: rgb(180, 236, 252); 30 | @color-pro3: rgb(203, 224, 172); 31 | @color-pro4: rgb(124, 178, 211); 32 | 33 | @color-con0: rgb(238, 121, 137); 34 | @color-con1: rgb(245, 181, 135); 35 | @color-con2: rgb(228, 103, 87); 36 | @color-con3: rgb(218, 127, 154); 37 | @color-con4: rgb(225, 150, 206); 38 | 39 | // layout element colors 40 | // @color-lightGray: rgb(235, 235, 235); // rgba(220, 220, 220, 0.6); 41 | @color-lightGray: rgb(240, 240, 240); 42 | // @color-darkGray: rgb(160, 160, 160); 43 | 44 | @color-gray: #ABB7B7; // rgb(127, 175, 247); 45 | @color-darkGray: #95A5A6; // rgb(127, 175, 247); 46 | 47 | @color-primary: #4fc1e9; // rgb(62, 130, 231); 48 | @color-darkPrimary: #3bafda; // rgb(27, 103, 215); 49 | 50 | @color-background: #ebebeb; 51 | @color-pro: rgb(146, 240, 52); 52 | @color-con: rgb(255, 82, 76); 53 | 54 | // bg color fade effect ---------------------------- 55 | 56 | @-webkit-keyframes target-fade { 57 | 0% { background-color: rgba(202, 245, 245, .6); } 58 | 100% { background-color: #fff; } 59 | } 60 | @-moz-keyframes target-fade { 61 | 0% { background-color: rgba(202, 245, 245, .6); } 62 | 100% { background-color: #fff; } 63 | } 64 | 65 | // mixins -------------------------------------------- 66 | 67 | .bg-fade { 68 | -webkit-animation: target-fade 2s 1; 69 | -moz-animation: target-fade 2s 1; 70 | } 71 | .no-select { 72 | -webkit-touch-callout: none; 73 | -webkit-user-select: none; 74 | -khtml-user-select: none; 75 | -moz-user-select: none; 76 | -ms-user-select: none; 77 | user-select: none; 78 | } 79 | .content-header { 80 | font-size: @fontSize-large; 81 | font-weight: 400; 82 | } 83 | .content-header(@color) { 84 | .content-header; 85 | color: @color; 86 | } 87 | .border-shadow(@shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2)) { 88 | -webkit-box-shadow: @shadow; 89 | -moz-box-shadow: @shadow; 90 | box-shadow: @shadow; 91 | } 92 | .border-radius(@radius: @radius-main) { 93 | -webkit-border-radius: @radius; 94 | -moz-border-radius: @radius; 95 | border-radius: @radius; 96 | } 97 | .transform(@transform) { 98 | -webkit-transform: @transform; 99 | -ms-transform: @transform; 100 | transform: @transform; 101 | } 102 | .transition(@type) { 103 | -webkit-transition: -webkit-transform .3s @type; 104 | -moz-transition: -moz-transform .3s @type; 105 | -o-transition: -o-transform .3s @type; 106 | transition: transform .3s @type; 107 | -webkit-transform: none; 108 | -ms-transform: none; 109 | transform: none; 110 | } 111 | .overflow-ellipsis { 112 | overflow: hidden; 113 | text-overflow: ellipsis; 114 | white-space: nowrap; 115 | } 116 | .muted { 117 | color: @fontColor-muted; 118 | } 119 | .uppercase { 120 | text-transform: uppercase; 121 | letter-spacing: 0.01em; 122 | font-weight: 500; 123 | &.muted { color: rgba(0, 0, 0, 0.35); } 124 | } 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /client/views/profile/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 18 | 19 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 55 | 56 | 74 | 75 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /collections/votes.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | upvoteComment: function (comment) { 3 | check(comment, Match.ObjectIncluding({ 4 | _id: String, 5 | userId: String, 6 | upvotes: Number 7 | })); 8 | 9 | var user = Meteor.user(); 10 | var userId = user._id; 11 | var commentId = comment._id; 12 | 13 | if (!user || !canUpvote(user, comment)) 14 | throw new Meteor.Error('duplicate-content', 'This content already exists.'); 15 | 16 | // in case user is upvoting a previously downvoted comment, cancel downvote first 17 | // cancelDownvote(collection, comment, user); 18 | 19 | // votes/score 20 | comment.upvotes++; 21 | var score = getCommentScore(comment); 22 | var result = Comments.update({ '_id': commentId, 'upvoters': { $ne: userId } }, { 23 | $addToSet: { 'upvoters': userId }, 24 | $inc: { 'upvotes': 1 }, 25 | $set: { 'score': score } 26 | }); 27 | 28 | if (!!result) { 29 | // add comment to list of user's upvoted items 30 | Meteor.users.update(userId, { $addToSet: { 'activity.upvotedComments': commentId } }); 31 | 32 | // if the comment is upvoted by owner, don't modify reputation 33 | if (comment.userId !== userId) 34 | Meteor.users.update(comment.userId, { $inc: { 'stats.reputation': 1 } }); 35 | } 36 | }, 37 | cancelUpvoteComment: function (comment) { 38 | check(comment, Match.ObjectIncluding({ 39 | _id: String, 40 | userId: String, 41 | upvotes: Number 42 | })); 43 | 44 | var user = Meteor.user(); 45 | var userId = this.userId; 46 | var commentId = comment._id; 47 | 48 | // if user isn't among the upvoters, abort 49 | if (!user || canUpvote(user, comment)) 50 | throw new Meteor.Error('invalid-content', 'This content does not exist.'); 51 | 52 | // votes/score 53 | comment.upvotes--; 54 | var score = getCommentScore(comment); 55 | var result = Comments.update({ '_id': commentId, 'upvoters': userId }, { 56 | $pull: { 'upvoters': userId }, 57 | $inc: { 'upvotes': -1 }, 58 | $set: { 'score': score } 59 | }); 60 | 61 | if (!!result) { 62 | // Remove item from list of upvoted items 63 | Meteor.users.update(userId, { $pull: { 'activity.upvotedComments': commentId } }); 64 | 65 | // if the item is upvoted by owner, don't modify reputation 66 | if (comment.userId !== userId) 67 | Meteor.users.update(comment.userId, { $inc: { 'stats.reputation': -1 } }); 68 | } 69 | }, 70 | vote: function (topic, side) { 71 | check(topic, Match.ObjectIncluding({ 72 | _id: String, 73 | proUsers: [String], 74 | conUsers: [String] 75 | })); 76 | check(side, String); 77 | 78 | var user = Meteor.user(); 79 | var userId = this.userId; 80 | var topicId = topic._id; 81 | 82 | if (!user || !canUpvote(user)) 83 | throw new Meteor.Error('invalid-content', 'This content already exists.'); 84 | 85 | // in case user voted already, cancel previous vote 86 | if (_.contains(topic.proUsers, userId)) 87 | var field = 'pro'; 88 | else if (_.contains(topic.conUsers, userId)) 89 | var field = 'con'; 90 | 91 | if (field) { 92 | Topics.update(topicId, { 93 | $pull: setProperty({}, field + 'Users', userId), 94 | $inc: setProperty({}, field, -1) // need the function to convert variable key 95 | }); 96 | 97 | // just cancelling vote, not switching. so don't revote after cancel 98 | if (field === side) return; 99 | } 100 | 101 | Topics.update(topicId, { 102 | $addToSet: setProperty({}, side + 'Users', userId), 103 | $inc: setProperty({}, side, 1) 104 | }); 105 | 106 | // store voting history in user activity ? 107 | } 108 | }); 109 | 110 | -------------------------------------------------------------------------------- /collections/comments.js: -------------------------------------------------------------------------------- 1 | // schema -------------------------------------------- 2 | 3 | CommentSchema = new SimpleSchema({ 4 | _id: { 5 | type: String, 6 | optional: true 7 | }, 8 | userId: { 9 | type: String 10 | }, 11 | content: { 12 | type: String 13 | }, 14 | htmlContent: { 15 | type: String, 16 | optional: true, 17 | autoValue: afAutoMarkdown('content') 18 | }, 19 | createdAt: { 20 | type: Date 21 | }, 22 | isDeleted: { 23 | type: Boolean 24 | }, 25 | score: { 26 | type: Number, 27 | decimal: true 28 | }, 29 | upvotes: { 30 | type: Number, 31 | min: 0 32 | }, 33 | upvoters: { 34 | type: [String] 35 | }, 36 | replies: { 37 | type: [String] 38 | }, 39 | replyTo: { 40 | type: String, 41 | optional: true 42 | }, 43 | side: { 44 | type: String 45 | }, 46 | topicId: { 47 | type: String 48 | } 49 | }); 50 | 51 | Comments = new Mongo.Collection('comments'); 52 | Comments.attachSchema(CommentSchema); 53 | 54 | // end schema ---------------------------------------- 55 | 56 | 57 | // permissions --------------------------------------- 58 | 59 | Comments.allow({ 60 | update: canEditById 61 | }); 62 | Comments.deny({ 63 | update: function (userId, comment, fields) { 64 | if (isAdminById(userId)) return false; 65 | 66 | // deny update if it contains invalid fields 67 | return _.without(fields, 'content').length > 0; 68 | }, 69 | remove: function () { 70 | return true; 71 | } 72 | }); 73 | 74 | // end permissions ----------------------------------- 75 | 76 | 77 | // methods ------------------------------------------- 78 | 79 | Meteor.methods({ 80 | newComment: function(topicId, comment) { 81 | check(topicId, String); 82 | check(comment, Match.ObjectIncluding({ 83 | content: String, 84 | side: String 85 | })); 86 | 87 | var user = Meteor.user(); 88 | var userId = this.userId; 89 | var content = comment.content; 90 | var side = comment.side; 91 | var replyTo = comment.replyTo; 92 | var timeSinceLastComment = user && timeSinceLast(user, Comments); 93 | var commentInterval = 15; // 15 seconds 94 | 95 | if (!user || !canComment(user)) 96 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 97 | 98 | // check that user waits more than 15 seconds between comments 99 | if (!isAdmin(user) && !this.isSimulation && timeSinceLastComment < commentInterval) 100 | throw new Meteor.Error('wait', (commentInterval - timeSinceLastComment)); 101 | 102 | if (!validInput(content)) 103 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 104 | 105 | var comment = { 106 | userId: userId, 107 | topicId: topicId, 108 | createdAt: new Date(), 109 | content: content, 110 | side: side, 111 | upvotes: 0, 112 | upvoters: [], 113 | replyTo: replyTo, 114 | replies: [], 115 | isDeleted: false 116 | }; 117 | 118 | comment.score = getCommentScore(comment); 119 | comment._id = Comments.insert(comment); 120 | 121 | if (!!replyTo) { 122 | Comments.update(replyTo, { $addToSet: { 'replies': comment._id } }); 123 | } 124 | 125 | Meteor.users.update(userId, { 126 | $inc: { 'stats.commentsCount': 1 }, 127 | $addToSet: { 'activity.discussedTopics': topicId } 128 | }); 129 | Topics.update(topicId, { 130 | $inc: { 'commentsCount': 1 }, 131 | $addToSet: { 'commenters': userId } 132 | }); 133 | Meteor.call('newCommentNotification', comment); 134 | 135 | return comment._id; 136 | } 137 | }); 138 | 139 | // end methods --------------------------------------- 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /client/views/profile/profile.js: -------------------------------------------------------------------------------- 1 | // profile comments ---------------------------------- 2 | 3 | Template.profileComments.onCreated(function () { 4 | var self = this; 5 | 6 | self.autorun(function () { 7 | var params = getCurrentParams(); // see note on topic.onCreated 8 | initInfiniteScroll.call(self, Comments.find({ 'userId': params._id }, { 9 | fields: { '_id': 1 } 10 | })); 11 | }); 12 | }); 13 | Template.profileComments.onDestroyed(function () { 14 | stopInfiniteScroll.call(this); 15 | }); 16 | 17 | // profile topics ------------------------------------ 18 | 19 | Template.profileTopics.onCreated(function () { 20 | var self = this; 21 | 22 | self.autorun(function () { 23 | var params = getCurrentParams(); // see note on topic.onCreated 24 | initInfiniteScroll.call(self, Topics.find({ 'userId': params._id }, { 25 | fields: { '_id': 1 } 26 | })); 27 | }); 28 | }); 29 | Template.profileTopics.onDestroyed(function () { 30 | stopInfiniteScroll.call(this); 31 | }); 32 | 33 | // profile ------------------------------------------- 34 | 35 | Template.profile.helpers({ 36 | currentTab: function () { 37 | var controller = getCurrentController(); 38 | return controller.state.get('currentTab'); 39 | } 40 | }); 41 | 42 | // profile buttons ----------------------------------- 43 | 44 | Template.profileButtons.helpers({ 45 | ownProfile: function () { 46 | return Meteor.userId() === this._id; 47 | } 48 | }); 49 | 50 | Template.profileButtons.events({ 51 | 'click #js-settings': function (event, template) { 52 | Router.go('settings', { '_id': this._id }); 53 | }, 54 | 'click #js-logout': function (event, template) { 55 | Meteor.logout(function (error) { 56 | Router.go('home'); 57 | }); 58 | } 59 | }); 60 | 61 | // profile header ------------------------------------ 62 | 63 | Template.profileHeader.helpers({ 64 | canFollow: function () { 65 | return canFollow(Meteor.user(), this._id); 66 | }, 67 | following: function () { 68 | var followers = getProperty(this, 'activity.followers'); 69 | return followers && _.contains(followers, Meteor.userId()); 70 | } 71 | }); 72 | 73 | Template.profileHeader.events({ 74 | 'click #js-follow': function (event, template) { 75 | Meteor.call('newFollower', this._id, function (error) { 76 | if (error && error.error === 'logged-out') 77 | toastr.warning(i18n.t('please_login')); 78 | }); 79 | }, 80 | 'click #js-unfollow': function (event, template) { 81 | Meteor.call('removeFollower', this._id, function (error) { 82 | if (error && error.error === 'logged-out') 83 | toastr.warning(i18n.t('please_login')); 84 | }); 85 | } 86 | }); 87 | 88 | // profile nav --------------------------------------- 89 | 90 | Template.profileNav.helpers({ 91 | activeClass: function (tab) { 92 | var controller = getCurrentController(); 93 | return controller.state.get('currentTab') === tab && 'active'; 94 | } 95 | }); 96 | 97 | Template.profileNav.events({ 98 | 'click .js-nav-button': function (event, template) { 99 | var controller = getCurrentController(); 100 | controller.state.set('currentTab', event.currentTarget.getAttribute('data-tab')); 101 | } 102 | }); 103 | 104 | // profile tabs -------------------------------------- 105 | 106 | Template.profileTopics.helpers({ 107 | topics: function () { 108 | // data context (this) is the user 109 | return Topics.find({ 'userId': this._id }); 110 | } 111 | }); 112 | 113 | Template.profileComments.helpers({ 114 | comments: function () { 115 | var comments = Comments.find({ 'userId': this._id }).map(function (comment) { 116 | comment.isCommentItem = true; 117 | return comment; 118 | }); 119 | return comments; 120 | } 121 | }); 122 | 123 | Template.profileFollowers.helpers({ 124 | followers: function () { 125 | return this.activity && Meteor.users.find({ 126 | '_id': { $in: this.activity.followers } 127 | }); 128 | } 129 | }); 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /server/publications/single_topic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @summary Publish all comments for a topic, limited by topicId, each transformed with an additional `initScore` property 3 | * @param {String} topicId Id of the specific topic 4 | * @param {String} side Each column of comments is published separately. Possible values: 'pro' or 'con' 5 | * @param {Number} limit Limit the amount of comments published (note that each side of comments is limited separately) 6 | */ 7 | Meteor.publish('topicComments', function (topicId, side, limit) { 8 | check([topicId, side], [String]); 9 | check(limit, Match.Integer); 10 | 11 | var pub = this; 12 | var topic = Topics.findOne(topicId); 13 | 14 | if (!topic) return this.ready(); 15 | 16 | var selector = { 'topicId': topicId, 'side': side, 'replyTo': { $exists: false } }; 17 | var comments = Comments.find(selector, { sort: { 'score': -1 }, limit: limit }); 18 | var commentsHandle = comments.observeChanges({ 19 | added: function (id, fields) { 20 | publishOwner(fields); 21 | fields.initScore = fields.score; 22 | pub.added('comments', id, fields); 23 | }, 24 | changed: function (id, fields) { 25 | pub.changed('comments', id, fields); 26 | } 27 | }); 28 | 29 | // we don't need owners to be reactive 30 | function publishOwner (comment) { 31 | var owner = Meteor.users.findOne({ '_id': comment.userId }, { 32 | fields: { 'profile': 1, 'stats': 1 } 33 | }); 34 | pub.added('users', owner._id, owner); 35 | } 36 | 37 | pub.ready(); 38 | pub.onStop(function () { 39 | commentsHandle.stop(); 40 | }); 41 | }); 42 | 43 | /** 44 | * @summary Publish a specific comment 45 | * @param {String} commentId The id the comment 46 | */ 47 | Meteor.publish('topicComment', function (commentId) { 48 | check(commentId, String); 49 | 50 | var comment = Comments.findOne(commentId); 51 | return [ 52 | Meteor.users.find({ '_id': comment.userId }, { fields: { 'profile': 1, 'stats': 1 } }), 53 | Comments.find(commentId) 54 | ]; 55 | }); 56 | 57 | /** 58 | * @summary Publish replies for specific comment 59 | * @param {String} commentId The id the parent comment whose replies we want 60 | */ 61 | Meteor.publish('commentReplies', function (commentId) { 62 | check(commentId, String); 63 | 64 | var pub = this; 65 | var sort = { sort: { 'score': -1 } }; 66 | var comments = Comments.find({ 'replyTo': commentId }, sort); 67 | 68 | var commentsHandle = comments.observeChanges({ 69 | added: function (id, fields) { 70 | publishOwner(fields); 71 | fields.initScore = fields.score; 72 | pub.added('comments', id, fields); 73 | }, 74 | changed: function (id, fields) { 75 | pub.changed('comments', id, fields); 76 | } 77 | }); 78 | 79 | function publishOwner (comment) { 80 | var owner = Meteor.users.findOne({ '_id': comment.userId }, { 81 | fields: { 'profile': 1, 'stats': 1 } 82 | }); 83 | pub.added('users', owner._id, owner); 84 | } 85 | 86 | pub.ready(); 87 | pub.onStop(function () { 88 | commentsHandle.stop(); 89 | }); 90 | }); 91 | 92 | /** 93 | * @summary Publish specific topic document, along with owner and current user's new comments within this topic 94 | * @param {String} topicId Id of the specific topic 95 | * @param {Date} initDate Initial date of visiting the topic route. Used to determine which comments are new 96 | */ 97 | Meteor.publishComposite('singleTopic', function (topicId, initDate) { 98 | check(topicId, String); 99 | check(initDate, Date); 100 | 101 | var userId = this.userId; 102 | 103 | return { 104 | find: function () { 105 | return Topics.find(topicId); 106 | }, 107 | children: [{ 108 | find: function (topic) { // topic author 109 | return Meteor.users.find(topic.userId, { 110 | limit: 1, fields: { 'profile': 1 } 111 | }); 112 | } 113 | }, { 114 | find: function (topic) { // new comments posted by currentUser 115 | return Comments.find({ 'userId': userId, 'createdAt': { $gt: initDate } }); 116 | } 117 | }] 118 | }; 119 | }); 120 | -------------------------------------------------------------------------------- /server/templates/emailWrapper.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Binary 8 | 82 | 83 | 84 |
85 | 86 | 87 | 88 | 116 | 117 |
89 | 90 | 91 | 92 | 93 | 96 | 97 | 98 | 102 | 103 | 104 | 111 | 112 |
94 | Binary 95 |
99 | {{{body}}} 100 | {{{signature}}} 101 |
113 | 114 | 115 |
118 | 119 |
120 |
121 | 122 | 123 | -------------------------------------------------------------------------------- /client/stylesheets/comments.import.less: -------------------------------------------------------------------------------- 1 | @import "includes.import.less"; 2 | 3 | .comment-container:before { 4 | content: "▲"; 5 | display: inline-block; 6 | text-shadow: 0 -2px 1px rgba(0,0,0,0.18); 7 | position: relative; 8 | top: -18px; 9 | .transform(scale(3,2)); 10 | } 11 | .comment-container { 12 | opacity: 0; 13 | margin: @padding-large -@padding-main; 14 | padding: 0 @padding-main @padding-small; 15 | box-shadow: 0px -5px 6px -3px rgba(0,0,0,0.3), 0px 7px 6px -3px rgba(0,0,0,0.3); 16 | .border-radius(@radius-main); 17 | } 18 | 19 | .comment-container.pro:before { left: 13%; } 20 | .comment-container.pro0:before { color: @color-pro0; } 21 | .comment-container.pro1:before { color: @color-pro1; } 22 | .comment-container.pro2:before { color: @color-pro2; } 23 | .comment-container.pro3:before { color: @color-pro3; } 24 | .comment-container.pro4:before { color: @color-pro4; } 25 | 26 | .comment-container.con:before { left: 85%; } 27 | .comment-container.con0:before { color: @color-con0; } 28 | .comment-container.con1:before { color: @color-con1; } 29 | .comment-container.con2:before { color: @color-con2; } 30 | .comment-container.con3:before { color: @color-con3; } 31 | .comment-container.con4:before { color: @color-con4; } 32 | 33 | .pro0 { background: @color-pro0; } 34 | .pro1 { background: @color-pro1; } 35 | .pro2 { background: @color-pro2; } 36 | .pro3 { background: @color-pro3; } 37 | .pro4 { background: @color-pro4; } 38 | 39 | .con0 { background: @color-con0; } 40 | .con1 { background: @color-con1; } 41 | .con2 { background: @color-con2; } 42 | .con3 { background: @color-con3; } 43 | .con4 { background: @color-con4; } 44 | 45 | .comment-pro, 46 | .comment-con { 47 | padding: 7.5px; 48 | } 49 | .comment.pro, 50 | .comment.con { 51 | .avatar { position: relative; } 52 | .avatar:after { 53 | content: " "; 54 | display: block; 55 | width: 13px; 56 | height: 13px; 57 | position: absolute; 58 | bottom: -2px; 59 | right: 0; 60 | border: 3px solid #fff; 61 | .border-radius(50%); 62 | } 63 | } 64 | .comment-pro { 65 | padding-left: 0; 66 | } 67 | .comment.pro .avatar:after { background-color: @color-pro; } 68 | 69 | .comment-con { 70 | padding-right: 0; 71 | } 72 | .comment.con .avatar:after { background-color: @color-con; } 73 | 74 | .comment-row { 75 | padding: 0; 76 | width: 100%; 77 | } 78 | // .comment-replies {} 79 | .comment-new { 80 | margin-bottom: 7.5px; 81 | } 82 | .comment-new > input.form-control { 83 | background-color: #fff; 84 | padding: @padding-large @padding-small; 85 | } 86 | .comment-new textarea { 87 | padding: 12px @padding-small 0; 88 | background-color: transparent; 89 | } 90 | .comment-new .comment-new-action { 91 | padding: 0 @padding-small @padding-small; 92 | } 93 | .comment-new .comment-new-action a { 94 | float: left; 95 | } 96 | .comment-new .comment-new-action a, 97 | .comment-new .comment-new-action .toggle { 98 | margin: 4px @padding-small 0 0; 99 | } 100 | .comment-new .comment-new-action .toggle, 101 | .comment-new .comment-new-action button { 102 | float: right; 103 | } 104 | 105 | // comment card display ------------------------------ 106 | .comment { 107 | padding: 0; 108 | overflow: hidden; 109 | } 110 | .comment .comment-header { 111 | padding: @padding-large @padding-main @padding-small; 112 | } 113 | .comment .comment-content { 114 | padding: 0 @padding-main; 115 | word-wrap: break-word; 116 | } 117 | .comment.collapsed .comment-content { 118 | height: 120px; 119 | overflow-y: hidden; 120 | } 121 | .comment.deleted, 122 | .comment.comment-reply { 123 | background-color: #fafafa; 124 | color: #969696; 125 | } 126 | .comment.comment-reply a { 127 | color: #969696; 128 | } 129 | .comment.deleted .comment-content { 130 | height: 195px; 131 | padding-top: @padding-large; 132 | cursor: default; 133 | } 134 | .comment .comment-info { 135 | padding: 0 @padding-main; 136 | margin-top: @padding-small; 137 | height: 20px; 138 | font-size: @fontSize-small; 139 | span { padding-right: 8px; } 140 | } 141 | .comment .comment-footer { 142 | // padding: @padding-small @padding-main; 143 | height: 36px; 144 | background: @color-lightGray; 145 | } 146 | .comment-footer-left, 147 | .comment-footer-left .icon-group { 148 | height: 16px; 149 | } 150 | 151 | // comment item -------------------------------------- 152 | 153 | .comment.comment-item.collapsed .comment-content { 154 | min-height: 40px; 155 | height: 40px; 156 | } 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /server/notifications.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | newFollowerNotification: function (followingId) { // initiated by the follower 3 | check(followingId, String); 4 | 5 | var follower = Meteor.user(); 6 | if (!follower) return; 7 | 8 | var notificationData = { 9 | follower: { '_id': follower._id, 'name': follower.profile.name } 10 | }; 11 | 12 | // notify user who follower is following 13 | Herald.createNotification(followingId, { 14 | courier: 'newFollower', 15 | data: notificationData, 16 | 'duplicates': false, 17 | 'aggregate': true, 18 | 'aggregateAt': 3 19 | }); 20 | }, 21 | newTopicNotification: function (topic) { // initiated by the topic creator 22 | check(topic, Match.ObjectIncluding({ 23 | _id: String, 24 | title: String 25 | })); 26 | 27 | var user = Meteor.user(); // topic owner 28 | if (!user || !user.activity || !user.activity.followers) return; 29 | 30 | var notificationData = { 31 | author: { '_id': user._id, 'name': user.profile.name }, 32 | topic: _.pick(topic, '_id', 'title') 33 | }; 34 | 35 | // notify owner's followers 36 | _.each(user.activity.followers, function (followerId) { 37 | if (Herald.userPreference(followerId, 'onsite', 'newTopic')) { 38 | Herald.createNotification(followerId, { 39 | courier: 'newTopic', 40 | data: notificationData // don't aggregate new topics 41 | }); 42 | } 43 | }); 44 | }, 45 | newCommentNotification: function (comment) { // initiated by the comment creator 46 | check(comment, Match.ObjectIncluding({ 47 | _id: String, 48 | topicId: String, 49 | replyTo: Match.Optional(String) 50 | })); 51 | 52 | var topic = Topics.findOne(comment.topicId); 53 | var user = Meteor.user(); // comment owner 54 | 55 | if (!topic || !user) return; 56 | 57 | var replyToId = comment.replyTo; 58 | var notified = []; 59 | var notificationData = { 60 | author: { '_id': user._id, 'name': user.profile.name }, 61 | comment: _.pick(comment, '_id'), 62 | topic: _.pick(topic, '_id', 'title', 'userId') 63 | }; 64 | 65 | if (replyToId) { // comment reply 66 | var replyTo = Comments.findOne(replyToId); 67 | 68 | // notify replyTo owner, unless user is just replying to self 69 | if (replyTo && replyTo.userId !== user._id) { 70 | Herald.createNotification(replyTo.userId, { 71 | courier: 'newReply', 72 | data: notificationData, 73 | 'duplicates': false, 74 | 'aggregate': true, 75 | 'aggregateAt': 3, 76 | 'aggregateUnder': 'replyTo' // combine notifications that share the same comment 77 | }); 78 | notified.push(replyTo.userId); 79 | } 80 | } 81 | 82 | // notify topic owner (if topic owner wants to be notified) 83 | // unless the comment owner is also the topic owner, 84 | // or the topic owner is the replyTo owner (in which case we already notified them with 'newReply') 85 | if (topic.userId !== user._id && !_.contains(notified, topic.userId)) { 86 | Herald.createNotification(topic.userId, { 87 | courier: 'newComment.topicOwner', 88 | data: notificationData, 89 | 'duplicates': false, 90 | 'aggregate': true, 91 | 'aggregateAt': 3, 92 | 'aggregateUnder': 'topic' 93 | }); 94 | notified.push(topic.userId); 95 | } 96 | 97 | // notify topic followers 98 | var topicFollowers = _.difference(topic.followers, notified); 99 | 100 | _.each(topicFollowers, function (followerId) { 101 | // in case user is following the topic 102 | if (followerId !== user._id) { 103 | Herald.createNotification(followerId, { 104 | courier: 'newComment.topicFollower', 105 | data: notificationData, 106 | 'duplicates': false, 107 | 'aggregate': true, 108 | 'aggregateAt': 3, 109 | 'aggregateUnder': 'topic' 110 | }); 111 | notified.push(followerId); 112 | } 113 | }); 114 | 115 | // notify commenter's followers 116 | var commenterFollowers = _.difference(user.activity.followers, notified); 117 | 118 | _.each(commenterFollowers, function (followerId) { 119 | Herald.createNotification(followerId, { 120 | courier: 'newComment.follower', 121 | data: notificationData, 122 | 'aggregate': true, 123 | 'aggregateAt': 3, 124 | 'aggregateUnder': 'topic' 125 | }); 126 | // notified.push(followerId); 127 | }); 128 | } 129 | }); 130 | -------------------------------------------------------------------------------- /collections/topics.js: -------------------------------------------------------------------------------- 1 | // schema -------------------------------------------- 2 | 3 | TopicSchema = new SimpleSchema({ 4 | _id: { 5 | type: String, 6 | optional: true 7 | }, 8 | userId: { 9 | type: String 10 | }, 11 | title: { 12 | type: String 13 | }, 14 | description: { 15 | type: String, 16 | optional: true 17 | }, 18 | htmlDescription: { 19 | type: String, 20 | optional: true, 21 | autoValue: afAutoMarkdown('description') 22 | }, 23 | createdAt: { 24 | type: Date 25 | }, 26 | score: { 27 | type: Number, 28 | min: 0, 29 | decimal: true 30 | }, 31 | commentsCount: { 32 | type: Number 33 | }, 34 | commenters: { 35 | type: [String] 36 | }, 37 | pro: { 38 | type: Number, 39 | min: 0 40 | }, 41 | proUsers: { 42 | type: [String] 43 | }, 44 | con: { 45 | type: Number, 46 | min: 0 47 | }, 48 | conUsers: { 49 | type: [String] 50 | }, 51 | followers: { 52 | type: [String] 53 | } 54 | }); 55 | 56 | Topics = new Mongo.Collection('topics'); 57 | Topics.attachSchema(TopicSchema); 58 | 59 | // end schema ---------------------------------------- 60 | 61 | 62 | // search -------------------------------------------- 63 | 64 | Topics.initEasySearch('title', { 65 | limit: 10, 66 | use: 'mongo-db' 67 | }); 68 | 69 | // end search ---------------------------------------- 70 | 71 | 72 | // permissions --------------------------------------- 73 | 74 | Topics.allow({ 75 | // insert: canPostById, 76 | update: canEditById, 77 | remove: isAdminById 78 | }); 79 | 80 | Topics.deny({ 81 | update: function (userId, topic, fields) { 82 | if (isAdminById(userId)) return false; 83 | 84 | var validFields = [ 85 | 'title', 86 | 'description' 87 | ]; 88 | // deny update if it contains invalid fields 89 | return _.difference(fields, validFields).length > 0; 90 | } 91 | }); 92 | 93 | // end permissions ----------------------------------- 94 | 95 | 96 | // methods ------------------------------------------- 97 | 98 | Meteor.methods({ 99 | newTopic: function (topic) { 100 | check(topic, { 101 | title: String, 102 | description: String 103 | }); 104 | 105 | var user = Meteor.user(); 106 | var userId = this.userId; 107 | var title = topic.title; 108 | var description = topic.description; 109 | var timeSinceLastTopic = user && timeSinceLast(user, Topics); 110 | var topicInterval = 15; // 15 seconds 111 | 112 | if (!user || !canPost(user)) 113 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 114 | 115 | if(!isAdmin(user) && !this.isSimulation && timeSinceLastTopic < topicInterval) 116 | throw new Meteor.Error('wait', (topicInterval - timeSinceLastTopic)); 117 | 118 | if (!validInput(title)) 119 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 120 | 121 | // check if title already exists 122 | var topicWithTitle = Topics.findOne({ 'title': title }); 123 | 124 | if (typeof topicWithTitle !== 'undefined') 125 | throw new Meteor.Error('duplicate-content', 'This content already exists.', topicWithTitle._id); 126 | 127 | var topic = { 128 | title: title, 129 | description: description, 130 | userId: userId, 131 | createdAt: new Date(), 132 | // category: category, 133 | commentsCount: 0, 134 | pro: 0, 135 | con: 0, 136 | commenters: [], 137 | proUsers: [], 138 | conUsers: [], 139 | followers: [] 140 | }; 141 | 142 | topic.score = getTopicScore(topic); 143 | topic._id = Topics.insert(topic); 144 | 145 | Meteor.users.update(userId, { $inc: { 'stats.topicsCount': 1 } }); 146 | Meteor.call('newTopicNotification', topic); 147 | Meteor.call('followTopic', topic._id); 148 | 149 | return topic._id; 150 | }, 151 | followTopic: function (topicId) { 152 | check(topicId, String); 153 | 154 | var userId = this.userId; 155 | if (!userId || !canFollowById(userId)) 156 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 157 | 158 | Topics.update(topicId, { $addToSet: { 'followers': userId } }); 159 | Meteor.users.update(userId, { $addToSet: { 'activity.followingTopics': topicId } }); 160 | }, 161 | unfollowTopic: function (topicId) { 162 | check(topicId, String); 163 | 164 | var userId = this.userId; 165 | if (!userId || !canFollowById(userId)) 166 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 167 | 168 | Topics.update(topicId, { $pull: { 'followers': userId } }); 169 | Meteor.users.update(userId, { $pull: { 'activity.followingTopics': topicId } }); 170 | } 171 | }); 172 | 173 | // end methods --------------------------------------- 174 | 175 | -------------------------------------------------------------------------------- /client/views/settings/settings.html: -------------------------------------------------------------------------------- 1 | 23 | 24 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /collections/users.js: -------------------------------------------------------------------------------- 1 | // schema -------------------------------------------- 2 | 3 | var Schema = {}; 4 | 5 | Schema.UserInvites = new SimpleSchema({ 6 | inviteCount: { 7 | type: Number, 8 | min: 0 9 | }, 10 | invitedEmails: { 11 | type: [String] 12 | }, 13 | invitedBy: { 14 | type: String, 15 | optional: true 16 | } 17 | }); 18 | 19 | Schema.UserProfile = new SimpleSchema({ 20 | name: { 21 | type: String, 22 | regEx: /^([a-zA-Z]+[a-zA-Z0-9.'-\s]*){3,25}$/ 23 | }, 24 | bio: { 25 | type: String, 26 | max: 100, 27 | optional: true 28 | }, 29 | notifications: { 30 | type: Object, 31 | optional: true, 32 | blackbox: true 33 | } 34 | }); 35 | 36 | Schema.UserFlags = new SimpleSchema({ 37 | comments: { 38 | type: [String], 39 | defaultValue: [] 40 | }, 41 | topics: { 42 | type: [String], 43 | defaultValue: [] 44 | } 45 | }); 46 | 47 | Schema.UserStats = new SimpleSchema({ 48 | commentsCount: { 49 | type: Number, 50 | min: 0, 51 | defaultValue: 0 52 | }, 53 | followersCount: { 54 | type: Number, 55 | min: 0, 56 | defaultValue: 0 57 | }, 58 | topicsCount: { 59 | type: Number, 60 | min: 0, 61 | defaultValue: 0 62 | }, 63 | reputation: { 64 | type: Number, 65 | min: 0, // possibly negative if we have downvotes ? 66 | defaultValue: 0 67 | }, 68 | flagsCount: { // number of helpful flags 69 | type: Number, 70 | min: 0, 71 | defaultValue: 0 72 | } 73 | }); 74 | 75 | Schema.User = new SimpleSchema({ 76 | _id: { 77 | type: String, 78 | optional: true 79 | }, 80 | emails: { 81 | type: [Object], 82 | optional: true 83 | }, 84 | 'emails.$.address': { 85 | type: String, 86 | regEx: SimpleSchema.RegEx.Email 87 | }, 88 | 'emails.$.verified': { 89 | type: Boolean 90 | }, 91 | isAdmin: { 92 | type: Boolean, 93 | defaultValue: false 94 | }, 95 | ipAddress: { 96 | type: String, 97 | optional: true 98 | }, 99 | // email_hash: { 100 | // type: String, 101 | // optional: true 102 | // }, 103 | createdAt: { 104 | type: Date 105 | }, 106 | invites: { 107 | type: Schema.UserInvites, 108 | optional: true 109 | }, 110 | stats: { // public but not modifiable 111 | type: Schema.UserStats, 112 | optional: true 113 | }, 114 | flags: { 115 | type: Schema.UserFlags, 116 | optional: true 117 | }, 118 | profile: { // public and modifiable 119 | type: Schema.UserProfile 120 | }, 121 | activity: { // public but not modifiable 122 | type: Object, 123 | optional: true, 124 | blackbox: true 125 | }, 126 | services: { 127 | type: Object, 128 | blackbox: true 129 | } 130 | }); 131 | 132 | Meteor.users.attachSchema(Schema.User); 133 | 134 | // end schema ---------------------------------------- 135 | 136 | 137 | // permissions --------------------------------------- 138 | 139 | Meteor.users.deny({ 140 | update: function (userId, user, fields) { 141 | if (isAdminById(userId)) return false; 142 | 143 | // deny the update if it contains something other than the profile field 144 | return (_.without(fields, 'profile').length > 0); 145 | } 146 | }); 147 | 148 | Meteor.users.allow({ 149 | // update: function (userId, user) { 150 | // return isAdminById(userId) || userId == user._id; 151 | // }, 152 | // remove: function (userId, user) { 153 | // return isAdminById(userId) || userId == user._id; 154 | // } 155 | update: canEditById, 156 | remove: canEditById 157 | }); 158 | 159 | // end permissions ----------------------------------- 160 | 161 | 162 | // search -------------------------------------------- 163 | 164 | Meteor.users.initEasySearch('profile.name', { 165 | limit: 15, 166 | use: 'mongo-db', 167 | query: function (searchString) { 168 | var query = EasySearch.getSearcher(this.use).defaultQuery(this, searchString); 169 | var user = Meteor.users.findOne(this.publishScope.userId); 170 | 171 | if (user) query['profile.name'] = { $ne: user.profile.name }; 172 | return query; 173 | } 174 | }); 175 | 176 | // end search ---------------------------------------- 177 | 178 | 179 | // methods ------------------------------------------- 180 | 181 | Meteor.methods({ 182 | newFollower: function (followingId) { 183 | check(followingId, String); 184 | 185 | var userId = this.userId; 186 | if (!userId || !canFollowById(userId)) 187 | throw new Meteor.Error('logged-out', 'This user must be logged in to continue.'); 188 | 189 | // update user being followed 190 | Meteor.users.update(userId, { 191 | $addToSet: { 'activity.followingUsers': followingId } 192 | }); 193 | 194 | // update the user who is following 195 | Meteor.users.update(followingId, { 196 | $addToSet: { 'activity.followers': userId }, 197 | $inc: { 'stats.followersCount': 1 } 198 | }); 199 | 200 | Meteor.call('newFollowerNotification', followingId); 201 | }, 202 | removeFollower: function (followingId) { 203 | check(followingId, String); 204 | 205 | var userId = this.userId; 206 | if (!userId || !canFollowById(userId)) 207 | throw new Meteor.Error('logged-out', 'This user must be logged in to continue.'); 208 | 209 | // update user being followed 210 | Meteor.users.update(userId, { 211 | $pull: { 'activity.followingUsers': followingId } 212 | }); 213 | 214 | // update the user who is following 215 | Meteor.users.update(followingId, { 216 | $pull: { 'activity.followers': userId }, 217 | $inc: { 'stats.followersCount': -1 } 218 | }); 219 | } 220 | }); 221 | 222 | // end methods --------------------------------------- 223 | 224 | -------------------------------------------------------------------------------- /server/email.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Adopted from TelescopeJS 3 | * 4 | * See: https://github.com/TelescopeJS/Telescope/blob/ba38e2a75b1de3e5a5e5332341a74d5f4424498c/lib/permissions.js 5 | */ 6 | 7 | var htmlToText = Meteor.npmRequire('html-to-text'); 8 | 9 | buildEmailTemplate = function (htmlContent) { 10 | var emailProperties = { 11 | body: htmlContent, 12 | signature: i18n.t('email_signature'), 13 | opt_out: i18n.t('email_opt_out', { url: getSettingsUrl() }), 14 | copyright: i18n.t('copyright') 15 | }; 16 | 17 | var emailHTML = Handlebars.templates['emailWrapper'](emailProperties); 18 | var inlinedHTML = juice(emailHTML); 19 | var doctype = '' 20 | 21 | return doctype + inlinedHTML; 22 | }; 23 | 24 | buildEmailNotification = function (notification) { 25 | return (function (n, courier) { 26 | var email = {}; 27 | 28 | switch (courier) { 29 | case 'newTopic': 30 | if (!n.author || !n.topic) break; 31 | 32 | email.message = i18n.t('notification_new_topic', { 33 | user: n.author.name, 34 | topic: n.topic.title 35 | }); 36 | email.action = { 37 | link: getTopicUrl(n.topic._id), 38 | message: i18n.t('discuss') 39 | }; 40 | 41 | break; 42 | case 'newComment.topicOwner': 43 | case 'newComment.topicFollower': 44 | case 'newComment.follower': 45 | if (!n.topic || !n.comment) break; 46 | email.action = { 47 | link: getCommentUrl(n.topic._id, n.comment._id), 48 | message: i18n.t('discuss') 49 | }; 50 | 51 | var topicMessage = n.topic.userId === n.user._id ? 52 | i18n.t('in_topic_owning', { topic: n.topic.title }) : 53 | i18n.t('in_topic', { topic: n.topic.title }); 54 | var options = { 55 | user: n.author.name, 56 | topic: topicMessage 57 | }; 58 | 59 | if (!n.count) { 60 | email.message = i18n.t('notification_new_comment', options); 61 | break; 62 | } 63 | 64 | options.count = n.count - 1; 65 | if (courier === 'newComment.follower') { 66 | email.message = i18n.t('notification_new_comment_plural', options); 67 | } else { 68 | email.message = i18n.t('notification_new_comment_users_plural', options); 69 | } 70 | 71 | break; 72 | case 'newReply': 73 | if (!n.author || !n.topic) break; 74 | 75 | email.action = { 76 | link: getCommentUrl(n.topic._id, n.comment._id), 77 | message: i18n.t('discuss') 78 | }; 79 | 80 | var topicMessage = n.topic.userId === n.user._id ? 81 | i18n.t('in_topic_owning', { topic: n.topic.title }) : 82 | i18n.t('in_topic', { topic: n.topic.title }); 83 | var options = { 84 | user: n.author.name, 85 | topic: topicMessage 86 | }; 87 | if (n.count) { 88 | options.count = n.count - 1; 89 | email.message = i18n.t('notification_new_reply_plural', options); 90 | } else { 91 | email.message = i18n.t('notification_new_reply', options); 92 | } 93 | 94 | break; 95 | case 'newFollower': 96 | if (!n.follower || !n.user) break; 97 | 98 | email.action = { 99 | link: n.count ? 100 | getProfileUrl(n.user._id, 'followers') : getProfileUrl(n.follower._id), 101 | message: i18n.t('view_profile') 102 | }; 103 | 104 | if (n.count) { 105 | email.message = i18n.t('notification_new_follower_plural', { 106 | user: n.follower.name, 107 | count: n.count - 1 108 | }); 109 | } else { 110 | email.message = i18n.t('notification_new_follower', { user: n.follower.name }); 111 | } 112 | 113 | break; 114 | default: 115 | break; 116 | } 117 | 118 | if (!email.message || !email.action) throw new Meteor.Error('invalid-content', 'Email notification not sent: missing/invalid params!'); 119 | 120 | // var properties = _.extend(n, email.properties); 121 | var templateHTML = Handlebars.templates['emailNotification'](email); 122 | // var templateHTML = Handlebars.templates[email.template](properties); 123 | var emailHTML = buildEmailTemplate(templateHTML); 124 | 125 | return { 126 | subject: email.message, 127 | html: emailHTML 128 | }; 129 | })(notification.data, notification.courier); 130 | }; 131 | 132 | buildEmailText = function (html) { 133 | // Auto-generate text version if it doesn't exist. Has bugs, but should be good enough. 134 | return htmlToText.fromString(html, { 135 | wordwrap: 130 136 | }); 137 | }; 138 | 139 | buildAndSendEmail = function (to, subject, template, properties) { 140 | var html = buildEmailTemplate(Handlebars.templates[template](properties)); 141 | return sendEmail(to, subject, html); 142 | }; 143 | 144 | sendEmail = function (to, subject, html, text) { 145 | 146 | // TODO: limit who can send emails 147 | // TODO: fix this error: Error: getaddrinfo ENOTFOUND 148 | 149 | var from = 'Binary '; 150 | 151 | if (typeof text === 'undefined'){ 152 | var text = buildEmailText(html); 153 | } 154 | 155 | // console.log('//////// sending email…'); 156 | // console.log('from: ' + from); 157 | // console.log('to: ' + to); 158 | // console.log('subject: ' + subject); 159 | // console.log('html: ' + html); 160 | // console.log('text: ' + text); 161 | 162 | var email = { 163 | from: from, 164 | to: to, 165 | subject: subject, 166 | text: text, 167 | html: html 168 | } 169 | 170 | Email.send(email); 171 | 172 | return email; 173 | }; 174 | -------------------------------------------------------------------------------- /client/views/topics/topic.js: -------------------------------------------------------------------------------- 1 | Template.topic.onCreated(function () { 2 | var topicId = this.data._id; 3 | 4 | this.autorun(function () { 5 | // reruns infinite scroll when we switch between single comment page and comment list page. 6 | // because the templates/layout are identical between the two pages, the templates won't be recreated 7 | // so we need to track changes on params and rerun it ourselves. ideally we only want to 8 | // track the commentId param (not any query or hash changes) but there are none of those for now. 9 | getCurrentParams(); 10 | 11 | initInfiniteScroll.call(this, [ 12 | Comments.find({ 'topicId': topicId, 'side': 'pro' }, { fields: { '_id': 1 } }), 13 | Comments.find({ 'topicId': topicId, 'side': 'con' }, { fields: { '_id': 1 } }) 14 | ]); 15 | }); 16 | }); 17 | 18 | Template.topic.onRendered(function () { 19 | // top level comment reply boxes have to be animated separately 20 | var container = this.find('.list'); 21 | container._uihooks = { 22 | insertElement: function (node, next) { 23 | var $node = $(node); 24 | if ($node.hasClass('comment-container')) { 25 | Meteor.setTimeout(function () { 26 | $node.insertBefore(next); 27 | $node.velocity('slideDown', { duration: 200 }); 28 | Meteor.setTimeout(function () { $node.css('opacity', 1); }, 1); 29 | }, 1); 30 | } else { 31 | $node.insertBefore(next); 32 | } 33 | } 34 | }; 35 | }); 36 | 37 | Template.topic.onDestroyed(function () { 38 | stopInfiniteScroll.call(this); 39 | }); 40 | 41 | Template.topicHeader.onRendered(function () { 42 | var description = this.find('.topic-description'); 43 | var $description = $(description); 44 | 45 | if (!$description || !$description.length) return; 46 | 47 | if (description.scrollHeight > $description.innerHeight()) { 48 | $description.addClass('collapsible'); 49 | } 50 | }); 51 | 52 | Template.topic.events({ 53 | 'click #js-load-original': function (event, template) { 54 | Router.go('topic', { _id: this._id }); 55 | SessionAmplify.set('showingReplies', []); 56 | } 57 | }); 58 | 59 | Template.topic.helpers({ 60 | showOriginal: function () { 61 | var params = getCurrentParams(); 62 | return params && Comments.findOne(params.commentId, { fields: { '_id': 1 } }); 63 | }, 64 | comments: function () { 65 | var params = getCurrentParams(); 66 | var comment = params && Comments.findOne(params.commentId); 67 | var res = []; 68 | 69 | if (comment) { 70 | comment.side === 'pro' ? 71 | res.push({ 'pros': comment, 'cons': null }) : 72 | res.push({ 'pros': null, 'cons': comment }); 73 | res.push({ 'bottom': true }); 74 | return res; 75 | } 76 | 77 | var selector = { 'replyTo': { $exists: false }, 'topicId': this._id }; 78 | var incomingComments = getIncomingComments(selector); 79 | var comments = getComments(selector); 80 | comments = incomingComments.concat(comments); 81 | 82 | var pros = [], cons = []; 83 | _.each(comments, function (comment) { 84 | comment.side === 'pro' ? pros.push(comment) : cons.push(comment); 85 | }); 86 | 87 | var len = Math.max(pros.length, cons.length), i = -1; 88 | while (++i < len) { 89 | res.push({ 'pros': pros[i], 'cons': cons[i] }); 90 | } 91 | 92 | res.push({ 'bottom': true }); 93 | return res; 94 | } 95 | }); 96 | 97 | Template.topicButtons.helpers({ 98 | following: function () { 99 | var user = Meteor.user(); 100 | var following = user && user.activity && user.activity.followingTopics; 101 | return following && _.contains(following, this._id); 102 | } 103 | }); 104 | 105 | Template.topicHeader.helpers({ 106 | selected: function (side) { 107 | var userId = Meteor.userId(); 108 | var side = side + 'Users'; 109 | return userId && _.contains(this[side], userId) && 'selected'; 110 | } 111 | }); 112 | 113 | Template.topicNav.helpers({ 114 | canFlag: function () { 115 | return canFlagTopic(Meteor.user(), this._id); 116 | } 117 | }); 118 | 119 | Template.topicNav.events({ 120 | 'click #js-delete-topic': function (event, template) { 121 | if (confirm(i18n.t('are_you_sure', { action: i18n.t('delete_topic') }))) { 122 | Meteor.call('removeTopic', this, function (error) { 123 | if (error) { 124 | if (error.error === 'no-permission') 125 | toastr.warning(i18n.t('no_permission')); 126 | else 127 | toastr.warning(i18n.t('error')); 128 | } 129 | }); 130 | } 131 | }, 132 | 'click #js-flag-topic': function (event, template) { 133 | OneModal('flagModal', { data: { _id: this._id, type: 'topics' } }); 134 | } 135 | }); 136 | 137 | Template.topicHeader.events({ 138 | 'click .collapsible': function (event, template) { 139 | template.$('.topic-description').toggleClass('collapsed'); 140 | }, 141 | 'click #js-vote-pro': function (event, template) { 142 | if (Meteor.userId()) 143 | Meteor.call('vote', this, 'pro'); 144 | else 145 | OneModal('signupModal', { modalClass: 'modal-sm' }); 146 | }, 147 | 'click #js-vote-con': function (event, template) { 148 | if (Meteor.userId()) 149 | Meteor.call('vote', this, 'con'); 150 | else 151 | OneModal('signupModal', { modalClass: 'modal-sm' }); 152 | } 153 | }); 154 | 155 | Template.topicButtons.events({ 156 | 'click #js-follow': function (event, template) { 157 | Meteor.call('followTopic', this._id, function (error) { 158 | if (error) { 159 | if (error.error === 'logged-out') 160 | toastr.warning(i18n.t('please_login')); 161 | else 162 | toastr.warning(i18n.t('error')); 163 | } 164 | }); 165 | }, 166 | 'click #js-unfollow': function (event, template) { 167 | Meteor.call('unfollowTopic', this._id, function (error) { 168 | if (error) { 169 | if (error.error === 'logged-out') 170 | toastr.warning(i18n.t('please_login')); 171 | else 172 | toastr.warning(i18n.t('error')); 173 | } 174 | }); 175 | } 176 | }); 177 | 178 | -------------------------------------------------------------------------------- /server/admin.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | removeComment: function (comment) { 3 | check(comment, Match.ObjectIncluding({ 4 | _id: String, 5 | topicId: String, 6 | userId: String, 7 | upvotes: Match.Integer, 8 | upvoters: [String] 9 | })); 10 | 11 | if (!isAdmin(Meteor.user())) 12 | throw new Meteor.Error('no-permission', 'This user does not have permission to continue.'); 13 | 14 | var commentId = comment._id; 15 | var topicId = comment.topicId; 16 | var userId = comment.userId; 17 | var upvotes = comment.upvotes; 18 | var upvoters = comment.upvoters; 19 | 20 | // subtract comment count from topic 21 | Topics.update(topicId, { $inc: { 'commentsCount': -1 } }); 22 | 23 | // mark comment as deleted 24 | Comments.update(commentId, { 25 | $set: { 26 | 'isDeleted': true, 27 | 'upvotes': 0, 28 | 'content': i18n.t('comment_deleted') 29 | } 30 | }); 31 | // subtract comments count and reputation 32 | Meteor.users.update(userId, { 33 | $inc: { 'stats.commentsCount': -1, 'stats.reputation': -upvotes }, 34 | $pull: { 'activity.discussedTopics': topicId } 35 | }); 36 | // remove comment from users upvotedComments 37 | Meteor.users.update(upvoters, { 38 | $pull: { 'activity.upvotedComments': commentId } 39 | }, { multi: true }); 40 | }, 41 | removeTopic: function (topic) { 42 | check(topic, Match.ObjectIncluding({ 43 | _id: String, 44 | userId: String, 45 | followers: [String], 46 | commenters: [String] 47 | })); 48 | 49 | if (!isAdmin(Meteor.user())) 50 | throw new Meteor.Error('no-permission', 'This user does not have permission to continue.'); 51 | 52 | var topicId = topic._id; 53 | var userId = topic.userId; 54 | 55 | var commentIds = []; 56 | var commenters = {}; // all users related to the topic comments (creators, upvoters, etc) 57 | var opts = { // the fields that could be changed 58 | 'commentsCount': 0, 59 | 'reputation': 0, 60 | 'upvotedComments': [] 61 | }; 62 | 63 | Comments.find({ 'topicId': topicId }).forEach(function (comment) { 64 | commenters[comment.userId] = commenters[comment.userId] || opts; 65 | commenters[comment.userId].commentsCount++; 66 | commenters[comment.userId].reputation += comment.upvotes; 67 | 68 | _.each(comment.upvoters, function (upvoter) { 69 | commenters[upvoter] = commenters[upvoter] || opts; 70 | commenters[upvoter].upvotedComments.push(comment._id); 71 | }); 72 | 73 | commentIds.push(comment._id); 74 | }); 75 | 76 | var commenterIds = _.keys(commenters); 77 | _.each(commenterIds, function (commenterId) { 78 | var commenter = commenters[commenterId]; 79 | // subtract comments count & reputation, remove upvoted comments 80 | Meteor.users.update({ '_id': commenterId }, { 81 | $inc: { 82 | 'stats.commentsCount': -commenter.commentsCount, 83 | 'stats.reputation': -commenter.reputation 84 | }, 85 | $pullAll: { 'activity.upvotedComments': commenter.upvotedComments } 86 | }); 87 | }); 88 | 89 | Comments.remove({ '_id': { $in: commentIds } }); 90 | 91 | // remove topic from users following 92 | Meteor.users.update({ '_id': { $in: topic.followers } }, { 93 | $pull: { 'activity.followingTopics': topicId } 94 | }, { multi: true }); 95 | 96 | // remove topic from users discussed 97 | Meteor.users.update({ '_id': { $in: topic.commenters } }, { 98 | $pull: { 'activity.discussedTopics': topicId } 99 | }, { multi: true }); 100 | 101 | // subtract topics count for creator 102 | Meteor.users.update(userId, { $inc: { 'stats.topicsCount': -1 } }); 103 | 104 | // delete the topic 105 | Topics.remove(topicId); 106 | }, 107 | newFlag: function (itemId, itemType, reason) { 108 | check([itemId, itemType, reason], [String]); 109 | 110 | var user = Meteor.user(); 111 | var userId = user && user._id; 112 | 113 | if (!user) 114 | throw new Meteor.Error('no-permission', 'This user does not have permission to continue.'); 115 | 116 | if (!_.contains(['comments', 'topics'], itemType)) 117 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 118 | 119 | Flags.insert({ 120 | userId: userId, 121 | createdAt: new Date(), 122 | reason: reason, 123 | itemId: itemId, 124 | itemType: itemType 125 | }); 126 | var query = setProperty({}, 'flags.' + itemType, itemId); 127 | Meteor.users.update(userId, { $addToSet: query }); 128 | 129 | var admins = Meteor.users.find({ 'isAdmin': true }); 130 | admins.forEach(function (admin) { 131 | var properties = { 132 | message: getDisplayName(user) + ' flagged item of type: ' + itemType, 133 | action: { 134 | link: getSiteUrl() + 'admin', 135 | message: 'Admin Panel' 136 | } 137 | }; 138 | 139 | var adminEmail = admin.emails[0].address; 140 | Meteor.setTimeout(function () { 141 | buildAndSendEmail(adminEmail, 'New flag on Binary', 'emailNotification', properties); 142 | }, 1); 143 | }); 144 | }, 145 | changeFlag: function (flag, newStatus) { 146 | check(flag, Match.ObjectIncluding({ 147 | _id: String, 148 | userId: String 149 | })); 150 | check(newStatus, Match.Integer); 151 | 152 | var user = Meteor.user(); 153 | if (!user || !isAdmin(user)) 154 | throw new Meteor.Error('no-permission', 'This user does not have permission to continue.'); 155 | 156 | if (!flag) 157 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 158 | 159 | var flagId = flag._id; 160 | var userId = flag.userId; 161 | var count = newStatus === 0 ? -1 : 1; // decrease helpful flags count if helpful status is retracted 162 | 163 | Flags.update(flagId, { $set: { 'status': newStatus } }); 164 | Meteor.users.update(userId, { $inc: { 'stats.flagsCount': count } }); 165 | } 166 | }); 167 | -------------------------------------------------------------------------------- /server/users.js: -------------------------------------------------------------------------------- 1 | // Accounts.validateNewUser(function (user) { 2 | // }); 3 | Accounts.onCreateUser(function (options, user) { 4 | var userProperties = { 5 | profile: options.profile || {}, 6 | ipAddress: options.ipAddress, 7 | invites: { 8 | inviteCount: 3, 9 | invitedEmails: [] 10 | }, 11 | activity: { // activity involving other users/collections 12 | upvotedComments: [], 13 | followers: [], 14 | followingUsers: [], 15 | followingTopics: [], 16 | discussedTopics: [] 17 | } 18 | }; 19 | // add default properties 20 | user = _.extend(user, userProperties); 21 | 22 | var email = user.emails[0].address; 23 | if (!email) { 24 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 25 | } 26 | 27 | var invite = Invites.findOne({ 'invitedEmail': email, 'accepted': false }); 28 | if (invite) { 29 | // update the user who invited 30 | user.invites.invitedBy = invite.inviterId; 31 | // update the invite status to accepted 32 | Invites.update(invite._id, { $set: { 'accepted': true } }); 33 | 34 | user.emails[0].verified = true; 35 | } 36 | 37 | // set notifications default preferences 38 | user.profile.notifications = { 39 | media: { 40 | onsite: true, 41 | email: true 42 | }, 43 | couriers: { 44 | newTopic: { 45 | onsite: true, 46 | email: true 47 | }, 48 | newComment: { 49 | topicOwner: { 50 | onsite: true, // notify user of comments if he is the topic owner 51 | email: true 52 | }, 53 | topicFollower: { 54 | onsite: true, // notify user if he is topic follower 55 | email: true 56 | }, 57 | follower: { 58 | onsite: true, // notify user of another's comments if he is a follower 59 | email: true 60 | } 61 | }, 62 | newReply: { 63 | onsite: true, 64 | email: true 65 | }, 66 | newFollower: { 67 | onsite: true, 68 | email: true 69 | } 70 | } 71 | }; 72 | 73 | return user; 74 | }); 75 | 76 | var sendWelcomeEmail = function (userId) { 77 | var user = userId && Meteor.users.findOne(userId); 78 | if (!user) return; 79 | 80 | var name = getDisplayName(user); 81 | var email = getEmail(user); 82 | var profileUrl = getProfileUrl(user._id); 83 | 84 | // notify admins 85 | var admins = Meteor.users.find({ 'isAdmin': true }); 86 | admins.forEach(function (admin) { 87 | var properties = { 88 | name: name, 89 | actionLink: profileUrl 90 | }; 91 | 92 | var adminEmail = admin.emails[0].address; 93 | Meteor.setTimeout(function () { 94 | buildAndSendEmail(adminEmail, 'A new user just joined Binary', 'emailNewUser', properties); 95 | }, 1); 96 | }); 97 | 98 | // send welcome email 99 | Meteor.setTimeout(function () { 100 | buildAndSendEmail(email, i18n.t('email_welcome_subject'), 'emailWelcome', { 101 | greeting: i18n.t('greeting', name), 102 | message: [ 103 | i18n.t('email_welcome_message_0'), 104 | i18n.t('email_welcome_message_1'), 105 | i18n.t('email_welcome_message_2') 106 | ] 107 | }); 108 | }, 1); 109 | }; 110 | 111 | Meteor.methods({ 112 | newUser: function (email, name, password) { 113 | check([email, name, password], [String]); 114 | 115 | var name = stripHTML(name); 116 | if (!validName(name)) 117 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 118 | 119 | if (password.length < 6) 120 | throw new Meteor.Error('weak-password', 'This password must have at least 6 characters.'); 121 | 122 | var userId = Accounts.createUser({ 123 | 'email': email, 124 | 'password': password, 125 | 'ipAddress': this.connection.clientAddress, 126 | 'profile': { 127 | 'name': name, 128 | 'bio': i18n.t('default_profile') 129 | } 130 | }); 131 | 132 | sendWelcomeEmail(userId); 133 | }, 134 | newInvitedUser: function (name, password, inviteCode) { 135 | check([name, password, inviteCode], [String]); 136 | 137 | var name = stripHTML(name); 138 | var invite = { 139 | inviteCode: inviteCode, 140 | accepted: false 141 | }; 142 | invite = Invites.findOne(invite); 143 | 144 | if (!invite) 145 | throw new Meteor.Error('invalid-invite', 'This invitation does not match any existing invitations.'); 146 | 147 | if (!validName(name)) 148 | throw new Meteor.Error('invalid-content', 'This content does not meet the specified requirements.'); 149 | 150 | if (password.length < 6) 151 | throw new Meteor.Error('weak-password', 'This password must have at least 6 characters.'); 152 | 153 | var userId = Accounts.createUser({ 154 | 'email': invite.invitedEmail, 155 | 'password': password, 156 | 'ipAddress': this.connection.clientAddress, 157 | 'profile': { 158 | 'name': name, 159 | 'bio': i18n.t('default_profile') 160 | } 161 | }); 162 | 163 | sendWelcomeEmail(userId); 164 | return invite.invitedEmail; 165 | }, 166 | changeProfile: function (newName, newBio) { 167 | check([newName, newBio], [String]); 168 | 169 | if (!this.userId) 170 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 171 | 172 | var query = { $set: { 'profile.name': newName, 'profile.bio': newBio } }; 173 | Meteor.users.update(this.userId, query, function (error, result) { 174 | if (error) { 175 | // throw error.sanitizedError; 176 | throw error; 177 | } 178 | }); 179 | }, 180 | changeEmail: function (newEmail) { 181 | check(newEmail, String); 182 | 183 | if (!this.userId) 184 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 185 | 186 | Meteor.users.update(this.userId, { $set: { 187 | 'emails': [{ 'address': newEmail, 'verified': false }] 188 | }}); 189 | Accounts.sendVerificationEmail(this.userId); 190 | }, 191 | sendVerificationEmail: function () { 192 | if (!this.userId) 193 | throw new Meteor.Error('no-permission', i18n.t('please_login')); 194 | 195 | Accounts.sendVerificationEmail(this.userId); 196 | }, 197 | // sendResetSuccessEmail: function () { 198 | // var user = Meteor.user(); 199 | // if (!user) return; 200 | 201 | // Meteor.setTimeout(function () { 202 | // buildAndSendEmail(user.emails[0].address, 'Your password on Binary has been reset', 'emailResetSuccess', { 203 | // name: user.profile.name, 204 | // actionLink: '' 205 | // }); 206 | // }, 1); 207 | // } 208 | }); 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | i18n = { 2 | t: function (str, options) { 3 | return TAPi18n.__(str, options); 4 | } 5 | }; 6 | 7 | getIncomingComments = function (selector) { 8 | var userId = Meteor.userId(); 9 | var controller = getCurrentController(); 10 | 11 | if (!userId || !controller) return []; 12 | 13 | var runAt = controller._runAt; 14 | selector.createdAt = { $gt: runAt }; 15 | selector.userId = userId; 16 | 17 | return Comments.find(selector, { sort: { 'createdAt': -1 } }).fetch(); 18 | }; 19 | getComments = function (selector) { 20 | var controller = getCurrentController(); 21 | if (!controller) return []; 22 | if (_.has(selector, 'userId')) delete selector.userId; 23 | 24 | var runAt = controller._runAt; 25 | selector.createdAt = { $lt: runAt }; 26 | return Comments.find(selector, { sort: { 'initScore': -1 } }).fetch(); 27 | }; 28 | 29 | getSiteUrl = function () { 30 | return Meteor.absoluteUrl(); 31 | }; 32 | getCurrentController = function () { 33 | return Router && Router.current(); 34 | }; 35 | getCurrentRoute = function () { 36 | var controller = getCurrentController(); 37 | return controller.url; 38 | }; 39 | getCurrentParams = function () { 40 | var controller = getCurrentController(); 41 | return controller && controller.getParams(); 42 | }; 43 | getCurrentQuery = function () { 44 | var params = getCurrentParams(); 45 | return params && params.query; 46 | }; 47 | getCurrentHash = function () { 48 | var params = getCurrentParams(); 49 | return params && params.hash; 50 | }; 51 | getSettingsUrl = function () { 52 | return Router.routes['settings'].url(); 53 | }; 54 | getTopicRoute = function (topicId, commentId) { 55 | return Router.routes['topic'].path({ '_id': topicId }); 56 | }; 57 | getTopicUrl = function (topicId, commentId) { 58 | return Router.routes['topic'].url({ '_id': topicId }); 59 | }; 60 | getCommentRoute = function (topicId, commentId) { 61 | return Router.routes['comment'].path({ 62 | '_id': topicId, 63 | 'commentId': commentId 64 | }); 65 | }; 66 | getCommentUrl = function (topicId, commentId) { 67 | return Router.routes['comment'].path({ 68 | '_id': topicId, 69 | 'commentId': commentId 70 | }); 71 | }; 72 | getProfileRoute = function (userId, tab) { 73 | if (typeof tab !== 'undefined') { 74 | var query = { 'query': { 'tab': tab } }; 75 | } 76 | return Router.routes['profile'].path({ '_id': userId }, query); 77 | }; 78 | getProfileUrl = function (userId, tab) { 79 | if (typeof tab !== 'undefined') { 80 | var query = { 'query': { 'tab': tab } }; 81 | } 82 | return Router.routes['profile'].url({ '_id': userId }, query); 83 | }; 84 | getInviteUrl = function (inviterId, inviteCode) { 85 | return Router.routes['invite'].url({}, { 86 | 'query': { 87 | 'inviter_id': inviterId, 88 | 'invite_code': inviteCode 89 | } 90 | }); 91 | }; 92 | 93 | getScore = function (options) { 94 | check(options, { votes: Number, createdAt: Date }); 95 | 96 | var votes = options.votes; 97 | var order = Math.log(Math.max(votes, 1)) / Math.LN10; 98 | var age = options.createdAt / 1000 - 1134028003; 99 | 100 | return (order + age / 45000).toFixed(7); 101 | }; 102 | getCommentScore = function (comment) { 103 | check(comment, Match.ObjectIncluding({ upvotes: Number, createdAt: Date })); 104 | return getScore({ votes: comment.upvotes, createdAt: comment.createdAt }); 105 | }; 106 | getTopicScore = function (topic) { 107 | check(topic, Match.ObjectIncluding({ pro: Number, con: Number, createdAt: Date })); 108 | return getScore({ votes: topic.pro + topic.con, createdAt: topic.createdAt }); 109 | }; 110 | 111 | /** 112 | * Takes a string in camel case format and returns 113 | * the string with spaces and capitalization 114 | */ 115 | camelToTitle = function (str) { 116 | return str && capitalize(str.replace(/([a-z])([A-Z])/g, '$1 $2')); 117 | }; 118 | 119 | capitalize = function (string) { 120 | return string.charAt(0).toUpperCase() + string.slice(1); 121 | }; 122 | 123 | // fadeElement = function ($elem) { 124 | // if ($elem.css('opacity') > 0) { 125 | // $elem.velocity('fadeOut', { duration: 1000, complete: function () { 126 | // $(this).html(''); 127 | // }}); 128 | // } 129 | // }; 130 | 131 | /** 132 | * Sets the property 'property' of object 'obj' 133 | * to value 'value' 134 | */ 135 | setProperty = function (obj, property, value) { 136 | obj = obj || {}; 137 | obj[property] = value; 138 | return obj; 139 | }; 140 | 141 | getProperty = function (obj, path) { 142 | var path = path.split('.'); 143 | for (var i = 0, len = path.length; i < len; i++) 144 | obj = obj && obj[path[i]]; 145 | return obj; 146 | }; 147 | 148 | // http://stackoverflow.com/questions/7793811/convert-javascript-dot-notation-object-to-nested-object 149 | getObject = function (o) { 150 | var oo = {}, t, parts, part; 151 | for (var k in o) { 152 | t = oo; 153 | parts = k.split('.'); 154 | var key = parts.pop(); 155 | while (parts.length) { 156 | part = parts.shift(); 157 | t = t[part] = t[part] || {}; 158 | } 159 | t[key] = o[k] 160 | } 161 | return oo; 162 | }; 163 | 164 | scrollToId = function (id) { 165 | // ensure that dom is ready first 166 | Tracker.afterFlush(function () { 167 | var $comment = $('#' + id); 168 | if (!$comment || !$comment.length) return; 169 | 170 | $comment 171 | .velocity('scroll', { 'duration': 500, 'offset': -63 }) 172 | .addClass('bg-fade'); 173 | 174 | setTimeout(function() { 175 | $comment.removeClass('bg-fade'); 176 | }, 2000); 177 | }); 178 | }; 179 | 180 | validName = function (s) { 181 | var s = s && stripHTML(s).trim(); 182 | return !!s && /^([a-zA-Z]+[a-zA-Z0-9.'-\s]*){3,25}$/.test(s); 183 | }; 184 | 185 | validInput = function (s, len) { 186 | var len = len || 8; 187 | var s = s && stripSpaces(stripHTML(s)); 188 | return !!s && s.length >= len; 189 | }; 190 | 191 | sanitize = function (s) { 192 | if (Meteor.isServer) { 193 | var s = sanitizeHtml(s, { 194 | allowedTags: [ 195 | 'p', 'a', 'b', 'i', 'strong', 196 | 'em', 'strike', 'code', 'pre' 197 | ] 198 | }); 199 | } 200 | return s; 201 | }; 202 | 203 | stripHTML = function (s) { 204 | return s && s.replace(/<(?:.|\n)*?>/gm, ''); 205 | }; 206 | 207 | stripMarkdown = function (s) { 208 | var html_body = marked(s); 209 | return stripHTML(html_body); 210 | }; 211 | 212 | markdownToHTML = function (s) { 213 | return typeof s === 'string' && sanitize(marked(s)); 214 | }; 215 | 216 | // Strips any form of whitespace, including spaces and newlines 217 | stripSpaces = function (s) { 218 | return s && s.replace(/\s| /gm, ''); 219 | }; 220 | 221 | /** 222 | * Takes a javascript Date object 223 | * If the date is over a week ago, 224 | * Returns the date in month/day/year format 225 | * Otherwise, returns 'x time ago' 226 | */ 227 | formatDate = function (date) { 228 | if (!date) return; 229 | 230 | var then = date.getTime(); 231 | var now = new Date().getTime(); 232 | var weekInMilliseconds = 604800000; 233 | 234 | if (now - then > weekInMilliseconds) { 235 | return moment(date).format('D MMM YYYY'); 236 | } 237 | return moment(date).fromNow(); 238 | }; 239 | 240 | formatError = function (error) { 241 | return error.reason; 242 | }; 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | -------------------------------------------------------------------------------- /packages/npm-container/.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "html-to-text": { 4 | "version": "0.1.0", 5 | "dependencies": { 6 | "htmlparser": { 7 | "version": "1.7.7" 8 | }, 9 | "underscore": { 10 | "version": "1.7.0" 11 | }, 12 | "underscore.string": { 13 | "version": "2.3.3" 14 | }, 15 | "optimist": { 16 | "version": "0.6.1", 17 | "dependencies": { 18 | "wordwrap": { 19 | "version": "0.0.2" 20 | }, 21 | "minimist": { 22 | "version": "0.0.10" 23 | } 24 | } 25 | } 26 | } 27 | }, 28 | "juice": { 29 | "version": "0.4.0", 30 | "dependencies": { 31 | "cssom": { 32 | "version": "0.2.5" 33 | }, 34 | "jsdom": { 35 | "version": "0.6.5", 36 | "dependencies": { 37 | "htmlparser2": { 38 | "version": "3.7.3", 39 | "dependencies": { 40 | "domhandler": { 41 | "version": "2.2.0" 42 | }, 43 | "domutils": { 44 | "version": "1.5.0" 45 | }, 46 | "domelementtype": { 47 | "version": "1.1.1" 48 | }, 49 | "readable-stream": { 50 | "version": "1.1.13", 51 | "dependencies": { 52 | "core-util-is": { 53 | "version": "1.0.1" 54 | }, 55 | "isarray": { 56 | "version": "0.0.1" 57 | }, 58 | "string_decoder": { 59 | "version": "0.10.31" 60 | }, 61 | "inherits": { 62 | "version": "2.0.1" 63 | } 64 | } 65 | }, 66 | "entities": { 67 | "version": "1.0.0" 68 | } 69 | } 70 | }, 71 | "nwmatcher": { 72 | "version": "1.3.3" 73 | }, 74 | "request": { 75 | "version": "2.45.0", 76 | "dependencies": { 77 | "bl": { 78 | "version": "0.9.3", 79 | "dependencies": { 80 | "readable-stream": { 81 | "version": "1.0.33-1", 82 | "dependencies": { 83 | "core-util-is": { 84 | "version": "1.0.1" 85 | }, 86 | "isarray": { 87 | "version": "0.0.1" 88 | }, 89 | "string_decoder": { 90 | "version": "0.10.31" 91 | }, 92 | "inherits": { 93 | "version": "2.0.1" 94 | } 95 | } 96 | } 97 | } 98 | }, 99 | "caseless": { 100 | "version": "0.6.0" 101 | }, 102 | "forever-agent": { 103 | "version": "0.5.2" 104 | }, 105 | "qs": { 106 | "version": "1.2.2" 107 | }, 108 | "json-stringify-safe": { 109 | "version": "5.0.0" 110 | }, 111 | "mime-types": { 112 | "version": "1.0.2" 113 | }, 114 | "node-uuid": { 115 | "version": "1.4.1" 116 | }, 117 | "tunnel-agent": { 118 | "version": "0.4.0" 119 | }, 120 | "form-data": { 121 | "version": "0.1.4", 122 | "dependencies": { 123 | "combined-stream": { 124 | "version": "0.0.5", 125 | "dependencies": { 126 | "delayed-stream": { 127 | "version": "0.0.5" 128 | } 129 | } 130 | }, 131 | "mime": { 132 | "version": "1.2.11" 133 | }, 134 | "async": { 135 | "version": "0.9.0" 136 | } 137 | } 138 | }, 139 | "tough-cookie": { 140 | "version": "0.12.1", 141 | "dependencies": { 142 | "punycode": { 143 | "version": "1.3.1" 144 | } 145 | } 146 | }, 147 | "http-signature": { 148 | "version": "0.10.0", 149 | "dependencies": { 150 | "assert-plus": { 151 | "version": "0.1.2" 152 | }, 153 | "asn1": { 154 | "version": "0.1.11" 155 | }, 156 | "ctype": { 157 | "version": "0.5.2" 158 | } 159 | } 160 | }, 161 | "oauth-sign": { 162 | "version": "0.4.0" 163 | }, 164 | "hawk": { 165 | "version": "1.1.1", 166 | "dependencies": { 167 | "hoek": { 168 | "version": "0.9.1" 169 | }, 170 | "boom": { 171 | "version": "0.4.2" 172 | }, 173 | "cryptiles": { 174 | "version": "0.2.2" 175 | }, 176 | "sntp": { 177 | "version": "0.2.4" 178 | } 179 | } 180 | }, 181 | "aws-sign2": { 182 | "version": "0.5.0" 183 | }, 184 | "stringstream": { 185 | "version": "0.0.4" 186 | } 187 | } 188 | }, 189 | "cssstyle": { 190 | "version": "0.2.21", 191 | "dependencies": { 192 | "cssom": { 193 | "version": "0.3.0" 194 | } 195 | } 196 | }, 197 | "contextify": { 198 | "version": "0.1.9", 199 | "dependencies": { 200 | "bindings": { 201 | "version": "1.2.1" 202 | }, 203 | "nan": { 204 | "version": "1.3.0" 205 | } 206 | } 207 | } 208 | } 209 | }, 210 | "batch": { 211 | "version": "0.3.2" 212 | }, 213 | "superagent": { 214 | "version": "0.14.9", 215 | "dependencies": { 216 | "qs": { 217 | "version": "0.6.5" 218 | }, 219 | "formidable": { 220 | "version": "1.0.9" 221 | }, 222 | "mime": { 223 | "version": "1.2.5" 224 | }, 225 | "emitter-component": { 226 | "version": "1.0.0" 227 | }, 228 | "methods": { 229 | "version": "0.0.1" 230 | }, 231 | "cookiejar": { 232 | "version": "1.3.0" 233 | }, 234 | "debug": { 235 | "version": "0.7.4" 236 | } 237 | } 238 | }, 239 | "commander": { 240 | "version": "1.1.1", 241 | "dependencies": { 242 | "keypress": { 243 | "version": "0.1.0" 244 | } 245 | } 246 | }, 247 | "slick": { 248 | "version": "1.10.4" 249 | } 250 | } 251 | } 252 | } 253 | } 254 | --------------------------------------------------------------------------------