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}}
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.search_title' %>
-
-
-
+
+
+
+
+
+
<%= 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