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
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:
@@ -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
@@ -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
@@ -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`)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user