7.0 KiB · text History 6280797
defmodule GitGudWeb.ModerationAdminComponents do
@moduledoc """
The report queue and ban list shared by the org and repo moderation
pages.
One set of components rather than two near-identical pages, so the
two scopes can't drift apart in what they let a moderator do.
The parent LiveView owns every action: `dismiss_report`,
`action_report`, `moderate_report`, `ban_user` and `lift_ban`.
"""
use GitGudWeb, :html
alias GitGud.Moderation.Ban
@doc "Open reports in this scope, each with a preview of what was reported."
attr :reports, :list, required: true
attr :previews, :map, required: true, doc: "report id => Reports.preview/1 result"
def report_queue(assigns) do
~H"""
<p :if={@reports == []} class="py-8 text-center text-sm opacity-60">
Nothing reported here.
</p>
<ul :if={@reports != []} class="space-y-3">
<li
:for={report <- @reports}
id={"report-#{report.id}"}
class="rounded-lg border border-base-300 p-3"
>
<div class="flex items-baseline gap-2 flex-wrap text-sm">
<span class="badge badge-sm badge-warning">{report.reason}</span>
<span class="opacity-60 text-xs">
reported by {reporter_name(report)} · {format_dt(report.inserted_at)}
</span>
</div>
<p :if={report.details} class="text-sm mt-1 opacity-80">{report.details}</p>
<.preview preview={@previews[report.id]} />
<div class="flex flex-wrap gap-2 mt-3">
<button
type="button"
phx-click="dismiss_report"
phx-value-id={report.id}
class="btn btn-xs btn-ghost"
>
Dismiss
</button>
<button
type="button"
phx-click="action_report"
phx-value-id={report.id}
class="btn btn-xs btn-ghost"
>
Mark handled
</button>
<button
:if={moderatable?(@previews[report.id])}
type="button"
phx-click="moderate_report"
phx-value-id={report.id}
class="btn btn-xs btn-warning btn-outline"
data-confirm="Replace this content with a moderation message? The original stays visible to admins and its author."
>
Replace with a mod message
</button>
<button
:if={author_id(@previews[report.id])}
type="button"
phx-click="ban_reported_author"
phx-value-id={report.id}
class="btn btn-xs btn-error btn-outline"
data-confirm="Ban this author from writing here?"
>
Ban the author
</button>
</div>
</li>
</ul>
"""
end
attr :preview, :any, default: nil
defp preview(assigns) do
~H"""
<div :if={is_map(@preview)} class="mt-2 rounded border border-base-300 bg-base-200/40 p-2">
<p class="text-xs opacity-60">
{@preview.kind} by {@preview.author}
<span :if={@preview.moderated?} class="badge badge-xs badge-ghost ml-1">
already moderated
</span>
</p>
<.link :if={@preview.url} navigate={@preview.url} class="text-sm link link-hover">
{@preview.title}
</.link>
<p :if={is_nil(@preview.url)} class="text-sm">{@preview.title}</p>
<p :if={@preview.excerpt} class="text-xs opacity-70 mt-1 whitespace-pre-wrap">
{@preview.excerpt}
</p>
</div>
<p :if={@preview == :missing} class="mt-2 text-xs opacity-60 italic">
The reported content is gone.
</p>
"""
end
@doc "Everyone banned in this scope, in force or not."
attr :bans, :list, required: true
attr :scope_label, :string, required: true
def ban_list(assigns) do
~H"""
<p :if={@bans == []} class="py-6 text-sm opacity-60">
Nobody is banned from this {@scope_label}.
</p>
<ul :if={@bans != []} class="divide-y divide-base-300">
<li :for={ban <- @bans} id={"ban-#{ban.id}"} class="py-2 flex items-center gap-3 flex-wrap">
<.avatar name={user_name(ban.banned_user)} size="sm" alt={user_name(ban.banned_user)} />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium">{user_name(ban.banned_user)}</span>
<span class={[
"badge badge-xs",
if(Ban.active?(ban), do: "badge-error", else: "badge-ghost")
]}>
{ban_state(ban)}
</span>
</div>
<p class="text-xs opacity-60">
<span :if={ban.reason}>{ban.reason} · </span>
banned by {user_name(ban.banned_by)} on {format_dt(ban.inserted_at)}<span :if={
ban.expires_at
}>, until {format_dt(ban.expires_at)}</span>
</p>
</div>
<button
:if={Ban.active?(ban)}
type="button"
phx-click="lift_ban"
phx-value-id={ban.id}
class="btn btn-xs btn-ghost"
>
Lift
</button>
</li>
</ul>
"""
end
@doc "The form for banning someone by handle."
attr :scope_label, :string, required: true
def ban_form(assigns) do
~H"""
<form phx-submit="ban_user" class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs opacity-70">Handle</span>
<input
type="text"
name="handle"
required
placeholder="alice"
aria-label="Handle to ban"
class="input input-bordered input-sm font-mono"
/>
</label>
<label class="flex flex-col gap-1 flex-1 min-w-48">
<span class="text-xs opacity-70">Reason (optional)</span>
<input type="text" name="reason" class="input input-bordered input-sm" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-70">Until (optional)</span>
<input type="date" name="expires_on" class="input input-bordered input-sm" />
</label>
<button type="submit" class="btn btn-sm btn-error btn-outline">Ban</button>
</form>
<p class="text-xs opacity-60 mt-1">
Stops them opening issues and pull requests, commenting and reviewing in this {@scope_label}, and hides what they've already written here. Leave the date
empty for a ban that lasts until it's lifted.
</p>
"""
end
defp ban_state(ban) do
cond do
ban.lifted_at -> "lifted"
not Ban.active?(ban) -> "expired"
ban.expires_at -> "temporary"
true -> "banned"
end
end
defp moderatable?(%{moderatable?: true, moderated?: false}), do: true
defp moderatable?(_), do: false
defp author_id(%{author_id: id}) when is_integer(id), do: id
defp author_id(_), do: nil
defp reporter_name(%{reporter: %{handle: h}}) when is_binary(h), do: h
defp reporter_name(_), do: "someone"
defp user_name(%{handle: h}) when is_binary(h), do: h
defp user_name(%{email: e}) when is_binary(e), do: e |> String.split("@") |> hd()
defp user_name(_), do: "unknown"
defp format_dt(nil), do: "—"
defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d")
end