From 921dc57c128e977dbbdfd3089c78d8ee2194af85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 17:52:41 +0200 Subject: [PATCH 1/2] feat(backoffice): an operator restores or clears a reported drawing A Drawings page lists the reports of a drawing on the map, open ones first. A report links the drawing on the map, restores its area as it was before the report after a dry run that names who painted it, clears an area older than the ledger, and closes the report. Co-Authored-By: Claude Opus 5.5 --- apps/backoffice/CLAUDE.md | 30 +++- apps/backoffice/src/App.tsx | 2 + apps/backoffice/src/clients.ts | 5 +- apps/backoffice/src/drawings/ClearPanel.tsx | 60 +++++++ .../backoffice/src/drawings/DrawingDetail.tsx | 88 ++++++++++ apps/backoffice/src/drawings/DrawingList.tsx | 69 ++++++++ .../src/drawings/DrawingsPage.test.tsx | 165 ++++++++++++++++++ apps/backoffice/src/drawings/DrawingsView.tsx | 16 ++ apps/backoffice/src/drawings/PainterList.tsx | 42 +++++ apps/backoffice/src/drawings/RestorePanel.tsx | 83 +++++++++ apps/backoffice/src/drawings/actions.test.ts | 84 +++++++++ apps/backoffice/src/drawings/actions.ts | 111 ++++++++++++ apps/backoffice/src/fakeAdmin.ts | 40 ++++- apps/backoffice/src/index.css | 16 ++ apps/backoffice/src/navigation.ts | 11 +- apps/backoffice/src/reports/DecideForm.tsx | 11 +- apps/backoffice/src/reports/ReportDetail.tsx | 5 +- apps/backoffice/src/support/labels.ts | 13 +- 18 files changed, 834 insertions(+), 17 deletions(-) create mode 100644 apps/backoffice/src/drawings/ClearPanel.tsx create mode 100644 apps/backoffice/src/drawings/DrawingDetail.tsx create mode 100644 apps/backoffice/src/drawings/DrawingList.tsx create mode 100644 apps/backoffice/src/drawings/DrawingsPage.test.tsx create mode 100644 apps/backoffice/src/drawings/DrawingsView.tsx create mode 100644 apps/backoffice/src/drawings/PainterList.tsx create mode 100644 apps/backoffice/src/drawings/RestorePanel.tsx create mode 100644 apps/backoffice/src/drawings/actions.test.ts create mode 100644 apps/backoffice/src/drawings/actions.ts diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 910e44569..9250c697c 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -12,7 +12,8 @@ burger menu (see [The menu](#the-menu)), by group: [Who sent a note](#who-sent-a-note)) and its photos and videos (see [A note's photos and videos](#a-notes-photos-and-videos)). - **Moderation**: Reports, which the operator acts on and closes (see [Acting on a report](#acting-on-a-report)), - and Players, who played and every tool on one player (see [The player page](#the-player-page)). + Drawings, the reports of a drawing on the map and the tools that undo one (see [Drawings](#drawings)), and + Players, who played and every tool on one player (see [The player page](#the-player-page)). - **Map**: Reassign a country, Paint random tiles, Download a replay (see [The map tools](#the-map-tools)). - **Ops**: Reconcile titles, the run after a deploy (see [Ops](#ops)). @@ -180,6 +181,30 @@ note and keeps its text). each request (`askedFor`) and can refuse a method with a code. The jsdom tests drive each action through its page. +## Drawings + +`src/drawings/`: the reports of a drawing on the map (`ListPlaceReports`), a page like Reports. + +- **The list** shows the open reports first, and a switch shows them all: the reason (hate, sexual, other), the + tile, the reporter's name, the text and the time, and the decision once decided. +- **The detail** links the drawing on the map, in a new tab (`mapLinkOf`: the game at `?tile=`, close up on + the tile), and holds the area every tool acts on: the reported tile and the rings around it, 8 by default, as a + bomb's blast, 1 to 50. +- **Restore** (`RestoreArea`, `RestorePanel`) puts each tile of the area back to what it held at a time: an hour + before the report by default (`beforeOf`). The dry run says how many tiles the area holds and how many changed + since, how far back the ledger reaches (`kept_since`, then the field's `min`), and who painted the area since, + most takes first, with their takes and tiles in the area alone: each by flag and name (`GetAuthors`, as the + players list), each opening on the player page, where the ban is (`PainterList`). **Restore stays off until a dry + run matches the rings and the time**, asks with its count ("Put back the 12 tiles changed since …?"), and needs a + new dry run after it. A time older than the ledger is `failed_precondition`: "The ledger does not reach back to + then: clear the area instead." +- **Clear** (`ClearArea`, `ClearPanel`) gives every tile of the area that a flag holds to nobody, for a drawing older + than the ledger. Dry run first (how many are held), then Clear, which asks with its count, on the same rule. +- **Close the report**: Acted or Dismissed, with a comment, `DecidePlaceReport` through the same `DecideForm` as a + player's report (it takes the call that decides). +- `FakeAdmin` refuses a restore older than its `keptSince` and changes tiles only when it is not a dry run, as the + server does. + ## The player page `src/players/`. **A player is an account or an address** (`Caller` in `caller.ts`), never both: the planet's tools @@ -280,7 +305,8 @@ src/ components/ Menu, ConfirmAction, FailureNotice, AccountId and Copyable (with Copy), Country, CountryField, Fact, Person, When support/ Ideas and bugs: NoteList, Attachments, labels.ts (names, outcomes, device lines, sizes) - reports/ Reports: list, detail, the report's own actions + reports/ Reports: list, detail, the report's own actions, DecideForm + drawings/ Drawings: list, detail, RestorePanel, ClearPanel, PainterList, actions.ts players/ Players: list, PlayerName, PlayerView, ProfilePanel, the tools on one player, actions.ts map/ Reassign a country, Paint random tiles, Download a replay ops/ Reconcile titles diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 9ce0a37aa..c2aa65b9a 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -10,6 +10,7 @@ import {OpenPlayer, Opened, PageId, titleOf} from "./navigation.ts" import {TitlesPanel} from "./ops/TitlesPanel.tsx" import {PlayersPage} from "./players/PlayersPage.tsx" import {ReportsView} from "./reports/ReportsView.tsx" +import {DrawingsView} from "./drawings/DrawingsView.tsx" import {Callback} from "./signIn/callback.ts" import {SignInCallback} from "./signIn/SignInCallback.tsx" import {SignOutButton} from "./signIn/SignOutButton.tsx" @@ -73,6 +74,7 @@ function Console({operator, onSignedOut}: {operator: string, onSignedOut: () =>

{titleOf(page)}

{page === "notes" && } {page === "reports" && } + {page === "drawings" && } {page === "players" && } {page === "reassign" && } {page === "paint" && } diff --git a/apps/backoffice/src/clients.ts b/apps/backoffice/src/clients.ts index 51646d7f8..f8f392cbc 100644 --- a/apps/backoffice/src/clients.ts +++ b/apps/backoffice/src/clients.ts @@ -9,7 +9,10 @@ import {AdminService as SupportAdminService} from "./gen/grpc/support/v1/admin_c export type ChatAdmin = Pick, "getMessage" | "listMessages" | "removeMessage" | "mute"> export type PlanetAdmin = PromiseClient export type PlayerAdmin = PromiseClient -export type SupportAdmin = Pick, "listNotes" | "decideReport"> +export type SupportAdmin = Pick< + PromiseClient, + "listNotes" | "decideReport" | "listPlaceReports" | "decidePlaceReport" +> export type Clients = {chat: ChatAdmin, planet: PlanetAdmin, player: PlayerAdmin, support: SupportAdmin} diff --git a/apps/backoffice/src/drawings/ClearPanel.tsx b/apps/backoffice/src/drawings/ClearPanel.tsx new file mode 100644 index 000000000..70e2b3c8c --- /dev/null +++ b/apps/backoffice/src/drawings/ClearPanel.tsx @@ -0,0 +1,60 @@ +import {useState} from "react" +import {PlanetAdmin} from "../clients.ts" +import {ConfirmAction} from "../components/ConfirmAction.tsx" +import {countOf} from "../support/labels.ts" +import {useAction} from "../useAction.ts" +import {clearArea, Cleared, refusalOf} from "./actions.ts" + +type Props = { + planet: PlanetAdmin + tileId: number + rings: number | undefined +} + +const refused = (err: unknown) => refusalOf("clear", err) + +// For a drawing older than the ledger keeps, which no restore reaches: every tile of the area goes to nobody. +export function ClearPanel({planet, tileId, rings}: Props) { + const [count, start, reset] = useAction(refused) + const [countedFor, setCountedFor] = useState(undefined) + const counted = count.step === "done" && countedFor === rings ? count.value : undefined + + const dryRun = () => { + if (rings === undefined) return + setCountedFor(rings) + start(() => clearArea(planet, tileId, rings, true)) + } + + return ( +
+

Clear the area

+
+ + {count.step === "refused" &&

{count.words}

} + {counted !== undefined && ( +

+ {countOf(counted.tiles, "tile")} in the area, {counted.owned} held by a flag. +

+ )} +
+ clearArea(planet, tileId, rings ?? 0, false)} + said={done => `Cleared ${done.cleared} of ${countOf(done.owned, "tile")} held.` + + (done.cleared < done.owned ? " The rest were taken again while it ran." : "")} + refusal={refused} + onDone={reset} + /> +
+ ) +} diff --git a/apps/backoffice/src/drawings/DrawingDetail.tsx b/apps/backoffice/src/drawings/DrawingDetail.tsx new file mode 100644 index 000000000..8f46071ff --- /dev/null +++ b/apps/backoffice/src/drawings/DrawingDetail.tsx @@ -0,0 +1,88 @@ +import {Timestamp} from "@bufbuild/protobuf" +import {useState} from "react" +import {Clients} from "../clients.ts" +import {Person} from "../components/Person.tsx" +import {When} from "../components/When.tsx" +import {PlaceReport, ReportDecision} from "../gen/grpc/support/v1/admin_pb.ts" +import {Decided} from "../reports/actions.ts" +import {DecideForm} from "../reports/DecideForm.tsx" +import {DecisionLine} from "../reports/DecisionLine.tsx" +import {placeReasonLabel} from "../support/labels.ts" +import {beforeOf, decidePlace, DEFAULT_RINGS, mapLinkOf, MAX_RINGS} from "./actions.ts" +import {ClearPanel} from "./ClearPanel.tsx" +import {RestorePanel} from "./RestorePanel.tsx" + +type Props = { + report: PlaceReport + clients: Clients + onBack: () => void +} + +export function DrawingDetail({report, clients, onBack}: Props) { + const [decision, setDecision] = useState(report.decision) + const [ringsValue, setRingsValue] = useState(String(DEFAULT_RINGS)) + const rings = ringsOf(ringsValue) + + const decided = (done: Decided) => setDecision(new ReportDecision({ + outcome: done.outcome, + comment: done.comment, + decidedAt: Timestamp.fromDate(done.decidedAt), + })) + + return ( +
+ + +
+
+ {placeReasonLabel(report.reason)} + +
+
+ +
+ {report.text !== "" ?

{report.text}

:

No details.

} +

+ Tile {report.tileId}{" · "} + See it on the map ↗ +

+ {decision !== undefined && } +
+ +
+

The area

+ +
+ + + + + decidePlace(clients.support, report.id, outcome, comment)} + decided={decision !== undefined} + onDecided={decided} + /> +
+ ) +} + +// Whole rings from 1 to MAX_RINGS, or nothing while the field holds another value. +function ringsOf(value: string): number | undefined { + const rings = Number(value) + return Number.isInteger(rings) && rings >= 1 && rings <= MAX_RINGS ? rings : undefined +} diff --git a/apps/backoffice/src/drawings/DrawingList.tsx b/apps/backoffice/src/drawings/DrawingList.tsx new file mode 100644 index 000000000..332f59a1b --- /dev/null +++ b/apps/backoffice/src/drawings/DrawingList.tsx @@ -0,0 +1,69 @@ +import {useCallback} from "react" +import {SupportAdmin} from "../clients.ts" +import {FailureNotice} from "../components/FailureNotice.tsx" +import {When} from "../components/When.tsx" +import {PlaceReport} from "../gen/grpc/support/v1/admin_pb.ts" +import {DecisionLine} from "../reports/DecisionLine.tsx" +import {countOf, placeReasonLabel} from "../support/labels.ts" +import {useCall} from "../useCall.ts" +import {listPlaceReports} from "./actions.ts" + +type Props = { + support: SupportAdmin + openOnly: boolean + onOpenOnly: (openOnly: boolean) => void + onOpen: (report: PlaceReport) => void +} + +export function DrawingList({support, openOnly, onOpenOnly, onOpen}: Props) { + const list = useCallback( + (signal: AbortSignal) => listPlaceReports(support, openOnly, signal), + [support, openOnly], + ) + const [reports, again] = useCall(list) + + return ( +
+
+
+ + +
+ +
+ + {reports.state === "loading" &&

Loading…

} + {reports.state === "failed" && } + {reports.state === "done" && ( + <> +

{countOf(reports.value.length, openOnly ? "open report" : "report")}

+
    + {reports.value.map(report => ( + onOpen(report)}/> + ))} +
+ + )} +
+ ) +} + +function DrawingCard({report, onOpen}: {report: PlaceReport, onOpen: () => void}) { + return ( +
  • +
    + {placeReasonLabel(report.reason)} + +
    +

    + Tile {report.tileId}, reported by{" "} + {report.reporterName === "" ? "an unnamed player" : {report.reporterName}} +

    + {report.text !== "" &&

    {report.text}

    } +
    + {report.decision !== undefined ? : } + +
    +
  • + ) +} diff --git a/apps/backoffice/src/drawings/DrawingsPage.test.tsx b/apps/backoffice/src/drawings/DrawingsPage.test.tsx new file mode 100644 index 000000000..bdf63acc3 --- /dev/null +++ b/apps/backoffice/src/drawings/DrawingsPage.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import {Timestamp} from "@bufbuild/protobuf" +import {cleanup, fireEvent, render, screen, within} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" +import {ACCOUNT, AT, FakeAdmin} from "../fakeAdmin.ts" +import {ReportDecision, ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" +import {OpenPlayer} from "../navigation.ts" +import {localInputOf, OLDER_THAN_THE_LEDGER} from "./actions.ts" +import {DrawingsView} from "./DrawingsView.tsx" + +afterEach(cleanup) + +function show(admin: FakeAdmin) { + render() +} + +// The card's Open, not the switch to the open reports. +async function openCard() { + await screen.findByText("a swastika over Berlin") + fireEvent.click(within(screen.getByRole("listitem")).getByRole("button", {name: "Open"})) +} + +async function openTheReport(admin: FakeAdmin) { + show(admin) + await openCard() +} + +function region(name: string) { + return within(screen.getByRole("region", {name})) +} + +function button(panel: string, name: string): HTMLButtonElement { + return region(panel).getByRole("button", {name}) as HTMLButtonElement +} + +describe("Drawings", () => { + it("lists the open reports first, and all on asking", async () => { + const admin = new FakeAdmin() + show(admin) + + expect(await screen.findByText("a swastika over Berlin")).toBeTruthy() + expect(screen.getByText("Hate")).toBeTruthy() + expect(screen.getByText("Bob")).toBeTruthy() + fireEvent.click(screen.getByRole("button", {name: "All"})) + await screen.findByText("1 report") + + expect(admin.askedFor("listPlaceReports")).toEqual([{openOnly: true}, {openOnly: false}]) + }) + + it("shows the decision of a report decided", async () => { + const admin = new FakeAdmin() + admin.placeReports[0].decision = new ReportDecision({ + outcome: ReportOutcome.DISMISSED, comment: "a flag, not a symbol", decidedAt: Timestamp.fromDate(AT), + }) + show(admin) + fireEvent.click(await screen.findByRole("button", {name: "All"})) + + expect(await screen.findByText("Dismissed")).toBeTruthy() + expect(screen.getByText("a flag, not a symbol")).toBeTruthy() + }) + + it("links the drawing on the map, in a new tab", async () => { + await openTheReport(new FakeAdmin()) + + const link = screen.getByRole("link", {name: "See it on the map ↗"}) + expect(link.getAttribute("href")).toBe("https://clickplanet.lol/?tile=1234") + expect(link.getAttribute("target")).toBe("_blank") + }) + + it("restores only after a dry run, asking with its numbers", async () => { + const admin = new FakeAdmin() + await openTheReport(admin) + + expect(button("Restore", "Restore").disabled).toBe(true) + fireEvent.click(button("Restore", "Dry run")) + + expect(await region("Restore").findByText(/^37 tiles in the area, 12 changed since /)).toBeTruthy() + expect(admin.askedFor("restoreArea")).toEqual([{ + tileId: 1234, rings: 8, before: Timestamp.fromDate(new Date(AT.getTime() - 3_600_000)), dryRun: true, + }]) + fireEvent.click(button("Restore", "Restore")) + expect(region("Restore").getByText(/^Put back the 12 tiles changed since /)).toBeTruthy() + fireEvent.click(button("Restore", "Yes, restore")) + + expect(await region("Restore").findByText("Restored 12 of 12 tiles.")).toBeTruthy() + expect(admin.askedFor("restoreArea").at(-1)).toMatchObject({tileId: 1234, rings: 8, dryRun: false}) + }) + + it("needs a new dry run once the area changes", async () => { + const admin = new FakeAdmin() + await openTheReport(admin) + fireEvent.click(button("Restore", "Dry run")) + await region("Restore").findByText(/^37 tiles in the area/) + + fireEvent.change(region("The area").getByRole("spinbutton"), {target: {value: "12"}}) + + expect(button("Restore", "Restore").disabled).toBe(true) + expect(region("Restore").queryByText(/^37 tiles in the area/)).toBeNull() + }) + + it("names the painters, each opening on the player page", async () => { + const admin = new FakeAdmin() + const openPlayer = vi.fn() + render( + + + , + ) + await openCard() + fireEvent.click(button("Restore", "Dry run")) + + const painters = await region("Restore").findByRole("list", {name: "Painters"}) + expect(within(painters).getByText("BraveFox42")).toBeTruthy() + expect(within(painters).getByText("No account")).toBeTruthy() + expect(within(painters).getByText("40 takes, 12 held")).toBeTruthy() + fireEvent.click(within(painters).getAllByRole("button", {name: "Open"})[0]) + + expect(openPlayer).toHaveBeenCalledWith(expect.objectContaining({caller: {accountId: ACCOUNT}, name: "BraveFox42"})) + }) + + it("says when the ledger does not reach back far enough, and the clear is there", async () => { + const admin = new FakeAdmin() + await openTheReport(admin) + + fireEvent.change(region("Restore").getByLabelText("As it was at"), + {target: {value: localInputOf(new Date(admin.keptSince.getTime() - 3_600_000))}}) + fireEvent.click(button("Restore", "Dry run")) + + expect(await region("Restore").findByText(OLDER_THAN_THE_LEDGER)).toBeTruthy() + expect(button("Restore", "Restore").disabled).toBe(true) + expect(button("Clear", "Dry run").disabled).toBe(false) + }) + + it("clears the area only after a dry run, asking with its numbers", async () => { + const admin = new FakeAdmin() + await openTheReport(admin) + + expect(button("Clear", "Clear").disabled).toBe(true) + fireEvent.click(button("Clear", "Dry run")) + expect(await region("Clear").findByText("37 tiles in the area, 20 held by a flag.")).toBeTruthy() + fireEvent.click(button("Clear", "Clear")) + expect(region("Clear").getByText("Give the 20 tiles held in the area to nobody?")).toBeTruthy() + fireEvent.click(button("Clear", "Yes, clear")) + + expect(await region("Clear").findByText("Cleared 20 of 20 tiles held.")).toBeTruthy() + expect(admin.askedFor("clearArea")).toEqual([ + {tileId: 1234, rings: 8, dryRun: true}, + {tileId: 1234, rings: 8, dryRun: false}, + ]) + }) + + it("closes the report with the outcome picked and its comment", async () => { + const admin = new FakeAdmin() + await openTheReport(admin) + + fireEvent.click(screen.getByRole("radio", {name: "Acted"})) + fireEvent.change(screen.getByRole("textbox", {name: "Comment (optional)"}), {target: {value: "restored"}}) + fireEvent.click(screen.getByRole("button", {name: "Close the report"})) + fireEvent.click(screen.getByRole("button", {name: "Yes, close the report"})) + + expect(await screen.findByText(/^Closed as Acted at /)).toBeTruthy() + expect(admin.askedFor("decidePlaceReport")) + .toEqual([{reportId: "place-report-1", outcome: ReportOutcome.ACTED, comment: "restored"}]) + }) +}) diff --git a/apps/backoffice/src/drawings/DrawingsView.tsx b/apps/backoffice/src/drawings/DrawingsView.tsx new file mode 100644 index 000000000..5e7044e82 --- /dev/null +++ b/apps/backoffice/src/drawings/DrawingsView.tsx @@ -0,0 +1,16 @@ +import {useState} from "react" +import {Clients, CLIENTS} from "../clients.ts" +import {PlaceReport} from "../gen/grpc/support/v1/admin_pb.ts" +import {DrawingDetail} from "./DrawingDetail.tsx" +import {DrawingList} from "./DrawingList.tsx" + +// The list is read again on the way back: a report closed in the detail leaves the open list. +export function DrawingsView({clients = CLIENTS}: {clients?: Clients}) { + const [openOnly, setOpenOnly] = useState(true) + const [opened, setOpened] = useState(undefined) + + if (opened !== undefined) { + return setOpened(undefined)}/> + } + return +} diff --git a/apps/backoffice/src/drawings/PainterList.tsx b/apps/backoffice/src/drawings/PainterList.tsx new file mode 100644 index 000000000..723f59e33 --- /dev/null +++ b/apps/backoffice/src/drawings/PainterList.tsx @@ -0,0 +1,42 @@ +import {Player} from "../gen/grpc/planet/v1/admin_pb.ts" +import {useOpenPlayer} from "../navigation.ts" +import {PlayerName} from "../players/PlayerName.tsx" +import {countOf} from "../support/labels.ts" +import {Painters} from "./actions.ts" + +// Who painted the area, most takes first, each opening on the player page, where the ban is. +export function PainterList({painters}: {painters: Painters}) { + if (painters.painters.length === 0) return

    Nobody painted it since then.

    + + const shown = painters.painters.length + return ( + <> +

    + {shown < painters.paintersTotal + ? `${shown} of ${painters.paintersTotal} painters` + : countOf(painters.paintersTotal, "painter")} +

    + {painters.unnamed !== undefined &&

    The names could not be read. {painters.unnamed}

    } +
      + {painters.painters.map(row => ( + + ))} +
    + + ) +} + +function Painter({row, name}: {row: Player, name: string}) { + const openPlayer = useOpenPlayer() + const caller = row.accountId !== "" ? {accountId: row.accountId} : {scope: row.scope} + + return ( +
  • + + {countOf(row.takes, "take")}, {row.tiles} held + {openPlayer !== undefined && ( + + )} +
  • + ) +} diff --git a/apps/backoffice/src/drawings/RestorePanel.tsx b/apps/backoffice/src/drawings/RestorePanel.tsx new file mode 100644 index 000000000..13341969a --- /dev/null +++ b/apps/backoffice/src/drawings/RestorePanel.tsx @@ -0,0 +1,83 @@ +import {useState} from "react" +import {PlanetAdmin, PlayerAdmin} from "../clients.ts" +import {ConfirmAction} from "../components/ConfirmAction.tsx" +import {timeOf} from "../components/time.ts" +import {countOf} from "../support/labels.ts" +import {useAction} from "../useAction.ts" +import {dateOfInput, localInputOf, Painters, paintersOf, refusalOf, restoreArea} from "./actions.ts" +import {PainterList} from "./PainterList.tsx" + +type Props = { + planet: PlanetAdmin + player: PlayerAdmin + tileId: number + rings: number | undefined + defaultBefore: Date +} + +const refused = (err: unknown) => refusalOf("restore", err) + +// The dry run comes first, and the restore asks with its numbers: a restore is undone by nothing. +export function RestorePanel({planet, player, tileId, rings, defaultBefore}: Props) { + const [beforeValue, setBeforeValue] = useState(localInputOf(defaultBefore)) + const before = dateOfInput(beforeValue) + const [count, start, reset] = useAction(refused) + const [countedFor, setCountedFor] = useState("") + const inputs = `${rings}|${beforeValue}` + const counted = count.step === "done" && countedFor === inputs ? count.value : undefined + const keptSince = count.step === "done" ? count.value.keptSince : undefined + + const dryRun = () => { + if (rings === undefined || before === undefined) return + setCountedFor(inputs) + start(() => paintersOf(planet, player, tileId, rings, before)) + } + + return ( +
    +

    Restore the area

    + +
    + + {count.step === "refused" &&

    {count.words}

    } + {counted !== undefined && before !== undefined && ( + <> +

    + {countOf(counted.tiles, "tile")} in the area, {counted.changed} changed since {timeOf(before)}. +

    + {counted.keptSince !== undefined && ( +

    The ledger reaches back to {timeOf(counted.keptSince)}.

    + )} + + + )} +
    + restoreArea(planet, tileId, rings ?? 0, before ?? defaultBefore, false)} + said={done => `Restored ${done.restored} of ${countOf(done.changed, "tile")}.` + + (done.restored < done.changed ? " The rest were taken again while it ran." : "")} + refusal={refused} + onDone={reset} + /> +
    + ) +} diff --git a/apps/backoffice/src/drawings/actions.test.ts b/apps/backoffice/src/drawings/actions.test.ts new file mode 100644 index 000000000..4444c7b27 --- /dev/null +++ b/apps/backoffice/src/drawings/actions.test.ts @@ -0,0 +1,84 @@ +import {Timestamp} from "@bufbuild/protobuf" +import {Code, ConnectError} from "@connectrpc/connect" +import {describe, expect, it} from "vitest" +import {ACCOUNT, AT, FakeAdmin} from "../fakeAdmin.ts" +import {PlaceReport, ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" +import { + beforeOf, + clearArea, + dateOfInput, + decidePlace, + localInputOf, + mapLinkOf, + OLDER_THAN_THE_LEDGER, + paintersOf, + PLACE_REPORT_GONE, + refusalOf, + restoreArea, +} from "./actions.ts" + +describe("drawing actions", () => { + it("links the game close up on the tile", () => { + expect(mapLinkOf(1234)).toBe("https://clickplanet.lol/?tile=1234") + }) + + it("starts the restore an hour before the report", () => { + const report = new PlaceReport({createdAt: Timestamp.fromDate(AT)}) + expect(beforeOf(report)).toEqual(new Date(AT.getTime() - 3_600_000)) + }) + + it("restores the area as it was, and answers what changed", async () => { + const admin = new FakeAdmin() + const before = new Date(AT.getTime() - 3_600_000) + + const done = await restoreArea(admin, 1234, 8, before, false) + + expect(admin.askedFor("restoreArea")).toEqual([{tileId: 1234, rings: 8, before: Timestamp.fromDate(before), dryRun: false}]) + expect(done).toMatchObject({tiles: 37, changed: 12, restored: 12, paintersTotal: 2}) + expect(done.keptSince).toEqual(admin.keptSince) + }) + + it("names the painters of a dry run, each account once", async () => { + const admin = new FakeAdmin() + + const counted = await paintersOf(admin, admin, 1234, 8, new Date(AT.getTime() - 3_600_000)) + + expect(admin.askedFor("restoreArea")).toEqual([expect.objectContaining({dryRun: true})]) + expect(admin.askedFor("getAuthors")).toEqual([{accountIds: [ACCOUNT]}]) + expect(counted.authors.get(ACCOUNT)?.name).toBe("BraveFox42") + expect(counted.unnamed).toBeUndefined() + }) + + it("clears the area, and answers how many tiles it gave to nobody", async () => { + const admin = new FakeAdmin() + + expect(await clearArea(admin, 1234, 8, true)).toEqual({tiles: 37, owned: 20, cleared: 0}) + expect(await clearArea(admin, 1234, 8, false)).toEqual({tiles: 37, owned: 20, cleared: 20}) + expect(admin.askedFor("clearArea")).toEqual([ + {tileId: 1234, rings: 8, dryRun: true}, + {tileId: 1234, rings: 8, dryRun: false}, + ]) + }) + + it("closes a place report with its outcome and a trimmed comment", async () => { + const admin = new FakeAdmin() + + expect(await decidePlace(admin, "place-report-1", ReportOutcome.ACTED, " restored \n")) + .toEqual({outcome: ReportOutcome.ACTED, comment: "restored", decidedAt: AT}) + expect(admin.askedFor("decidePlaceReport")) + .toEqual([{reportId: "place-report-1", outcome: ReportOutcome.ACTED, comment: "restored"}]) + }) + + it("says each refusal in the operator's words", () => { + expect(refusalOf("restore", new ConnectError("x", Code.FailedPrecondition))).toBe(OLDER_THAN_THE_LEDGER) + expect(refusalOf("decide", new ConnectError("x", Code.NotFound))).toBe(PLACE_REPORT_GONE) + expect(refusalOf("clear", new ConnectError("rings is 1 to 50", Code.InvalidArgument))) + .toBe("The server refused it: rings is 1 to 50") + }) + + it("reads back the time a datetime-local input holds, to the minute", () => { + const at = new Date(2026, 9, 18, 13, 5) + expect(dateOfInput(localInputOf(at))).toEqual(at) + expect(dateOfInput("")).toBeUndefined() + }) +}) diff --git a/apps/backoffice/src/drawings/actions.ts b/apps/backoffice/src/drawings/actions.ts new file mode 100644 index 000000000..2990b14f5 --- /dev/null +++ b/apps/backoffice/src/drawings/actions.ts @@ -0,0 +1,111 @@ +import {Timestamp} from "@bufbuild/protobuf" +import {PlanetAdmin, PlayerAdmin, SupportAdmin} from "../clients.ts" +import {Player} from "../gen/grpc/planet/v1/admin_pb.ts" +import {PlaceReport, ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" +import {Named, namesOf} from "../players/actions.ts" +import {wordsOf} from "../refusal.ts" +import {Decided} from "../reports/actions.ts" + +// A bomb's blast, and the most the server takes. +export const DEFAULT_RINGS = 8 +export const MAX_RINGS = 50 +// How long before its report a drawing is taken to have started. +export const BEFORE_REPORT_MS = 60 * 60 * 1000 + +export const MAP_URL = "https://clickplanet.lol/" + +export type Restored = { + tiles: number + changed: number + restored: number + painters: Player[] + paintersTotal: number + keptSince: Date | undefined +} +export type Painters = Restored & Named +export type Cleared = {tiles: number, owned: number, cleared: number} + +// The game, close up on the tile. +export function mapLinkOf(tileId: number): string { + return `${MAP_URL}?tile=${tileId}` +} + +export function beforeOf(report: PlaceReport): Date { + const reported = report.createdAt?.toDate() ?? new Date() + return new Date(reported.getTime() - BEFORE_REPORT_MS) +} + +export async function listPlaceReports(support: SupportAdmin, openOnly: boolean, signal?: AbortSignal): Promise { + const answer = await support.listPlaceReports({openOnly}, {signal}) + return answer.reports +} + +export async function decidePlace( + support: SupportAdmin, reportId: string, outcome: ReportOutcome, comment: string, +): Promise { + const said = comment.trim() + const answer = await support.decidePlaceReport({reportId, outcome, comment: said}) + return {outcome, comment: said, decidedAt: dateOf(answer.decidedAt)} +} + +export async function restoreArea( + planet: PlanetAdmin, tileId: number, rings: number, before: Date, dryRun: boolean, +): Promise { + const answer = await planet.restoreArea({tileId, rings, before: Timestamp.fromDate(before), dryRun}) + return { + tiles: answer.tiles, + changed: answer.changed, + restored: answer.restored, + painters: answer.painters, + paintersTotal: answer.paintersTotal, + keptSince: answer.keptSince?.toDate(), + } +} + +// The dry run, with who each painter is: the names the players list shows. +export async function paintersOf( + planet: PlanetAdmin, player: PlayerAdmin, tileId: number, rings: number, before: Date, +): Promise { + const counted = await restoreArea(planet, tileId, rings, before, true) + return {...counted, ...await namesOf(player, counted.painters.map(painter => painter.accountId))} +} + +export async function clearArea(planet: PlanetAdmin, tileId: number, rings: number, dryRun: boolean): Promise { + const answer = await planet.clearArea({tileId, rings, dryRun}) + return {tiles: answer.tiles, owned: answer.owned, cleared: answer.cleared} +} + +export type Action = "restore" | "clear" | "decide" | "list" + +export const OLDER_THAN_THE_LEDGER = "The ledger does not reach back to then: clear the area instead." +export const PLACE_REPORT_GONE = "This report is no longer kept." + +export function refusalOf(action: Action, err: unknown): string { + switch (action) { + case "restore": + return wordsOf(err, {failedPrecondition: OLDER_THAN_THE_LEDGER}) + case "decide": + return wordsOf(err, {notFound: PLACE_REPORT_GONE}) + case "clear": + case "list": + return wordsOf(err) + } +} + +// A date as a datetime-local input holds it, in the operator's time zone, to the minute. +export function localInputOf(date: Date): string { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 16) +} + +// What a datetime-local input holds, as a date; undefined while it holds none. +export function dateOfInput(value: string): Date | undefined { + if (value === "") return undefined + const date = new Date(value) + return Number.isNaN(date.getTime()) ? undefined : date +} + +function dateOf(at: {toDate(): Date} | undefined): Date { + if (at === undefined) throw new Error("the server answered no time") + return at.toDate() +} diff --git a/apps/backoffice/src/fakeAdmin.ts b/apps/backoffice/src/fakeAdmin.ts index a44a2a6ce..568bfe68e 100644 --- a/apps/backoffice/src/fakeAdmin.ts +++ b/apps/backoffice/src/fakeAdmin.ts @@ -49,13 +49,19 @@ import { import {Author, GetAuthorsRequest, GetAuthorsResponse} from "./gen/grpc/player/v1/internal_pb.ts" import {CountryTiles, Stats} from "./gen/grpc/player/v1/player_pb.ts" import { + DecidePlaceReportRequest, + DecidePlaceReportResponse, DecideReportRequest, DecideReportResponse, ListNotesRequest, ListNotesResponse, + ListPlaceReportsRequest, + ListPlaceReportsResponse, Note, NoteKind, + PlaceReport, } from "./gen/grpc/support/v1/admin_pb.ts" +import {PlaceReportReason} from "./gen/grpc/support/v1/support_pb.ts" import {ChatAdmin, Clients, PlanetAdmin, PlayerAdmin, SupportAdmin} from "./clients.ts" export const AT = new Date("2026-10-18T12:00:00Z") @@ -101,6 +107,18 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd new Player({scope: "2001:db8:1:2::/64", tiles: 40, takes: 50}), ] inspection = new InspectPlayerResponse({accountId: "account-reported", clicks: 42, banned: false}) + // A drawing reported on tile 1234, painted by two players; the ledger reaches back to a day before AT. + placeReports = [ + new PlaceReport({ + id: "place-report-1", reporterAccountId: "account-reporter", reporterName: "Bob", tileId: 1234, + reason: PlaceReportReason.HATE, text: "a swastika over Berlin", createdAt: Timestamp.fromDate(AT), + }), + ] + keptSince = new Date(AT.getTime() - 24 * 3_600_000) + painters = [ + new Player({accountId: ACCOUNT, scope: "203.0.113.7", takes: 40, tiles: 12, flagCountryId: "de"}), + new Player({scope: "2001:db8:1:2::/64", takes: 3, tiles: 0, flagCountryId: "de"}), + ] authors = [new Author({accountId: ACCOUNT, name: "BraveFox42"})] profile = new AdminGetPlayerResponse({ name: "BraveFox42", linked: true, createdAtUnixMs: BigInt(AT.getTime()), messagesSent: 4n, @@ -219,12 +237,21 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd })) } + // As the server: a time older than the ledger keeps is refused, and only a real run changes tiles. restoreArea(request: PartialMessage): Promise { - return this.answer("restoreArea", request, new RestoreAreaResponse({tiles: 37, changed: 12})) + if (request.before !== undefined && new Timestamp(request.before).toDate() < this.keptSince) { + this.asked.push({method: "restoreArea", request}) + return Promise.reject(new ConnectError("before is older than the ledger keeps", Code.FailedPrecondition)) + } + return this.answer("restoreArea", request, new RestoreAreaResponse({ + tiles: 37, changed: 12, restored: request.dryRun === true ? 0 : 12, + painters: this.painters, paintersTotal: this.painters.length, keptSince: Timestamp.fromDate(this.keptSince), + })) } clearArea(request: PartialMessage): Promise { - return this.answer("clearArea", request, new ClearAreaResponse({tiles: 37, owned: 20})) + return this.answer("clearArea", request, + new ClearAreaResponse({tiles: 37, owned: 20, cleared: request.dryRun === true ? 0 : 20})) } reconcileTitles(request: PartialMessage): Promise { @@ -250,4 +277,13 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd decideReport(request: PartialMessage): Promise { return this.answer("decideReport", request, new DecideReportResponse({decidedAt: Timestamp.fromDate(AT)})) } + + listPlaceReports(request: PartialMessage): Promise { + const reports = this.placeReports.filter(report => request.openOnly !== true || report.decision === undefined) + return this.answer("listPlaceReports", request, new ListPlaceReportsResponse({reports})) + } + + decidePlaceReport(request: PartialMessage): Promise { + return this.answer("decidePlaceReport", request, new DecidePlaceReportResponse({decidedAt: Timestamp.fromDate(AT)})) + } } diff --git a/apps/backoffice/src/index.css b/apps/backoffice/src/index.css index 8329f46ff..c0cc81faf 100644 --- a/apps/backoffice/src/index.css +++ b/apps/backoffice/src/index.css @@ -832,3 +832,19 @@ textarea { form.panel > .button { justify-self: start; } + +.painters { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.painter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + min-width: 0; +} diff --git a/apps/backoffice/src/navigation.ts b/apps/backoffice/src/navigation.ts index 5b8d5b8b5..764e27ee6 100644 --- a/apps/backoffice/src/navigation.ts +++ b/apps/backoffice/src/navigation.ts @@ -2,14 +2,21 @@ import {createContext, useContext} from "react" import {Player} from "./gen/grpc/planet/v1/admin_pb.ts" import {Caller} from "./players/caller.ts" -export type PageId = "notes" | "reports" | "players" | "reassign" | "paint" | "replay" | "titles" +export type PageId = "notes" | "reports" | "drawings" | "players" | "reassign" | "paint" | "replay" | "titles" export type MenuGroup = {label: string, pages: {page: PageId, label: string}[]} // Every page, as the burger menu lists them. A page's label is its title too. export const MENU: MenuGroup[] = [ {label: "Feedback", pages: [{page: "notes", label: "Ideas and bugs"}]}, - {label: "Moderation", pages: [{page: "reports", label: "Reports"}, {page: "players", label: "Players"}]}, + { + label: "Moderation", + pages: [ + {page: "reports", label: "Reports"}, + {page: "drawings", label: "Drawings"}, + {page: "players", label: "Players"}, + ], + }, { label: "Map", pages: [ diff --git a/apps/backoffice/src/reports/DecideForm.tsx b/apps/backoffice/src/reports/DecideForm.tsx index 79060e407..556ca5da3 100644 --- a/apps/backoffice/src/reports/DecideForm.tsx +++ b/apps/backoffice/src/reports/DecideForm.tsx @@ -1,21 +1,20 @@ import {useState} from "react" -import {SupportAdmin} from "../clients.ts" import {ConfirmAction} from "../components/ConfirmAction.tsx" import {timeOf} from "../components/time.ts" import {ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" import {outcomeLabel} from "../support/labels.ts" -import {decide, Decided, MAX_COMMENT_LENGTH, refusalOf} from "./actions.ts" +import {Decided, MAX_COMMENT_LENGTH, refusalOf} from "./actions.ts" const OUTCOMES = [ReportOutcome.ACTED, ReportOutcome.DISMISSED] type Props = { - support: SupportAdmin - reportId: string + // Closes the report, a player's or a place's. + decide: (outcome: ReportOutcome, comment: string) => Promise decided: boolean onDecided: (decided: Decided) => void } -export function DecideForm({support, reportId, decided, onDecided}: Props) { +export function DecideForm({decide, decided, onDecided}: Props) { const [outcome, setOutcome] = useState(undefined) const [comment, setComment] = useState("") @@ -48,7 +47,7 @@ export function DecideForm({support, reportId, decided, onDecided}: Props) { label="Close the report" disabled={outcome === undefined} question={`Close this report as ${outcomeLabel(outcome ?? ReportOutcome.UNSPECIFIED)}?`} - run={() => decide(support, reportId, outcome ?? ReportOutcome.UNSPECIFIED, comment)} + run={() => decide(outcome ?? ReportOutcome.UNSPECIFIED, comment)} said={done => `Closed as ${outcomeLabel(done.outcome)} at ${timeOf(done.decidedAt)}.`} refusal={err => refusalOf("decide", err)} onDone={onDecided} diff --git a/apps/backoffice/src/reports/ReportDetail.tsx b/apps/backoffice/src/reports/ReportDetail.tsx index 5385d3222..d76cacf44 100644 --- a/apps/backoffice/src/reports/ReportDetail.tsx +++ b/apps/backoffice/src/reports/ReportDetail.tsx @@ -5,7 +5,7 @@ import {Person} from "../components/Person.tsx" import {When} from "../components/When.tsx" import {Report, ReportDecision} from "../gen/grpc/support/v1/admin_pb.ts" import {reasonLabel} from "../support/labels.ts" -import {Decided} from "./actions.ts" +import {decide, Decided} from "./actions.ts" import {ActPanel} from "./ActPanel.tsx" import {DecideForm} from "./DecideForm.tsx" import {DecisionLine} from "./DecisionLine.tsx" @@ -64,8 +64,7 @@ export function ReportDetail({report, onBack, clients = CLIENTS}: Props) { )} decide(clients.support, report.id, outcome, comment)} decided={decision !== undefined} onDecided={decided} /> diff --git a/apps/backoffice/src/support/labels.ts b/apps/backoffice/src/support/labels.ts index b6c9247fa..c63ba7d08 100644 --- a/apps/backoffice/src/support/labels.ts +++ b/apps/backoffice/src/support/labels.ts @@ -1,5 +1,5 @@ import {NoteKind, ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" -import {PageContext, ReportReason} from "../gen/grpc/support/v1/support_pb.ts" +import {PageContext, PlaceReportReason, ReportReason} from "../gen/grpc/support/v1/support_pb.ts" const KINDS: Record = { [NoteKind.UNSPECIFIED]: "Unknown", @@ -30,6 +30,17 @@ export function reasonLabel(reason: ReportReason): string { return REASONS[reason] ?? `Reason ${reason}` } +const PLACE_REASONS: Record = { + [PlaceReportReason.UNSPECIFIED]: "Unknown", + [PlaceReportReason.HATE]: "Hate", + [PlaceReportReason.SEXUAL]: "Sexual", + [PlaceReportReason.OTHER]: "Other", +} + +export function placeReasonLabel(reason: PlaceReportReason): string { + return PLACE_REASONS[reason] ?? `Reason ${reason}` +} + export function outcomeLabel(outcome: ReportOutcome): string { return OUTCOMES[outcome] ?? `Outcome ${outcome}` } From 278b6677948550f06d376cd4b46dd86744867e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= <56402078+raphoester@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:08:14 +0200 Subject: [PATCH 2/2] feat(backoffice): an account's player page lists its chat messages (#439) * feat(backoffice): a report shows every message of the reported player A report from a player's card names no message, so the operator could not read what the player said. ListMessages takes an account id: that account's messages alone, the cursors included. The report detail lists them, newest first, and opens the conversation around each. Co-Authored-By: Claude Opus 5.5 * feat(backoffice): an account's player page lists its chat messages The player page counted an account's messages and could not show them. It now lists them as a report does, newest first, each with the conversation around it. Conversation and TheirMessages move to src/chat, which both pages use. Off a report there is no reporter, so the tags are optional, and the player page tags no line. Opening the player already open keeps its row: its own name in its messages opens it again. Co-Authored-By: Claude Opus 5.5 * feat(backoffice): a reported player's messages show in conversations Each message of the reported player came alone, its conversation behind a click. The panel now reads the chat around each and shows the conversations at once, three at a time. A message one already holds needs none of its own, and two that touch join, so an exchange is one conversation. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/backoffice/CLAUDE.md | 34 +++++++++------- apps/backoffice/src/App.tsx | 5 ++- .../src/{reports => chat}/Conversation.tsx | 10 ++--- .../src/{reports => chat}/TheirMessages.tsx | 10 ++--- .../src/{reports => chat}/chatPages.ts | 16 +------- .../theirConversations.test.ts | 0 .../{reports => chat}/theirConversations.ts | 0 apps/backoffice/src/players/PlayerView.tsx | 5 +++ .../src/players/PlayersPage.test.tsx | 39 +++++++++++++++++++ apps/backoffice/src/reports/MessagePanel.tsx | 20 ++++++++-- .../src/reports/ReportDetail.test.tsx | 2 +- apps/backoffice/src/reports/ReportDetail.tsx | 2 +- .../src/reports/TheirMessages.test.tsx | 2 +- 13 files changed, 98 insertions(+), 47 deletions(-) rename apps/backoffice/src/{reports => chat}/Conversation.tsx (97%) rename apps/backoffice/src/{reports => chat}/TheirMessages.tsx (96%) rename apps/backoffice/src/{reports => chat}/chatPages.ts (51%) rename apps/backoffice/src/{reports => chat}/theirConversations.test.ts (100%) rename apps/backoffice/src/{reports => chat}/theirConversations.ts (100%) diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 7a7a4cea5..cc8fc2960 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -16,8 +16,8 @@ burger menu (see [The menu](#the-menu)), by group: - **Map**: Reassign a country, Paint random tiles, Download a replay (see [The map tools](#the-map-tools)). - **Ops**: Reconcile titles, the run after a deploy (see [Ops](#ops)). -What is left of the plan is a live chat view: `ListMessages` pages through the chat (the report page reads it), -and no admin method streams it. +What is left of the plan is a live chat view: `ListMessages` pages through the chat (the report and player pages +read it), and no admin method streams it. It is a third app, not a page of the game: **it imports nothing from `apps/frontend`** and has no Three.js (see the root `CLAUDE.md`, "Independence of the apps"). @@ -144,20 +144,21 @@ note and keeps its text). the oldest or the newest message shown. A page shorter than asked is the end, and its button goes. The reported message shows while they load, marked ("Reported message", `aria-current`), and stays shown if they fail. `GetMessage` names no author, so its line takes the name the report gives its account. -- **What the reported account said, in its conversations** (`TheirMessages`, "What Ana said"), for every report: a - report from a player's card names no message, and one line cannot be judged alone. `ListMessages` with the - account's id reads its messages, 20 at a time (`THEIR_PAGE`), then `moreConversations` (`theirConversations.ts`) - reads the whole chat around each, `AROUND` (5) messages each side, and shows them at once, newest first, three at a - time (`CONVERSATIONS`), "Show more conversations" for the next. **One of their messages a conversation already holds - gets none of its own, and a conversation that reaches the next one joins it**, so an exchange of three insults is - one conversation, read with two calls. Their lines are marked (`line-theirs`) and tagged `reported`, the reporter's - `reporter`. Each conversation grows with "Show earlier" and "Show later". Nothing kept is "Nothing they said in the - chat is kept." +- **What the reported account said, in its conversations** (`chat/TheirMessages`, "What Ana said"), for every + report: a report from a player's card names no message, and one line cannot be judged alone. `ListMessages` with + the account's id reads its messages, 20 at a time (`THEIR_PAGE`), then `moreConversations` + (`theirConversations.ts`) reads the whole chat around each, `AROUND` (5) messages each side, and shows them at + once, newest first, three at a time (`CONVERSATIONS`), "Show more conversations" for the next. **One of their + messages a conversation already holds gets none of its own, and a conversation that reaches the next one joins + it**, so an exchange of three insults is one conversation, read with two calls. Their lines are marked + (`line-theirs`) and tagged `reported`, the reporter's `reporter`. Each conversation grows with "Show earlier" and + "Show later". Nothing kept is "Nothing they said in the chat is kept." - **Each line** has its author's name as the chat names it now, its country and its time (with the date when it is not the reported message's day), what it answers ("In reply to …"), and is struck through with when it was removed. **What the reporter and the reported account said is tagged** `reporter` and `reported`, by account id. An author's name opens it on the player page (`OpenPlayer`), so a third player in the conversation is a - click away. A page past the chat's retention is `not_found`: "That part of the chat is no longer kept." + click away; the player already open there stays open, with its row. A page past the chat's retention is + `not_found`: "That part of the chat is no longer kept." - **The actions, all on the reported account id**, never its name: remove the message (`RemoveMessage`, only while it is shown), mute (`Mute`, an hour by default), ban (`BanPlayer`, no duration by default, so the antibot's ladder picks the next step) and inspect (`InspectPlayer`). Then close the report (`DecideReport`): Acted or @@ -218,6 +219,10 @@ address over IPv4, the /64 over IPv6) and checks. heading is its flag and its name: the name the profile answers, which is the name it holds now, over the one the page that opened it knew; the flag of its row, or else the country it took the most tiles for. `not_found` is an account the game keeps nothing about. An address has no profile, and is not asked. +- **An account's page shows what it said, in its conversations** (`chat/TheirMessages`, as a report does): its + messages by its account id, the chat around each, three conversations at a time. Its lines are marked + (`line-theirs`), and no line is tagged: there is no report. An address has no messages, since the chat keeps them + by account, and is not asked. - **The page inspects on open** (`InspectPlayer`): the antibot's readings, its ban, its pardon and its clicks. The antibot off reads as such, not as a failure. A ban or an unban reads it again. - **The tools**: ban (`BanPlayer`, the ladder's next step by default), unban (`UnbanPlayer`, which forgets the @@ -289,8 +294,9 @@ src/ components/ Menu, ConfirmAction, FailureNotice, AccountId and Copyable (with Copy), Country, CountryField, Fact, Person, When support/ Ideas and bugs: NoteList, Attachments, labels.ts (names, outcomes, device lines, sizes) - reports/ Reports: list, detail, the report's own actions, Conversation and TheirMessages over - chatPages.ts (a page of ListMessages, the parties a report names) and theirConversations.ts + chat/ Conversation and TheirMessages, which reports/ and players/ show, over chatPages.ts (a page of + ListMessages, the parties a report names) and theirConversations.ts + reports/ Reports: list, detail, the report's own actions players/ Players: list, PlayerName, PlayerView, ProfilePanel, the tools on one player, actions.ts map/ Reassign a country, Paint random tiles, Download a replay ops/ Reconcile titles diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 9ce0a37aa..4cce20b13 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -8,6 +8,7 @@ import {ReassignPanel} from "./map/ReassignPanel.tsx" import {ReplayPanel} from "./map/ReplayPanel.tsx" import {OpenPlayer, Opened, PageId, titleOf} from "./navigation.ts" import {TitlesPanel} from "./ops/TitlesPanel.tsx" +import {idOf} from "./players/caller.ts" import {PlayersPage} from "./players/PlayersPage.tsx" import {ReportsView} from "./reports/ReportsView.tsx" import {Callback} from "./signIn/callback.ts" @@ -47,9 +48,9 @@ function Console({operator, onSignedOut}: {operator: string, onSignedOut: () => const [page, setPage] = usePage() const [player, setPlayer] = useState(undefined) - // A report opens its player on the Players page, where every tool on it is. + // A report opens its player on the Players page, where every tool on it is. The player already open keeps its row. const openPlayer = useCallback((opened: Opened) => { - setPlayer(opened) + setPlayer(shown => shown !== undefined && idOf(shown.caller) === idOf(opened.caller) ? shown : opened) setPage("players") }, [setPage]) diff --git a/apps/backoffice/src/reports/Conversation.tsx b/apps/backoffice/src/chat/Conversation.tsx similarity index 97% rename from apps/backoffice/src/reports/Conversation.tsx rename to apps/backoffice/src/chat/Conversation.tsx index 43c373a69..56935b933 100644 --- a/apps/backoffice/src/reports/Conversation.tsx +++ b/apps/backoffice/src/chat/Conversation.tsx @@ -18,8 +18,8 @@ type Props = { chat: ChatAdmin anchor: KeptMessage removedAt: Date | undefined - parties: Parties - // What the anchor is to the operator: "Reported message", "Their message". + parties: Parties | undefined + // What the anchor is to the operator: "Reported message", "This message". mark: string } @@ -55,7 +55,7 @@ type PagesProps = { chat: ChatAdmin first: {before: Page, after: Page} reported: Reported - parties: Parties + parties: Parties | undefined } function Pages({chat, first, reported, parties}: PagesProps) { @@ -118,7 +118,7 @@ type LinesProps = { before: KeptMessage[] reported: Reported after: KeptMessage[] - parties: Parties + parties: Parties | undefined } function Lines({before, reported, after, parties}: LinesProps) { @@ -134,7 +134,7 @@ function Lines({before, reported, after, parties}: LinesProps) { type LineProps = { line: KeptMessage - // Who the report names, to tag what each said; none when every line is one party's. + // Who the report names, to tag what each said; none off a report, or when every line is one party's. parties: Parties | undefined // The anchor's day: a line sent on another one shows its date too, as the anchor does. day: Date | undefined diff --git a/apps/backoffice/src/reports/TheirMessages.tsx b/apps/backoffice/src/chat/TheirMessages.tsx similarity index 96% rename from apps/backoffice/src/reports/TheirMessages.tsx rename to apps/backoffice/src/chat/TheirMessages.tsx index 4d94b64c3..e3a78824a 100644 --- a/apps/backoffice/src/reports/TheirMessages.tsx +++ b/apps/backoffice/src/chat/TheirMessages.tsx @@ -17,7 +17,7 @@ type Props = { chat: ChatAdmin accountId: string name: string - parties: Parties + parties: Parties | undefined } type Built = { @@ -34,8 +34,8 @@ async function theirPage(chat: ChatAdmin, accountId: string, before: string, sig return {newestFirst: [...page.messages].reverse(), more: page.more, oldest: page.messages[0]?.messageId ?? before} } -// What the reported account said in the chat, in the conversations it said it in, whatever the report names: a -// report from a player's card names no message. +// What one account said in the chat, in the conversations it said it in, by its account id: a report from a +// player's card names no message. export function TheirMessages({chat, accountId, name, parties}: Props) { const read = useCallback((ask, limit) => pageOf(chat, ask, limit), [chat]) const first = useCallback(async (signal: AbortSignal): Promise => { @@ -57,7 +57,7 @@ export function TheirMessages({chat, accountId, name, parties}: Props) { ) } -type ConversationsProps = {chat: ChatAdmin, accountId: string, first: Built, read: Read, parties: Parties} +type ConversationsProps = {chat: ChatAdmin, accountId: string, first: Built, read: Read, parties: Parties | undefined} function Conversations({chat, accountId, first, read, parties}: ConversationsProps) { const [built, setBuilt] = useState(first) @@ -101,7 +101,7 @@ type WindowProps = { chat: ChatAdmin window: Window accountId: string - parties: Parties + parties: Parties | undefined onGrown: (window: Window) => void } diff --git a/apps/backoffice/src/reports/chatPages.ts b/apps/backoffice/src/chat/chatPages.ts similarity index 51% rename from apps/backoffice/src/reports/chatPages.ts rename to apps/backoffice/src/chat/chatPages.ts index e421fc277..35c5a70c7 100644 --- a/apps/backoffice/src/reports/chatPages.ts +++ b/apps/backoffice/src/chat/chatPages.ts @@ -1,5 +1,5 @@ import {ChatAdmin} from "../clients.ts" -import {GetMessageResponse, KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" // An account a report names, so the conversation marks what it said. export type Party = {accountId: string, name: string} @@ -13,17 +13,3 @@ export async function pageOf(chat: ChatAdmin, ask: Ask, limit: number, signal?: const answer = await chat.listMessages({...ask, limit}, {signal}) return {messages: answer.messages, more: answer.messages.length === limit} } - -// GetMessage names no author: the report does, for the account it names. -export function keptOf(message: GetMessageResponse, parties: Parties): KeptMessage { - const party = [parties.reported, parties.reporter].find(each => each.accountId === message.accountId) - return new KeptMessage({ - messageId: message.messageId, - accountId: message.accountId, - authorName: message.accountId === "" ? "" : party?.name ?? "", - text: message.text, - sentAt: message.sentAt, - countryId: message.countryId, - removedAt: message.removedAt, - }) -} diff --git a/apps/backoffice/src/reports/theirConversations.test.ts b/apps/backoffice/src/chat/theirConversations.test.ts similarity index 100% rename from apps/backoffice/src/reports/theirConversations.test.ts rename to apps/backoffice/src/chat/theirConversations.test.ts diff --git a/apps/backoffice/src/reports/theirConversations.ts b/apps/backoffice/src/chat/theirConversations.ts similarity index 100% rename from apps/backoffice/src/reports/theirConversations.ts rename to apps/backoffice/src/chat/theirConversations.ts diff --git a/apps/backoffice/src/players/PlayerView.tsx b/apps/backoffice/src/players/PlayerView.tsx index ad6ea22c9..fc7c70e4f 100644 --- a/apps/backoffice/src/players/PlayerView.tsx +++ b/apps/backoffice/src/players/PlayerView.tsx @@ -1,5 +1,6 @@ import {Code, ConnectError} from "@connectrpc/connect" import {useCallback} from "react" +import {TheirMessages} from "../chat/TheirMessages.tsx" import {Clients} from "../clients.ts" import {Copyable} from "../components/AccountId.tsx" import {FailureNotice} from "../components/FailureNotice.tsx" @@ -88,6 +89,10 @@ export function PlayerView({clients, opened, onBack, onOpen}: Props) { {isAccount(caller) && } + {isAccount(caller) && ( + + )} +

    Antibot

    diff --git a/apps/backoffice/src/players/PlayersPage.test.tsx b/apps/backoffice/src/players/PlayersPage.test.tsx index 6346014a5..6ff13c518 100644 --- a/apps/backoffice/src/players/PlayersPage.test.tsx +++ b/apps/backoffice/src/players/PlayersPage.test.tsx @@ -4,7 +4,9 @@ import {Code} from "@connectrpc/connect" import {cleanup, fireEvent, render, screen, within} from "@testing-library/react" import {useState} from "react" import {afterEach, describe, expect, it} from "vitest" +import {AROUND} from "../chat/theirConversations.ts" import {ACCOUNT, AT, FakeAdmin} from "../fakeAdmin.ts" +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" import {InspectPlayerResponse} from "../gen/grpc/planet/v1/admin_pb.ts" import {CountryTiles} from "../gen/grpc/player/v1/player_pb.ts" import {Opened} from "../navigation.ts" @@ -18,6 +20,13 @@ function Page({admin, first}: {admin: FakeAdmin, first?: Opened}) { return } +function said(n: number, accountId = ACCOUNT, authorName = "BraveFox42") { + return new KeptMessage({ + messageId: `${accountId}-${n}`, accountId, authorName, text: `${authorName} ${n}`, + sentAt: Timestamp.fromDate(new Date(AT.getTime() + n * 60_000)), countryId: "fr", + }) +} + function confirm(label: string, yes: string) { fireEvent.click(screen.getByRole("button", {name: label})) fireEvent.click(screen.getByRole("button", {name: yes})) @@ -138,6 +147,36 @@ describe("one player", () => { expect(admin.askedFor("getPlayer")).toHaveLength(1) }) + it("shows what the account said in its conversations, read by its account id, no one tagged", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1, "account-cleo", "Cleo"), said(2), said(3, "account-dan", "Dan")] + render() + + const messages = within(await screen.findByRole("region", {name: "Their messages"})) + expect(await messages.findByRole("heading", {name: "What BraveFox42 said"})).toBeTruthy() + const list = await messages.findByRole("list", {name: "Their conversations, newest first"}) + const shown = within(list).getAllByRole("list", {name: "A conversation"}).map(conversation => + within(conversation).getAllByText(/^\w+ \d+$/, {selector: ".line-text"}).map(line => line.textContent)) + expect(shown).toEqual([["Cleo 1", "BraveFox42 2", "Dan 3"]]) + expect(messages.getByText("BraveFox42 2").closest("li")?.className).toContain("line-theirs") + expect(messages.queryByText("reported")).toBeNull() + expect(messages.queryByText("reporter")).toBeNull() + expect(admin.askedFor("listMessages")).toEqual([ + {accountId: ACCOUNT, limit: 20}, + {beforeMessageId: `${ACCOUNT}-2`, limit: AROUND}, + {afterMessageId: `${ACCOUNT}-2`, limit: AROUND}, + ]) + }) + + it("reads no messages for an address: the chat keeps them by account", async () => { + const admin = new FakeAdmin() + render() + + await screen.findByRole("heading", {name: "Ban"}) + expect(screen.queryByRole("region", {name: "Their messages"})).toBeNull() + expect(admin.askedFor("listMessages")).toEqual([]) + }) + it("says the antibot is off rather than failing", async () => { const admin = new FakeAdmin() admin.refuse("inspectPlayer", Code.FailedPrecondition) diff --git a/apps/backoffice/src/reports/MessagePanel.tsx b/apps/backoffice/src/reports/MessagePanel.tsx index 520671c79..bf01d5ac1 100644 --- a/apps/backoffice/src/reports/MessagePanel.tsx +++ b/apps/backoffice/src/reports/MessagePanel.tsx @@ -2,13 +2,13 @@ import {Code, ConnectError} from "@connectrpc/connect" import {useCallback, useState} from "react" import {FailureNotice} from "../components/FailureNotice.tsx" import {timeOf} from "../components/time.ts" -import {GetMessageResponse} from "../gen/grpc/chat/v1/admin_pb.ts" +import {GetMessageResponse, KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" import {ChatAdmin} from "../clients.ts" +import {Parties} from "../chat/chatPages.ts" +import {Conversation} from "../chat/Conversation.tsx" import {ConfirmAction} from "../components/ConfirmAction.tsx" import {useCall} from "../useCall.ts" import {MESSAGE_GONE, refusalOf, removeMessage} from "./actions.ts" -import {keptOf, Parties} from "./chatPages.ts" -import {Conversation} from "./Conversation.tsx" type Read = {kept: true, message: GetMessageResponse} | {kept: false} @@ -87,3 +87,17 @@ function Message({chat, message, parties, removedAt, remove, onRemoved}: Message ) } + +// GetMessage names no author: the report does, for the account it names. +function keptOf(message: GetMessageResponse, parties: Parties): KeptMessage { + const party = [parties.reported, parties.reporter].find(each => each.accountId === message.accountId) + return new KeptMessage({ + messageId: message.messageId, + accountId: message.accountId, + authorName: message.accountId === "" ? "" : party?.name ?? "", + text: message.text, + sentAt: message.sentAt, + countryId: message.countryId, + removedAt: message.removedAt, + }) +} diff --git a/apps/backoffice/src/reports/ReportDetail.test.tsx b/apps/backoffice/src/reports/ReportDetail.test.tsx index a080f3f90..8c0d82fc3 100644 --- a/apps/backoffice/src/reports/ReportDetail.test.tsx +++ b/apps/backoffice/src/reports/ReportDetail.test.tsx @@ -9,7 +9,7 @@ import {Report, ReportOutcome} from "../gen/grpc/support/v1/admin_pb.ts" import {ReportReason} from "../gen/grpc/support/v1/support_pb.ts" import {OpenPlayer} from "../navigation.ts" import {ReportDetail} from "./ReportDetail.tsx" -import {AROUND} from "./theirConversations.ts" +import {AROUND} from "../chat/theirConversations.ts" afterEach(cleanup) diff --git a/apps/backoffice/src/reports/ReportDetail.tsx b/apps/backoffice/src/reports/ReportDetail.tsx index e76a268ed..be5440c2a 100644 --- a/apps/backoffice/src/reports/ReportDetail.tsx +++ b/apps/backoffice/src/reports/ReportDetail.tsx @@ -1,5 +1,6 @@ import {Timestamp} from "@bufbuild/protobuf" import {useState} from "react" +import {TheirMessages} from "../chat/TheirMessages.tsx" import {Clients, CLIENTS} from "../clients.ts" import {Person} from "../components/Person.tsx" import {When} from "../components/When.tsx" @@ -10,7 +11,6 @@ import {ActPanel} from "./ActPanel.tsx" import {DecideForm} from "./DecideForm.tsx" import {DecisionLine} from "./DecisionLine.tsx" import {MessagePanel} from "./MessagePanel.tsx" -import {TheirMessages} from "./TheirMessages.tsx" type Props = { report: Report diff --git a/apps/backoffice/src/reports/TheirMessages.test.tsx b/apps/backoffice/src/reports/TheirMessages.test.tsx index cdc3dbd3a..9e5a171a3 100644 --- a/apps/backoffice/src/reports/TheirMessages.test.tsx +++ b/apps/backoffice/src/reports/TheirMessages.test.tsx @@ -8,7 +8,7 @@ import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" import {Report} from "../gen/grpc/support/v1/admin_pb.ts" import {ReportReason} from "../gen/grpc/support/v1/support_pb.ts" import {ReportDetail} from "./ReportDetail.tsx" -import {AROUND} from "./theirConversations.ts" +import {AROUND} from "../chat/theirConversations.ts" afterEach(cleanup)