Skip to content

Commit a93b782

Browse files
authored
Merge pull request #1931 from codidact/0valt/1930/comments-last-activity
Follow / unfollow thread actions should bump its last activity date & time + minor general fixes
2 parents a6eb2a5 + 8a6e63b commit a93b782

25 files changed

+276
-41
lines changed

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Naming/BlockForwarding:
5858
EnforcedStyle: explicit
5959
Naming/PredicateMethod:
6060
Mode: conservative
61-
AllowedPatterns: ['verify_.*', 'check_.*', 'enforce_.*', 'setup_.*']
61+
AllowedPatterns: ['verify_.*', 'check_.*', 'enforce_.*', 'setup_.*','add_.*', 'remove_.*']
6262
AllowedMethods:
6363
- post_sign_in # returns a boolean but is a necessary after-sign-in method for both normal and SAML flows
6464
- comment_rate_limited? # returns a tuple with the boolean as the first value

app/assets/javascripts/comments.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,32 @@ $(() => {
250250

251251
QPixel.handleJSONResponse(data, () => {
252252
const wrapper = getCommentThreadWrapper($tgt);
253-
const inline = isInlineCommentThread(wrapper);
254-
openThread(wrapper, threadID, { inline });
253+
254+
if (wrapper) {
255+
const inline = isInlineCommentThread(wrapper);
256+
openThread(wrapper, threadID, { inline });
257+
}
255258
});
256259
});
257260

261+
$(document).on('click', '.js--unfollow-thread', async (ev) => {
262+
ev.preventDefault();
263+
264+
const $tgt = $(ev.target);
265+
const threadID = $tgt.data('thread');
266+
267+
const data = await QPixel.unfollowThread(threadID);
268+
269+
QPixel.handleJSONResponse(data, () => {
270+
const wrapper = getCommentThreadWrapper($tgt);
271+
272+
if (wrapper) {
273+
const inline = isInlineCommentThread(wrapper);
274+
openThread(wrapper, threadID, { inline });
275+
}
276+
});
277+
})
278+
258279
/**
259280
* @param {Element} target
260281
*/

app/assets/javascripts/qpixel_api.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,14 @@ window.QPixel = {
501501
return QPixel.parseJSONResponse(resp, 'Failed to follow thread');
502502
},
503503

