Skip to content

Commit bd654db

Browse files
authored
feat: add ignore thread feature (#117)
Users can ignore mailing list threads from the topic list via an inline eye-slash button (visible on row hover). Ignored threads are suppressed from all views and search results by default. - New TopicIgnore junction table (mirrors TopicStar pattern) - POST /topics/:id/ignore and DELETE /topics/:id/unignore actions - Turbo Stream removes the row immediately on ignore/unignore - ignored:me search selector shows only ignored threads - Default QueryBuilder filter excludes ignored threads for signed-in users - Starring always wins: ignored+starred threads still appear in all views - apply_cursor_pagination applies the ignore filter at SQL level for index Signed-off-by: Kai Wagner <kai.wagner@percona.com>
1 parent 41382f6 commit bd654db

19 files changed

Lines changed: 401 additions & 8 deletions

app/assets/stylesheets/components/topics.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,20 @@ a.topic-icon {
331331
}
332332
}
333333

334+
.activity-ignore {
335+
display: none;
336+
color: var(--color-text-secondary);
337+
}
338+
339+
.activity-ignore.is-ignored {
340+
display: inline-block;
341+
background-color: var(--color-bg-activity-team);
342+
}
343+
344+
.topic-row:hover .activity-ignore {
345+
display: inline-block;
346+
}
347+
334348
.is-hidden {
335349
display: none !important;
336350
}

app/controllers/topics_controller.rb

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
class TopicsController < ApplicationController
22
include DraftSidebarLoader
33

4-
before_action :set_topic, only: [ :show, :message_batch, :attachments_sidebar, :patchsets_sidebar, :aware, :read_all, :unread_all, :star, :unstar, :latest_patchset, :summary, :messages ]
5-
before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar ]
4+
before_action :set_topic, only: [ :show, :message_batch, :attachments_sidebar, :patchsets_sidebar, :aware, :read_all, :unread_all, :star, :unstar, :ignore, :unignore, :latest_patchset, :summary, :messages ]
5+
before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar, :ignore, :unignore ]
66

77
TOPIC_LIST_PRELOADS = [ :creator, { creator_person: :default_alias }, { last_sender_person: :default_alias } ].freeze
88

99
def index
1010
@search_query = nil
1111
base_query = Topic.includes(*TOPIC_LIST_PRELOADS)
12+
base_query = apply_default_ignore_filter(base_query) if user_signed_in?
1213

1314
apply_cursor_pagination(base_query)
1415
preload_topic_participants
@@ -228,6 +229,30 @@ def unstar
228229
end
229230
end
230231

232+
def ignore
233+
TopicIgnore.find_or_create_by!(user: current_user, topic: @topic)
234+
respond_to do |format|
235+
format.turbo_stream { render :update_ignore_state }
236+
format.json { render json: { ignored: true } }
237+
format.html { redirect_to topic_path(@topic) }
238+
end
239+
rescue ActiveRecord::RecordNotUnique
240+
respond_to do |format|
241+
format.turbo_stream { render :update_ignore_state }
242+
format.json { render json: { ignored: true } }
243+
format.html { redirect_to topic_path(@topic) }
244+
end
245+
end
246+
247+
def unignore
248+
TopicIgnore.where(user: current_user, topic: @topic).destroy_all
249+
respond_to do |format|
250+
format.turbo_stream { render :update_ignore_state }
251+
format.json { render json: { ignored: false } }
252+
format.html { redirect_to topic_path(@topic) }
253+
end
254+
end
255+
231256
def latest_patchset
232257
latest_message = latest_patchset_message
233258
return head :not_found unless latest_message
@@ -569,6 +594,16 @@ def assign_branch_segments!
569594
end
570595
end
571596

597+
# Starring always wins: an ignored-and-starred topic still appears.
598+
def apply_default_ignore_filter(base_query)
599+
user_id = current_user.id
600+
base_query.where(
601+
"topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \
602+
"OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)",
603+
user_id, user_id
604+
)
605+
end
606+
572607
def apply_cursor_pagination(base_query)
573608
@viewing_since = viewing_since_param
574609

