Merge pull request #3313 from techAPJ/patch-1

FEATURE: invite existing user to a topic
This commit is contained in:
Régis Hanol 2015-04-03 11:41:10 +02:00
commit 0e1c4a0a44
16 changed files with 137 additions and 113 deletions

View File

@ -1,47 +0,0 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import ObjectController from 'discourse/controllers/object';
export default ObjectController.extend(ModalFunctionality, {
modalClass: 'invite',
isAdmin: function(){
return Discourse.User.currentProp("admin");
}.property(),
onShow: function(){
this.set('controllers.modal.modalClass', 'invite-modal');
this.set('emailOrUsername', '');
},
disabled: function() {
if (this.get('saving')) return true;
return this.blank('emailOrUsername');
}.property('emailOrUsername', 'saving'),
buttonTitle: function() {
if (this.get('saving')) return I18n.t('topic.inviting');
return I18n.t('topic.invite_private.action');
}.property('saving'),
actions: {
invite: function() {
if (this.get('disabled')) return;
var self = this;
this.setProperties({saving: true, error: false});
// Invite the user to the private message
this.get('model').createInvite(this.get('emailOrUsername')).then(function(result) {
self.setProperties({saving: true, finished: true});
if(result && result.user) {
self.get('model.details.allowed_users').pushObject(result.user);
}
}).catch(function() {
self.setProperties({error: true, saving: false});
});
return false;
}
}
});

View File

