6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
214 lines
7.8 KiB
TypeScript
214 lines
7.8 KiB
TypeScript
// 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.
|
|
|
|
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
|
|
|
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
|
export const defaultBannerTimings: BannerTimings = {
|
|
holdMs: 60_000,
|
|
edgePauseMs: 5_000,
|
|
scrollPxPerSec: 40,
|
|
fadeOutMs: 1_000,
|
|
gapMs: 250,
|
|
fadeInMs: 1_000,
|
|
};
|
|
|
|
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
|
|
export interface BannerItem {
|
|
md: string;
|
|
colors: BannerColors;
|
|
}
|
|
|
|
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
|
export interface Scheduler {
|
|
next(): BannerItem;
|
|
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
|
readonly total: number;
|
|
}
|
|
|
|
/**
|
|
* 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 colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
|
|
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);
|
|
const empty: BannerColors = { all: null, dark: null };
|
|
|
|
return {
|
|
total,
|
|
next(): BannerItem {
|
|
if (cs.length === 0) return { md: '', colors: empty };
|
|
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, colors: colors[best] };
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
|
export interface BannerHost {
|
|
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
|
|
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
|
|
show(md: string, colors: BannerColors): 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 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(
|
|
campaigns: BannerCampaign[],
|
|
host: BannerHost,
|
|
config: BannerTimings = defaultBannerTimings,
|
|
): Rotator {
|
|
const sched = createScheduler(campaigns);
|
|
let running = false;
|
|
let cycleStart = 0;
|
|
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
const at = (ms: number, fn: () => void) => {
|
|
timers.push(setTimeout(fn, ms));
|
|
};
|
|
const clear = () => {
|
|
for (const t of timers) clearTimeout(t);
|
|
timers.length = 0;
|
|
};
|
|
|
|
function present(item: BannerItem) {
|
|
if (!running) return;
|
|
clear();
|
|
lastShown = item;
|
|
host.show(item.md, item.colors);
|
|
at(config.fadeInMs, measure);
|
|
}
|
|
|
|
function measure() {
|
|
if (!running) return;
|
|
cycleStart = Date.now();
|
|
const over = host.overflowPx();
|
|
if (over > 0) {
|
|
scrollCycle(over);
|
|
return;
|
|
}
|
|
// 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) {
|
|
const dur = (over / config.scrollPxPerSec) * 1000;
|
|
at(config.edgePauseMs, () => {
|
|
host.scrollTo(over, dur);
|
|
at(dur + config.edgePauseMs, () => {
|
|
if (Date.now() - cycleStart >= config.holdMs) {
|
|
advance();
|
|
} else {
|
|
// 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, () => {
|
|
if (lastShown) host.show(lastShown.md, lastShown.colors);
|
|
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 || sched.total === 0) return;
|
|
running = true;
|
|
present(sched.next());
|
|
},
|
|
stop() {
|
|
running = false;
|
|
clear();
|
|
},
|
|
restart() {
|
|
if (running && lastShown) present(lastShown);
|
|
},
|
|
};
|
|
}
|
|
|
|
const URL_RE = /^(https?:\/\/|\/)/i;
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
}
|
|
|
|
/**
|
|
* linkify renders minimal markdown to a safe HTML string: everything is escaped, then
|
|
* `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed).
|
|
*/
|
|
export function linkify(md: string): string {
|
|
const parts: string[] = [];
|
|
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(md)) !== null) {
|
|
parts.push(escapeHtml(md.slice(last, m.index)));
|
|
const label = escapeHtml(m[1]);
|
|
const url = m[2].trim();
|
|
if (URL_RE.test(url)) {
|
|
parts.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
|
|
} else {
|
|
parts.push(label);
|
|
}
|
|
last = re.lastIndex;
|
|
}
|
|
parts.push(escapeHtml(md.slice(last)));
|
|
return parts.join('');
|
|
}
|