From a8e036c02ae6e37ca9f4ebaaeecd3e2d79150f45 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Mon, 13 Jul 2026 13:54:29 +0200 Subject: [PATCH 1/2] refactor: render per-user topic state in initial response for logged-in users Remove the two-pass approach (cached public render + user_state_frame Turbo Frame second pass) for authenticated users. Instead, load per-user state (read status, stars, participation, ignore) in the initial index/search action and render topic rows with full user context from the start. The anonymous/unauthenticated path is unchanged and continues to use the public cache. Signed-off-by: Kai Wagner --- app/controllers/topics_controller.rb | 76 ++++++++++++++++++++--- app/views/topics/_topics_authed.html.slim | 21 +++++++ app/views/topics/index.html.slim | 12 ++-- app/views/topics/index.turbo_stream.slim | 10 +-- app/views/topics/search.html.slim | 7 ++- app/views/topics/search.turbo_stream.slim | 10 +-- 6 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 app/views/topics/_topics_authed.html.slim diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index a93be77..68bb6f9 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -1,8 +1,8 @@ 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, :aware_bulk, :aware_all, :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, :aware_bulk, :aware_all, :read_all, :unread_all, :star, :unstar, :ignore, :unignore ] def index @search_query = nil @@ -13,7 +13,17 @@ def index preload_commitfest_summaries preload_topic_mailing_lists @new_topics_count = 0 - @page_cache_key = topics_page_cache_key + + if user_signed_in? + preload_topic_states + preload_note_counts + preload_participation_flags + preload_star_counts + preload_ignore_states + @page_cache_key = nil + else + @page_cache_key = topics_page_cache_key + end load_visible_tags if user_signed_in? load_saved_searches @@ -22,10 +32,14 @@ def index respond_to do |format| format.html format.turbo_stream do - body = topics_turbo_stream_cache_fetch do - render_to_string(:index, formats: [ :turbo_stream ]) + if user_signed_in? + render :index + else + body = topics_turbo_stream_cache_fetch do + render_to_string(:index, formats: [ :turbo_stream ]) + end + render body:, content_type: "text/vnd.turbo-stream.html" end - render body:, content_type: "text/vnd.turbo-stream.html" end end end @@ -235,6 +249,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 @@ -384,7 +422,13 @@ def search preload_topic_participants preload_commitfest_summaries preload_topic_mailing_lists - preload_participation_flags if user_signed_in? + if user_signed_in? + preload_topic_states + preload_note_counts + preload_participation_flags + preload_star_counts + preload_ignore_states + end load_visible_tags if user_signed_in? load_saved_searches @all_mailing_lists = MailingList.order(:display_name) @@ -448,6 +492,7 @@ def user_state_frame preload_participation_flags preload_commitfest_summaries preload_star_counts + preload_ignore_states preload_topic_participants preload_topic_mailing_lists @@ -646,6 +691,15 @@ def apply_cursor_pagination(base_query) .having("MAX(messages.created_at) <= ?", @viewing_since) .select("topics.*, MAX(messages.created_at) as last_activity") + if user_signed_in? + user_id = current_user.id + windowed_query = windowed_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 + if params[:cursor].present? cursor_time, cursor_id = params[:cursor].split("_") @topics = windowed_query.having("(MAX(messages.created_at), topics.id) < (?, ?)", @@ -747,6 +801,14 @@ def preload_star_counts end end + def preload_ignore_states + topic_ids = @topics.map(&:id) + return unless user_signed_in? && topic_ids.any? + + ignored_ids = TopicIgnore.where(user: current_user, topic_id: topic_ids).pluck(:topic_id).to_set + @topic_ignore_data = topic_ids.index_with { |id| ignored_ids.include?(id) } + end + def load_visible_tags @available_note_tags = NoteTag.joins(:note) .merge(Note.active.visible_to(current_user)) diff --git a/app/views/topics/_topics_authed.html.slim b/app/views/topics/_topics_authed.html.slim new file mode 100644 index 0000000..acc333e --- /dev/null +++ b/app/views/topics/_topics_authed.html.slim @@ -0,0 +1,21 @@ +- @topic_states ||= {} +- @topic_note_counts ||= {} +- @participation_flags ||= {} +- @topic_star_data ||= {} +- @topic_ignore_data ||= {} + +- if topics.empty? + tr.topic-empty-row + td colspan=3 + .topics-empty-state + i.fa-regular.fa-folder-open aria-hidden="true" + p No topics found. + +- topics.each do |topic| + - state = @topic_states[topic.id] || {} + - note_count = @topic_note_counts&.dig(topic.id).to_i + - participation = @participation_flags&.dig(topic.id) || {} + - team_readers = state[:team_readers] || [] + - star_data = @topic_star_data&.dig(topic.id) || {} + - ignored = @topic_ignore_data[topic.id] || false + = render partial: "topics/topic_row_user", locals: { topic: topic, state: state, note_count: note_count, participation: participation, team_readers: team_readers, star_data: star_data, ignored: ignored } diff --git a/app/views/topics/index.html.slim b/app/views/topics/index.html.slim index c1d22da..1ffa4c0 100644 --- a/app/views/topics/index.html.slim +++ b/app/views/topics/index.html.slim @@ -12,7 +12,10 @@ th.activity-header Activity th.participants-header Participants tbody#topics - = render partial: "topics", locals: { topics: @topics } + - if user_signed_in? + = render partial: "topics/topics_authed", locals: { topics: @topics } + - else + = render partial: "topics", locals: { topics: @topics } - if @topics.size == 25 - last_topic = @topics.last @@ -23,10 +26,9 @@ = turbo_frame_tag "pagination" - if user_signed_in? - #user-state-requests - = turbo_frame_tag "user-state-root", src: user_state_frame_topics_path(topic_ids: @topics.map(&:id), format: :turbo_stream), loading: :eager - -- if @page_cache_key + = render partial: "sidebar", locals: { available_note_tags: @available_note_tags, search_query: @search_query } + - cache_block.call +- elsif @page_cache_key = render partial: "sidebar", locals: { available_note_tags: @available_note_tags, search_query: @search_query } - cache(@page_cache_key) do - cache_block.call diff --git a/app/views/topics/index.turbo_stream.slim b/app/views/topics/index.turbo_stream.slim index 65ecea6..004d7f9 100644 --- a/app/views/topics/index.turbo_stream.slim +++ b/app/views/topics/index.turbo_stream.slim @@ -1,9 +1,11 @@ -= turbo_stream.append "topics" do - = render partial: "topics", locals: { topics: @topics } - - if user_signed_in? - - frame_id = params[:cursor].present? ? "user-state-#{params[:cursor]}" : "user-state-root" + = turbo_stream.append "topics" do + = render partial: "topics/topics_authed", locals: { topics: @topics } +- else + = turbo_stream.append "topics" do + = render partial: "topics", locals: { topics: @topics } = turbo_stream.append "user-state-requests" do + - frame_id = params[:cursor].present? ? "user-state-#{params[:cursor]}" : "user-state-root" = turbo_frame_tag frame_id, src: user_state_frame_topics_path(topic_ids: @topics.map(&:id), format: :turbo_stream), loading: :eager - if @topics.size == 25 diff --git a/app/views/topics/search.html.slim b/app/views/topics/search.html.slim index 23b41f0..da7dfb9 100644 --- a/app/views/topics/search.html.slim +++ b/app/views/topics/search.html.slim @@ -50,7 +50,7 @@ - if @search_query.present? - if @topics.any? - - if user_signed_in? + - unless user_signed_in? #user-state-requests = turbo_frame_tag "user-state-root", src: user_state_frame_topics_path(topic_ids: @topics.map(&:id), format: :turbo_stream), loading: :eager .topics-table @@ -62,7 +62,10 @@ th.activity-header Activity th.participants-header Participants tbody#topics - = render partial: "topics", locals: { topics: @topics } + - if user_signed_in? + = render partial: "topics/topics_authed", locals: { topics: @topics } + - else + = render partial: "topics", locals: { topics: @topics } - if @topics.size == 25 - last_topic = @topics.last diff --git a/app/views/topics/search.turbo_stream.slim b/app/views/topics/search.turbo_stream.slim index c5f74ab..c8fb7ff 100644 --- a/app/views/topics/search.turbo_stream.slim +++ b/app/views/topics/search.turbo_stream.slim @@ -1,9 +1,11 @@ -= turbo_stream.append "topics" do - = render partial: "topics", locals: { topics: @topics } - - if user_signed_in? - - frame_id = params[:cursor].present? ? "user-state-#{params[:cursor]}" : "user-state-root" + = turbo_stream.append "topics" do + = render partial: "topics/topics_authed", locals: { topics: @topics } +- else + = turbo_stream.append "topics" do + = render partial: "topics", locals: { topics: @topics } = turbo_stream.append "user-state-requests" do + - frame_id = params[:cursor].present? ? "user-state-#{params[:cursor]}" : "user-state-root" = turbo_frame_tag frame_id, src: user_state_frame_topics_path(topic_ids: @topics.map(&:id), format: :turbo_stream), loading: :eager - search_params = @saved_search ? { saved_search_id: @saved_search.id, team_id: @saved_search_team&.id }.compact : { q: @search_query } From 5136112c70a00d21817389f87ac2beb4840930b9 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Mon, 13 Jul 2026 13:54:45 +0200 Subject: [PATCH 2/2] feat: add ignore thread feature 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 --- app/assets/stylesheets/components/topics.css | 14 +++ app/helpers/topics_helper.rb | 18 +++ app/models/topic_ignore.rb | 21 ++++ app/services/search/query_builder.rb | 45 +++++++ app/services/search/query_parser.rb | 2 +- app/services/search/query_validator.rb | 2 +- app/views/topics/_status_cell.html.slim | 2 + app/views/topics/_topic_row_user.html.slim | 3 +- .../update_ignore_state.turbo_stream.slim | 1 + .../topics/user_state_frame.turbo_stream.slim | 3 +- config/routes.rb | 2 + .../20260713113313_create_topic_ignores.rb | 12 ++ db/schema.rb | 35 +++++- spec/factories/topic_ignores.rb | 6 + spec/models/topic_ignore_spec.rb | 51 ++++++++ spec/requests/topics_spec.rb | 110 ++++++++++++++++++ spec/services/search/query_builder_spec.rb | 48 ++++++++ 17 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 app/models/topic_ignore.rb create mode 100644 app/views/topics/update_ignore_state.turbo_stream.slim create mode 100644 db/migrate/20260713113313_create_topic_ignores.rb create mode 100644 spec/factories/topic_ignores.rb create mode 100644 spec/models/topic_ignore_spec.rb diff --git a/app/assets/stylesheets/components/topics.css b/app/assets/stylesheets/components/topics.css index 0196af3..c7e9528 100644 --- a/app/assets/stylesheets/components/topics.css +++ b/app/assets/stylesheets/components/topics.css @@ -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; } diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb index 75d3e8b..6da2acd 100644 --- a/app/helpers/topics_helper.rb +++ b/app/helpers/topics_helper.rb @@ -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 || {} diff --git a/app/models/topic_ignore.rb b/app/models/topic_ignore.rb new file mode 100644 index 0000000..bed9948 --- /dev/null +++ b/app/models/topic_ignore.rb @@ -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 diff --git a/app/services/search/query_builder.rb b/app/services/search/query_builder.rb index 4e3deca..37f0687 100644 --- a/app/services/search/query_builder.rb +++ b/app/services/search/query_builder.rb @@ -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 @@ -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 @@ -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) diff --git a/app/services/search/query_parser.rb b/app/services/search/query_parser.rb index d138106..cbece74 100644 --- a/app/services/search/query_parser.rb +++ b/app/services/search/query_parser.rb @@ -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 diff --git a/app/services/search/query_validator.rb b/app/services/search/query_validator.rb index 04b71b9..4ed14c1 100644 --- a/app/services/search/query_validator.rb +++ b/app/services/search/query_validator.rb @@ -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 diff --git a/app/views/topics/_status_cell.html.slim b/app/views/topics/_status_cell.html.slim index f9de424..741cad2 100644 --- a/app/views/topics/_status_cell.html.slim +++ b/app/views/topics/_status_cell.html.slim @@ -6,6 +6,7 @@ - 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" @@ -13,6 +14,7 @@ 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) - commitfest_summary = @commitfest_summaries&.dig(topic.id) diff --git a/app/views/topics/_topic_row_user.html.slim b/app/views/topics/_topic_row_user.html.slim index 129058b..0a59fea 100644 --- a/app/views/topics/_topic_row_user.html.slim +++ b/app/views/topics/_topic_row_user.html.slim @@ -3,6 +3,7 @@ - participation = participation || {} - team_readers = team_readers || [] - star_data = star_data || {} +- ignored = local_assigns[:ignored] || false - status = state[:status] || "new" - row_class = ["topic-row", "topic-#{status}"] - row_class << "has-new-replies" if status.to_s == "reading" @@ -12,7 +13,7 @@ - contributor_participants = tp_data[:contributors] || [] 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? diff --git a/app/views/topics/update_ignore_state.turbo_stream.slim b/app/views/topics/update_ignore_state.turbo_stream.slim new file mode 100644 index 0000000..f6c59af --- /dev/null +++ b/app/views/topics/update_ignore_state.turbo_stream.slim @@ -0,0 +1 @@ += turbo_stream.remove dom_id(@topic) diff --git a/app/views/topics/user_state_frame.turbo_stream.slim b/app/views/topics/user_state_frame.turbo_stream.slim index 9968789..cd8ea21 100644 --- a/app/views/topics/user_state_frame.turbo_stream.slim +++ b/app/views/topics/user_state_frame.turbo_stream.slim @@ -4,6 +4,7 @@ - participation = @participation_flags&.dig(topic.id) || {} - team_readers = state[:team_readers] || [] - star_data = @topic_star_data&.dig(topic.id) || {} + - ignored = @topic_ignore_data&.dig(topic.id) || false = turbo_stream.replace dom_id(topic) do - = render partial: "topics/topic_row_user", locals: { topic: topic, state: state, note_count: note_count, participation: participation, team_readers: team_readers, star_data: star_data } + = render partial: "topics/topic_row_user", locals: { topic: topic, state: state, note_count: note_count, participation: participation, team_readers: team_readers, star_data: star_data, ignored: ignored } diff --git a/config/routes.rb b/config/routes.rb index ae21619..67fdfba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,8 @@ post :unread_all post :star delete :unstar + post :ignore + delete :unignore get :latest_patchset get :message_batch get :attachments_sidebar diff --git a/db/migrate/20260713113313_create_topic_ignores.rb b/db/migrate/20260713113313_create_topic_ignores.rb new file mode 100644 index 0000000..950a67c --- /dev/null +++ b/db/migrate/20260713113313_create_topic_ignores.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f47a432..fd537df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_13_113313) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -662,6 +662,16 @@ t.index ["user_id"], name: "index_thread_awarenesses_on_user_id" end + create_table "topic_ignores", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "topic_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_topic_ignores_on_topic_id" + t.index ["user_id", "topic_id"], name: "index_topic_ignores_on_user_id_and_topic_id", unique: true + t.index ["user_id"], name: "index_topic_ignores_on_user_id" + end + create_table "topic_mailing_lists", force: :cascade do |t| t.bigint "topic_id", null: false t.bigint "mailing_list_id", null: false @@ -710,6 +720,18 @@ t.index ["user_id"], name: "index_topic_stars_on_user_id" end + create_table "topic_subscriptions", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "topic_id", null: false + t.string "unsubscribe_token", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_topic_subscriptions_on_topic_id" + t.index ["unsubscribe_token"], name: "index_topic_subscriptions_on_unsubscribe_token", unique: true + t.index ["user_id", "topic_id"], name: "index_topic_subscriptions_on_user_id_and_topic_id", unique: true + t.index ["user_id"], name: "index_topic_subscriptions_on_user_id" + end + create_table "topics", force: :cascade do |t| t.string "title", null: false t.bigint "creator_id", null: false @@ -770,6 +792,7 @@ t.datetime "deleted_at" t.bigint "person_id", null: false t.enum "mention_restriction", default: "anyone", null: false, enum_type: "user_mention_restriction" + t.boolean "bold_unread_threads", default: false, null: false t.boolean "open_threads_at_first_unread", default: false, null: false t.datetime "last_login_at" t.boolean "collapse_read_messages", default: true, null: false @@ -782,7 +805,7 @@ add_foreign_key "admin_email_changes", "users", column: "performed_by_id" add_foreign_key "admin_email_changes", "users", column: "target_user_id" add_foreign_key "aliases", "people" - add_foreign_key "aliases", "users", validate: false + add_foreign_key "aliases", "users" add_foreign_key "attachments", "messages" add_foreign_key "commitfest_patch_commitfests", "commitfest_patches" add_foreign_key "commitfest_patch_commitfests", "commitfests" @@ -804,7 +827,7 @@ add_foreign_key "message_read_ranges", "topics" add_foreign_key "message_read_ranges", "users" add_foreign_key "messages", "aliases", column: "sender_id" - add_foreign_key "messages", "identities", column: "sent_via_identity_id", validate: false + add_foreign_key "messages", "identities", column: "sent_via_identity_id" add_foreign_key "messages", "messages", column: "reply_to_id" add_foreign_key "messages", "people", column: "sender_person_id" add_foreign_key "messages", "topics" @@ -832,6 +855,8 @@ add_foreign_key "team_members", "users" add_foreign_key "thread_awarenesses", "topics" add_foreign_key "thread_awarenesses", "users" + add_foreign_key "topic_ignores", "topics" + add_foreign_key "topic_ignores", "users" add_foreign_key "topic_mailing_lists", "mailing_lists" add_foreign_key "topic_mailing_lists", "topics" add_foreign_key "topic_merges", "topics", column: "source_topic_id" @@ -841,12 +866,14 @@ add_foreign_key "topic_participants", "topics" add_foreign_key "topic_stars", "topics" add_foreign_key "topic_stars", "users" + add_foreign_key "topic_subscriptions", "topics" + add_foreign_key "topic_subscriptions", "users" add_foreign_key "topics", "aliases", column: "creator_id" add_foreign_key "topics", "messages", column: "last_message_id" add_foreign_key "topics", "people", column: "creator_person_id" add_foreign_key "topics", "people", column: "last_sender_person_id" add_foreign_key "topics", "topics", column: "merged_into_topic_id" - add_foreign_key "user_features", "users", validate: false + add_foreign_key "user_features", "users" add_foreign_key "user_tokens", "users" add_foreign_key "users", "people" end diff --git a/spec/factories/topic_ignores.rb b/spec/factories/topic_ignores.rb new file mode 100644 index 0000000..4ddefbe --- /dev/null +++ b/spec/factories/topic_ignores.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :topic_ignore do + association :user + association :topic + end +end diff --git a/spec/models/topic_ignore_spec.rb b/spec/models/topic_ignore_spec.rb new file mode 100644 index 0000000..be9f155 --- /dev/null +++ b/spec/models/topic_ignore_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe TopicIgnore, type: :model do + describe 'associations' do + it { should belong_to(:user) } + it { should belong_to(:topic) } + end + + describe 'validations' do + it 'prevents duplicate ignores' do + user = create(:user) + topic = create(:topic) + create(:topic_ignore, user: user, topic: topic) + + duplicate = build(:topic_ignore, user: user, topic: topic) + expect(duplicate).not_to be_valid + end + end + + describe '.toggle_ignore' do + let(:user) { create(:user) } + let(:topic) { create(:topic) } + + it 'creates an ignore record if none exists' do + result = TopicIgnore.toggle_ignore(user: user, topic: topic) + expect(result).to be true + expect(TopicIgnore.count).to eq(1) + end + + it 'removes an ignore record if one exists' do + create(:topic_ignore, user: user, topic: topic) + result = TopicIgnore.toggle_ignore(user: user, topic: topic) + expect(result).to be false + expect(TopicIgnore.count).to eq(0) + end + end + + describe '.ignored_by_user?' do + let(:user) { create(:user) } + let(:topic) { create(:topic) } + + it 'returns true when user has ignored the topic' do + create(:topic_ignore, user: user, topic: topic) + expect(TopicIgnore.ignored_by_user?(user: user, topic: topic)).to be true + end + + it 'returns false when user has not ignored the topic' do + expect(TopicIgnore.ignored_by_user?(user: user, topic: topic)).to be false + end + end +end diff --git a/spec/requests/topics_spec.rb b/spec/requests/topics_spec.rb index fd4cbe7..9247d9d 100644 --- a/spec/requests/topics_spec.rb +++ b/spec/requests/topics_spec.rb @@ -61,6 +61,47 @@ def attach_verified_alias(user, email:, primary: true) expect(response.body).to include("topics-table") end end + + context "when signed in, user-state data is included in initial render" do + let!(:user) { create(:user) } + let!(:topic1) { create(:topic, created_at: 2.days.ago) } + let!(:message1) { create(:message, topic: topic1, created_at: 2.days.ago) } + + before { sign_in_as(user) } + + it "renders without a user-state-root turbo frame" do + get topics_path + expect(response.body).not_to include('id="user-state-root"') + end + end + + context "ignore filter on index" do + let!(:user) { create(:user) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_topic) { create(:topic) } + let!(:ignored_and_starred_topic) { create(:topic) } + let!(:msg1) { create(:message, topic: normal_topic, created_at: 3.hours.ago) } + let!(:msg2) { create(:message, topic: ignored_topic, created_at: 2.hours.ago) } + let!(:msg3) { create(:message, topic: ignored_and_starred_topic, created_at: 1.hour.ago) } + + before do + sign_in_as(user) + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_and_starred_topic) + create(:topic_star, user: user, topic: ignored_and_starred_topic) + end + + it "excludes ignored topics" do + get topics_path + expect(response.body).to include(normal_topic.title) + expect(response.body).not_to include(ignored_topic.title) + end + + it "shows ignored topics that are also starred (star wins)" do + get topics_path + expect(response.body).to include(ignored_and_starred_topic.title) + end + end end describe "GET /topics/:id" do @@ -404,6 +445,19 @@ def attach_verified_alias(user, email:, primary: true) expect(response).to have_http_status(:not_found) end end + + context "when signed in, search renders without user-state frame" do + let!(:user) { create(:user) } + let!(:topic1) { create(:topic, title: "pgconf planning") } + let!(:msg1) { create(:message, topic: topic1, body: "test") } + + before { sign_in_as(user) } + + it "does not include user-state-root turbo frame" do + get search_topics_path, params: { q: "pgconf" } + expect(response.body).not_to include('id="user-state-root"') + end + end end describe "GET /topics/:id/latest_patchset" do @@ -578,6 +632,62 @@ def attach_verified_alias(user, email:, primary: true) expect(response.body).to include(message.body) end end + + describe "POST /topics/:id/ignore" do + let!(:user) { create(:user) } + let!(:creator) { create(:alias) } + let!(:topic) { create(:topic, creator: creator) } + let!(:message) { create(:message, topic: topic, sender: creator) } + + context "when signed in" do + before { sign_in_as(user) } + + it "creates a TopicIgnore record" do + expect { + post ignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.to change(TopicIgnore, :count).by(1) + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)["ignored"]).to be true + end + + it "is idempotent" do + create(:topic_ignore, user: user, topic: topic) + expect { + post ignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.not_to change(TopicIgnore, :count) + expect(response).to have_http_status(:success) + end + end + + context "when not signed in" do + it "redirects to sign in" do + post ignore_topic_path(topic) + expect(response).to redirect_to(new_session_path) + end + end + end + + describe "DELETE /topics/:id/unignore" do + let!(:user) { create(:user) } + let!(:creator) { create(:alias) } + let!(:topic) { create(:topic, creator: creator) } + let!(:message) { create(:message, topic: topic, sender: creator) } + + context "when signed in" do + before do + sign_in_as(user) + create(:topic_ignore, user: user, topic: topic) + end + + it "destroys the TopicIgnore record" do + expect { + delete unignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.to change(TopicIgnore, :count).by(-1) + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)["ignored"]).to be false + end + end + end end RSpec.describe 'Topics show — drafts sidebar', type: :request do diff --git a/spec/services/search/query_builder_spec.rb b/spec/services/search/query_builder_spec.rb index c71ebaa..c73404d 100644 --- a/spec/services/search/query_builder_spec.rb +++ b/spec/services/search/query_builder_spec.rb @@ -352,6 +352,54 @@ def build_query(query_string) end end + describe 'ignored:me' do + let!(:ignored_topic) { create(:topic) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_starred_topic) { create(:topic) } + + before do + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_starred_topic) + create(:topic_star, user: user, topic: ignored_starred_topic) + end + + it 'returns only ignored topics when ignored:me is used' do + result = build_query('ignored:me') + expect(result.relation).to include(ignored_topic) + expect(result.relation).to include(ignored_starred_topic) + expect(result.relation).not_to include(normal_topic) + end + end + + describe 'default ignore exclusion' do + let!(:ignored_topic) { create(:topic) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_starred_topic) { create(:topic) } + + before do + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_starred_topic) + create(:topic_star, user: user, topic: ignored_starred_topic) + end + + it 'excludes ignored topics from general search results' do + result = build_query('starred:me') + expect(result.relation).not_to include(ignored_topic) + end + + it 'includes ignored-but-starred topics (star wins)' do + result = build_query('starred:me') + expect(result.relation).to include(ignored_starred_topic) + end + + it 'excludes ignored topics from a general text search' do + ignored_topic.update!(title: 'unique-xyzzy-word') + create(:message, topic: ignored_topic, body: 'unique-xyzzy-word content') + result = build_query('unique-xyzzy-word') + expect(result.relation).not_to include(ignored_topic) + end + end + describe 'commitfest: selector' do let!(:tag) { create(:commitfest_tag) } let!(:commitfest_first) { create(:commitfest, name: 'PGX-Final') }