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
11 changed files with 399 additions and 165 deletions
Showing only changes of commit cb4a31a860 - Show all commits
+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 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 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. 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` - **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase
— the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod (PRERELEASE.md) later turned it into the real server-driven advertising network and removed
bundle (code kept for post-release polish). the gate (`Screen` renders it from `app.profile.banner`).
- **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). - **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 - **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 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** | | 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** | | 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** | | 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) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## 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 under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". 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 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 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 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 minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats viewer — see ARCHITECTURE §10), so `Screen` renders the strip only when that block is present, and
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short a `notify` `banner` event re-fetches the profile to show or hide it in place.
message; the source becomes a server-driven channel later (see ARCHITECTURE).
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`) ## Result / status iconography (`lib/result.ts`)
+44 -33
View File
@@ -1,43 +1,57 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { createBannerRotator, defaultBannerTimings, linkify } from '../lib/banner';
import { fade } from 'svelte/transition'; import type { BannerCampaign, BannerTimings } from '../lib/model';
import {
createBannerRotator,
defaultBannerConfig,
linkify,
mockBanners,
type BannerConfig,
type BannerItem,
} from '../lib/banner';
import { onExternalLinkClick } from '../lib/telegram'; import { onExternalLinkClick } from '../lib/telegram';
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } = let {
$props(); 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 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>();
let rotator: ReturnType<typeof createBannerRotator> | null = null; // Recreate the rotator whenever the campaigns, timings or reduce-motion change (a
// `banner` notify re-fetches the profile, swapping the campaigns in place). Under
onMount(() => { // reduce-motion the fade/gap timings collapse to 0 (instant swap) and overflowPx
rotator = createBannerRotator(items, { // reports 0 (no scroll).
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)), $effect(() => {
show: (i) => { const cfg: BannerTimings = reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings;
current = i; 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; tx = 0;
txDur = 0; txDur = 0;
}, },
hide: (durationMs) => {
opDur = durationMs;
opacity = 0;
},
overflowPx: () => (reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0))),
scrollTo: (toPx, durationMs) => { scrollTo: (toPx, durationMs) => {
txDur = durationMs; txDur = durationMs;
tx = -toPx; tx = -toPx;
}, },
}, config); }, cfg);
rotator.start(); rotator.start();
return () => rotator.stop();
}); });
onDestroy(() => rotator?.stop());
</script> </script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the <!-- 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_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}>
{#key current} <div
<div class="track"
class="track" bind:this={track}
bind:this={track} style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease"
in:fade={{ duration: config.fadeMs }} >
style="transform:translateX({tx}px); transition:transform {txDur}ms linear" {@html linkify(current)}
> </div>
{@html linkify(items[current]?.md ?? '')}
</div>
{/key}
</div> </div>
<style> <style>
@@ -76,7 +87,7 @@
/* The side inset lives on the track (not the clipping .ad) so the scroll distance /* 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. */ (scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad); padding: 0 var(--pad);
will-change: transform; will-change: transform, opacity;
} }
.track :global(a) { .track :global(a) {
color: var(--accent); color: var(--accent);
+8 -7
View File
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Header from './Header.svelte'; import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte'; import AdBanner from './AdBanner.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and // The app-shell layout (all screens): the nav bar grows; the ad strip, content and
@@ -27,12 +28,6 @@
column?: boolean; column?: boolean;
} = $props(); } = $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 // 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 // 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 // window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the
@@ -94,7 +89,13 @@
<div class="screen"> <div class="screen">
<Header {title} {back} grow={growNav} /> <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> <main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar} {#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav> <nav class="tabbar">{@render tabbar()}</nav>
+19
View File
@@ -276,6 +276,11 @@ function openStream(): void {
if (e.sub === 'admin_reply') { if (e.sub === 'admin_reply') {
app.feedbackReplyUnread = true; 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(); void refreshNotifications();
} }
}, },
@@ -300,6 +305,20 @@ function scheduleReconnect(): void {
}, 4000); }, 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). * refreshNotifications recomputes the badge count (incoming friend requests).
* Authoritative poll, complementing the live 'notify' push. Game invitations have * 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 { 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', () => { describe('linkify', () => {
it('escapes html and renders markdown links', () => { 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()); 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(); vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 }; const { host, calls, shown } = recordingHost(0);
const shown: number[] = []; const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
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,
);
r.start(); r.start();
expect(shown).toEqual([0]); expect(shown).toEqual(['a']); // first message shown immediately
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0 vi.advanceTimersByTime(cfg.fadeInMs); // fade in -> measure (fits)
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1 vi.advanceTimersByTime(cfg.holdMs - 1);
expect(shown).toEqual([0, 1]); expect(calls).not.toContain('hide'); // still holding
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows) vi.advanceTimersByTime(1); // hold elapsed -> fade out
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo expect(calls).toContain('hide');
expect(scrolled).toBe(1); 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(); r.stop();
}); });
it('repeats the scroll cycle while under holdMs, then advances', () => { it('scrolls an overflowing message', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 }; const { host, calls } = recordingHost(200); // overflow 200px, 200px/s -> 1000ms scroll
const shown: number[] = []; const r = createBannerRotator([{ weight: 1, messages: ['long'] }, { weight: 1, messages: ['x'] }], host, cfg);
let scrolls = 0;
const r = createBannerRotator(
[{ md: 'long' }, { md: 'short' }],
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
cfg,
);
r.start(); r.start();
vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
expect(scrolls).toBe(1); expect(calls).toContain('scroll');
expect(shown).toEqual([0]); r.stop();
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) it('shows a single message once and never fades it out', () => {
vi.advanceTimersByTime(10_000); vi.useFakeTimers();
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message 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(); r.stop();
}); });
it('stop() halts further advancement', () => { it('stop() halts further advancement', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 }; const { host, shown } = recordingHost(0);
const shown: number[] = []; const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
cfg,
);
r.start(); 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(); 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. // Advertising banner — the server-driven rotation engine plus a tiny markdown
// The rotator is DOM-agnostic (the host measures overflow and applies the visual // linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow
// effects through callbacks), so its timing is unit-testable with fake timers. Today // and applies the visual effects through callbacks), so their timing and fairness are
// the content is a mock long↔short rotation; later it becomes a server-driven // unit-testable with fake timers. The campaigns + timings come from the profile's
// announcements channel (see ARCHITECTURE). // banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
export interface BannerConfig { import type { BannerCampaign, BannerTimings } from './model';
/** 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;
}
export const defaultBannerConfig: BannerConfig = { /** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
holdMs: 60_000, holdMs: 60_000,
edgePauseMs: 5_000, edgePauseMs: 5_000,
scrollPxPerSec: 40, scrollPxPerSec: 40,
fadeMs: 400, fadeOutMs: 1_000,
gapMs: 250,
fadeInMs: 1_000,
}; };
export interface BannerItem { /** Scheduler yields the next message to show, fairly across weighted campaigns. */
/** Minimal markdown: plain text + `[label](url)` links. */ export interface Scheduler {
md: string; 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 { export interface BannerHost {
/** Overflow width of item `index` in px (0 when it fits). */ /** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
overflowPx(index: number): number; show(md: string): void;
/** Render item `index` (the host fades it in and resets scroll to the start). */ /** Reset the scroll to the start without re-fading (to loop a long message). */
show(index: number): void; resetScroll(): void;
/** Animate the horizontal scroll to `toPx` over `durationMs`. */ /** 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; scrollTo(toPx: number, durationMs: number): void;
} }
@@ -43,16 +76,19 @@ export interface Rotator {
} }
/** /**
* createBannerRotator drives a list of messages: a fitting message holds `holdMs` * createBannerRotator rotates the scheduled messages: each message fades in, holds
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then * `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs),
* repeats while the elapsed cycle is under `holdMs`, else advances. * 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( export function createBannerRotator(
items: BannerItem[], campaigns: BannerCampaign[],
host: BannerHost, host: BannerHost,
config: BannerConfig = defaultBannerConfig, config: BannerTimings = defaultBannerTimings,
): Rotator { ): Rotator {
let index = 0; const sched = createScheduler(campaigns);
let running = false; let running = false;
let cycleStart = 0; let cycleStart = 0;
const timers: ReturnType<typeof setTimeout>[] = []; const timers: ReturnType<typeof setTimeout>[] = [];
@@ -65,26 +101,23 @@ export function createBannerRotator(
timers.length = 0; timers.length = 0;
}; };
function advance() { function present(md: string) {
if (!running) return;
index = (index + 1) % items.length;
present();
}
function present() {
if (!running) return; if (!running) return;
clear(); clear();
host.show(index); host.show(md);
// Let the swapped-in message render before measuring its overflow. at(config.fadeInMs, measure);
at(config.fadeMs, () => { }
const over = host.overflowPx(index);
if (over <= 0) { function measure() {
at(config.holdMs, advance); if (!running) return;
return; cycleStart = Date.now();
} const over = host.overflowPx();
cycleStart = Date.now(); if (over > 0) {
scrollCycle(over); scrollCycle(over);
}); return;
}
if (sched.total > 1) at(config.holdMs, advance);
// A single fitting message stays put.
} }
function scrollCycle(over: number) { function scrollCycle(over: number) {
@@ -92,22 +125,27 @@ export function createBannerRotator(
at(config.edgePauseMs, () => { at(config.edgePauseMs, () => {
host.scrollTo(over, dur); host.scrollTo(over, dur);
at(dur + config.edgePauseMs, () => { at(dur + config.edgePauseMs, () => {
if (Date.now() - cycleStart >= config.holdMs) { if (sched.total > 1 && Date.now() - cycleStart >= config.holdMs) {
advance(); advance();
} else { } else {
host.show(index); // resets scroll to the start host.resetScroll();
scrollCycle(over); scrollCycle(over);
} }
}); });
}); });
} }
function advance() {
if (!running) return;
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => present(sched.next()));
}
return { return {
start() { start() {
if (running || items.length === 0) return; if (running || sched.total === 0) return;
running = true; running = true;
index = 0; present(sched.next());
present();
}, },
stop() { stop() {
running = false; running = false;
@@ -145,13 +183,3 @@ export function linkify(md: string): string {
parts.push(escapeHtml(md.slice(last))); parts.push(escapeHtml(md.slice(last)));
return parts.join(''); 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, decodeLinkResult,
decodeMatchResult, decodeMatchResult,
decodeOutgoingList, decodeOutgoingList,
decodeProfile,
decodeSession, decodeSession,
decodeFeedbackState, decodeFeedbackState,
decodeFeedbackUnread, decodeFeedbackUnread,
@@ -449,6 +450,40 @@ describe('codec', () => {
expect(r.game?.id).toBe('g-open'); expect(r.game?.id).toBe('g-open');
expect(r.game?.status).toBe('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 // 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 { PlacedTile } from './client';
import type { import type {
AccountRef, AccountRef,
Banner,
BannerCampaign,
BlockStatus, BlockStatus,
ChatMessage, ChatMessage,
EvalResult, EvalResult,
@@ -316,6 +318,33 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockFriendRequests: p.blockFriendRequests(), blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(), isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(), 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; isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */ /** Confine notifications to the in-app stream (no out-of-app platform push). */
notificationsInAppOnly: boolean; 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 /** BlockStatus is the caller's current manual block, fetched after any call reports