diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js index 7a6f15c0c..2b25d5f14 100644 --- a/app/assets/javascripts/discourse/controllers/topic_controller.js +++ b/app/assets/javascripts/discourse/controllers/topic_controller.js @@ -60,6 +60,8 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected return canDelete; }.property('selectedPostsCount'), + hasError: Ember.computed.or('errorBodyHtml', 'message'), + streamPercentage: function() { if (!this.get('postStream.loaded')) { return 0; } if (this.get('postStream.filteredPostsCount') === 0) { return 0; } @@ -248,12 +250,8 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected }.property('isPrivateMessage'), deleteTopic: function() { - var topicController = this; this.unsubscribe(); - this.get('content').destroy().then(function() { - topicController.set('message', I18n.t('topic.deleted')); - topicController.set('loaded', false); - }); + this.get('content').destroy(Discourse.User.current()); }, toggleVisibility: function() { diff --git a/app/assets/javascripts/discourse/models/post_stream.js b/app/assets/javascripts/discourse/models/post_stream.js index 51b668516..a258424d8 100644 --- a/app/assets/javascripts/discourse/models/post_stream.js +++ b/app/assets/javascripts/discourse/models/post_stream.js @@ -258,7 +258,7 @@ Discourse.PostStream = Em.Object.extend({ Discourse.URL.set('queryParams', postStream.get('streamFilters')); }, function(result) { - postStream.errorLoading(result.status); + postStream.errorLoading(result); }); }, hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), @@ -612,7 +612,8 @@ Discourse.PostStream = Em.Object.extend({ @param {Integer} status the HTTP status code @param {Discourse.Topic} topic The topic instance we were trying to load **/ - errorLoading: function(status) { + errorLoading: function(result) { + var status = result.status; var topic = this.get('topic'); topic.set('loadingFilter', false); @@ -621,7 +622,7 @@ Discourse.PostStream = Em.Object.extend({ // If the result was 404 the post is not found if (status === 404) { topic.set('errorTitle', I18n.t('topic.not_found.title')); - topic.set('message', I18n.t('topic.not_found.description')); + topic.set('errorBodyHtml', result.responseText); return; } diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js index a74f76cdf..8d6b08490 100644 --- a/app/assets/javascripts/discourse/models/topic.js +++ b/app/assets/javascripts/discourse/models/topic.js @@ -17,6 +17,7 @@ Discourse.Topic = Discourse.Model.extend({ }.property(), invisible: Em.computed.not('visible'), + deleted: Em.computed.notEmpty('deleted_at'), canConvertToRegular: function() { var a = this.get('archetype'); @@ -142,13 +143,13 @@ Discourse.Topic = Discourse.Model.extend({ }); }, - favoriteTooltipKey: (function() { + favoriteTooltipKey: function() { return this.get('starred') ? 'favorite.help.unstar' : 'favorite.help.star'; - }).property('starred'), + }.property('starred'), - favoriteTooltip: (function() { + favoriteTooltip: function() { return I18n.t(this.get('favoriteTooltipKey')); - }).property('favoriteTooltipKey'), + }.property('favoriteTooltipKey'), toggleStar: function() { var topic = this; @@ -181,22 +182,26 @@ Discourse.Topic = Discourse.Model.extend({ // Reset our read data for this topic resetRead: function() { - return Discourse.ajax("/t/" + (this.get('id')) + "/timings", { + return Discourse.ajax("/t/" + this.get('id') + "/timings", { type: 'DELETE' }); }, // Invite a user to this topic inviteUser: function(user) { - return Discourse.ajax("/t/" + (this.get('id')) + "/invite", { + return Discourse.ajax("/t/" + this.get('id') + "/invite", { type: 'POST', data: { user: user } }); }, // Delete this topic - destroy: function() { - return Discourse.ajax("/t/" + (this.get('id')), { type: 'DELETE' }); + destroy: function(deleted_by) { + this.setProperties({ + deleted_at: new Date(), + deleted_by: deleted_by + }); + return Discourse.ajax("/t/" + this.get('id'), { type: 'DELETE' }); }, // Update our attributes from a JSON result diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars index 8df9d1e38..3c017d826 100644 --- a/app/assets/javascripts/discourse/templates/topic.js.handlebars +++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars @@ -107,15 +107,18 @@ {{else}} - {{#if message}} + {{#if hasError}}
-
+ {{#if errorBodyHtml}} + {{{errorBodyHtml}}} + {{/if}} -

{{message}}

+ {{#if message}} +
+

{{message}}

+
+ {{/if}} -

- {{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}} -

{{else}}
diff --git a/app/assets/javascripts/discourse/views/buttons/button_view.js b/app/assets/javascripts/discourse/views/buttons/button_view.js index e9070c84f..a2daadfe8 100644 --- a/app/assets/javascripts/discourse/views/buttons/button_view.js +++ b/app/assets/javascripts/discourse/views/buttons/button_view.js @@ -9,7 +9,7 @@ Discourse.ButtonView = Discourse.View.extend({ tagName: 'button', classNameBindings: [':btn', ':standard', 'dropDownToggle'], - attributeBindings: ['data-not-implemented', 'title', 'data-toggle', 'data-share-url'], + attributeBindings: ['title', 'data-toggle', 'data-share-url'], title: function() { return I18n.t(this.get('helpKey') || this.get('textKey')); diff --git a/app/assets/javascripts/discourse/views/buttons/dropdown_button_view.js b/app/assets/javascripts/discourse/views/buttons/dropdown_button_view.js index 9d9e8bfc6..ce0b918b7 100644 --- a/app/assets/javascripts/discourse/views/buttons/dropdown_button_view.js +++ b/app/assets/javascripts/discourse/views/buttons/dropdown_button_view.js @@ -7,9 +7,7 @@ @module Discourse **/ Discourse.DropdownButtonView = Discourse.View.extend({ - classNames: ['btn-group'], - attributeBindings: ['data-not-implemented'], - + classNameBindings: [':btn-group', 'hidden'], shouldRerender: Discourse.View.renderIfChanged('text', 'longDescription'), didInsertElement: function(e) { diff --git a/app/assets/javascripts/discourse/views/buttons/favorite_button.js b/app/assets/javascripts/discourse/views/buttons/favorite_button.js index 7834622b5..d04381cd2 100644 --- a/app/assets/javascripts/discourse/views/buttons/favorite_button.js +++ b/app/assets/javascripts/discourse/views/buttons/favorite_button.js @@ -8,9 +8,10 @@ **/ Discourse.FavoriteButton = Discourse.ButtonView.extend({ textKey: 'favorite.title', - helpKeyBinding: 'controller.content.favoriteTooltipKey', + helpKeyBinding: 'controller.favoriteTooltipKey', + attributeBindings: ['disabled'], - shouldRerender: Discourse.View.renderIfChanged('controller.content.starred'), + shouldRerender: Discourse.View.renderIfChanged('controller.starred'), click: function() { this.get('controller').toggleStar(); @@ -18,7 +19,7 @@ Discourse.FavoriteButton = Discourse.ButtonView.extend({ renderIcon: function(buffer) { buffer.push(""); } }); diff --git a/app/assets/javascripts/discourse/views/buttons/invite_reply_button.js b/app/assets/javascripts/discourse/views/buttons/invite_reply_button.js index 8adcd9ae3..11ad7dcba 100644 --- a/app/assets/javascripts/discourse/views/buttons/invite_reply_button.js +++ b/app/assets/javascripts/discourse/views/buttons/invite_reply_button.js @@ -10,7 +10,7 @@ Discourse.InviteReplyButton = Discourse.ButtonView.extend({ textKey: 'topic.invite_reply.title', helpKey: 'topic.invite_reply.help', attributeBindings: ['disabled'], - disabled: Em.computed.or('controller.content.archived', 'controller.content.closed'), + disabled: Em.computed.or('controller.archived', 'controller.closed', 'controller.deleted'), renderIcon: function(buffer) { buffer.push(""); diff --git a/app/assets/javascripts/discourse/views/buttons/notifications_button.js b/app/assets/javascripts/discourse/views/buttons/notifications_button.js index 4050a35bf..c71de9126 100644 --- a/app/assets/javascripts/discourse/views/buttons/notifications_button.js +++ b/app/assets/javascripts/discourse/views/buttons/notifications_button.js @@ -9,6 +9,8 @@ Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({ title: I18n.t('topic.notifications.title'), longDescriptionBinding: 'topic.details.notificationReasonText', + topic: Em.computed.alias('controller.model'), + hidden: Em.computed.alias('topic.deleted'), dropDownContent: [ [Discourse.Topic.NotificationLevel.WATCHING, 'topic.notifications.watching'], diff --git a/app/assets/javascripts/discourse/views/buttons/share_button.js b/app/assets/javascripts/discourse/views/buttons/share_button.js index fe4196065..50a9010f2 100644 --- a/app/assets/javascripts/discourse/views/buttons/share_button.js +++ b/app/assets/javascripts/discourse/views/buttons/share_button.js @@ -10,6 +10,7 @@ Discourse.ShareButton = Discourse.ButtonView.extend({ textKey: 'topic.share.title', helpKey: 'topic.share.help', 'data-share-url': Em.computed.alias('topic.shareUrl'), + topic: Em.computed.alias('controller.model'), renderIcon: function(buffer) { buffer.push(""); diff --git a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js index 68c7835fd..e599648d2 100644 --- a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js +++ b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js @@ -26,13 +26,13 @@ Discourse.TopicFooterButtonsView = Discourse.ContainerView.extend({ this.attachViewClass(Discourse.InviteReplyButton); } this.attachViewClass(Discourse.FavoriteButton); - this.attachViewWithArgs({topic: topic}, Discourse.ShareButton); + this.attachViewClass(Discourse.ShareButton); this.attachViewClass(Discourse.ClearPinButton); } this.attachViewClass(Discourse.ReplyButton); if (!topic.get('isPrivateMessage')) { - this.attachViewWithArgs({topic: topic}, Discourse.NotificationsButton); + this.attachViewClass(Discourse.NotificationsButton); } this.trigger('additionalButtons', this); } else { diff --git a/app/assets/javascripts/discourse/views/topic_view.js b/app/assets/javascripts/discourse/views/topic_view.js index 401af52f7..33e1c1b17 100644 --- a/app/assets/javascripts/discourse/views/topic_view.js +++ b/app/assets/javascripts/discourse/views/topic_view.js @@ -9,15 +9,17 @@ **/ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, { templateName: 'topic', - topicBinding: 'controller.content', + topicBinding: 'controller.model', userFiltersBinding: 'controller.userFilters', - classNameBindings: ['controller.multiSelect:multi-select', 'topic.archetype', 'topic.category.secure:secure_category'], + classNameBindings: ['controller.multiSelect:multi-select', + 'topic.archetype', + 'topic.category.secure:secure_category', + 'topic.deleted:deleted-topic'], menuVisible: true, SHORT_POST: 1200, postStream: Em.computed.alias('controller.postStream'), - updateBar: function() { var $topicProgress = $('#topic-progress'); if (!$topicProgress.length) return; @@ -168,7 +170,6 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, { // Called for every post seen, returns the post number postSeen: function($post) { - var post = this.getPost($post); if (post) { diff --git a/app/assets/stylesheets/application/topic-post.css.scss b/app/assets/stylesheets/application/topic-post.css.scss index e3435a7a0..796272f4f 100644 --- a/app/assets/stylesheets/application/topic-post.css.scss +++ b/app/assets/stylesheets/application/topic-post.css.scss @@ -777,7 +777,7 @@ } } -// Private messages +// Custom Gutter Glyphs // -------------------------------------------------- .private_message .gutter { @@ -794,6 +794,20 @@ } +.deleted-topic .gutter { + position: relative; + &:before { + display: block; + position: absolute; + top: 0; + left: 0; + color: rgba($black, 0.05); + font: 90px/1 FontAwesome; + content: "\f05c"; + } +} + + .secure_category .gutter { position: relative; &:before { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2c324ae5a..fc1f7ef2c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -74,9 +74,9 @@ class ApplicationController < ActionController::Base def rescue_discourse_actions(message, error) if request.format && request.format.json? - render status: error, layout: false, text: message + render status: error, layout: false, text: (error == 404) ? build_not_found_page(error) : message else - render_not_found_page(error) + render text: build_not_found_page(error, 'no_js') end end @@ -123,7 +123,6 @@ class ApplicationController < ActionController::Base @guardian ||= Guardian.new(current_user) end - def serialize_data(obj, serializer, opts={}) # If it's an array, apply the serializer as an each_serializer to the elements serializer_opts = {scope: guardian}.merge!(opts) @@ -261,13 +260,13 @@ class ApplicationController < ActionController::Base redirect_to :login if SiteSetting.login_required? && !current_user end - def render_not_found_page(status=404) + def build_not_found_page(status=404, layout=false) @top_viewed = TopicQuery.top_viewed(10) @recent = TopicQuery.recent(10) @slug = params[:slug].class == String ? params[:slug] : '' @slug = (params[:id].class == String ? params[:id] : '') if @slug.blank? @slug.gsub!('-',' ') - render status: status, layout: 'no_js', formats: [:html], template: '/exceptions/not_found' + render_to_string status: status, layout: layout, formats: [:html], template: '/exceptions/not_found' end protected diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 0c9764955..2db297b54 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -25,6 +25,7 @@ class TopicsController < ApplicationController caches_action :avatar, cache_path: Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" } def show + # We'd like to migrate the wordpress feed to another url. This keeps up backwards compatibility with # existing installs. return wordpress if params[:best].present? @@ -33,6 +34,7 @@ class TopicsController < ApplicationController begin @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts) rescue Discourse::NotFound + Rails.logger.info ">>>> B" topic = Topic.where(slug: params[:id]).first if params[:id] raise Discourse::NotFound unless topic return redirect_to(topic.relative_url) diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 38dd719b8..4720f0909 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -19,7 +19,8 @@ class TopicViewSerializer < ApplicationSerializer :has_best_of, :archetype, :slug, - :category_id] + :category_id, + :deleted_at] end attributes :draft, diff --git a/app/views/exceptions/not_found.html.erb b/app/views/exceptions/not_found.html.erb index f5852f326..478400dc3 100644 --- a/app/views/exceptions/not_found.html.erb +++ b/app/views/exceptions/not_found.html.erb @@ -1,31 +1,43 @@ <% local_domain = "#{request.protocol}#{request.host_with_port}" %>

<%= t 'page_not_found.title' %>

- - - - - -
-

<%= t 'page_not_found.popular_topics' %>

- <% @top_viewed.each do |t| %> - <%= link_to t.title, t.relative_url %>
- <% end %> -
- <%= t 'page_not_found.see_more' %>… -
-

<%= t 'page_not_found.recent_topics' %>

- <% @recent.each do |t| %> - <%= link_to t.title, t.relative_url %>
- <% end %> -
- <%= t 'page_not_found.see_more' %>… -
-

<%= t 'page_not_found.search_title' %>

-

-

- - - -
-

+ +
+
+

<%= t 'page_not_found.popular_topics' %>

+ <% @top_viewed.each do |t| %> + <%= link_to t.title, t.relative_url %>
+ <% end %> +
+ <%= t 'page_not_found.see_more' %>… +
+
+

<%= t 'page_not_found.recent_topics' %>

+ <% @recent.each do |t| %> + <%= link_to t.title, t.relative_url %>
+ <% end %> +
+ <%= t 'page_not_found.see_more' %>… +
+
+ +
+
+

<%= t 'page_not_found.search_title' %>

+

+

+

+
+
+ + \ No newline at end of file diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c3e264ad9..ea940cb0c 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1052,7 +1052,7 @@ en: access_token_problem: "Tell an admin: Please update the site settings to include the correct discourse_org_access_key." page_not_found: - title: "The page you requested doesn't exist on this discussion forum. Perhaps we can help find it, or another topic like it:" + title: "The page you requested doesn't exist or may have been deleted by a moderator." popular_topics: "Popular topics" recent_topics: "Recent topics" see_more: "See More" diff --git a/lib/guardian.rb b/lib/guardian.rb index ad3ba5470..d5a24d71c 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -242,7 +242,11 @@ class Guardian end def can_create_post_on_topic?(topic) - is_staff? || (not(topic.closed? || topic.archived?) && can_create_post?(topic)) + + # No users can create posts on deleted topics + return false if topic.trashed? + + is_staff? || (not(topic.closed? || topic.archived? || topic.trashed?) && can_create_post?(topic)) end # Editing Methods @@ -283,7 +287,9 @@ class Guardian end def can_delete_topic?(topic) - is_staff? && not(Category.exists?(topic_id: topic.id)) + !topic.trashed? && + is_staff? && + !(Category.exists?(topic_id: topic.id)) end def can_delete_post_action?(post_action) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 506b7e8fe..ecbdec529 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -8,17 +8,18 @@ class TopicView attr_accessor :draft, :draft_key, :draft_sequence def initialize(topic_id, user=nil, options={}) + @user = user @topic = find_topic(topic_id) + raise Discourse::NotFound if @topic.blank? - @guardian = Guardian.new(user) + @guardian = Guardian.new(@user) # Special case: If the topic is private and the user isn't logged in, ask them # to log in! - if @topic.present? && @topic.private_message? && user.blank? + if @topic.present? && @topic.private_message? && @user.blank? raise Discourse::NotLoggedIn.new end - guardian.ensure_can_see!(@topic) @post_number, @page = options[:post_number], options[:page].to_i @@ -36,14 +37,13 @@ class TopicView @filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames) end - @user = user @initial_load = true @index_reverse = false filter_posts(options) @draft_key = @topic.draft_key - @draft_sequence = DraftSequence.current(user, @draft_key) + @draft_sequence = DraftSequence.current(@user, @draft_key) end def canonical_path @@ -317,6 +317,8 @@ class TopicView end def find_topic(topic_id) - Topic.where(id: topic_id).includes(:category).first + finder = Topic.where(id: topic_id).includes(:category) + finder = finder.with_deleted if @user.try(:staff?) + finder.first end end diff --git a/lib/trashable.rb b/lib/trashable.rb index 2d763c33f..37580e4d3 100644 --- a/lib/trashable.rb +++ b/lib/trashable.rb @@ -5,7 +5,6 @@ module Trashable default_scope where(with_deleted_scope_sql) # scope unscoped does not work - belongs_to :deleted_by, class_name: 'User' end @@ -26,6 +25,10 @@ module Trashable end end + def trashed? + deleted_at.present? + end + def trash!(trashed_by=nil) # note, an argument could be made that the column should probably called trashed_at # however, deleted_at is the terminology used in the UI diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index a5587cdc1..0eb163bfd 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -318,7 +318,6 @@ describe Guardian do end context 'regular users' do - it "doesn't allow new posts from regular users" do Guardian.new(coding_horror).can_create?(Post, topic).should be_false end @@ -326,7 +325,6 @@ describe Guardian do it 'allows editing of posts' do Guardian.new(coding_horror).can_edit?(post).should be_false end - end it "allows new posts from moderators" do @@ -338,6 +336,26 @@ describe Guardian do end end + context "trashed topic" do + before do + topic.deleted_at = Time.now + end + + it "doesn't allow new posts from regular users" do + Guardian.new(coding_horror).can_create?(Post, topic).should be_false + end + + it "doesn't allow new posts from moderators users" do + Guardian.new(moderator).can_create?(Post, topic).should be_false + end + + it "doesn't allow new posts from admins" do + Guardian.new(admin).can_create?(Post, topic).should be_false + end + + end + + end end diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index a12d4a14e..f769092fa 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -18,6 +18,14 @@ describe TopicView do lambda { topic_view }.should raise_error(Discourse::InvalidAccess) end + it "handles deleted topics" do + topic.trash!(coding_horror) + lambda { TopicView.new(topic.id, coding_horror) }.should raise_error(Discourse::NotFound) + coding_horror.stubs(:staff?).returns(true) + lambda { TopicView.new(topic.id, coding_horror) }.should_not raise_error + end + + context "with a few sample posts" do let!(:p1) { Fabricate(:post, topic: topic, user: first_poster, percent_rank: 1 )} let!(:p2) { Fabricate(:post, topic: topic, user: coding_horror, percent_rank: 0.5 )} diff --git a/test/javascripts/models/topic_test.js b/test/javascripts/models/topic_test.js index 38f9ff87c..e29faa70d 100644 --- a/test/javascripts/models/topic_test.js +++ b/test/javascripts/models/topic_test.js @@ -1,5 +1,11 @@ module("Discourse.Topic"); +test("defaults", function() { + var topic = Discourse.Topic.create({id: 1234}); + blank(topic.get('deleted_at'), 'deleted_at defaults to blank'); + blank(topic.get('deleted_by'), 'deleted_by defaults to blank'); +}); + test('has details', function() { var topic = Discourse.Topic.create({id: 1234}); var topicDetails = topic.get('details'); @@ -36,4 +42,16 @@ test("updateFromJson", function() { equal(topic.get('details.hello'), 'world', 'it updates the details'); equal(topic.get('cool'), "property", "it updates other properties"); equal(topic.get('category'), category); +}); + +test("destroy", function() { + var topic = Discourse.Topic.create({id: 1234}); + var user = Discourse.User.create({username: 'eviltrout'}); + + this.stub(Discourse, 'ajax'); + + topic.destroy(user); + present(topic.get('deleted_at'), 'deleted at is set'); + equal(topic.get('deleted_by'), user, 'deleted by is set'); + ok(Discourse.ajax.calledOnce, "it called delete over the wire"); }); \ No newline at end of file