-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(bans): show the ban reason to the banned player #4659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
iiamlewis
wants to merge
4
commits into
main
Choose a base branch
from
feat-ban-reason-screen
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9e4007b
feat(bans): show the ban reason to the banned player
iiamlewis 28b422b
Merge branch 'main' into feat-ban-reason-screen
iiamlewis 5579298
fix(bans): clear the ban notice when @me later reports no ban
iiamlewis 0f88f93
Merge branch 'main' into feat-ban-reason-screen
iiamlewis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { html, LitElement } from "lit"; | ||
| import { customElement, query, state } from "lit/decorators.js"; | ||
| import type { UserMeResponse } from "../../core/ApiSchemas"; | ||
| import { translateText } from "../Utils"; | ||
| import "./baseComponents/Modal"; | ||
| import type { OModal } from "./baseComponents/Modal"; | ||
|
|
||
| type Ban = NonNullable<NonNullable<UserMeResponse["ban"]>>; | ||
|
|
||
| /** | ||
| * Shows a banned player *why* they're banned. The game server already refuses a | ||
| * banned account's WebSocket ("Account Banned"); this surfaces the rich reason | ||
| * the API returns on GET /users/@me (`ban`) — a localized category, the | ||
| * moderator's player-facing reason, and when the ban lifts (or that it's | ||
| * permanent). | ||
| * | ||
| * Reads the `userMeResponse` document event (dispatched after login / on load), | ||
| * like <marketing-consent-toast>. It opens the modal once; if the player closes | ||
| * it, it stays closed for the session (they still can't play — the server | ||
| * enforces that — but they aren't trapped behind the dialog). | ||
| */ | ||
| @customElement("banned-modal") | ||
| export class BannedModal extends LitElement { | ||
| @state() private ban: Ban | null = null; | ||
| @query("o-modal") private modalEl?: OModal; | ||
| private opened = false; | ||
|
|
||
| private onUserMeResponse = (event: Event) => { | ||
| const detail = (event as CustomEvent<UserMeResponse | false>).detail; | ||
| // No ban (or @me failed) clears the notice — e.g. an unban that lands in | ||
| // the same session — and re-arms `opened` so a later re-ban reopens it. | ||
| if (detail === false || !detail.ban) { | ||
| this.ban = null; | ||
| this.opened = false; | ||
| return; | ||
| } | ||
| this.ban = detail.ban; | ||
| }; | ||
|
|
||
| createRenderRoot() { | ||
| return this; | ||
| } | ||
|
|
||
| connectedCallback() { | ||
| super.connectedCallback(); | ||
| document.addEventListener("userMeResponse", this.onUserMeResponse); | ||
| } | ||
|
|
||
| disconnectedCallback() { | ||
| super.disconnectedCallback(); | ||
| document.removeEventListener("userMeResponse", this.onUserMeResponse); | ||
| } | ||
|
|
||
| updated() { | ||
| // Open once. The `open` guard keeps this resilient where <o-modal> isn't | ||
| // registered (unit tests) and stops a re-open after the player closes it. | ||
| if (this.ban && !this.opened && typeof this.modalEl?.open === "function") { | ||
| this.opened = true; | ||
| this.modalEl.open(); | ||
| } | ||
| } | ||
|
|
||
| // Map the server category to its label, falling back to the generic ToS | ||
| // wording for a value a newer server introduced that this client can't name. | ||
| private categoryLabel(category: string): string { | ||
| const key = `ban_notice.category.${category}`; | ||
| const label = translateText(key); | ||
| return label === key ? translateText("ban_notice.category.other") : label; | ||
| } | ||
|
|
||
| render() { | ||
| const ban = this.ban; | ||
| if (!ban) return html``; | ||
| return html` | ||
| <o-modal title=${translateText("ban_notice.title")}> | ||
| <div style="max-width: 32rem; line-height: 1.5;"> | ||
| <p style="font-weight: 600; font-size: 1.05rem; margin: 0 0 0.5rem;"> | ||
| ${this.categoryLabel(ban.category)} | ||
| </p> | ||
| ${ban.reason | ||
| ? html`<p style="margin: 0 0 0.5rem;"> | ||
| ${translateText("ban_notice.reason", { reason: ban.reason })} | ||
| </p>` | ||
| : null} | ||
| <p style="margin: 0 0 0.75rem;"> | ||
| ${ban.expiresAt | ||
| ? translateText("ban_notice.until", { | ||
| date: new Date(ban.expiresAt).toLocaleString(), | ||
| }) | ||
| : translateText("ban_notice.permanent")} | ||
| </p> | ||
| <p style="margin: 0; opacity: 0.8;"> | ||
| ${translateText("ban_notice.appeal")} | ||
| </p> | ||
| </div> | ||
| </o-modal> | ||
| `; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| // translateText stand-in that mirrors the real one's key-resolution: a known | ||
| // key renders to `[key]` (so it differs from the key — the component's | ||
| // unknown-category fallback keys off `label === key`), an UNKNOWN key returns | ||
| // the key unchanged (simulating a missing translation), and params are appended | ||
| // so assertions can see interpolated values. | ||
| vi.mock("../../src/client/Utils", () => ({ | ||
| translateText: (key: string, params?: Record<string, string | number>) => { | ||
| // Only this category has no translation, to exercise the fallback path. | ||
| if (key === "ban_notice.category.brand_new_reason") return key; | ||
| return params | ||
| ? `[${key}] ${Object.entries(params) | ||
| .map(([k, v]) => `${k}=${v}`) | ||
| .join(",")}` | ||
| : `[${key}]`; | ||
| }, | ||
| })); | ||
|
|
||
| import { BannedModal } from "../../src/client/components/BannedModal"; | ||
|
|
||
| function fireUserMe(detail: unknown) { | ||
| document.dispatchEvent(new CustomEvent("userMeResponse", { detail })); | ||
| } | ||
|
|
||
| describe("banned-modal", () => { | ||
| let el: BannedModal; | ||
|
|
||
| beforeEach(async () => { | ||
| // The @customElement define() side-effect doesn't run under the test | ||
| // transform, so register explicitly (as other client component tests do). | ||
| if (!customElements.get("banned-modal")) { | ||
| customElements.define("banned-modal", BannedModal); | ||
| } | ||
| el = document.createElement("banned-modal") as BannedModal; | ||
| document.body.appendChild(el); | ||
| await el.updateComplete; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| el.remove(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| async function text(): Promise<string> { | ||
| await el.updateComplete; | ||
| return el.textContent ?? ""; | ||
| } | ||
|
|
||
| it("renders nothing when the player is not banned", async () => { | ||
| fireUserMe({ ban: null, user: {}, player: {} }); | ||
| expect((await text()).trim()).toBe(""); | ||
| }); | ||
|
|
||
| it("renders nothing when @me failed to load", async () => { | ||
| fireUserMe(false); | ||
| expect((await text()).trim()).toBe(""); | ||
| }); | ||
|
|
||
| it("shows the localized category, reason and lift date for a temp ban", async () => { | ||
| fireUserMe({ | ||
| ban: { | ||
| category: "cheating", | ||
| reason: "aimbot in ranked", | ||
| expiresAt: "2026-08-01T00:00:00.000Z", | ||
| }, | ||
| user: {}, | ||
| player: {}, | ||
| }); | ||
| const t = await text(); | ||
| // Title is set on the modal shell (an attribute), the rest is body text. | ||
| expect(el.querySelector("o-modal")?.getAttribute("title")).toContain( | ||
| "ban_notice.title", | ||
| ); | ||
| expect(t).toContain("ban_notice.category.cheating"); | ||
| expect(t).toContain("reason=aimbot in ranked"); | ||
| expect(t).toContain("ban_notice.until"); | ||
| expect(t).not.toContain("ban_notice.permanent"); | ||
| }); | ||
|
|
||
| it("shows 'permanent' and no reason line for a permanent ban with no reason", async () => { | ||
| fireUserMe({ | ||
| ban: { category: "other", reason: null, expiresAt: null }, | ||
| user: {}, | ||
| player: {}, | ||
| }); | ||
| const t = await text(); | ||
| expect(t).toContain("ban_notice.category.other"); | ||
| expect(t).toContain("ban_notice.permanent"); | ||
| expect(t).not.toContain("ban_notice.reason"); | ||
| }); | ||
|
|
||
| it("clears the notice when a later @me arrives with no ban (unbanned in-session)", async () => { | ||
| fireUserMe({ | ||
| ban: { category: "cheating", reason: null, expiresAt: null }, | ||
| user: {}, | ||
| player: {}, | ||
| }); | ||
| expect(await text()).toContain("ban_notice.category.cheating"); | ||
|
|
||
| // The player is unbanned and @me re-dispatches without a ban. | ||
| fireUserMe({ ban: null, user: {}, player: {} }); | ||
| expect((await text()).trim()).toBe(""); | ||
| }); | ||
|
|
||
| it("falls back to the generic category for an unknown value", async () => { | ||
| fireUserMe({ | ||
| ban: { category: "brand_new_reason", reason: null, expiresAt: null }, | ||
| user: {}, | ||
| player: {}, | ||
| }); | ||
| // Unknown key → translateText returns the key unchanged → fallback to other. | ||
| expect(await text()).toContain("ban_notice.category.other"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.