- 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
- * <p> and <br> tags.
- * TODO: make this method handle mentions and various
- * supported tags.
- *
- * @params {string} text Text to format.
- * @returns {string} Escaped and <p> <br> 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 <br> 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('<br>')
- .value();
- }).join('</p><p>');
- return '<p>' + html + '</p>';
- };
- })(),
- });
- //
- // 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