FEATURE: per-topic unsubscribe option in emails

This commit is contained in:
Régis Hanol 2015-08-12 23:00:16 +02:00
parent 56abd247e1
commit 6669a2d94d
18 changed files with 156 additions and 69 deletions

View File

@ -24,8 +24,11 @@ export default Ember.Component.extend(StringBuffer, {
}.on('willDestroyElement'), }.on('willDestroyElement'),
renderString(buffer) { renderString(buffer) {
const title = this.get('title');
if (title) {
buffer.push("<h4 class='title'>" + title + "</h4>");
}
buffer.push("<h4 class='title'>" + this.get('title') + "</h4>");
buffer.push("<button class='btn standard dropdown-toggle' data-toggle='dropdown'>"); buffer.push("<button class='btn standard dropdown-toggle' data-toggle='dropdown'>");
buffer.push(this.get('text')); buffer.push(this.get('text'));
buffer.push("</button>"); buffer.push("</button>");

View File

@ -0,0 +1,9 @@
import ObjectController from "discourse/controllers/object";
export default ObjectController.extend({
stopNotificiationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),
})

View File

@ -10,6 +10,7 @@ export default function() {
this.route('fromParamsNear', { path: '/:nearPost' }); this.route('fromParamsNear', { path: '/:nearPost' });
}); });
this.resource('topicBySlug', { path: '/t/:slug' }); this.resource('topicBySlug', { path: '/t/:slug' });
this.route('topicUnsubscribe', { path: '/t/:slug/:id/unsubscribe' });
this.resource('discovery', { path: '/' }, function() { this.resource('discovery', { path: '/' }, function() {
// top // top

View File

@ -2,32 +2,32 @@
export default Discourse.Route.extend({ export default Discourse.Route.extend({
// Avoid default model hook // Avoid default model hook
model: function(p) { return p; }, model(params) { return params; },
setupController: function(controller, params) { setupController(controller, params) {
params = params || {}; params = params || {};
params.track_visit = true; params.track_visit = true;
var topic = this.modelFor('topic'),
postStream = topic.get('postStream');
var topicController = this.controllerFor('topic'), const self = this,
topicProgressController = this.controllerFor('topic-progress'), topic = this.modelFor('topic'),
composerController = this.controllerFor('composer'); postStream = topic.get('postStream'),
topicController = this.controllerFor('topic'),
topicProgressController = this.controllerFor('topic-progress'),
composerController = this.controllerFor('composer');
// I sincerely hope no topic gets this many posts // I sincerely hope no topic gets this many posts
if (params.nearPost === "last") { params.nearPost = 999999999; } if (params.nearPost === "last") { params.nearPost = 999999999; }
var self = this;
params.forceLoad = true; params.forceLoad = true;
postStream.refresh(params).then(function () {
postStream.refresh(params).then(function () {
// TODO we are seeing errors where closest post is null and this is exploding // TODO we are seeing errors where closest post is null and this is exploding
// we need better handling and logging for this condition. // we need better handling and logging for this condition.
// The post we requested might not exist. Let's find the closest post // The post we requested might not exist. Let's find the closest post
var closestPost = postStream.closestPostForPostNumber(params.nearPost || 1), const closestPost = postStream.closestPostForPostNumber(params.nearPost || 1),
closest = closestPost.get('post_number'), closest = closestPost.get('post_number'),
progress = postStream.progressIndexOfPost(closestPost); progress = postStream.progressIndexOfPost(closestPost);
topicController.setProperties({ topicController.setProperties({
'model.currentPost': closest, 'model.currentPost': closest,
@ -43,6 +43,7 @@ export default Discourse.Route.extend({
Ember.run.scheduleOnce('afterRender', function() { Ember.run.scheduleOnce('afterRender', function() {
self.appEvents.trigger('post:highlight', closest); self.appEvents.trigger('post:highlight', closest);
}); });
Discourse.URL.jumpToPost(closest); Discourse.URL.jumpToPost(closest);
if (topic.present('draft')) { if (topic.present('draft')) {

View File

@ -0,0 +1,23 @@
import PostStream from "discourse/models/post-stream";
export default Discourse.Route.extend({
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {
topic.updateFromJson(json);
return topic;
});
},
afterModel(topic) {
// hide the notification reason text
topic.set("details.notificationReasonText", null);
},
actions: {
didTransition() {
this.controllerFor("application").set("showFooter", true);
return true;
}
}
});

View File

@ -0,0 +1,8 @@
<div class="container">
<p>
{{{stopNotificiationsText}}}
</p>
<p>
{{i18n "topic.unsubscribe.change_notification_state"}} {{topic-notifications-button topic=model}}
</p>
</div>

View File

@ -0,0 +1,3 @@
export default Discourse.View.extend({
classNames: ["topic-unsubscribe"]
});

View File

@ -63,3 +63,15 @@
// Top of bullet aligns with top of line - adjust line height to vertically align bullet. // Top of bullet aligns with top of line - adjust line height to vertically align bullet.
line-height: 0.8; line-height: 0.8;
} }
.topic-unsubscribe {
.notification-options {
display: inline-block;
.dropdown-toggle {
float: none;
}
.dropdown-menu {
bottom: initial;
}
}
}

View File

@ -24,11 +24,12 @@ class TopicsController < ApplicationController
:bulk, :bulk,
:reset_new, :reset_new,
:change_post_owners, :change_post_owners,
:bookmark] :bookmark,
:unsubscribe]
before_filter :consider_user_for_promotion, only: :show before_filter :consider_user_for_promotion, only: :show
skip_before_filter :check_xhr, only: [:show, :feed] skip_before_filter :check_xhr, only: [:show, :unsubscribe, :feed]
def id_for_slug def id_for_slug
topic = Topic.find_by(slug: params[:slug].downcase) topic = Topic.find_by(slug: params[:slug].downcase)
@ -94,6 +95,26 @@ class TopicsController < ApplicationController
raise ex raise ex
end end
def unsubscribe
@topic_view = TopicView.new(params[:topic_id], current_user)
if slugs_do_not_match || (!request.format.json? && params[:slug].blank?)
return redirect_to @topic_view.topic.unsubscribe_url, status: 301
end
tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])
if tu.notification_level > TopicUser.notification_levels[:regular]
tu.notification_level = TopicUser.notification_levels[:regular]
else
tu.notification_level = TopicUser.notification_levels[:muted]
end
tu.save!
perform_show_response
end
def wordpress def wordpress
params.require(:best) params.require(:best)
params.require(:topic_id) params.require(:topic_id)
@ -476,6 +497,7 @@ class TopicsController < ApplicationController
format.html do format.html do
@description_meta = @topic_view.topic.excerpt @description_meta = @topic_view.topic.excerpt
store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer)) store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
render :show
end end
format.json do format.json do

View File

@ -292,6 +292,7 @@ class UserNotifications < ActionMailer::Base
context: context, context: context,
username: username, username: username,
add_unsubscribe_link: true, add_unsubscribe_link: true,
unsubscribe_url: post.topic.unsubscribe_url,
allow_reply_by_email: allow_reply_by_email, allow_reply_by_email: allow_reply_by_email,
use_site_subject: use_site_subject, use_site_subject: use_site_subject,
add_re_to_subject: add_re_to_subject, add_re_to_subject: add_re_to_subject,
@ -306,9 +307,7 @@ class UserNotifications < ActionMailer::Base
} }
# If we have a display name, change the from address # If we have a display name, change the from address
if from_alias.present? email_opts[:from_alias] = from_alias if from_alias.present?
email_opts[:from_alias] = from_alias
end
TopicUser.change(user.id, post.topic_id, last_emailed_post_number: post.post_number) TopicUser.change(user.id, post.topic_id, last_emailed_post_number: post.post_number)

View File

@ -716,6 +716,10 @@ class Topic < ActiveRecord::Base
url url
end end
def unsubscribe_url
"#{url}/unsubscribe"
end
def clear_pin_for(user) def clear_pin_for(user)
return unless user.present? return unless user.present?
TopicUser.change(user.id, id, cleared_pinned_at: Time.now) TopicUser.change(user.id, id, cleared_pinned_at: Time.now)

View File

@ -7,8 +7,9 @@ class TopicUser < ActiveRecord::Base
scope :tracking, lambda { |topic_id| scope :tracking, lambda { |topic_id|
where(topic_id: topic_id) where(topic_id: topic_id)
.where("COALESCE(topic_users.notification_level, :regular) >= :tracking", .where("COALESCE(topic_users.notification_level, :regular) >= :tracking",
regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) regular: TopicUser.notification_levels[:regular],
tracking: TopicUser.notification_levels[:tracking])
} }
# Class methods # Class methods
@ -58,13 +59,9 @@ class TopicUser < ActiveRecord::Base
def create_lookup(topic_users) def create_lookup(topic_users)
topic_users = topic_users.to_a topic_users = topic_users.to_a
result = {} result = {}
return result if topic_users.blank? return result if topic_users.blank?
topic_users.each { |ftu| result[ftu.topic_id] = ftu }
topic_users.each do |ftu|
result[ftu.topic_id] = ftu
end
result result
end end
@ -113,11 +110,9 @@ class TopicUser < ActiveRecord::Base
end end
if attrs[:notification_level] if attrs[:notification_level]
MessageBus.publish("/topic/#{topic_id}", MessageBus.publish("/topic/#{topic_id}", { notification_level_change: attrs[:notification_level] }, user_ids: [user_id])
{notification_level_change: attrs[:notification_level]}, user_ids: [user_id])
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
# In case of a race condition to insert, do nothing # In case of a race condition to insert, do nothing
end end
@ -127,7 +122,7 @@ class TopicUser < ActiveRecord::Base
user_id = user.is_a?(User) ? user.id : topic user_id = user.is_a?(User) ? user.id : topic
now = DateTime.now now = DateTime.now
rows = TopicUser.where({topic_id: topic_id, user_id: user_id}).update_all({last_visited_at: now}) rows = TopicUser.where(topic_id: topic_id, user_id: user_id).update_all(last_visited_at: now)
if rows == 0 if rows == 0
TopicUser.create(topic_id: topic_id, user_id: user_id, last_visited_at: now, first_visited_at: now) TopicUser.create(topic_id: topic_id, user_id: user_id, last_visited_at: now, first_visited_at: now)
else else
@ -196,7 +191,7 @@ class TopicUser < ActiveRecord::Base
end end
if before != after if before != after
MessageBus.publish("/topic/#{topic_id}", {notification_level_change: after}, user_ids: [user.id]) MessageBus.publish("/topic/#{topic_id}", { notification_level_change: after }, user_ids: [user.id])
end end
end end
@ -220,7 +215,7 @@ class TopicUser < ActiveRecord::Base
WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)", WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)",
args) args)
MessageBus.publish("/topic/#{topic_id}", {notification_level_change: args[:new_status]}, user_ids: [user.id]) MessageBus.publish("/topic/#{topic_id}", { notification_level_change: args[:new_status] }, user_ids: [user.id])
end end
end end

View File

@ -1,31 +1,29 @@
<div id='main' class=<%= classes %>> <div id='main' class=<%= classes %>>
<%= render :partial => 'email/post', :locals => {:post => post} %> <%= render partial: 'email/post', locals: { post: post } %>
<% if context_posts.present? %> <% if context_posts.present? %>
<div class='footer'> <div class='footer'>%{respond_instructions}</div>
%{respond_instructions}
</div>
<hr>
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
<% context_posts.each do |p| %> <hr>
<%= render :partial => 'email/post', :locals => {:post => p} %>
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
<% context_posts.each do |p| %>
<%= render partial: 'email/post', locals: { post: p } %>
<% end %>
<% end %> <% end %>
<% end %>
<hr> <hr>
<div class='footer'>%{respond_instructions}</div>
<div class='footer'>%{unsubscribe_link}</div>
<div class='footer'>
%{respond_instructions}
</div>
<div class='footer'>
%{unsubscribe_link}
</div>
</div> </div>
<div itemscope itemtype="http://schema.org/EmailMessage" style="display:none"> <div itemscope itemtype="http://schema.org/EmailMessage" style="display:none">
<div itemprop="action" itemscope itemtype="http://schema.org/ViewAction"> <div itemprop="action" itemscope itemtype="http://schema.org/ViewAction">
<link itemprop="url" href="<%= Discourse.base_url %><%= post.url %>" /> <link itemprop="url" href="<%= Discourse.base_url %><%= post.url %>" />
<meta itemprop="name" content="<%= t 'read_full_topic' %>"/> <meta itemprop="name" content="<%= t 'read_full_topic' %>"/>
</div> </div>
</div> </div>

View File

@ -981,6 +981,9 @@ en:
search: "There are no more search results." search: "There are no more search results."
topic: topic:
unsubscribe:
stop_notifications: "You will stop receiving notifications for <strong>{{title}}</strong>."
change_notification_state: "You can change your notification state"
filter_to: "{{post_count}} posts in topic" filter_to: "{{post_count}} posts in topic"
create: 'New Topic' create: 'New Topic'
create_long: 'Create a new Topic' create_long: 'Create a new Topic'
@ -1014,7 +1017,6 @@ en:
new_posts: new_posts:
one: "there is 1 new post in this topic since you last read it" one: "there is 1 new post in this topic since you last read it"
other: "there are {{count}} new posts in this topic since you last read it" other: "there are {{count}} new posts in this topic since you last read it"
likes: likes:
one: "there is 1 like in this topic" one: "there is 1 like in this topic"
other: "there are {{count}} likes in this topic" other: "there are {{count}} likes in this topic"

View File

@ -1849,7 +1849,10 @@ en:
subject_template: "Downloading remote images disabled" subject_template: "Downloading remote images disabled"
text_body_template: "The `download_remote_images_to_local` setting was disabled because the disk space limit at `download_remote_images_threshold` was reached." text_body_template: "The `download_remote_images_to_local` setting was disabled because the disk space limit at `download_remote_images_threshold` was reached."
unsubscribe_link: "To unsubscribe from these emails, visit your [user preferences](%{user_preferences_url})." unsubscribe_link: |
To unsubscribe from these emails, visit your [user preferences](%{user_preferences_url}).
To stop receiving notifications about this particular topic, [click here](%{unsubscribe_url}).
subject_re: "Re: " subject_re: "Re: "
subject_pm: "[PM] " subject_pm: "[PM] "

View File

@ -434,10 +434,12 @@ Discourse::Application.routes.draw do
# Topic routes # Topic routes
get "t/id_for/:slug" => "topics#id_for_slug" get "t/id_for/:slug" => "topics#id_for_slug"
get "t/:slug/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/} get "t/:slug/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/}
get "t/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/} get "t/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/summary" => "topics#show", defaults: {summary: true}, constraints: {topic_id: /\d+/, post_number: /\d+/} get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/}
get "t/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/} get "t/:slug/:topic_id/summary" => "topics#show", defaults: {summary: true}, constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: {topic_id: /\d+/}
get "t/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: {topic_id: /\d+/}
get "t/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/}
put "t/:slug/:topic_id" => "topics#update", constraints: {topic_id: /\d+/} put "t/:slug/:topic_id" => "topics#update", constraints: {topic_id: /\d+/}
put "t/:slug/:topic_id/star" => "topics#star", constraints: {topic_id: /\d+/} put "t/:slug/:topic_id/star" => "topics#star", constraints: {topic_id: /\d+/}
put "t/:topic_id/star" => "topics#star", constraints: {topic_id: /\d+/} put "t/:topic_id/star" => "topics#star", constraints: {topic_id: /\d+/}

View File

@ -21,20 +21,21 @@ module Email
@to = to @to = to
@opts = opts || {} @opts = opts || {}
@template_args = {site_name: SiteSetting.email_prefix.presence || SiteSetting.title, @template_args = {
base_url: Discourse.base_url, site_name: SiteSetting.email_prefix.presence || SiteSetting.title,
user_preferences_url: "#{Discourse.base_url}/my/preferences" }.merge!(@opts) base_url: Discourse.base_url,
user_preferences_url: "#{Discourse.base_url}/my/preferences",
}.merge!(@opts)
if @template_args[:url].present? if @template_args[:url].present?
if @opts[:include_respond_instructions] == false if @opts[:include_respond_instructions] == false
@template_args[:respond_instructions] = '' @template_args[:respond_instructions] = ''
else else
@template_args[:respond_instructions] = @template_args[:respond_instructions] = if allow_reply_by_email?
if allow_reply_by_email? I18n.t('user_notifications.reply_by_email', @template_args)
I18n.t('user_notifications.reply_by_email', @template_args) else
else I18n.t('user_notifications.visit_link_to_respond', @template_args)
I18n.t('user_notifications.visit_link_to_respond', @template_args) end
end
end end
end end
end end
@ -56,15 +57,15 @@ module Email
def html_part def html_part
return unless html_override = @opts[:html_override] return unless html_override = @opts[:html_override]
if @opts[:add_unsubscribe_link]
if @opts[:add_unsubscribe_link]
if response_instructions = @template_args[:respond_instructions] if response_instructions = @template_args[:respond_instructions]
respond_instructions = PrettyText.cook(response_instructions).html_safe respond_instructions = PrettyText.cook(response_instructions).html_safe
html_override.gsub!("%{respond_instructions}", respond_instructions) html_override.gsub!("%{respond_instructions}", respond_instructions)
end end
unsubscribe_link = PrettyText.cook(I18n.t('unsubscribe_link', template_args)).html_safe unsubscribe_link = PrettyText.cook(I18n.t('unsubscribe_link', template_args)).html_safe
html_override.gsub!("%{unsubscribe_link}",unsubscribe_link) html_override.gsub!("%{unsubscribe_link}", unsubscribe_link)
end end
styled = Email::Styles.new(html_override) styled = Email::Styles.new(html_override)

View File

@ -168,7 +168,8 @@ describe Email::MessageBuilder do
let(:message_with_unsubscribe) { Email::MessageBuilder.new(to_address, let(:message_with_unsubscribe) { Email::MessageBuilder.new(to_address,
body: 'hello world', body: 'hello world',
add_unsubscribe_link: true) } add_unsubscribe_link: true,
unsubscribe_url: "/t/1234/unsubscribe") }
it "has an List-Unsubscribe header" do it "has an List-Unsubscribe header" do
expect(message_with_unsubscribe.header_args['List-Unsubscribe']).to be_present expect(message_with_unsubscribe.header_args['List-Unsubscribe']).to be_present