feat: tune deeplink fallback page
This commit is contained in:
@@ -59,6 +59,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
|
||||
@@ -1000,6 +1000,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
|
||||
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
|
||||
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
|
||||
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
|
||||
APP_DEEPLINK_I18N_KEYS = {
|
||||
"title": "wa_app_launch_title",
|
||||
"hint": "wa_app_launch_opening_hint",
|
||||
"manualHint": "wa_app_launch_hint",
|
||||
"button": "wa_app_launch_button",
|
||||
"retryButton": "wa_app_launch_retry_button",
|
||||
"doneTitle": "wa_app_launch_done_title",
|
||||
"doneHint": "wa_app_launch_done_hint",
|
||||
"closeButton": "wa_app_launch_close_button",
|
||||
"unavailableTitle": "wa_app_launch_unavailable_title",
|
||||
"unavailableHint": "wa_app_launch_unavailable_hint",
|
||||
}
|
||||
APP_DEEPLINK_I18N_FALLBACKS = {
|
||||
"wa_app_launch_title": "Opening app",
|
||||
"wa_app_launch_opening_hint": "Opening the app on this device...",
|
||||
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
|
||||
"wa_app_launch_button": "Open app",
|
||||
"wa_app_launch_retry_button": "Open again",
|
||||
"wa_app_launch_done_title": "Settings added",
|
||||
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
|
||||
"wa_app_launch_close_button": "Close window",
|
||||
"wa_app_launch_unavailable_title": "App link unavailable",
|
||||
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
|
||||
}
|
||||
|
||||
|
||||
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
|
||||
@@ -1199,124 +1223,45 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||
title = html.escape(str(getattr(settings, "WEBAPP_TITLE", "") or "Subscription"), quote=True)
|
||||
html_text = f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} - Opening app</title>
|
||||
<style nonce="{nonce}">
|
||||
:root {{
|
||||
color-scheme: dark light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #0b1017;
|
||||
color: #f7fafc;
|
||||
}}
|
||||
|
||||
body {{
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
main {{
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
|
||||
p {{
|
||||
margin: 0;
|
||||
color: #aeb8c5;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}}
|
||||
|
||||
a.button {{
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #14b86f;
|
||||
color: #03120b;
|
||||
padding: 0 18px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}}
|
||||
|
||||
a.button[aria-disabled="true"] {{
|
||||
pointer-events: none;
|
||||
background: #344052;
|
||||
color: #aeb8c5;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Opening app</h1>
|
||||
<p id="status">If nothing happened, tap the button below.</p>
|
||||
<a id="open-link" class="button" href="#" rel="noreferrer">Open app</a>
|
||||
</main>
|
||||
<script nonce="{nonce}">
|
||||
(() => {{
|
||||
const statusEl = document.getElementById("status");
|
||||
const openLink = document.getElementById("open-link");
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const target = String(params.get("url") || "").trim();
|
||||
const isUnsafe =
|
||||
!target ||
|
||||
hasControlChars(target) ||
|
||||
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||
|
||||
function hasControlChars(value) {{
|
||||
return Array.from(String(value || "")).some((char) => {{
|
||||
const code = char.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
}});
|
||||
}}
|
||||
|
||||
function openTarget() {{
|
||||
if (isUnsafe) return;
|
||||
window.location.href = target;
|
||||
}}
|
||||
|
||||
if (isUnsafe) {{
|
||||
statusEl.textContent = "The app link is unavailable.";
|
||||
openLink.setAttribute("aria-disabled", "true");
|
||||
openLink.removeAttribute("href");
|
||||
return;
|
||||
}}
|
||||
|
||||
openLink.href = target;
|
||||
openLink.addEventListener("click", (event) => {{
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
}});
|
||||
|
||||
window.setTimeout(openTarget, 80);
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
query = getattr(request, "query", {}) or {}
|
||||
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
||||
messages = _app_deeplink_i18n_payload(request, lang)
|
||||
page_title = html.escape(
|
||||
f"{getattr(settings, 'WEBAPP_TITLE', '') or 'Subscription'} - {messages['title']}",
|
||||
quote=False,
|
||||
)
|
||||
messages_json = json.dumps(
|
||||
messages,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).replace("</", "<\\/")
|
||||
html_text = (
|
||||
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
|
||||
.replace("__LANG__", html.escape(lang, quote=True))
|
||||
.replace("__PAGE_TITLE__", page_title)
|
||||
.replace("__NONCE__", nonce)
|
||||
.replace("__MESSAGES_JSON__", messages_json)
|
||||
)
|
||||
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _app_deeplink_i18n_payload(request: web.Request, lang: str) -> Dict[str, str]:
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
payload: Dict[str, str] = {}
|
||||
for payload_key, i18n_key in APP_DEEPLINK_I18N_KEYS.items():
|
||||
fallback = APP_DEEPLINK_I18N_FALLBACKS[i18n_key]
|
||||
value = ""
|
||||
if i18n_instance is not None:
|
||||
try:
|
||||
value = str(i18n_instance.gettext(lang, i18n_key) or "")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve open-app i18n key %s: %s", i18n_key, exc)
|
||||
payload[payload_key] = value if value and value != i18n_key else fallback
|
||||
return payload
|
||||
|
||||
|
||||
async def _serve_template_asset(
|
||||
request: web.Request,
|
||||
filename: str,
|
||||
|
||||
Reference in New Issue
Block a user