504+
unfollowThread: async (id) => {
505+
const resp = await QPixel.fetchJSON(`/comments/thread/${id}/unfollow`, {}, {
506+
headers: { 'Accept': 'application/json' },
507+
});
508+
509+
return QPixel.parseJSONResponse(resp, 'Failed to unfollow thread');
510+
},
511+
504512
lockThread: async (id, duration) => {
505513
const resp = await QPixel.fetchJSON(`/comments/thread/${id}/lock`, {
506514
duration,

app/controllers/comments_controller.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class CommentsController < ApplicationController
1212
:archive_thread,
1313
:delete_thread,
1414
:follow_thread,
15+
:unfollow_thread,
1516
:lock_thread,
1617
:thread_unrestrict,
1718
:thread_followers]
@@ -188,14 +189,16 @@ def show
188189
end
189190

190191
def thread
191-
respond_to do |format|
192-
format.html { render 'comments/thread' }
193-
format.json { render json: @comment_thread }
192+
if stale?(last_modified: @comment_thread.last_activity.utc)
193+
respond_to do |format|
194+
format.html { render 'comments/thread' }
195+
format.json { render json: @comment_thread }
196+
end
194197
end
195198
end
196199

197200
def thread_content
198-
if stale?(last_modified: @comment_thread.last_activity_at.utc)
201+
if stale?(last_modified: @comment_thread.last_activity.utc)
199202
render partial: 'comment_threads/expanded',
200203
locals: { inline: params[:inline] == 'true',
201204
show_deleted: params[:show_deleted_comments] == '1',
@@ -264,7 +267,7 @@ def delete_thread
264267
end
265268

266269
def follow_thread
267-
status = ThreadFollower.create(comment_thread: @comment_thread, user: current_user)
270+
status = @comment_thread.add_follower(current_user)
268271
restrict_thread_response(@comment_thread, status)
269272
end
270273

@@ -293,7 +296,7 @@ def undelete_thread
293296
end
294297

295298
def unfollow_thread
296-
status = ThreadFollower.find_by(comment_thread: @comment_thread, user: current_user)&.destroy
299+
status = @comment_thread.remove_follower(current_user)
297300
restrict_thread_response(@comment_thread, status)
298301
end
299302

@@ -311,8 +314,6 @@ def thread_unrestrict
311314
unarchive_thread
312315
when 'delete'
313316
undelete_thread
314-
when 'follow'
315-
unfollow_thread
316317
else
317318
not_found!
318319
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
class CleanUpThreadFollowersJob < ApplicationJob
2+
queue_as :default
3+
4+
def perform
5+
sql = File.read(Rails.root.join('db/scripts/threads_with_duplicate_followers.sql'))
6+
threads = ActiveRecord::Base.connection.execute(sql).to_a
7+
8+
threads.each do |thread|
9+
user_id, thread_id = thread
10+
11+
followers = ThreadFollower.where(comment_thread_id: thread_id, user_id: user_id)
12+
13+
next unless followers.many?
14+
15+
duplicate = followers.first
16+
result = duplicate.destroy
17+
18+
unless result
19+
puts "failed to destroy thread follower duplicate \"#{duplicate.id}\""
20+
duplicate.errors.each { |e| puts e.full_message }
21+
end
22+
end
23+
end
24+
end

app/models/comment.rb

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,12 @@ class Comment < ApplicationRecord
1717
after_create :create_follower
1818
after_update :delete_thread
1919

20+
before_save :bump_last_activity
21+
2022
counter_culture :comment_thread, column_name: proc { |model| model.deleted? ? nil : 'reply_count' }, touch: true
2123

2224
validate :content_length
2325

24-
# Gets last activity date and time on the comment
25-
# @return [DateTime] last activity date and time
26-
def last_activity_at
27-
[created_at, updated_at].compact.max
28-
end
29-
3026
def root
3127
# If parent_question is nil, the comment is already on a question, so we can just return post.
3228
parent_question || post
@@ -41,12 +37,28 @@ def content_length
4137
end
4238
end
4339

40+
# Gets last activity date and time on the comment
41+
# @return [DateTime] last activity date and time
42+
def last_activity
43+
[created_at, updated_at, last_activity_at].compact.max
44+
end
45+
4446
def pings
4547
pingable = comment_thread.pingable
4648
matches = content.scan(USER_PING_REG_EXP)
4749
matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i)
4850
end
4951

52+
# Directly bumps the comment's last activity date & time
53+
# @param persist_changes [Boolean] if set to +true+, will persist the changes
54+
def bump_last_activity(persist_changes: false)
55+
self.last_activity_at = DateTime.now
56+
57+
if persist_changes
58+
save
59+
end
60+
end
61+
5062
private
5163

5264
def create_follower

app/models/comment_thread.rb

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class CommentThread < ApplicationRecord
1616

1717
after_create :create_follower
1818

19+
before_save :bump_last_activity
20+
1921
# Gets threads appropriately scoped for a given user & post
2022
# @param user [User, nil] user to check
2123
# @para post [Post] post to check
@@ -51,9 +53,9 @@ def can_access?(user)
5153

5254
# Gets last activity date and time on the thread
5355
# @return [DateTime] last activity date and time
54-
def last_activity_at
55-
last_comment_activity_at = comments.map(&:last_activity_at).max
56-
[created_at, locked_at, updated_at, last_comment_activity_at].compact.max
56+
def last_activity
57+
last_comment_activity = comments.map(&:last_activity).compact.max
58+
[created_at, updated_at, last_activity_at, last_comment_activity].compact.max
5759
end
5860

5961
# Gets a list of user IDs who should be pingable in the thread.
@@ -86,6 +88,40 @@ def maximum_title_length
8688
end
8789
end
8890

91+
# Registers a given user as a follower of the thread
92+
# @param user [User] user to register as a follower
93+
# @return [Boolean] status of the operation
94+
def add_follower(user)
95+
if ThreadFollower.where(comment_thread: self, user: user).any?
96+
bump_last_activity(persist_changes: true)
97+
return true
98+
end
99+
100+
ThreadFollower.create(comment_thread: self, user: user)
101+
end
102+
103+
# Directly bumps the thread's last activity date & time
104+
# @param persist_changes [Boolean] if set to +true+, will persist the changes
105+
def bump_last_activity(persist_changes: false)
106+
self.last_activity_at = DateTime.now
107+
108+
if persist_changes
109+
save
110+
end
111+
end
112+
113+
# Removes a given user from the thread's followers
114+
# @param user [User] user to remove from followers
115+
# @return [Boolean] status of the operation
116+
def remove_follower(user)
117+
if ThreadFollower.where(comment_thread: self, user: user).none?
118+
bump_last_activity(persist_changes: true)
119+
return true
120+
end
121+
122+
ThreadFollower.where(comment_thread: self, user: user).destroy_all.any?
123+
end
124+
89125
private
90126

91127
# Comment author and post author are automatically followed to the thread. Question author is NOT

app/models/thread_follower.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ class ThreadFollower < ApplicationRecord
33
belongs_to :post, optional: true
44
belongs_to :user
55

6+
after_create :bump_thread_last_activity
7+
before_destroy :bump_thread_last_activity
8+
69
validate :thread_or_post
710

811
private
912

13+
def bump_thread_last_activity
14+
comment_thread&.bump_last_activity(persist_changes: true)
15+
end
16+
1017
def thread_or_post
1118
if comment_thread.nil? && post.nil?
1219
errors.add(:base, 'Must refer to either a comment thread or a post.')

app/models/user.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,13 +483,13 @@ def active_flags(post)
483483
end
484484

485485
# Anonymizes the user (f.e., for the purpose of soft deletion)
486-
# @param persist_changes [Boolean] if set to +false+, will persist the changes
486+
# @param persist_changes [Boolean] if set to +true+, will persist the changes
487487
def anonymize(persist_changes: false)
488488
assign_attributes(username: "user#{id}",
489489
email: "#{id}@deleted.localhost",
490490
password: SecureRandom.hex(32))
491491

492-
unless persist_changes
492+
if persist_changes
493493
skip_reconfirmation!
494494
save
495495
end
@@ -505,7 +505,7 @@ def soft_delete(attribute_to)
505505
deleted_by_id: attribute_to.id,
506506
deleted_at: DateTime.now)
507507

508-
anonymize(persist_changes: true)
508+
anonymize(persist_changes: false)
509509

510510
skip_reconfirmation!
511511
save

app/views/comments/_thread_follow_link.html.erb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88

99
<% if thread.followed_by?(user) %>
1010
<a href="#"
11-
class="widget--header-link js--unrestrict-thread"
12-
data-action="follow"
11+
class="widget--header-link js--unfollow-thread"
1312
data-thread="<%= thread.id %>"
1413
title="You are following this thread and will be notified of every response. You can unfollow at any time."
1514
role="button"

0 commit comments

Comments
 (0)