feat(ads): client banner rotation, fade UX & live toggle (PR2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

Consume the server-driven banner block (PR1) in the UI and retire the gate.

- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
  appears its weight share per cycle, evenly interleaved) with round-robin over a
  campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
  -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
  from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
  app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
  the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
  shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
  reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
This commit is contained in:
Ilia Denisov
2026-06-15 23:25:27 +02:00
parent a5f066224c
commit cb4a31a860
11 changed files with 399 additions and 165 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)
+14 -6
View File
@@ -195,16 +195,24 @@ 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`, `lib/banner.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).
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 `Screen` 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.
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. The **rotator** then drives one message: it 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 — when more than one
message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. A lone message stays
put (a long one keeps scrolling). 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`)
+44 -33
View File
@@ -1,43 +1,57 @@
<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';
import { createBannerRotator, defaultBannerTimings, linkify } 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);
let current = $state('');
let opacity = $state(0);
let opDur = $state(0); // opacity transition duration, ms (fade-in / fade-out)
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
let rotator: ReturnType<typeof createBannerRotator> | null = null;
onMount(() => {
rotator = createBannerRotator(items, {
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
show: (i) => {
current = i;
// Recreate the rotator whenever the campaigns, timings or reduce-motion change (a
// `banner` notify re-fetches the profile, swapping the campaigns in place). Under
// reduce-motion the fade/gap timings collapse to 0 (instant swap) and overflowPx
// reports 0 (no scroll).
$effect(() => {
const cfg: BannerTimings = reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings;
const rotator = createBannerRotator(campaigns, {
show: (md) => {
current = md;
tx = 0;
txDur = 0;
opDur = 0;
opacity = 0;
requestAnimationFrame(() => {
opDur = cfg.fadeInMs;
opacity = 1;
});
},
resetScroll: () => {
tx = 0;
txDur = 0;
},
hide: (durationMs) => {
opDur = durationMs;
opacity = 0;
},
overflowPx: () => (reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0))),
scrollTo: (toPx, durationMs) => {
txDur = durationMs;
tx = -toPx;
},
}, config);
}, cfg);
rotator.start();
return () => rotator.stop();
});
onDestroy(() => rotator?.stop());
</script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
@@ -45,16 +59,13 @@
<!-- 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}
<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 ?? '')}
</div>
{/key}
<div
class="track"
bind:this={track}
style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease"
>
{@html linkify(current)}
</div>
</div>
<style>
@@ -76,7 +87,7 @@
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad);
will-change: transform;
will-change: transform, opacity;
}
.track :global(a) {
color: var(--accent);
+8 -7
View File
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
@@ -27,12 +28,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 +89,13 @@
<div class="screen">
<Header {title} {back} grow={growNav} />
{#if SHOW_AD_BANNER}<AdBanner />{/if}
{#if app.profile?.banner && app.profile.banner.campaigns.length}
<AdBanner
campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings}
reduceMotion={app.reduceMotion}
/>
{/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
+128 -51
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import type { BannerTimings } from './model';
describe('linkify', () => {
it('escapes html and renders markdown links', () => {
@@ -20,71 +21,147 @@ 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'),
},
};
}
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('shows a single message once and never fades it out', () => {
vi.useFakeTimers();
const { host, calls, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
r.start();
vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs * 5);
expect(shown).toEqual(['solo']);
expect(calls).not.toContain('hide');
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]);
});
});
+92 -64
View File
@@ -1,39 +1,72 @@
// 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;
}
@@ -43,16 +76,19 @@ export interface Rotator {
}
/**
* 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 — when more than one message exists — fades out over `fadeOutMs`, waits `gapMs`,
* and fades the next one in. A single message stays put (a long one keeps scrolling).
* 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;
const timers: ReturnType<typeof setTimeout>[] = [];
@@ -65,26 +101,23 @@ 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);
return;
}
cycleStart = Date.now();
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;
}
if (sched.total > 1) at(config.holdMs, advance);
// A single fitting message stays put.
}
function scrollCycle(over: number) {
@@ -92,22 +125,27 @@ export function createBannerRotator(
at(config.edgePauseMs, () => {
host.scrollTo(over, dur);
at(dur + config.edgePauseMs, () => {
if (Date.now() - cycleStart >= config.holdMs) {
if (sched.total > 1 && Date.now() - cycleStart >= config.holdMs) {
advance();
} else {
host.show(index); // resets scroll to the start
host.resetScroll();
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;
@@ -145,13 +183,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)!',
},
];
}
+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