@ -6,7 +6,7 @@ export default ObjectController.extend(ModalFunctionality, {
// If this isn't defined, it will proxy to the user model on the preferences
// page which is wrong.
email: null,
emailOrUsername: null,
isAdmin: function(){
return Discourse.User.currentProp("admin");
@ -19,12 +19,12 @@ export default ObjectController.extend(ModalFunctionality, {
**/
disabled: function() {
if (this.get('saving')) return true;
if (this.blank('email')) return true;
if (!Discourse.Utilities.emailValid(this.get('email'))) return true;
if (this.blank('emailOrUsername')) return true;
if ( !this.get('invitingToTopic') && !Discourse.Utilities.emailValid(this.get('emailOrUsername')) ) return true;
if (this.get('model.details.can_invite_to')) return false;
if (this.get('isPrivateTopic') && this.blank('groupNames')) return true;
return false;
}.property('email', 'isPrivateTopic', 'groupNames', 'saving'),
}.property('emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'groupNames', 'saving'),
/**
The current text for the invite button
@ -53,18 +53,45 @@ export default ObjectController.extend(ModalFunctionality, {
**/
isPrivateTopic: Em.computed.and('invitingToTopic', 'model.category.read_restricted'),
/**
Is Message?
@property isMessage
**/
isMessage: Em.computed.equal('model.archetype', 'private_message'),
/**
Allow Existing Members? (username autocomplete)
@property allowExistingMembers
**/
allowExistingMembers: function() {
return this.get('invitingToTopic') && !this.get('isPrivateTopic');
}.property('invitingToTopic', 'isPrivateTopic'),
/**
Show Groups? (add invited user to private group)
@property showGroups
**/
showGroups: function() {
return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic'));
}.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'invitingToTopic'),
/**
Instructional text for the modal.
@property inviteInstructions
**/
inviteInstructions: function() {
if (this.get('invitingToTopic')) {
if (this.get('isMessage')) {
return I18n.t('topic.invite_private.email_or_username');
} else if (this.get('invitingToTopic')) {
return I18n.t('topic.invite_reply.to_topic');
} else {
return I18n.t('topic.invite_reply.to_forum');
}
}.property('invitingToTopic'),
}.property('isMessage', 'invitingToTopic'),
/**
Instructional text for the group selection.
@ -92,8 +119,25 @@ export default ObjectController.extend(ModalFunctionality, {
@property successMessage
**/
successMessage: function() {
return I18n.t('topic.invite_reply.success', { email: this.get('email') });
}.property('email'),
if (this.get('isMessage')) {
return I18n.t('topic.invite_private.success');
} else {
return I18n.t('topic.invite_reply.success', { emailOrUsername: this.get('emailOrUsername') });
}
}.property('isMessage', 'emailOrUsername'),
/**
The "error" text for when the invite fails.
@property errorMessage
**/
errorMessage: function() {
if (this.get('isMessage')) {
return I18n.t('topic.invite_private.error');
} else {
return I18n.t('topic.invite_reply.error');
}
}.property('isMessage'),
/**
Reset the modal to allow a new user to be invited.
@ -102,7 +146,7 @@ export default ObjectController.extend(ModalFunctionality, {
**/
reset: function() {
this.setProperties({
email: null,
emailOrUsername: null,
groupNames: null,
error: false,
saving: false,
@ -126,13 +170,15 @@ export default ObjectController.extend(ModalFunctionality, {
var userInvitedController = this.get('controllers.user-invited');
this.setProperties({ saving: true, error: false });
this.get('model').createInvite(this.get('email'), groupNames).then(function() {
this.get('model').createInvite(this.get('emailOrUsername'), groupNames).then(function(result) {
self.setProperties({ saving: false, finished: true });
if (!self.get('invitingToTopic')) {
Discourse.Invite.findInvitedBy(Discourse.User.current()).then(function (invite_model) {
userInvitedController.set('model', invite_model);
userInvitedController.set('totalInvites', invite_model.invites.length);
});
} else if (self.get('isMessage') && result && result.user) {
self.get('model.details.allowed_users').pushObject(result.user);
}
}).catch(function() {
self.setProperties({ saving: false, error: true });

View File

@ -69,16 +69,6 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, {
this.controllerFor('invite').reset();
},
showPrivateInvite() {
showModal('invitePrivate', this.modelFor('topic'));
this.controllerFor('invitePrivate').setProperties({
email: null,
error: false,
saving: false,
finished: false
});
},
showHistory(post) {
showModal('history', post);
this.controllerFor('history').refresh(post.get("id"), "latest");

View File

@ -2,7 +2,7 @@
{{#if error}}
<div class="alert alert-error">
<button class="close" data-dismiss="alert">×</button>
{{i18n 'topic.invite_reply.error'}}
{{errorMessage}}
</div>
{{/if}}
@ -11,9 +11,13 @@
{{else}}
<label>{{inviteInstructions}}</label>
{{text-field value=email placeholderKey="topic.invite_reply.email_placeholder"}}
{{#if allowExistingMembers}}
{{user-selector single="true" allowAny=true usernames=emailOrUsername includeGroups="true" placeholderKey="topic.invite_private.email_or_username_placeholder"}}
{{else}}
{{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}}
{{#if isAdmin}}
{{#if showGroups}}
<label>{{{groupInstructions}}}</label>
{{group-selector groupFinder=groupFinder groupNames=groupNames placeholderKey="topic.invite_private.group_name"}}
{{/if}}

View File

@ -1,23 +0,0 @@
<div class="modal-body">
{{#if error}}
<div class="alert alert-error">
<button class="close" data-dismiss="alert">×</button>
{{i18n 'topic.invite_private.error'}}
</div>
{{/if}}
{{#if finished}}
{{i18n 'topic.invite_private.success'}}
{{else}}
<label>{{i18n 'topic.invite_private.email_or_username'}}</label>
{{user-selector single="true" allowAny=true usernames=emailOrUsername includeGroups="true" placeholderKey="topic.invite_private.email_or_username_placeholder"}}
{{/if}}
</div>
<div class="modal-footer">
{{#if finished}}
<button class='btn btn-primary' {{action "closeModal"}}>{{i18n 'close'}}</button>
{{else}}
<button class='btn btn-primary' {{bind-attr disabled="disabled"}} {{action "invite"}}>{{buttonTitle}}</button>
{{/if}}
</div>

View File

@ -1,6 +0,0 @@
import ModalBodyView from "discourse/views/modal-body";
export default ModalBodyView.extend({
templateName: 'modal/invite_private',
title: I18n.t('topic.invite_private.title')
});

View File

@ -4,9 +4,13 @@ export default ModalBodyView.extend({
templateName: 'modal/invite',
title: function() {
return this.get('controller.invitingToTopic') ?
I18n.t('topic.invite_reply.title') :
I18n.t('user.invited.create');
}.property('controller.invitingToTopic')
if (this.get('controller.isMessage')) {
return I18n.t('topic.invite_private.title');
} else if (this.get('controller.invitingToTopic')) {
return I18n.t('topic.invite_reply.title');
} else {
return I18n.t('user.invited.create');
}
}.property('controller.{invitingToTopic,isMessage}')
});

View File

@ -51,8 +51,7 @@ export default DiscourseContainerView.extend({
// If we have a private message
if (this.get('topic.isPrivateMessage')) {
container.attachViewWithArgs({ topic: topic, showPrivateInviteAction: 'showPrivateInvite' }, PrivateMessageMapComponent);
container.attachViewWithArgs({ topic: topic, showPrivateInviteAction: 'showInvite' }, PrivateMessageMapComponent);
}
}
});

View File

@ -124,6 +124,11 @@ class UserNotifications < ActionMailer::Base
notification_email(user, opts)
end
def user_invited_to_topic(user, opts)
opts[:show_category_in_subject] = true
notification_email(user, opts)
end
def mailing_list_notify(user, post)
send_notification_email(
title: post.topic.title,
@ -188,11 +193,12 @@ class UserNotifications < ActionMailer::Base
use_site_subject = opts[:use_site_subject]
add_re_to_subject = opts[:add_re_to_subject]
show_category_in_subject = opts[:show_category_in_subject]
original_username = @notification.data_hash[:original_username] || @notification.data_hash[:display_username]
send_notification_email(
title: title,
post: @post,
username: @notification.data_hash[:original_username],
username: original_username,
from_alias: user_name,
allow_reply_by_email: allow_reply_by_email,
use_site_subject: use_site_subject,

View File

@ -31,7 +31,7 @@ class Notification < ActiveRecord::Base
@types ||= Enum.new(
:mentioned, :replied, :quoted, :edited, :liked, :private_message,
:invited_to_private_message, :invitee_accepted, :posted, :moved_post,
:linked, :granted_badge
:linked, :granted_badge, :invited_to_topic
)
end

View File

@ -537,7 +537,7 @@ class Topic < ActiveRecord::Base
# Invite a user to the topic by username or email. Returns success/failure
def invite(invited_by, username_or_email, group_ids=nil)
if private_message?
# If the user exists, add them to the topic.
# If the user exists, add them to the message.
user = User.find_by_username_or_email(username_or_email)
if user && topic_allowed_users.create!(user_id: user.id)
@ -555,7 +555,20 @@ class Topic < ActiveRecord::Base
# NOTE callers expect an invite object if an invite was sent via email
invite_by_email(invited_by, username_or_email, group_ids)
else
false
# invite existing member to a topic
user = User.find_by_username_or_email(username_or_email)
if user && topic_allowed_users.create!(user_id: user.id)
# Notify the user they've been invited
user.notifications.create(notification_type: Notification.types[:invited_to_topic],
topic_id: id,
post_number: 1,
data: { topic_title: title,
display_username: invited_by.username }.to_json)
return true
else
false
end
end
end

View File

@ -32,6 +32,10 @@ class UserEmailObserver < ActiveRecord::Observer
enqueue :user_invited_to_private_message
end
def invited_to_topic
enqueue :user_invited_to_topic
end
private
def enqueue(type)

View File

@ -484,7 +484,7 @@ en:
weekly: "weekly"
every_two_weeks: "every two weeks"
email_direct: "Send me an email when someone quotes me, replies to my post, or mentions my @username"
email_direct: "Send me an email when someone quotes me, replies to my post, or mentions my @username or invites me to a topic"
email_private_messages: "Send me an email when someone messages me"
email_always: "Do not suppress email notifications when I am active on the site"
@ -789,6 +789,7 @@ en:
liked: "<i title='liked' class='fa fa-heart'></i><p><span>{{username}}</span> {{description}}</p>"
private_message: "<i title='private message' class='fa fa-envelope-o'></i><p><span>{{username}}</span> {{description}}</p>"
invited_to_private_message: "<i title='private message' class='fa fa-envelope-o'></i><p><span>{{username}}</span> {{description}}</p>"
invited_to_topic: "<i title='invited to topic' class='fa fa-envelope-o'></i><p><span>{{username}}</span> {{description}}</p>"
invitee_accepted: "<i title='accepted your invitation' class='fa fa-user'></i><p><span>{{username}}</span> accepted your invitation</p>"
moved_post: "<i title='moved post' class='fa fa-sign-out'></i><p><span>{{username}}</span> moved {{description}}</p>"
linked: "<i title='linked post' class='fa fa-arrow-left'></i><p><span>{{username}}</span> {{description}}</p>"
@ -1072,14 +1073,14 @@ en:
invite_reply:
title: 'Invite'
action: 'Email Invite'
action: 'Send Invite'
help: 'send invitations to friends so they can reply to this topic with a single click'
to_topic: "We'll send a brief email allowing your friend to immediately join and reply to this topic by clicking a link, no login required."
to_forum: "We'll send a brief email allowing your friend to immediately join by clicking a link, no login required."
email_placeholder: 'name@example.com'
success: "We mailed out an invitation to <b>{{email}}</b>. We'll notify you when the invitation is redeemed. Check the invitations tab on your user page to keep track of your invites."
error: "Sorry, we couldn't invite that person. Perhaps they are already a user? (Invites are rate limited)"
success: "We mailed out an invitation to <b>{{emailOrUsername}}</b>. We'll notify you when the invitation is redeemed. Check the invitations tab on your user page to keep track of your invites."
error: "Sorry, we couldn't invite that person. Perhaps they have already been invited? (Invites are rate limited)"
login_reply: 'Log In to Reply'

View File

@ -1158,6 +1158,7 @@ en:
moved_post: "%{display_username} moved your post to %{link}"
private_message: "%{display_username} sent you a message: %{link}"
invited_to_private_message: "%{display_username} invited you to a message: %{link}"
invited_to_topic: "%{display_username} invited you to a topic: %{link}"
invitee_accepted: "%{display_username} accepted your invitation"
linked: "%{display_username} linked you in %{link}"
granted_badge: "You earned %{link}"
@ -1773,6 +1774,13 @@ en:
Please visit this link to view the topic: %{base_url}%{url}
user_invited_to_topic:
subject_template: "[%{site_name}] %{username} invited you to a topic '%{topic_title}'"
text_body_template: |
%{username} invited you to a topic '%{topic_title}' on %{site_name}:
Please visit this link to view the topic: %{base_url}%{url}
user_replied:
subject_template: "[%{site_name}] %{topic_title}"
text_body_template: |

View File

@ -326,4 +326,11 @@ describe UserNotifications do
end
end
describe "user invited to a topic" do
include_examples "notification email building" do
let(:notification_type) { :invited_to_topic }
include_examples "no reply by email"
end
end
end

View File

@ -98,4 +98,22 @@ describe UserEmailObserver do
end
context 'user_invited_to_topic' do
let(:user) { Fabricate(:user) }
let!(:notification) { Fabricate(:notification, user: user, notification_type: 13) }
it "enqueues a job for the email" do
Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_invited_to_topic, user_id: notification.user_id, notification_id: notification.id)
UserEmailObserver.send(:new).after_commit(notification)
end
it "doesn't enqueue an email if the user has mention emails disabled" do
user.expects(:email_direct?).returns(false)
Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_invited_to_topic)).never
UserEmailObserver.send(:new).after_commit(notification)
end
end
end