app/helpers/topics_helper.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ def star_icon_html(topic:, star_data:)
160160
end
161161
end
162162

163+
def ignore_icon_html(topic:, ignored:)
164+
ignored = ignored || false
165+
path = ignored ? unignore_topic_path(topic) : ignore_topic_path(topic)
166+
method = ignored ? :delete : :post
167+
icon_class = ignored ? "fa-solid fa-eye-slash" : "fa-regular fa-eye-slash"
168+
classes = [ "topic-icon", "activity-ignore" ]
169+
classes << "is-ignored" if ignored
170+
171+
link_to path,
172+
method: method,
173+
data: { turbo_method: method, turbo_stream: true },
174+
class: classes.join(" "),
175+
title: ignored ? "Unignore" : "Ignore",
176+
id: dom_id(topic, "ignore_button") do
177+
tag.i(class: icon_class)
178+
end
179+
end
180+
163181
# Replaces app/views/topics/_participation_icon.html.slim
164182
def participation_icon_html(topic:, participation:)
165183
participation = participation || {}

app/models/topic_ignore.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class TopicIgnore < ApplicationRecord
2+
belongs_to :user
3+
belongs_to :topic
4+
5+
validates :user_id, uniqueness: { scope: :topic_id }
6+
7+
def self.toggle_ignore(user:, topic:)
8+
existing = find_by(user: user, topic: topic)
9+
if existing
10+
existing.destroy
11+
false
12+
else
13+
create!(user: user, topic: topic)
14+
true
15+
end
16+
end
17+
18+
def self.ignored_by_user?(user:, topic:)
19+
exists?(user: user, topic: topic)
20+
end
21+
end

app/services/search/query_builder.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ def build
2020
return Result.new(relation: Topic.none, warnings: []) if @ast.nil?
2121

2222
relation = apply_node(@ast, Topic.all)
23+
24+
if @user && !has_ignored_selector?(@ast)
25+
relation = apply_default_ignore_filter(relation)
26+
end
27+
2328
Result.new(relation: relation, warnings: @warnings)
2429
end
2530

@@ -99,6 +104,8 @@ def apply_selector(node, relation)
99104
apply_new_selector(value, relation, negated: negated)
100105
when :starred
101106
apply_starred_selector(value, relation, negated: negated)
107+
when :ignored
108+
apply_ignored_selector(value, relation, negated: negated)
102109
when :notes
103110
apply_notes_selector(value, relation, negated: negated)
104111
when :tag
@@ -579,6 +586,44 @@ def apply_starred_selector(value, relation, negated:)
579586
end
580587
end
581588

589+
def apply_ignored_selector(value, relation, negated:)
590+
result = @value_resolver.resolve_state_subject(value)
591+
@warnings.concat(result.warnings)
592+
593+
user_ids = result.user_ids
594+
return relation if user_ids.empty?
595+
596+
ignored_topic_ids = TopicIgnore.where(user_id: user_ids).select(:topic_id)
597+
598+
if negated
599+
relation.where.not(id: ignored_topic_ids)
600+
else
601+
relation.where(id: ignored_topic_ids)
602+
end
603+
end
604+
605+
def apply_default_ignore_filter(relation)
606+
user_id = @user.id
607+
relation.where(
608+
"topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \
609+
"OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)",
610+
user_id, user_id
611+
)
612+
end
613+
614+
def has_ignored_selector?(node)
615+
return false if node.nil?
616+
617+
case node[:type]
618+
when :selector
619+
node[:key] == :ignored
620+
when :and, :or
621+
node[:children].any? { |child| has_ignored_selector?(child) }
622+
else
623+
false
624+
end
625+
end
626+
582627
def apply_notes_selector(value, relation, negated:)
583628
result = @value_resolver.resolve_state_subject(value)
584629
@warnings.concat(result.warnings)

