feat(ads): client banner rotation, fade UX & live toggle (PR2) #71

Merged
developer merged 9 commits from feature/ad-network-ui into development 2026-06-16 05:45:01 +00:00
20 changed files with 829 additions and 185 deletions
+3 -3
View File
@@ -1428,9 +1428,9 @@ cannot submit; three-way admin filter.
docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's
per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host
caddy → public clients untrusted → real peer used). `peerIP` unit-tested.
- **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte`
— the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod
bundle (code kept for post-release polish).
- **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase
(PRERELEASE.md) later turned it into the real server-driven advertising network and removed
the gate (`Screen` renders it from `app.profile.banner`).
- **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption).
- **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger
right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's
+1 -1
View File
@@ -32,7 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
@@ -86,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
font: inherit;
}
.form textarea { min-height: 4rem; resize: vertical; }
/* A static help aside (e.g. message-formatting hints) beside an intro note at the top of a
section: the note fills the row, the aside takes ~40% and both wrap on a narrow viewport. */
.help-top { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin: 0.2rem 0 0.6rem; }
.help-top .note { flex: 1 1 16rem; margin: 0; }
.help {
flex: 0 1 40%;
min-width: 16rem;
background: var(--panel-hi);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
color: var(--ink-dim);
}
.help h4 { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--ink); }
.help p { margin: 0.35rem 0; }
button {
background: var(--accent);
color: #06121f;
@@ -21,12 +21,21 @@
{{end}}
</section>
<section class="panel"><h2>Messages</h2>
<div class="help-top">
<p class="note">Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.</p>
<aside class="help">
<h4>Formatting</h4>
<p>Plain text is shown as-is (any HTML is escaped).</p>
<p>Add a link with markdown — <code>[visible text](https://example.com)</code> — which renders as the linked <em>visible text</em>.</p>
<p>Only <code>https://</code>, <code>http://</code> and root-relative <code>/path</code> targets become links; any other scheme (e.g. <code>javascript:</code>, <code>ftp:</code>) is dropped and shown as plain text.</p>
<p>Keep it short: an over-long line scrolls in the strip; the same formatting applies when editing a message above.</p>
</aside>
</div>
{{range .Messages}}
<div class="row">
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
<label>English <input type="text" name="body_en" value="{{.BodyEn}}" maxlength="500" required></label>
<label>Russian <input type="text" name="body_ru" value="{{.BodyRu}}" maxlength="500" required></label>
<label>English <textarea name="body_en" rows="3" maxlength="500" required>{{.BodyEn}}</textarea></label>
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required>{{.BodyRu}}</textarea></label>
<div class="actions">
<button type="submit">Save</button>
</div>
@@ -40,8 +49,8 @@
{{else}}<p class="note">no messages yet</p>{{end}}
<h3>Add message</h3>
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
<label>English <input type="text" name="body_en" maxlength="500" required></label>
<label>Russian <input type="text" name="body_ru" maxlength="500" required></label>
<label>English <textarea name="body_en" rows="3" maxlength="500" required></textarea></label>
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required></textarea></label>
<div><button type="submit">Add message</button></div>
</form>
</section>
@@ -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
+34 -15
View File
@@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro
## Layout shell (`components/Screen.svelte`)
A full-height flex column: the nav bar, the announcement strip, the content, and an
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
pinning the board and controls to the **bottom** for thumb reach. Every screen except
Login uses `Screen`.
A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
except Login uses `Screen`.
## Navigation
@@ -195,16 +196,34 @@ Login uses `Screen`.
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
message; the source becomes a server-driven channel later (see ARCHITECTURE).
A one-line inset strip docked inside the nav bar, directly under the title (so its position never
jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
the profile to show or hide it in place.
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
live outside the components, so navigating between screens (which remounts the view) **continues the
cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and
renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so
a screen change does not replay the fade; only a real message advance fades. A **smooth weighted
round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each
appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages
advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the
strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats while
under `holdMs`), then — with more than one message — fades out (`fadeOutMs`), waits `gapMs`, and the
next fades in. The fade (opacity on a `.fadewrap` layer) is independent of the scroll (the inner
track's transform), so a long, scrolling message still fades at both ends. A lone message stays put
(a long one keeps scrolling). A **viewport size change** (e.g. a portrait↔landscape rotation)
re-measures the current message (`remeasureBanner`, debounced), so its scroll re-evaluates for the
new width. All timings are operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades
collapse to an instant swap
and a long message does not scroll.
## Result / status iconography (`lib/result.ts`)
+114 -25
View File
@@ -1,43 +1,114 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import {
createBannerRotator,
defaultBannerConfig,
linkify,
mockBanners,
type BannerConfig,
type BannerItem,
} from '../lib/banner';
attachBannerHost,
bannerCurrent,
configureBanner,
detachBannerHost,
remeasureBanner,
} from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/telegram';
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
$props();
let {
campaigns,
timings = defaultBannerTimings,
reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
let current = $state(0);
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
// current message and is visible at once (no fade — see inFade), so a screen change does not
// replay the fade. Empty on the very first mount (engine not yet started).
let current = $state(bannerCurrent());
let visible = $state(bannerCurrent() !== '');
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
let rotator: ReturnType<typeof createBannerRotator> | null = null;
// 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() !== '';
onMount(() => {
rotator = createBannerRotator(items, {
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
show: (i) => {
current = i;
// 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 stay in
// step with the rotator's hold/transition scheduling.
const eff = $derived<BannerTimings>(
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
);
// in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
// fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
// 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 = {
show(md) {
current = md;
tx = 0;
txDur = 0;
visible = true;
},
resetScroll() {
tx = 0;
txDur = 0;
},
scrollTo: (toPx, durationMs) => {
hide() {
visible = false;
},
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
},
scrollTo(toPx, durationMs) {
txDur = durationMs;
tx = -toPx;
},
}, config);
rotator.start();
resumeScroll(fromTx, toPx, durationMs) {
// Jump to the carried-over offset instantly, then continue to the end over the remaining
// time — so a long message keeps its scroll position across a navigation.
txDur = 0;
tx = fromTx;
requestAnimationFrame(() => {
txDur = durationMs;
tx = -toPx;
});
},
};
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
// before configure so the host is attached when the rotator first measures overflow.
$effect(() => {
attachBannerHost(host);
return () => detachBannerHost(host);
});
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
// unchanged, so a remount continues the running cycle rather than restarting it.
$effect(() => {
configureBanner(campaigns, eff);
});
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
$effect(() => {
let t: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(t);
t = setTimeout(remeasureBanner, 250);
};
window.addEventListener('resize', onResize);
window.addEventListener('orientationchange', onResize);
return () => {
clearTimeout(t);
window.removeEventListener('resize', onResize);
window.removeEventListener('orientationchange', onResize);
};
});
onDestroy(() => rotator?.stop());
</script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
@@ -45,20 +116,26 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
{#key current}
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
collapses while the message layer is absent during the fade gap (the message is overlaid
absolutely, so its presence/absence does not change the strip height). -->
<span class="reserve" aria-hidden="true">&nbsp;</span>
{#if visible}
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
<div
class="track"
bind:this={track}
in:fade={{ duration: config.fadeMs }}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(items[current]?.md ?? '')}
{@html linkify(current)}
</div>
{/key}
</div>
{/if}
</div>
<style>
.ad {
position: relative;
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
@@ -71,6 +148,18 @@
border-bottom: 1px solid var(--border);
user-select: none;
}
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
fade gap; invisible and non-interactive. */
.reserve {
visibility: hidden;
}
/* The message layer is overlaid on the reserved line, so showing/hiding it never resizes .ad. */
.fadewrap {
position: absolute;
top: 6px;
left: 0;
right: 0;
}
.track {
display: inline-block;
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
+12
View File
@@ -3,7 +3,9 @@
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
@@ -29,6 +31,16 @@
<!-- A right-hand spacer balances the back button so the title stays centred. -->
<span class="spacer"></span>
</div>
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
same place on every screen — and in the game (grown nav) the spare height falls below
it (banner under the title, board pinned to the bottom). -->
{#if app.profile?.banner && app.profile.banner.campaigns.length}
<AdBanner
campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings}
reduceMotion={app.reduceMotion}
/>
{/if}
</header>
<style>
-8
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
@@ -27,12 +26,6 @@
column?: boolean;
} = $props();
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
// banner.ts logic — is dead-code-eliminated from the production bundle. Flip to
// true to bring it back.
const SHOW_AD_BANNER = false;
// Edge-swipe back: a rightward drag begun in the left band returns to `back`, the standard
// mobile gesture (instant on release — the route slide plays the animation). Listened at the
// window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the
@@ -94,7 +87,6 @@
<div class="screen">
<Header {title} {back} grow={growNav} />
{#if SHOW_AD_BANNER}<AdBanner />{/if}
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
+19
View File
@@ -276,6 +276,11 @@ function openStream(): void {
if (e.sub === 'admin_reply') {
app.feedbackReplyUnread = true;
}
// The viewer's ad-banner eligibility may have changed (paid/hints/no_banner): re-fetch
// the profile, which carries the banner block, so the banner shows or hides in place.
if (e.sub === 'banner') {
void refreshProfile();
}
void refreshNotifications();
}
},
@@ -300,6 +305,20 @@ function scheduleReconnect(): void {
}, 4000);
}
/**
* refreshProfile re-fetches the account profile (which carries the ad-banner block),
* after a `banner` notify signals the viewer's banner eligibility changed. Best-effort:
* a transient failure leaves the previous profile in place.
*/
export async function refreshProfile(): Promise<void> {
if (!app.session) return;
try {
app.profile = await gateway.profileGet();
} catch {
// Best-effort; the banner just stays as it was until the next fetch.
}
}
/**
* refreshNotifications recomputes the badge count (incoming friend requests).
* Authoritative poll, complementing the live 'notify' push. Game invitations have
+258 -51
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
import type { BannerTimings } from './model';
describe('linkify', () => {
it('escapes html and renders markdown links', () => {
@@ -20,71 +22,276 @@ describe('linkify', () => {
});
});
describe('banner rotator', () => {
describe('createScheduler', () => {
it('serves each campaign its weight share over a cycle, evenly spread', () => {
const sched = createScheduler([
{ weight: 3, messages: ['a'] },
{ weight: 7, messages: ['b'] },
]);
expect(sched.total).toBe(2);
const cycle = Array.from({ length: 10 }, () => sched.next());
const a = cycle.filter((m) => m === 'a').length;
const b = cycle.filter((m) => m === 'b').length;
expect(a).toBe(3);
expect(b).toBe(7);
// Smoothly interleaved, not clustered: the minority campaign appears in both halves.
expect(cycle.slice(0, 5)).toContain('a');
expect(cycle.slice(5)).toContain('a');
});
it('round-robins messages within a campaign', () => {
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
expect(sched.total).toBe(3);
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
});
it('drops empty and non-positive-weight campaigns', () => {
const sched = createScheduler([
{ weight: 0, messages: ['skip-zero'] },
{ weight: 5, messages: [] },
{ weight: 2, messages: ['keep'] },
]);
expect(sched.total).toBe(1);
expect(sched.next()).toBe('keep');
});
it('is empty for no campaigns', () => {
const sched = createScheduler([]);
expect(sched.total).toBe(0);
expect(sched.next()).toBe('');
});
});
// recordingHost captures the rotator's host calls as a sequence, with a fixed overflow.
function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown: string[] } {
const calls: string[] = [];
const shown: string[] = [];
return {
calls,
shown,
host: {
show: (md) => {
shown.push(md);
calls.push('show');
},
resetScroll: () => calls.push('reset'),
hide: () => calls.push('hide'),
overflowPx: () => overflow,
scrollTo: () => calls.push('scroll'),
resumeScroll: () => calls.push('resume'),
},
};
}
const cfg: BannerTimings = {
holdMs: 1000,
edgePauseMs: 100,
scrollPxPerSec: 200,
fadeOutMs: 300,
gapMs: 50,
fadeInMs: 200,
};
describe('createBannerRotator', () => {
afterEach(() => vi.useRealTimers());
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
it('runs the fade-out / gap / fade-in sequence between two messages', () => {
vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
const shown: number[] = [];
let scrolled = 0;
const overflow = [0, 200]; // item 0 fits, item 1 overflows
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{
overflowPx: (i) => overflow[i],
show: (i) => shown.push(i),
scrollTo: () => scrolled++,
},
cfg,
);
const { host, calls, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
r.start();
expect(shown).toEqual([0]);
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
expect(shown).toEqual([0, 1]);
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
expect(scrolled).toBe(1);
expect(shown).toEqual(['a']); // first message shown immediately
vi.advanceTimersByTime(cfg.fadeInMs); // fade in -> measure (fits)
vi.advanceTimersByTime(cfg.holdMs - 1);
expect(calls).not.toContain('hide'); // still holding
vi.advanceTimersByTime(1); // hold elapsed -> fade out
expect(calls).toContain('hide');
expect(shown).toEqual(['a']); // next not shown yet (during fade-out + gap)
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs); // -> show the next message
expect(shown).toEqual(['a', 'b']);
r.stop();
});
it('repeats the scroll cycle while under holdMs, then advances', () => {
it('scrolls an overflowing message', () => {
vi.useFakeTimers();
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 };
const shown: number[] = [];
let scrolls = 0;
const r = createBannerRotator(
[{ md: 'long' }, { md: 'short' }],
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
cfg,
);
const { host, calls } = recordingHost(200); // overflow 200px, 200px/s -> 1000ms scroll
const r = createBannerRotator([{ weight: 1, messages: ['long'] }, { weight: 1, messages: ['x'] }], host, cfg);
r.start();
vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll
expect(scrolls).toBe(1);
expect(shown).toEqual([0]);
vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll
expect(scrolls).toBe(2);
expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs)
vi.advanceTimersByTime(10_000);
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message
vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
expect(calls).toContain('scroll');
r.stop();
});
it('pulses a lone message through the fade cycle (fades out, then back in)', () => {
vi.useFakeTimers();
const { host, calls, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
r.start();
expect(shown).toEqual(['solo']);
vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs); // hold elapses -> fade out
expect(calls).toContain('hide');
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs + 1); // -> the same message fades back in
expect(shown).toEqual(['solo', 'solo']);
r.stop();
});
it('fades the scroll loop of a long message (out, rewind, in) instead of a hard reset', () => {
vi.useFakeTimers();
const c: BannerTimings = { holdMs: 30000, edgePauseMs: 100, scrollPxPerSec: 1000, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const { host, calls } = recordingHost(200); // overflows -> scroll dur = 200ms
const r = createBannerRotator([{ weight: 1, messages: ['long'] }], host, c);
r.start();
// fadeIn -> measure -> edgePause -> scrollTo -> (dur + edgePause) -> loop boundary
vi.advanceTimersByTime(c.fadeInMs + c.edgePauseMs + 200 + c.edgePauseMs + 5);
expect(calls).toContain('scroll');
expect(calls).toContain('hide'); // the loop fades out at the right edge (not a hard reset)
vi.advanceTimersByTime(c.fadeOutMs + c.gapMs + 5); // -> the same message fades back in
expect(calls.filter((x) => x === 'show').length).toBeGreaterThanOrEqual(2);
r.stop();
});
it('swaps instantly when fade timings are zero (reduce-motion)', () => {
vi.useFakeTimers();
const zero: BannerTimings = { ...cfg, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 };
const { host, shown } = recordingHost(0); // reduce-motion host reports no overflow
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, zero);
r.start();
// fadeIn(0) + hold -> fadeOut(0) + gap(0) -> next; the +5 flushes the trailing
// zero-delay timer chain that lands on the hold boundary.
vi.advanceTimersByTime(zero.holdMs + 5);
expect(shown).toEqual(['a', 'b']);
r.stop();
});
it('stop() halts further advancement', () => {
vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 };
const shown: number[] = [];
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
cfg,
);
const { host, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
r.start();
vi.advanceTimersByTime(cfg.fadeMs);
vi.advanceTimersByTime(cfg.fadeInMs);
r.stop();
vi.advanceTimersByTime(60_000);
expect(shown).toEqual(['a']);
});
it('does not start with no campaigns', () => {
vi.useFakeTimers();
const { host, shown } = recordingHost(0);
const r = createBannerRotator([], host, defaultBannerTimings);
r.start();
vi.advanceTimersByTime(60_000);
expect(shown).toEqual([]);
r.stop();
vi.advanceTimersByTime(10_000);
expect(shown).toEqual([0]);
});
});
describe('bannerEngine', () => {
afterEach(() => vi.useRealTimers());
// A minimal host that records the messages it is told to show.
function engineHost(): { host: BannerHost; shown: string[] } {
const shown: string[] = [];
return {
shown,
host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {}, resumeScroll() {} },
};
}
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
vi.useFakeTimers();
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
configureBanner(camps, cfg);
const a = engineHost();
attachBannerHost(a.host);
// Advance mid-sequence so the live message is no longer the first.
vi.advanceTimersByTime(period(cfg));
expect(bannerCurrent()).toBe('x1');
// Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
// and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
detachBannerHost(a.host);
const b = engineHost();
attachBannerHost(b.host);
expect(bannerCurrent()).toBe('x1');
expect(b.shown).toEqual([]);
// The engine keeps advancing and drives the new host — the cycle continued, not restarted.
vi.advanceTimersByTime(period(cfg));
expect(b.shown).toContain('x0');
expect(bannerCurrent()).toBe('x0');
detachBannerHost(b.host);
});
it('does not restart on a no-op reconfigure with unchanged data', () => {
vi.useFakeTimers();
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
configureBanner(camps, cfg);
const a = engineHost();
attachBannerHost(a.host);
vi.advanceTimersByTime(period(cfg));
expect(bannerCurrent()).toBe('y1');
// Re-configuring with identical data keeps the running cycle (the live message is unchanged).
configureBanner(camps, cfg);
vi.advanceTimersByTime(5);
expect(bannerCurrent()).toBe('y1');
detachBannerHost(a.host);
});
it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
vi.useFakeTimers();
const big: BannerTimings = { ...cfg, holdMs: 5000 };
const shown: string[] = [];
let scrolled = 0;
let overflow = 0;
const host: BannerHost = {
show: (md) => shown.push(md),
resetScroll() {},
hide() {},
overflowPx: () => overflow,
scrollTo: () => scrolled++,
resumeScroll() {},
};
configureBanner([{ weight: 1, messages: ['z0'] }], big);
attachBannerHost(host);
vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
expect(scrolled).toBe(0);
// The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
// engages (the owner accepts the cycle restarting on a size change).
overflow = 300;
remeasureBanner();
expect(shown).toContain('z0');
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
expect(scrolled).toBeGreaterThan(0);
detachBannerHost(host);
});
it('resumes a long message scroll mid-flight on a new host (navigation)', () => {
vi.useFakeTimers();
const big: BannerTimings = { holdMs: 10000, edgePauseMs: 100, scrollPxPerSec: 100, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const noop: BannerHost = { show() {}, resetScroll() {}, hide() {}, overflowPx: () => 500, scrollTo() {}, resumeScroll() {} };
let resumed: { fromTx: number; toPx: number; dur: number } | null = null;
const b: BannerHost = { ...noop, resumeScroll: (fromTx, toPx, dur) => (resumed = { fromTx, toPx, dur }) };
configureBanner([{ weight: 1, messages: ['scrollme'] }], big);
attachBannerHost(noop);
// fadeIn(100) + edgePause(100) -> scrollTo (dur = 500/100*1000 = 5000ms); then 2000ms into it.
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 2000);
// Remount mid-scroll: the new host resumes from ~40% of the way, not from the start.
detachBannerHost(noop);
attachBannerHost(b);
expect(resumed).not.toBeNull();
expect(resumed!.toPx).toBe(500);
expect(resumed!.fromTx).toBeLessThan(0); // already scrolled left of 0
expect(resumed!.fromTx).toBeGreaterThan(-500); // but not yet at the end
expect(resumed!.dur).toBeGreaterThan(0);
expect(resumed!.dur).toBeLessThan(5000); // only the remaining time
detachBannerHost(b);
});
});
+110 -63
View File
@@ -1,60 +1,104 @@
// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
// effects through callbacks), so its timing is unit-testable with fake timers. Today
// the content is a mock long↔short rotation; later it becomes a server-driven
// announcements channel (see ARCHITECTURE).
// Advertising banner — the server-driven rotation engine plus a tiny markdown
// linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow
// and applies the visual effects through callbacks), so their timing and fairness are
// unit-testable with fake timers. The campaigns + timings come from the profile's
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
export interface BannerConfig {
/** How long one message is shown before advancing (short text), ms. */
holdMs: number;
/** Pause at each end before/after scrolling a long message, ms. */
edgePauseMs: number;
/** Scroll speed for a long (overflowing) message, px/sec. */
scrollPxPerSec: number;
/** Cross-fade duration between messages, ms. */
fadeMs: number;
}
import type { BannerCampaign, BannerTimings } from './model';
export const defaultBannerConfig: BannerConfig = {
/** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
holdMs: 60_000,
edgePauseMs: 5_000,
scrollPxPerSec: 40,
fadeMs: 400,
fadeOutMs: 1_000,
gapMs: 250,
fadeInMs: 1_000,
};
export interface BannerItem {
/** Minimal markdown: plain text + `[label](url)` links. */
md: string;
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
next(): string;
/** Total messages across all (non-empty, positive-weight) campaigns. */
readonly total: number;
}
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
/**
* createScheduler builds a smooth weighted round-robin over campaigns: each call to
* next() picks the campaign whose running weight is highest (then decremented by the
* total weight), so over one cycle of `sum(weights)` picks every campaign appears
* exactly its weight share, evenly interleaved rather than clustered (the nginx/envoy
* algorithm). Within a campaign its messages advance round-robin. Campaigns with no
* messages or a non-positive weight are dropped.
*/
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
const current = cs.map(() => 0); // SWRR running weights
const cursor = cs.map(() => 0); // per-campaign round-robin position
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
return {
total,
next(): string {
if (cs.length === 0) return '';
let best = 0;
for (let i = 0; i < cs.length; i++) {
current[i] += cs[i].weight;
if (current[i] > current[best]) best = i;
}
current[best] -= totalWeight;
const c = cs[best];
const md = c.messages[cursor[best] % c.messages.length];
cursor[best]++;
return md;
},
};
}
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
export interface BannerHost {
/** Overflow width of item `index` in px (0 when it fits). */
overflowPx(index: number): number;
/** Render item `index` (the host fades it in and resets scroll to the start). */
show(index: number): void;
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
show(md: string): void;
/** Reset the scroll to the start without re-fading (to loop a long message). */
resetScroll(): void;
/** Fade the current message out over durationMs. */
hide(durationMs: number): void;
/** Overflow width of the currently-shown message in px (0 when it fits or motion is reduced). */
overflowPx(): number;
/** Animate the horizontal scroll to toPx over durationMs. */
scrollTo(toPx: number, durationMs: number): void;
/** Resume an in-flight scroll on a freshly-mounted view: jump to fromTx (px, signed) instantly,
* then continue to toPx over durationMs. Used by the engine on attach to carry the scroll
* position across a navigation; the rotator itself never calls it. */
resumeScroll(fromTx: number, toPx: number, durationMs: number): void;
}
export interface Rotator {
start(): void;
stop(): void;
/** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the
* viewport size changed. A no-op before start(). */
restart(): void;
}
/**
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
* repeats while the elapsed cycle is under `holdMs`, else advances.
* createBannerRotator rotates the scheduled messages: each message fades in, holds
* `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs),
* then fades out over `fadeOutMs`, waits `gapMs`, and fades the next one in. A lone
* message pulses (the same message fades back in) so the cycle keeps its rhythm.
* Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that
* returns 0, and messages swap instantly without scrolling.
*/
export function createBannerRotator(
items: BannerItem[],
campaigns: BannerCampaign[],
host: BannerHost,
config: BannerConfig = defaultBannerConfig,
config: BannerTimings = defaultBannerTimings,
): Rotator {
let index = 0;
const sched = createScheduler(campaigns);
let running = false;
let cycleStart = 0;
let lastShown = ''; // the message currently presented, for restart() (re-measure)
const timers: ReturnType<typeof setTimeout>[] = [];
const at = (ms: number, fn: () => void) => {
@@ -65,26 +109,25 @@ export function createBannerRotator(
timers.length = 0;
};
function advance() {
if (!running) return;
index = (index + 1) % items.length;
present();
}
function present() {
function present(md: string) {
if (!running) return;
clear();
host.show(index);
// Let the swapped-in message render before measuring its overflow.
at(config.fadeMs, () => {
const over = host.overflowPx(index);
if (over <= 0) {
at(config.holdMs, advance);
lastShown = md;
host.show(md);
at(config.fadeInMs, measure);
}
function measure() {
if (!running) return;
cycleStart = Date.now();
const over = host.overflowPx();
if (over > 0) {
scrollCycle(over);
return;
}
cycleStart = Date.now();
scrollCycle(over);
});
// Hold, then run the fade cycle. A lone message pulses (fades out, then back in) rather than
// sitting frozen, so the banner keeps its rhythm even with a single campaign message.
at(config.holdMs, advance);
}
function scrollCycle(over: number) {
@@ -95,24 +138,38 @@ export function createBannerRotator(
if (Date.now() - cycleStart >= config.holdMs) {
advance();
} else {
host.show(index); // resets scroll to the start
scrollCycle(over);
// Loop the same message with a fade: fade out at the right edge, rewind to the start
// while hidden, fade the same message back in, then scroll again — so the rewind gets
// the same fade as a message change, not a hard jump.
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => {
host.show(lastShown);
at(config.fadeInMs, () => scrollCycle(over));
});
}
});
});
}
function advance() {
if (!running) return;
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => present(sched.next()));
}
return {
start() {
if (running || items.length === 0) return;
if (running || sched.total === 0) return;
running = true;
index = 0;
present();
present(sched.next());
},
stop() {
running = false;
clear();
},
restart() {
if (running && lastShown) present(lastShown);
},
};
}
@@ -145,13 +202,3 @@ export function linkify(md: string): string {
parts.push(escapeHtml(md.slice(last)));
return parts.join('');
}
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
export function mockBanners(): BannerItem[] {
return [
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
{
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
},
];
}
+110
View File
@@ -0,0 +1,110 @@
// A single, persistent banner rotation engine. The rotator and scheduler live here at module
// scope, so the rotation keeps running across screen navigations: each screen mounts its own
// AdBanner view, which only attaches as the DOM host. This makes the banner continuous and
// independent of route transitions (the cycle is not restarted on navigation).
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
import type { BannerCampaign, BannerTimings } from './model';
let rotator: Rotator | null = null;
let mounted: BannerHost | null = null;
let current = '';
let key = '';
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
let activeScroll: { toPx: number; dur: number; start: number } | null = null;
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
const proxy: BannerHost = {
show(md) {
current = md;
activeScroll = null;
mounted?.show(md);
},
resetScroll() {
activeScroll = null;
mounted?.resetScroll();
},
hide(durationMs) {
mounted?.hide(durationMs);
},
overflowPx() {
return mounted?.overflowPx() ?? 0;
},
scrollTo(toPx, durationMs) {
activeScroll = { toPx, dur: durationMs, start: Date.now() };
mounted?.scrollTo(toPx, durationMs);
},
resumeScroll(fromTx, toPx, durationMs) {
mounted?.resumeScroll(fromTx, toPx, durationMs);
},
};
// bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a
// real change, not on every (re)mount.
function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string {
return JSON.stringify({ campaigns, timings });
}
/**
* configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the
* running rotation untouched when they are unchanged — so navigating between screens (which
* remounts the view with the same data) continues the cycle rather than restarting it.
*/
export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void {
const k = bannerKey(campaigns, timings);
if (k === key && rotator) return;
key = k;
rotator?.stop();
current = '';
rotator = createBannerRotator(campaigns, proxy, timings);
rotator.start();
}
/**
* bannerCurrent returns the message the engine is currently displaying (empty before the first
* one). A freshly-mounted view reads it to render the live message immediately, without a fade —
* so navigating between screens does not visibly restart the cycle.
*/
export function bannerCurrent(): string {
return current;
}
/**
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
* replay the fade-in on every navigation, which looks like the cycle restarting. The engine's own
* timer keeps driving show/hide for real message changes through this host.
*/
export function attachBannerHost(host: BannerHost): void {
mounted = host;
// Resume the current message's scroll from where it had reached, so navigation does not restart
// a long message at the left. Only while a scroll is still in flight; a finished scroll is left
// at its end and the rotator's own loop takes over.
if (activeScroll) {
const elapsed = Date.now() - activeScroll.start;
if (elapsed < activeScroll.dur) {
const progress = elapsed / activeScroll.dur;
host.resumeScroll(-activeScroll.toPx * progress, activeScroll.toPx, activeScroll.dur - elapsed);
}
}
}
/**
* remeasureBanner re-presents the current message (re-measuring overflow and restarting its
* scroll), for when the viewport size changed (e.g. a portrait↔landscape rotation) and a message
* that fit may now overflow, or vice versa.
*/
export function remeasureBanner(): void {
rotator?.restart();
}
/**
* detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host
* only when it is still the current one, so an outgoing view leaving after the incoming view has
* already attached does not detach the new host (the transition briefly double-mounts).
*/
export function detachBannerHost(host: BannerHost): void {
if (mounted === host) mounted = null;
}
+35
View File
@@ -12,6 +12,7 @@ import {
decodeLinkResult,
decodeMatchResult,
decodeOutgoingList,
decodeProfile,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
@@ -449,6 +450,40 @@ describe('codec', () => {
expect(r.game?.id).toBe('g-open');
expect(r.game?.status).toBe('open');
});
it('decodes the profile banner block (campaigns + timings)', () => {
const b = new Builder(256);
const uid = b.createString('u-1');
const m0 = b.createString('promo-en');
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addBanner(b, banner);
b.finish(fb.Profile.endProfile(b));
const p = decodeProfile(b.asUint8Array());
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'] }]);
expect(p.banner?.timings).toEqual({
holdMs: 60000,
edgePauseMs: 5000,
scrollPxPerSec: 40,
fadeOutMs: 1000,
gapMs: 250,
fadeInMs: 1000,
});
});
it('leaves the profile banner undefined when absent', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
});
});
// The live play loop exchanges alphabet indices, mapped through the per-variant
+29
View File
@@ -9,6 +9,8 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f
import type { PlacedTile } from './client';
import type {
AccountRef,
Banner,
BannerCampaign,
BlockStatus,
ChatMessage,
EvalResult,
@@ -316,6 +318,33 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
banner: decodeBanner(p),
};
}
// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
const b = p.banner();
if (!b) return undefined;
const campaigns: BannerCampaign[] = [];
for (let i = 0; i < b.campaignsLength(); i++) {
const c = b.campaigns(i);
if (!c) continue;
const messages: string[] = [];
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
campaigns.push({ weight: c.weight(), messages });
}
return {
campaigns,
timings: {
holdMs: b.holdMs(),
edgePauseMs: b.edgePauseMs(),
scrollPxPerSec: b.scrollPxPerSec(),
fadeOutMs: b.fadeOutMs(),
gapMs: b.gapMs(),
fadeInMs: b.fadeInMs(),
},
};
}
+26
View File
@@ -138,6 +138,32 @@ export interface Profile {
isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */
notificationsInAppOnly: boolean;
/** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner;
}
/** Banner is the advertising-banner block of an eligible viewer's profile. */
export interface Banner {
campaigns: BannerCampaign[];
timings: BannerTimings;
}
/** BannerCampaign is one campaign in the rotation feed. */
export interface BannerCampaign {
/** GCD-reduced show weight for the client's smooth weighted round-robin. */
weight: number;
/** Messages in display order, already resolved to the viewer's bot language. */
messages: string[];
}
/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */
export interface BannerTimings {
holdMs: number;
edgePauseMs: number;
scrollPxPerSec: number;
fadeOutMs: number;
gapMs: number;
fadeInMs: number;
}
/** BlockStatus is the caller's current manual block, fetched after any call reports