feat(ads): client banner rotation, fade UX & live toggle (PR2) #71
@@ -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)
|
||||
|
||||
@@ -86,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||
font: inherit;
|
||||
}
|
||||
.form textarea { min-height: 4rem; resize: vertical; }
|
||||
/* A static help aside (e.g. message-formatting hints) beside an intro note at the top of a
|
||||
section: the note fills the row, the aside takes ~40% and both wrap on a narrow viewport. */
|
||||
.help-top { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin: 0.2rem 0 0.6rem; }
|
||||
.help-top .note { flex: 1 1 16rem; margin: 0; }
|
||||
.help {
|
||||
flex: 0 1 40%;
|
||||
min-width: 16rem;
|
||||
background: var(--panel-hi);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.help h4 { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--ink); }
|
||||
.help p { margin: 0.35rem 0; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
|
||||
@@ -21,12 +21,21 @@
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Messages</h2>
|
||||
<div class="help-top">
|
||||
<p class="note">Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.</p>
|
||||
<aside class="help">
|
||||
<h4>Formatting</h4>
|
||||
<p>Plain text is shown as-is (any HTML is escaped).</p>
|
||||
<p>Add a link with markdown — <code>[visible text](https://example.com)</code> — which renders as the linked <em>visible text</em>.</p>
|
||||
<p>Only <code>https://</code>, <code>http://</code> and root-relative <code>/path</code> targets become links; any other scheme (e.g. <code>javascript:</code>, <code>ftp:</code>) is dropped and shown as plain text.</p>
|
||||
<p>Keep it short: an over-long line scrolls in the strip; the same formatting applies when editing a message above.</p>
|
||||
</aside>
|
||||
</div>
|
||||
{{range .Messages}}
|
||||
<div class="row">
|
||||
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
|
||||
<label>English <input type="text" name="body_en" value="{{.BodyEn}}" maxlength="500" required></label>
|
||||
<label>Russian <input type="text" name="body_ru" value="{{.BodyRu}}" maxlength="500" required></label>
|
||||
<label>English <textarea name="body_en" rows="3" maxlength="500" required>{{.BodyEn}}</textarea></label>
|
||||
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required>{{.BodyRu}}</textarea></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
@@ -40,8 +49,8 @@
|
||||
{{else}}<p class="note">no messages yet</p>{{end}}
|
||||
<h3>Add message</h3>
|
||||
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
|
||||
<label>English <input type="text" name="body_en" maxlength="500" required></label>
|
||||
<label>Russian <input type="text" name="body_ru" maxlength="500" required></label>
|
||||
<label>English <textarea name="body_en" rows="3" maxlength="500" required></textarea></label>
|
||||
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required></textarea></label>
|
||||
<div><button type="submit">Add message</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -247,3 +248,28 @@ func TestBannerProfileEligibility(t *testing.T) {
|
||||
t.Fatal("a non-empty hint wallet still shows the banner")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
|
||||
// banner block too, so the client's profile keeps the banner instead of losing it until reload.
|
||||
func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
srv, _ := bannerServer(t)
|
||||
id := provisionAccount(t)
|
||||
|
||||
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
|
||||
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
|
||||
req.Header.Set("X-User-ID", id.String())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update profile = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var p profileBanner
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if p.Banner == nil || len(p.Banner.Campaigns) == 0 {
|
||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ type bannerTimingsDTO struct {
|
||||
FadeInMs int `json:"fade_in_ms"`
|
||||
}
|
||||
|
||||
// profileResponse builds the account's profile DTO with the advertising-banner block attached when
|
||||
// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link)
|
||||
// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g.
|
||||
// a language switch).
|
||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||
r := profileResponseFor(acc)
|
||||
r.Banner = s.bannerFor(ctx, acc)
|
||||
return r
|
||||
}
|
||||
|
||||
// bannerFor builds the advertising-banner block for the account's profile, or
|
||||
// nil when the ads service is not configured or the viewer is not eligible to
|
||||
// see a banner. The message language follows the account's bot (service)
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||
}
|
||||
|
||||
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
|
||||
|
||||
@@ -180,7 +180,7 @@ func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p := profileResponseFor(acc)
|
||||
p := s.profileResponse(ctx, acc)
|
||||
return &p
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ func (s *Server) handleProfile(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
resp := profileResponseFor(acc)
|
||||
resp.Banner = s.bannerFor(c.Request.Context(), acc)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||
}
|
||||
|
||||
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
||||
|
||||
+34
-15
@@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro
|
||||
|
||||
## Layout shell (`components/Screen.svelte`)
|
||||
|
||||
A full-height flex column: the nav bar, the announcement strip, the content, and an
|
||||
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
|
||||
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
|
||||
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
|
||||
pinning the board and controls to the **bottom** for thumb reach. Every screen except
|
||||
Login uses `Screen`.
|
||||
A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
|
||||
tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
|
||||
nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
|
||||
most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
|
||||
the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
|
||||
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
|
||||
except Login uses `Screen`.
|
||||
|
||||
## Navigation
|
||||
|
||||
@@ -195,16 +196,34 @@ Login uses `Screen`.
|
||||
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||
|
||||
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
||||
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
|
||||
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
|
||||
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
|
||||
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
|
||||
message; the source becomes a server-driven channel later (see ARCHITECTURE).
|
||||
A one-line inset strip docked inside the nav bar, directly under the title (so its position never
|
||||
jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
|
||||
the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
|
||||
`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
|
||||
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
||||
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
||||
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
||||
the profile to show or hide it in place.
|
||||
|
||||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||
cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and
|
||||
renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so
|
||||
a screen change does not replay the fade; only a real message advance fades. A **smooth weighted
|
||||
round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each
|
||||
appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages
|
||||
advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the
|
||||
strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats while
|
||||
under `holdMs`), then — with more than one message — fades out (`fadeOutMs`), waits `gapMs`, and the
|
||||
next fades in. The fade (opacity on a `.fadewrap` layer) is independent of the scroll (the inner
|
||||
track's transform), so a long, scrolling message still fades at both ends. A lone message stays put
|
||||
(a long one keeps scrolling). A **viewport size change** (e.g. a portrait↔landscape rotation)
|
||||
re-measures the current message (`remeasureBanner`, debounced), so its scroll re-evaluates for the
|
||||
new width. All timings are operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades
|
||||
collapse to an instant swap
|
||||
and a long message does not scroll.
|
||||
|
||||
## Result / status iconography (`lib/result.ts`)
|
||||
|
||||
|
||||
@@ -1,43 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
createBannerRotator,
|
||||
defaultBannerConfig,
|
||||
linkify,
|
||||
mockBanners,
|
||||
type BannerConfig,
|
||||
type BannerItem,
|
||||
} from '../lib/banner';
|
||||
attachBannerHost,
|
||||
bannerCurrent,
|
||||
configureBanner,
|
||||
detachBannerHost,
|
||||
remeasureBanner,
|
||||
} from '../lib/bannerEngine';
|
||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
||||
import { onExternalLinkClick } from '../lib/telegram';
|
||||
|
||||
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
|
||||
$props();
|
||||
let {
|
||||
campaigns,
|
||||
timings = defaultBannerTimings,
|
||||
reduceMotion = false,
|
||||
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
||||
|
||||
let current = $state(0);
|
||||
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
||||
// current message and is visible at once (no fade — see inFade), so a screen change does not
|
||||
// replay the fade. Empty on the very first mount (engine not yet started).
|
||||
let current = $state(bannerCurrent());
|
||||
let visible = $state(bannerCurrent() !== '');
|
||||
let tx = $state(0);
|
||||
let txDur = $state(0);
|
||||
let track = $state<HTMLElement>();
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
let rotator: ReturnType<typeof createBannerRotator> | null = null;
|
||||
// The first appearance after mounting onto an already-running cycle is instant; every later
|
||||
// message change fades. (Consumed by the first in:fade.)
|
||||
let instantOnce = bannerCurrent() !== '';
|
||||
|
||||
onMount(() => {
|
||||
rotator = createBannerRotator(items, {
|
||||
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
|
||||
show: (i) => {
|
||||
current = i;
|
||||
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
||||
// scroll. The engine is configured with the same effective timings, so the fade durations stay in
|
||||
// step with the rotator's hold/transition scheduling.
|
||||
const eff = $derived<BannerTimings>(
|
||||
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
||||
);
|
||||
|
||||
// in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
|
||||
// fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
|
||||
// The resync mount renders the live message instantly (duration 0).
|
||||
function inFade(node: Element) {
|
||||
const duration = instantOnce ? 0 : eff.fadeInMs;
|
||||
instantOnce = false;
|
||||
return fade(node, { duration });
|
||||
}
|
||||
|
||||
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
||||
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
||||
const host: BannerHost = {
|
||||
show(md) {
|
||||
current = md;
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
visible = true;
|
||||
},
|
||||
resetScroll() {
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
},
|
||||
scrollTo: (toPx, durationMs) => {
|
||||
hide() {
|
||||
visible = false;
|
||||
},
|
||||
overflowPx() {
|
||||
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
||||
},
|
||||
scrollTo(toPx, durationMs) {
|
||||
txDur = durationMs;
|
||||
tx = -toPx;
|
||||
},
|
||||
}, config);
|
||||
rotator.start();
|
||||
resumeScroll(fromTx, toPx, durationMs) {
|
||||
// Jump to the carried-over offset instantly, then continue to the end over the remaining
|
||||
// time — so a long message keeps its scroll position across a navigation.
|
||||
txDur = 0;
|
||||
tx = fromTx;
|
||||
requestAnimationFrame(() => {
|
||||
txDur = durationMs;
|
||||
tx = -toPx;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
|
||||
// before configure so the host is attached when the rotator first measures overflow.
|
||||
$effect(() => {
|
||||
attachBannerHost(host);
|
||||
return () => detachBannerHost(host);
|
||||
});
|
||||
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
|
||||
// unchanged, so a remount continues the running cycle rather than restarting it.
|
||||
$effect(() => {
|
||||
configureBanner(campaigns, eff);
|
||||
});
|
||||
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
||||
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
||||
$effect(() => {
|
||||
let t: ReturnType<typeof setTimeout>;
|
||||
const onResize = () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(remeasureBanner, 250);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
};
|
||||
});
|
||||
onDestroy(() => rotator?.stop());
|
||||
</script>
|
||||
|
||||
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
||||
@@ -45,20 +116,26 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||
{#key current}
|
||||
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
|
||||
collapses while the message layer is absent during the fade gap (the message is overlaid
|
||||
absolutely, so its presence/absence does not change the strip height). -->
|
||||
<span class="reserve" aria-hidden="true"> </span>
|
||||
{#if visible}
|
||||
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
|
||||
<div
|
||||
class="track"
|
||||
bind:this={track}
|
||||
in:fade={{ duration: config.fadeMs }}
|
||||
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||
>
|
||||
{@html linkify(items[current]?.md ?? '')}
|
||||
{@html linkify(current)}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ad {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 6px 0;
|
||||
@@ -71,6 +148,18 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
|
||||
fade gap; invisible and non-interactive. */
|
||||
.reserve {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* The message layer is overlaid on the reserved line, so showing/hiding it never resizes .ad. */
|
||||
.fadewrap {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.track {
|
||||
display: inline-block;
|
||||
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
|
||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||
|
||||
@@ -29,6 +31,16 @@
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||
it (banner under the title, board pinned to the bottom). -->
|
||||
{#if app.profile?.banner && app.profile.banner.campaigns.length}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
reduceMotion={app.reduceMotion}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Header from './Header.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
|
||||
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
||||
@@ -27,12 +26,6 @@
|
||||
column?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
|
||||
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
|
||||
// banner.ts logic — is dead-code-eliminated from the production bundle. Flip to
|
||||
// true to bring it back.
|
||||
const SHOW_AD_BANNER = false;
|
||||
|
||||
// Edge-swipe back: a rightward drag begun in the left band returns to `back`, the standard
|
||||
// mobile gesture (instant on release — the route slide plays the animation). Listened at the
|
||||
// window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the
|
||||
@@ -94,7 +87,6 @@
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} grow={growNav} />
|
||||
{#if SHOW_AD_BANNER}<AdBanner />{/if}
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
|
||||
@@ -276,6 +276,11 @@ function openStream(): void {
|
||||
if (e.sub === 'admin_reply') {
|
||||
app.feedbackReplyUnread = true;
|
||||
}
|
||||
// The viewer's ad-banner eligibility may have changed (paid/hints/no_banner): re-fetch
|
||||
// the profile, which carries the banner block, so the banner shows or hides in place.
|
||||
if (e.sub === 'banner') {
|
||||
void refreshProfile();
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
@@ -300,6 +305,20 @@ function scheduleReconnect(): void {
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshProfile re-fetches the account profile (which carries the ad-banner block),
|
||||
* after a `banner` notify signals the viewer's banner eligibility changed. Best-effort:
|
||||
* a transient failure leaves the previous profile in place.
|
||||
*/
|
||||
export async function refreshProfile(): Promise<void> {
|
||||
if (!app.session) return;
|
||||
try {
|
||||
app.profile = await gateway.profileGet();
|
||||
} catch {
|
||||
// Best-effort; the banner just stays as it was until the next fetch.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshNotifications recomputes the badge count (incoming friend requests).
|
||||
* Authoritative poll, complementing the live 'notify' push. Game invitations have
|
||||
|
||||
+258
-51
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
|
||||
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
||||
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
|
||||
import type { BannerTimings } from './model';
|
||||
|
||||
describe('linkify', () => {
|
||||
it('escapes html and renders markdown links', () => {
|
||||
@@ -20,71 +22,276 @@ describe('linkify', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('banner rotator', () => {
|
||||
describe('createScheduler', () => {
|
||||
it('serves each campaign its weight share over a cycle, evenly spread', () => {
|
||||
const sched = createScheduler([
|
||||
{ weight: 3, messages: ['a'] },
|
||||
{ weight: 7, messages: ['b'] },
|
||||
]);
|
||||
expect(sched.total).toBe(2);
|
||||
const cycle = Array.from({ length: 10 }, () => sched.next());
|
||||
const a = cycle.filter((m) => m === 'a').length;
|
||||
const b = cycle.filter((m) => m === 'b').length;
|
||||
expect(a).toBe(3);
|
||||
expect(b).toBe(7);
|
||||
// Smoothly interleaved, not clustered: the minority campaign appears in both halves.
|
||||
expect(cycle.slice(0, 5)).toContain('a');
|
||||
expect(cycle.slice(5)).toContain('a');
|
||||
});
|
||||
|
||||
it('round-robins messages within a campaign', () => {
|
||||
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
|
||||
expect(sched.total).toBe(3);
|
||||
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
|
||||
});
|
||||
|
||||
it('drops empty and non-positive-weight campaigns', () => {
|
||||
const sched = createScheduler([
|
||||
{ weight: 0, messages: ['skip-zero'] },
|
||||
{ weight: 5, messages: [] },
|
||||
{ weight: 2, messages: ['keep'] },
|
||||
]);
|
||||
expect(sched.total).toBe(1);
|
||||
expect(sched.next()).toBe('keep');
|
||||
});
|
||||
|
||||
it('is empty for no campaigns', () => {
|
||||
const sched = createScheduler([]);
|
||||
expect(sched.total).toBe(0);
|
||||
expect(sched.next()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// recordingHost captures the rotator's host calls as a sequence, with a fixed overflow.
|
||||
function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown: string[] } {
|
||||
const calls: string[] = [];
|
||||
const shown: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
shown,
|
||||
host: {
|
||||
show: (md) => {
|
||||
shown.push(md);
|
||||
calls.push('show');
|
||||
},
|
||||
resetScroll: () => calls.push('reset'),
|
||||
hide: () => calls.push('hide'),
|
||||
overflowPx: () => overflow,
|
||||
scrollTo: () => calls.push('scroll'),
|
||||
resumeScroll: () => calls.push('resume'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cfg: BannerTimings = {
|
||||
holdMs: 1000,
|
||||
edgePauseMs: 100,
|
||||
scrollPxPerSec: 200,
|
||||
fadeOutMs: 300,
|
||||
gapMs: 50,
|
||||
fadeInMs: 200,
|
||||
};
|
||||
|
||||
describe('createBannerRotator', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
|
||||
it('runs the fade-out / gap / fade-in sequence between two messages', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
|
||||
const shown: number[] = [];
|
||||
let scrolled = 0;
|
||||
const overflow = [0, 200]; // item 0 fits, item 1 overflows
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'a' }, { md: 'b' }],
|
||||
{
|
||||
overflowPx: (i) => overflow[i],
|
||||
show: (i) => shown.push(i),
|
||||
scrollTo: () => scrolled++,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
const { host, calls, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
|
||||
r.start();
|
||||
expect(shown).toEqual([0]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
|
||||
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
|
||||
expect(shown).toEqual([0, 1]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
|
||||
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
|
||||
expect(scrolled).toBe(1);
|
||||
expect(shown).toEqual(['a']); // first message shown immediately
|
||||
vi.advanceTimersByTime(cfg.fadeInMs); // fade in -> measure (fits)
|
||||
vi.advanceTimersByTime(cfg.holdMs - 1);
|
||||
expect(calls).not.toContain('hide'); // still holding
|
||||
vi.advanceTimersByTime(1); // hold elapsed -> fade out
|
||||
expect(calls).toContain('hide');
|
||||
expect(shown).toEqual(['a']); // next not shown yet (during fade-out + gap)
|
||||
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs); // -> show the next message
|
||||
expect(shown).toEqual(['a', 'b']);
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('repeats the scroll cycle while under holdMs, then advances', () => {
|
||||
it('scrolls an overflowing message', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 };
|
||||
const shown: number[] = [];
|
||||
let scrolls = 0;
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'long' }, { md: 'short' }],
|
||||
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
|
||||
cfg,
|
||||
);
|
||||
const { host, calls } = recordingHost(200); // overflow 200px, 200px/s -> 1000ms scroll
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['long'] }, { weight: 1, messages: ['x'] }], host, cfg);
|
||||
r.start();
|
||||
vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll
|
||||
expect(scrolls).toBe(1);
|
||||
expect(shown).toEqual([0]);
|
||||
vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll
|
||||
expect(scrolls).toBe(2);
|
||||
expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs)
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message
|
||||
vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
|
||||
expect(calls).toContain('scroll');
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('pulses a lone message through the fade cycle (fades out, then back in)', () => {
|
||||
vi.useFakeTimers();
|
||||
const { host, calls, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
|
||||
r.start();
|
||||
expect(shown).toEqual(['solo']);
|
||||
vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs); // hold elapses -> fade out
|
||||
expect(calls).toContain('hide');
|
||||
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs + 1); // -> the same message fades back in
|
||||
expect(shown).toEqual(['solo', 'solo']);
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('fades the scroll loop of a long message (out, rewind, in) instead of a hard reset', () => {
|
||||
vi.useFakeTimers();
|
||||
const c: BannerTimings = { holdMs: 30000, edgePauseMs: 100, scrollPxPerSec: 1000, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const { host, calls } = recordingHost(200); // overflows -> scroll dur = 200ms
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['long'] }], host, c);
|
||||
r.start();
|
||||
// fadeIn -> measure -> edgePause -> scrollTo -> (dur + edgePause) -> loop boundary
|
||||
vi.advanceTimersByTime(c.fadeInMs + c.edgePauseMs + 200 + c.edgePauseMs + 5);
|
||||
expect(calls).toContain('scroll');
|
||||
expect(calls).toContain('hide'); // the loop fades out at the right edge (not a hard reset)
|
||||
vi.advanceTimersByTime(c.fadeOutMs + c.gapMs + 5); // -> the same message fades back in
|
||||
expect(calls.filter((x) => x === 'show').length).toBeGreaterThanOrEqual(2);
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('swaps instantly when fade timings are zero (reduce-motion)', () => {
|
||||
vi.useFakeTimers();
|
||||
const zero: BannerTimings = { ...cfg, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 };
|
||||
const { host, shown } = recordingHost(0); // reduce-motion host reports no overflow
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, zero);
|
||||
r.start();
|
||||
// fadeIn(0) + hold -> fadeOut(0) + gap(0) -> next; the +5 flushes the trailing
|
||||
// zero-delay timer chain that lands on the hold boundary.
|
||||
vi.advanceTimersByTime(zero.holdMs + 5);
|
||||
expect(shown).toEqual(['a', 'b']);
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('stop() halts further advancement', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 };
|
||||
const shown: number[] = [];
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'a' }, { md: 'b' }],
|
||||
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
|
||||
cfg,
|
||||
);
|
||||
const { host, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
|
||||
r.start();
|
||||
vi.advanceTimersByTime(cfg.fadeMs);
|
||||
vi.advanceTimersByTime(cfg.fadeInMs);
|
||||
r.stop();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(shown).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does not start with no campaigns', () => {
|
||||
vi.useFakeTimers();
|
||||
const { host, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([], host, defaultBannerTimings);
|
||||
r.start();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(shown).toEqual([]);
|
||||
r.stop();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(shown).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bannerEngine', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
// A minimal host that records the messages it is told to show.
|
||||
function engineHost(): { host: BannerHost; shown: string[] } {
|
||||
const shown: string[] = [];
|
||||
return {
|
||||
shown,
|
||||
host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {}, resumeScroll() {} },
|
||||
};
|
||||
}
|
||||
|
||||
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
|
||||
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
|
||||
it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
|
||||
vi.useFakeTimers();
|
||||
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
||||
configureBanner(camps, cfg);
|
||||
const a = engineHost();
|
||||
attachBannerHost(a.host);
|
||||
// Advance mid-sequence so the live message is no longer the first.
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(bannerCurrent()).toBe('x1');
|
||||
|
||||
// Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
|
||||
// and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
|
||||
detachBannerHost(a.host);
|
||||
const b = engineHost();
|
||||
attachBannerHost(b.host);
|
||||
expect(bannerCurrent()).toBe('x1');
|
||||
expect(b.shown).toEqual([]);
|
||||
|
||||
// The engine keeps advancing and drives the new host — the cycle continued, not restarted.
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(b.shown).toContain('x0');
|
||||
expect(bannerCurrent()).toBe('x0');
|
||||
detachBannerHost(b.host);
|
||||
});
|
||||
|
||||
it('does not restart on a no-op reconfigure with unchanged data', () => {
|
||||
vi.useFakeTimers();
|
||||
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
|
||||
configureBanner(camps, cfg);
|
||||
const a = engineHost();
|
||||
attachBannerHost(a.host);
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(bannerCurrent()).toBe('y1');
|
||||
|
||||
// Re-configuring with identical data keeps the running cycle (the live message is unchanged).
|
||||
configureBanner(camps, cfg);
|
||||
vi.advanceTimersByTime(5);
|
||||
expect(bannerCurrent()).toBe('y1');
|
||||
detachBannerHost(a.host);
|
||||
});
|
||||
|
||||
it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
|
||||
vi.useFakeTimers();
|
||||
const big: BannerTimings = { ...cfg, holdMs: 5000 };
|
||||
const shown: string[] = [];
|
||||
let scrolled = 0;
|
||||
let overflow = 0;
|
||||
const host: BannerHost = {
|
||||
show: (md) => shown.push(md),
|
||||
resetScroll() {},
|
||||
hide() {},
|
||||
overflowPx: () => overflow,
|
||||
scrollTo: () => scrolled++,
|
||||
resumeScroll() {},
|
||||
};
|
||||
configureBanner([{ weight: 1, messages: ['z0'] }], big);
|
||||
attachBannerHost(host);
|
||||
vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
|
||||
expect(scrolled).toBe(0);
|
||||
|
||||
// The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
|
||||
// engages (the owner accepts the cycle restarting on a size change).
|
||||
overflow = 300;
|
||||
remeasureBanner();
|
||||
expect(shown).toContain('z0');
|
||||
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
|
||||
expect(scrolled).toBeGreaterThan(0);
|
||||
detachBannerHost(host);
|
||||
});
|
||||
|
||||
it('resumes a long message scroll mid-flight on a new host (navigation)', () => {
|
||||
vi.useFakeTimers();
|
||||
const big: BannerTimings = { holdMs: 10000, edgePauseMs: 100, scrollPxPerSec: 100, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const noop: BannerHost = { show() {}, resetScroll() {}, hide() {}, overflowPx: () => 500, scrollTo() {}, resumeScroll() {} };
|
||||
let resumed: { fromTx: number; toPx: number; dur: number } | null = null;
|
||||
const b: BannerHost = { ...noop, resumeScroll: (fromTx, toPx, dur) => (resumed = { fromTx, toPx, dur }) };
|
||||
|
||||
configureBanner([{ weight: 1, messages: ['scrollme'] }], big);
|
||||
attachBannerHost(noop);
|
||||
// fadeIn(100) + edgePause(100) -> scrollTo (dur = 500/100*1000 = 5000ms); then 2000ms into it.
|
||||
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 2000);
|
||||
|
||||
// Remount mid-scroll: the new host resumes from ~40% of the way, not from the start.
|
||||
detachBannerHost(noop);
|
||||
attachBannerHost(b);
|
||||
expect(resumed).not.toBeNull();
|
||||
expect(resumed!.toPx).toBe(500);
|
||||
expect(resumed!.fromTx).toBeLessThan(0); // already scrolled left of 0
|
||||
expect(resumed!.fromTx).toBeGreaterThan(-500); // but not yet at the end
|
||||
expect(resumed!.dur).toBeGreaterThan(0);
|
||||
expect(resumed!.dur).toBeLessThan(5000); // only the remaining time
|
||||
detachBannerHost(b);
|
||||
});
|
||||
});
|
||||
|
||||
+110
-63
@@ -1,60 +1,104 @@
|
||||
// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
|
||||
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
|
||||
// effects through callbacks), so its timing is unit-testable with fake timers. Today
|
||||
// the content is a mock long↔short rotation; later it becomes a server-driven
|
||||
// announcements channel (see ARCHITECTURE).
|
||||
// Advertising banner — the server-driven rotation engine plus a tiny markdown
|
||||
// linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow
|
||||
// and applies the visual effects through callbacks), so their timing and fairness are
|
||||
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
||||
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
||||
|
||||
export interface BannerConfig {
|
||||
/** How long one message is shown before advancing (short text), ms. */
|
||||
holdMs: number;
|
||||
/** Pause at each end before/after scrolling a long message, ms. */
|
||||
edgePauseMs: number;
|
||||
/** Scroll speed for a long (overflowing) message, px/sec. */
|
||||
scrollPxPerSec: number;
|
||||
/** Cross-fade duration between messages, ms. */
|
||||
fadeMs: number;
|
||||
}
|
||||
import type { BannerCampaign, BannerTimings } from './model';
|
||||
|
||||
export const defaultBannerConfig: BannerConfig = {
|
||||
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
||||
export const defaultBannerTimings: BannerTimings = {
|
||||
holdMs: 60_000,
|
||||
edgePauseMs: 5_000,
|
||||
scrollPxPerSec: 40,
|
||||
fadeMs: 400,
|
||||
fadeOutMs: 1_000,
|
||||
gapMs: 250,
|
||||
fadeInMs: 1_000,
|
||||
};
|
||||
|
||||
export interface BannerItem {
|
||||
/** Minimal markdown: plain text + `[label](url)` links. */
|
||||
md: string;
|
||||
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
||||
export interface Scheduler {
|
||||
next(): string;
|
||||
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
|
||||
/**
|
||||
* createScheduler builds a smooth weighted round-robin over campaigns: each call to
|
||||
* next() picks the campaign whose running weight is highest (then decremented by the
|
||||
* total weight), so over one cycle of `sum(weights)` picks every campaign appears
|
||||
* exactly its weight share, evenly interleaved rather than clustered (the nginx/envoy
|
||||
* algorithm). Within a campaign its messages advance round-robin. Campaigns with no
|
||||
* messages or a non-positive weight are dropped.
|
||||
*/
|
||||
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
||||
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
||||
const current = cs.map(() => 0); // SWRR running weights
|
||||
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
||||
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
||||
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
next(): string {
|
||||
if (cs.length === 0) return '';
|
||||
let best = 0;
|
||||
for (let i = 0; i < cs.length; i++) {
|
||||
current[i] += cs[i].weight;
|
||||
if (current[i] > current[best]) best = i;
|
||||
}
|
||||
current[best] -= totalWeight;
|
||||
const c = cs[best];
|
||||
const md = c.messages[cursor[best] % c.messages.length];
|
||||
cursor[best]++;
|
||||
return md;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
||||
export interface BannerHost {
|
||||
/** Overflow width of item `index` in px (0 when it fits). */
|
||||
overflowPx(index: number): number;
|
||||
/** Render item `index` (the host fades it in and resets scroll to the start). */
|
||||
show(index: number): void;
|
||||
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
|
||||
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
|
||||
show(md: string): void;
|
||||
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
||||
resetScroll(): void;
|
||||
/** Fade the current message out over durationMs. */
|
||||
hide(durationMs: number): void;
|
||||
/** Overflow width of the currently-shown message in px (0 when it fits or motion is reduced). */
|
||||
overflowPx(): number;
|
||||
/** Animate the horizontal scroll to toPx over durationMs. */
|
||||
scrollTo(toPx: number, durationMs: number): void;
|
||||
/** Resume an in-flight scroll on a freshly-mounted view: jump to fromTx (px, signed) instantly,
|
||||
* then continue to toPx over durationMs. Used by the engine on attach to carry the scroll
|
||||
* position across a navigation; the rotator itself never calls it. */
|
||||
resumeScroll(fromTx: number, toPx: number, durationMs: number): void;
|
||||
}
|
||||
|
||||
export interface Rotator {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
/** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the
|
||||
* viewport size changed. A no-op before start(). */
|
||||
restart(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
|
||||
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
|
||||
* repeats while the elapsed cycle is under `holdMs`, else advances.
|
||||
* createBannerRotator rotates the scheduled messages: each message fades in, holds
|
||||
* `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs),
|
||||
* then fades out over `fadeOutMs`, waits `gapMs`, and fades the next one in. A lone
|
||||
* message pulses (the same message fades back in) so the cycle keeps its rhythm.
|
||||
* Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that
|
||||
* returns 0, and messages swap instantly without scrolling.
|
||||
*/
|
||||
export function createBannerRotator(
|
||||
items: BannerItem[],
|
||||
campaigns: BannerCampaign[],
|
||||
host: BannerHost,
|
||||
config: BannerConfig = defaultBannerConfig,
|
||||
config: BannerTimings = defaultBannerTimings,
|
||||
): Rotator {
|
||||
let index = 0;
|
||||
const sched = createScheduler(campaigns);
|
||||
let running = false;
|
||||
let cycleStart = 0;
|
||||
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const at = (ms: number, fn: () => void) => {
|
||||
@@ -65,26 +109,25 @@ export function createBannerRotator(
|
||||
timers.length = 0;
|
||||
};
|
||||
|
||||
function advance() {
|
||||
if (!running) return;
|
||||
index = (index + 1) % items.length;
|
||||
present();
|
||||
}
|
||||
|
||||
function present() {
|
||||
function present(md: string) {
|
||||
if (!running) return;
|
||||
clear();
|
||||
host.show(index);
|
||||
// Let the swapped-in message render before measuring its overflow.
|
||||
at(config.fadeMs, () => {
|
||||
const over = host.overflowPx(index);
|
||||
if (over <= 0) {
|
||||
at(config.holdMs, advance);
|
||||
lastShown = md;
|
||||
host.show(md);
|
||||
at(config.fadeInMs, measure);
|
||||
}
|
||||
|
||||
function measure() {
|
||||
if (!running) return;
|
||||
cycleStart = Date.now();
|
||||
const over = host.overflowPx();
|
||||
if (over > 0) {
|
||||
scrollCycle(over);
|
||||
return;
|
||||
}
|
||||
cycleStart = Date.now();
|
||||
scrollCycle(over);
|
||||
});
|
||||
// Hold, then run the fade cycle. A lone message pulses (fades out, then back in) rather than
|
||||
// sitting frozen, so the banner keeps its rhythm even with a single campaign message.
|
||||
at(config.holdMs, advance);
|
||||
}
|
||||
|
||||
function scrollCycle(over: number) {
|
||||
@@ -95,24 +138,38 @@ export function createBannerRotator(
|
||||
if (Date.now() - cycleStart >= config.holdMs) {
|
||||
advance();
|
||||
} else {
|
||||
host.show(index); // resets scroll to the start
|
||||
scrollCycle(over);
|
||||
// Loop the same message with a fade: fade out at the right edge, rewind to the start
|
||||
// while hidden, fade the same message back in, then scroll again — so the rewind gets
|
||||
// the same fade as a message change, not a hard jump.
|
||||
host.hide(config.fadeOutMs);
|
||||
at(config.fadeOutMs + config.gapMs, () => {
|
||||
host.show(lastShown);
|
||||
at(config.fadeInMs, () => scrollCycle(over));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (!running) return;
|
||||
host.hide(config.fadeOutMs);
|
||||
at(config.fadeOutMs + config.gapMs, () => present(sched.next()));
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (running || items.length === 0) return;
|
||||
if (running || sched.total === 0) return;
|
||||
running = true;
|
||||
index = 0;
|
||||
present();
|
||||
present(sched.next());
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
clear();
|
||||
},
|
||||
restart() {
|
||||
if (running && lastShown) present(lastShown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,13 +202,3 @@ export function linkify(md: string): string {
|
||||
parts.push(escapeHtml(md.slice(last)));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
|
||||
export function mockBanners(): BannerItem[] {
|
||||
return [
|
||||
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
|
||||
{
|
||||
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// A single, persistent banner rotation engine. The rotator and scheduler live here at module
|
||||
// scope, so the rotation keeps running across screen navigations: each screen mounts its own
|
||||
// AdBanner view, which only attaches as the DOM host. This makes the banner continuous and
|
||||
// independent of route transitions (the cycle is not restarted on navigation).
|
||||
|
||||
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
|
||||
import type { BannerCampaign, BannerTimings } from './model';
|
||||
|
||||
let rotator: Rotator | null = null;
|
||||
let mounted: BannerHost | null = null;
|
||||
let current = '';
|
||||
let key = '';
|
||||
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
||||
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
||||
let activeScroll: { toPx: number; dur: number; start: number } | null = null;
|
||||
|
||||
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
||||
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
||||
const proxy: BannerHost = {
|
||||
show(md) {
|
||||
current = md;
|
||||
activeScroll = null;
|
||||
mounted?.show(md);
|
||||
},
|
||||
resetScroll() {
|
||||
activeScroll = null;
|
||||
mounted?.resetScroll();
|
||||
},
|
||||
hide(durationMs) {
|
||||
mounted?.hide(durationMs);
|
||||
},
|
||||
overflowPx() {
|
||||
return mounted?.overflowPx() ?? 0;
|
||||
},
|
||||
scrollTo(toPx, durationMs) {
|
||||
activeScroll = { toPx, dur: durationMs, start: Date.now() };
|
||||
mounted?.scrollTo(toPx, durationMs);
|
||||
},
|
||||
resumeScroll(fromTx, toPx, durationMs) {
|
||||
mounted?.resumeScroll(fromTx, toPx, durationMs);
|
||||
},
|
||||
};
|
||||
|
||||
// bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a
|
||||
// real change, not on every (re)mount.
|
||||
function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string {
|
||||
return JSON.stringify({ campaigns, timings });
|
||||
}
|
||||
|
||||
/**
|
||||
* configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the
|
||||
* running rotation untouched when they are unchanged — so navigating between screens (which
|
||||
* remounts the view with the same data) continues the cycle rather than restarting it.
|
||||
*/
|
||||
export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void {
|
||||
const k = bannerKey(campaigns, timings);
|
||||
if (k === key && rotator) return;
|
||||
key = k;
|
||||
rotator?.stop();
|
||||
current = '';
|
||||
rotator = createBannerRotator(campaigns, proxy, timings);
|
||||
rotator.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* bannerCurrent returns the message the engine is currently displaying (empty before the first
|
||||
* one). A freshly-mounted view reads it to render the live message immediately, without a fade —
|
||||
* so navigating between screens does not visibly restart the cycle.
|
||||
*/
|
||||
export function bannerCurrent(): string {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
||||
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
||||
* replay the fade-in on every navigation, which looks like the cycle restarting. The engine's own
|
||||
* timer keeps driving show/hide for real message changes through this host.
|
||||
*/
|
||||
export function attachBannerHost(host: BannerHost): void {
|
||||
mounted = host;
|
||||
// Resume the current message's scroll from where it had reached, so navigation does not restart
|
||||
// a long message at the left. Only while a scroll is still in flight; a finished scroll is left
|
||||
// at its end and the rotator's own loop takes over.
|
||||
if (activeScroll) {
|
||||
const elapsed = Date.now() - activeScroll.start;
|
||||
if (elapsed < activeScroll.dur) {
|
||||
const progress = elapsed / activeScroll.dur;
|
||||
host.resumeScroll(-activeScroll.toPx * progress, activeScroll.toPx, activeScroll.dur - elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* remeasureBanner re-presents the current message (re-measuring overflow and restarting its
|
||||
* scroll), for when the viewport size changed (e.g. a portrait↔landscape rotation) and a message
|
||||
* that fit may now overflow, or vice versa.
|
||||
*/
|
||||
export function remeasureBanner(): void {
|
||||
rotator?.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
* detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host
|
||||
* only when it is still the current one, so an outgoing view leaving after the incoming view has
|
||||
* already attached does not detach the new host (the transition briefly double-mounts).
|
||||
*/
|
||||
export function detachBannerHost(host: BannerHost): void {
|
||||
if (mounted === host) mounted = null;
|
||||
}
|
||||
@@ -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