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
]]>