From 7a5becec40fcd35291bdde0826b8d43e40439063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 16:44:34 +0200 Subject: [PATCH 1/3] 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 --- apps/backend/CLAUDE.md | 7 +- apps/backend/e2e/report_actions_test.go | 28 ++++ .../generated/proto/chat/v1/admin.pb.go | 17 ++- .../chat/v1/chatv1connect/admin.connect.go | 4 +- .../list_messages_handler/handler.go | 15 ++- .../list_messages_handler/handler_test.go | 34 ++++- .../messages_query/query_postgres.go | 60 +++++++-- .../messages_query/query_postgres_test.go | 41 +++++- apps/backoffice/CLAUDE.md | 8 +- apps/backoffice/src/fakeAdmin.ts | 14 +- .../src/gen/grpc/chat/v1/admin_connect.ts | 2 +- .../src/gen/grpc/chat/v1/admin_pb.ts | 8 ++ apps/backoffice/src/reports/Conversation.tsx | 81 +++++------- apps/backoffice/src/reports/MessagePanel.tsx | 11 +- .../src/reports/ReportDetail.test.tsx | 28 ++-- apps/backoffice/src/reports/ReportDetail.tsx | 19 ++- .../src/reports/TheirMessages.test.tsx | 124 ++++++++++++++++++ apps/backoffice/src/reports/TheirMessages.tsx | 76 +++++++++++ apps/backoffice/src/reports/chatPages.ts | 29 ++++ .../src/gen/grpc/chat/v1/admin_connect.ts | 2 +- .../frontend/src/gen/grpc/chat/v1/admin_pb.ts | 8 ++ proto/chat/v1/admin.proto | 4 +- 22 files changed, 517 insertions(+), 103 deletions(-) create mode 100644 apps/backoffice/src/reports/TheirMessages.test.tsx create mode 100644 apps/backoffice/src/reports/TheirMessages.tsx create mode 100644 apps/backoffice/src/reports/chatPages.ts diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 0220cd19e..194cd99df 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -998,11 +998,16 @@ listener, for a report it acts on. The notice-and-action answer to a message, be message until its next load. - **`chat.v1.AdminService/GetMessage(message_id)`** is the message as it was sent, removed or not, with `removed_at`, for the operator who reads a report (`message_query`). `NotFound` past the retention. -- **`chat.v1.AdminService/ListMessages(before_message_id | after_message_id, limit)`** is the chat as it was sent, +- **`chat.v1.AdminService/ListMessages(before_message_id | after_message_id, limit, account_id)`** is the chat as it was sent, removed or not, a page at a time (`messages_query`). **A message's id is a cursor**: the page just before it, or just after it, by `seq` as the history orders them, never with it. Neither is the newest page; both is `InvalidArgument` (`ErrTwoCursors`), and a cursor past the retention is `NotFound`. The report page reads one page on each side of the reported message, and the next page from the oldest or the newest message it holds. + - **With an `account_id`, it is that account's messages alone**, cursors included: the page of its messages just + before or just after the cursor, which may be anybody's message. A report from a player's card names no + message, so the report page lists the reported account's this way, and reads the whole chat around any one of + them. An id that is not a UUID is `InvalidArgument` (`ErrInvalidAccount`): read as everyone, it would show other + people's messages as the account's. The two statements of one author ride the `(account_id)` index. - **A page is oldest first**, 50 messages unless asked, 200 at most. Fewer than the limit is the end: nothing more kept before it, or nothing more sent after it. Before the cursor it stops at the retention. - **Each is a `KeptMessage`**: its text as sent and its `removed_at`, as `GetMessage` answers, its country, its diff --git a/apps/backend/e2e/report_actions_test.go b/apps/backend/e2e/report_actions_test.go index 379dca9bf..eaa5c27b7 100644 --- a/apps/backend/e2e/report_actions_test.go +++ b/apps/backend/e2e/report_actions_test.go @@ -122,6 +122,34 @@ func TestAnOperatorReadsWhatWasSaidAroundAReportedMessageAPageEachSide(t *testin assert.Equal(t, answer.GetId(), newest[1].GetMessageId()) } +func TestAnOperatorReadsTheMessagesOfAPlayerReportedFromItsCard(t *testing.T) { + game := startGame(t) + offender, reporter := game.newPlayer(t), game.newPlayer(t) + first, err := offender.post() + require.NoError(t, err) + between, err := reporter.post() + require.NoError(t, err) + second, err := offender.post() + require.NoError(t, err) + require.NoError(t, reporter.reportPlayer(offender.account(), supportv1.ReportReason_REPORT_REASON_CHAT_ABUSE, "", "")) + + report := game.openReports(t)[0] + require.Empty(t, report.GetMessageId(), "a report from the card names no message") + + theirs := game.messages(t, &chatv1.ListMessagesRequest{AccountId: report.GetReportedAccountId()}) + require.Len(t, theirs, 2) + assert.Equal(t, first.GetId(), theirs[0].GetMessageId()) + assert.Equal(t, second.GetId(), theirs[1].GetMessageId()) + + around := game.messages(t, &chatv1.ListMessagesRequest{AfterMessageId: first.GetId(), Limit: 1}) + require.Len(t, around, 1) + assert.Equal(t, between.GetId(), around[0].GetMessageId(), "the context is everyone's") + + _, err = game.chatDesk().ListMessages(t.Context(), + connect.NewRequest(&chatv1.ListMessagesRequest{AccountId: "not-an-account"})) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) +} + func TestNothingToRemoveReadOrDecideIsNotFound(t *testing.T) { game := startGame(t) diff --git a/apps/backend/generated/proto/chat/v1/admin.pb.go b/apps/backend/generated/proto/chat/v1/admin.pb.go index c759235f2..2cd0cd7f3 100644 --- a/apps/backend/generated/proto/chat/v1/admin.pb.go +++ b/apps/backend/generated/proto/chat/v1/admin.pb.go @@ -352,7 +352,9 @@ type ListMessagesRequest struct { // The page of the messages sent just after this one. AfterMessageId string `protobuf:"bytes,2,opt,name=after_message_id,json=afterMessageId,proto3" json:"after_message_id,omitempty"` // 50 unless asked, 200 at most. - Limit uint32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + Limit uint32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + // Only this account's messages, the cursors included: a page is its messages sent just before or after the cursor. Empty is everyone's; not a UUID is InvalidArgument. + AccountId string `protobuf:"bytes,4,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -408,6 +410,13 @@ func (x *ListMessagesRequest) GetLimit() uint32 { return 0 } +func (x *ListMessagesRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + type ListMessagesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Oldest first. Fewer than the limit: nothing more is kept before it, or nothing more was sent after it. @@ -661,11 +670,13 @@ const file_chat_v1_admin_proto_rawDesc = "" + "\n" + "country_id\x18\x05 \x01(\tR\tcountryId\x129\n" + "\n" + - "removed_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tremovedAt\"\x81\x01\n" + + "removed_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tremovedAt\"\xa0\x01\n" + "\x13ListMessagesRequest\x12*\n" + "\x11before_message_id\x18\x01 \x01(\tR\x0fbeforeMessageId\x12(\n" + "\x10after_message_id\x18\x02 \x01(\tR\x0eafterMessageId\x12\x14\n" + - "\x05limit\x18\x03 \x01(\rR\x05limit\"H\n" + + "\x05limit\x18\x03 \x01(\rR\x05limit\x12\x1d\n" + + "\n" + + "account_id\x18\x04 \x01(\tR\taccountId\"H\n" + "\x14ListMessagesResponse\x120\n" + "\bmessages\x18\x01 \x03(\v2\x14.chat.v1.KeptMessageR\bmessages\"\xbe\x02\n" + "\vKeptMessage\x12\x1d\n" + diff --git a/apps/backend/generated/proto/chat/v1/chatv1connect/admin.connect.go b/apps/backend/generated/proto/chat/v1/chatv1connect/admin.connect.go index ec3104c54..1061f6919 100644 --- a/apps/backend/generated/proto/chat/v1/chatv1connect/admin.connect.go +++ b/apps/backend/generated/proto/chat/v1/chatv1connect/admin.connect.go @@ -52,7 +52,7 @@ type AdminServiceClient interface { RemoveMessage(context.Context, *connect.Request[v1.RemoveMessageRequest]) (*connect.Response[v1.RemoveMessageResponse], error) // A message as it was sent, removed or not. NotFound past the retention. GetMessage(context.Context, *connect.Request[v1.GetMessageRequest]) (*connect.Response[v1.GetMessageResponse], error) - // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. + // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. With an account, that account's messages alone. ListMessages(context.Context, *connect.Request[v1.ListMessagesRequest]) (*connect.Response[v1.ListMessagesResponse], error) } @@ -129,7 +129,7 @@ type AdminServiceHandler interface { RemoveMessage(context.Context, *connect.Request[v1.RemoveMessageRequest]) (*connect.Response[v1.RemoveMessageResponse], error) // A message as it was sent, removed or not. NotFound past the retention. GetMessage(context.Context, *connect.Request[v1.GetMessageRequest]) (*connect.Response[v1.GetMessageResponse], error) - // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. + // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. With an account, that account's messages alone. ListMessages(context.Context, *connect.Request[v1.ListMessagesRequest]) (*connect.Response[v1.ListMessagesResponse], error) } diff --git a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler.go b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler.go index 4b00b2ac1..4d9213563 100644 --- a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler.go +++ b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler.go @@ -9,10 +9,16 @@ import ( chatv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/chat/v1" "github.com/raphoester/clickplanet.lol-backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query" + "github.com/raphoester/clickplanet.lol-backend/internal/chat/internal/messages" ) +// Read as everyone, it would answer a page of other people's messages to an operator reading one account's. +var ErrInvalidAccount = errors.New("the account id is not a UUID") + type Query interface { - Messages(ctx context.Context, cursor messages_query.Cursor, limit uint32) (*chatv1.ListMessagesResponse, error) + Messages( + ctx context.Context, cursor messages_query.Cursor, author messages.AccountID, limit uint32, + ) (*chatv1.ListMessagesResponse, error) } func New(query Query) ListMessagesHandler { @@ -27,8 +33,13 @@ func (h ListMessagesHandler) ListMessages( ctx context.Context, req *connect.Request[chatv1.ListMessagesRequest], ) (*connect.Response[chatv1.ListMessagesResponse], error) { + author := messages.AccountIDOf(req.Msg.GetAccountId()) + if req.Msg.GetAccountId() != "" && author == messages.NoAccount { + return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidAccount) + } + cursor := messages_query.Cursor{Before: req.Msg.GetBeforeMessageId(), After: req.Msg.GetAfterMessageId()} - page, err := h.query.Messages(ctx, cursor, req.Msg.GetLimit()) + page, err := h.query.Messages(ctx, cursor, author, req.Msg.GetLimit()) if errors.Is(err, messages_query.ErrTwoCursors) { return nil, connect.NewError(connect.CodeInvalidArgument, messages_query.ErrTwoCursors) } diff --git a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler_test.go b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler_test.go index d636ba1f7..dc698a2c8 100644 --- a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler_test.go +++ b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/handler_test.go @@ -12,19 +12,21 @@ import ( chatv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/chat/v1" "github.com/raphoester/clickplanet.lol-backend/internal/chat/internal/chatv1controller/list_messages_handler" "github.com/raphoester/clickplanet.lol-backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query" + "github.com/raphoester/clickplanet.lol-backend/internal/chat/internal/messages" ) type stubQuery struct { err error cursor *messages_query.Cursor + author *messages.AccountID limit *uint32 } func (q stubQuery) Messages( - _ context.Context, cursor messages_query.Cursor, limit uint32, + _ context.Context, cursor messages_query.Cursor, author messages.AccountID, limit uint32, ) (*chatv1.ListMessagesResponse, error) { if q.cursor != nil { - *q.cursor, *q.limit = cursor, limit + *q.cursor, *q.author, *q.limit = cursor, author, limit } if q.err != nil { return nil, q.err @@ -32,19 +34,45 @@ func (q stubQuery) Messages( return &chatv1.ListMessagesResponse{Messages: []*chatv1.KeptMessage{{MessageId: "insult"}}}, nil } +func asking() stubQuery { + return stubQuery{cursor: new(messages_query.Cursor), author: new(messages.AccountID), limit: new(uint32)} +} + func TestThePageAskedForIsAnsweredAndNeverCached(t *testing.T) { - query := stubQuery{cursor: new(messages_query.Cursor), limit: new(uint32)} + query := asking() res, err := list_messages_handler.New(query).ListMessages(t.Context(), connect.NewRequest(&chatv1.ListMessagesRequest{BeforeMessageId: "insult", Limit: 10})) require.NoError(t, err) assert.Equal(t, messages_query.Cursor{Before: "insult"}, *query.cursor) + assert.Equal(t, messages.NoAccount, *query.author, "no account is everyone's messages") assert.Equal(t, uint32(10), *query.limit) assert.Equal(t, "insult", res.Msg.GetMessages()[0].GetMessageId()) assert.Equal(t, "no-store", res.Header().Get("Cache-Control")) } +func TestAnAccountAskedForIsTheOneAuthorRead(t *testing.T) { + query := asking() + ada := messages.AccountID{15: 1} + + _, err := list_messages_handler.New(query).ListMessages(t.Context(), + connect.NewRequest(&chatv1.ListMessagesRequest{AccountId: ada.String()})) + require.NoError(t, err) + + assert.Equal(t, ada, *query.author) +} + +func TestAnAccountThatIsNotAUUIDIsRefusedNotReadAsEveryone(t *testing.T) { + query := asking() + + _, err := list_messages_handler.New(query).ListMessages(t.Context(), + connect.NewRequest(&chatv1.ListMessagesRequest{AccountId: "Ada"})) + + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + assert.Equal(t, messages_query.Cursor{}, *query.cursor, "nothing was read") +} + func TestErrorsMapToTheirCodes(t *testing.T) { for err, code := range map[error]connect.Code{ messages_query.ErrTwoCursors: connect.CodeInvalidArgument, diff --git a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres.go b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres.go index deffd1ae8..3a48c98ff 100644 --- a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres.go +++ b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres.go @@ -59,7 +59,7 @@ const keptSeq = ` LIMIT 1 ` -// $1 is the start of the retention, $2 the limit and $3 the cursor's seq. +// $1 is the start of the retention, $2 the limit, $3 the cursor's seq and $4 the one author, when there is one. const ( older = ` SELECT seq, id, account_id, name, text, sent_at, country, reply_to @@ -75,6 +75,20 @@ const ( ORDER BY seq LIMIT $2 ` + olderOfOne = ` + SELECT seq, id, account_id, name, text, sent_at, country, reply_to + FROM messages + WHERE sent_at >= $1 AND seq < $3 AND account_id = $4 + ORDER BY seq DESC + LIMIT $2 + ` + newerOfOne = ` + SELECT seq, id, account_id, name, text, sent_at, country, reply_to + FROM messages + WHERE sent_at >= $1 AND seq > $3 AND account_id = $4 + ORDER BY seq + LIMIT $2 + ` projection = `) SELECT page.id, page.account_id, page.name, page.text, page.sent_at, page.country, removals.removed_at, @@ -92,8 +106,10 @@ const ( ) quoted ON true ORDER BY page.seq ` - olderPage = `WITH page AS (` + older + projection - newerPage = `WITH page AS (` + newer + projection + olderPage = `WITH page AS (` + older + projection + newerPage = `WITH page AS (` + newer + projection + olderPageOfOne = `WITH page AS (` + olderOfOne + projection + newerPageOfOne = `WITH page AS (` + newerOfOne + projection ) type row struct { @@ -108,19 +124,22 @@ type author struct { name string } -// A zero limit is DefaultLimit, and none passes MaxLimit. -func (q *PostgresQuery) Messages(ctx context.Context, cursor Cursor, limit uint32) (*chatv1.ListMessagesResponse, error) { +// A zero limit is DefaultLimit, and none passes MaxLimit. NoAccount as the author is everyone's messages. +func (q *PostgresQuery) Messages( + ctx context.Context, cursor Cursor, author cpsession.AccountID, limit uint32, +) (*chatv1.ListMessagesResponse, error) { if cursor.Before != "" && cursor.After != "" { return nil, ErrTwoCursors } since := q.clock.Now().Add(-q.retention) - statement, from, err := q.window(ctx, cursor, since) + backward, from, err := q.window(ctx, cursor, since) if err != nil { return nil, err } - rows, err := q.rows(ctx, statement, since, limitOf(limit), from) + statement, args := statementOf(backward, author) + rows, err := q.rows(ctx, statement, append([]any{since, limitOf(limit), from}, args...)...) if err != nil { return nil, err } @@ -141,17 +160,30 @@ func (q *PostgresQuery) Messages(ctx context.Context, cursor Cursor, limit uint3 return answer, nil } -// The statement that reads the page, and the seq it reads from. -func (q *PostgresQuery) window(ctx context.Context, cursor Cursor, since time.Time) (string, int64, error) { +// Whether the page reads back from its cursor, and the seq it reads from. +func (q *PostgresQuery) window(ctx context.Context, cursor Cursor, since time.Time) (bool, int64, error) { switch { case cursor.Before != "": seq, err := q.seqOf(ctx, cursor.Before, since) - return olderPage, seq, err + return true, seq, err case cursor.After != "": seq, err := q.seqOf(ctx, cursor.After, since) - return newerPage, seq, err + return false, seq, err + default: + return true, math.MaxInt64, nil + } +} + +func statementOf(backward bool, author cpsession.AccountID) (string, []any) { + switch { + case author == cpsession.NoAccount && backward: + return olderPage, nil + case author == cpsession.NoAccount: + return newerPage, nil + case backward: + return olderPageOfOne, []any{uuid.UUID(author)} default: - return olderPage, math.MaxInt64, nil + return newerPageOfOne, []any{uuid.UUID(author)} } } @@ -167,8 +199,8 @@ func (q *PostgresQuery) seqOf(ctx context.Context, id string, since time.Time) ( return seq, nil } -func (q *PostgresQuery) rows(ctx context.Context, statement string, since time.Time, limit int, from int64) ([]row, error) { - found, err := q.db.QueryContext(ctx, statement, since, limit, from) +func (q *PostgresQuery) rows(ctx context.Context, statement string, args ...any) ([]row, error) { + found, err := q.db.QueryContext(ctx, statement, args...) if err != nil { return nil, fmt.Errorf("failed to read a page of messages: %w", err) } diff --git a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres_test.go b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres_test.go index 72698f6a2..19d5cbea5 100644 --- a/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres_test.go +++ b/apps/backend/internal/chat/internal/chatv1controller/list_messages_handler/messages_query/query_postgres_test.go @@ -105,7 +105,13 @@ func (s *testSuite) query() *messages_query.PostgresQuery { } func (s *testSuite) page(cursor messages_query.Cursor, limit uint32) []*chatv1.KeptMessage { - page, err := s.query().Messages(s.T().Context(), cursor, limit) + page, err := s.query().Messages(s.T().Context(), cursor, messages.NoAccount, limit) + s.Require().NoError(err) + return page.GetMessages() +} + +func (s *testSuite) pageOf(author messages.AccountID, cursor messages_query.Cursor, limit uint32) []*chatv1.KeptMessage { + page, err := s.query().Messages(s.T().Context(), cursor, author, limit) s.Require().NoError(err) return page.GetMessages() } @@ -155,6 +161,31 @@ func (s *testSuite) TestAPageIsAsManyAsThereAre() { s.Empty(s.page(messages_query.Cursor{Before: "1"}, 10), "nothing is kept before the oldest") } +func (s *testSuite) TestAnAccountsPageHoldsItsMessagesAloneOldestFirst() { + s.conversation(9) + + s.Equal([]string{"2", "4", "6", "8"}, ids(s.pageOf(bob, messages_query.Cursor{}, 10))) + s.Equal([]string{"6", "8"}, ids(s.pageOf(bob, messages_query.Cursor{}, 2)), "its newest") + s.Empty(s.pageOf(deleted, messages_query.Cursor{}, 10), "an account that said nothing") +} + +func (s *testSuite) TestAnAccountsCursorsPageThroughItsMessagesAlone() { + s.conversation(9) + + s.Equal([]string{"4", "6"}, ids(s.pageOf(bob, messages_query.Cursor{Before: "8"}, 2))) + s.Equal([]string{"4", "6"}, ids(s.pageOf(bob, messages_query.Cursor{After: "2"}, 2))) + s.Equal([]string{"2", "4"}, ids(s.pageOf(bob, messages_query.Cursor{Before: "5"}, 10)), + "a cursor may be another author's message") + + var read []string + page := s.pageOf(ada, messages_query.Cursor{}, 2) + for len(page) > 0 { + read = append(ids(page), read...) + page = s.pageOf(ada, messages_query.Cursor{Before: page[0].GetMessageId()}, 2) + } + s.Equal([]string{"1", "3", "5", "7", "9"}, read, "paging back reads each of its messages once") +} + func (s *testSuite) TestPagingBackFromTheNewestReadsEveryMessageOnce() { s.conversation(7) @@ -188,7 +219,7 @@ func (s *testSuite) TestACursorPastTheRetentionOrNeverSentIsNotFound() { s.sent(messages.Snapshot{ID: "old", SentAt: now.Add(-retention - time.Second), Account: bob, Country: "fr", Text: "old"}) for _, cursor := range []messages_query.Cursor{{Before: "old"}, {After: "old"}, {Before: "never"}, {After: "never"}} { - _, err := s.query().Messages(s.T().Context(), cursor, 10) + _, err := s.query().Messages(s.T().Context(), cursor, messages.NoAccount, 10) s.Require().ErrorIs(err, messages_query.ErrNoMessage, cursor) } } @@ -196,7 +227,7 @@ func (s *testSuite) TestACursorPastTheRetentionOrNeverSentIsNotFound() { func (s *testSuite) TestTwoCursorsAreRefused() { s.conversation(3) - _, err := s.query().Messages(s.T().Context(), messages_query.Cursor{Before: "3", After: "1"}, 10) + _, err := s.query().Messages(s.T().Context(), messages_query.Cursor{Before: "3", After: "1"}, messages.NoAccount, 10) s.Require().ErrorIs(err, messages_query.ErrTwoCursors) } @@ -223,7 +254,7 @@ func (s *testSuite) TestEveryAuthorIsNamedAsNowAndAskedForOnce() { s.sent(messages.Snapshot{ID: "again", SentAt: now.Add(-time.Minute), Account: bob, Country: "fr", Text: "again"}) page, err := messages_query.NewPostgresQuery(s.db, stubAuthors{asked: asked}, cptime.NewFixedClock(now), retention). - Messages(s.T().Context(), messages_query.Cursor{}, 10) + Messages(s.T().Context(), messages_query.Cursor{}, messages.NoAccount, 10) s.Require().NoError(err) kept := page.GetMessages() @@ -268,7 +299,7 @@ func (s *testSuite) TestNobodyToNameRefusesTheRead() { s.conversation(3) _, err := messages_query.NewPostgresQuery(s.db, stubAuthors{err: assert.AnError}, cptime.NewFixedClock(now), retention). - Messages(s.T().Context(), messages_query.Cursor{}, 10) + Messages(s.T().Context(), messages_query.Cursor{}, messages.NoAccount, 10) s.Require().ErrorIs(err, assert.AnError) } diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 314ccaa8e..8b9edd26b 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -130,6 +130,11 @@ title (`titleOf`), shown above it. `Console` in `App.tsx` shows the page picked. 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. +- **The reported account's own messages** (`TheirMessages`, "Messages from Ana"), for every report: a report from a + player's card names no message, and a chat abuse is rarely one line. `ListMessages` with the account's id reads + them, 20 a page (`THEIR_PAGE`), shown newest first, "Show earlier" for more. Each is struck through once removed, + and "Show what was said around it" opens its `Conversation` in place, the message marked "This message", the + reporter and the reported tagged. 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 @@ -266,7 +271,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, Conversation and TheirMessages over + chatPages.ts (a page of ListMessages, the parties a report names) 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/fakeAdmin.ts b/apps/backoffice/src/fakeAdmin.ts index 244f6a647..7ad5f35fe 100644 --- a/apps/backoffice/src/fakeAdmin.ts +++ b/apps/backoffice/src/fakeAdmin.ts @@ -119,7 +119,8 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd return this.answer("getMessage", request, this.message) } - // A page of chat as the server reads it: oldest first, just before or just after the cursor, the newest with none. + // A page of chat as the server reads it: oldest first, just before or just after the cursor, the newest with none, + // and one account's messages alone when it names one. The cursor may be anybody's message. listMessages(request: PartialMessage): Promise { const limit = request.limit || 50 const cursor = request.beforeMessageId || request.afterMessageId || "" @@ -128,10 +129,13 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd this.asked.push({method: "listMessages", request}) return Promise.reject(new ConnectError("no message kept under this id", Code.NotFound)) } - const messages = request.afterMessageId - ? this.chat.slice(at + 1, at + 1 + limit) - : this.chat.slice(Math.max(0, (cursor === "" ? this.chat.length : at) - limit), cursor === "" ? undefined : at) - return this.answer("listMessages", request, new ListMessagesResponse({messages})) + const kept = this.chat + .map((message, index) => ({message, index})) + .filter(({message}) => !request.accountId || message.accountId === request.accountId) + const page = request.afterMessageId + ? kept.filter(({index}) => index > at).slice(0, limit) + : kept.filter(({index}) => cursor === "" || index < at).slice(-limit) + return this.answer("listMessages", request, new ListMessagesResponse({messages: page.map(({message}) => message)})) } removeMessage(request: PartialMessage): Promise { diff --git a/apps/backoffice/src/gen/grpc/chat/v1/admin_connect.ts b/apps/backoffice/src/gen/grpc/chat/v1/admin_connect.ts index b37459573..19f42b0cb 100644 --- a/apps/backoffice/src/gen/grpc/chat/v1/admin_connect.ts +++ b/apps/backoffice/src/gen/grpc/chat/v1/admin_connect.ts @@ -46,7 +46,7 @@ export const AdminService = { kind: MethodKind.Unary, }, /** - * The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. + * The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. With an account, that account's messages alone. * * @generated from rpc chat.v1.AdminService.ListMessages */ diff --git a/apps/backoffice/src/gen/grpc/chat/v1/admin_pb.ts b/apps/backoffice/src/gen/grpc/chat/v1/admin_pb.ts index 67afee6ac..9ea704749 100644 --- a/apps/backoffice/src/gen/grpc/chat/v1/admin_pb.ts +++ b/apps/backoffice/src/gen/grpc/chat/v1/admin_pb.ts @@ -299,6 +299,13 @@ export class ListMessagesRequest extends Message { */ limit = 0; + /** + * Only this account's messages, the cursors included: a page is its messages sent just before or after the cursor. Empty is everyone's; not a UUID is InvalidArgument. + * + * @generated from field: string account_id = 4; + */ + accountId = ""; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -310,6 +317,7 @@ export class ListMessagesRequest extends Message { { no: 1, name: "before_message_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "after_message_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ListMessagesRequest { diff --git a/apps/backoffice/src/reports/Conversation.tsx b/apps/backoffice/src/reports/Conversation.tsx index b3cbe5103..43c373a69 100644 --- a/apps/backoffice/src/reports/Conversation.tsx +++ b/apps/backoffice/src/reports/Conversation.tsx @@ -2,10 +2,11 @@ import {useCallback, useState} from "react" import {ChatAdmin} from "../clients.ts" import {FailureNotice} from "../components/FailureNotice.tsx" import {clockOf, timeOf} from "../components/time.ts" -import {GetMessageResponse, KeptMessage, KeptQuote} from "../gen/grpc/chat/v1/admin_pb.ts" +import {KeptMessage, KeptQuote} from "../gen/grpc/chat/v1/admin_pb.ts" import {useOpenPlayer} from "../navigation.ts" import {wordsOf} from "../refusal.ts" import {useCall} from "../useCall.ts" +import {Page, pageOf, Parties} from "./chatPages.ts" // How many messages the first page on each side of the reported one holds, and each page asked for after. const FIRST_PAGE = 10 @@ -13,30 +14,19 @@ const NEXT_PAGE = 20 export const CHAT_GONE = "That part of the chat is no longer kept." -// An account a report names, so the conversation marks what it said. -export type Party = {accountId: string, name: string} -export type Parties = {reporter: Party, reported: Party} - -// A page is full when it holds as many as asked: there may be more past it. -type Page = {messages: KeptMessage[], more: boolean} -type Cursor = {beforeMessageId: string} | {afterMessageId: string} - -async function pageOf(chat: ChatAdmin, cursor: Cursor, limit: number, signal?: AbortSignal): Promise { - const answer = await chat.listMessages({...cursor, limit}, {signal}) - return {messages: answer.messages, more: answer.messages.length === limit} -} - type Props = { chat: ChatAdmin - message: GetMessageResponse + anchor: KeptMessage removedAt: Date | undefined parties: Parties + // What the anchor is to the operator: "Reported message", "Their message". + mark: string } -// The message reported among the pages of the chat just before and just after it, oldest first, the reported one -// shown while they load. -export function Conversation({chat, message, removedAt, parties}: Props) { - const messageId = message.messageId +// A message among the pages of the chat just before and just after it, oldest first, the message itself shown while +// they load. +export function Conversation({chat, anchor, removedAt, parties, mark}: Props) { + const messageId = anchor.messageId const first = useCallback( (signal: AbortSignal) => Promise.all([ pageOf(chat, {beforeMessageId: messageId}, FIRST_PAGE, signal), @@ -45,7 +35,7 @@ export function Conversation({chat, message, removedAt, parties}: Props) { [chat, messageId], ) const [call, again] = useCall(first) - const reported = {line: lineOf(message, parties), removedAt} + const reported = {line: anchor, removedAt, mark} if (call.state === "done") { return @@ -59,21 +49,7 @@ export function Conversation({chat, message, removedAt, parties}: Props) { ) } -// GetMessage names no author: the report does, for the account it names. -function lineOf(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, - }) -} - -type Reported = {line: KeptMessage, removedAt: Date | undefined} +type Reported = {line: KeptMessage, removedAt: Date | undefined, mark: string} type PagesProps = { chat: ChatAdmin @@ -114,7 +90,7 @@ function Pages({chat, first, reported, parties}: PagesProps) { type MoreStep = {step: "idle"} | {step: "running"} | {step: "refused", words: string} // The next page, on a click: a read, so it asks nothing first. -function More({label, load, onLoaded}: {label: string, load: () => Promise, onLoaded: (page: Page) => void}) { +export function More({label, load, onLoaded}: {label: string, load: () => Promise, onLoaded: (page: Page) => void}) { const [step, setStep] = useState({step: "idle"}) const run = () => { @@ -150,7 +126,7 @@ function Lines({before, reported, after, parties}: LinesProps) { return (
    {before.map(line => )} - + {after.map(line => )}
) @@ -158,21 +134,32 @@ function Lines({before, reported, after, parties}: LinesProps) { type LineProps = { line: KeptMessage - parties: Parties - // The day of the message reported: a line sent on another one shows its date too, as the reported one does. + // Who the report names, to tag what each said; none 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 - reported?: boolean + // The anchor's mark: the line the conversation is around. + mark?: string removedAt?: Date } -function Line({line, parties, day, reported = false, removedAt}: LineProps) { +function Line(props: LineProps) { + const anchor = props.mark !== undefined + return ( +
  • + +
  • + ) +} + +export function LineBody({line, parties, day, mark, removedAt}: LineProps) { const removed = removedAt ?? line.removedAt?.toDate() const party = partyOf(line.accountId, parties) return ( -
  • + <>
    - {reported && Reported message} + {mark !== undefined && {mark}} {party !== undefined && {party}} @@ -182,13 +169,13 @@ function Line({line, parties, day, reported = false, removedAt}: LineProps) {
    {line.replyTo !== undefined && }

    {line.text}

    - {removed !== undefined && !reported &&

    Removed at {timeOf(removed)}.

    } -
  • + {removed !== undefined && mark === undefined &&

    Removed at {timeOf(removed)}.

    } + ) } -function partyOf(accountId: string, parties: Parties): "reporter" | "reported" | undefined { - if (accountId === "") return undefined +function partyOf(accountId: string, parties: Parties | undefined): "reporter" | "reported" | undefined { + if (accountId === "" || parties === undefined) return undefined if (accountId === parties.reported.accountId) return "reported" if (accountId === parties.reporter.accountId) return "reporter" return undefined diff --git a/apps/backoffice/src/reports/MessagePanel.tsx b/apps/backoffice/src/reports/MessagePanel.tsx index 175a32b9c..520671c79 100644 --- a/apps/backoffice/src/reports/MessagePanel.tsx +++ b/apps/backoffice/src/reports/MessagePanel.tsx @@ -7,7 +7,8 @@ import {ChatAdmin} from "../clients.ts" import {ConfirmAction} from "../components/ConfirmAction.tsx" import {useCall} from "../useCall.ts" import {MESSAGE_GONE, refusalOf, removeMessage} from "./actions.ts" -import {Conversation, Parties} from "./Conversation.tsx" +import {keptOf, Parties} from "./chatPages.ts" +import {Conversation} from "./Conversation.tsx" type Read = {kept: true, message: GetMessageResponse} | {kept: false} @@ -63,7 +64,13 @@ type MessageProps = { function Message({chat, message, parties, removedAt, remove, onRemoved}: MessageProps) { return ( <> - + {removedAt !== undefined ?

    Removed at {timeOf(removedAt)}.

    : ( diff --git a/apps/backoffice/src/reports/ReportDetail.test.tsx b/apps/backoffice/src/reports/ReportDetail.test.tsx index d2ba42fb3..1380d7af0 100644 --- a/apps/backoffice/src/reports/ReportDetail.test.tsx +++ b/apps/backoffice/src/reports/ReportDetail.test.tsx @@ -35,6 +35,16 @@ async function lines(): Promise { return within(screen.getByRole("list", {name: "The conversation"})).getAllByRole("listitem") } +// The reported message's own panel: the page also lists every message of the reported account. +function messagePanel() { + return within(screen.getByRole("region", {name: "The message"})) +} + +// What the conversation around the reported message asked, leaving out the reported account's own messages. +function asksAround(admin: FakeAdmin): unknown[] { + return admin.askedFor("listMessages").filter(ask => !(ask as {accountId?: string}).accountId) +} + function confirm(label: string, yes: string) { fireEvent.click(screen.getByRole("button", {name: label})) fireEvent.click(screen.getByRole("button", {name: yes})) @@ -45,10 +55,10 @@ describe("ReportDetail", () => { const admin = new FakeAdmin() open(admin) - expect(await screen.findByText("you all suck")).toBeTruthy() + expect(await messagePanel().findByText("you all suck")).toBeTruthy() await lines() expect(admin.askedFor("getMessage")).toEqual([{messageId: "message-1"}]) - expect(admin.askedFor("listMessages")).toEqual([ + expect(asksAround(admin)).toEqual([ {beforeMessageId: "message-1", limit: 10}, {afterMessageId: "message-1", limit: 10}, ]) @@ -110,7 +120,7 @@ describe("ReportDetail", () => { expect(await within(list).findByText("after 22")).toBeTruthy() expect(within(list).getAllByRole("listitem")).toHaveLength(48) - expect(admin.askedFor("listMessages").slice(2)).toEqual([ + expect(asksAround(admin).slice(2)).toEqual([ {beforeMessageId: "before-16", limit: 20}, {afterMessageId: "after-10", limit: 20}, ]) @@ -125,7 +135,7 @@ describe("ReportDetail", () => { expect(await screen.findByText("Nothing said before it is kept.")).toBeTruthy() expect(screen.getByText("Nothing was said after it.")).toBeTruthy() - expect(screen.queryByRole("button", {name: /^Show/})).toBeNull() + expect(messagePanel().queryByRole("button", {name: /^Show/})).toBeNull() }) it("strikes a message removed around it, with when", async () => { @@ -143,13 +153,13 @@ describe("ReportDetail", () => { admin.refuse("listMessages", Code.Unavailable) open(admin) - expect(await screen.findByText("The call failed.")).toBeTruthy() - expect(screen.getByText("you all suck")).toBeTruthy() + expect(await messagePanel().findByText("The call failed.")).toBeTruthy() + expect(messagePanel().getByText("you all suck")).toBeTruthy() admin.refusals.delete("listMessages") - fireEvent.click(screen.getByRole("button", {name: "Try again"})) + fireEvent.click(messagePanel().getByRole("button", {name: "Try again"})) - expect(await screen.findByText("so rude")).toBeTruthy() + expect(await messagePanel().findByText("so rude")).toBeTruthy() }) it("says when an earlier page is no longer kept", async () => { @@ -191,7 +201,7 @@ describe("ReportDetail", () => { it("removes the message once confirmed, and shows it removed", async () => { const admin = new FakeAdmin() open(admin) - await screen.findByText("you all suck") + await messagePanel().findByText("you all suck") confirm("Remove the message", "Yes, remove the message") diff --git a/apps/backoffice/src/reports/ReportDetail.tsx b/apps/backoffice/src/reports/ReportDetail.tsx index 5385d3222..e76a268ed 100644 --- a/apps/backoffice/src/reports/ReportDetail.tsx +++ b/apps/backoffice/src/reports/ReportDetail.tsx @@ -10,6 +10,7 @@ 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 @@ -19,6 +20,10 @@ type Props = { export function ReportDetail({report, onBack, clients = CLIENTS}: Props) { const [decision, setDecision] = useState(report.decision) + const parties = { + reporter: {accountId: report.reporterAccountId, name: report.reporterName}, + reported: {accountId: report.reportedAccountId, name: report.reportedName}, + } const decided = (done: Decided) => setDecision(new ReportDecision({ outcome: done.outcome, @@ -44,13 +49,15 @@ export function ReportDetail({report, onBack, clients = CLIENTS}: Props) { {report.messageId !== "" && ( - + )} + + {report.reportedAccountId !== "" && ( + )} diff --git a/apps/backoffice/src/reports/TheirMessages.test.tsx b/apps/backoffice/src/reports/TheirMessages.test.tsx new file mode 100644 index 000000000..f34316168 --- /dev/null +++ b/apps/backoffice/src/reports/TheirMessages.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import {Timestamp} from "@bufbuild/protobuf" +import {Code} from "@connectrpc/connect" +import {cleanup, fireEvent, render, screen, waitFor, within} from "@testing-library/react" +import {afterEach, describe, expect, it} from "vitest" +import {AT, FakeAdmin} from "../fakeAdmin.ts" +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" + +afterEach(cleanup) + +// A report from the player's card: it names the account, and no message. +const fromTheCard = new Report({ + id: "report-2", + reporterAccountId: "account-reporter", + reportedAccountId: "account-reported", + reporterName: "Bob", + reportedName: "Ana", + reason: ReportReason.CHAT_ABUSE, + createdAt: Timestamp.fromDate(AT), +}) + +function said(n: number, accountId = "account-reported", authorName = "Ana") { + return new KeptMessage({ + messageId: `${accountId}-${n}`, accountId, authorName, text: `${authorName} ${n}`, + sentAt: Timestamp.fromDate(new Date(AT.getTime() + n * 60_000)), countryId: "fr", + }) +} + +function open(admin: FakeAdmin) { + render( undefined} clients={admin.clients()}/>) +} + +function panel() { + return within(screen.getByRole("region", {name: "Their messages"})) +} + +async function theirs(): Promise { + const list = await panel().findByRole("list", {name: "Their messages, newest first"}) + return within(list).getAllByText(/^Ana \d+$/, {selector: ".line-text"}).map(line => line.textContent ?? "") +} + +describe("TheirMessages", () => { + it("lists what the reported account said, newest first, read by its account id", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1), said(1, "account-cleo", "Cleo"), said(2), said(3)] + open(admin) + + expect(await theirs()).toEqual(["Ana 3", "Ana 2", "Ana 1"]) + expect(admin.askedFor("listMessages")).toEqual([{accountId: "account-reported", limit: 20}]) + expect(screen.queryByRole("region", {name: "The message"})).toBeNull() + }) + + it("reads their earlier messages on asking, from the oldest shown", async () => { + const admin = new FakeAdmin() + admin.chat = Array.from({length: 25}, (_, n) => said(n + 1)) + open(admin) + + expect(await theirs()).toHaveLength(20) + fireEvent.click(panel().getByRole("button", {name: "Show earlier"})) + + await waitFor(async () => expect(await theirs()).toHaveLength(25)) + expect((await theirs()).at(-1)).toBe("Ana 1") + expect(admin.askedFor("listMessages").at(-1)) + .toEqual({accountId: "account-reported", beforeMessageId: "account-reported-6", limit: 20}) + expect(panel().queryByRole("button", {name: "Show earlier"})).toBeNull() + }) + + it("shows what everyone said around one of their messages, on asking", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1, "account-reporter", "Bob"), said(2), said(3, "account-cleo", "Cleo")] + open(admin) + await theirs() + + fireEvent.click(panel().getByRole("button", {name: "Show what was said around it"})) + + await panel().findByRole("list", {name: "The conversation"}) + await waitFor(() => expect(panel().queryByText("Loading what was said around it…")).toBeNull()) + const conversation = panel().getByRole("list", {name: "The conversation"}) + expect(within(conversation).getByText("Cleo 3")).toBeTruthy() + expect(within(conversation).getByText("Bob 1")).toBeTruthy() + expect(within(conversation).getByText("This message")).toBeTruthy() + expect(within(conversation).getByText("reporter")).toBeTruthy() + expect(admin.askedFor("listMessages").slice(1)).toEqual([ + {beforeMessageId: "account-reported-2", limit: 10}, + {afterMessageId: "account-reported-2", limit: 10}, + ]) + }) + + it("strikes a message of theirs that was removed", async () => { + const admin = new FakeAdmin() + const removed = said(1) + removed.removedAt = Timestamp.fromDate(AT) + admin.chat = [removed] + open(admin) + + await theirs() + expect(panel().getByText("Ana 1").className).toContain("line-removed") + expect(panel().getByText(/^Removed at /)).toBeTruthy() + }) + + it("says when nothing they said is kept", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1, "account-cleo", "Cleo")] + open(admin) + + expect(await panel().findByText("Nothing they said in the chat is kept.")).toBeTruthy() + }) + + it("says when their messages cannot be read, and reads them again", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1)] + admin.refuse("listMessages", Code.Unavailable) + open(admin) + + expect(await panel().findByText("The call failed.")).toBeTruthy() + admin.refusals.delete("listMessages") + fireEvent.click(panel().getByRole("button", {name: "Try again"})) + + expect(await theirs()).toEqual(["Ana 1"]) + }) +}) diff --git a/apps/backoffice/src/reports/TheirMessages.tsx b/apps/backoffice/src/reports/TheirMessages.tsx new file mode 100644 index 000000000..a07abfb70 --- /dev/null +++ b/apps/backoffice/src/reports/TheirMessages.tsx @@ -0,0 +1,76 @@ +import {useCallback, useState} from "react" +import {ChatAdmin} from "../clients.ts" +import {FailureNotice} from "../components/FailureNotice.tsx" +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" +import {useCall} from "../useCall.ts" +import {Page, pageOf, Parties} from "./chatPages.ts" +import {Conversation, LineBody, More} from "./Conversation.tsx" + +// How many of their messages each page holds, the newest first. +export const THEIR_PAGE = 20 + +type Props = { + chat: ChatAdmin + accountId: string + name: string + parties: Parties +} + +// What the reported account said in the chat, whatever the report names: a report from a player's card names no message. +export function TheirMessages({chat, accountId, name, parties}: Props) { + const first = useCallback( + (signal: AbortSignal) => pageOf(chat, {accountId}, THEIR_PAGE, signal), + [chat, accountId], + ) + const [call, again] = useCall(first) + + return ( +
    +

    {name === "" ? "Their messages" : `Messages from ${name}`}

    + {call.state === "loading" &&

    Loading…

    } + {call.state === "failed" && } + {call.state === "done" && } +
    + ) +} + +type PagesProps = {chat: ChatAdmin, accountId: string, first: Page, parties: Parties} + +function Pages({chat, accountId, first, parties}: PagesProps) { + const [page, setPage] = useState(first) + const oldest = page.messages[0]?.messageId ?? "" + + if (page.messages.length === 0) return

    Nothing they said in the chat is kept.

    + return ( + <> +
      + {[...page.messages].reverse().map(message => ( + + ))} +
    + {page.more && ( + pageOf(chat, {accountId, beforeMessageId: oldest}, THEIR_PAGE)} + onLoaded={older => setPage(shown => ({messages: [...older.messages, ...shown.messages], more: older.more}))} + /> + )} + + ) +} + +function Theirs({chat, message, parties}: {chat: ChatAdmin, message: KeptMessage, parties: Parties}) { + const [open, setOpen] = useState(false) + + return ( +
  • + + + {open && ( + + )} +
  • + ) +} diff --git a/apps/backoffice/src/reports/chatPages.ts b/apps/backoffice/src/reports/chatPages.ts new file mode 100644 index 000000000..e421fc277 --- /dev/null +++ b/apps/backoffice/src/reports/chatPages.ts @@ -0,0 +1,29 @@ +import {ChatAdmin} from "../clients.ts" +import {GetMessageResponse, 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} +export type Parties = {reporter: Party, reported: Party} + +// A page is full when it holds as many as asked: there may be more past it. +export type Page = {messages: KeptMessage[], more: boolean} +type Ask = {beforeMessageId?: string, afterMessageId?: string, accountId?: string} + +export async function pageOf(chat: ChatAdmin, ask: Ask, limit: number, signal?: AbortSignal): Promise { + 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/frontend/src/gen/grpc/chat/v1/admin_connect.ts b/apps/frontend/src/gen/grpc/chat/v1/admin_connect.ts index b37459573..19f42b0cb 100644 --- a/apps/frontend/src/gen/grpc/chat/v1/admin_connect.ts +++ b/apps/frontend/src/gen/grpc/chat/v1/admin_connect.ts @@ -46,7 +46,7 @@ export const AdminService = { kind: MethodKind.Unary, }, /** - * The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. + * The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. With an account, that account's messages alone. * * @generated from rpc chat.v1.AdminService.ListMessages */ diff --git a/apps/frontend/src/gen/grpc/chat/v1/admin_pb.ts b/apps/frontend/src/gen/grpc/chat/v1/admin_pb.ts index 862986791..569bbab48 100644 --- a/apps/frontend/src/gen/grpc/chat/v1/admin_pb.ts +++ b/apps/frontend/src/gen/grpc/chat/v1/admin_pb.ts @@ -299,6 +299,13 @@ export class ListMessagesRequest extends Message { */ limit = 0; + /** + * Only this account's messages, the cursors included: a page is its messages sent just before or after the cursor. Empty is everyone's; not a UUID is InvalidArgument. + * + * @generated from field: string account_id = 4; + */ + accountId = ""; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -310,6 +317,7 @@ export class ListMessagesRequest extends Message { { no: 1, name: "before_message_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "after_message_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ListMessagesRequest { diff --git a/proto/chat/v1/admin.proto b/proto/chat/v1/admin.proto index c2a4c2a86..663393248 100644 --- a/proto/chat/v1/admin.proto +++ b/proto/chat/v1/admin.proto @@ -15,7 +15,7 @@ service AdminService { // A message as it was sent, removed or not. NotFound past the retention. rpc GetMessage(GetMessageRequest) returns (GetMessageResponse); - // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. + // The messages kept, as they were sent, removed or not, a page at a time. A message's id is a cursor: the page just before it, or just after it. NotFound for a cursor past the retention. With an account, that account's messages alone. rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse); } @@ -59,6 +59,8 @@ message ListMessagesRequest { string after_message_id = 2; // 50 unless asked, 200 at most. uint32 limit = 3; + // Only this account's messages, the cursors included: a page is its messages sent just before or after the cursor. Empty is everyone's; not a UUID is InvalidArgument. + string account_id = 4; } message ListMessagesResponse { From c1bba592da7cb446b4c71b4900bbc96f5688eef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 17:05:01 +0200 Subject: [PATCH 2/3] 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 --- apps/backoffice/CLAUDE.md | 25 ++++---- apps/backoffice/src/App.tsx | 5 +- .../src/{reports => chat}/Conversation.tsx | 10 ++-- .../src/{reports => chat}/TheirMessages.tsx | 8 +-- .../src/{reports => chat}/chatPages.ts | 16 +----- apps/backoffice/src/players/PlayerView.tsx | 5 ++ .../src/players/PlayersPage.test.tsx | 57 ++++++++++++++++++- apps/backoffice/src/reports/MessagePanel.tsx | 20 ++++++- apps/backoffice/src/reports/ReportDetail.tsx | 2 +- 9 files changed, 106 insertions(+), 42 deletions(-) rename apps/backoffice/src/{reports => chat}/Conversation.tsx (97%) rename apps/backoffice/src/{reports => chat}/TheirMessages.tsx (92%) rename apps/backoffice/src/{reports => chat}/chatPages.ts (51%) diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 8b9edd26b..352473783 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -15,8 +15,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"). @@ -130,16 +130,17 @@ title (`titleOf`), shown above it. `Console` in `App.tsx` shows the page picked. 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. -- **The reported account's own messages** (`TheirMessages`, "Messages from Ana"), for every report: a report from a - player's card names no message, and a chat abuse is rarely one line. `ListMessages` with the account's id reads - them, 20 a page (`THEIR_PAGE`), shown newest first, "Show earlier" for more. Each is struck through once removed, - and "Show what was said around it" opens its `Conversation` in place, the message marked "This message", the - reporter and the reported tagged. Nothing kept is "Nothing they said in the chat is kept." +- **The reported account's own messages** (`chat/TheirMessages`, "Messages from Ana"), for every report: a report + from a player's card names no message, and a chat abuse is rarely one line. `ListMessages` with the account's id + reads them, 20 a page (`THEIR_PAGE`), shown newest first, "Show earlier" for more. Each is struck through once + removed, and "Show what was said around it" opens its `Conversation` in place, the message marked "This + message", the reporter and the reported tagged. 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 @@ -200,6 +201,9 @@ 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 lists its messages** (`chat/TheirMessages`, as a report does): `ListMessages` with its + account id, 20 a page, newest first, each with "Show what was said around it". 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 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 @@ -271,8 +275,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) + chat/ Conversation and TheirMessages, which reports/ and players/ show, over chatPages.ts (a page of + ListMessages, the parties a report names) + 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 92% rename from apps/backoffice/src/reports/TheirMessages.tsx rename to apps/backoffice/src/chat/TheirMessages.tsx index a07abfb70..8a9cda4e4 100644 --- a/apps/backoffice/src/reports/TheirMessages.tsx +++ b/apps/backoffice/src/chat/TheirMessages.tsx @@ -13,10 +13,10 @@ type Props = { chat: ChatAdmin accountId: string name: string - parties: Parties + parties: Parties | undefined } -// What the reported account said in the chat, whatever the report names: a report from a player's card names no message. +// What one account said in the chat, by its account id: a report from a player's card names no message. export function TheirMessages({chat, accountId, name, parties}: Props) { const first = useCallback( (signal: AbortSignal) => pageOf(chat, {accountId}, THEIR_PAGE, signal), @@ -34,7 +34,7 @@ export function TheirMessages({chat, accountId, name, parties}: Props) { ) } -type PagesProps = {chat: ChatAdmin, accountId: string, first: Page, parties: Parties} +type PagesProps = {chat: ChatAdmin, accountId: string, first: Page, parties: Parties | undefined} function Pages({chat, accountId, first, parties}: PagesProps) { const [page, setPage] = useState(first) @@ -59,7 +59,7 @@ function Pages({chat, accountId, first, parties}: PagesProps) { ) } -function Theirs({chat, message, parties}: {chat: ChatAdmin, message: KeptMessage, parties: Parties}) { +function Theirs({chat, message, parties}: {chat: ChatAdmin, message: KeptMessage, parties: Parties | undefined}) { const [open, setOpen] = useState(false) return ( 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/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 b722a54ee..b106bfa70 100644 --- a/apps/backoffice/src/players/PlayersPage.test.tsx +++ b/apps/backoffice/src/players/PlayersPage.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom +import {Timestamp} from "@bufbuild/protobuf" import {Code} from "@connectrpc/connect" -import {cleanup, fireEvent, render, screen, within} from "@testing-library/react" +import {cleanup, fireEvent, render, screen, waitFor, within} from "@testing-library/react" import {useState} from "react" import {afterEach, describe, expect, it} from "vitest" -import {ACCOUNT, FakeAdmin} from "../fakeAdmin.ts" +import {ACCOUNT, AT, FakeAdmin} from "../fakeAdmin.ts" +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" import {CountryTiles} from "../gen/grpc/player/v1/player_pb.ts" import {Opened} from "../navigation.ts" import {PlayersPage} from "./PlayersPage.tsx" @@ -16,6 +18,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})) @@ -136,6 +145,50 @@ describe("one player", () => { expect(admin.askedFor("getPlayer")).toHaveLength(1) }) + it("lists what the account said, newest first, read by its account id", async () => { + const admin = new FakeAdmin() + admin.chat = [said(1), said(1, "account-cleo", "Cleo"), said(2)] + render() + + const messages = await screen.findByRole("region", {name: "Their messages"}) + expect(await within(messages).findByRole("heading", {name: "Messages from BraveFox42"})).toBeTruthy() + const list = await within(messages).findByRole("list", {name: "Their messages, newest first"}) + expect(within(list).getAllByText(/^\w+ \d+$/, {selector: ".line-text"}).map(line => line.textContent)) + .toEqual(["BraveFox42 2", "BraveFox42 1"]) + expect(admin.askedFor("listMessages")).toEqual([{accountId: ACCOUNT, limit: 20}]) + }) + + it("shows what everyone said around one of its messages, 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 = await screen.findByRole("region", {name: "Their messages"}) + fireEvent.click(await within(messages).findByRole("button", {name: "Show what was said around it"})) + + await within(messages).findByRole("list", {name: "The conversation"}) + await waitFor(() => expect(within(messages).queryByText("Loading what was said around it…")).toBeNull()) + const conversation = within(within(messages).getByRole("list", {name: "The conversation"})) + expect(conversation.getByText("Cleo 1")).toBeTruthy() + expect(conversation.getByText("Dan 3")).toBeTruthy() + expect(conversation.getByText("This message")).toBeTruthy() + expect(conversation.queryByText("reported")).toBeNull() + expect(conversation.queryByText("reporter")).toBeNull() + expect(admin.askedFor("listMessages").slice(1)).toEqual([ + {beforeMessageId: `${ACCOUNT}-2`, limit: 10}, + {afterMessageId: `${ACCOUNT}-2`, limit: 10}, + ]) + }) + + 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.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 From 091cc909678adf7a6ac4ace441e45bc54fa837eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 17:22:46 +0200 Subject: [PATCH 3/3] 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 --- apps/backoffice/CLAUDE.md | 16 +- apps/backoffice/src/index.css | 21 +++ .../src/reports/ReportDetail.test.tsx | 23 +-- .../src/reports/TheirMessages.test.tsx | 138 +++++++++++----- apps/backoffice/src/reports/TheirMessages.tsx | 148 ++++++++++++++---- .../src/reports/theirConversations.test.ts | 109 +++++++++++++ .../src/reports/theirConversations.ts | 60 +++++++ 7 files changed, 424 insertions(+), 91 deletions(-) create mode 100644 apps/backoffice/src/reports/theirConversations.test.ts create mode 100644 apps/backoffice/src/reports/theirConversations.ts diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 8b9edd26b..f1c1b3e4c 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -130,11 +130,15 @@ title (`titleOf`), shown above it. `Console` in `App.tsx` shows the page picked. 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. -- **The reported account's own messages** (`TheirMessages`, "Messages from Ana"), for every report: a report from a - player's card names no message, and a chat abuse is rarely one line. `ListMessages` with the account's id reads - them, 20 a page (`THEIR_PAGE`), shown newest first, "Show earlier" for more. Each is struck through once removed, - and "Show what was said around it" opens its `Conversation` in place, the message marked "This message", the - reporter and the reported tagged. Nothing kept is "Nothing they said in the chat is kept." +- **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." - **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 @@ -272,7 +276,7 @@ src/ 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) + chatPages.ts (a page of ListMessages, the parties a report names) and theirConversations.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/index.css b/apps/backoffice/src/index.css index 8329f46ff..4304c7ebe 100644 --- a/apps/backoffice/src/index.css +++ b/apps/backoffice/src/index.css @@ -450,6 +450,27 @@ button:disabled { background: color-mix(in srgb, var(--report) 12%, transparent); } +.line-theirs { + border-left-color: var(--report); +} + +.conversations { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.conversation-window { + display: grid; + gap: 6px; + justify-items: start; + min-width: 0; + padding: 8px 0 8px 10px; + border-left: 2px dashed var(--line); +} + .line-head { display: flex; flex-wrap: wrap; diff --git a/apps/backoffice/src/reports/ReportDetail.test.tsx b/apps/backoffice/src/reports/ReportDetail.test.tsx index 1380d7af0..a080f3f90 100644 --- a/apps/backoffice/src/reports/ReportDetail.test.tsx +++ b/apps/backoffice/src/reports/ReportDetail.test.tsx @@ -9,6 +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" afterEach(cleanup) @@ -40,9 +41,13 @@ function messagePanel() { return within(screen.getByRole("region", {name: "The message"})) } -// What the conversation around the reported message asked, leaving out the reported account's own messages. +// What the conversation around the reported message asked, leaving out the reported account's own messages and the +// conversations TheirMessages reads around them. function asksAround(admin: FakeAdmin): unknown[] { - return admin.askedFor("listMessages").filter(ask => !(ask as {accountId?: string}).accountId) + return admin.askedFor("listMessages").filter(ask => { + const {accountId, limit} = ask as {accountId?: string, limit?: number} + return !accountId && limit !== AROUND + }) } function confirm(label: string, yes: string) { @@ -114,9 +119,9 @@ describe("ReportDetail", () => { expect(within(list).getByText("after 10")).toBeTruthy() expect(within(list).queryByText("after 11")).toBeNull() - fireEvent.click(screen.getByRole("button", {name: "Show earlier"})) + fireEvent.click(messagePanel().getByRole("button", {name: "Show earlier"})) expect(await within(list).findByText("before 1")).toBeTruthy() - fireEvent.click(screen.getByRole("button", {name: "Show later"})) + fireEvent.click(messagePanel().getByRole("button", {name: "Show later"})) expect(await within(list).findByText("after 22")).toBeTruthy() expect(within(list).getAllByRole("listitem")).toHaveLength(48) @@ -124,8 +129,8 @@ describe("ReportDetail", () => { {beforeMessageId: "before-16", limit: 20}, {afterMessageId: "after-10", limit: 20}, ]) - expect(screen.queryByRole("button", {name: "Show earlier"})).toBeNull() - expect(screen.queryByRole("button", {name: "Show later"})).toBeNull() + expect(messagePanel().queryByRole("button", {name: "Show earlier"})).toBeNull() + expect(messagePanel().queryByRole("button", {name: "Show later"})).toBeNull() }) it("says when nothing was said around it", async () => { @@ -143,8 +148,8 @@ describe("ReportDetail", () => { admin.chat[1].removedAt = Timestamp.fromDate(AT) open(admin) - expect((await screen.findByText("calm down")).className).toContain("line-removed") - expect(screen.getByText(/^Removed at /)).toBeTruthy() + expect((await messagePanel().findByText("calm down")).className).toContain("line-removed") + expect(messagePanel().getByText(/^Removed at /)).toBeTruthy() expect(screen.getByRole("button", {name: "Remove the message"})).toBeTruthy() }) @@ -172,7 +177,7 @@ describe("ReportDetail", () => { await lines() admin.refuse("listMessages", Code.NotFound) - fireEvent.click(screen.getByRole("button", {name: "Show earlier"})) + fireEvent.click(messagePanel().getByRole("button", {name: "Show earlier"})) expect(await screen.findByText("That part of the chat is no longer kept.")).toBeTruthy() }) diff --git a/apps/backoffice/src/reports/TheirMessages.test.tsx b/apps/backoffice/src/reports/TheirMessages.test.tsx index f34316168..cdc3dbd3a 100644 --- a/apps/backoffice/src/reports/TheirMessages.test.tsx +++ b/apps/backoffice/src/reports/TheirMessages.test.tsx @@ -8,6 +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" afterEach(cleanup) @@ -22,13 +23,24 @@ const fromTheCard = new Report({ createdAt: Timestamp.fromDate(AT), }) -function said(n: number, accountId = "account-reported", authorName = "Ana") { - return new KeptMessage({ - messageId: `${accountId}-${n}`, accountId, authorName, text: `${authorName} ${n}`, - sentAt: Timestamp.fromDate(new Date(AT.getTime() + n * 60_000)), countryId: "fr", +const AUTHORS: Record = {"account-reported": "Ana", "account-reporter": "Bob", "account-cleo": "Cleo"} + +// The chat, one message a minute, each line "Ana", "Bob" or "Cleo" for whoever says it: "Bob 1", "Ana 2"… +function chatOf(...who: string[]): KeptMessage[] { + return who.map((name, i) => { + const accountId = Object.keys(AUTHORS).find(account => AUTHORS[account] === name) ?? "" + return new KeptMessage({ + messageId: `m${i + 1}`, accountId, authorName: name, text: `${name} ${i + 1}`, + sentAt: Timestamp.fromDate(new Date(AT.getTime() + i * 60_000)), countryId: "fr", + }) }) } +// Ana's lines, `gap` messages of Bob's between each two. +function apart(count: number, gap: number): KeptMessage[] { + return chatOf(...Array.from({length: count}, () => ["Ana", ...Array(gap).fill("Bob")]).flat()) +} + function open(admin: FakeAdmin) { render( undefined} clients={admin.clients()}/>) } @@ -37,73 +49,113 @@ function panel() { return within(screen.getByRole("region", {name: "Their messages"})) } -async function theirs(): Promise { - const list = await panel().findByRole("list", {name: "Their messages, newest first"}) - return within(list).getAllByText(/^Ana \d+$/, {selector: ".line-text"}).map(line => line.textContent ?? "") +// The text of each line of each conversation shown, once they are. +async function conversations(): Promise { + const list = await panel().findByRole("list", {name: "Their conversations, newest first"}) + return within(list).getAllByRole("list", {name: "A conversation"}).map(conversation => + within(conversation).getAllByText(/^(Ana|Bob|Cleo) \d+$/, {selector: ".line-text"}).map(line => line.textContent ?? "")) } describe("TheirMessages", () => { - it("lists what the reported account said, newest first, read by its account id", async () => { + it("shows what they said in its conversation, with no click", async () => { const admin = new FakeAdmin() - admin.chat = [said(1), said(1, "account-cleo", "Cleo"), said(2), said(3)] + admin.chat = chatOf("Bob", "Ana", "Cleo") open(admin) - expect(await theirs()).toEqual(["Ana 3", "Ana 2", "Ana 1"]) - expect(admin.askedFor("listMessages")).toEqual([{accountId: "account-reported", limit: 20}]) + expect(await conversations()).toEqual([["Bob 1", "Ana 2", "Cleo 3"]]) + expect(admin.askedFor("listMessages")).toEqual([ + {accountId: "account-reported", limit: 20}, + {beforeMessageId: "m2", limit: AROUND}, + {afterMessageId: "m2", limit: AROUND}, + ]) + expect(panel().getByText("Ana 2").closest("li")?.className).toContain("line-theirs") + expect(panel().getByText("reporter")).toBeTruthy() expect(screen.queryByRole("region", {name: "The message"})).toBeNull() }) - it("reads their earlier messages on asking, from the oldest shown", async () => { + it("shows an exchange as one conversation, read once", async () => { const admin = new FakeAdmin() - admin.chat = Array.from({length: 25}, (_, n) => said(n + 1)) + admin.chat = chatOf("Bob", "Ana", "Cleo", "Ana", "Bob") open(admin) - expect(await theirs()).toHaveLength(20) - fireEvent.click(panel().getByRole("button", {name: "Show earlier"})) + expect(await conversations()).toEqual([["Bob 1", "Ana 2", "Cleo 3", "Ana 4", "Bob 5"]]) + expect(admin.askedFor("listMessages")).toHaveLength(3) + }) - await waitFor(async () => expect(await theirs()).toHaveLength(25)) - expect((await theirs()).at(-1)).toBe("Ana 1") - expect(admin.askedFor("listMessages").at(-1)) - .toEqual({accountId: "account-reported", beforeMessageId: "account-reported-6", limit: 20}) - expect(panel().queryByRole("button", {name: "Show earlier"})).toBeNull() + it("keeps conversations far apart apart, the newest first", async () => { + const admin = new FakeAdmin() + admin.chat = apart(2, 2 * AROUND + 1) + open(admin) + + const shown = await conversations() + expect(shown).toHaveLength(2) + expect(shown[0]).toContain("Ana 13") + expect(shown[1]).toContain("Ana 1") }) - it("shows what everyone said around one of their messages, on asking", async () => { + it("builds three conversations, then the next on asking", async () => { const admin = new FakeAdmin() - admin.chat = [said(1, "account-reporter", "Bob"), said(2), said(3, "account-cleo", "Cleo")] + admin.chat = apart(5, 2 * AROUND + 1) open(admin) - await theirs() - - fireEvent.click(panel().getByRole("button", {name: "Show what was said around it"})) - - await panel().findByRole("list", {name: "The conversation"}) - await waitFor(() => expect(panel().queryByText("Loading what was said around it…")).toBeNull()) - const conversation = panel().getByRole("list", {name: "The conversation"}) - expect(within(conversation).getByText("Cleo 3")).toBeTruthy() - expect(within(conversation).getByText("Bob 1")).toBeTruthy() - expect(within(conversation).getByText("This message")).toBeTruthy() - expect(within(conversation).getByText("reporter")).toBeTruthy() - expect(admin.askedFor("listMessages").slice(1)).toEqual([ - {beforeMessageId: "account-reported-2", limit: 10}, - {afterMessageId: "account-reported-2", limit: 10}, + + expect(await conversations()).toHaveLength(3) + fireEvent.click(panel().getByRole("button", {name: "Show more conversations"})) + + await waitFor(async () => expect(await conversations()).toHaveLength(5)) + expect(panel().queryByRole("button", {name: "Show more conversations"})).toBeNull() + }) + + it("reads more of their messages once every one read is in a conversation", async () => { + const admin = new FakeAdmin() + admin.chat = apart(21, 2 * AROUND + 1) + open(admin) + await conversations() + + // 3 at a time from the 20 read: 6, 9 … 18, 20, then the 21st, once the next page of theirs is read. + for (const expected of [6, 9, 12, 15, 18, 20, 21]) { + fireEvent.click(panel().getByRole("button", {name: "Show more conversations"})) + await waitFor(async () => expect(await conversations()).toHaveLength(expected)) + } + + const theirs = admin.askedFor("listMessages").filter(ask => (ask as {accountId?: string}).accountId) + expect(theirs).toEqual([ + {accountId: "account-reported", limit: 20}, + {accountId: "account-reported", beforeMessageId: "m13", limit: 20}, + ]) + expect(panel().queryByRole("button", {name: "Show more conversations"})).toBeNull() + }) + + it("grows a conversation earlier and later, on asking", async () => { + const admin = new FakeAdmin() + admin.chat = chatOf(...Array(20).fill("Bob"), "Ana", ...Array(20).fill("Bob")) + open(admin) + + expect((await conversations())[0]).toHaveLength(2 * AROUND + 1) + fireEvent.click(panel().getByRole("button", {name: "Show earlier"})) + await waitFor(async () => expect((await conversations())[0]).toHaveLength(2 * AROUND + 11)) + fireEvent.click(panel().getByRole("button", {name: "Show later"})) + await waitFor(async () => expect((await conversations())[0]).toHaveLength(2 * AROUND + 21)) + + expect(admin.askedFor("listMessages").slice(3)).toEqual([ + {beforeMessageId: "m16", limit: 10}, + {afterMessageId: "m26", limit: 10}, ]) }) it("strikes a message of theirs that was removed", async () => { const admin = new FakeAdmin() - const removed = said(1) - removed.removedAt = Timestamp.fromDate(AT) - admin.chat = [removed] + admin.chat = chatOf("Ana") + admin.chat[0].removedAt = Timestamp.fromDate(AT) open(admin) - await theirs() + await conversations() expect(panel().getByText("Ana 1").className).toContain("line-removed") expect(panel().getByText(/^Removed at /)).toBeTruthy() }) it("says when nothing they said is kept", async () => { const admin = new FakeAdmin() - admin.chat = [said(1, "account-cleo", "Cleo")] + admin.chat = chatOf("Cleo") open(admin) expect(await panel().findByText("Nothing they said in the chat is kept.")).toBeTruthy() @@ -111,7 +163,7 @@ describe("TheirMessages", () => { it("says when their messages cannot be read, and reads them again", async () => { const admin = new FakeAdmin() - admin.chat = [said(1)] + admin.chat = chatOf("Ana") admin.refuse("listMessages", Code.Unavailable) open(admin) @@ -119,6 +171,6 @@ describe("TheirMessages", () => { admin.refusals.delete("listMessages") fireEvent.click(panel().getByRole("button", {name: "Try again"})) - expect(await theirs()).toEqual(["Ana 1"]) + expect(await conversations()).toEqual([["Ana 1"]]) }) }) diff --git a/apps/backoffice/src/reports/TheirMessages.tsx b/apps/backoffice/src/reports/TheirMessages.tsx index a07abfb70..4d94b64c3 100644 --- a/apps/backoffice/src/reports/TheirMessages.tsx +++ b/apps/backoffice/src/reports/TheirMessages.tsx @@ -2,12 +2,16 @@ import {useCallback, useState} from "react" import {ChatAdmin} from "../clients.ts" import {FailureNotice} from "../components/FailureNotice.tsx" import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" +import {wordsOf} from "../refusal.ts" import {useCall} from "../useCall.ts" import {Page, pageOf, Parties} from "./chatPages.ts" -import {Conversation, LineBody, More} from "./Conversation.tsx" +import {CHAT_GONE, LineBody, More} from "./Conversation.tsx" +import {CONVERSATIONS, moreConversations, Read, Window} from "./theirConversations.ts" -// How many of their messages each page holds, the newest first. +// How many of their messages are read at a time, to build conversations around. export const THEIR_PAGE = 20 +// How many messages "Show earlier" and "Show later" add to one conversation. +const NEXT_PAGE = 10 type Props = { chat: ChatAdmin @@ -16,61 +20,139 @@ type Props = { parties: Parties } -// What the reported account said in the chat, whatever the report names: a report from a player's card names no message. +type Built = { + shown: Window[] + // Their messages read and in no conversation yet, newest first. + left: KeptMessage[] + // Whether more of their messages are kept before the oldest read, and its id. + theirsMore: boolean + oldest: string +} + +async function theirPage(chat: ChatAdmin, accountId: string, before: string, signal?: AbortSignal) { + const page = await pageOf(chat, before === "" ? {accountId} : {accountId, beforeMessageId: before}, THEIR_PAGE, signal) + 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. export function TheirMessages({chat, accountId, name, parties}: Props) { - const first = useCallback( - (signal: AbortSignal) => pageOf(chat, {accountId}, THEIR_PAGE, signal), - [chat, accountId], - ) + const read = useCallback((ask, limit) => pageOf(chat, ask, limit), [chat]) + const first = useCallback(async (signal: AbortSignal): Promise => { + const theirs = await theirPage(chat, accountId, "", signal) + const built = await moreConversations([], theirs.newestFirst, read, CONVERSATIONS) + return {...built, theirsMore: theirs.more, oldest: theirs.oldest} + }, [chat, accountId, read]) const [call, again] = useCall(first) return (
    -

    {name === "" ? "Their messages" : `Messages from ${name}`}

    +

    {name === "" ? "What they said" : `What ${name} said`}

    {call.state === "loading" &&

    Loading…

    } {call.state === "failed" && } - {call.state === "done" && } + {call.state === "done" && ( + + )}
    ) } -type PagesProps = {chat: ChatAdmin, accountId: string, first: Page, parties: Parties} +type ConversationsProps = {chat: ChatAdmin, accountId: string, first: Built, read: Read, parties: Parties} + +function Conversations({chat, accountId, first, read, parties}: ConversationsProps) { + const [built, setBuilt] = useState(first) -function Pages({chat, accountId, first, parties}: PagesProps) { - const [page, setPage] = useState(first) - const oldest = page.messages[0]?.messageId ?? "" + if (built.shown.length === 0) return

    Nothing they said in the chat is kept.

    + + const more = async (): Promise => { + let {left, theirsMore, oldest} = built + if (left.length === 0 && theirsMore) { + const theirs = await theirPage(chat, accountId, oldest) + left = theirs.newestFirst + theirsMore = theirs.more + oldest = theirs.oldest + } + const next = await moreConversations(built.shown, left, read, CONVERSATIONS) + return {...next, theirsMore, oldest} + } + const grown = (at: number) => (window: Window) => + setBuilt(shown => ({...shown, shown: shown.shown.map((each, i) => i === at ? window : each)})) - if (page.messages.length === 0) return

    Nothing they said in the chat is kept.

    return ( <> -
      - {[...page.messages].reverse().map(message => ( - +
        + {built.shown.map((window, at) => ( + ))}
      - {page.more && ( - pageOf(chat, {accountId, beforeMessageId: oldest}, THEIR_PAGE)} - onLoaded={older => setPage(shown => ({messages: [...older.messages, ...shown.messages], more: older.more}))} - /> - )} + {(built.left.length > 0 || built.theirsMore) && } ) } -function Theirs({chat, message, parties}: {chat: ChatAdmin, message: KeptMessage, parties: Parties}) { - const [open, setOpen] = useState(false) +type WindowProps = { + chat: ChatAdmin + window: Window + accountId: string + parties: Parties + onGrown: (window: Window) => void +} + +function ConversationWindow({chat, window, accountId, parties, onGrown}: WindowProps) { + const oldest = window.lines[0]?.messageId ?? "" + const newest = window.lines.at(-1)?.messageId ?? "" + const earlier = (page: Page) => onGrown({...window, lines: [...page.messages, ...window.lines], moreBefore: page.more}) + const later = (page: Page) => onGrown({...window, lines: [...window.lines, ...page.messages], moreAfter: page.more}) return ( -
    1. - - - {open && ( - +
    2. + {window.moreBefore && ( + pageOf(chat, {beforeMessageId: oldest}, NEXT_PAGE)} onLoaded={earlier}/> + )} +
        + {window.lines.map(line => ( +
      1. + +
      2. + ))} +
      + {window.moreAfter && ( + pageOf(chat, {afterMessageId: newest}, NEXT_PAGE)} onLoaded={later}/> )}
    3. ) } + +type BuildStep = {step: "idle"} | {step: "running"} | {step: "refused", words: string} + +// The next conversations, on a click: reads, so it asks nothing first. +function BuildMore({load, onLoaded}: {load: () => Promise, onLoaded: (built: Built) => void}) { + const [step, setStep] = useState({step: "idle"}) + + const run = () => { + setStep({step: "running"}) + load().then( + built => { + setStep({step: "idle"}) + onLoaded(built) + }, + (err: unknown) => setStep({step: "refused", words: wordsOf(err, {notFound: CHAT_GONE})}), + ) + } + + return ( +
      + + {step.step === "refused" &&

      {step.words}

      } +
      + ) +} diff --git a/apps/backoffice/src/reports/theirConversations.test.ts b/apps/backoffice/src/reports/theirConversations.test.ts new file mode 100644 index 000000000..a77d70549 --- /dev/null +++ b/apps/backoffice/src/reports/theirConversations.test.ts @@ -0,0 +1,109 @@ +import {describe, expect, it} from "vitest" +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" +import {AROUND, moreConversations, Read, Window} from "./theirConversations.ts" + +// A chat of `length` messages, "m1" the oldest; the ones in `theirs` are Ana's, the others Bob's. +function chatOf(length: number, theirs: number[]): KeptMessage[] { + return Array.from({length}, (_, i) => new KeptMessage({ + messageId: `m${i + 1}`, + accountId: theirs.includes(i + 1) ? "ana" : "bob", + })) +} + +// Reads the chat as the server does, and counts the reads. +function reader(chat: KeptMessage[]): {read: Read, asked: string[]} { + const asked: string[] = [] + const read: Read = (ask, limit) => { + const cursor = ask.beforeMessageId ?? ask.afterMessageId ?? "" + asked.push(ask.beforeMessageId !== undefined ? `before ${cursor}` : `after ${cursor}`) + const at = chat.findIndex(message => message.messageId === cursor) + const messages = ask.beforeMessageId !== undefined + ? chat.slice(Math.max(0, at - limit), at) + : chat.slice(at + 1, at + 1 + limit) + return Promise.resolve({messages, more: messages.length === limit}) + } + return {read, asked} +} + +function theirsOf(chat: KeptMessage[]): KeptMessage[] { + return chat.filter(message => message.accountId === "ana").reverse() +} + +const ids = (window: Window) => window.lines.map(line => line.messageId) + +describe("moreConversations", () => { + it("shows one of their messages among the messages each side of it", async () => { + const chat = chatOf(30, [15]) + + const {shown, left} = await moreConversations([], theirsOf(chat), reader(chat).read, 3) + + expect(shown).toHaveLength(1) + expect(ids(shown[0])).toEqual(Array.from({length: 2 * AROUND + 1}, (_, i) => `m${10 + i}`)) + expect(shown[0].moreBefore).toBe(true) + expect(shown[0].moreAfter).toBe(true) + expect(left).toEqual([]) + }) + + it("needs no conversation of its own for a message another one already holds", async () => { + const chat = chatOf(30, [12, 15]) + const {read, asked} = reader(chat) + + const {shown} = await moreConversations([], theirsOf(chat), read, 3) + + expect(shown).toHaveLength(1) + expect(asked).toEqual(["before m15", "after m15"]) + }) + + it("joins a conversation that reaches the one after it, so an exchange is one conversation", async () => { + const chat = chatOf(40, [12, 20]) + + const {shown} = await moreConversations([], theirsOf(chat), reader(chat).read, 3) + + expect(shown).toHaveLength(1) + expect(ids(shown[0])).toEqual(Array.from({length: 19}, (_, i) => `m${7 + i}`)) + expect(shown[0].moreBefore).toBe(true) + expect(shown[0].moreAfter).toBe(true) + }) + + it("keeps conversations a gap lies between apart, newest first", async () => { + const chat = chatOf(60, [8, 20, 40]) + + const {shown} = await moreConversations([], theirsOf(chat), reader(chat).read, 3) + + expect(shown.map(window => window.lines.find(line => line.accountId === "ana")?.messageId)) + .toEqual(["m40", "m20", "m8"]) + }) + + it("builds as many conversations as asked, and leaves the rest of their messages for later", async () => { + const chat = chatOf(100, [10, 30, 50, 70, 90]) + + const first = await moreConversations([], theirsOf(chat), reader(chat).read, 2) + expect(first.shown).toHaveLength(2) + expect(first.left.map(message => message.messageId)).toEqual(["m50", "m30", "m10"]) + + const next = await moreConversations(first.shown, first.left, reader(chat).read, 2) + expect(next.shown).toHaveLength(4) + expect(next.left.map(message => message.messageId)).toEqual(["m10"]) + }) + + it("joins a new conversation to the last one shown, across two builds", async () => { + const chat = chatOf(40, [12, 20]) + const theirs = theirsOf(chat) + const first = await moreConversations([], theirs.slice(0, 1), reader(chat).read, 1) + + const next = await moreConversations(first.shown, theirs.slice(1), reader(chat).read, 1) + + expect(next.shown).toHaveLength(1) + expect(ids(next.shown[0])[0]).toBe("m7") + }) + + it("tells the start and the end of the chat", async () => { + const chat = chatOf(4, [2]) + + const {shown} = await moreConversations([], theirsOf(chat), reader(chat).read, 1) + + expect(ids(shown[0])).toEqual(["m1", "m2", "m3", "m4"]) + expect(shown[0].moreBefore).toBe(false) + expect(shown[0].moreAfter).toBe(false) + }) +}) diff --git a/apps/backoffice/src/reports/theirConversations.ts b/apps/backoffice/src/reports/theirConversations.ts new file mode 100644 index 000000000..af7b0b049 --- /dev/null +++ b/apps/backoffice/src/reports/theirConversations.ts @@ -0,0 +1,60 @@ +import {KeptMessage} from "../gen/grpc/chat/v1/admin_pb.ts" +import {Page} from "./chatPages.ts" + +// How many messages each side of one of theirs a conversation starts with, and how many conversations are built at a +// time. +export const AROUND = 5 +export const CONVERSATIONS = 3 + +// A stretch of the chat, oldest first, with whether more is kept before it and was sent after it. +export type Window = {lines: KeptMessage[], moreBefore: boolean, moreAfter: boolean} + +export type Read = (ask: {beforeMessageId?: string, afterMessageId?: string}, limit: number) => Promise + +// The conversations around their messages not shown yet, newest first, added to those shown, up to `count` more. Their +// messages a conversation holds need none of their own, and a conversation that reaches the one shown after it in +// the chat joins it, so an exchange of three insults is one conversation, not three that overlap. +export async function moreConversations( + shown: Window[], theirs: KeptMessage[], read: Read, count: number, +): Promise<{shown: Window[], left: KeptMessage[]}> { + const windows = [...shown] + let left = withoutShown(theirs, windows) + let built = 0 + + while (left.length > 0 && built < count) { + const message = left[0] + const [before, after] = await Promise.all([ + read({beforeMessageId: message.messageId}, AROUND), + read({afterMessageId: message.messageId}, AROUND), + ]) + const window = { + lines: [...before.messages, message, ...after.messages], + moreBefore: before.more, + moreAfter: after.more, + } + + const later = windows.at(-1) + const joined = later === undefined ? undefined : joinedWith(window, later) + if (joined === undefined) { + windows.push(window) + built++ + } else { + windows[windows.length - 1] = joined + } + left = withoutShown(left, windows) + } + + return {shown: windows, left} +} + +// The earlier window followed by the later one, when they share a line; nothing when a gap lies between them. +function joinedWith(earlier: Window, later: Window): Window | undefined { + const at = earlier.lines.findIndex(line => line.messageId === later.lines[0]?.messageId) + if (at < 0) return undefined + return {lines: [...earlier.lines.slice(0, at), ...later.lines], moreBefore: earlier.moreBefore, moreAfter: later.moreAfter} +} + +function withoutShown(theirs: KeptMessage[], windows: Window[]): KeptMessage[] { + const shown = new Set(windows.flatMap(window => window.lines.map(line => line.messageId))) + return theirs.filter(message => !shown.has(message.messageId)) +}