feat(ui): route all in-app external links through Telegram openLink
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.
Outside Telegram every anchor keeps its native target=_blank.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
insideTelegram,
|
||||
routeExternalLinkInTelegram,
|
||||
telegramClosingConfirmation,
|
||||
telegramLaunch,
|
||||
telegramOpenExternalLink,
|
||||
@@ -121,3 +122,36 @@ describe('telegramOpenExternalLink', () => {
|
||||
expect(telegramOpenExternalLink('https://x.io')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeExternalLinkInTelegram', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubInside(openLink = vi.fn(), origin = 'https://app.example') {
|
||||
vi.stubGlobal('window', { Telegram: { WebApp: { initData: 'query_id=abc', openLink } } });
|
||||
vi.stubGlobal('location', { href: origin + '/', origin });
|
||||
return openLink;
|
||||
}
|
||||
|
||||
it('routes an external http(s) _blank link through openLink inside Telegram', () => {
|
||||
const openLink = stubInside();
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true);
|
||||
expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот');
|
||||
});
|
||||
|
||||
it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => {
|
||||
const openLink = stubInside();
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://t.me/some_bot', target: '_blank' })).toBe(false);
|
||||
expect(openLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves same-origin and non-_blank links to in-app navigation', () => {
|
||||
const openLink = stubInside(vi.fn(), 'https://app.example');
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false);
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru', target: '' })).toBe(false);
|
||||
expect(openLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing outside Telegram (the anchor opens natively)', () => {
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,48 @@ export function telegramOpenExternalLink(url: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the
|
||||
* app — so a same-origin or root-relative in-app (SPA) link is left to normal navigation, only a
|
||||
* genuinely external link is routed through Telegram's browser.
|
||||
*/
|
||||
function isExternalHttpUrl(href: string): boolean {
|
||||
try {
|
||||
const u = new URL(href, typeof location === 'undefined' ? undefined : location.href);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||
return typeof location === 'undefined' || u.origin !== location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* routeExternalLinkInTelegram decides whether a clicked anchor should be opened through the Mini
|
||||
* App SDK rather than the WebView's default navigation: only inside Telegram, and only for an
|
||||
* external http(s) link opened in a new tab (target=_blank) that is not a t.me link (those use
|
||||
* telegramOpenLink/openTelegramLink). Returns true when it opened the link through openLink — the
|
||||
* caller should then preventDefault — and false to let the browser handle the click normally.
|
||||
*/
|
||||
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
|
||||
if (!insideTelegram()) return false;
|
||||
if (anchor.target !== '_blank') return false;
|
||||
if (anchor.href.startsWith('https://t.me/')) return false;
|
||||
if (!isExternalHttpUrl(anchor.href)) return false;
|
||||
return telegramOpenExternalLink(anchor.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* onExternalLinkClick is the anchor click handler that applies routeExternalLinkInTelegram. It
|
||||
* resolves the clicked anchor with closest(), so it works both attached directly on an <a> and
|
||||
* delegated on a container that renders links (e.g. {@html} markdown). Attach it as onclick on an
|
||||
* external link or its container; outside Telegram it is a no-op and the anchor's own
|
||||
* target=_blank handles the click.
|
||||
*/
|
||||
export function onExternalLinkClick(e: MouseEvent): void {
|
||||
const a = (e.target as HTMLElement | null)?.closest('a');
|
||||
if (a && routeExternalLinkInTelegram(a)) e.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
|
||||
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
|
||||
|
||||
Reference in New Issue
Block a user