neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Fold away a banned user's existing content
407ee5c · Gabriel Morell · 2026-09-10 16:17
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" /> Hidden — its 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 from — at 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