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"; import { escapeExpression, tinyAvatar } from "discourse/lib/utilities"; // The actions the composer can take export const CREATE_TOPIC = "createTopic", CREATE_SHARED_DRAFT = "createSharedDraft", EDIT_SHARED_DRAFT = "editSharedDraft", PRIVATE_MESSAGE = "privateMessage", NEW_PRIVATE_MESSAGE_KEY = "new_private_message", REPLY = "reply", EDIT = "edit", REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic", REPLY_AS_NEW_PRIVATE_MESSAGE_KEY = "reply_as_new_private_message"; function isEdit(action) { return action === EDIT || action === EDIT_SHARED_DRAFT; } const CLOSED = "closed", SAVING = "saving", OPEN = "open", DRAFT = "draft", // When creating, these fields are moved into the post model from the composer model _create_serializer = { raw: "reply", title: "title", unlist_topic: "unlistTopic", 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", featured_link: "featuredLink", shared_draft: "sharedDraft" }, _edit_topic_serializer = { title: "topic.title", categoryId: "topic.category.id", tags: "topic.tags", featuredLink: "topic.featured_link" }; const SAVE_LABELS = { [EDIT]: "composer.save_edit", [REPLY]: "composer.reply", [CREATE_TOPIC]: "composer.create_topic", [PRIVATE_MESSAGE]: "composer.create_pm", [CREATE_SHARED_DRAFT]: "composer.create_shared_draft", [EDIT_SHARED_DRAFT]: "composer.save_edit" }; const SAVE_ICONS = { [EDIT]: "pencil", [EDIT_SHARED_DRAFT]: "clipboard", [REPLY]: "reply", [CREATE_TOPIC]: "plus", [PRIVATE_MESSAGE]: "envelope", [CREATE_SHARED_DRAFT]: "clipboard" }; const Composer = RestModel.extend({ _categoryId: null, unlistTopic: false, archetypes: function() { return this.site.get("archetypes"); }.property(), @computed("action") sharedDraft: action => action === CREATE_SHARED_DRAFT, @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; } }, @computed("categoryId") category(categoryId) { return categoryId ? this.site.categories.findBy("id", categoryId) : null; }, @computed("category") minimumRequiredTags(category) { return category && category.get("minimum_required_tags") > 0 ? category.get("minimum_required_tags") : null; }, creatingTopic: Em.computed.equal("action", CREATE_TOPIC), creatingSharedDraft: Em.computed.equal("action", CREATE_SHARED_DRAFT), creatingPrivateMessage: Em.computed.equal("action", PRIVATE_MESSAGE), notCreatingPrivateMessage: Em.computed.not("creatingPrivateMessage"), @computed("privateMessage", "archetype.hasOptions") showCategoryChooser(isPrivateMessage, hasOptions) { const manyCategories = this.site.get("categories").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"), @computed("action") editingPost: isEdit, 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").findBy("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", "creatingSharedDraft" ), canCategorize: Em.computed.and("canEditTitle", "notCreatingPrivateMessage"), @computed("canEditTitle", "creatingPrivateMessage", "categoryId") canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { if ( !this.siteSettings.topic_featured_link_enabled || !canEditTitle || creatingPrivateMessage ) { return false; } const categoryIds = this.site.get( "topic_featured_link_allowed_category_ids" ); if ( !categoryId && categoryIds && (categoryIds.indexOf(this.site.get("uncategorized_category_id")) !== -1 || !this.siteSettings.allow_uncategorized_topics) ) { return true; } return ( categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1 ); }, @computed("canEditTopicFeaturedLink") titlePlaceholder() { return this.get("canEditTopicFeaturedLink") ? "composer.title_or_link_placeholder" : "composer.title_placeholder"; }, @computed("action", "post", "topic", "topic.title") replyOptions(action, post, topic, topicTitle) { let options = { userLink: null, topicLink: null, postLink: null, userAvatar: null, originalUser: null }; if (topic) { options.topicLink = { href: topic.get("url"), anchor: topic.get("fancy_title") || escapeExpression(topicTitle) }; } if (post) { options.label = I18n.t(`post.${action}`); options.userAvatar = tinyAvatar(post.get("avatar_template")); if (!this.site.mobileView) { const originalUserName = post.get("reply_to_user.username"); const originalUserAvatar = post.get("reply_to_user.avatar_template"); if (originalUserName && originalUserAvatar && isEdit(action)) { options.originalUser = { username: originalUserName, avatar: tinyAvatar(originalUserAvatar) }; } } } if (topic && post) { const postNumber = post.get("post_number"); options.postLink = { href: `${topic.get("url")}/${postNumber}`, anchor: I18n.t("post.post_number", { number: postNumber }) }; options.userLink = { href: `${topic.get("url")}/${postNumber}`, anchor: post.get("username") }; } return options; }, @computed isStaffUser() { const currentUser = Discourse.User.current(); return currentUser && currentUser.get("staff"); }, @computed( "loading", "canEditTitle", "titleLength", "targetUsernames", "replyLength", "categoryId", "missingReplyCharacters", "tags", "topicFirstPost", "minimumRequiredTags", "isStaffUser" ) cantSubmitPost( loading, canEditTitle, titleLength, targetUsernames, replyLength, categoryId, missingReplyCharacters, tags, topicFirstPost, minimumRequiredTags, isStaffUser ) { // can't submit while loading if (loading) return true; // title is required when // - creating a new topic/private message // - editing the 1st post if (canEditTitle && !this.get("titleLengthValid")) return true; // reply is always required if (missingReplyCharacters > 0) return true; if ( this.site.get("can_tag_topics") && !isStaffUser && topicFirstPost && minimumRequiredTags ) { const tagsArray = tags || []; if (tagsArray.length < minimumRequiredTags) { return true; } } if (this.get("privateMessage")) { // need at least one user when sending a PM return ( targetUsernames && (targetUsernames.trim() + ",").indexOf(",") === 0 ); } else { // has a category? (when needed) return this.get("requiredCategoryMissing"); } }, @computed("canCategorize", "categoryId") requiredCategoryMissing(canCategorize, categoryId) { return ( canCategorize && !categoryId && !this.siteSettings.allow_uncategorized_topics && !this.user.get("admin") ); }, 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"), @computed("action") saveIcon(action) { return SAVE_ICONS[action]; }, @computed("action", "whisper") saveLabel(action, whisper) { return whisper ? "composer.create_whisper" : SAVE_LABELS[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"), /** Did the user make changes to the topic title? @property titleDirty **/ @computed("title", "originalTitle") titleDirty(title, originalTitle) { return title !== originalTitle; }, /** 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 **/ @computed("privateMessage") minimumTitleLength(privateMessage) { if (privateMessage) { return this.siteSettings.min_personal_message_title_length; } else { return this.siteSettings.min_topic_title_length; } }, @computed("minimumPostLength", "replyLength", "canEditTopicFeaturedLink") missingReplyCharacters( minimumPostLength, replyLength, canEditTopicFeaturedLink ) { if ( this.get("post.post_type") === this.site.get("post_types.small_action") || (canEditTopicFeaturedLink && this.get("featuredLink")) ) { return 0; } return minimumPostLength - replyLength; }, /** Minimum number of characters for a post body to be valid. @property minimumPostLength **/ @computed("privateMessage", "topicFirstPost", "topic.pm_with_non_human_user") minimumPostLength(privateMessage, topicFirstPost, pmWithNonHumanUser) { if (pmWithNonHumanUser) { return 1; } else if (privateMessage) { return this.siteSettings.min_personal_message_post_length; } else if (topicFirstPost) { // first post (topic body) return this.siteSettings.min_first_post_length; } else { return this.siteSettings.min_post_length; } }, /** Computes the length of the title minus non-significant whitespaces @property titleLength **/ titleLength: function() { const title = this.get("title") || ""; return title.replace(/\s+/gim, " ").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+/gim, " ").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.findBy("id", oldCategoryId); if (oldCat && oldCat.get("topic_template") === reply) { reply = ""; } } if (!Ember.isEmpty(reply)) { return; } const category = this.site.categories.findBy("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 || isEdit(opts.action)) && this.get("replyDirty")) ) { return; } if (opts.action === REPLY && isEdit(this.get("action"))) this.set("reply", ""); if (!opts.draftKey) throw new Error("draft key is required"); if (opts.draftSequence === null) throw new Error("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, whisper: opts.whisper, tags: opts.tags }); 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 = this.site.get("categories"); 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 (isEdit(opts.action) && opts.post) { const topicProps = this.serialize(_edit_topic_serializer); topicProps.loading = true; // When editing a shared draft, use its category if (opts.action === EDIT_SHARED_DRAFT && opts.destinationCategoryId) { topicProps.categoryId = opts.destinationCategoryId; } 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")); if (this.get("editingFirstPost")) { this.set("originalTitle", this.get("title")); } return false; }, save(opts) { if (!this.get("cantSubmitPost")) { // change category may result in some effect for topic featured link if (!this.get("canEditTopicFeaturedLink")) { this.set("featuredLink", null); } 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, unlistTopic: false, editReason: null, stagedPost: false, typingTime: 0, composerOpened: null, composerTotalOpened: 0, featuredLink: null }); }, // When you edit a post editPost(opts) { let post = this.get("post"); let oldCooked = post.get("cooked"); let promise = Ember.RSVP.resolve(); // Update the topic if we're editing the first post if ( this.get("title") && post.get("post_number") === 1 && this.get("topic.details.can_edit") ) { const topicProps = this.getProperties( Object.keys(_edit_topic_serializer) ); let topic = this.get("topic"); // If we're editing a shared draft, keep the original category if (this.get("action") === EDIT_SHARED_DRAFT) { let destinationCategoryId = topicProps.categoryId; promise = promise.then(() => topic.updateDestinationCategory(destinationCategoryId) ); topicProps.categoryId = topic.get("category.id"); } promise = promise.then(() => Topic.update(topic, topicProps)); } const props = { raw: this.get("reply"), edit_reason: opts.editReason, image_sizes: opts.imageSizes, cooked: this.getCookedHtml() }; this.set("composeState", SAVING); let rollback = throwAjaxError(() => { post.set("cooked", oldCooked); this.set("composeState", OPEN); }); return promise .then(() => { // rest model only sets props after it is saved post.set("cooked", props.cooked); return post.save(props).then(result => { this.clearState(); return result; }); }) .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; } // We sometimes want to hide the `reply_to_user` if the post contains a quote if ( result.responseJson && result.responseJson.post && !result.responseJson.post.reply_to_user ) { createdPost.set("reply_to_user", null); } 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); if (post) { post.set("reply_count", post.get("reply_count") - 1); } } Ember.run.next(() => composer.set("composeState", OPEN)); }) ); }, getCookedHtml() { return $("#reply-control .d-editor-preview") .html() .replace(/<\/span>/g, ""); }, saveDraft() { // Do not save when drafts are disabled if (this.get("disableDrafts")) return; if (this.get("canEditTitle")) { // Save title and/or post body if (!this.get("title") && !this.get("reply")) return; if ( this.get("title") && this.get("titleLengthValid") && this.get("reply") && this.get("replyLength") < this.siteSettings.min_post_length ) return; } else { // 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"), whisper: this.get("whisper"), metaData: this.get("metaData"), usernames: this.get("targetUsernames"), composerTime: this.get("composerTime"), typingTime: this.get("typingTime"), tags: this.get("tags") }; 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, CREATE_SHARED_DRAFT, EDIT_SHARED_DRAFT, PRIVATE_MESSAGE, REPLY, EDIT, // Draft key NEW_PRIVATE_MESSAGE_KEY, REPLY_AS_NEW_TOPIC_KEY, REPLY_AS_NEW_PRIVATE_MESSAGE_KEY }); export default Composer;