thredded 0.15.5 → 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -7
  3. data/app/assets/stylesheets/thredded/_thredded.scss +1 -0
  4. data/app/assets/stylesheets/thredded/components/_messageboard.scss +16 -3
  5. data/app/assets/stylesheets/thredded/utilities/_flex.scss +3 -0
  6. data/app/commands/thredded/mark_all_read.rb +3 -7
  7. data/app/controllers/thredded/messageboards_controller.rb +4 -1
  8. data/app/controllers/thredded/moderation_controller.rb +7 -2
  9. data/app/controllers/thredded/private_topics_controller.rb +1 -5
  10. data/app/controllers/thredded/theme_previews_controller.rb +5 -1
  11. data/app/controllers/thredded/topics_controller.rb +1 -1
  12. data/app/forms/thredded/post_form.rb +1 -1
  13. data/app/forms/thredded/private_post_form.rb +1 -1
  14. data/app/forms/thredded/private_topic_form.rb +2 -0
  15. data/app/forms/thredded/topic_form.rb +2 -1
  16. data/app/forms/thredded/user_preferences_form.rb +5 -1
  17. data/app/models/concerns/thredded/post_common.rb +10 -6
  18. data/app/models/concerns/thredded/topic_common.rb +4 -8
  19. data/app/models/concerns/thredded/user_topic_read_state_common.rb +56 -62
  20. data/app/models/thredded/messageboard.rb +27 -1
  21. data/app/models/thredded/post.rb +6 -0
  22. data/app/models/thredded/private_topic.rb +12 -2
  23. data/app/models/thredded/topic.rb +8 -3
  24. data/app/models/thredded/user_extender.rb +0 -7
  25. data/app/models/thredded/user_private_topic_read_state.rb +35 -0
  26. data/app/models/thredded/user_topic_read_state.rb +41 -0
  27. data/app/view_models/thredded/messageboard_group_view.rb +24 -7
  28. data/app/view_models/thredded/messageboard_view.rb +50 -0
  29. data/app/views/thredded/messageboards/_messageboard.html.erb +8 -18
  30. data/app/views/thredded/messageboards/index.html.erb +6 -1
  31. data/app/views/thredded/messageboards/messageboard/_description.html.erb +1 -0
  32. data/app/views/thredded/messageboards/messageboard/_header.html.erb +6 -0
  33. data/app/views/thredded/messageboards/messageboard/_last_updated.html.erb +7 -0
  34. data/app/views/thredded/messageboards/messageboard/_meta.html.erb +19 -0
  35. data/app/views/thredded/messageboards/messageboard/_unread_followed_topics_badge.html.erb +6 -0
  36. data/app/views/thredded/preferences/_messageboards_nav_item.html.erb +1 -1
  37. data/app/views/thredded/private_posts/_private_post.html.erb +1 -1
  38. data/app/views/thredded/theme_previews/show.html.erb +10 -4
  39. data/app/views/thredded/topics/_topic.html.erb +1 -1
  40. data/app/views/thredded/topics/unread.html.erb +1 -1
  41. data/config/locales/de.yml +1 -0
  42. data/config/locales/en.yml +2 -1
  43. data/config/locales/es.yml +1 -0
  44. data/config/locales/fr.yml +1 -0
  45. data/config/locales/it.yml +1 -0
  46. data/config/locales/pl.yml +1 -0
  47. data/config/locales/pt-BR.yml +1 -0
  48. data/config/locales/ru.yml +1 -0
  49. data/config/locales/zh-CN.yml +1 -0
  50. data/db/migrate/20160329231848_create_thredded.rb +5 -0
  51. data/db/upgrade_migrations/20180930063614_upgrade_thredded_v0_15_to_v0_16.rb +40 -0
  52. data/lib/generators/thredded/install/templates/initializer.rb +4 -4
  53. data/lib/thredded/arel_compat.rb +18 -24
  54. data/lib/thredded/content_formatter.rb +1 -1
  55. data/lib/thredded/database_seeder.rb +7 -6
  56. data/lib/thredded/version.rb +1 -1
  57. metadata +24 -18
  58. data/app/views/thredded/messageboards/_messageboard_meta.html.erb +0 -13
  59. data/lib/tasks/thredded_tasks.rake +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bd8be4009af787b59ded83d2baa329f83fe8af37162b24d44191bbdae5623a7
