discourse/app/assets/javascripts/discourse/models/composer.js.es6

781 lines
23 KiB
JavaScript

import RestModel from 'discourse/models/rest';
import Topic from 'discourse/models/topic';
import { throwAjaxError } from 'discourse/lib/ajax-error';
import Quote from 'discourse/lib/quote';
import Draft from 'discourse/models/draft';
import computed from 'ember-addons/ember-computed-decorators';
const CLOSED = 'closed',
SAVING = 'saving',
OPEN = 'open',
DRAFT = 'draft',
// The actions the composer can take
CREATE_TOPIC = 'createTopic',
PRIVATE_MESSAGE = 'privateMessage',
REPLY = 'reply',
EDIT = 'edit',
REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic",
// When creating, these fields are moved into the post model from the composer model
_create_serializer = {
raw: 'reply',
title: 'title',
category: 'categoryId',
topic_id: 'topic.id',
is_warning: 'isWarning',
whisper: 'whisper',
archetype: 'archetypeId',
target_usernames: 'targetUsernames',
typing_duration_msecs: 'typingTime',
composer_open_duration_msecs: 'composerTime',
tags: 'tags'
},
_edit_topic_serializer = {
title: 'topic.title',
categoryId: 'topic.category.id',
tags: 'topic.tags'
};
const Composer = RestModel.extend({
_categoryId: null,
archetypes: function() {
return this.site.get('archetypes');
}.property(),
@computed
categoryId: {
get() { return this._categoryId; },
// We wrap categoryId this way so we can fire `applyTopicTemplate` with
// the previous value as well as the new value
set(categoryId) {
const oldCategoryId = this._categoryId;
if (Ember.isEmpty(categoryId)) { categoryId = null; }
this._categoryId = categoryId;
if (oldCategoryId !== categoryId) {
this.applyTopicTemplate(oldCategoryId, categoryId);
}
return categoryId;
}
},
creatingTopic: Em.computed.equal('action', CREATE_TOPIC),
creatingPrivateMessage: Em.computed.equal('action', PRIVATE_MESSAGE),
notCreatingPrivateMessage: Em.computed.not('creatingPrivateMessage'),
@computed("privateMessage", "archetype.hasOptions")
showCategoryChooser(isPrivateMessage, hasOptions) {
const manyCategories = Discourse.Category.list().length > 1;
return !isPrivateMessage && (hasOptions || manyCategories);
},
@computed("creatingPrivateMessage", "topic")
privateMessage(creatingPrivateMessage, topic) {
return creatingPrivateMessage || (topic && topic.get('archetype') === 'private_message');
},
topicFirstPost: Em.computed.or('creatingTopic', 'editingFirstPost'),
editingPost: Em.computed.equal('action', EDIT),
replyingToTopic: Em.computed.equal('action', REPLY),
viewOpen: Em.computed.equal('composeState', OPEN),
viewDraft: Em.computed.equal('composeState', DRAFT),
composeStateChanged: function() {
var oldOpen = this.get('composerOpened');
if (this.get('composeState') === OPEN) {
this.set('composerOpened', oldOpen || new Date());
} else {
if (oldOpen) {
var oldTotal = this.get('composerTotalOpened') || 0;
this.set('composerTotalOpened', oldTotal + (new Date() - oldOpen));
}
this.set('composerOpened', null);
}
}.observes('composeState'),
composerTime: function() {
var total = this.get('composerTotalOpened') || 0;
var oldOpen = this.get('composerOpened');
if (oldOpen) {
total += (new Date() - oldOpen);
}
return total;
}.property().volatile(),
archetype: function() {
return this.get('archetypes').findProperty('id', this.get('archetypeId'));
}.property('archetypeId'),
archetypeChanged: function() {
return this.set('metaData', Em.Object.create());
}.observes('archetype'),
// view detected user is typing
typing: _.throttle(function(){
var typingTime = this.get("typingTime") || 0;
this.set("typingTime", typingTime + 100);
}, 100, {leading: false, trailing: true}),
editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'),
canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'),
canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'),
// Determine the appropriate title for this action
actionTitle: function() {
const topic = this.get('topic');
let postLink, topicLink, usernameLink;
if (topic) {
const postNumber = this.get('post.post_number');
postLink = "<a href='" + (topic.get('url')) + "/" + postNumber + "'>" +
I18n.t("post.post_number", { number: postNumber }) + "</a>";
topicLink = "<a href='" + (topic.get('url')) + "'> " + Discourse.Utilities.escapeExpression(topic.get('title')) + "</a>";
usernameLink = "<a href='" + (topic.get('url')) + "/" + postNumber + "'>" + this.get('post.username') + "</a>";
}
let postDescription;
const post = this.get('post');
if (post) {
postDescription = I18n.t('post.' + this.get('action'), {
link: postLink,
replyAvatar: Discourse.Utilities.tinyAvatar(post.get('avatar_template')),
username: this.get('post.username'),
usernameLink
});
if (!this.site.mobileView) {
const replyUsername = post.get('reply_to_user.username');
const replyAvatarTemplate = post.get('reply_to_user.avatar_template');
if (replyUsername && replyAvatarTemplate && this.get('action') === EDIT) {
postDescription += " <i class='fa fa-mail-forward reply-to-glyph'></i> " + Discourse.Utilities.tinyAvatar(replyAvatarTemplate) + " " + replyUsername;
}
}
}
switch (this.get('action')) {
case PRIVATE_MESSAGE: return I18n.t('topic.private_message');
case CREATE_TOPIC: return I18n.t('topic.create_long');
case REPLY:
case EDIT:
if (postDescription) return postDescription;
if (topic) return I18n.t('post.reply_topic', { link: topicLink });
}
}.property('action', 'post', 'topic', 'topic.title'),
// whether to disable the post button
cantSubmitPost: function() {
// can't submit while loading
if (this.get('loading')) return true;
// title is required when
// - creating a new topic/private message
// - editing the 1st post
if (this.get('canEditTitle') && !this.get('titleLengthValid')) return true;
// reply is always required
if (this.get('missingReplyCharacters') > 0) return true;
if (this.get("privateMessage")) {
// need at least one user when sending a PM
return this.get('targetUsernames') && (this.get('targetUsernames').trim() + ',').indexOf(',') === 0;
} else {
// has a category? (when needed)
return this.get('canCategorize') &&
!this.siteSettings.allow_uncategorized_topics &&
!this.get('categoryId') &&
!this.user.get('admin');
}
}.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'),
titleLengthValid: function() {
if (this.user.get('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true;
if (this.get('titleLength') < this.get('minimumTitleLength')) return false;
return (this.get('titleLength') <= this.siteSettings.max_topic_title_length);
}.property('minimumTitleLength', 'titleLength', 'post.static_doc'),
// The icon for the save button
saveIcon: function () {
switch (this.get('action')) {
case EDIT: return '<i class="fa fa-pencil"></i>';
case REPLY: return '<i class="fa fa-reply"></i>';
case CREATE_TOPIC: return '<i class="fa fa-plus"></i>';
case PRIVATE_MESSAGE: return '<i class="fa fa-envelope"></i>';
}
}.property('action'),
// The text for the save button
saveText: function() {
switch (this.get('action')) {
case EDIT: return I18n.t('composer.save_edit');
case REPLY: return I18n.t('composer.reply');
case CREATE_TOPIC: return I18n.t('composer.create_topic');
case PRIVATE_MESSAGE: return I18n.t('composer.create_pm');
}
}.property('action'),
hasMetaData: function() {
const metaData = this.get('metaData');
return metaData ? Em.isEmpty(Em.keys(this.get('metaData'))) : false;
}.property('metaData'),
/**
Did the user make changes to the reply?
@property replyDirty
**/
replyDirty: function() {
return this.get('reply') !== this.get('originalText');
}.property('reply', 'originalText'),
/**
Number of missing characters in the title until valid.
@property missingTitleCharacters
**/
missingTitleCharacters: function() {
return this.get('minimumTitleLength') - this.get('titleLength');
}.property('minimumTitleLength', 'titleLength'),
/**
Minimum number of characters for a title to be valid.
@property minimumTitleLength
**/
minimumTitleLength: function() {
if (this.get('privateMessage')) {
return this.siteSettings.min_private_message_title_length;
} else {
return this.siteSettings.min_topic_title_length;
}
}.property('privateMessage'),
missingReplyCharacters: function() {
const postType = this.get('post.post_type');
if (postType === this.site.get('post_types.small_action')) { return 0; }
return this.get('minimumPostLength') - this.get('replyLength');
}.property('minimumPostLength', 'replyLength'),
/**
Minimum number of characters for a post body to be valid.
@property minimumPostLength
**/
minimumPostLength: function() {
if( this.get('privateMessage') ) {
return this.siteSettings.min_private_message_post_length;
} else if (this.get('topicFirstPost')) {
// first post (topic body)
return this.siteSettings.min_first_post_length;
} else {
return this.siteSettings.min_post_length;
}
}.property('privateMessage', 'topicFirstPost'),
/**
Computes the length of the title minus non-significant whitespaces
@property titleLength
**/
titleLength: function() {
const title = this.get('title') || "";
return title.replace(/\s+/img, " ").trim().length;
}.property('title'),
/**
Computes the length of the reply minus the quote(s) and non-significant whitespaces
@property replyLength
**/
replyLength: function() {
let reply = this.get('reply') || "";
while (Quote.REGEXP.test(reply)) { reply = reply.replace(Quote.REGEXP, ""); }
return reply.replace(/\s+/img, " ").trim().length;
}.property('reply'),
_setupComposer: function() {
this.set('archetypeId', this.site.get('default_archetype'));
}.on('init'),
/**
Append text to the current reply
@method appendText
@param {String} text the text to append
**/
appendText(text,position,opts) {
const reply = (this.get('reply') || '');
position = typeof(position) === "number" ? position : reply.length;
let before = reply.slice(0, position) || '';
let after = reply.slice(position) || '';
let stripped, i;
if (opts && opts.block){
if (before.trim() !== ""){
stripped = before.replace(/\r/g, "");
for(i=0; i<2; i++){
if(stripped[stripped.length - 1 - i] !== "\n"){
before += "\n";
position++;
}
}
}
if(after.trim() !== ""){
stripped = after.replace(/\r/g, "");
for(i=0; i<2; i++){
if(stripped[i] !== "\n"){
after = "\n" + after;
}
}
}
}
if(opts && opts.space){
if(before.length > 0 && !before[before.length-1].match(/\s/)){
before = before + " ";
}
if(after.length > 0 && !after[0].match(/\s/)){
after = " " + after;
}
}
this.set('reply', before + text + after);
return before.length + text.length;
},
prependText(text, opts) {
const reply = (this.get('reply') || '');
if (opts && opts.new_line && reply.length > 0) {
text = text.trim() + "\n\n";
}
this.set('reply', text + reply);
},
applyTopicTemplate(oldCategoryId, categoryId) {
if (this.get('action') !== CREATE_TOPIC) { return; }
let reply = this.get('reply');
// If the user didn't change the template, clear it
if (oldCategoryId) {
const oldCat = this.site.categories.findProperty('id', oldCategoryId);
if (oldCat && (oldCat.get('topic_template') === reply)) {
reply = "";
}
}
if (!Ember.isEmpty(reply)) { return; }
const category = this.site.categories.findProperty('id', categoryId);
if (category) {
this.set('reply', category.get('topic_template') || "");
}
},
/*
Open a composer
opts:
action - The action we're performing: edit, reply or createTopic
post - The post we're replying to, if present
topic - The topic we're replying to, if present
quote - If we're opening a reply from a quote, the quote we're making
*/
open(opts) {
if (!opts) opts = {};
this.set('loading', false);
const replyBlank = Em.isEmpty(this.get("reply"));
const composer = this;
if (!replyBlank &&
((opts.reply || opts.action === EDIT) && this.get('replyDirty'))) {
return;
}
if (opts.action === REPLY && this.get('action') === EDIT) this.set('reply', '');
if (!opts.draftKey) throw 'draft key is required';
if (opts.draftSequence === null) throw 'draft sequence is required';
this.setProperties({
draftKey: opts.draftKey,
draftSequence: opts.draftSequence,
composeState: opts.composerState || OPEN,
action: opts.action,
topic: opts.topic,
targetUsernames: opts.usernames,
composerTotalOpened: opts.composerTime,
typingTime: opts.typingTime
});
if (opts.post) {
this.set('post', opts.post);
this.set('whisper', opts.post.get('post_type') === this.site.get('post_types.whisper'));
if (!this.get('topic')) {
this.set('topic', opts.post.get('topic'));
}
} else {
this.set('post', null);
}
this.setProperties({
archetypeId: opts.archetypeId || this.site.get('default_archetype'),
metaData: opts.metaData ? Em.Object.create(opts.metaData) : null,
reply: opts.reply || this.get("reply") || ""
});
// We set the category id separately for topic templates on opening of composer
this.set('categoryId', opts.categoryId || this.get('topic.category.id'));
if (!this.get('categoryId') && this.get('creatingTopic')) {
const categories = Discourse.Category.list();
if (categories.length === 1) {
this.set('categoryId', categories[0].get('id'));
}
}
if (opts.postId) {
this.set('loading', true);
this.store.find('post', opts.postId).then(function(post) {
composer.set('post', post);
composer.set('loading', false);
});
}
// If we are editing a post, load it.
if (opts.action === EDIT && opts.post) {
const topicProps = this.serialize(_edit_topic_serializer);
topicProps.loading = true;
this.setProperties(topicProps);
this.store.find('post', opts.post.get('id')).then(function(post) {
composer.setProperties({
reply: post.get('raw'),
originalText: post.get('raw'),
loading: false
});
});
} else if (opts.action === REPLY && opts.quote) {
this.setProperties({
reply: opts.quote,
originalText: opts.quote
});
}
if (opts.title) { this.set('title', opts.title); }
this.set('originalText', opts.draft ? '' : this.get('reply'));
return false;
},
save(opts) {
if (!this.get('cantSubmitPost')) {
return this.get('editingPost') ? this.editPost(opts) : this.createPost(opts);
}
},
/**
Clear any state we have in preparation for a new composition.
@method clearState
**/
clearState() {
this.setProperties({
originalText: null,
reply: null,
post: null,
title: null,
editReason: null,
stagedPost: false,
typingTime: 0,
composerOpened: null,
composerTotalOpened: 0
});
},
// When you edit a post
editPost(opts) {
const post = this.get('post'),
oldCooked = post.get('cooked'),
self = this;
let promise;
// Update the title if we've changed it, otherwise consider it a
// successful resolved promise
if (this.get('title') &&
post.get('post_number') === 1 &&
this.get('topic.details.can_edit')) {
const topicProps = this.getProperties(Object.keys(_edit_topic_serializer));
promise = Topic.update(this.get('topic'), topicProps);
} else {
promise = Ember.RSVP.resolve();
}
const props = {
raw: this.get('reply'),
edit_reason: opts.editReason,
image_sizes: opts.imageSizes,
cooked: this.getCookedHtml()
};
this.set('composeState', CLOSED);
var rollback = throwAjaxError(function(){
post.set('cooked', oldCooked);
self.set('composeState', OPEN);
});
return promise.then(function() {
return post.save(props).then(function(result) {
self.clearState();
return result;
}).catch(function(error) {
throw error;
});
}).catch(rollback);
},
serialize(serializer, dest) {
dest = dest || {};
Object.keys(serializer).forEach(f => {
const val = this.get(serializer[f]);
if (typeof val !== 'undefined') {
Ember.set(dest, f, val);
}
});
return dest;
},
// Create a new Post
createPost(opts) {
const post = this.get('post'),
topic = this.get('topic'),
user = this.user,
postStream = this.get('topic.postStream');
let addedToStream = false;
const postTypes = this.site.get('post_types');
const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular;
// Build the post object
const createdPost = this.store.createRecord('post', {
imageSizes: opts.imageSizes,
cooked: this.getCookedHtml(),
reply_count: 0,
name: user.get('name'),
display_username: user.get('name'),
username: user.get('username'),
user_id: user.get('id'),
user_title: user.get('title'),
avatar_template: user.get('avatar_template'),
user_custom_fields: user.get('custom_fields'),
post_type: postType,
actions_summary: [],
moderator: user.get('moderator'),
admin: user.get('admin'),
yours: true,
read: true,
wiki: false,
typingTime: this.get('typingTime'),
composerTime: this.get('composerTime')
});
this.serialize(_create_serializer, createdPost);
if (post) {
createdPost.setProperties({
reply_to_post_number: post.get('post_number'),
reply_to_user: {
username: post.get('username'),
avatar_template: post.get('avatar_template')
}
});
}
let state = null;
// If we're in a topic, we can append the post instantly.
if (postStream) {
// If it's in reply to another post, increase the reply count
if (post) {
post.set('reply_count', (post.get('reply_count') || 0) + 1);
post.set('replies', []);
}
// We do not stage posts in mobile view, we do not have the "cooked"
// Furthermore calculating cooked is very complicated, especially since
// we would need to handle oneboxes and other bits that are not even in the
// engine, staging will just cause a blank post to render
if (!_.isEmpty(createdPost.get('cooked'))) {
state = postStream.stagePost(createdPost, user);
if (state === "alreadyStaging") { return; }
}
}
const composer = this;
composer.set('composeState', SAVING);
composer.set("stagedPost", state === "staged" && createdPost);
return createdPost.save().then(function(result) {
let saving = true;
if (result.responseJson.action === "enqueued") {
if (postStream) { postStream.undoPost(createdPost); }
return result;
}
if (topic) {
// It's no longer a new post
topic.set('draft_sequence', result.target.draft_sequence);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
// We created a new topic, let's show it.
composer.set('composeState', CLOSED);
saving = false;
// Update topic_count for the category
const category = composer.site.get('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); });
if (category) category.incrementProperty('topic_count');
Discourse.notifyPropertyChange('globalNotice');
}
composer.clearState();
composer.set('createdPost', createdPost);
if (addedToStream) {
composer.set('composeState', CLOSED);
} else if (saving) {
composer.set('composeState', SAVING);
}
return result;
}).catch(throwAjaxError(function() {
if (postStream) {
postStream.undoPost(createdPost);
}
Ember.run.next(() => composer.set('composeState', OPEN));
}));
},
getCookedHtml() {
return $('#reply-control .d-editor-preview').html().replace(/<span class="marker"><\/span>/g, '');
},
saveDraft() {
// Do not save when drafts are disabled
if (this.get('disableDrafts')) return;
// Do not save when there is no reply
if (!this.get('reply')) return;
// Do not save when the reply's length is too small
if (this.get('replyLength') < this.siteSettings.min_post_length) return;
const data = {
reply: this.get('reply'),
action: this.get('action'),
title: this.get('title'),
categoryId: this.get('categoryId'),
postId: this.get('post.id'),
archetypeId: this.get('archetypeId'),
metaData: this.get('metaData'),
usernames: this.get('targetUsernames'),
composerTime: this.get('composerTime'),
typingTime: this.get('typingTime')
};
this.set('draftStatus', I18n.t('composer.saving_draft_tip'));
const composer = this;
if (this._clearingStatus) {
Em.run.cancel(this._clearingStatus);
this._clearingStatus = null;
}
// try to save the draft
return Draft.save(this.get('draftKey'), this.get('draftSequence'), data)
.then(function() {
composer.set('draftStatus', I18n.t('composer.saved_draft_tip'));
}).catch(function() {
composer.set('draftStatus', I18n.t('composer.drafts_offline'));
});
},
dataChanged: function(){
const draftStatus = this.get('draftStatus');
const self = this;
if (draftStatus && !this._clearingStatus) {
this._clearingStatus = Em.run.later(this, function(){
self.set('draftStatus', null);
self._clearingStatus = null;
}, 1000);
}
}.observes('title','reply')
});
Composer.reopenClass({
// TODO: Replace with injection
create(args) {
args = args || {};
args.user = args.user || Discourse.User.current();
args.site = args.site || Discourse.Site.current();
args.siteSettings = args.siteSettings || Discourse.SiteSettings;
return this._super(args);
},
serializeToTopic(fieldName, property) {
if (!property) { property = fieldName; }
_edit_topic_serializer[fieldName] = property;
},
serializeOnCreate(fieldName, property) {
if (!property) { property = fieldName; }
_create_serializer[fieldName] = property;
},
serializedFieldsForCreate() {
return Object.keys(_create_serializer);
},
// The status the compose view can have
CLOSED,
SAVING,
OPEN,
DRAFT,
// The actions the composer can take
CREATE_TOPIC,
PRIVATE_MESSAGE,
REPLY,
EDIT,
// Draft key
REPLY_AS_NEW_TOPIC_KEY
});
export default Composer;