Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/assets/stylesheets/components/topics.css
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,20 @@ a.topic-icon {
}
}

.activity-ignore {
display: none;
color: var(--color-text-secondary);
}

.activity-ignore.is-ignored {
display: inline-block;
background-color: var(--color-bg-activity-team);
}

.topic-row:hover .activity-ignore {
display: inline-block;
}

.is-hidden {
display: none !important;
}
Expand Down
39 changes: 37 additions & 2 deletions app/controllers/topics_controller.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
class TopicsController < ApplicationController
include DraftSidebarLoader

before_action :set_topic, only: [ :show, :message_batch, :attachments_sidebar, :patchsets_sidebar, :aware, :read_all, :unread_all, :star, :unstar, :latest_patchset, :summary, :messages ]
before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar ]
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 ]
before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar, :ignore, :unignore ]

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

def index
@search_query = nil
base_query = Topic.includes(*TOPIC_LIST_PRELOADS)
base_query = apply_default_ignore_filter(base_query) if user_signed_in?

apply_cursor_pagination(base_query)
preload_topic_participants
Expand Down Expand Up @@ -228,6 +229,30 @@ def unstar
end
end

def ignore
TopicIgnore.find_or_create_by!(user: current_user, topic: @topic)
respond_to do |format|
format.turbo_stream { render :update_ignore_state }
format.json { render json: { ignored: true } }
format.html { redirect_to topic_path(@topic) }
end
rescue ActiveRecord::RecordNotUnique
respond_to do |format|
format.turbo_stream { render :update_ignore_state }
format.json { render json: { ignored: true } }
format.html { redirect_to topic_path(@topic) }
end
end

def unignore
TopicIgnore.where(user: current_user, topic: @topic).destroy_all
respond_to do |format|
format.turbo_stream { render :update_ignore_state }
format.json { render json: { ignored: false } }
format.html { redirect_to topic_path(@topic) }
end
end

def latest_patchset
latest_message = latest_patchset_message
return head :not_found unless latest_message
Expand Down Expand Up @@ -569,6 +594,16 @@ def assign_branch_segments!
end
end

# Starring always wins: an ignored-and-starred topic still appears.
def apply_default_ignore_filter(base_query)
user_id = current_user.id
base_query.where(
"topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \
"OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)",
user_id, user_id
)
end

def apply_cursor_pagination(base_query)
@viewing_since = viewing_since_param

Expand Down
18 changes: 18 additions & 0 deletions app/helpers/topics_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ def star_icon_html(topic:, star_data:)
end
end

def ignore_icon_html(topic:, ignored:)
ignored = ignored || false
path = ignored ? unignore_topic_path(topic) : ignore_topic_path(topic)
method = ignored ? :delete : :post
icon_class = ignored ? "fa-solid fa-eye-slash" : "fa-regular fa-eye-slash"
classes = [ "topic-icon", "activity-ignore" ]
classes << "is-ignored" if ignored

link_to path,
method: method,
data: { turbo_method: method, turbo_stream: true },
class: classes.join(" "),
title: ignored ? "Unignore" : "Ignore",
id: dom_id(topic, "ignore_button") do
tag.i(class: icon_class)
end
end

# Replaces app/views/topics/_participation_icon.html.slim
def participation_icon_html(topic:, participation:)
participation = participation || {}
Expand Down
21 changes: 21 additions & 0 deletions app/models/topic_ignore.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class TopicIgnore < ApplicationRecord
belongs_to :user
belongs_to :topic

validates :user_id, uniqueness: { scope: :topic_id }

def self.toggle_ignore(user:, topic:)
existing = find_by(user: user, topic: topic)
if existing
existing.destroy
false
else
create!(user: user, topic: topic)
true
end
end

def self.ignored_by_user?(user:, topic:)
exists?(user: user, topic: topic)
end
end
45 changes: 45 additions & 0 deletions app/services/search/query_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def build
return Result.new(relation: Topic.none, warnings: []) if @ast.nil?

relation = apply_node(@ast, Topic.all)

if @user && !has_ignored_selector?(@ast)
relation = apply_default_ignore_filter(relation)
end

Result.new(relation: relation, warnings: @warnings)
end

Expand Down Expand Up @@ -99,6 +104,8 @@ def apply_selector(node, relation)
apply_new_selector(value, relation, negated: negated)
when :starred
apply_starred_selector(value, relation, negated: negated)
when :ignored
apply_ignored_selector(value, relation, negated: negated)
when :notes
apply_notes_selector(value, relation, negated: negated)
when :tag
Expand Down Expand Up @@ -579,6 +586,44 @@ def apply_starred_selector(value, relation, negated:)
end
end

def apply_ignored_selector(value, relation, negated:)
result = @value_resolver.resolve_state_subject(value)
@warnings.concat(result.warnings)

