import ObjectController from 'discourse/controllers/object'; import BufferedContent from 'discourse/mixins/buffered-content'; import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; import Topic from 'discourse/models/topic'; export default ObjectController.extend(SelectedPostsCount, BufferedContent, { multiSelect: false, needs: ['header', 'modal', 'composer', 'quote-button', 'search', 'topic-progress', 'application'], allPostsSelected: false, editingTopic: false, selectedPosts: null, selectedReplies: null, queryParams: ['filter', 'username_filters', 'show_deleted'], searchHighlight: null, loadedAllPosts: false, enteredAt: null, firstPostExpanded: false, retrying: false, maxTitleLength: Discourse.computed.setting('max_topic_title_length'), contextChanged: function() { this.set('controllers.search.searchContext', this.get('model.searchContext')); }.observes('topic'), _titleChanged: function() { const title = this.get('model.title'); if (!Ember.isEmpty(title)) { // Note normally you don't have to trigger this, but topic titles can be updated // and are sometimes lazily loaded. this.send('refreshTitle'); } }.observes('model.title', 'category'), termChanged: function() { const dropdown = this.get('controllers.header.visibleDropdown'); const term = this.get('controllers.search.term'); if(dropdown === 'search-dropdown' && term){ this.set('searchHighlight', term); } else { if(this.get('searchHighlight')){ this.set('searchHighlight', null); } } }.observes('controllers.search.term', 'controllers.header.visibleDropdown'), postStreamLoadedAllPostsChanged: function() { // semantics of loaded all posts are slightly diff at topic level, // it just means that we "once" loaded all posts, this means we don't // keep re-rendering the suggested topics when new posts zoom in let loaded = this.get('model.postStream.loadedAllPosts'); if (loaded) { this.set('model.loadedTopicId', this.get('model.id')); } else { loaded = this.get('model.loadedTopicId') === this.get('model.id'); } this.set('loadedAllPosts', loaded); }.observes('model.postStream', 'model.postStream.loadedAllPosts'), show_deleted: function(key, value) { const postStream = this.get('model.postStream'); if (!postStream) { return; } if (arguments.length > 1) { postStream.set('show_deleted', value); } return postStream.get('show_deleted') ? true : undefined; }.property('model.postStream.summary'), filter: function(key, value) { const postStream = this.get('model.postStream'); if (!postStream) { return; } if (arguments.length > 1) { postStream.set('summary', value === "summary"); } return postStream.get('summary') ? "summary" : undefined; }.property('model.postStream.summary'), username_filters: function(key, value) { const postStream = this.get('model.postStream'); if (!postStream) { return; } if (arguments.length > 1) { postStream.set('streamFilters.username_filters', value); } return postStream.get('streamFilters.username_filters'); }.property('model.postStream.streamFilters.username_filters'), _clearSelected: function() { this.set('selectedPosts', []); this.set('selectedReplies', []); }.on('init'), _togglePinnedStates(property) { const value = this.get('model.pinned_at') ? false : true, topic = this.get('content'); // optimistic update topic.setProperties({ pinned_at: value, pinned_globally: value }); return topic.saveStatus(property, value); }, actions: { deleteTopic() { this.deleteTopic(); }, // Post related methods replyToPost(post) { const composerController = this.get('controllers.composer'), quoteController = this.get('controllers.quote-button'), quotedText = Discourse.Quote.build(quoteController.get('post'), quoteController.get('buffer')), topic = post ? post.get('topic') : this.get('model'); quoteController.set('buffer', ''); if (composerController.get('content.topic.id') === topic.get('id') && composerController.get('content.action') === Discourse.Composer.REPLY) { composerController.set('content.post', post); composerController.set('content.composeState', Discourse.Composer.OPEN); composerController.appendText(quotedText); } else { const opts = { action: Discourse.Composer.REPLY, draftKey: topic.get('draft_key'), draftSequence: topic.get('draft_sequence') }; if(post && post.get("post_number") !== 1){ opts.post = post; } else { opts.topic = topic; } composerController.open(opts).then(function() { composerController.appendText(quotedText); }); } return false; }, toggleLike(post) { const likeAction = post.get('actionByName.like'); if (likeAction && likeAction.get('canToggle')) { likeAction.toggle(post); } }, recoverPost(post) { // Recovering the first post recovers the topic instead if (post.get('post_number') === 1) { this.recoverTopic(); return; } post.recover(); }, deletePost(post) { // Deleting the first post deletes the topic if (post.get('post_number') === 1) { this.deleteTopic(); return; } const user = Discourse.User.current(), replyCount = post.get('reply_count'), self = this; // If the user is staff and the post has replies, ask if they want to delete replies too. if (user.get('staff') && replyCount > 0) { bootbox.dialog(I18n.t("post.controls.delete_replies.confirm", {count: replyCount}), [ {label: I18n.t("cancel"), 'class': 'btn-danger rightg'}, {label: I18n.t("post.controls.delete_replies.no_value"), callback() { post.destroy(user); } }, {label: I18n.t("post.controls.delete_replies.yes_value"), 'class': 'btn-primary', callback() { Discourse.Post.deleteMany([post], [post]); self.get('model.postStream.posts').forEach(function (p) { if (p === post || p.get('reply_to_post_number') === post.get('post_number')) { p.setDeletedState(user); } }); } } ]); } else { post.destroy(user).then(null, function(e) { post.undoDeleteState(); const response = $.parseJSON(e.responseText); if (response && response.errors) { bootbox.alert(response.errors[0]); } else { bootbox.alert(I18n.t('generic_error')); } }); } }, editPost(post) { if (!Discourse.User.current()) { return bootbox.alert(I18n.t('post.controls.edit_anonymous')); } this.get('controllers.composer').open({ post: post, action: Discourse.Composer.EDIT, draftKey: post.get('topic.draft_key'), draftSequence: post.get('topic.draft_sequence') }); }, toggleBookmark(post) { if (!Discourse.User.current()) { alert(I18n.t("bookmarks.not_bookmarked")); return; } if (post) { return post.toggleBookmark().catch(function(error) { if (error && error.responseText) { bootbox.alert($.parseJSON(error.responseText).errors[0]); } else { bootbox.alert(I18n.t('generic_error')); } }); } else { return this.get("model").toggleBookmark(); } }, jumpTop() { this.get('controllers.topic-progress').send('jumpTop'); }, selectAll() { const posts = this.get('model.postStream.posts'), selectedPosts = this.get('selectedPosts'); if (posts) { selectedPosts.addObjects(posts); } this.set('allPostsSelected', true); }, deselectAll() { this.get('selectedPosts').clear(); this.get('selectedReplies').clear(); this.set('allPostsSelected', false); }, toggleParticipant(user) { this.get('model.postStream').toggleParticipant(Em.get(user, 'username')); }, editTopic() { if (!this.get('model.details.can_edit')) return false; this.set('editingTopic', true); return false; }, cancelEditingTopic() { this.set('editingTopic', false); this.rollbackBuffer(); }, toggleMultiSelect() { this.toggleProperty('multiSelect'); }, finishedEditingTopic() { if (!this.get('editingTopic')) { return; } // save the modifications const self = this, props = this.get('buffered.buffer'); Topic.update(this.get('model'), props).then(function() { // Note we roll back on success here because `update` saves // the properties to the topic. self.rollbackBuffer(); self.set('editingTopic', false); }).catch(function(error) { if (error && error.responseText) { bootbox.alert($.parseJSON(error.responseText).errors[0]); } else { bootbox.alert(I18n.t('generic_error')); } }); }, toggledSelectedPost(post) { this.performTogglePost(post); }, toggledSelectedPostReplies(post) { const selectedReplies = this.get('selectedReplies'); if (this.performTogglePost(post)) { selectedReplies.addObject(post); } else { selectedReplies.removeObject(post); } }, deleteSelected() { const self = this; bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) { if (result) { // If all posts are selected, it's the same thing as deleting the topic if (self.get('allPostsSelected')) { return self.deleteTopic(); } const selectedPosts = self.get('selectedPosts'), selectedReplies = self.get('selectedReplies'), postStream = self.get('model.postStream'), toRemove = []; Discourse.Post.deleteMany(selectedPosts, selectedReplies); postStream.get('posts').forEach(function (p) { if (self.postSelected(p)) { toRemove.addObject(p); } }); postStream.removePosts(toRemove); self.send('toggleMultiSelect'); } }); }, expandHidden(post) { post.expandHidden(); }, toggleVisibility() { this.get('content').toggleStatus('visible'); }, toggleClosed() { this.get('content').toggleStatus('closed'); }, recoverTopic() { this.get('content').recover(); }, makeBanner() { this.get('content').makeBanner(); }, removeBanner() { this.get('content').removeBanner(); }, togglePinned() { const value = this.get('model.pinned_at') ? false : true, topic = this.get('content'); // optimistic update topic.setProperties({ pinned_at: value ? moment() : null, pinned_globally: false }); return topic.saveStatus("pinned", value); }, pinGlobally() { const topic = this.get('content'); // optimistic update topic.setProperties({ pinned_at: moment(), pinned_globally: true }); return topic.saveStatus("pinned_globally", true); }, toggleArchived() { this.get('content').toggleStatus('archived'); }, // Toggle the star on the topic toggleStar() { this.get('content').toggleStar(); }, clearPin() { this.get('content').clearPin(); }, togglePinnedForUser() { if (this.get('model.pinned_at')) { if (this.get('pinned')) { this.get('content').clearPin(); } else { this.get('content').rePin(); } } }, replyAsNewTopic(post) { const composerController = this.get('controllers.composer'), quoteController = this.get('controllers.quote-button'), quotedText = Discourse.Quote.build(quoteController.get('post'), quoteController.get('buffer')), self = this; quoteController.deselectText(); composerController.open({ action: Discourse.Composer.CREATE_TOPIC, draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY, categoryId: this.get('category.id') }).then(function() { return Em.isEmpty(quotedText) ? Discourse.Post.loadQuote(post.get('id')) : quotedText; }).then(function(q) { const postUrl = "" + location.protocol + "//" + location.host + post.get('url'), postLink = "[" + Handlebars.escapeExpression(self.get('model.title')) + "](" + postUrl + ")"; composerController.appendText(I18n.t("post.continue_discussion", { postLink: postLink }) + "\n\n" + q); }); }, expandFirstPost(post) { const self = this; this.set('loadingExpanded', true); post.expand().then(function() { self.set('firstPostExpanded', true); }).catch(function(error) { bootbox.alert($.parseJSON(error.responseText).errors); }).finally(function() { self.set('loadingExpanded', false); }); }, retryLoading() { const self = this; self.set('retrying', true); this.get('model.postStream').refresh().then(function() { self.set('retrying', false); }, function() { self.set('retrying', false); }); }, toggleWiki(post) { // the request to the server is made in an observer in the post class post.toggleProperty('wiki'); }, togglePostType(post) { // the request to the server is made in an observer in the post class const regular = this.site.get('post_types.regular'), moderator = this.site.get('post_types.moderator_action'); if (post.get("post_type") === moderator) { post.set("post_type", regular); } else { post.set("post_type", moderator); } }, rebakePost(post) { post.rebake(); }, unhidePost(post) { post.unhide(); } }, togglePinnedState() { this.send('togglePinnedForUser'); }, showExpandButton: function() { const post = this.get('post'); return post.get('post_number') === 1 && post.get('topic.expandable_first_post'); }.property(), canMergeTopic: function() { if (!this.get('model.details.can_move_posts')) return false; return (this.get('selectedPostsCount') > 0); }.property('selectedPostsCount'), canSplitTopic: function() { if (!this.get('model.details.can_move_posts')) return false; if (this.get('allPostsSelected')) return false; return (this.get('selectedPostsCount') > 0); }.property('selectedPostsCount'), canChangeOwner: function() { if (!Discourse.User.current() || !Discourse.User.current().admin) return false; return !!this.get('selectedPostsUsername'); }.property('selectedPostsUsername'), categories: function() { return Discourse.Category.list(); }.property(), canSelectAll: Em.computed.not('allPostsSelected'), canDeselectAll: function () { if (this.get('selectedPostsCount') > 0) return true; if (this.get('allPostsSelected')) return true; }.property('selectedPostsCount', 'allPostsSelected'), canDeleteSelected: function() { const selectedPosts = this.get('selectedPosts'); if (this.get('allPostsSelected')) return true; if (this.get('selectedPostsCount') === 0) return false; let canDelete = true; selectedPosts.forEach(function(p) { if (!p.get('can_delete')) { canDelete = false; return false; } }); return canDelete; }.property('selectedPostsCount'), hasError: Ember.computed.or('model.notFoundHtml', 'model.message'), noErrorYet: Ember.computed.not('hasError'), multiSelectChanged: function() { // Deselect all posts when multi select is turned off if (!this.get('multiSelect')) { this.send('deselectAll'); } }.observes('multiSelect'), deselectPost(post) { this.get('selectedPosts').removeObject(post); const selectedReplies = this.get('selectedReplies'); selectedReplies.removeObject(post); const selectedReply = selectedReplies.findProperty('post_number', post.get('reply_to_post_number')); if (selectedReply) { selectedReplies.removeObject(selectedReply); } this.set('allPostsSelected', false); }, postSelected(post) { if (this.get('allPostsSelected')) { return true; } if (this.get('selectedPosts').contains(post)) { return true; } if (this.get('selectedReplies').findProperty('post_number', post.get('reply_to_post_number'))) { return true; } return false; }, showStarButton: function() { return Discourse.User.current() && !this.get('model.isPrivateMessage'); }.property('model.isPrivateMessage'), loadingHTML: function() { return spinnerHTML; }.property(), recoverTopic() { this.get('content').recover(); }, deleteTopic() { this.unsubscribe(); this.get('content').destroy(Discourse.User.current()); }, // Receive notifications for this topic subscribe() { // Unsubscribe before subscribing again this.unsubscribe(); const self = this; this.messageBus.subscribe("/topic/" + this.get('model.id'), function(data) { const topic = self.get('model'); if (data.notification_level_change) { topic.set('details.notification_level', data.notification_level_change); topic.set('details.notifications_reason_id', data.notifications_reason_id); return; } const postStream = self.get('model.postStream'); switch (data.type) { case "revised": case "acted": case "rebaked": { // TODO we could update less data for "acted" (only post actions) postStream.triggerChangedPost(data.id, data.updated_at); return; } case "deleted": { postStream.triggerDeletedPost(data.id, data.post_number); return; } case "recovered": { postStream.triggerRecoveredPost(data.id, data.post_number); return; } case "created": { postStream.triggerNewPostInStream(data.id); return; } default: { Em.Logger.warn("unknown topic bus message type", data); } } }); }, unsubscribe() { const topicId = this.get('content.id'); if (!topicId) return; // there is a condition where the view never calls unsubscribe, navigate to a topic from a topic this.messageBus.unsubscribe('/topic/*'); }, // Topic related reply() { this.replyToPost(); }, performTogglePost(post) { const selectedPosts = this.get('selectedPosts'); if (this.postSelected(post)) { this.deselectPost(post); return false; } else { selectedPosts.addObject(post); // If the user manually selects all posts, all posts are selected this.set('allPostsSelected', selectedPosts.length === this.get('model.posts_count')); return true; } }, // If our current post is changed, notify the router _currentPostChanged: function() { const currentPost = this.get('model.currentPost'); if (currentPost) { this.send('postChangedRoute', currentPost); } }.observes('model.currentPost'), readPosts(topicId, postNumbers) { const postStream = this.get('model.postStream'); if (postStream.get('topic.id') === topicId){ _.each(postStream.get('posts'), function(post){ // optimise heavy loop // TODO identity map for postNumber if(_.include(postNumbers,post.post_number) && !post.read){ post.set("read", true); } }); const max = _.max(postNumbers); if(max > this.get('model.last_read_post_number')){ this.set('model.sast_read_post_number', max); } } }, // Called the the topmost visible post on the page changes. topVisibleChanged(post) { if (!post) { return; } const postStream = this.get('model.postStream'), firstLoadedPost = postStream.get('firstLoadedPost'); this.set('model.currentPost', post.get('post_number')); if (post.get('post_number') === 1) { return; } if (firstLoadedPost && firstLoadedPost === post) { // Note: jQuery shouldn't be done in a controller, but how else can we // trigger a scroll after a promise resolves in a controller? We need // to do this to preserve upwards infinte scrolling. const $body = $('body'); let $elem = $('#post-cloak-' + post.get('post_number')); const distToElement = $body.scrollTop() - $elem.position().top; postStream.prependMore().then(function() { Em.run.next(function () { $elem = $('#post-cloak-' + post.get('post_number')); // Quickly going back might mean the element is destroyed const position = $elem.position(); if (position && position.top) { $('html, body').scrollTop(position.top + distToElement); } }); }); } }, /** Called the the bottommost visible post on the page changes. @method bottomVisibleChanged @params {Discourse.Post} post that is at the bottom **/ bottomVisibleChanged(post) { if (!post) { return; } const postStream = this.get('model.postStream'), lastLoadedPost = postStream.get('lastLoadedPost'); this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post)); if (lastLoadedPost && lastLoadedPost === post) { postStream.appendMore(); } }, _showFooter: function() { this.set("controllers.application.showFooter", this.get("model.postStream.loadedAllPosts")); }.observes("model.postStream.loadedAllPosts") });