4
- data.tar.gz: 66601634a6691c8b0488011034dc5a03d872d203452651bf5e792ed87edfa801
3
+ metadata.gz: a642798592ef42f04c27440bdeb2b9930273fca1bbb2cb41da3138dd10af429c
4
+ data.tar.gz: 78e334145b31fc55950554b3909864fbe6617a262fbfa07006eec2262ab526bf
5
5
  SHA512:
6
- metadata.gz: 416d1ccbef43a2d7b2127151cba2727013a348cf3b36bb5db91cae85e179f7eb579359e945e70296fe965685b73d394125db5bb66dac691c37a756ac4ca49ac4
7
- data.tar.gz: 37e9420a65e8278e3dd85c6159817c0336ca544d09119720234a87bad97ba4189238e159eb9a739ce5a90748eb8b6f230cfb95486b05e0c156e41b019c7ff8eb
6
+ metadata.gz: 5b134c56f3c79b38ed2b13b42e45c6c5fa7dbb54258ab941980fa7791077a8f1fdc1e76ba24d0c5658478ce762beddaf080133fe16fbd8684c467f2d1efbbe80
7
+ data.tar.gz: 560bfe2c2922595f5a93728bffb7becb25a32a39fa4ddc209ff8f5778c28ccc87cd46b0a08d106383cf2bb440c83f1a7a532bd0247683ff2e63d30028ca99e3f
data/README.md CHANGED
@@ -95,7 +95,7 @@ Then, see the rest of this Readme for more information about using and customizi
95
95
  Add the gem to your Gemfile:
96
96
 
97
97
  ```ruby
98
- gem 'thredded', '~> 0.15.5'
98
+ gem 'thredded', '~> 0.16.0'
99
99
  ```
100
100
 
101
101
  Add the Thredded [initializer] to your parent app by running the install generator.
@@ -104,12 +104,6 @@ Add the Thredded [initializer] to your parent app by running the install generat
104
104
  rails generate thredded:install
