Skip to content

Commit 9153578

Browse files
committed
Added activities for starred threads
When starred threads receive new messages, users now get a similar notification as mentioned notes. And related to this, sending a message to a thread (or creating a new thread) results in automatically subscribing to the thread.
1 parent 85b9581 commit 9153578

File tree

14 files changed

+723
-13
lines changed

14 files changed

+723
-13
lines changed

app/controllers/activities_controller.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def index
77
@search_query = params[:q].to_s.strip
88
@activities = base_scope
99
.then { |scope| apply_query(scope) }
10-
.includes(subject: [:topic, :message])
10+
.includes(subject: [:topic, { message: :sender }])
1111
.order(created_at: :desc)
1212
.limit(100)
1313
mark_shown_as_read!(@activities)
@@ -28,8 +28,13 @@ def base_scope
2828
def apply_query(scope)
2929
return scope if @search_query.blank?
3030

31-
scope.joins("INNER JOIN notes ON notes.id = activities.subject_id AND activities.subject_type = 'Note'")
32-
.where("notes.body ILIKE ?", "%#{@search_query}%")
31+
note_query = scope.joins("INNER JOIN notes ON notes.id = activities.subject_id AND activities.subject_type = 'Note'")
32+
.where("notes.body ILIKE ?", "%#{@search_query}%")
33+
34+
message_query = scope.joins("INNER JOIN messages ON messages.id = activities.subject_id AND activities.subject_type = 'Message'")
35+
.where("messages.body ILIKE ? OR messages.subject ILIKE ?", "%#{@search_query}%", "%#{@search_query}%")
36+
37+
Activity.from("(#{note_query.to_sql} UNION #{message_query.to_sql}) AS activities")
3338
end
3439

3540
def mark_shown_as_read!(activities)

app/helpers/activities_helper.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module ActivitiesHelper
4+
def activity_type_label(activity)
5+
case activity.activity_type
6+
when "topic_message_received"
7+
subject = activity.subject
8+
if subject.is_a?(Message)
9+
"New message from #{subject.sender.name}"
10+
else
11+
"Topic message received"
12+
end
13+
else
14+
activity.activity_type.humanize
15+
end
16+
end
17+
end

app/models/alias.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class Alias < ApplicationRecord
99
validates :email, presence: true
1010
validates :name, uniqueness: { scope: :email }
1111

12+
after_commit :auto_star_recent_topics, if: :saved_change_to_user_id?
13+
1214

