define('core/models/Post',[ 'jquery', 'underscore', 'backbone', 'moment', 'core/config/urls', 'core/api', 'core/strings', 'core/time', 'core/utils', 'core/utils/html', 'core/advice', 'remote/config', 'core/models/mixins', 'core/collections/VotersUserCollection', 'core/collections/VoteCollection', ], function ( $, _, Backbone, moment, urls, api, strings, time, utils, htmlUtils, advice, config, modelMixins, VotersUserCollection, VoteCollection ) { 'use strict'; var VOTE_RATE_LIMIT = 1000; // 1s rate limit on voting var LAST_VOTE = 0; var MAX_POST_LENGTH = 25000; var acquireVoteLock = function () { var now = $.now(); if (now - LAST_VOTE < VOTE_RATE_LIMIT) { return false; } LAST_VOTE = now; return true; }; var gettext = strings.get; var Post = Backbone.Model.extend({ votersCollectionClass: VotersUserCollection, defaults: function () { return { createdAt: moment().format(time.ISO_8601), editableUntil: moment().add(config.max_post_edit_days, 'days').format(time.ISO_8601), dislikes: 0, isApproved: true, // Assume approved isDeleted: false, isEdited: false, isFlagged: false, isFlaggedByUser: false, isHighlighted: false, isRealtime: false, isImmediateReply: false, isMinimized: null, hasMedia: false, message: null, raw_message: null, likes: 0, media: [], parent: null, points: 0, depth: 0, userScore: 0, rating: null, }; }, initialize: function () { this.votes = new VoteCollection(); }, messageText: function () { var msg = this.get('message'); return msg && htmlUtils.stripTags(msg); }, permalink: function (thread, useCurrentPage) { var id = this.id; if (!(id && thread)) { return ''; } // Favor currentUrl if useCurrentPage is not false and always fallback to permalink var baseUrl = useCurrentPage !== false && // default to true thread.currentUrl || thread.permalink(); var urlTool = window.document.createElement('a'); urlTool.href = baseUrl; urlTool.hash = '#comment-' + id; return urlTool.href; }, shortLink: function () { return urls.shortener + '/p/' + Number(this.id).toString(36); }, twitterText: function (permalink) { var charsRemaining = 140; var author = this.author.get('name') || this.author.get('username'); charsRemaining -= author.length + 3; // +3 for space dash space before author charsRemaining -= permalink.length + 1; // +1 for space before url charsRemaining -= 2; // For the quotation marks var text = utils.niceTruncate(this.messageText(), charsRemaining); return '"' + text + '" \u2014 ' + author; }, toJSON: function (options) { var json = Backbone.Model.prototype.toJSON.call(this); if (options) { var session = options.session; var thread = options.thread; json.canBeEdited = this.canBeEdited(session, thread); json.canBeRepliedTo = this.canBeRepliedTo(session, thread); json.canBeShared = this.canBeShared(); json.permalink = this.permalink(thread); } json.shortLink = this.shortLink(); json.isMinimized = this.isMinimized(); json.plaintext = this.messageText(); // Add relativeCreatedAt for display in the UI (e.g., "Comment created 1 hour ago") json.relativeCreatedAt = this.getRelativeCreatedAt(); // formattedCreatedAt is nice absolute time (e.g. "Jan 1, 2008, 12:00 pm") json.formattedCreatedAt = this.getFormattedCreatedAt(); // expose cid to make it easier to find the model in collections later json.cid = this.cid; return json; }, /** * Is this post visible to all users? * @returns {boolean} */ isPublic: function () { // Posts that are highlighted or sponsored are always public. if (this.get('isHighlighted') || this.get('isSponsored')) { return true; } // Otherwise, if they are deleted they are definitely *not* public. if (this.get('isDeleted')) { return false; } // Finally, if they are approved then they are public. return this.get('isApproved'); }, isMinimized: function () { // If comment has been highlighted by a moderator, it is NOT minimized // (highlighting is implicit approval by the moderator) if (this.get('isHighlighted')) { return false; } // If the comment has been explicitly expanded (unminimized) by // the user, it is NOT minimized if (this.get('isMinimized') === false) { return false; } return !this.get('isApproved'); }, // These methods are stubbed out because they rely on the Session object, // which hasn't been ported to next-core yet. They still exist, and return // sensible default values, because they're called from Post.prototype.toJSON. // // NOTE: Until Session is ported to next-core, any applications using Post w/ // sessions will need to override these prototype methods isAuthorSessionUser: function () { return false; }, canBeEdited: function () { return false; }, canBeRepliedTo: function () { return false; }, canBeShared: function () { return false; }, validateMessage: function (attrs) { if (_.isString(attrs.raw_message)) { if (attrs.raw_message === '') { return gettext('Comments can\'t be blank.'); } // eslint-disable-next-line no-magic-numbers if (attrs.raw_message.length < 2) { return gettext('Comments must have at least 2 characters.'); } if (attrs.raw_message.length > MAX_POST_LENGTH) { return strings.interpolate(gettext('Comments can\'t be longer than %(maxLength)s characters (currently %(currentLength)s characters).'), { maxLength: MAX_POST_LENGTH, currentLength: attrs.raw_message.length, }); } } }, validate: function (attrs) { if (this.id || attrs.id) { return; } var messageError = this.validateMessage(attrs); if (messageError) { return messageError; } if (attrs.author_email) { attrs.author_email = $.trim(attrs.author_email); } if (attrs.author_name) { attrs.author_name = $.trim(attrs.author_name); } if (attrs.author_email === '' && attrs.author_name === '') { return gettext('Please sign in or enter a name and email address.'); } else if (attrs.author_email === '' || attrs.author_name === '') { return gettext('Please enter both a name and email address.'); } else if (attrs.author_name && attrs.author_name.length && !attrs.age_declaration) { return gettext('Please confirm that you are 18 years of age or older.'); } // Simple client-side email validation. This is obviously not a complete // regex, but it should catch 90+% of errors. We can catch the rest on // the server. if (_.isString(attrs.author_email) && !this.validateEmail(attrs.author_email)) { return gettext('Invalid email address format.'); } }, validateEmail: function (email) { return utils.validateEmail(email); }, report: function (reason) { this.set('isFlagged', true); var data = { post: this.id, }; if (reason) { data.reason = reason; } api.call('posts/report.json', { data: data, method: 'POST', }); }, _highlight: function (shouldBeHighlighted) { this.set('isHighlighted', shouldBeHighlighted); api.call('posts/' + (shouldBeHighlighted ? 'highlight' : 'unhighlight') + '.json', { data: { post: this.id, }, method: 'POST', }); }, highlight: function () { this._highlight(true); }, unhighlight: function () { this._highlight(false); }, /** * Override for post models that don't store the thread id * in the thread attr * * @returns {string} id of thread. */ getThreadId: function () { return this.get('thread'); }, getUpvotersUserCollection: _.memoize(function () { var CollectionClass = this.votersCollectionClass; return new CollectionClass(undefined, { postId: this.id, threadId: this.getThreadId(), }); }, function () { return [this.id, '1'].join(''); }), getDownvotersUserCollection: _.memoize(function () { var CollectionClass = this.votersCollectionClass; return new CollectionClass(undefined, { postId: this.id, threadId: this.getThreadId(), }); }, function () { return [this.id, '-1'].join(''); }), // This function does score calculations and increments/decrements // counters. We can't just bind it to the 'add' event because // we want to call _vote before making a network request and adding // a freshly created vote to a collection. (sad face) _vote: function (vote, score, voter) { var delta = vote - score; var updates = { likes: this.get('likes'), dislikes: this.get('dislikes'), points: this.get('points'), }; // If the vote is unchanged from the user's previous vote, // so ignore - there's nothing to do if (delta === 0) { return delta; } // Update likes and dislikes. This is tricky because a // switched vote requires updating *both* attributes, whereas // a new vote only touches one. if (vote > 0) { updates.likes += vote; updates.dislikes += score; } else if (vote < 0) { updates.dislikes -= vote; updates.likes -= score; } else if (score > 0) { updates.likes -= score; } else { updates.dislikes += score; } updates.points += delta; // add user into correct collection and remove from the other one if (voter) { if (vote === 1) { this.getUpvotersUserCollection().add(voter); this.getDownvotersUserCollection().remove(voter); } else { this.getDownvotersUserCollection().add(voter); this.getUpvotersUserCollection().remove(voter); } } // All these values should be updated at once to prevent any "half-baked" // data to trigger the view or any other thing that listens on change // events of these properties. this.set(updates); return delta; }, // Vote on a post, taking into account any earlier votes // made by the current user (via the userScore attribute). // // @params vote {Integer} Can be one of -1, 0, or 1 vote: function (vote) { // Rate limit voting on the Post's public vote interface. // We don't rate limit on the private interface because that // is used for realtime votes which could come in faster than // the rate limit. if (!acquireVoteLock()) { return 0; } var self = this; var delta = self._vote(vote, self.get('userScore')); if (delta === 0) { return; } var newNumLikesReceived = self.author ? self.author.get('numLikesReceived') : 0; if (self.get('userScore') === 1) { newNumLikesReceived -= 1; } else if (vote === 1) { newNumLikesReceived += 1; } self.set('userScore', vote); api.call('posts/vote.json', { data: { post: self.id, vote: vote, }, method: 'POST', success: function (data) { // We have merge:true in order to update existing vote instances, // so when the same vote will come from realtime it won't be recognized // as a change. self.votes.add({ id: data.response.id, score: vote }, { merge: true }); if (self.author) { self.author.set('numLikesReceived', newNumLikesReceived); } }, }); }, _delete: function () { this.set({ isApproved: false, isDeleted: true, }); return api.call('posts/remove.json', { data: { post: this.id }, method: 'POST', }); }, spam: function () { this.set({ isApproved: false, isDeleted: true, isSpam: true, }); this.trigger('spam'); api.call('posts/spam.json', { data: { post: this.id }, method: 'POST', }); }, _create: function (model, options) { var self = this; var attrs = model.attributes; var params = { thread: attrs.thread, message: attrs.raw_message, rating: attrs.rating, }; if (attrs.parent) { params.parent = attrs.parent; } // Anon posting if (attrs.author_name) { params.author_name = attrs.author_name; params.author_email = attrs.author_email; params.age_declaration = attrs.age_declaration; } return api.call('posts/create.json', { data: params, method: 'POST', success: function (data) { self.set(data.response); if (options.success) { options.success(); } }, error: options.error, }); }, _update: function (model, options) { var self = this; var attrs = model.attributes; var params = { post: attrs.id, message: attrs.raw_message, rating: attrs.rating, }; return api.call('posts/update.json', { data: params, method: 'POST', success: function (data) { self.set(data.response); if (options.success) { options.success(); } }, error: options.error, }); }, _read: function (_model, options) { var self = this; options = options || {}; return api.call('posts/details.json', { data: { post: self.id }, method: 'GET', success: function (data) { // When highlighting and unhighlighting posts in quick succession, // sometimes we send a cached version of the model that is unhighlighted. // If that happens, we fix that here in the response if (options.isHighlighted && !data.response.isHighlighted) { data.response.isHighlighted = true; } self.set(data.response); if (options.success) { options.success(); } }, error: options.error, }); }, sync: function (method, model, options) { options = options || {}; var error = options.error; if (error) { options.error = function (xhr) { var response = {}; try { response = JSON.parse(xhr.responseText); } catch (err) { // ignore } error(response); }; } switch (method) { case 'create': return this._create(model, options); case 'update': return this._update(model, options); case 'delete': return this._delete(); case 'read': return this._read(model, options); default: return null; } }, /** * Generate key for storing draft posts in local storage. * A draft post is one that has not yet been saved on the back * end. * * Example: drafts:thread:1:parent:0 * * @returns {string} key name */ storageKey: function () { // Only allow draft posts to be stored in local storage if (!this.isNew()) { return; } if (!this.getThreadId()) { return; } return ['drafts', 'thread', this.getThreadId(), 'parent', this.get('parent') || 0].join(':'); }, }, { /** * Get an escaped approximation of how a raw message * will be formatted by our API. * * This method currently _only_ formats newlines into *
and
tags.
* TODO: make this method handle mentions and various
* supported tags.
*
* @params {string} text Text to format.
* @returns {string} Escaped and
message.
*/
formatMessage: (function () {
var paragraphReg = /(?:\r\n|\r|\n){2,}/;
var lineReg = /\r\n|\r|\n/;
return function (text) {
// Any series of two or more newlines is a paragraph.
// Use `compact()` to filter empty values.
var paragraphs = _.chain(text.split(paragraphReg))
.compact()
.value();
var html = _.map(paragraphs, function (paragraph) {
// Single newlines are spaced with
tags.
// Use `compact()` to filter empty values.
return _.chain(paragraph.split(lineReg))
.compact()
// Escape text to prevent users from
// accidentally XXSing themselves.
.map(_.escape)
.join('
')
.value();
}).join('
'); return '
' + html + '
'; }; })(), }); // // Applied mixins // modelMixins.withCreatedAt.call(Post.prototype); advice.withAdvice.call(Post.prototype); // // Optional mixins // /** * Adds `author` as a sub-model of Post */ Post.withAuthor = function (UserModel) { this.around('set', function (set, key, val, options) { var attrs; // eslint-disable-next-line eqeqeq, no-eq-null if (key == null) { return this; } // Handle both `"key", value` and `{key: value}` -style arguments. if (typeof key === 'object') { attrs = key; options = val; } else { attrs = {}; attrs[key] = val; } var author = attrs.author; if (author) { // Convert 'author' attributes to a model instance. Attributes // should never contain nested properties, but that's what the API // returns, so we convert it here. // // NOTE: This kind of stuff is made easier with a Backbone plugin // called Backbone-Relational, but I'm not sure we need it // just yet. (https://github.com/PaulUithol/Backbone-relational) if (_.isString(author) || _.isNumber(author)) { var id = author; author = {}; author[UserModel.prototype.idAttribute || 'id'] = id; } // Usually the Post will have access to the PostCollection, // but when the sort-order changes we need to grab the collection from the author var collection = this.collection || (this.author && this.author.collection); // Convert the forum badge IDs on the post author to forum badge objects var forum = collection && collection.thread && collection.thread.forum; if (this.author && this.author.get('badges').length && this.author.get('badges')[0].id) { // If the author already has the forum badge objects author.badges = this.author.get('badges'); } else if (forum && forum.get('badges') && author.badges) { var badges = []; var badgeIds = author.badges || []; var forumBadges = forum.get('badges'); badgeIds.forEach(function (badgeId) { if (forumBadges[badgeId]) { badges.push(forumBadges[badgeId]); } }); author.badges = badges; } this.author = new UserModel(author); // This is the standard event structure for when a related model is // set (used in home). this.trigger('changeRelated:author'); delete attrs.author; } return set.call(this, attrs, options); }); this.around('toJSON', function (toJSON) { var json = toJSON.apply(this, _.rest(arguments)); if (this.author) { json.author = this.author.toJSON(); } return json; }); }; /** * Adds `media` as a sub-collection of Post * * @param {Object} MediaCollection - a MediaCollection class that encapsulates media attributes * @returns {undefined} */ Post.withMediaCollection = function (MediaCollection) { this.after('set', function (attrs) { if (attrs && typeof attrs !== 'string') { // Convert 'media' attribute into collection if (!_.isUndefined(attrs.media)) { // Populate media collection if (this.media) { this.media.reset(attrs.media); } else { this.media = new MediaCollection(attrs.media); } delete attrs.media; } } }); this.around('toJSON', function (toJSON) { var json = toJSON.apply(this, _.rest(arguments)); if (this.media) { json.media = this.media.toJSON(); } return json; }); }; return Post; }); // https://c.disquscdn.com/next/next-core/core/models/Post.js