user_ids = result.user_ids
return relation if user_ids.empty?

ignored_topic_ids = TopicIgnore.where(user_id: user_ids).select(:topic_id)

if negated
relation.where.not(id: ignored_topic_ids)
else
relation.where(id: ignored_topic_ids)
end
end

def apply_default_ignore_filter(relation)
user_id = @user.id
relation.where(
"topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \
"OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)",
user_id, user_id
)
end

def has_ignored_selector?(node)
return false if node.nil?

case node[:type]
when :selector
node[:key] == :ignored
when :and, :or
node[:children].any? { |child| has_ignored_selector?(child) }
else
false
end
end

def apply_notes_selector(value, relation, negated:)
result = @value_resolver.resolve_state_subject(value)
@warnings.concat(result.warnings)
Expand Down
2 changes: 1 addition & 1 deletion app/services/search/query_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class Grammar < Parslet::Parser
str("title") | str("body") |
str("contributors") | str("participants") | str("messages") |
str("unread") | str("reading") | str("read") | str("new") |
str("starred") | str("notes") | str("tag") |
str("starred") | str("ignored") | str("notes") | str("tag") |
str("has") | str("commitfest") | str("list")
).as(:selector_key)
end
Expand Down
2 changes: 1 addition & 1 deletion app/services/search/query_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class QueryValidator

AUTHOR_SELECTORS = %i[from starter last_from].freeze

STATE_SELECTORS = %i[unread read reading new starred notes].freeze
STATE_SELECTORS = %i[unread read reading new starred ignored notes].freeze

CONTENT_SELECTORS = %i[title body].freeze

Expand Down
12 changes: 12 additions & 0 deletions app/services/topic_list_personalization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def initialize(user:, topics:)
preload_states
preload_note_counts
preload_star_data
preload_ignore_data
preload_participation
end

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

def ignored_for(topic)
@ignored_topic_ids.include?(topic.id)
end

private

attr_reader :user, :topics, :topic_ids
Expand Down Expand Up @@ -106,6 +111,13 @@ def preload_star_data
end
end

def preload_ignore_data
@ignored_topic_ids = Set.new
return if topic_ids.empty?

@ignored_topic_ids = TopicIgnore.where(user:, topic_id: topic_ids).pluck(:topic_id).to_set
end

def preload_participation
@participation = {}
return if topic_ids.empty?
Expand Down
2 changes: 2 additions & 0 deletions app/views/topics/_status_cell.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
- reading_unread_count = [total_count - read_count, 0].max
- status_class = "status-#{status}"
- status_class = "#{status_class} has-new-replies" if status.to_s == "reading"
- ignored = local_assigns[:ignored] || false
- star_data = star_data || {}
- icons_html = capture do
- if status.to_s == "reading"
= 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
i.fa-solid.fa-envelope
span.topic-icon-badge.topic-icon-badge-sup = reading_unread_count
= star_icon_html(topic: topic, star_data: star_data)
= ignore_icon_html(topic: topic, ignored: ignored)
= note_icon_html(topic: topic, count: note_count.to_i)
= team_readers_icon_html(topic: topic, readers: team_readers)
- commit_summary = @commit_summaries&.dig(topic.id)
Expand Down
3 changes: 2 additions & 1 deletion app/views/topics/_topics.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
- participation = personalization ? personalization.participation_for(topic) : {}
- team_readers = personalization ? personalization.team_readers_for(topic) : []
- star_data = personalization ? personalization.star_data_for(topic) : {}
- ignored = personalization ? personalization.ignored_for(topic) : false
- row_class = [ "topic-row" ]
- if personalization
- status = state[:status] || "new"
- row_class << "topic-#{status}"
- row_class << "has-new-replies" if status.to_s == "reading"

tr id=dom_id(topic) class=row_class.join(" ") data-topic-id=topic.id data-last-message-id=topic.last_message_id
= render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data }
= render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data, ignored: ignored }
td.topic-mailing-lists data-label="Mailing Lists"
- topic_lists = @topic_mailing_lists_map&.dig(topic.id) || []
- if topic_lists.any?
Expand Down
1 change: 1 addition & 0 deletions app/views/topics/update_ignore_state.turbo_stream.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
= turbo_stream.remove dom_id(@topic)
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
post :unread_all
post :star
delete :unstar
post :ignore
delete :unignore
get :latest_patchset
get :message_batch
get :attachments_sidebar
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20260713113313_create_topic_ignores.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateTopicIgnores < ActiveRecord::Migration[8.0]
def change
create_table :topic_ignores do |t|
t.references :user, null: false, foreign_key: true
t.references :topic, null: false, foreign_key: true

t.timestamps
end

add_index :topic_ignores, [ :user_id, :topic_id ], unique: true
end
end
33 changes: 30 additions & 3 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading