From dc582e9f730770c3d3a95037ea465c445cc5fd93 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 06:09:35 +0200 Subject: [PATCH] fix(ads): restore reliable banner fades + keep banner on profile update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the previous banner pass: - Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and skip the transition — most visible for a single (default) campaign message, whose only fade is the first show. Revert the fade to Svelte transition:fade (which forces the from-state, so even the first/only message fades), keeping it on its own {#if} layer independent of the scroll. A freshly-mounted view onto a running cycle still renders the live message instantly (inFade duration 0 once), so navigation does not replay the fade. Verified by opacity sampling: advances fade, navigation stays at opacity 1. - Profile update (#3): the banner block was attached only to GET /profile, so a profile.update (e.g. a language switch) returned a profile without it and the banner vanished until reload. A shared profileResponse() now attaches the banner to GET, PUT and the link/merge profile responses. Regression test added (TestBannerSurvivesProfileUpdate). Still open: the scroll position is not preserved across navigation (the view remounts); discussed separately. --- .../internal/inttest/banner_console_test.go | 26 +++++++++ backend/internal/server/banner.go | 10 ++++ backend/internal/server/handlers_account.go | 2 +- backend/internal/server/handlers_link.go | 2 +- backend/internal/server/handlers_user.go | 4 +- ui/src/components/AdBanner.svelte | 57 ++++++++++++------- 6 files changed, 75 insertions(+), 26 deletions(-) diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index 67ee9fa..8c33350 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "strings" "testing" @@ -247,3 +248,28 @@ func TestBannerProfileEligibility(t *testing.T) { t.Fatal("a non-empty hint wallet still shows the banner") } } + +// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the +// banner block too, so the client's profile keeps the banner instead of losing it until reload. +func TestBannerSurvivesProfileUpdate(t *testing.T) { + srv, _ := bannerServer(t) + id := provisionAccount(t) + + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + + `"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body)) + req.Header.Set("X-User-ID", id.String()) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("update profile = %d: %s", rec.Code, rec.Body.String()) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode: %v", err) + } + if p.Banner == nil || len(p.Banner.Campaigns) == 0 { + t.Fatalf("profile update dropped the banner block: %v", p.Banner) + } +} diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index 1fa0f94..af91626 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -38,6 +38,16 @@ type bannerTimingsDTO struct { FadeInMs int `json:"fade_in_ms"` } +// profileResponse builds the account's profile DTO with the advertising-banner block attached when +// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link) +// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g. +// a language switch). +func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { + r := profileResponseFor(acc) + r.Banner = s.bannerFor(ctx, acc) + return r +} + // bannerFor builds the advertising-banner block for the account's profile, or // nil when the ads service is not configured or the viewer is not eligible to // see a banner. The message language follows the account's bot (service) diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 1bd974a..55ae838 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -83,7 +83,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, profileResponseFor(acc)) + c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } // blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index 27a318d..de37628 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -180,7 +180,7 @@ func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse if err != nil { return nil } - p := profileResponseFor(acc) + p := s.profileResponse(ctx, acc) return &p } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index e46e408..bd5973a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -23,9 +23,7 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } - resp := profileResponseFor(acc) - resp.Banner = s.bannerFor(c.Request.Context(), acc) - c.JSON(http.StatusOK, resp) + c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } // submitPlayRequest places tiles on the player's turn; the engine infers the diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index b50d3c2..c5b5a05 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -1,4 +1,5 @@