feat(04-04): the destination resolver — two carriers, one validation, memory only
- resolve-destination.ts: reads ?dest= (URL, primary — the inline web_app-button carrier) then initData's start_param (the signed string, secondary — the direct-link/home-screen carrier), ONE slug validation, dest_ prefix stripped; resolves against the webview's own build-time @araharah/search copy; honest null for unknown ids; module-scope memory-only holder (GEO-03/D-07) - destination-card.ts: the confirmation + honest-unavailable cards, createElement/textContent only, existing theme classes (T-04-32) - webapp.ts: TelegramWebAppLike gains startParam, addToHomeScreen?, checkHomeScreenStatus? + HomeScreenStatus (Bot API 8.0+ members) - telegram-mock.ts: satisfies the extended contract in the same change (startParam initData wiring, homeScreenStatus, __homeScreenAdded) - strings.ts: 5 new copy fields in en+ms; web depends on @araharah/search - DP1-DP12 green (45/45 web suite), web:typecheck green
This commit is contained in:
Generated
+1
@@ -3969,6 +3969,7 @@
|
|||||||
"web": {
|
"web": {
|
||||||
"name": "@araharah/web",
|
"name": "@araharah/web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@araharah/search": "*",
|
||||||
"@araharah/theme": "*",
|
"@araharah/theme": "*",
|
||||||
"maplibre-gl": "^6.15.0"
|
"maplibre-gl": "^6.15.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "^6.15.0",
|
"maplibre-gl": "^6.15.0",
|
||||||
"@araharah/theme": "*"
|
"@araharah/theme": "*",
|
||||||
|
"@araharah/search": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^8.3.5",
|
"vite": "^8.3.5",
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export interface ShellStrings {
|
|||||||
stripAwaitingPin: string;
|
stripAwaitingPin: string;
|
||||||
attribution: string;
|
attribution: string;
|
||||||
ariaRecenter: string;
|
ariaRecenter: string;
|
||||||
|
destinationHeadline: string;
|
||||||
|
destinationUnavailableHeadline: string;
|
||||||
|
destinationUnavailableBody: string;
|
||||||
|
addToHomeScreen: string;
|
||||||
|
homeScreenAlreadyAdded: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Locale = "en" | "ms";
|
export type Locale = "en" | "ms";
|
||||||
@@ -72,6 +77,11 @@ export const strings: Record<Locale, ShellStrings> = {
|
|||||||
stripAwaitingPin: "AWAITING PIN",
|
stripAwaitingPin: "AWAITING PIN",
|
||||||
attribution: "© OpenStreetMap contributors",
|
attribution: "© OpenStreetMap contributors",
|
||||||
ariaRecenter: "Recenter map",
|
ariaRecenter: "Recenter map",
|
||||||
|
destinationHeadline: "Destination",
|
||||||
|
destinationUnavailableHeadline: "Destination unavailable",
|
||||||
|
destinationUnavailableBody: "That place is not in this build. Search again from the bot.",
|
||||||
|
addToHomeScreen: "ADD TO HOME SCREEN",
|
||||||
|
homeScreenAlreadyAdded: "Already on your home screen",
|
||||||
},
|
},
|
||||||
ms: {
|
ms: {
|
||||||
bootValidatingRegister: "BOOT-01 // SESSION",
|
bootValidatingRegister: "BOOT-01 // SESSION",
|
||||||
@@ -100,6 +110,11 @@ export const strings: Record<Locale, ShellStrings> = {
|
|||||||
stripAwaitingPin: "AWAITING PIN",
|
stripAwaitingPin: "AWAITING PIN",
|
||||||
attribution: "© OpenStreetMap contributors",
|
attribution: "© OpenStreetMap contributors",
|
||||||
ariaRecenter: "Tetapkan semula peta",
|
ariaRecenter: "Tetapkan semula peta",
|
||||||
|
destinationHeadline: "Destinasi",
|
||||||
|
destinationUnavailableHeadline: "Destinasi tidak tersedia",
|
||||||
|
destinationUnavailableBody: "Tempat itu tiada dalam binaan ini. Cari semula dari bot.",
|
||||||
|
addToHomeScreen: "TAMBAH KE SKRIN UTAMA",
|
||||||
|
homeScreenAlreadyAdded: "Sudah di skrin utama anda",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,18 @@
|
|||||||
*
|
*
|
||||||
* This module ships nothing to production: `no-storage.test.mjs` and
|
* This module ships nothing to production: `no-storage.test.mjs` and
|
||||||
* the build-artifact gate both assert it is excluded from `web/dist`.
|
* the build-artifact gate both assert it is excluded from `web/dist`.
|
||||||
|
*
|
||||||
|
* Phase 4 (04-04): the mock satisfies the contract's three new OPTIONAL
|
||||||
|
* members in the SAME change that introduces them — `startParam` (appended
|
||||||
|
* to the fake initData as `&start_param=<encoded>`, the direct-link carrier
|
||||||
|
* the ONBD-02 harness leg exercises), `homeScreenStatus` (what the mock's
|
||||||
|
* checkHomeScreenStatus callback delivers; default "unknown", the iOS
|
||||||
|
* reality), and the addToHomeScreen stub whose `__homeScreenAdded` counter
|
||||||
|
* tests read (the `__events` / `__backButtonClicks` exposure precedent).
|
||||||
|
* The mock's initData is HMAC-unvalidated by design — the runner page
|
||||||
|
* stubs `/api/session`.
|
||||||
*/
|
*/
|
||||||
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
|
import type { TelegramWebAppLike, HomeScreenStatus } from "../telegram/webapp.ts";
|
||||||
|
|
||||||
export interface MockOptions {
|
export interface MockOptions {
|
||||||
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
|
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
|
||||||
@@ -40,6 +50,15 @@ export interface MockOptions {
|
|||||||
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
|
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
|
||||||
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
|
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
|
||||||
grantLocation?: boolean;
|
grantLocation?: boolean;
|
||||||
|
/** The direct-link start parameter (04-04): appended to the mock initData
|
||||||
|
* as `&start_param=<encoded>` — the SECONDARY destination carrier's
|
||||||
|
* harness leg. The URL query carrier needs no mock: page.goto preserves
|
||||||
|
* `?dest=` in window.location.search. */
|
||||||
|
startParam?: string | null;
|
||||||
|
/** What the mock's checkHomeScreenStatus callback delivers (04-04).
|
||||||
|
* Defaults to "unknown" — the documented iOS reality, treated by the
|
||||||
|
* app as "supported but indeterminate", never an error. */
|
||||||
|
homeScreenStatus?: HomeScreenStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockLocationManager {
|
interface MockLocationManager {
|
||||||
@@ -54,6 +73,7 @@ interface MockLocationManager {
|
|||||||
interface MockWebApp extends TelegramWebAppLike {
|
interface MockWebApp extends TelegramWebAppLike {
|
||||||
__events: Map<string, Array<() => void>>;
|
__events: Map<string, Array<() => void>>;
|
||||||
__backButtonClicks: Array<() => void>;
|
__backButtonClicks: Array<() => void>;
|
||||||
|
__homeScreenAdded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */
|
/* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */
|
||||||
@@ -131,12 +151,15 @@ let installed: MockWebApp | null = null;
|
|||||||
* localeFromInitData parses the real wire format instead of falling
|
* localeFromInitData parses the real wire format instead of falling
|
||||||
* back to "en" (locale = options.languageCode, defaulting to "en").
|
* back to "en" (locale = options.languageCode, defaulting to "en").
|
||||||
* Untouched when no languageCode is given (the plain-dev fallback mock
|
* Untouched when no languageCode is given (the plain-dev fallback mock
|
||||||
* keeps initData "" exactly as before).
|
* keeps initData "" exactly as before) — unless a startParam arrives
|
||||||
|
* (04-04), in which case the wire carries it as `&start_param=<encoded>`
|
||||||
|
* so the SECONDARY destination carrier is exercisable end-to-end.
|
||||||
*/
|
*/
|
||||||
function buildMockInitData(languageCode?: string): string {
|
function buildMockInitData(languageCode?: string, startParam?: string | null): string {
|
||||||
if (languageCode === undefined) return "";
|
if (languageCode === undefined && !startParam) return "";
|
||||||
const user = JSON.stringify({ language_code: languageCode, id: 1, first_name: "Harness" });
|
const user = JSON.stringify({ language_code: languageCode ?? "en", id: 1, first_name: "Harness" });
|
||||||
return `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`;
|
const base = `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`;
|
||||||
|
return startParam ? `${base}&start_param=${encodeURIComponent(startParam)}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
|
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
|
||||||
@@ -178,20 +201,31 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike
|
|||||||
};
|
};
|
||||||
const events = new Map<string, Array<() => void>>();
|
const events = new Map<string, Array<() => void>>();
|
||||||
const backButtonClicks: Array<() => void> = [];
|
const backButtonClicks: Array<() => void> = [];
|
||||||
|
let homeScreenAddedCount = 0;
|
||||||
|
const homeScreenStatus: HomeScreenStatus = options.homeScreenStatus ?? "unknown";
|
||||||
const fake: MockWebApp = {
|
const fake: MockWebApp = {
|
||||||
initData: buildMockInitData(options.languageCode),
|
initData: buildMockInitData(options.languageCode, options.startParam),
|
||||||
|
startParam: options.startParam ?? undefined,
|
||||||
colorScheme: options.colorScheme ?? "light",
|
colorScheme: options.colorScheme ?? "light",
|
||||||
themeParams: {},
|
themeParams: {},
|
||||||
version,
|
version,
|
||||||
platform: options.platform ?? "android",
|
platform: options.platform ?? "android",
|
||||||
__events: events,
|
__events: events,
|
||||||
__backButtonClicks: backButtonClicks,
|
__backButtonClicks: backButtonClicks,
|
||||||
|
__homeScreenAdded: 0,
|
||||||
isVersionAtLeast(v: string) {
|
isVersionAtLeast(v: string) {
|
||||||
return versionAtLeast(version, v);
|
return versionAtLeast(version, v);
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
/* the runner page listens to window-event hooks; nothing to close here */
|
/* the runner page listens to window-event hooks; nothing to close here */
|
||||||
},
|
},
|
||||||
|
addToHomeScreen() {
|
||||||
|
homeScreenAddedCount += 1;
|
||||||
|
fake.__homeScreenAdded = homeScreenAddedCount;
|
||||||
|
},
|
||||||
|
checkHomeScreenStatus(cb: (status: HomeScreenStatus) => void) {
|
||||||
|
setTimeout(() => cb(homeScreenStatus), 0);
|
||||||
|
},
|
||||||
onEvent(event: string, cb: () => void) {
|
onEvent(event: string, cb: () => void) {
|
||||||
const list = events.get(event) ?? [];
|
const list = events.get(event) ?? [];
|
||||||
list.push(cb);
|
list.push(cb);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* destination-card.ts — the ONBD-02 destination surface (D-11).
|
||||||
|
* -------------------------------------------------------------
|
||||||
|
* The webview's visible confirmation of a PRELOADED destination: the entry's
|
||||||
|
* canonical NAME and its curated ADDRESS (the address VERBATIM from the
|
||||||
|
* gazetteer — never the normalized form; matching normalizes, display does
|
||||||
|
* not, MS 2039 display rule / RESEARCH Pitfall 3). The honest counterpart
|
||||||
|
* renders when an id ARRIVED but does not resolve: "destination unavailable"
|
||||||
|
* — never a blank map (Phase 3 D-09 anti-blank invariant), never a
|
||||||
|
* fabricated coordinate (T-04-34).
|
||||||
|
*
|
||||||
|
* Built with createElement + textContent ONLY — an id-derived string never
|
||||||
|
* reaches innerHTML (T-04-32). Composed from the theme's existing component
|
||||||
|
* classes (.card / .card-header / .card-ref / .text-headline-md /
|
||||||
|
* .text-body-md / .text-body-sm) exactly as web/src/ui/cards.ts does — no
|
||||||
|
* new CSS, no off-token styling, no new screen: this is the Phase 3 card
|
||||||
|
* stack's existing surface carrying a different card. Every string comes
|
||||||
|
* from the strings module (COPY5 law).
|
||||||
|
*/
|
||||||
|
import type { GazetteerEntry } from "@araharah/search/src/types.ts";
|
||||||
|
import { strings, type Locale } from "../i18n/strings.ts";
|
||||||
|
|
||||||
|
/** The destination confirmation card: register line, name headline, the
|
||||||
|
* curated address verbatim. */
|
||||||
|
export function buildDestinationCard(locale: Locale, entry: GazetteerEntry): HTMLElement {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "card-header";
|
||||||
|
const ref = document.createElement("span");
|
||||||
|
ref.className = "card-ref";
|
||||||
|
ref.textContent = strings[locale].destinationHeadline;
|
||||||
|
header.appendChild(ref);
|
||||||
|
|
||||||
|
const headline = document.createElement("h2");
|
||||||
|
headline.className = "text-headline-md";
|
||||||
|
headline.textContent = entry.name;
|
||||||
|
|
||||||
|
const address = document.createElement("p");
|
||||||
|
address.className = "text-body-sm";
|
||||||
|
address.textContent = entry.address ?? "";
|
||||||
|
|
||||||
|
card.appendChild(header);
|
||||||
|
card.appendChild(headline);
|
||||||
|
card.appendChild(address);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The honest unavailable card: an id arrived and resolved to nothing.
|
||||||
|
* The boot continues into the normal staged boot around it. */
|
||||||
|
export function buildDestinationUnavailableCard(locale: Locale): HTMLElement {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "card-header";
|
||||||
|
const ref = document.createElement("span");
|
||||||
|
ref.className = "card-ref";
|
||||||
|
ref.textContent = strings[locale].destinationHeadline;
|
||||||
|
header.appendChild(ref);
|
||||||
|
|
||||||
|
const headline = document.createElement("h2");
|
||||||
|
headline.className = "text-headline-md";
|
||||||
|
headline.textContent = strings[locale].destinationUnavailableHeadline;
|
||||||
|
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.className = "text-body-md";
|
||||||
|
body.textContent = strings[locale].destinationUnavailableBody;
|
||||||
|
|
||||||
|
card.appendChild(header);
|
||||||
|
card.appendChild(headline);
|
||||||
|
card.appendChild(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* resolve-destination.ts — the memory-only destination resolver (ONBD-02, D-11).
|
||||||
|
* ------------------------------------------------------------------------------
|
||||||
|
* MECHANISM (corrected per RESEARCH Pitfall 1): the bot's OPEN button is a
|
||||||
|
* `web_app` inline button whose WebAppInfo carries ONLY a `url` — so the
|
||||||
|
* destination id arrives on TWO carriers, read here in a fixed order with ONE
|
||||||
|
* shared validation:
|
||||||
|
*
|
||||||
|
* 1. PRIMARY — the URL's own query string (`?dest=<id>`): the inline-button
|
||||||
|
* path. Telegram opens the button's URL verbatim in the webview.
|
||||||
|
* 2. SECONDARY — the `start_param` field of the SIGNED initData string: the
|
||||||
|
* documented direct-link (`t.me/<bot>?startapp=<id>`) and home-screen
|
||||||
|
* shortcut path.
|
||||||
|
*
|
||||||
|
* The id is a plain PUBLIC gazetteer id. NO crypto, NO signature, NO token
|
||||||
|
* anywhere on this path (D-11) — the payload's only threat is a user pranking
|
||||||
|
* themselves, and the bounded consequence is a different public destination in
|
||||||
|
* their own session. Only the SIGNED initData string is ever read (the signed
|
||||||
|
* string is what the server validates); its `start_param` field is treated as
|
||||||
|
* an UNTRUSTED payload and is used ONLY as an exact lookup key.
|
||||||
|
*
|
||||||
|
* WHAT is stored: the resolved destination entry, in module scope (closure
|
||||||
|
* over `let`). WHAT is never stored: anything else — and NO persistence call
|
||||||
|
* anywhere in this file (no storage surface of any kind; the grep gates pin
|
||||||
|
* this — DP9 and the no-storage suite). The destination is user context and
|
||||||
|
* lives in memory for this session only (GEO-03/D-07).
|
||||||
|
*
|
||||||
|
* THE HONEST-NULL LAW: an unknown or malformed id resolves to `null` — the
|
||||||
|
* caller renders the honest "destination unavailable" surface. Never a blank
|
||||||
|
* map (the Phase 3 D-09 anti-blank invariant binds here too), never a
|
||||||
|
* fabricated coordinate.
|
||||||
|
*/
|
||||||
|
import { getEntryById } from "@araharah/search/src/index.ts";
|
||||||
|
import type { GazetteerEntry } from "@araharah/search/src/types.ts";
|
||||||
|
|
||||||
|
/** Where the id came from — "none" means no id arrived at all. */
|
||||||
|
export type DestinationSource = "url" | "start-param" | "none";
|
||||||
|
|
||||||
|
export interface ResolvedDestination {
|
||||||
|
entry: GazetteerEntry;
|
||||||
|
source: DestinationSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared id validation (BOTH carriers): slug charset, 1..64 chars. */
|
||||||
|
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
|
/** Strip one leading `dest_` (D-11's literal `dest_<id>` wire form) and
|
||||||
|
* validate the remainder. Any failure returns null — an id that fails the
|
||||||
|
* charset check is treated as absent, never partially trusted (T-04-32). */
|
||||||
|
function cleanId(raw: string): string | null {
|
||||||
|
const stripped = raw.startsWith("dest_") ? raw.slice("dest_".length) : raw;
|
||||||
|
return ID_PATTERN.test(stripped) ? stripped : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `start_param` from the SIGNED initData string — parsed the way
|
||||||
|
* bot/src/security/initdata.ts parses fields: split on `&`, single
|
||||||
|
* decodeURIComponent. `+` stays literal (never URLSearchParams on initData —
|
||||||
|
* the signed string's encoding differs from a URL query). An undecodable
|
||||||
|
* percent-escape is treated as absent. */
|
||||||
|
function startParamFromInitData(initData: string | null | undefined): string | null {
|
||||||
|
if (!initData) return null;
|
||||||
|
for (const pair of initData.split("&")) {
|
||||||
|
if (!pair.startsWith("start_param=")) continue;
|
||||||
|
const value = pair.slice("start_param=".length);
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the destination id from BOTH carriers (mechanism corrected per
|
||||||
|
* RESEARCH Pitfall 1). Primary: the URL's own query string (`?dest=<id>`) —
|
||||||
|
* the inline-button path. Secondary: initData's start_param — the
|
||||||
|
* documented direct-link / home-screen path. Both are validated against
|
||||||
|
* the same charset; a leading `dest_` is stripped. */
|
||||||
|
export function readDestinationId(
|
||||||
|
search: string,
|
||||||
|
initData: string | null | undefined,
|
||||||
|
): { id: string | null; source: DestinationSource } {
|
||||||
|
const fromUrl = new URLSearchParams(search).get("dest");
|
||||||
|
const cleaned = fromUrl === null ? null : cleanId(fromUrl);
|
||||||
|
if (cleaned !== null) return { id: cleaned, source: "url" };
|
||||||
|
const fromStart = startParamFromInitData(initData);
|
||||||
|
const cleanedStart = fromStart === null ? null : cleanId(fromStart);
|
||||||
|
if (cleanedStart !== null) return { id: cleanedStart, source: "start-param" };
|
||||||
|
return { id: null, source: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an id against the webview's OWN build-time copy of the shared
|
||||||
|
* gazetteer (`@araharah/search` — the same committed dataset the bot
|
||||||
|
* bundles, so the id mapping stays in sync by construction, D-08/D-11).
|
||||||
|
* Returns null for an unknown id — the honest null (never a fabricated
|
||||||
|
* coordinate, T-04-34). An exact-key match only: never a substring, never
|
||||||
|
* a path join, never a guess. */
|
||||||
|
export function resolveDestination(id: string | null): GazetteerEntry | null {
|
||||||
|
if (id === null) return null;
|
||||||
|
return getEntryById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The memory-only holder: the resolved destination lives in module scope,
|
||||||
|
* nowhere else (GEO-03/D-07). Seeded ONCE at boot by main.ts. */
|
||||||
|
let destination: GazetteerEntry | null = null;
|
||||||
|
|
||||||
|
export function setDestination(entry: GazetteerEntry | null): void {
|
||||||
|
destination = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDestination(): GazetteerEntry | null {
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
@@ -12,9 +12,27 @@
|
|||||||
* that null is the contract the location chain must handle. All
|
* that null is the contract the location chain must handle. All
|
||||||
* `Telegram.WebApp.*` calls must originate from the app's own document
|
* `Telegram.WebApp.*` calls must originate from the app's own document
|
||||||
* (Bot API 10.2 origin restriction).
|
* (Bot API 10.2 origin restriction).
|
||||||
|
*
|
||||||
|
* Phase 4 (ONBD-02/ONBD-03) grows the contract by exactly three OPTIONAL
|
||||||
|
* members: `startParam` (the documented direct-link start parameter — NOT
|
||||||
|
* populated by an inline web_app button, whose WebAppInfo carries only a
|
||||||
|
* `url`), `addToHomeScreen` and `checkHomeScreenStatus` (both Bot API 8.0+
|
||||||
|
* only; on iOS the status is ALWAYS "unknown"). The hand-rolled contract
|
||||||
|
* stays the contract — `@types/telegram-web-app` remains deliberately
|
||||||
|
* NOT a dependency; every test fake and the harness mock satisfy exactly
|
||||||
|
* these members.
|
||||||
*/
|
*/
|
||||||
|
export type HomeScreenStatus =
|
||||||
|
| "unsupported" // the feature is not supported here
|
||||||
|
| "unknown" // supported, but whether the icon is installed is indeterminate (always iOS)
|
||||||
|
| "added" // already on the home screen
|
||||||
|
| "missed"; // not added yet
|
||||||
|
|
||||||
export interface TelegramWebAppLike {
|
export interface TelegramWebAppLike {
|
||||||
initData: string;
|
initData: string;
|
||||||
|
/** The documented direct-link start parameter — NOT populated by an
|
||||||
|
* inline web_app button (its WebAppInfo carries only a `url`). */
|
||||||
|
startParam?: string;
|
||||||
colorScheme?: "light" | "dark";
|
colorScheme?: "light" | "dark";
|
||||||
themeParams?: { header_bg_color?: string };
|
themeParams?: { header_bg_color?: string };
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -23,6 +41,10 @@ export interface TelegramWebAppLike {
|
|||||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
isVersionAtLeast(version: string): boolean;
|
isVersionAtLeast(version: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
|
/** Bot API 8.0+ only. Optional — absent on older clients. */
|
||||||
|
addToHomeScreen?(): void;
|
||||||
|
/** Bot API 8.0+ only. On iOS this ALWAYS reports "unknown". */
|
||||||
|
checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void;
|
||||||
onEvent?(event: string, cb: () => void): void;
|
onEvent?(event: string, cb: () => void): void;
|
||||||
offEvent?(event: string, cb: () => void): void;
|
offEvent?(event: string, cb: () => void): void;
|
||||||
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
|
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
|
||||||
|
|||||||
Reference in New Issue
Block a user