From 1ae43080ec6218869ff0eca4ca4137da13fcb421 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:59:18 +0200 Subject: [PATCH] fix(feedback): show the operator reply only on the player's latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply was bound to 'the latest message that has a reply', so after the player read a reply and sent a new (unanswered) message, the old reply kept showing as 'Ответ на ваше последнее сообщение'. Bind it to the single most-recent message instead: sending any new message immediately drops the previous reply (the new message has no reply yet), well before the one-week window. Client clears the reply optimistically on submit; the mock mirrors it; inttest covers the case. --- backend/internal/feedback/store.go | 26 ++++++++--------- backend/internal/inttest/feedback_test.go | 35 +++++++++++++++++++++++ ui/e2e/feedback.spec.ts | 13 +++++++++ ui/src/lib/mock/client.ts | 1 + ui/src/screens/Feedback.svelte | 3 +- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 7d63cb3..7d9c4a6 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -86,25 +86,25 @@ func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, // VisibleReply is the operator reply shown back to the player: the reply on their // most recent replied message still inside the visibility window. type VisibleReply struct { - MessageID uuid.UUID - Body string - RepliedAt time.Time - ReplyReadAt sql.NullTime + Body string + RepliedAt time.Time } -// LatestVisibleReply returns the account's most recent message that carries a reply -// still visible to the player — not yet delivered, or delivered after cutoff (one -// week ago). Reports false when there is none. +// LatestVisibleReply returns the reply on the account's **most recent** message, if +// that message carries one still visible to the player — not yet delivered, or +// delivered after cutoff (one week ago). Binding it to the single latest message +// means that the moment the player sends a newer message the previous reply stops +// showing (the new message has no reply yet). Reports false when there is none. func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) { var vr VisibleReply var repliedAt sql.NullTime err := s.db.QueryRowContext(ctx, - `SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at - FROM backend.feedback_messages - WHERE account_id = $1 AND reply_body IS NOT NULL - AND (reply_read_at IS NULL OR reply_read_at > $2) - ORDER BY created_at DESC LIMIT 1`, - accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt) + `SELECT COALESCE(reply_body, ''), replied_at FROM ( + SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages + WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1 + ) latest + WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`, + accountID, cutoff).Scan(&vr.Body, &repliedAt) if errors.Is(err, sql.ErrNoRows) { return VisibleReply{}, false, nil } diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 57bec32..98b3ee3 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -110,6 +110,41 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { } } +func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + // msg1, replied → the player can send again and currently sees the reply. + if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil { + t.Fatalf("submit msg1: %v", err) + } + if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { + t.Fatalf("reply: %v", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.Reply == nil || st.Reply.Body != "the answer" { + t.Fatalf("reply not shown before the new message: %+v", st.Reply) + } + + // Sending a new message immediately drops the previous reply (it now belongs to an + // older message), even though it is well within the one-week window. + if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil { + t.Fatalf("submit msg2: %v", err) + } + st, err := svc.State(ctx, acc) + if err != nil { + t.Fatal(err) + } + if st.Reply != nil { + t.Fatalf("reply should be hidden after a new message, got %+v", st.Reply) + } + if st.CanSend || st.BlockedReason != "pending" { + t.Fatalf("state after new message = %+v, want pending", st) + } +} + func TestFeedbackBanRole(t *testing.T) { ctx := context.Background() svc := newFeedbackService() diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index d08cb5f..8982ce3 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -45,3 +45,16 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy await expect(page.getByText('Reply to your last message')).toBeVisible(); await expect(page.getByText(/looking into it/)).toBeVisible(); }); + +test('feedback: sending a new message clears the previous reply', async ({ page }) => { + await loginLobby(page); + await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply()); + await openFeedback(page); + await expect(page.getByText('Reply to your last message')).toBeVisible(); + + // A new message has no reply yet, so the previous reply must stop showing at once. + await page.locator('textarea').fill('a follow-up question'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('Your message has been sent.')).toBeVisible(); + await expect(page.getByText('Reply to your last message')).toBeHidden(); +}); diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 8afd844..5970bd1 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -418,6 +418,7 @@ export class MockGateway implements GatewayClient { } async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { this.feedbackPending = true; + this.feedbackReply = null; // a new message: the previous operator reply no longer shows } async feedbackGet(): Promise { this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 1877065..7c69b2e 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -71,7 +71,8 @@ removeFile(); sent = true; // The message is now under review: block resending until the operator handles it. - view = { canSend: false, blockedReason: 'pending', reply: view?.reply ?? null }; + // A new message has no reply yet, so the previous operator reply stops showing. + view = { canSend: false, blockedReason: 'pending', reply: null }; } catch (e) { handleError(e); } finally {