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)