db17287113
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.
176 lines
6.3 KiB
Svelte
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"> </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>
|