import { isEmpty } from "@ember/utils"; import { reads, equal, not, or, and } from "@ember/object/computed"; import EmberObject from "@ember/object"; import { next } from "@ember/runloop"; import { cancel } from "@ember/runloop"; import { later } from "@ember/runloop"; 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 discourseComputed, { observes, on } from "discourse-common/utils/decorators"; import { escapeExpression, tinyAvatar, emailValid } from "discourse/lib/utilities"; import { propertyNotEqual } from "discourse/lib/computed"; import { throttle } from "@ember/runloop"; import { Promise } from "rsvp"; import { set } from "@ember/object"; import Site from "discourse/models/site"; import User from "discourse/models/user"; import deprecated from "discourse-common/lib/deprecated"; // The actions the composer can take export const CREATE_TOPIC = "createTopic", CREATE_SHARED_DRAFT = "createSharedDraft", EDIT_SHARED_DRAFT = "editSharedDraft", PRIVATE_MESSAGE = "privateMessage", REPLY = "reply", EDIT = "edit", NEW_PRIVATE_MESSAGE_KEY = "new_private_message", NEW_TOPIC_KEY = "new_topic"; function isEdit(action) { return action === EDIT || action === EDIT_SHARED_DRAFT; } const CLOSED = "closed", SAVING = "saving", OPEN = "open", DRAFT = "draft", FULLSCREEN = "fullscreen", // 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_recipients: "targetRecipients", typing_duration_msecs: "typingTime", composer_open_duration_msecs: "composerTime", tags: "tags", featured_link: "featuredLink", shared_draft: "sharedDraft", no_bump: "noBump", draft_key: "draftKey" }, _edit_topic_serializer = { title: "topic.title", categoryId: "topic.category.id", tags: "topic.tags", featuredLink: "topic.featured_link" }, _draft_serializer = { reply: "reply", action: "action", title: "title", categoryId: "categoryId", archetypeId: "archetypeId", whisper: "whisper", metaData: "metaData", composerTime: "composerTime", typingTime: "typingTime", postId: "post.id", // TODO remove together with 'targetUsername' deprecations usernames: "targetUsernames", recipients: "targetRecipients" }, _add_draft_fields = {}, FAST_REPLY_LENGTH_THRESHOLD = 10000; export 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" }; export const SAVE_ICONS = { [EDIT]: "pencil-alt", [EDIT_SHARED_DRAFT]: "far-clipboard", [REPLY]: "reply", [CREATE_TOPIC]: "plus", [PRIVATE_MESSAGE]: "envelope", [CREATE_SHARED_DRAFT]: "far-clipboard" }; const Composer = RestModel.extend({ _categoryId: null, unlistTopic: false, noBump: false, draftSaving: false, draftSaved: false, archetypes: reads("site.archetypes"), sharedDraft: equal("action", CREATE_SHARED_DRAFT), @discourseComputed 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 (isEmpty(categoryId)) { categoryId = null; } this._categoryId = categoryId; if (oldCategoryId !== categoryId) { this.applyTopicTemplate(oldCategoryId, categoryId); } return categoryId; } }, @discourseComputed("categoryId") category(categoryId) { return categoryId ? this.site.categories.findBy("id", categoryId) : null; }, @discourseComputed("category") minimumRequiredTags(category) { return category && category.minimum_required_tags > 0 ? category.minimum_required_tags : null; }, creatingTopic: equal("action", CREATE_TOPIC), creatingSharedDraft: equal("action", CREATE_SHARED_DRAFT), creatingPrivateMessage: equal("action", PRIVATE_MESSAGE), notCreatingPrivateMessage: not("creatingPrivateMessage"), notPrivateMessage: not("privateMessage"), @discourseComputed("editingPost", "topic.details.can_edit") disableTitleInput(editingPost, canEditTopic) { return editingPost && !canEditTopic; }, @discourseComputed("privateMessage", "archetype.hasOptions") showCategoryChooser(isPrivateMessage, hasOptions) { const manyCategories = this.site.categories.length > 1; return !isPrivateMessage && (hasOptions || manyCategories); }, @discourseComputed("creatingPrivateMessage", "topic") privateMessage(creatingPrivateMessage, topic) { return ( creatingPrivateMessage || (topic && topic.archetype === "private_message") ); }, topicFirstPost: or("creatingTopic", "editingFirstPost"), @discourseComputed("action") editingPost: isEdit, replyingToTopic: equal("action", REPLY), viewOpen: equal("composeState", OPEN), viewDraft: equal("composeState", DRAFT), viewFullscreen: equal("composeState", FULLSCREEN), viewOpenOrFullscreen: or("viewOpen", "viewFullscreen"), @observes("composeState") composeStateChanged() { const oldOpen = this.composerOpened; const elem = document.querySelector("html"); if (this.composeState === FULLSCREEN) { elem.classList.add("fullscreen-composer"); } else { elem.classList.remove("fullscreen-composer"); } if (this.composeState === OPEN) { this.set("composerOpened", oldOpen || new Date()); } else { if (oldOpen) { const oldTotal = this.composerTotalOpened || 0; this.set("composerTotalOpened", oldTotal + (new Date() - oldOpen)); } this.set("composerOpened", null); } }, @discourseComputed composerTime: { get() { let total = this.composerTotalOpened || 0; const oldOpen = this.composerOpened; if (oldOpen) { total += new Date() - oldOpen; } return total; } }, @discourseComputed("archetypeId") archetype(archetypeId) { return this.archetypes.findBy("id", archetypeId); }, @observes("archetype") archetypeChanged() { return this.set("metaData", EmberObject.create()); }, // called whenever the user types to update the typing time typing() { throttle( this, function() { const typingTime = this.typingTime || 0; this.set("typingTime", typingTime + 100); }, 100, false ); }, editingFirstPost: and("editingPost", "post.firstPost"), canEditTitle: or( "creatingTopic", "creatingPrivateMessage", "editingFirstPost", "creatingSharedDraft" ), canCategorize: and( "canEditTitle", "notCreatingPrivateMessage", "notPrivateMessage" ), @discourseComputed("canEditTitle", "creatingPrivateMessage", "categoryId") canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { if ( !this.siteSettings.topic_featured_link_enabled || !canEditTitle || creatingPrivateMessage ) { return false; } const categoryIds = this.site.topic_featured_link_allowed_category_ids; if ( !categoryId && categoryIds && (categoryIds.indexOf(this.site.uncategorized_category_id) !== -1 || !this.siteSettings.allow_uncategorized_topics) ) { return true; } return ( categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1 ); }, @discourseComputed("canEditTopicFeaturedLink") titlePlaceholder(canEditTopicFeaturedLink) { return canEditTopicFeaturedLink ? "composer.title_or_link_placeholder" : "composer.title_placeholder"; }, @discourseComputed("action", "post", "topic", "topic.title") replyOptions(action, post, topic, topicTitle) { const options = { userLink: null, topicLink: null, postLink: null, userAvatar: null, originalUser: null }; if (topic) { options.topicLink = { href: topic.url, anchor: topic.fancy_title || escapeExpression(topicTitle) }; } if (post) { options.label = I18n.t(`post.${action}`); options.userAvatar = tinyAvatar(post.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.post_number; options.postLink = { href: `${topic.url}/${postNumber}`, anchor: I18n.t("post.post_number", { number: postNumber }) }; options.userLink = { href: `${topic.url}/${postNumber}`, anchor: post.username }; } return options; }, @discourseComputed("targetRecipients") targetUsernames(targetRecipients) { deprecated( "`targetUsernames` is deprecated, use `targetRecipients` instead." ); return targetRecipients; }, @discourseComputed("targetRecipients") targetRecipientsArray(targetRecipients) { const recipients = targetRecipients ? targetRecipients.split(",") : []; const groups = new Set(this.site.groups.map(g => g.name)); return recipients.map(item => { if (groups.has(item)) { return { type: "group", name: item }; } else if (emailValid(item)) { return { type: "email", name: item }; } else { return { type: "user", name: item }; } }); }, @discourseComputed( "loading", "canEditTitle", "titleLength", "targetRecipients", "targetRecipientsArray", "replyLength", "categoryId", "missingReplyCharacters", "tags", "topicFirstPost", "minimumRequiredTags", "isStaffUser" ) cantSubmitPost( loading, canEditTitle, titleLength, targetRecipients, targetRecipientsArray, 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.titleLengthValid) return true; // reply is always required if (missingReplyCharacters > 0) return true; if ( this.site.can_tag_topics && !isStaffUser && topicFirstPost && minimumRequiredTags ) { const tagsArray = tags || []; if (tagsArray.length < minimumRequiredTags) { return true; } } if (topicFirstPost) { // user should modify topic template const category = this.category; if (category && category.topic_template) { if (this.reply.trim() === category.topic_template.trim()) { bootbox.alert(I18n.t("composer.error.topic_template_not_modified")); return true; } } } if (this.privateMessage) { // need at least one user when sending a PM return targetRecipients && targetRecipientsArray.length === 0; } else { // has a category? (when needed) return this.requiredCategoryMissing; } }, @discourseComputed("canCategorize", "categoryId") requiredCategoryMissing(canCategorize, categoryId) { return ( canCategorize && !categoryId && !this.siteSettings.allow_uncategorized_topics ); }, @discourseComputed("minimumTitleLength", "titleLength", "post.static_doc") titleLengthValid(minTitleLength, titleLength, staticDoc) { if (this.user.admin && staticDoc && titleLength > 0) return true; if (titleLength < minTitleLength) return false; return titleLength <= this.siteSettings.max_topic_title_length; }, @discourseComputed("metaData") hasMetaData(metaData) { return metaData ? isEmpty(Ember.keys(metaData)) : false; }, replyDirty: propertyNotEqual("reply", "originalText"), titleDirty: propertyNotEqual("title", "originalTitle"), @discourseComputed("minimumTitleLength", "titleLength") missingTitleCharacters(minimumTitleLength, titleLength) { return minimumTitleLength - titleLength; }, @discourseComputed("privateMessage") minimumTitleLength(privateMessage) { if (privateMessage) { return this.siteSettings.min_personal_message_title_length; } else { return this.siteSettings.min_topic_title_length; } }, @discourseComputed( "minimumPostLength", "replyLength", "canEditTopicFeaturedLink" ) missingReplyCharacters( minimumPostLength, replyLength, canEditTopicFeaturedLink ) { if ( this.get("post.post_type") === this.site.get("post_types.small_action") || (canEditTopicFeaturedLink && this.featuredLink) ) { return 0; } return minimumPostLength - replyLength; }, @discourseComputed( "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; } }, @discourseComputed("title") titleLength(title) { title = title || ""; return title.replace(/\s+/gim, " ").trim().length; }, @discourseComputed("reply") replyLength(reply) { reply = reply || ""; if (reply.length > FAST_REPLY_LENGTH_THRESHOLD) { return reply.length; } while (Quote.REGEXP.test(reply)) { // make it global so we can strip as many quotes at once // keep in mind nested quotes mean we still need a loop here const regex = new RegExp(Quote.REGEXP.source, "img"); reply = reply.replace(regex, ""); } // This is in place so we do not generate any intermediate // strings while calculating the length, this is issued // every keypress in the composer so it needs to be very fast let len = 0, skipSpace = true; for (let i = 0; i < reply.length; i++) { const code = reply.charCodeAt(i); let isSpace = false; if (code >= 0x2000 && code <= 0x200a) { isSpace = true; } else { switch (code) { case 0x09: // \t case 0x0a: // \n case 0x0b: // \v case 0x0c: // \f case 0x0d: // \r case 0x20: case 0xa0: case 0x1680: case 0x202f: case 0x205f: case 0x3000: isSpace = true; } } if (isSpace) { if (!skipSpace) { len++; skipSpace = true; } } else { len++; skipSpace = false; } } if (len > 0 && skipSpace) { len--; } return len; }, @on("init") _setupComposer() { this.set("archetypeId", this.site.default_archetype); }, appendText(text, position, opts) { const reply = this.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.reply || ""; if (opts && opts.new_line && reply.length > 0) { text = text.trim() + "\n\n"; } this.set("reply", text + reply); }, applyTopicTemplate(oldCategoryId, categoryId) { if (this.action !== CREATE_TOPIC) { return; } let reply = this.reply; // If the user didn't change the template, clear it if (oldCategoryId) { const oldCat = this.site.categories.findBy("id", oldCategoryId); if (oldCat && oldCat.topic_template === reply) { reply = ""; } } if (!isEmpty(reply)) { return; } const category = this.site.categories.findBy("id", categoryId); if (category) { this.set("reply", category.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 = isEmpty(this.reply); const composer = this; if (!replyBlank && (opts.reply || isEdit(opts.action)) && this.replyDirty) { return; } if (opts.action === REPLY && isEdit(this.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"); } if (opts.usernames) { deprecated("`usernames` is deprecated, use `recipients` instead."); } this.setProperties({ draftKey: opts.draftKey, draftSequence: opts.draftSequence, composeState: opts.composerState || OPEN, action: opts.action, topic: opts.topic, targetRecipients: opts.usernames || opts.recipients, composerTotalOpened: opts.composerTime, typingTime: opts.typingTime, whisper: opts.whisper, tags: opts.tags, noBump: opts.noBump }); if (opts.post) { this.setProperties({ post: opts.post, whisper: opts.post.post_type === this.site.post_types.whisper }); if (!this.topic) { this.set("topic", opts.post.topic); } } else { this.set("post", null); } this.setProperties({ archetypeId: opts.archetypeId || this.site.default_archetype, metaData: opts.metaData ? EmberObject.create(opts.metaData) : null, reply: opts.reply || this.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.categoryId && this.creatingTopic) { const categories = this.site.categories; if (categories.length === 1) { this.set("categoryId", categories[0].id); } } if (opts.postId) { this.set("loading", true); this.store .find("post", opts.postId) .then(post => composer.setProperties({ post, 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.id).then(post => { composer.setProperties({ reply: post.raw, originalText: post.raw, loading: false }); composer.appEvents.trigger("composer:reply-reloaded", composer); }); } 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.reply); if (this.editingFirstPost) { this.set("originalTitle", this.title); } if (!isEdit(opts.action) || !opts.post) { composer.appEvents.trigger("composer:reply-reloaded", composer); } // Ensure additional draft fields are set Object.keys(_add_draft_fields).forEach(f => { this.set(_add_draft_fields[f], opts[f]); }); return false; }, // Overwrite to implement custom logic beforeSave() { return Promise.resolve(); }, save(opts) { return this.beforeSave().then(() => { if (!this.cantSubmitPost) { // change category may result in some effect for topic featured link if (!this.canEditTopicFeaturedLink) { this.set("featuredLink", null); } return this.editingPost ? this.editPost(opts) : this.createPost(opts); } }); }, 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, noBump: false, editConflict: false }); }, editPost(opts) { const post = this.post; const oldCooked = post.cooked; let promise = Promise.resolve(); // Update the topic if we're editing the first post if (this.title && post.post_number === 1) { const topic = this.topic; if (topic.details.can_edit) { const topicProps = this.getProperties( Object.keys(_edit_topic_serializer) ); // frontend should have featuredLink but backend needs featured_link if (topicProps.featuredLink) { topicProps.featured_link = topicProps.featuredLink; delete topicProps.featuredLink; } // If we're editing a shared draft, keep the original category if (this.action === EDIT_SHARED_DRAFT) { const destinationCategoryId = topicProps.categoryId; promise = promise.then(() => topic.updateDestinationCategory(destinationCategoryId) ); topicProps.categoryId = topic.get("category.id"); } promise = promise.then(() => Topic.update(topic, topicProps)); } else if (topic.details.can_edit_tags) { promise = promise.then(() => topic.updateTags(this.tags)); } } const props = { topic_id: this.topic.id, raw: this.reply, raw_old: this.editConflict ? null : this.originalText, edit_reason: opts.editReason, image_sizes: opts.imageSizes, cooked: this.getCookedHtml() }; this.set("composeState", SAVING); const rollback = throwAjaxError(error => { post.set("cooked", oldCooked); this.set("composeState", OPEN); if (error.jqXHR && error.jqXHR.status === 409) { this.set("editConflict", true); } }); 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") { set(dest, f, val); } }); return dest; }, createPost(opts) { if (this.action === CREATE_TOPIC) { this.set("topic", null); } const post = this.post; const topic = this.topic; const user = this.user; const postStream = this.get("topic.postStream"); let addedToStream = false; const postTypes = this.site.post_types; const postType = this.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.name, display_username: user.name, username: user.username, user_id: user.id, user_title: user.title, avatar_template: user.avatar_template, user_custom_fields: user.custom_fields, post_type: postType, actions_summary: [], moderator: user.moderator, admin: user.admin, yours: true, read: true, wiki: false, typingTime: this.typingTime, composerTime: this.composerTime }); this.serialize(_create_serializer, createdPost); if (post) { createdPost.setProperties({ reply_to_post_number: post.post_number, reply_to_user: post.getProperties("username", "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.setProperties({ reply_count: (post.reply_count || 0) + 1, 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.cooked)) { state = postStream.stagePost(createdPost, user); if (state === "alreadyStaging") { return; } } } const composer = this; composer.setProperties({ composeState: SAVING, stagedPost: state === "staged" && createdPost }); return createdPost .save() .then(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.categories.find( x => x.id === (parseInt(createdPost.category, 10) || 1) ); if (category) category.incrementProperty("topic_count"); Discourse.notifyPropertyChange("globalNotice"); } composer.clearState(); composer.set("createdPost", createdPost); if (composer.replyingToTopic) { this.appEvents.trigger("post:created", createdPost); } else { this.appEvents.trigger("topic:created", createdPost, composer); } if (addedToStream) { composer.set("composeState", CLOSED); } else if (saving) { composer.set("composeState", SAVING); } return result; }) .catch( throwAjaxError(() => { if (postStream) { postStream.undoPost(createdPost); if (post) { post.set("reply_count", post.reply_count - 1); } } next(() => composer.set("composeState", OPEN)); }) ); }, getCookedHtml() { const editorPreviewNode = document.querySelector( "#reply-control .d-editor-preview" ); if (editorPreviewNode) { return editorPreviewNode.innerHTML.replace( /<\/span>/g, "" ); } return ""; }, saveDraft() { // Do not save when drafts are disabled if (this.disableDrafts) return; if (this.canEditTitle) { // Save title and/or post body if (!this.title && !this.reply) return; if ( this.title && this.titleLengthValid && this.reply && this.replyLength < this.siteSettings.min_post_length ) { return; } } else { // Do not save when there is no reply if (!this.reply) return; // Do not save when the reply's length is too small if (this.replyLength < this.siteSettings.min_post_length) return; } this.setProperties({ draftSaved: false, draftSaving: true, draftConflictUser: null }); if (this._clearingStatus) { cancel(this._clearingStatus); this._clearingStatus = null; } let data = this.serialize(_draft_serializer); if (data.postId && !isEmpty(this.originalText)) { data.originalText = this.originalText; } return Draft.save( this.draftKey, this.draftSequence, data, this.messageBus.clientId ) .then(result => { if (result.draft_sequence) { this.draftSequence = result.draft_sequence; } if (result.conflict_user) { this.setProperties({ draftSaving: false, draftStatus: I18n.t("composer.edit_conflict"), draftConflictUser: result.conflict_user }); } else { this.setProperties({ draftSaving: false, draftSaved: true, draftConflictUser: null }); } }) .catch(e => { let draftStatus; const xhr = e && e.jqXHR; if ( xhr && xhr.status === 409 && xhr.responseJSON && xhr.responseJSON.errors && xhr.responseJSON.errors.length ) { const json = e.jqXHR.responseJSON; draftStatus = json.errors[0]; if (json.extras && json.extras.description) { bootbox.alert(json.extras.description); } } this.setProperties({ draftSaving: false, draftStatus: draftStatus || I18n.t("composer.drafts_offline"), draftConflictUser: null }); }); }, @observes("title", "reply") dataChanged() { const draftStatus = this.draftStatus; if (draftStatus && !this._clearingStatus) { this._clearingStatus = later( this, () => { this.setProperties({ draftStatus: null, draftConflictUser: null }); this._clearingStatus = null; this.setProperties({ draftSaving: false, draftSaved: false }); }, Ember.Test ? 0 : 1000 ); } } }); Composer.reopenClass({ // TODO: Replace with injection create(args) { args = args || {}; args.user = args.user || User.current(); args.site = args.site || 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); }, serializeToDraft(fieldName, property) { if (!property) { property = fieldName; } _draft_serializer[fieldName] = property; _add_draft_fields[fieldName] = property; }, serializedFieldsForDraft() { return Object.keys(_draft_serializer); }, // The status the compose view can have CLOSED, SAVING, OPEN, DRAFT, FULLSCREEN, // The actions the composer can take CREATE_TOPIC, CREATE_SHARED_DRAFT, EDIT_SHARED_DRAFT, PRIVATE_MESSAGE, REPLY, EDIT, // Draft key NEW_PRIVATE_MESSAGE_KEY, NEW_TOPIC_KEY }); export default Composer;