105
105
  ```
106
106
 
107
- Copy emoji images to your `public/emoji` directory.
108
-
109
- ```console
110
- rake thredded:install:emoji
111
- ```
112
-
113
107
  Thredded needs to know the base application User model name and certain columns on it. Configure
114
108
  these in the initializer installed with the command above.
115
109
 
@@ -1,5 +1,6 @@
1
1
  @import "base";
2
2
 
3
+ @import "utilities/flex";
3
4
  @import "utilities/is-compact";
4
5
  @import "utilities/is-expanded";
5
6
 
@@ -33,7 +33,6 @@
33
33
  float: left;
34
34
  font-size: 1.125rem; // 18px
35
35
  line-height: 1.2;
36
- margin-right: $thredded-small-spacing;
37
36
  vertical-align: baseline;
38
37
  }
39
38
 
@@ -66,6 +65,19 @@
66
65
  vertical-align: baseline;
67
66
  }
68
67
 
68
+ .thredded--messageboard--unread-followed-topics-count {
69
+ @extend %thredded--nav-tabs--item--badge;
70
+ align-self: baseline;
71
+ line-height: inherit;
72
+ display: flex;
73
+ }
74
+
75
+ .thredded--messageboard--unread-followed-icon {
76
+ fill: currentColor;
77
+ width: 1rem;
78
+ height: 1rem;
79
+ }
80
+
69
81
  .thredded--messageboard--description {
70
82
  @extend %thredded--paragraph;
71
83
  clear: both;
@@ -96,7 +108,9 @@
96
108
  &--header {
97
109
  display: flex;
98
110
  flex-wrap: wrap;
99
- justify-content: space-between;
111
+ > .thredded--flex-spacer {
112
+ margin-right: $thredded-small-spacing;
113
+ }
100
114
  }
101
115
  &--meta {
102
116
  text-align: right;
@@ -149,7 +163,6 @@
149
163
  margin-bottom: $margin-y;
150
164
  padding: $thredded-messageboards-grid-item-padding-y $thredded-messageboards-grid-item-padding-x;
151
165
  }
152
-
153
166
  }
154
167
  }
155
168
  }
@@ -0,0 +1,3 @@
1
+ .thredded--flex-spacer {
2
+ flex-grow: 1;
3
+ }
@@ -1,15 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thredded
4
+ # Marks all private topics as read for the given user.
4
5
  class MarkAllRead
5
6
  def self.run(user)
6
- unread_topics = Thredded::PrivateTopic.unread(user)
7
- return if unread_topics.empty?
8
-
9
- unread_topics.each do |topic|
10
- last_post = topic.posts.order_oldest_first.last
11
-
12
- Thredded::UserPrivateTopicReadState.touch!(user.id, topic.id, last_post)
7
+ Thredded::PrivateTopic.unread(user).each do |topic|
8
+ Thredded::UserPrivateTopicReadState.touch!(user.id, topic.last_post)
13
9
  end
14
10
  end
15
11
  end
@@ -8,7 +8,10 @@ module Thredded
8
8
  after_action :verify_policy_scoped, except: %i[new create edit update]
9
9
 
10
10
  def index
11
- @groups = Thredded::MessageboardGroupView.grouped(policy_scope(Thredded::Messageboard.all))
11
+ @groups = Thredded::MessageboardGroupView.grouped(
12
+ policy_scope(Thredded::Messageboard.all),
13
+ user: thredded_current_user
14
+ )
12
15
  end
13
16
 
14
17
  def new
@@ -54,8 +54,13 @@ module Thredded
54
54
 
55
55
  def users
56
56
  @users = Thredded.user_class
57
- .left_join_thredded_user_details
58
- .merge(Thredded::UserDetail.order(moderation_state_changed_at: :desc))
57
+ .eager_load(:thredded_user_detail)
58
+ .merge(
59
+ Thredded::UserDetail.order(
60
+ Arel.sql('COALESCE(thredded_user_details.moderation_state, 0) ASC,'\
61
+ 'thredded_user_details.moderation_state_changed_at DESC')
62
+ )
63
+ )
59
64
  @query = params[:q].to_s
60
65
  @users = DbTextSearch::CaseInsensitive.new(@users, Thredded.user_name_column).prefix(@query) if @query.present?
61
66
  @users = @users.page(current_page)
@@ -32,11 +32,7 @@ module Thredded
32
32
  .page(current_page)
33
33
  return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope)
34
34
  @posts = Thredded::TopicPostsPageView.new(thredded_current_user, private_topic, page_scope)
35
-
36
- if thredded_signed_in?
37
- Thredded::UserPrivateTopicReadState.touch!(thredded_current_user.id, private_topic.id, page_scope.last)
38
- end
39
-
35
+ Thredded::UserPrivateTopicReadState.touch!(thredded_current_user.id, page_scope.last) if thredded_signed_in?
40
36
  @new_post = Thredded::PrivatePostForm.new(
41
37
  user: thredded_current_user, topic: private_topic, post_params: new_private_post_params
42
38
  )
@@ -10,7 +10,11 @@ module Thredded
10
10
  else
11
11
  thredded_current_user
12
12
  end
13
- @messageboards = Thredded::Messageboard.all
13
+ @messageboard_views = [
14
+ Thredded::MessageboardView.new(@messageboard, unread_topics_count: 3, unread_followed_topics_count: 2),
15
+ Thredded::MessageboardView.new(Thredded::Messageboard.first(2)[-1], unread_topics_count: 2),
16
+ Thredded::MessageboardView.new(Thredded::Messageboard.first(3)[-1]),
17
+ ]
14
18
  @topics = Thredded::TopicsPageView.new(@user, @messageboard.topics.page(1).limit(3))
15
19
  @private_topics = Thredded::PrivateTopicsPageView.new(@user, @user.thredded_private_topics.page(1).limit(3))
16
20
  topic = Thredded::Topic.new(messageboard: @messageboard, title: 'Hello', slug: 'hello', user: @user)
@@ -60,7 +60,7 @@ module Thredded
60
60
  .page(current_page)
61
61
  return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope)
62
62
  @posts = Thredded::TopicPostsPageView.new(thredded_current_user, topic, page_scope)
63
- Thredded::UserTopicReadState.touch!(thredded_current_user.id, topic.id, page_scope.last) if thredded_signed_in?
63
+ Thredded::UserTopicReadState.touch!(thredded_current_user.id, page_scope.last) if thredded_signed_in?
64
64
  @new_post = Thredded::PostForm.new(user: thredded_current_user, topic: topic, post_params: new_post_params)
65
65
  end
66
66
 
@@ -49,7 +49,7 @@ module Thredded
49
49
  return false unless @post.valid?
50
50
  was_persisted = @post.persisted?
51
51
  @post.save!
52
- Thredded::UserTopicReadState.touch!(@post.user.id, @topic.id, @post) unless was_persisted
52
+ Thredded::UserTopicReadState.touch!(@post.user.id, @post) unless was_persisted
53
53
  true
54
54
  end
55
55
  end
@@ -45,7 +45,7 @@ module Thredded
45
45
  return false unless @post.valid?
46
46
  was_persisted = @post.persisted?
47
47
  @post.save!
48
- Thredded::UserPrivateTopicReadState.touch!(@post.user.id, @topic.id, @post) unless was_persisted
48
+ Thredded::UserPrivateTopicReadState.touch!(@post.user.id, @post) unless was_persisted
49
49
  true
50
50
  end
51
51
  end
@@ -44,8 +44,10 @@ module Thredded
44
44
  return false unless valid?
45
45
 
46
46
  ActiveRecord::Base.transaction do
47
+ new_topic = !private_topic.persisted?
47
48
  private_topic.save!
48
49
  post.save!
50
+ Thredded::UserPrivateTopicReadState.read_on_first_post!(user, post) if new_topic
49
51
  end
50
52
  true
51
53
  end
@@ -31,9 +31,10 @@ module Thredded
31
31
  return false unless valid?
32
32
 
33
33
  ActiveRecord::Base.transaction do
34
+ new_topic = !topic.persisted?
34
35
  topic.save!
35
36
  post.save!
36
- Thredded::UserTopicReadState.read_on_first_post!(user, topic) if topic.previous_changes.include?(:id)
37
+ Thredded::UserTopicReadState.read_on_first_post!(user, post) if new_topic
37
38
  end
38
39
  true
39
40
  end
@@ -50,7 +50,11 @@ module Thredded
50
50
  end
51
51
 
52
52
  def messageboard_groups
53
- @messageboard_groups ||= Thredded::MessageboardGroupView.grouped(@messageboards)
53
+ @messageboard_groups ||= Thredded::MessageboardGroupView.grouped(
54
+ @messageboards,
55
+ user: @user,
56
+ with_unread_topics_counts: false
57
+ )
54
58
  end
55
59
 
56
60
  def notifications_for_private_topics
@@ -19,6 +19,8 @@ module Thredded
19
19
  scope :order_newest_first, -> { order(created_at: :desc, id: :desc) }
20
20
 
21
21
  before_validation :ensure_user_detail, on: :create
22
+
23
+ after_commit :update_unread_posts_count, on: %i[create destroy]
22
24
  end
23
25
 
24
26
  def avatar_url
@@ -48,17 +50,13 @@ module Thredded
48
50
  end
49
51
 
50
52
  # Marks all the posts from the given one as unread for the given user
51
- # @param user [Thredded.user_class]
52
- # @param page [Integer]
53
+ # @param [Thredded.user_class] user
53
54
  def mark_as_unread(user)
54
55
  if previous_post.nil?
55
56
  read_state = postable.user_read_states.find_by(user_id: user.id)
56
57
  read_state.destroy if read_state
57
58
  else
58
- read_state = postable.user_read_states.create_with(
59
- read_at: previous_post.created_at
60
- ).find_or_create_by(user_id: user.id)
61
- read_state.update_columns(read_at: previous_post.created_at)
59
+ postable.user_read_states.touch!(user.id, previous_post, overwrite_newer: true)
62
60
  end
63
61
  end
64
62
 
@@ -66,6 +64,12 @@ module Thredded
66
64
  @previous_post ||= postable.posts.order_newest_first.find_by('created_at < ?', created_at)
67
65
  end
68
66
 
67
+ protected
68
+
69
+ def update_unread_posts_count
70
+ postable.user_read_states.update_post_counts!
71
+ end
72
+
69
73
  private
70
74
 
71
75
  def ensure_user_detail
@@ -50,16 +50,12 @@ module Thredded
50
50
  # @param user [Thredded.user_class]
51
51
  # @return [ActiveRecord::Relation]
52
52
  def unread(user)
53
- topics = arel_table
53
+ topics = arel_table
54
54
  reads_class = reflect_on_association(:user_read_states).klass
55
- reads = reads_class.arel_table
55
+ reads = reads_class.arel_table
56
56
  joins(topics.join(reads, Arel::Nodes::OuterJoin)
57
57
  .on(topics[:id].eq(reads[:postable_id]).and(reads[:user_id].eq(user.id))).join_sources)
58
- .merge(reads_class.where(reads[:id].eq(nil).or(reads[:read_at].lt(topics[:last_post_at]))))
59
- end
60
-
61
- def post_class
62
- reflect_on_association(:posts).klass
58
+ .merge(reads_class.where(reads[:id].eq(nil).or(reads[:unread_posts_count].not_eq(0))))
63
59
  end
64
60
 
65
61
  private
@@ -69,7 +65,7 @@ module Thredded
69
65
  def read_states_by_postable_hash(user)
70
66
  read_states = reflect_on_association(:user_read_states).klass
71
67
  .where(user_id: user.id, postable_id: current_scope.map(&:id))
72
- .with_page_info(posts_scope: Pundit.policy_scope(user, post_class.all))
68
+ .with_page_info
73
69
  Thredded::TopicCommon::CachingHash.from_relation(read_states)
74
70
  end
75
71
 
@@ -21,79 +21,73 @@ module Thredded
21
21
  post.created_at <= read_at
22
22
  end
23
23
 
24
- module ClassMethods
25
- # @param user_id [Integer]
26
- # @param topic_id [Integer]
27
- # @param post [Thredded::PostCommon]
28
- def touch!(user_id, topic_id, post)
29
- # TODO: Switch to upsert once Travis supports PostgreSQL 9.5.
30
- # Travis issue: https://github.com/travis-ci/travis-ci/issues/4264
31
- # Upsert gem: https://github.com/seamusabshere/upsert
32
- state = find_or_initialize_by(user_id: user_id, postable_id: topic_id)
33
- return unless !state.read_at? || state.read_at < post.created_at
34
- state.update!(read_at: post.created_at)
35
- end
24
+ def calculate_post_counts
25
+ unread_posts_count, read_posts_count =
26
+ self.class.visible_posts_scope(user)
27
+ .where(postable_id: postable_id)
28
+ .pluck(*self.class.post_counts_arel(read_at))[0]
29
+ { unread_posts_count: unread_posts_count || 0, read_posts_count: read_posts_count || 0 }
30
+ end
36
31
 
37
- def read_on_first_post!(user, topic)
38
- create!(user: user, postable: topic, read_at: Time.zone.now)
39
- end
32
+ module ClassMethods
33
+ delegate :post_class, to: :topic_class
40
34
 
41
35
  # Adds `first_unread_post_page` and `last_read_post_page` columns onto the scope.
42
36
  # Skips the records that have no read posts.
43
- def with_page_info( # rubocop:disable Metrics/MethodLength
44
- posts_per_page: post_class.default_per_page, posts_scope: post_class.all
45
- )
37
+ def with_page_info(posts_per_page: post_class.default_per_page)
46
38
  states = arel_table
47
- self_relation = is_a?(ActiveRecord::Relation) ? self : all
48
- if self_relation == unscoped
49
- states_select_manager = states
50
- else
51
- # Using the relation here is redundant but massively improves performance.
52
- states_select_manager = Thredded::ArelCompat.new_arel_select_manager(
53
- Arel::Nodes::TableAlias.new(Thredded::ArelCompat.relation_to_arel(self_relation), table_name)
54
- )
55
- end
56
- read = if posts_scope == post_class.unscoped
57
- post_class.arel_table
58
- else
59
- posts_subquery = Thredded::ArelCompat.relation_to_arel(posts_scope)
60
- Arel::Nodes::TableAlias.new(posts_subquery, 'read_posts')
61
- end
62
- unread_topics = topic_class.arel_table
63
- page_info =
64
- states_select_manager
65
- .project(
66
- states[:id],
67
- Arel::Nodes::Case.new(unread_topics[:id].not_eq(nil))
68
- .when(Thredded::ArelCompat.true_value(self)).then(
69
- Arel::Nodes::Addition.new(
70
- Thredded::ArelCompat.integer_division(self, read[:id].count, posts_per_page), 1
71
- )
72
- ).else(nil)
73
- .as('first_unread_post_page'),
39
+ selects = []
40
+ selects << states[Arel.star] if !is_a?(ActiveRecord::Relation) || select_values.empty?
41
+ selects += [
42
+ Arel::Nodes::Case.new(states[:unread_posts_count].not_eq(0))
43
+ .when(Thredded::ArelCompat.true_value(self)).then(
74
44
  Arel::Nodes::Addition.new(
75
- Thredded::ArelCompat.integer_division(self, read[:id].count, posts_per_page),
76
- Arel::Nodes::Case.new(Arel::Nodes::InfixOperation.new(:%, read[:id].count, posts_per_page))
77
- .when(0).then(0).else(1)
78
- ).as('last_read_post_page')
79
- )
80
- .join(read)
81
- .on(read[:postable_id].eq(states[:postable_id]).and(read[:created_at].lteq(states[:read_at])))
82
- .outer_join(unread_topics)
83
- .on(states[:postable_id].eq(unread_topics[:id]).and(unread_topics[:last_post_at].gt(states[:read_at])))
84
- .group(states[:id], unread_topics[:id])
85
- .as('id_and_page_info')
45
+ Thredded::ArelCompat.integer_division(self, states[:read_posts_count], posts_per_page), 1
46
+ )
47
+ ).else(nil).as('first_unread_post_page'),
48
+ Arel::Nodes::Addition.new(
49
+ Thredded::ArelCompat.integer_division(self, states[:read_posts_count], posts_per_page),
50
+ Arel::Nodes::Case.new(Arel::Nodes::InfixOperation.new(:%, states[:read_posts_count], posts_per_page))
51
+ .when(0).then(0).else(1)
52
+ ).as('last_read_post_page')
53
+ ]
54
+ select(selects)
55
+ end
86
56
 
87
- # We use a subquery because selected fields must appear in the GROUP BY or be used in an aggregate function.
88
- select(states[Arel.star], page_info[:first_unread_post_page], page_info[:last_read_post_page])
89
- .joins(states.join(page_info).on(states[:id].eq(page_info[:id])).join_sources)
57
+ # Calculates and saves the `unread_posts_count` and `read_posts_count` columns.
58
+ def update_post_counts!
59
+ id_counts = calculate_post_counts_for_users(Thredded.user_class.where(id: distinct.select(:user_id)))
60
+ transaction do
61
+ id_counts.each do |(id, unread_posts_count, read_posts_count)|
62
+ where(id: id).update_all(unread_posts_count: unread_posts_count, read_posts_count: read_posts_count)
63
+ end
64
+ end
90
65
  end
91
66
 
92
- def topic_class
93
- reflect_on_association(:postable).klass
67
+ # @param [DateTime, Arel::Node] read_at
68
+ # @param [Arel::Table] posts
69
+ # @return [[Arel::Node, Arel::Node]] `unread_posts_count` and `read_posts_count` nodes.
70
+ def post_counts_arel(read_at, posts: post_class.arel_table)
71
+ [
72
+ Arel::Nodes::Sum.new(
73
+ [Arel::Nodes::Case.new(posts[:created_at].gt(read_at))
74
+ .when(Thredded::ArelCompat.true_value(self)).then(1).else(0)]
75
+ ).as('unread_posts_count'),
76
+ Arel::Nodes::Sum.new(
77
+ [Arel::Nodes::Case.new(posts[:created_at].gt(read_at))
78
+ .when(Thredded::ArelCompat.true_value(self)).then(0).else(1)]
79
+ ).as('read_posts_count')
80
+ ]
94
81
  end
95
82
 
96
- delegate :post_class, to: :topic_class
83
+ # @return [Array<[id, unread_posts_count, read_posts_count]>]
84
+ def calculate_post_counts
85
+ states = arel_table
86
+ posts = post_class.arel_table
87
+ joins(states.join(posts).on(states[:postable_id].eq(posts[:postable_id])).join_sources)
88
+ .group(states[:id])
89
+ .pluck(states[:id], *post_counts_arel(states[:read_at], posts: posts))
90
+ end
97
91
  end
98
92
  end
99
93
  end
@@ -4,7 +4,7 @@ module Thredded
4
4
  class Messageboard < ActiveRecord::Base
5
5
  extend FriendlyId
6
6
  friendly_id :slug_candidates,
7
- use: %i[slugged reserved],
7
+ use: %i[slugged reserved],
8
8
  # Avoid route conflicts
9
9
  reserved_words: ::Thredded::FriendlyIdReservedWordsAndPagination.new(
10
10
  %w[
@@ -51,6 +51,11 @@ module Thredded
51
51
  through: :recently_active_user_details,
52
52
  source: :user
53
53
 
54
+ has_many :user_topic_read_states,
55
+ class_name: 'Thredded::UserTopicReadState',
56
+ inverse_of: :messageboard,
57
+ dependent: :delete_all
58
+
54
59
  belongs_to :group,
55
60
  inverse_of: :messageboards,
56
61
  foreign_key: :messageboard_group_id,
@@ -114,5 +119,26 @@ module Thredded
114
119
  [:name, '-board']
115
120
  ]
116
121
  end
122
+
123
+ class << self
124
+ # @param [Thredded.user_class] user
125
+ # @param [ActiveRecord::Relation<Thredded::Topic>] topics_scope
126
+ def unread_topics_counts(user:, topics_scope: Thredded::Topic.all)
127
+ messageboards = arel_table
128
+ read_states = Thredded::UserTopicReadState.arel_table
129
+ topics = topics_scope.arel_table
130
+ joins(:topics).merge(topics_scope).joins(
131
+ messageboards.outer_join(read_states).on(
132
+ messageboards[:id].eq(read_states[:messageboard_id])
133
+ .and(read_states[:postable_id].eq(topics[:id]))
134
+ .and(read_states[:user_id].eq(user.id))
135
+ .and(read_states[:unread_posts_count].eq(0))
136
+ ).join_sources
137
+ ).group(messageboards[:id]).pluck(
138
+ :id,
139
+ Arel::Nodes::Subtraction.new(topics[:id].count, read_states[:id].count)
140
+ ).to_h
141
+ end
142
+ end
117
143
  end
118
144
  end