fix(ads): restore reliable banner fades + keep banner on profile update
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

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.
This commit is contained in:
Ilia Denisov
2026-06-16 06:09:35 +02:00
parent 5fb0daa746
commit dc582e9f73
6 changed files with 75 additions and 26 deletions
@@ -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)
}
}
+10
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -3
View File
@@ -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