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'),
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(this.get('text'));
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.resource('topicBySlug', { path: '/t/:slug' });
this.route('topicUnsubscribe', { path: '/t/:slug/:id/unsubscribe' });
this.resource('discovery', { path: '/' }, function() {
// top

View File

@ -2,32 +2,32 @@
export default Discourse.Route.extend({
// Avoid default model hook
model: function(p) { return p; },
model(params) { return params; },
setupController: function(controller, params) {
setupController(controller, params) {
params = params || {};
params.track_visit = true;
var topic = this.modelFor('topic'),
postStream = topic.get('postStream');
var topicController = this.controllerFor('topic'),
topicProgressController = this.controllerFor('topic-progress'),
composerController = this.controllerFor('composer');
const self = this,
topic = this.modelFor('topic'),
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
if (params.nearPost === "last") { params.nearPost = 999999999; }
var self = this;
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
// we need better handling and logging for this condition.
// The post we requested might not exist. Let's find the closest post
var closestPost = postStream.closestPostForPostNumber(params.nearPost || 1),
closest = closestPost.get('post_number'),
progress = postStream.progressIndexOfPost(closestPost);
const closestPost = postStream.closestPostForPostNumber(params.nearPost || 1),
closest = closestPost.get('post_number'),
progress = postStream.progressIndexOfPost(closestPost);
topicController.setProperties({
'model.currentPost': closest,
@ -43,6 +43,7 @@ export default Discourse.Route.extend({
Ember.run.scheduleOnce('afterRender', function() {
self.appEvents.trigger('post:highlight', closest);
});
Discourse.URL.jumpToPost(closest);
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.
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,
:reset_new,
:change_post_owners,
:bookmark]
:bookmark,
:unsubscribe]
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
topic = Topic.find_by(slug: params[:slug].downcase)
@ -94,6 +95,26 @@ class TopicsController < ApplicationController
raise ex
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
params.require(:best)
params.require(:topic_id)
@ -476,6 +497,7 @@ class TopicsController < ApplicationController
format.html do
@description_meta = @topic_view.topic.excerpt
store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
render :show
end
format.json do

View File

@ -292,6 +292,7 @@ class UserNotifications < ActionMailer::Base
context: context,
username: username,
add_unsubscribe_link: true,
unsubscribe_url: post.topic.unsubscribe_url,
allow_reply_by_email: allow_reply_by_email,
use_site_subject: use_site_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 from_alias.present?
email_opts[:from_alias] = from_alias
end
email_opts[:from_alias] = from_alias if from_alias.present?
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
end
def unsubscribe_url
"#{url}/unsubscribe"
end
def clear_pin_for(user)
return unless user.present?
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|
where(topic_id: topic_id)
.where("COALESCE(topic_users.notification_level, :regular) >= :tracking",
regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking])
.where("COALESCE(topic_users.notification_level, :regular) >= :tracking",
regular: TopicUser.notification_levels[:regular],
tracking: TopicUser.notification_levels[:tracking])
}
# Class methods
@ -58,13 +59,9 @@ class TopicUser < ActiveRecord::Base
def create_lookup(topic_users)
topic_users = topic_users.to_a
result = {}
return result if topic_users.blank?
topic_users.each do |ftu|
result[ftu.topic_id] = ftu
end
topic_users.each { |ftu| result[ftu.topic_id] = ftu }
result
end
@ -113,11 +110,9 @@ class TopicUser < ActiveRecord::Base
end
if attrs[:notification_level]
MessageBus.publish("/topic/#{topic_id}",
{notification_level_change: attrs[:notification_level]}, user_ids: [user_id])
MessageBus.publish("/topic/#{topic_id}", { notification_level_change: attrs[:notification_level] }, user_ids: [user_id])
end
rescue ActiveRecord::RecordNotUnique
# In case of a race condition to insert, do nothing
end
@ -127,7 +122,7 @@ class TopicUser < ActiveRecord::Base
user_id = user.is_a?(User) ? user.id : topic
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
TopicUser.create(topic_id: topic_id, user_id: user_id, last_visited_at: now, first_visited_at: now)
else
@ -196,7 +191,7 @@ class TopicUser < ActiveRecord::Base
end
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
@ -220,7 +215,7 @@ class TopicUser < ActiveRecord::Base
WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)",
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

View File

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

View File

@ -981,6 +981,9 @@ en:
search: "There are no more search results."
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"
create: 'New Topic'
create_long: 'Create a new Topic'
@ -1014,7 +1017,6 @@ en:
new_posts:
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"
likes:
one: "there is 1 like in this topic"
other: "there are {{count}} likes in this topic"

View File

@ -1849,7 +1849,10 @@ en:
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."
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_pm: "[PM] "

View File

@ -434,10 +434,12 @@ Discourse::Application.routes.draw do
# Topic routes
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/moderator-liked" => "topics#moderator_liked", 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/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/}
get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\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/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
@opts = opts || {}
@template_args = {site_name: SiteSetting.email_prefix.presence || SiteSetting.title,
base_url: Discourse.base_url,
user_preferences_url: "#{Discourse.base_url}/my/preferences" }.merge!(@opts)
@template_args = {
site_name: SiteSetting.email_prefix.presence || SiteSetting.title,
base_url: Discourse.base_url,
user_preferences_url: "#{Discourse.base_url}/my/preferences",
}.merge!(@opts)
if @template_args[:url].present?
if @opts[:include_respond_instructions] == false
@template_args[:respond_instructions] = ''
else
@template_args[:respond_instructions] =
if allow_reply_by_email?
I18n.t('user_notifications.reply_by_email', @template_args)
else
I18n.t('user_notifications.visit_link_to_respond', @template_args)
end
@template_args[:respond_instructions] = if allow_reply_by_email?
I18n.t('user_notifications.reply_by_email', @template_args)
else
I18n.t('user_notifications.visit_link_to_respond', @template_args)
end
end
end
end
@ -56,15 +57,15 @@ module Email
def html_part
return unless html_override = @opts[:html_override]
if @opts[:add_unsubscribe_link]
if @opts[:add_unsubscribe_link]
if response_instructions = @template_args[:respond_instructions]
respond_instructions = PrettyText.cook(response_instructions).html_safe
html_override.gsub!("%{respond_instructions}", respond_instructions)
end
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
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,
body: 'hello world',
add_unsubscribe_link: true) }
add_unsubscribe_link: true,
unsubscribe_url: "/t/1234/unsubscribe") }
it "has an List-Unsubscribe header" do
expect(message_with_unsubscribe.header_args['List-Unsubscribe']).to be_present