FEATURE: allow users to archive messages

Messages are now in 3 buckets

- Inbox for all new messages
- Sent for all sent messages
- Archive for all messages you are done with

You can select messages from your Inbox or Sent and move them to your Archive,
you can move messages out of your Archive similarly

Similar concept applied to group messages, except that archiving and unarchiving
will apply to all group members
This commit is contained in:
Sam 2015-12-23 11:09:17 +11:00
parent e03861da7e
commit 03ea0bfe22
30 changed files with 290 additions and 57 deletions

View File

@ -1,4 +1,5 @@
export default Ember.Component.extend({
loadingMore: Ember.computed.alias('topicList.loadingMore'),
loading: Ember.computed.not('loaded'),
loaded: function() {

View File

@ -1,25 +1,62 @@
import computed from 'ember-addons/ember-computed-decorators';
import Topic from 'discourse/models/topic';
export default Ember.Controller.extend({
needs: ["user-topics-list"],
pmView: false,
privateMessagesActive: Em.computed.equal('pmView', 'index'),
privateMessagesMineActive: Em.computed.equal('pmView', 'mine'),
privateMessagesUnreadActive: Em.computed.equal('pmView', 'unread'),
isGroup: Em.computed.equal('pmView', 'groups'),
selected: Em.computed.alias('controllers.user-topics-list.selected'),
bulkSelectEnabled: Em.computed.alias('controllers.user-topics-list.bulkSelectEnabled'),
@computed('model.groups', 'groupFilter', 'pmView')
groupPMStats(groups, filter, pmView) {
if (groups) {
return groups.filter(group => group.has_messages)
.map(g => {
return {
name: g.name,
active: (g.name === filter && pmView === "groups")
};
});
@computed('selected.@each', 'bulkSelectEnabled')
hasSelection(selected, bulkSelectEnabled){
return bulkSelectEnabled && selected && selected.length > 0;
},
@computed('hasSelection', 'pmView', 'archive')
canMoveToInbox(hasSelection, pmView, archive){
return hasSelection && (pmView === "archive" || archive);
},
@computed('hasSelection', 'pmView', 'archive')
canArchive(hasSelection, pmView, archive){
return hasSelection && pmView !== "archive" && !archive;
},
bulkOperation(operation) {
const selected = this.get('selected');
var params = {type: operation};
if (this.get('isGroup')) {
params.group = this.get('groupFilter');
}
Topic.bulkOperation(selected,params).then(() => {
const model = this.get('controllers.user-topics-list.model');
const topics = model.get('topics');
topics.removeObjects(selected);
selected.clear();
model.loadMore();
}, () => {
bootbox.alert(I18n.t("user.messages.failed_to_move"));
});
},
actions: {
archive() {
this.bulkOperation("archive_messages");
},
toInbox() {
this.bulkOperation("move_messages_to_inbox");
},
toggleBulkSelect(){
this.toggleProperty("bulkSelectEnabled");
},
selectAll() {
$('input.bulk-select:not(checked)').click();
}
}
});

View File

@ -17,6 +17,6 @@ export default Ember.Controller.extend({
showNewPM: function(){
return this.get('controllers.user.viewingSelf') &&
Discourse.User.currentProp('can_send_private_messages');
}.property('controllers.user.viewingSelf'),
}.property('controllers.user.viewingSelf')
});

View File

@ -0,0 +1,3 @@
Ember.Handlebars.registerBoundHelper("capitalize", function(str) {
return str[0].toUpperCase() + str.slice(1);
});

View File

@ -76,9 +76,10 @@ export default function() {
this.route('deletedPosts', { path: '/deleted-posts' });
this.resource('userPrivateMessages', { path: '/messages' }, function() {
this.route('mine');
this.route('unread');
this.route('sent');
this.route('archive');
this.route('group', { path: 'group/:name'});
this.route('groupArchive', { path: 'group/:name/archive'});
});
this.resource('preferences', function() {

View File

@ -21,10 +21,13 @@ export default (viewName, path) => {
this.controllerFor("user-topics-list").setProperties({
hideCategory: true,
showParticipants: true
showParticipants: true,
canBulkSelect: true,
selected: []
});
this.controllerFor("userPrivateMessages").set("pmView", viewName);
this.controllerFor("user-private-messages").set("archive", false);
this.controllerFor("user-private-messages").set("pmView", viewName);
this.searchService.set('contextType', 'private_messages');
},

View File

@ -1,3 +1,3 @@
import createPMRoute from "discourse/routes/build-private-messages-route";
export default createPMRoute('unread', 'private-messages-unread');
export default createPMRoute('archive', 'private-messages-archive');

View File

@ -0,0 +1,18 @@
import createPMRoute from "discourse/routes/build-private-messages-route";
export default createPMRoute('groups', 'private-messages-groups').extend({
model(params) {
const username = this.modelFor("user").get("username_lower");
return this.store.findFiltered("topicList", {
filter: `topics/private-messages-group/${username}/${params.name}/archive`
});
},
setupController(controller, model) {
this._super.apply(this, arguments);
const split = model.get("filter").split('/');
const group = split[split.length-2];
this.controllerFor("user-private-messages").set("groupFilter", group);
this.controllerFor("user-private-messages").set("archive", true);
}
});

View File

@ -1,4 +1,3 @@
import Group from 'discourse/models/group';
import createPMRoute from "discourse/routes/build-private-messages-route";
export default createPMRoute('groups', 'private-messages-groups').extend({
@ -9,17 +8,10 @@ export default createPMRoute('groups', 'private-messages-groups').extend({
});
},
afterModel(model) {
const groupName = _.last(model.get("filter").split('/'));
Group.findAll().then(groups => {
const group = _.first(groups.filterBy("name", groupName));
this.controllerFor("user-private-messages").set("group", group);
});
},
setupController(controller, model) {
this._super.apply(this, arguments);
const group = _.last(model.get("filter").split('/'));
this.controllerFor("userPrivateMessages").set("groupFilter", group);
this.controllerFor("user-private-messages").set("groupFilter", group);
this.controllerFor("user-private-messages").set("archive", false);
}
});

View File

@ -1,3 +1,3 @@
import createPMRoute from "discourse/routes/build-private-messages-route";
export default createPMRoute('mine', 'private-messages-sent');
export default createPMRoute('sent', 'private-messages-sent');

View File

@ -5,10 +5,15 @@
hideCategory=hideCategory
topics=topics
expandExcerpts=expandExcerpts
bulkSelectEnabled=bulkSelectEnabled
canBulkSelect=canBulkSelect
selected=selected
}}
{{else}}
{{#unless loadingMore}}
<div class='alert alert-info'>
{{i18n 'choose_topic.none_found'}}
</div>
{{/unless}}
{{/if}}
{{/conditional-loading-spinner}}

View File

@ -2,4 +2,7 @@
{{basic-topic-list topicList=model
hideCategory=hideCategory
showParticipants=showParticipants
canBulkSelect=canBulkSelect
bulkSelectEnabled=bulkSelectEnabled
selected=selected
postsAction="showTopicEntrance"}}

View File

@ -1,30 +1,34 @@
<section class='user-navigation'>
<ul class='action-list nav-stacked'>
<li {{bind-attr class=":noGlyph privateMessagesActive:active"}}>
<li class="noGlyph">
{{#link-to 'userPrivateMessages.index' model}}
{{i18n 'user.messages.all'}}
{{#if model.hasPMs}}<span class='count'>({{model.private_messages_stats.all}})</span>{{/if}}
{{i18n 'user.messages.inbox'}}
{{/link-to}}
</li>
<li {{bind-attr class=":noGlyph privateMessagesMineActive:active"}}>
{{#link-to 'userPrivateMessages.mine' model}}
{{i18n 'user.messages.mine'}}
{{#if model.hasStartedPMs}}<span class='count'>({{model.private_messages_stats.mine}})</span>{{/if}}
<li class="noGlyph">
{{#link-to 'userPrivateMessages.sent' model}}
{{i18n 'user.messages.sent'}}
{{/link-to}}
</li>
<li {{bind-attr class=":noGlyph privateMessagesUnreadActive:active"}}>
{{#link-to 'userPrivateMessages.unread' model}}
{{i18n 'user.messages.unread'}}
{{#if model.hasUnreadPMs}}<span class='badge-notification unread-private-messages'>{{model.private_messages_stats.unread}}</span>{{/if}}
<li class="noGlyph">
{{#link-to 'userPrivateMessages.archive' model}}
{{i18n 'user.messages.archive'}}
{{/link-to}}
</li>
{{#each groupPMStats as |group|}}
<li class="{{if group.active "active"}}">
{{#each model.groups as |group|}}
{{#if group.has_messages}}
<li>
{{#link-to 'userPrivateMessages.group' group.name}}
<i class='glyph fa fa-group'></i>
{{group.name}}
{{capitalize group.name}}
{{/link-to}}
</li>
<li>
{{#link-to 'userPrivateMessages.groupArchive' group.name}}
{{i18n 'user.messages.archive'}}
{{/link-to}}
</li>
{{/if}}
{{/each}}
</ul>
@ -33,11 +37,34 @@
<section class='user-right messages'>
{{#if isGroup}}
<div class="clearfix">
{{group-notifications-button group=group}}
<div class="clearfix list-actions">
<button {{action "toggleBulkSelect"}} class="btn bulk-select" title="{{i18n "user.messages.bulk_select"}}">
<i class="fa fa-list"></i>
</button>
{{#if canArchive}}
<button {{action "archive"}} class="btn btn-archive">
{{i18n "user.messages.archive"}}
</button>
{{/if}}
{{#if canMoveToInbox}}
<button {{action "toInbox"}} class="btn btn-to-inbox">
{{i18n "user.messages.move_to_inbox"}}
</button>
{{/if}}
{{#if bulkSelectEnabled}}
<button {{action "selectAll"}} class="btn btn-select-all">
{{i18n "user.messages.select_all"}}
</button>
{{/if}}
{{#if isGroup}}
{{group-notifications-button group=group}}
{{/if}}
</div>
{{/if}}
{{outlet}}
</section>

View File

@ -92,6 +92,7 @@
//= require ./discourse/components/conditional-loading-spinner
//= require ./discourse/helpers/user-avatar
//= require ./discourse/helpers/cold-age-class
//= require ./discourse/helpers/capitalize
//= require ./discourse/helpers/loading-spinner
//= require ./discourse/helpers/category-link
//= require ./discourse/lib/export-result

View File

@ -162,3 +162,9 @@
}
}
.user-right .list-actions {
margin-bottom: 10px;
.btn {
margin-right: 10px;
}
}

View File

@ -102,7 +102,7 @@ class ListController < ApplicationController
end
end
[:topics_by, :private_messages, :private_messages_sent, :private_messages_unread, :private_messages_group].each do |action|
[:topics_by, :private_messages, :private_messages_sent, :private_messages_unread, :private_messages_archive, :private_messages_group, :private_messages_group_archive].each do |action|
define_method("#{action}") do
list_opts = build_topic_list_options
target_user = fetch_user_from_params(include_inactive: current_user.try(:staff?))

View File

@ -451,7 +451,7 @@ class TopicsController < ApplicationController
operation = params.require(:operation).symbolize_keys
raise ActionController::ParameterMissing.new(:operation_type) if operation[:type].blank?
operator = TopicsBulkAction.new(current_user, topic_ids, operation)
operator = TopicsBulkAction.new(current_user, topic_ids, operation, group: operation[:group])
changed_topic_ids = operator.perform!
render_json_dump topic_ids: changed_topic_ids
end

View File

@ -5,6 +5,8 @@ class Group < ActiveRecord::Base
has_many :group_users, dependent: :destroy
has_many :group_mentions, dependent: :destroy
has_many :group_archived_messages, dependent: :destroy
has_many :categories, through: :category_groups
has_many :users, through: :group_users

View File

@ -0,0 +1,4 @@
class GroupArchivedMessage < ActiveRecord::Base
belongs_to :user
belongs_to :topic
end

View File

@ -83,6 +83,9 @@ class Topic < ActiveRecord::Base
has_many :topic_allowed_users
has_many :topic_allowed_groups
has_many :group_archived_messages, dependent: :destroy
has_many :user_archived_messages, dependent: :destroy
has_many :allowed_group_users, through: :allowed_groups, source: :users
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
has_many :allowed_users, through: :topic_allowed_users, source: :user

View File

@ -35,6 +35,8 @@ class User < ActiveRecord::Base
has_many :topic_links, dependent: :destroy
has_many :uploads
has_many :warnings
has_many :user_archived_messages, dependent: :destroy
has_one :user_avatar, dependent: :destroy
has_one :facebook_user_info, dependent: :destroy

View File

@ -0,0 +1,4 @@
class UserArchivedMessage < ActiveRecord::Base
belongs_to :user
belongs_to :topic
end

View File

@ -504,9 +504,14 @@ en:
messages:
all: "All"
mine: "Mine"
unread: "Unread"
inbox: "Inbox"
sent: "Sent"
archive: "Archive"
groups: "My Groups"
bulk_select: "Select messages"
move_to_inbox: "Move to Inbox"
failed_to_move: "Failed to move selected messages (perhaps your network is down)"
select_all: "Select All"
change_password:
success: "(email sent)"

View File

@ -279,6 +279,9 @@ Discourse::Application.routes.draw do
get "users/:username/messages" => "user_actions#private_messages", constraints: {username: USERNAME_ROUTE_FORMAT}
get "users/:username/messages/:filter" => "user_actions#private_messages", constraints: {username: USERNAME_ROUTE_FORMAT}
get "users/:username/messages/group/:group_name" => "user_actions#private_messages", constraints: {username: USERNAME_ROUTE_FORMAT, group_name: USERNAME_ROUTE_FORMAT}
get "users/:username/messages/group/:group_name/archive" => "user_actions#private_messages", constraints: {username: USERNAME_ROUTE_FORMAT, group_name: USERNAME_ROUTE_FORMAT}
get "users/:username.json" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}, defaults: {format: :json}
get "users/:username" => "users#show", as: 'user', constraints: {username: USERNAME_ROUTE_FORMAT}
put "users/:username" => "users#update", constraints: {username: USERNAME_ROUTE_FORMAT}
@ -468,12 +471,18 @@ Discourse::Application.routes.draw do
get "topics/created-by/:username" => "list#topics_by", as: "topics_by", constraints: {username: USERNAME_ROUTE_FORMAT}
get "topics/private-messages/:username" => "list#private_messages", as: "topics_private_messages", constraints: {username: USERNAME_ROUTE_FORMAT}
get "topics/private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", constraints: {username: USERNAME_ROUTE_FORMAT}
get "topics/private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", constraints: {username: USERNAME_ROUTE_FORMAT}
get "topics/private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", constraints: {username: USERNAME_ROUTE_FORMAT}
get "topics/private-messages-group/:username/:group_name.json" => "list#private_messages_group", as: "topics_private_messages_group", constraints: {
username: USERNAME_ROUTE_FORMAT,
group_name: USERNAME_ROUTE_FORMAT
}
get "topics/private-messages-group/:username/:group_name/archive.json" => "list#private_messages_group_archive", as: "topics_private_messages_group_archive", constraints: {
username: USERNAME_ROUTE_FORMAT,
group_name: USERNAME_ROUTE_FORMAT
}
get 'embed/comments' => 'embed#comments'
get 'embed/count' => 'embed#count'
get 'embed/info' => 'embed#info'

View File

@ -0,0 +1,19 @@
class AddUserArchivedMessagesGroupArchivedMessages < ActiveRecord::Migration
def change
create_table :user_archived_messages do |t|
t.integer :user_id, null: false
t.integer :topic_id, null: false
t.timestamps
end
add_index :user_archived_messages, [:user_id, :topic_id], unique: true
create_table :group_archived_messages do |t|
t.integer :group_id, null: false
t.integer :topic_id, null: false
t.timestamps
end
add_index :group_archived_messages, [:group_id, :topic_id], unique: true
end
end

View File

@ -134,6 +134,7 @@ class PostCreator
create_embedded_topic
ensure_in_allowed_users if guardian.is_staff?
unarchive_message
@post.advance_draft_sequence
@post.save_reply_relationships
end
@ -268,6 +269,13 @@ class PostCreator
end
end
def unarchive_message
return unless @topic.private_message? && @topic.id
UserArchivedMessage.where(topic_id: @topic.id).destroy_all
GroupArchivedMessage.where(topic_id: @topic.id).destroy_all
end
private
def create_topic

View File

@ -117,14 +117,35 @@ class TopicQuery
end
end
def not_archived(list, user)
list.joins("LEFT JOIN user_archived_messages um
ON um.user_id = #{user.id.to_i} AND um.topic_id = topics.id")
.where('um.user_id IS NULL')
end
def list_private_messages(user)
list = private_messages_for(user, :user)
list = not_archived(list, user)
.where('NOT (topics.participant_count = 1 AND topics.user_id = ?)', user.id)
create_list(:private_messages, {}, list)
end
def list_private_messages_archive(user)
list = private_messages_for(user, :user)
list = list.joins(:user_archived_messages).where('user_archived_messages.user_id = ?', user.id)
create_list(:private_messages, {}, list)
end
def list_private_messages_sent(user)
list = private_messages_for(user, :user)
list = list.where(user_id: user.id)
list = list.where('EXISTS (
SELECT 1 FROM posts
WHERE posts.topic_id = topics.id AND
posts.user_id = ?
)', user.id)
list = not_archived(list, user)
create_list(:private_messages, {}, list)
end
@ -136,6 +157,18 @@ class TopicQuery
def list_private_messages_group(user)
list = private_messages_for(user, :group)
group_id = Group.where('name ilike ?', @options[:group_name]).pluck(:id).first
list = list.joins("LEFT JOIN group_archived_messages gm ON gm.topic_id = topics.id AND
gm.group_id = #{group_id.to_i}")
list = list.where("gm.id IS NULL")
create_list(:private_messages, {}, list)
end
def list_private_messages_group_archive(user)
list = private_messages_for(user, :group)
group_id = Group.where('name ilike ?', @options[:group_name]).pluck(:id).first
list = list.joins("JOIN group_archived_messages gm ON gm.topic_id = topics.id AND
gm.group_id = #{group_id.to_i}")
create_list(:private_messages, {}, list)
end
@ -195,7 +228,9 @@ class TopicQuery
topics = yield(topics) if block_given?
options = options.merge(@options)
if ["activity","default"].include?(options[:order] || "activity") && !options[:unordered]
if ["activity","default"].include?(options[:order] || "activity") &&
!options[:unordered] &&
filter != :private_messages
topics = prioritize_pinned_topics(topics, options)
end

View File

@ -1,14 +1,17 @@
class TopicsBulkAction
def initialize(user, topic_ids, operation)
def initialize(user, topic_ids, operation, options={})
@user = user
@topic_ids = topic_ids
@operation = operation
@changed_ids = []
@options = options
end
def self.operations
@operations ||= %w(change_category close archive change_notification_level reset_read dismiss_posts delete unlist)
@operations ||= %w(change_category close archive change_notification_level
reset_read dismiss_posts delete unlist archive_messages
move_messages_to_inbox)
end
def self.register_operation(name, &block)
@ -24,6 +27,43 @@ class TopicsBulkAction
private
def find_group
return unless @options[:group]
group = Group.where('name ilike ?', @options[:group]).first
raise Discourse::InvalidParameters.new(:group) unless group
unless group.group_users.where(user_id: @user.id).exists?
raise Discourse::InvalidParameters.new(:group)
end
group
end
def move_messages_to_inbox
group = find_group
topics.each do |t|
if guardian.can_see?(t) && t.private_message?
if group
GroupArchivedMessage.where(group_id: group.id, topic_id: t.id).destroy_all
else
UserArchivedMessage.where(user_id: @user.id, topic_id: t.id).destroy_all
end
end
end
end
def archive_messages
group = find_group
topics.each do |t|
if guardian.can_see?(t) && t.private_message?
if group
GroupArchivedMessage.create!(group_id: group.id, topic_id: t.id)
else
UserArchivedMessage.create!(user_id: @user.id, topic_id: t.id)
end
end
end
end
def dismiss_posts
sql = "
UPDATE topic_users tu

View File

@ -478,6 +478,9 @@ describe PostCreator do
expect(unrelated.notifications.count).to eq(0)
expect(post.topic.subtype).to eq(TopicSubtype.user_to_user)
# archive this message and ensure archive is cleared for all users on reply
UserArchivedMessage.create(user_id: target_user2.id, topic_id: post.topic_id)
# if an admin replies they should be added to the allowed user list
admin = Fabricate(:admin)
PostCreator.create(admin, raw: 'hi there welcome topic, I am a mod',
@ -485,6 +488,8 @@ describe PostCreator do
post.topic.reload
expect(post.topic.topic_allowed_users.where(user_id: admin.id).count).to eq(1)
expect(UserArchivedMessage.where(user_id: target_user2.id, topic_id: post.topic_id).count).to eq(0)
end
end

View File

@ -1148,7 +1148,7 @@ describe TopicsController do
it "delegates work to `TopicsBulkAction`" do
topics_bulk_action = mock
TopicsBulkAction.expects(:new).with(user, topic_ids, operation).returns(topics_bulk_action)
TopicsBulkAction.expects(:new).with(user, topic_ids, operation, group: nil).returns(topics_bulk_action)
topics_bulk_action.expects(:perform!)
xhr :put, :bulk, topic_ids: topic_ids, operation: operation
end