app/services/search/query_parser.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Grammar < Parslet::Parser
4343
str("title") | str("body") |
4444
str("contributors") | str("participants") | str("messages") |
4545
str("unread") | str("reading") | str("read") | str("new") |
46-
str("starred") | str("notes") | str("tag") |
46+
str("starred") | str("ignored") | str("notes") | str("tag") |
4747
str("has") | str("commitfest") | str("list")
4848
).as(:selector_key)
4949
end

app/services/search/query_validator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class QueryValidator
1616

1717
AUTHOR_SELECTORS = %i[from starter last_from].freeze
1818

19-
STATE_SELECTORS = %i[unread read reading new starred notes].freeze
19+
STATE_SELECTORS = %i[unread read reading new starred ignored notes].freeze
2020

2121
CONTENT_SELECTORS = %i[title body].freeze
2222

app/services/topic_list_personalization.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def initialize(user:, topics:)
66
preload_states
77
preload_note_counts
88
preload_star_data
9+
preload_ignore_data
910
preload_participation
1011
end
1112

@@ -29,6 +30,10 @@ def star_data_for(topic)
2930
@star_data[topic.id] || { starred_by_me: false, team_starrers: [] }
3031
end
3132

33+
def ignored_for(topic)
34+
@ignored_topic_ids.include?(topic.id)
35+
end
36+
3237
private
3338

3439
attr_reader :user, :topics, :topic_ids
@@ -106,6 +111,13 @@ def preload_star_data
106111
end
107112
end
108113

114+
def preload_ignore_data
115+
@ignored_topic_ids = Set.new
116+
return if topic_ids.empty?
117+
118+
@ignored_topic_ids = TopicIgnore.where(user:, topic_id: topic_ids).pluck(:topic_id).to_set
119+
end
120+
109121
def preload_participation
110122
@participation = {}
111123
return if topic_ids.empty?

app/views/topics/_status_cell.html.slim

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
- reading_unread_count = [total_count - read_count, 0].max
77
- status_class = "status-#{status}"
88
- status_class = "#{status_class} has-new-replies" if status.to_s == "reading"
9+
- ignored = local_assigns[:ignored] || false
910
- star_data = star_data || {}
1011
- icons_html = capture do
1112
- if status.to_s == "reading"
1213
= link_to topic_path(topic, anchor: "first-unread"), class: "topic-icon topic-icon-reading", title: "Jump to first unread message (#{reading_unread_count} unread)" do
1314
i.fa-solid.fa-envelope
1415
span.topic-icon-badge.topic-icon-badge-sup = reading_unread_count
1516
= star_icon_html(topic: topic, star_data: star_data)
17+
= ignore_icon_html(topic: topic, ignored: ignored)
1618
= note_icon_html(topic: topic, count: note_count.to_i)
1719
= team_readers_icon_html(topic: topic, readers: team_readers)
1820
- commit_summary = @commit_summaries&.dig(topic.id)

app/views/topics/_topics.html.slim

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717
- participation = personalization ? personalization.participation_for(topic) : {}
1818
- team_readers = personalization ? personalization.team_readers_for(topic) : []
1919
- star_data = personalization ? personalization.star_data_for(topic) : {}
20+
- ignored = personalization ? personalization.ignored_for(topic) : false
2021
- row_class = [ "topic-row" ]
2122
- if personalization
2223
- status = state[:status] || "new"
2324
- row_class << "topic-#{status}"
2425
- row_class << "has-new-replies" if status.to_s == "reading"
2526

2627
tr id=dom_id(topic) class=row_class.join(" ") data-topic-id=topic.id data-last-message-id=topic.last_message_id
27-
= render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data }
28+
= render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data, ignored: ignored }
2829
td.topic-mailing-lists data-label="Mailing Lists"
2930
- topic_lists = @topic_mailing_lists_map&.dig(topic.id) || []
3031
- if topic_lists.any?

0 commit comments

Comments
 (0)