Fold away a banned user's existing content

407ee5c · Gabriel Morell · 2026-09-10 16:17

4 files +234 -4
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/components/moderation_components.ex
+55 −4
@@ -11,6 +11,12 @@ defmodule GitGudWeb.ModerationComponents do
11 11 * For admins + the original author: a collapsed `<details>` with
12 12 the original body, rendered as Markdown like the live version.
13 13
14 + The same component also folds away content written by someone
15 + currently banned in this scope. That's done at render time rather
16 + than by rewriting rows: lifting a ban restores everything for free,
17 + nothing touches `original_body`, and no bulk write can half-fail.
18 + Moderators and the author can still expand it.
19 +
14 20 `<.moderated_body>` is the shared component used by both issue and PR
15 21 show pages. The caller passes the schema struct, the viewer (the
16 22 current `User` or nil), the repo (for cross-reference resolution),
@@ -29,8 +35,23 @@ defmodule GitGudWeb.ModerationComponents do
29 35 attr :theme, :string, default: "onedark"
30 36 attr :empty_text, :string, default: "No description."
31 37
38 + attr :banned_author_ids, :list,
39 + default: [],
40 + doc: "Users banned in this scope; their content is folded away."
41 +
42 + attr :moderator?, :boolean,
43 + default: false,
44 + doc: "Repo/org admin — may expand content hidden by a ban."
45 +
32 46 def moderated_body(assigns) do
33 assigns = assign(assigns, :show_original?, can_view_original?(assigns.target, assigns.viewer))
47 + assigns =
48 + assigns
49 + |> assign(:show_original?, can_view_original?(assigns.target, assigns.viewer))
50 + |> assign(:hidden_by_ban?, hidden_by_ban?(assigns.target, assigns.banned_author_ids))
51 +
52 + # A repo admin can see through a ban they could also lift.
53 + assigns =
54 + assign(assigns, :may_unmask?, assigns.show_original? or assigns.moderator?)
34 55
35 56 ~H"""
36 57 <%= cond do %>
@@ -47,16 +68,30 @@ defmodule GitGudWeb.ModerationComponents do
47 68 Show original
48 69 </summary>
49 70 <div class="prose max-w-none mt-2 text-sm">
50 {Phoenix.HTML.raw(Markdown.to_html(@target.original_body || "", theme: @theme, repo: @repo))}
71 + {Phoenix.HTML.raw(
72 + Markdown.to_html(@target.original_body || "", theme: @theme, repo: @repo)
73 + )}
74 + </div>
75 + </details>
76 + </div>
77 + <% @hidden_by_ban? -> %>
78 + <div class="rounded border border-base-300 bg-base-200/60 p-3 text-sm">
79 + <p class="opacity-70">
80 + <.icon name="hero-eye-slash" class="size-4 inline" /> Hiddenits author is banned here.
81 + </p>
82 + <details :if={@may_unmask?} class="mt-1">
83 + <summary class="cursor-pointer text-xs opacity-70 hover:opacity-100">
84 + Show anyway
85 + </summary>
86 + <div class="prose max-w-none mt-2 text-sm">
87 + {Phoenix.HTML.raw(Markdown.to_html(@target.body || "", theme: @theme, repo: @repo))}
51 88 </div>
52 89 </details>
53 90 </div>
54
55 91 <% @target.body && @target.body != "" -> %>
56 92 <div class="prose max-w-none">
57 93 {Phoenix.HTML.raw(Markdown.to_html(@target.body, theme: @theme, repo: @repo))}
58 94 </div>
59
60 95 <% true -> %>
61 96 <p class="opacity-50">{@empty_text}</p>
62 97 <% end %>
@@ -72,6 +107,22 @@ defmodule GitGudWeb.ModerationComponents do
72 107 - The original author sees it.
73 108 - Everyone else: no.
74 109 """
110 + @doc """
111 + Whether this content should be folded away because its author is
112 + banned here.
113 +
114 + Only local authorship counts — a federated item has no local
115 + `author_id` and isn't covered by a local ban.
116 + """
117 + def hidden_by_ban?(_target, []), do: false
118 +
119 + def hidden_by_ban?(target, banned_ids) when is_list(banned_ids) do
120 + case Map.get(target, :author_id) do
121 + nil -> false
122 + author_id -> author_id in banned_ids
123 + end
124 + end
125 +
75 126 def can_view_original?(_target, nil), do: false
76 127
77 128 def can_view_original?(target, %{id: viewer_id, is_admin: is_admin}) do
modified lib/git_gud_web/live/issue_live/show.ex
+5 −0
@@ -28,6 +28,7 @@ defmodule GitGudWeb.IssueLive.Show do
28 28 |> assign_comment_form()
29 29 |> assign(:comment_body, "")
30 30 |> assign(:editing_title?, false)
31 + |> assign(:banned_author_ids, GitGud.Moderation.banned_user_ids(repo))
31 32 |> assign(:editing_comment_id, nil)
32 33 |> assign(:title_error, nil)
33 34 |> assign(:editing_body?, false)
@@ -401,6 +402,8 @@ defmodule GitGudWeb.IssueLive.Show do
401 402 viewer={@current_scope && @current_scope.user}
402 403 repo={@repo}
403 404 theme={@editor_theme}
405 + banned_author_ids={@banned_author_ids}
406 + moderator?={@can_admin?}
404 407 />
405 408 <% end %>
406 409 </section>
@@ -509,6 +512,8 @@ defmodule GitGudWeb.IssueLive.Show do
509 512 viewer={@current_scope && @current_scope.user}
510 513 repo={@repo}
511 514 theme={@editor_theme}
515 + banned_author_ids={@banned_author_ids}
516 + moderator?={@can_admin?}
512 517 empty_text="(empty comment)"
513 518 />
514 519 <% end %>
modified lib/git_gud_web/live/pr_live/show.ex
+5 −0
@@ -57,6 +57,7 @@ defmodule GitGudWeb.PrLive.Show do
57 57 |> assign_comment_form()
58 58 |> assign(:comment_body, "")
59 59 |> assign(:editing_title?, false)
60 + |> assign(:banned_author_ids, GitGud.Moderation.banned_user_ids(repo))
60 61 |> assign(:editing_comment_id, nil)
61 62 |> assign(:title_error, nil)
62 63 |> assign(:editing_body?, false)
@@ -565,6 +566,8 @@ defmodule GitGudWeb.PrLive.Show do
565 566 viewer={@current_scope && @current_scope.user}
566 567 repo={@repo}
567 568 theme={@editor_theme}
569 + banned_author_ids={@banned_author_ids}
570 + moderator?={@can_admin?}
568 571 />
569 572 <% end %>
570 573 </section>
@@ -792,6 +795,8 @@ defmodule GitGudWeb.PrLive.Show do
792 795 viewer={@current_scope && @current_scope.user}
793 796 repo={@repo}
794 797 theme={@editor_theme}
798 + banned_author_ids={@banned_author_ids}
799 + moderator?={@can_admin?}
795 800 empty_text="(empty comment)"
796 801 />
797 802 <% end %>
added test/git_gud_web/live/banned_content_test.exs
+169 −0
@@ -0,0 +1,169 @@
1 +defmodule GitGudWeb.BannedContentTest do
2 + @moduledoc """
3 + A banned user's existing content is folded away in the scope they're
4 + banned fromat render time, so lifting the ban restores it without
5 + anything having to be rewritten.
6 + """
7 +
8 + use GitGudWeb.ConnCase, async: false
9 +
10 + import Phoenix.LiveViewTest
11 + import GitGud.AccountsFixtures
12 + import GitGud.ForgeFixtures
13 +
14 + alias GitGud.Issues
15 + alias GitGud.Moderation
16 + alias GitGud.Organizations
17 + alias GitGud.Repositories
18 +
19 + defp issue_path(repo, issue) do
20 + handle = Repositories.Storage.repo_handle(GitGud.Repo.preload(repo, [:owner, :organization]))
21 + ~p"/r/#{handle}/#{repo.name}/issues/#{issue.number}"
22 + end
23 +
24 + test "a banned author's issue body is hidden", %{conn: conn} do
25 + {owner, repo} = repository_fixture(%{visibility: "public"})
26 + nuisance = user_fixture()
27 + issue = issue_fixture(repo, nuisance, %{"body" => "the offending words"})
28 +
29 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
30 +
31 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
32 +
33 + refute html =~ "the offending words"
34 + assert html =~ "author is banned here"
35 + end
36 +
37 + test "their comments are hidden too", %{conn: conn} do
38 + {owner, repo} = repository_fixture(%{visibility: "public"})
39 + nuisance = user_fixture()
40 + issue = issue_fixture(repo, owner)
41 + {:ok, _} = Issues.add_comment(issue, nuisance, %{"body" => "spammy comment"})
42 +
43 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
44 +
45 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
46 +
47 + refute html =~ "spammy comment"
48 + assert html =~ "author is banned here"
49 + end
50 +
51 + test "everyone else's content is untouched", %{conn: conn} do
52 + {owner, repo} = repository_fixture(%{visibility: "public"})
53 + nuisance = user_fixture()
54 + issue = issue_fixture(repo, owner, %{"body" => "perfectly fine"})
55 + {:ok, _} = Issues.add_comment(issue, nuisance, %{"body" => "spammy comment"})
56 +
57 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
58 +
59 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
60 +
61 + assert html =~ "perfectly fine"
62 + refute html =~ "spammy comment"
63 + end
64 +
65 + test "lifting the ban brings it straight back", %{conn: conn} do
66 + {owner, repo} = repository_fixture(%{visibility: "public"})
67 + nuisance = user_fixture()
68 + issue = issue_fixture(repo, nuisance, %{"body" => "restored words"})
69 + {:ok, ban} = Moderation.ban(repo, nuisance, banned_by: owner)
70 +
71 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
72 + refute html =~ "restored words"
73 +
74 + {:ok, _} = Moderation.lift(ban, owner)
75 +
76 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
77 + assert html =~ "restored words"
78 + end
79 +
80 + test "the author can still see their own words", %{conn: conn} do
81 + {owner, repo} = repository_fixture(%{visibility: "public"})
82 + nuisance = user_fixture()
83 + issue = issue_fixture(repo, nuisance, %{"body" => "my own words"})
84 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
85 +
86 + {:ok, _lv, html} = live(log_in_user(conn, nuisance), issue_path(repo, issue))
87 +
88 + assert html =~ "Show anyway"
89 + assert html =~ "my own words"
90 + end
91 +
92 + test "a repo admin can expand it", %{conn: conn} do
93 + {owner, repo} = repository_fixture(%{visibility: "public"})
94 + nuisance = user_fixture()
95 + issue = issue_fixture(repo, nuisance, %{"body" => "moderator can read this"})
96 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
97 +
98 + {:ok, _lv, html} = live(log_in_user(conn, owner), issue_path(repo, issue))
99 +
100 + assert html =~ "Show anyway"
101 + assert html =~ "moderator can read this"
102 + end
103 +
104 + test "an ordinary viewer gets no way to expand it", %{conn: conn} do
105 + {owner, repo} = repository_fixture(%{visibility: "public"})
106 + nuisance = user_fixture()
107 + issue = issue_fixture(repo, nuisance, %{"body" => "not for you"})
108 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
109 +
110 + {:ok, _lv, html} = live(log_in_user(conn, user_fixture()), issue_path(repo, issue))
111 +
112 + refute html =~ "Show anyway"
113 + refute html =~ "not for you"
114 + end
115 +
116 + test "hiding is scope-local — elsewhere they read normally", %{conn: conn} do
117 + {owner, repo} = repository_fixture(%{visibility: "public"})
118 + {other_owner, other} = repository_fixture(%{visibility: "public"})
119 + nuisance = user_fixture()
120 +
121 + here = issue_fixture(repo, nuisance, %{"body" => "words in the banned repo"})
122 + there = issue_fixture(other, nuisance, %{"body" => "words somewhere else"})
123 +
124 + {:ok, _} = Moderation.ban(repo, nuisance, banned_by: owner)
125 +
126 + {:ok, _lv, here_html} = live(conn, issue_path(repo, here))
127 + {:ok, _lv, there_html} = live(conn, issue_path(other, there))
128 +
129 + refute here_html =~ "words in the banned repo"
130 + assert there_html =~ "words somewhere else"
131 + _ = other_owner
132 + end
133 +
134 + test "an org ban hides their content across the org's repos", %{conn: conn} do
135 + admin = user_fixture()
136 + {:ok, org} = Organizations.create_organization(admin, %{"handle" => "hide-org"})
137 +
138 + {:ok, repo} =
139 + Repositories.create_repository_for_org(org, admin, %{
140 + "name" => "orgrepo",
141 + "visibility" => "public"
142 + })
143 +
144 + nuisance = user_fixture()
145 + issue = issue_fixture(repo, nuisance, %{"body" => "org-wide hidden"})
146 +
147 + {:ok, _} = Moderation.ban(org, nuisance, banned_by: admin)
148 +
149 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
150 +
151 + refute html =~ "org-wide hidden"
152 + end
153 +
154 + test "an expired ban stops hiding", %{conn: conn} do
155 + {_owner, repo} = repository_fixture(%{visibility: "public"})
156 + nuisance = user_fixture()
157 + issue = issue_fixture(repo, nuisance, %{"body" => "visible again"})
158 +
159 + GitGud.Repo.insert!(%GitGud.Moderation.Ban{
160 + repository_id: repo.id,
161 + banned_user_id: nuisance.id,
162 + expires_at: DateTime.utc_now(:second) |> DateTime.add(-60, :second)
163 + })
164 +
165 + {:ok, _lv, html} = live(conn, issue_path(repo, issue))
166 +
167 + assert html =~ "visible again"
168 + end
169 +end

Parents: 500bdf9