Files
scrabble-game/ui/src/components/AdBanner.svelte
T
Ilia Denisov db17287113 fix(ui): route external links out of the Android VK WebView
The Android VK client's WebView ignores target=_blank and navigates the
Mini App's own window to the target, stranding the player outside the
game with no way back (the dictionary lookup, About/Feedback links, the
ad banner and the bot-link modal fallbacks). vk-bridge 3.x has no method
to open an external URL, so external links are routed through VK's own
leave-VK redirect (vk.com/away.php), which the client intercepts
natively and hands to the system browser.

onExternalLinkClick moves from lib/telegram to a new lib/links that
composes the Telegram and VK routers; iOS and desktop VK open _blank
correctly and are left alone.
2026-07-02 00:09:52 +02:00

176 lines
6.3 KiB
Svelte

<script lang="ts">
import { fade } from 'svelte/transition';
import {
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/links';
let {
campaigns,
timings = defaultBannerTimings,
reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
// 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>();
// 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() !== '';
// 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;
},
hide() {
visible = false;
},
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
},
scrollTo(toPx, durationMs) {
txDur = durationMs;
tx = -toPx;
},
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);
};
});
</script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<!-- 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">&nbsp;</span>
{#if visible}
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
<div
class="track"
bind:this={track}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(current)}
</div>
</div>
{/if}
</div>
<style>
.ad {
position: relative;
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
background: var(--ad-bg);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.2;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
border-top: 1px solid var(--border);
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
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad);
will-change: transform;
}
.track :global(a) {
color: var(--accent);
text-decoration: underline;
}
</style>