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
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:
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -247,3 +248,28 @@ func TestBannerProfileEligibility(t *testing.T) {
|
|||||||
t.Fatal("a non-empty hint wallet still shows the banner")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ type bannerTimingsDTO struct {
|
|||||||
FadeInMs int `json:"fade_in_ms"`
|
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
|
// 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
|
// 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)
|
// see a banner. The message language follows the account's bot (service)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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
|
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
p := profileResponseFor(acc)
|
p := s.profileResponse(ctx, acc)
|
||||||
return &p
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := profileResponseFor(acc)
|
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||||
resp.Banner = s.bannerFor(c.Request.Context(), acc)
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
attachBannerHost,
|
attachBannerHost,
|
||||||
bannerCurrent,
|
bannerCurrent,
|
||||||
@@ -17,41 +18,50 @@
|
|||||||
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
||||||
|
|
||||||
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
||||||
// current message immediately (opacity 1, no transition), so a screen change does not replay the
|
// current message and is visible at once (no fade — see inFade), so a screen change does not
|
||||||
// fade — only a real message advance fades. Empty on the very first mount (engine not started).
|
// replay the fade. Empty on the very first mount (engine not yet started).
|
||||||
let current = $state(bannerCurrent());
|
let current = $state(bannerCurrent());
|
||||||
let opacity = $state(bannerCurrent() ? 1 : 0);
|
let visible = $state(bannerCurrent() !== '');
|
||||||
let opDur = $state(0);
|
|
||||||
let tx = $state(0);
|
let tx = $state(0);
|
||||||
let txDur = $state(0);
|
let txDur = $state(0);
|
||||||
let track = $state<HTMLElement>();
|
let track = $state<HTMLElement>();
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
|
// The first appearance after mounting onto an already-running cycle is instant; every later
|
||||||
|
// message change fades. (Consumed by the first in:fade.)
|
||||||
|
let instantOnce = bannerCurrent() !== '';
|
||||||
|
|
||||||
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
||||||
// scroll. The engine is configured with the same effective timings, so the fade durations here
|
// scroll. The engine is configured with the same effective timings, so the fade durations stay in
|
||||||
// stay in step with the rotator's hold/transition scheduling.
|
// step with the rotator's hold/transition scheduling.
|
||||||
const eff = $derived<BannerTimings>(
|
const eff = $derived<BannerTimings>(
|
||||||
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The DOM host the engine drives. Opacity (fade) lives on the .fadewrap layer and the transform
|
// in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
|
||||||
// (scroll) on the inner track, so a long message's scroll never blocks its fade in/out. show()
|
// fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
|
||||||
// fades in from the (already painted) faded-out state left by the preceding hide().
|
// The resync mount renders the live message instantly (duration 0).
|
||||||
|
function inFade(node: Element) {
|
||||||
|
const duration = instantOnce ? 0 : eff.fadeInMs;
|
||||||
|
instantOnce = false;
|
||||||
|
return fade(node, { duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
||||||
|
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
||||||
const host: BannerHost = {
|
const host: BannerHost = {
|
||||||
show(md) {
|
show(md) {
|
||||||
current = md;
|
current = md;
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
opDur = eff.fadeInMs;
|
visible = true;
|
||||||
opacity = 1;
|
|
||||||
},
|
},
|
||||||
resetScroll() {
|
resetScroll() {
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
},
|
},
|
||||||
hide() {
|
hide() {
|
||||||
opDur = eff.fadeOutMs;
|
visible = false;
|
||||||
opacity = 0;
|
|
||||||
},
|
},
|
||||||
overflowPx() {
|
overflowPx() {
|
||||||
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
||||||
@@ -96,15 +106,17 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||||
<div class="fadewrap" style="opacity:{opacity}; transition:opacity {opDur}ms ease">
|
{#if visible}
|
||||||
<div
|
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
|
||||||
class="track"
|
<div
|
||||||
bind:this={track}
|
class="track"
|
||||||
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
bind:this={track}
|
||||||
>
|
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||||
{@html linkify(current)}
|
>
|
||||||
|
{@html linkify(current)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -120,6 +132,9 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
/* A stable height so the strip does not collapse during the fade gap (the message layer is
|
||||||
|
removed between fade-out and fade-in). */
|
||||||
|
min-height: calc(0.85rem * 1.2 + 12px);
|
||||||
}
|
}
|
||||||
.track {
|
.track {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in New Issue
Block a user