import DiscourseURL from 'discourse/lib/url'; import Quote from 'discourse/lib/quote'; import Draft from 'discourse/models/draft'; import Composer from 'discourse/models/composer'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import { relativeAge } from 'discourse/lib/formatter'; import { escapeExpression } from 'discourse/lib/utilities'; import InputValidation from 'discourse/models/input-validation'; function loadDraft(store, opts) { opts = opts || {}; let draft = opts.draft; const draftKey = opts.draftKey; const draftSequence = opts.draftSequence; try { if (draft && typeof draft === 'string') { draft = JSON.parse(draft); } } catch (error) { draft = null; Draft.clear(draftKey, draftSequence); } if (draft && ((draft.title && draft.title !== '') || (draft.reply && draft.reply !== ''))) { const composer = store.createRecord('composer'); composer.open({ draftKey, draftSequence, action: draft.action, title: draft.title, categoryId: draft.categoryId || opts.categoryId, postId: draft.postId, archetypeId: draft.archetypeId, reply: draft.reply, metaData: draft.metaData, usernames: draft.usernames, draft: true, composerState: Composer.DRAFT, composerTime: draft.composerTime, typingTime: draft.typingTime }); return composer; } } const _popupMenuOptionsCallbacks = []; export function addPopupMenuOptionsCallback(callback) { _popupMenuOptionsCallbacks.push(callback); } export default Ember.Controller.extend({ topicController: Ember.inject.controller('topic'), application: Ember.inject.controller(), replyAsNewTopicDraft: Em.computed.equal('model.draftKey', Composer.REPLY_AS_NEW_TOPIC_KEY), checkedMessages: false, messageCount: null, showEditReason: false, editReason: null, scopedCategoryId: null, optionsVisible: false, lastValidatedAt: null, isUploading: false, topic: null, linkLookup: null, whisperOrUnlistTopic: Ember.computed.or('model.whisper', 'model.unlistTopic'), @computed('model.replyingToTopic', 'model.creatingPrivateMessage', 'model.targetUsernames') focusTarget(replyingToTopic, creatingPM, usernames) { if (this.capabilities.isIOS) { return "none"; } // Focus on usernames if it's blank or if it's just you usernames = usernames || ""; if (creatingPM && usernames.length === 0 || usernames === this.currentUser.get('username')) { return 'usernames'; } if (replyingToTopic) { return 'reply'; } return 'title'; }, showToolbar: Em.computed({ get(){ const keyValueStore = this.container.lookup('key-value-store:main'); const storedVal = keyValueStore.get("toolbar-enabled"); if (this._toolbarEnabled === undefined && storedVal === undefined) { // iPhone 6 is 375, anything narrower and toolbar should // be default disabled. // That said we should remember the state this._toolbarEnabled = $(window).width() > 370 && !this.capabilities.isAndroid; } return this._toolbarEnabled || storedVal === "true"; }, set(key, val){ const keyValueStore = this.container.lookup('key-value-store:main'); this._toolbarEnabled = val; keyValueStore.set({key: "toolbar-enabled", value: val ? "true" : "false"}); return val; } }), topicModel: Ember.computed.alias('topicController.model'), @computed('model.canEditTitle', 'model.creatingPrivateMessage') canEditTags(canEditTitle, creatingPrivateMessage) { return !this.site.mobileView && this.site.get('can_tag_topics') && canEditTitle && !creatingPrivateMessage; }, @computed('model.whisper', 'model.unlistTopic') whisperOrUnlistTopicText(whisper, unlistTopic) { if (whisper) { return I18n.t("composer.whisper"); } else if (unlistTopic) { return I18n.t("composer.unlist"); } }, @computed isStaffUser() { const currentUser = this.currentUser; return currentUser && currentUser.get('staff'); }, canUnlistTopic: Em.computed.and('model.creatingTopic', 'isStaffUser'), @computed('model.action', 'isStaffUser') canWhisper(action, isStaffUser) { return isStaffUser && this.siteSettings.enable_whispers && action === Composer.REPLY; }, @computed("popupMenuOptions") showPopupMenu(popupMenuOptions) { return popupMenuOptions ? popupMenuOptions.some(option => option.condition) : false; }, _setupPopupMenuOption(callback) { let option = callback(); if (option.condition) { option.condition = this.get(option.condition); } else { option.condition = true; } return option; }, @computed("model.composeState", "model.creatingTopic") popupMenuOptions(composeState) { if (composeState === 'open') { let options = []; options.push(this._setupPopupMenuOption(() => { return { action: 'toggleInvisible', icon: 'eye-slash', label: 'composer.toggle_unlisted', condition: "canUnlistTopic" }; })); options.push(this._setupPopupMenuOption(() => { return { action: 'toggleWhisper', icon: 'eye-slash', label: 'composer.toggle_whisper', condition: "canWhisper" }; })); return options.concat(_popupMenuOptionsCallbacks.map(callback => { return this._setupPopupMenuOption(callback); })); } }, showWarning: function() { if (!Discourse.User.currentProp('staff')) { return false; } var usernames = this.get('model.targetUsernames'); var hasTargetGroups = this.get('model.hasTargetGroups'); // We need exactly one user to issue a warning if (Ember.isEmpty(usernames) || usernames.split(',').length !== 1 || hasTargetGroups) { return false; } return this.get('model.creatingPrivateMessage'); }.property('model.creatingPrivateMessage', 'model.targetUsernames'), actions: { addLinkLookup(linkLookup) { this.set('linkLookup', linkLookup); }, afterRefresh($preview) { const topic = this.get('model.topic'); const linkLookup = this.get('linkLookup'); if (!topic || !linkLookup) { return; } // Don't check if there's only one post if (topic.get('posts_count') === 1) { return; } const post = this.get('model.post'); if (post && post.get('user_id') !== this.currentUser.id) { return; } const $links = $('a[href]', $preview); $links.each((idx, l) => { const href = $(l).prop('href'); if (href && href.length) { const [warn, info] = linkLookup.check(post, href); if (warn) { const body = I18n.t('composer.duplicate_link', { domain: info.domain, username: info.username, post_url: topic.urlForPostNumber(info.post_number), ago: relativeAge(moment(info.posted_at).toDate(), { format: 'medium' }) }); this.appEvents.trigger('composer-messages:create', { extraClass: 'custom-body', templateName: 'custom-body', body }); return false; } } return true; }); }, toggleWhisper() { this.toggleProperty('model.whisper'); }, toggleInvisible() { this.toggleProperty('model.unlistTopic'); }, toggleToolbar() { this.toggleProperty('showToolbar'); }, showOptions(toolbarEvent, loc) { this.set('toolbarEvent', toolbarEvent); this.appEvents.trigger('popup-menu:open', loc); this.set('optionsVisible', true); }, hideOptions() { this.set('optionsVisible', false); }, // Toggle the reply view toggle() { this.toggle(); }, togglePreview() { this.get('model').togglePreview(); }, // Import a quote from the post importQuote(toolbarEvent) { const postStream = this.get('topic.postStream'); let postId = this.get('model.post.id'); // If there is no current post, use the first post id from the stream if (!postId && postStream) { postId = postStream.get('stream.firstObject'); } // If we're editing a post, fetch the reply when importing a quote if (this.get('model.editingPost')) { const replyToPostNumber = this.get('model.post.reply_to_post_number'); if (replyToPostNumber) { const replyPost = postStream.get('posts').findBy('post_number', replyToPostNumber); if (replyPost) { postId = replyPost.get('id'); } } } if (postId) { this.set('model.loading', true); const composer = this; return this.store.find('post', postId).then(function(post) { const quote = Quote.build(post, post.get("raw"), {raw: true, full: true}); toolbarEvent.addText(quote); composer.set('model.loading', false); }); } }, cancel() { this.cancelComposer(); }, save() { this.save(); }, displayEditReason() { this.set("showEditReason", true); }, hitEsc() { if ((this.get('messageCount') || 0) > 0) { this.appEvents.trigger('composer-messages:close'); return; } if (this.get('model.viewOpen')) { this.shrink(); } }, openIfDraft() { if (this.get('model.viewDraft')) { this.set('model.composeState', Composer.OPEN); } }, groupsMentioned(groups) { if (!this.get('model.creatingPrivateMessage') && !this.get('model.topic.isPrivateMessage')) { groups.forEach(group => { const body = I18n.t('composer.group_mentioned', { group: "@" + group.name, count: group.user_count, group_link: Discourse.getURL(`/group/${group.name}/members`) }); this.appEvents.trigger('composer-messages:create', { extraClass: 'custom-body', templateName: 'custom-body', body }); }); } } }, categories: function() { return Discourse.Category.list(); }.property(), toggle() { this.closeAutocomplete(); switch (this.get('model.composeState')) { case Composer.OPEN: if (Ember.isEmpty(this.get('model.reply')) && Ember.isEmpty(this.get('model.title'))) { this.close(); } else { this.shrink(); } break; case Composer.DRAFT: this.set('model.composeState', Composer.OPEN); break; case Composer.SAVING: this.close(); } return false; }, disableSubmit: Ember.computed.or("model.loading", "isUploading"), save(force) { const composer = this.get('model'); const self = this; // Clear the warning state if we're not showing the checkbox anymore if (!this.get('showWarning')) { this.set('model.isWarning', false); } if (composer.get('cantSubmitPost')) { this.set('lastValidatedAt', Date.now()); return; } composer.set('disableDrafts', true); // for now handle a very narrow use case // if we are replying to a topic AND not on the topic pop the window up if (!force && composer.get('replyingToTopic')) { const currentTopic = this.get('topicModel'); if (!currentTopic || currentTopic.get('id') !== composer.get('topic.id')) { const message = I18n.t("composer.posting_not_on_topic"); let buttons = [{ "label": I18n.t("composer.cancel"), "class": "cancel", "link": true }]; if (currentTopic) { buttons.push({ "label": I18n.t("composer.reply_here") + "
" + escapeExpression(currentTopic.get('title')) + "
", "class": "btn btn-reply-here", "callback": function() { composer.set('topic', currentTopic); composer.set('post', null); self.save(true); } }); } buttons.push({ "label": I18n.t("composer.reply_original") + "
" + escapeExpression(this.get('model.topic.title')) + "
", "class": "btn-primary btn-reply-on-original", "callback": function() { self.save(true); } }); bootbox.dialog(message, buttons, { "classes": "reply-where-modal" }); return; } } var staged = false; // TODO: This should not happen in model const imageSizes = {}; $('#reply-control .d-editor-preview img').each((i, e) => { const $img = $(e); const src = $img.prop('src'); if (src && src.length) { imageSizes[src] = { width: $img.width(), height: $img.height() }; } }); const promise = composer.save({ imageSizes, editReason: this.get("editReason")}).then(function(result) { if (result.responseJson.action === "enqueued") { self.send('postWasEnqueued', result.responseJson); self.destroyDraft(); self.close(); self.appEvents.trigger('post-stream:refresh'); return result; } // If user "created a new topic/post" or "replied as a new topic" successfully, remove the draft. if (result.responseJson.action === "create_post" || self.get('replyAsNewTopicDraft')) { self.destroyDraft(); } if (self.get('model.action') === 'edit') { self.appEvents.trigger('post-stream:refresh', { id: parseInt(result.responseJson.id) }); } else { self.appEvents.trigger('post-stream:refresh'); } if (result.responseJson.action === "create_post") { self.appEvents.trigger('post:highlight', result.payload.post_number); } self.close(); const currentUser = Discourse.User.current(); if (composer.get('creatingTopic')) { currentUser.set('topic_count', currentUser.get('topic_count') + 1); } else { currentUser.set('reply_count', currentUser.get('reply_count') + 1); } const disableJumpReply = Discourse.User.currentProp('disable_jump_reply'); if (!composer.get('replyingToTopic') || !disableJumpReply) { const post = result.target; if (post && !staged) { DiscourseURL.routeTo(post.get('url')); } } }).catch(function(error) { composer.set('disableDrafts', false); self.appEvents.one('composer:will-open', () => bootbox.alert(error)); }); if (this.get('application.currentRouteName').split('.')[0] === 'topic' && composer.get('topic.id') === this.get('topicModel.id')) { staged = composer.get('stagedPost'); } this.appEvents.trigger('post-stream:posted', staged); this.messageBus.pause(); promise.finally(() => this.messageBus.resume()); return promise; }, // Notify the composer messages controller that a reply has been typed. Some // messages only appear after typing. checkReplyLength() { if (!Ember.isEmpty('model.reply')) { this.appEvents.trigger('composer:typed-reply'); } }, /** Open the composer view @method open @param {Object} opts Options for creating a post @param {String} opts.action The action we're performing: edit, reply or createTopic @param {Discourse.Post} [opts.post] The post we're replying to @param {Discourse.Topic} [opts.topic] The topic we're replying to @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making **/ open(opts) { opts = opts || {}; if (!opts.draftKey) { alert("composer was opened without a draft key"); throw "composer opened without a proper draft key"; } const self = this; let composerModel = this.get('model'); if (opts.ignoreIfChanged && composerModel && composerModel.composeState !== Composer.CLOSED) { return; } // If we show the subcategory list, scope the categories drop down to // the category we opened the composer with. if (this.siteSettings.show_subcategory_list && opts.draftKey !== 'reply_as_new_topic') { this.set('scopedCategoryId', opts.categoryId); } this.setProperties({ showEditReason: false, editReason: null }); // If we want a different draft than the current composer, close it and clear our model. if (composerModel && opts.draftKey !== composerModel.draftKey && composerModel.composeState === Composer.DRAFT) { this.close(); composerModel = null; } return new Ember.RSVP.Promise(function(resolve, reject) { if (composerModel && composerModel.get('replyDirty')) { // If we're already open, we don't have to do anything if (composerModel.get('composeState') === Composer.OPEN && composerModel.get('draftKey') === opts.draftKey && !opts.action) { return resolve(); } // If it's the same draft, just open it up again. if (composerModel.get('composeState') === Composer.DRAFT && composerModel.get('draftKey') === opts.draftKey) { composerModel.set('composeState', Composer.OPEN); if (!opts.action) return resolve(); } // If it's a different draft, cancel it and try opening again. return self.cancelComposer().then(function() { return self.open(opts); }).then(resolve, reject); } // we need a draft sequence for the composer to work if (opts.draftSequence === undefined) { return Draft.get(opts.draftKey).then(function(data) { opts.draftSequence = data.draft_sequence; opts.draft = data.draft; self._setModel(composerModel, opts); }).then(resolve, reject); } if (composerModel) { if (composerModel.get('action') !== opts.action) { composerModel.setProperties({ unlistTopic: false, whisper: false }); } } self._setModel(composerModel, opts); resolve(); }); }, // Given a potential instance and options, set the model for this composer. _setModel(composerModel, opts) { this.set('linkLookup', null); if (opts.draft) { composerModel = loadDraft(this.store, opts); if (composerModel) { composerModel.set('topic', opts.topic); } } else { composerModel = composerModel || this.store.createRecord('composer'); composerModel.open(opts); } this.set('model', composerModel); composerModel.set('composeState', Composer.OPEN); composerModel.set('isWarning', false); if (opts.topicTitle && opts.topicTitle.length <= this.siteSettings.max_topic_title_length) { this.set('model.title', opts.topicTitle); } if (opts.topicCategoryId) { this.set('model.categoryId', opts.topicCategoryId); } else if (opts.topicCategory) { const splitCategory = opts.topicCategory.split("/"); let category; if (!splitCategory[1]) { category = this.site.get('categories').findBy('nameLower', splitCategory[0].toLowerCase()); } else { const categories = Discourse.Category.list(); const mainCategory = categories.findBy('nameLower', splitCategory[0].toLowerCase()); category = categories.find(function(item) { return item && item.get('nameLower') === splitCategory[1].toLowerCase() && item.get('parent_category_id') === mainCategory.id; }); } if (category) { this.set('model.categoryId', category.get('id')); } } if (opts.topicTags && !this.site.mobileView && this.site.get('can_tag_topics')) { this.set('model.tags', opts.topicTags.split(",")); } if (opts.topicBody) { this.set('model.reply', opts.topicBody); } }, // View a new reply we've made viewNewReply() { DiscourseURL.routeTo(this.get('model.createdPost.url')); this.close(); return false; }, destroyDraft() { const key = this.get('model.draftKey'); if (key) { Draft.clear(key, this.get('model.draftSequence')); } }, cancelComposer() { const self = this; return new Ember.RSVP.Promise(function (resolve) { if (self.get('model.hasMetaData') || self.get('model.replyDirty')) { bootbox.confirm(I18n.t("post.abandon.confirm"), I18n.t("post.abandon.no_value"), I18n.t("post.abandon.yes_value"), function(result) { if (result) { self.destroyDraft(); self.get('model').clearState(); self.close(); resolve(); } }); } else { // it is possible there is some sort of crazy draft with no body ... just give up on it self.destroyDraft(); self.get('model').clearState(); self.close(); resolve(); } }); }, shrink() { if (this.get('model.replyDirty')) { this.collapse(); } else { this.close(); } }, _saveDraft() { const model = this.get('model'); if (model) { model.saveDraft(); }; }, @observes('model.reply', 'model.title') _shouldSaveDraft() { Ember.run.debounce(this, this._saveDraft, 2000); }, @computed('model.categoryId', 'lastValidatedAt') categoryValidation(categoryId, lastValidatedAt) { if( !this.siteSettings.allow_uncategorized_topics && !categoryId) { return InputValidation.create({ failed: true, reason: I18n.t('composer.error.category_missing'), lastShownAt: lastValidatedAt }); } }, collapse() { this._saveDraft(); this.set('model.composeState', Composer.DRAFT); }, close() { this.setProperties({ model: null, lastValidatedAt: null }); }, closeAutocomplete() { $('.d-editor-input').autocomplete({ cancel: true }); }, canEdit: function() { return this.get("model.action") === "edit" && Discourse.User.current().get("can_edit"); }.property("model.action"), visible: function() { var state = this.get('model.composeState'); return state && state !== 'closed'; }.property('model.composeState') });