1315
scope :by_email, ->(email) {
1416
where("lower(trim(email)) = lower(trim(?))", email)
@@ -92,4 +94,22 @@ def contributor_badge
9294
end
9395
end
9496

97+
private
98+
99+
def auto_star_recent_topics
100+
return unless user_id.present?
101+
102+
one_year_ago = 1.year.ago
103+
104+
topic_ids = Message.joins("INNER JOIN topics ON topics.id = messages.topic_id")
105+
.where(messages: { sender_id: id })
106+
.where("topics.updated_at >= ?", one_year_ago)
107+
.distinct
108+
.pluck(:topic_id)
109+
110+
topic_ids.each do |topic_id|
111+
TopicStar.find_or_create_by(user_id: user_id, topic_id: topic_id)
112+
rescue ActiveRecord::RecordNotUnique
113+
end
114+
end
95115
end

app/services/email_ingestor.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def ingest_raw(raw_message, fallback_threading: false)
5151

5252
handle_attachments(m, msg)
5353

54+
MessageActivityBuilder.new(msg).process!
55+
5456
msg
5557
end
5658

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
class MessageActivityBuilder
4+
def initialize(message)
5+
@message = message
6+
end
7+
8+
def process!
9+
ActiveRecord::Base.transaction do
10+
auto_star_topic_for_sender
11+
fan_out_to_starring_users
12+
end
13+
end
14+
15+
private
16+
17+
def auto_star_topic_for_sender
18+
sender_user = @message.sender.user
19+
return unless sender_user
20+
21+
TopicStar.find_or_create_by(user: sender_user, topic: @message.topic)
22+
rescue ActiveRecord::RecordNotUnique
23+
# Race condition - another process already created the star
24+
end
25+
26+
def fan_out_to_starring_users
27+
starring_user_ids = @message.topic.topic_stars.pluck(:user_id)
28+
return if starring_user_ids.empty?
29+
30+
sender_user_id = @message.sender.user_id
31+
payload = build_payload
32+
33+
starring_user_ids.each do |user_id|
34+
read_at = (user_id == sender_user_id) ? Time.current : nil
35+
36+
Activity.create!(
37+
user_id: user_id,
38+
activity_type: "topic_message_received",
39+
subject: @message,
40+
payload: payload,
41+
read_at: read_at,
42+
hidden: false
43+
)
44+
end
45+
end
46+
47+
def build_payload
48+
{
49+
topic_id: @message.topic_id,
50+
message_id: @message.id
51+
}
52+
end
53+
end

app/views/activities/index.html.slim

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,33 @@
1212
- if @activities.any?
1313
.activities-list
1414
- @activities.each do |activity|
15-
- note = activity.subject if activity.subject_type == "Note"
15+
- subject = activity.subject
1616
.activity-card class=("is-unread" if activity.read_at.nil?)
1717
.activity-meta
18-
span.activity-type = activity.activity_type.humanize
18+
span.activity-type = activity_type_label(activity)
1919
span.activity-time title=absolute_time_display(activity.created_at) = smart_time_display(activity.created_at)
2020
- if activity.read_at.nil?
2121
span.activity-unread-badge Unread
2222

23-
- if note
23+
- if subject.is_a?(Note)
2424
.activity-body
2525
.activity-title
26-
= link_to(note.topic.title, topic_path(note.topic, anchor: note.message_id ? message_dom_id(note.message) : "thread-notes"))
27-
.activity-snippet = truncate(note.body, length: 160)
28-
- if note.note_tags.any?
26+
= link_to(subject.topic.title, topic_path(subject.topic, anchor: subject.message_id ? message_dom_id(subject.message) : "thread-notes"))
27+
.activity-snippet = truncate(subject.body, length: 160)
28+
- if subject.note_tags.any?
2929
.activity-tags
30-
- note.note_tags.each do |tag|
30+
- subject.note_tags.each do |tag|
3131
span.note-tag ##{tag.tag}
32-
- if current_user.id == note.author_id
32+
- if current_user.id == subject.author_id
3333
.activity-actions
34-
= button_to "Delete note", note_path(note), method: :delete, class: "note-delete-button", form: { data: { turbo_confirm: "Delete this note?" } }
34+
= button_to "Delete note", note_path(subject), method: :delete, class: "note-delete-button", form: { data: { turbo_confirm: "Delete this note?" } }
35+
- elsif subject.is_a?(Message)
36+
.activity-body
37+
.activity-title
38+
= link_to(subject.topic.title, topic_path(subject.topic, anchor: message_dom_id(subject)))
39+
.activity-snippet
40+
strong> = subject.subject
41+
= truncate(subject.body, length: 140)
3542
- else
3643
.activity-body
3744
span.activity-missing Content not available anymore.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
class AutoStarTopicsForExistingUsers < ActiveRecord::Migration[8.0]
4+
def up
5+
one_year_ago = 1.year.ago
6+
7+
execute <<-SQL
8+
INSERT INTO topic_stars (user_id, topic_id, created_at, updated_at)
9+
SELECT DISTINCT
10+
aliases.user_id,
11+
messages.topic_id,
12+
NOW(),
13+
NOW()
14+
FROM messages
15+
INNER JOIN aliases ON aliases.id = messages.sender_id
16+
INNER JOIN topics ON topics.id = messages.topic_id
17+
WHERE aliases.user_id IS NOT NULL
18+
AND topics.updated_at >= '#{one_year_ago.to_fs(:db)}'
19+
AND NOT EXISTS (
20+
SELECT 1 FROM topic_stars
21+
WHERE topic_stars.user_id = aliases.user_id
22+
AND topic_stars.topic_id = messages.topic_id
23+
)
24+
SQL
25+
end
26+
27+
def down
28+
end
29+
end

db/schema.rb

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/seeds.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,4 +971,131 @@ def mark_aware_until(user:, topic:, message:, timestamp:)
971971

972972
# Leave some threads entirely new/unaware (e.g., past_topic, moderate_topic_2, contrib_topic)
973973

974+
# Topic stars and message notifications
975+
puts "Creating topic stars and message notification activities..."
976+
977+
TopicStar.create!(user: alice_user, topic: patch_topic)
978+
TopicStar.create!(user: bob_user, topic: patch_topic)
979+
TopicStar.create!(user: carol_user, topic: patch_topic)
980+
981+
TopicStar.create!(user: alice_user, topic: rfc_topic)
982+
TopicStar.create!(user: carol_user, topic: rfc_topic)
983+
984+
TopicStar.create!(user: bob_user, topic: discussion_topic)
985+
TopicStar.create!(user: carol_user, topic: discussion_topic)
986+
TopicStar.create!(user: dave_user, topic: discussion_topic)
987+
988+
TopicStar.create!(user: alice_user, topic: recent_topic)
989+
TopicStar.create!(user: dave_user, topic: recent_topic)
990+
991+
Activity.create!(
992+
user: alice_user,
993+
activity_type: "topic_message_received",
994+
subject: msg2,
995+
payload: { topic_id: patch_topic.id, message_id: msg2.id },
996+
read_at: nil,
997+
created_at: msg2.created_at,
998+
updated_at: msg2.created_at
999+
)
1000+
1001+
Activity.create!(
1002+
user: bob_user,
1003+
activity_type: "topic_message_received",
1004+
subject: msg3,
1005+
payload: { topic_id: patch_topic.id, message_id: msg3.id },
1006+
read_at: timestamp_now,
1007+
created_at: msg3.created_at,
1008+
updated_at: msg3.created_at
1009+
)
1010+
1011+
Activity.create!(
1012+
user: carol_user,
1013+
activity_type: "topic_message_received",
1014+
subject: msg3,
1015+
payload: { topic_id: patch_topic.id, message_id: msg3.id },
1016+
read_at: nil,
1017+
created_at: msg3.created_at,
1018+
updated_at: msg3.created_at
1019+
)
1020+
1021+
Activity.create!(
1022+
user: alice_user,
1023+
activity_type: "topic_message_received",
1024+
subject: rfc_msg2,
1025+
payload: { topic_id: rfc_topic.id, message_id: rfc_msg2.id },
1026+
read_at: timestamp_now,
1027+
created_at: rfc_msg2.created_at,
1028+
updated_at: rfc_msg2.created_at
1029+
)
1030+
1031+
Activity.create!(
1032+
user: carol_user,
1033+
activity_type: "topic_message_received",
1034+
subject: rfc_msg2,
1035+
payload: { topic_id: rfc_topic.id, message_id: rfc_msg2.id },
1036+
read_at: nil,
1037+
created_at: rfc_msg2.created_at,
1038+
updated_at: rfc_msg2.created_at
1039+
)
1040+
1041+
Activity.create!(
1042+
user: bob_user,
1043+
activity_type: "topic_message_received",
1044+
subject: disc_msg3,
1045+
payload: { topic_id: discussion_topic.id, message_id: disc_msg3.id },
1046+
read_at: nil,
1047+
created_at: disc_msg3.created_at,
1048+
updated_at: disc_msg3.created_at
1049+
)
1050+
1051+
Activity.create!(
1052+
user: carol_user,
1053+
activity_type: "topic_message_received",
1054+
subject: disc_msg4,
1055+
payload: { topic_id: discussion_topic.id, message_id: disc_msg4.id },
1056+
read_at: timestamp_now,
1057+
created_at: disc_msg4.created_at,
1058+
updated_at: disc_msg4.created_at
1059+
)
1060+
1061+
Activity.create!(
1062+
user: dave_user,
1063+
activity_type: "topic_message_received",
1064+
subject: disc_msg4,
1065+
payload: { topic_id: discussion_topic.id, message_id: disc_msg4.id },
1066+
read_at: nil,
1067+
created_at: disc_msg4.created_at,
1068+
updated_at: disc_msg4.created_at
1069+
)
1070+
1071+
recent_reply = create_message(
1072+
topic: recent_topic,
1073+
sender: carol_alias,
1074+
subject: "Re: #{recent_topic.title}",
1075+
body: "Fresh reply to demonstrate starred topic notifications.",
1076+
created_at: now - 2.hours,
1077+
reply_to: recent_msg1,
1078+
message_id_suffix: "recent-3"
1079+
)
1080+
1081+
Activity.create!(
1082+
user: alice_user,
1083+
activity_type: "topic_message_received",
1084+
subject: recent_reply,
1085+
payload: { topic_id: recent_topic.id, message_id: recent_reply.id },
1086+
read_at: nil,
1087+
created_at: recent_reply.created_at,
1088+
updated_at: recent_reply.created_at
1089+
)
1090+
1091+
Activity.create!(
1092+
user: dave_user,
1093+
activity_type: "topic_message_received",
1094+
subject: recent_reply,
1095+
payload: { topic_id: recent_topic.id, message_id: recent_reply.id },
1096+
read_at: nil,
1097+
created_at: recent_reply.created_at,
1098+
updated_at: recent_reply.created_at
1099+
)
1100+
9741101
puts "Development seed data loaded."

spec/factories/activities.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :activity do
5+
association :user
6+
association :subject, factory: :note
7+
activity_type { "note_created" }
8+
payload { {} }
9+
hidden { false }
10+
read_at { nil }
11+
12+
trait :read do
13+
read_at { 1.hour.ago }
14+
end
15+
16+
trait :hidden do
17+
hidden { true }
18+
end
19+
20+
trait :for_message do
21+
association :subject, factory: :message
22+
activity_type { "topic_message_received" }
23+
payload do
24+
{
25+
topic_id: subject.topic_id,
26+
message_id: subject.id
27+
}
28+
end
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)