(function () { if (window.NativeBridge) return; var pending = {}; var listeners = {}; var seq = 0; function nextId() { seq += 1; return "b" + Date.now().toString(36) + seq.toString(36); } // Reported once, not per animation: it is a constant for the life of the document, // and the whole point is that the shared start time costs no round trip when it // matters. function reportClock() { call("log.clock", { timeOrigin: performance.timeOrigin, now: performance.now(), }); } function call(method, params) { return new Promise(function (resolve, reject) { var id = nextId(); pending[id] = { resolve: resolve, reject: reject }; try { window.webkit.messageHandlers.nativeBridge.postMessage({ id: id, method: method, params: params || {}, }); } catch (e) { delete pending[id]; reject(new Error("NativeBridge unavailable: " + e.message)); } }); } function handleResponse(id, ok, result, error) { var p = pending[id]; if (!p) return; delete pending[id]; if (ok) p.resolve(result); else p.reject(new Error(error || "Native error")); } function handleEvent(name, payload) { (listeners[name] || []).forEach(function (fn) { try { fn(payload); } catch (e) { /* ignore listener errors */ } }); } function on(name, fn) { listeners[name] = listeners[name] || []; listeners[name].push(fn); return function off() { listeners[name] = (listeners[name] || []).filter(function (f) { return f !== fn; }); }; } // Scene-graph events all arrive on one native channel and are routed here by // (node id, event type), so a page registers per-node handlers instead of // filtering one firehose itself. "" joins the two because it cannot // appear in a developer-chosen id. var glassHandlers = {}; function dispatchGlassEvent(payload) { if (!payload || !payload.id) return; var exact = glassHandlers[payload.id + "" + payload.type] || []; var wildcard = glassHandlers["*" + payload.type] || []; exact.concat(wildcard).forEach(function (fn) { try { fn(payload); } catch (e) { /* one bad handler must not stop the rest */ } }); } window.NativeBridge = { __handleResponse: handleResponse, __handleEvent: handleEvent, ready: true, // haptics — the Taptic Engine. Feedback you feel rather than see, for the // moment an action lands. Free of charge and easy to overdo: one tap per real // event, never per frame. haptics: { // A physical tap through the Taptic Engine. `style` is "light" | "medium" | // "heavy" | "rigid" | "soft"; anything else is treated as "medium". impact: function (style) { return call("haptics.impact", { style: style || "medium" }); }, // The heavier two-part pattern iOS uses to report an outcome. `type` is // "success" | "warning" | "error"; anything else is treated as "success". notification: function (type) { return call("haptics.notification", { type: type || "success" }); }, // The faint tick for a value changing under the finger — a picker moving to // the next item, a segmented control switching. Not for taps. selection: function () { return call("haptics.selection", {}); }, }, // permissions — what this mini-app may ask the device for. The set is fixed when // the mini-app is registered on the portal; nothing here can widen it at runtime, // only prompt for what was already declared. permissions: { // Asks the system for a capability, showing iOS's own prompt the first time. // Rejects if the mini-app did not declare `name` when it was registered: a // permission you did not ask for at registration cannot be requested at // runtime. Resolves once the user has answered. request: function (name) { return call("permissions.request", { name: name }); }, // Whether a capability is available, without prompting. Resolves to // `{ status }` — "granted" | "denied" | "restricted" | "notDetermined" for // camera and microphone, which the system tracks; "declared" or // "not-declared" for the rest, which only depend on registration. status: function (name) { return call("permissions.status", { name: name }); }, }, // biometrics — Face ID and Touch ID inside an open mini-app. biometrics: { // Face ID or Touch ID, inside an already-open mini-app. `reason` is shown in // the system prompt. Resolves to `{ success }`, and `{ success: false, error }` // when the user cancels or biometrics are unavailable — it does not reject, so // a refusal is handled like any other answer. // // This gates something *within* your page. To require Face ID before the // mini-app opens at all, ask the portal to set lockOnOpen: that runs before // your page exists, and there is no way to reach it from here. // Requires the "faceid" permission. authenticate: function (reason) { return call("biometrics.authenticate", { reason: reason || "Подтвердите действие" }); }, }, // bluetooth — Bluetooth LE: scan, connect, read, write. Peripherals are addressed // by the id `onDiscover` reports, and everything a device sends back — whether // from a read or a notification — arrives through `onValueChange`. bluetooth: { // Starts discovering nearby Bluetooth LE peripherals; results arrive through // `onDiscover`. Pass an array of service UUID strings to see only devices // advertising those, or omit it for everything. Scanning is power-hungry — // call `stopScan` as soon as you have what you need. // Requires the "bluetooth" permission. startScan: function (serviceUUIDs) { return call("bluetooth.startScan", { serviceUUIDs: serviceUUIDs || [] }); }, // Stops discovery. Already-connected devices stay connected. stopScan: function () { return call("bluetooth.stopScan", {}); }, // Connects to a peripheral by the id `onDiscover` reported. Progress and // failure both arrive through `onConnectionChange`. connect: function (deviceId) { return call("bluetooth.connect", { deviceId: deviceId }); }, // Drops the connection to one peripheral. disconnect: function (deviceId) { return call("bluetooth.disconnect", { deviceId: deviceId }); }, // Writes to one characteristic. The value is base64 because the bridge carries // JSON, which has no way to express raw bytes. writeCharacteristic: function (deviceId, serviceUUID, characteristicUUID, base64Value) { return call("bluetooth.write", { deviceId: deviceId, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID, value: base64Value }); }, // Reads one characteristic once. The value comes back through `onValueChange`, // the same path notifications use, so both are handled in one place. readCharacteristic: function (deviceId, serviceUUID, characteristicUUID) { return call("bluetooth.read", { deviceId: deviceId, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID }); }, // Called for each peripheral found while scanning. onDiscover: function (fn) { return on("bluetooth.discovered", fn); }, // Called when a peripheral connects or disconnects, including disconnections // you did not ask for — a device going out of range or losing power. onConnectionChange: function (fn) { return on("bluetooth.connectionChange", fn); }, // Called with a characteristic's value, from either a read or a notification. onValueChange: function (fn) { return on("bluetooth.valueChange", fn); }, }, // share — the system share sheet. share: { // The system share sheet. Either argument may be omitted, but not both. // Resolves to `{ shared }` — false when the user dismisses the sheet. // Requires the "share" permission. open: function (text, url) { return call("share.open", { text: text || "", url: url || "" }); }, }, // home — this mini-app's own icon on the iOS Home Screen. // // iOS lets no app place an icon on the Home Screen — not this launcher, not // Apple's own apps. The only route is Safari's "Add to Home Screen", so `add` // sends the user to Safari on a page built to be clipped, and the icon it // produces opens this mini-app directly. // // Which means two things worth designing around. The user leaves your page to // finish the job, so call this from a deliberate tap and not on load. And // nobody — not the launcher, not Safari — can report back whether they went // through with it, which is what `check` says. home: { // Hands off to Safari with the "Add to Home Screen" instructions and this // mini-app's icon ready to be clipped. Resolves as soon as the hand-off is // made, not when the user is done: what happens in Safari is not observable // from here. Ignored while the mini-app is not the page on screen. add: function () { return call("home.add", {}); }, // Resolves to `{ status: "unknown" }` — always, on every iOS version. There is // no API that can be asked whether a web clip exists. It is here so the answer // is an explicit "cannot be known" rather than a missing method, and so that a // page written against it keeps working if that ever changes. check: function () { return call("home.check", {}); }, }, // clipboard — the system clipboard, shared with every other app on the device. clipboard: { // Puts text on the system clipboard, where every other app can read it. // Requires the "clipboard" permission. writeText: function (text) { return call("clipboard.writeText", { text: text }); }, // Reads the system clipboard. On iOS this may show the paste banner or prompt, // which is the system's decision and not something a mini-app can suppress. // Requires the "clipboard" permission. readText: function () { return call("clipboard.readText", {}); }, }, // contacts — one contact, chosen deliberately by the user in the system picker. // There is no way to read the address book. contacts: { // Opens the system contact picker and resolves to the one the user chose, or // to a cancelled result if they close it. Nothing is read from the address // book beyond that single deliberate choice. // Requires the "contacts" permission. pick: function () { return call("contacts.pick", {}); }, }, // photos — one image, chosen by the user in the system picker. photos: { // Resolves to { base64, mimeType } for the picked image, or { cancelled: true }. // No native Photos permission dialog — PHPickerViewController never gives this // app access to the library itself, only to whatever single image the user // themselves taps inside Apple's own picker UI. pick: function () { return call("photos.pick", {}); }, }, // On-device only — scheduled locally, delivered even if the mini-app (and this // launcher) is fully closed by then, same as any installed app's own reminders. // Not the same thing as a server waking a closed app on its own (real remote // push) — that needs infrastructure this launcher doesn't have wired up. push: { // A local notification from this mini-app, `delaySeconds` from now. Local // only: the launcher has no server to push from, so nothing arrives unless the // device scheduled it itself. // Requires the "push" permission. schedule: function (title, body, delaySeconds) { return call("push.schedule", { title: title, body: body, delaySeconds: delaySeconds || 1 }); }, // Cancels every notification this mini-app has scheduled and not yet delivered. cancelAll: function () { return call("push.cancelAll", {}); }, }, // device — what this mini-app is running on, and what it was allowed to ask for. device: { // Resolves to `{ appName, permissions, systemVersion, model, interfaceStyle }`. // `permissions` is what this mini-app declared at registration, which is the // reliable way to find out what it is allowed to ask for. info: function () { return call("device.info", {}); }, // Called when the system switches between light and dark. The current value is // in `device.info().interfaceStyle`. onThemeChange: function (fn) { return on("device.themeChange", fn); }, }, // ======================================================================== // lifecycle — being put away and brought back. // ======================================================================== // // Closing a mini-app does not tear it down. The page stays loaded, with its // scroll position, its open screen and anything typed into it intact, and // reopening it hands the same page back rather than reloading — closer to // switching away from an app on iOS than to quitting one. // // Which means your page keeps running while nobody is looking at it, and that // its load handlers do not run again when it comes back. Both are worth // handling: stop timers, polling and animation on suspend, and refresh anything // that goes stale on resume. // // NativeBridge.lifecycle.onSuspend(function () { clearInterval(poll); }); // NativeBridge.lifecycle.onResume(function () { poll = setInterval(tick, 5000); refresh(); }); // // Native UI you asked for — a toolbar, glass controls, edge bands — is restored // for you, so there is no need to describe it again on resume. // // A mini-app that requires Face ID to open is never suspended: handing back a // screen that is already past its own lock would defeat the lock. lifecycle: { // Called when the mini-app is closed but kept alive in the background. onSuspend: function (fn) { window.addEventListener("nativeappsuspend", fn); return function () { window.removeEventListener("nativeappsuspend", fn); }; }, // Called when a suspended mini-app is reopened. Never fires on a fresh load — // that case is your ordinary page load. onResume: function (fn) { window.addEventListener("nativeappresume", fn); return function () { window.removeEventListener("nativeappresume", fn); }; }, }, // Fire-and-forget diagnostics forwarded to the same log stream native-side events // land in. Not meant for mini-app authors to call directly — the launcher's own // injected scripts use it to report console errors and rendering liveness. debug: { // Writes a line to the launcher's device log, alongside its own diagnostics. // For working out what a page did on a real device, where there is no console // to open. log: function (event, data) { return call("log.event", { event: event, data: data || {} }); }, }, // ======================================================================== // glass — the general-purpose layer. // ======================================================================== // // Everything in `ui` below is a *fixed component*: the launcher decides what // a badge is, where the seven anchors are, which single spring an update // animates with. This is the other option — a real native view tree you // describe yourself, with real Auto Layout, real animations on any property // with your own timing, and real gestures. Nothing here is a preset; if you // could build it in a native app, you can build it here. // // ---- The tree ---- // glass.render(node) replaces the whole scene. Every node is: // { // id: "unique", // how you address it later // type: "view" | "glass" | "glassContainer" | "stack" // | "text" | "icon" | "image" | "input" | "spacer" | "edgeEffect", // layout: { ... }, // see below // material: { ... }, // "glass"/"glassContainer" only // gestures: ["tap","longPress","pan","pinch"], // opacity, hidden, zIndex, transform, // children: [ ...nodes ] // } // // "glass" is a real UIGlassEffect surface. "glassContainer" is a real // UIGlassContainerEffect: glass children inside it MERGE into one another as // they come within `spacing` points — the liquid-metaball behaviour you see // in the system tab bar. That is not expressible with `ui` at all. // // ---- Layout ---- // Auto Layout, exposed directly. Any of: // top, bottom, leading, trailing, centerX, centerY, width, height, // aspectRatio, hugging, compressionResistance // Each takes either: // 42 — points, relative to the parent's same edge // (for width/height: a fixed size) // "50%" — a fraction of the parent // { ref, anchor, offset, multiplier } // // `ref` is another node's id, or one of: // "@parent" the containing node (the default) // "@safeArea" the mini-app's safe area // "@screen" the whole screen, ignoring safe area // "@keyboard" a live guide whose top edge tracks the keyboard // // Referencing another node is the important one. "Keep these buttons level // with that field" is a constraint — `{ ref: "field", anchor: "centerY" }` — // resolved in one layout pass, not arithmetic your page performs against // constants and then re-applies on a second timer. They cannot drift apart, // because there is no second clock. The same goes for "@keyboard": pinning // above the keyboard is declarative, and moves inside the keyboard's own // animation. // // ---- Edge effects (the soft band at the top/bottom of a chat) ---- // An "edgeEffect" node is the blur-and-darken strip content runs off under, // rather than being cut at a hard line. Its size and position are ordinary // layout, so it can span the top of the screen, sit behind a composer, or // line any edge of any container: // // { id: "topFade", type: "edgeEffect", edge: "top", // blur: true, dim: { color: "#000000", alpha: 0.55 }, // layout: { top: 0, leading: 0, trailing: 0, height: 120 } } // // edge: "top" | "bottom" — which side the effect is strongest on; it // fades inwards from there. // blur: true | "ultraThin" | "thin" | "regular" | "thick" | "light" // dim: "#rrggbbaa" or { color, alpha } // // Both are independent: pass only `blur` for a frosted strip, only `dim` for // a plain legibility scrim, or both for the full effect. An edgeEffect never // takes touches, so it can sit over scrolling content freely. // // ---- Material ---- // material: { // style: "regular" | "clear", // tint: "#rrggbb" | "#rrggbbaa", // interactive: true, // the real touch-reactive material // corners: "capsule" | 18 | { topLeft, topRight, bottomLeft, bottomRight } // } // // ---- Animation ---- // glass.animate(changes, animation) — one animation, any number of nodes, // any animatable property, your timing: // glass.animate( // [ { id: "pill", layout: { height: 96 } }, // { id: "mic", opacity: 0, transform: { scale: 0.001 } }, // { id: "panel", material: { tint: "#2f9bffcc" } } ], // { duration: 0.4, curve: [0.22, 1, 0.36, 1] } // ); // `curve` is "easeInOut"/"easeIn"/"easeOut"/"linear" or four cubic-bezier // control points — the same numbers as your CSS. Or pass a real spring: // { duration: 0.5, spring: { mass: 1, stiffness: 460, damping: 22 } } // glass.set(changes) is the same thing with no animation. // // ---- Events ---- // glass.on("myButton", "tap", function (e) { ... }); // glass.on("myPanel", "pan", function (e) { // e.phase; e.translationX; e.translationY; e.velocityX; e.velocityY; // }); // An "input" node also emits "input" ({ text, height }) and "submit". // Pass "*" as the id to hear that event from every node. // // ---- Reading back ---- // await glass.measure("id") -> { x, y, width, height, centerX, centerY } // await glass.getText("id") -> an input node's current text glass: { // Replaces the whole scene with this tree. Pass null to tear it down. render: function (tree) { return call("glass.render", { tree: tree || null }); }, // Removes the scene entirely. clear: function () { return call("glass.render", { tree: null }); }, // Applies changes instantly. `changes` is an array of `{ id, ...properties }`; // whatever a change does not mention is left alone. Same vocabulary as the // node it addresses — text, layout, material, and so on. set: function (changes) { return call("glass.apply", { changes: [].concat(changes || []), animation: null }); }, // The same changes, animated. `animation` is `{ duration, curve }` or // `{ duration, spring: { mass, stiffness, damping, initialVelocity } }`. // // For a movement that has to line up with something your page is animating, // use `timeline` below instead: this animates the native side alone, and a // CSS transition started separately is a second clock. animate: function (changes, animation) { return call("glass.apply", { changes: [].concat(changes || []), animation: animation || { duration: 0.3 } }); }, // `animation.from` gives the node's starting state, so you decide how it // arrives — there is no built-in insertion look to opt out of. insert: function (node, parent, animation) { return call("glass.insert", { node: node, parent: parent || "root", animation: animation || null }); }, // `animation.to` gives the state to animate to before it goes; without // one it simply fades. remove: function (id, animation) { return call("glass.remove", { id: id, animation: animation || null }); }, // Resolves to `{ x, y, width, height, centerX, centerY }` for one node, in // screen points after layout has settled — so a page can place its own content // around a control whose size the launcher decided. Resolves to null if there // is no such node. measure: function (id) { return call("glass.measure", { id: id }); }, // The current contents of an `input` node. getText: function (id) { return call("glass.getText", { id: id }); }, // Focuses an `input` node and raises the keyboard. focus: function (id) { return call("glass.focus", { id: id, focused: true }); }, // Drops focus and dismisses the keyboard. blur: function (id) { return call("glass.focus", { id: id, focused: false }); }, // Subscribes to one node's events: "tap" on anything with `gestures: ["tap"]`, // "input" and "submit" on an `input` node. The handler receives the event // payload — an input event carries the text and the field's height. on: function (id, type, fn) { var key = id + "" + type; glassHandlers[key] = glassHandlers[key] || []; glassHandlers[key].push(fn); return function off() { glassHandlers[key] = (glassHandlers[key] || []).filter(function (f) { return f !== fn; }); }; }, // Every event from every node, if you'd rather route them yourself. onEvent: function (fn) { return on("glass.event", fn); }, // One animation, drawn by both renderers. // // `glass.animate` and a CSS transition are two clocks: each is scheduled by a // different process against a different origin, so however carefully they are // started together they drift a few milliseconds — small, and exactly the drift // that reads as the header not being part of the screen it belongs to. // // This is the other option. You describe the whole movement once — the parts // that happen in your page and the parts that happen in glass — and both are // committed to the *same* start instant. They are not kept in step; they are in // step, the way two layers of one native screen are, and there is nothing left // to calibrate. // // NativeBridge.glass.timeline({ // duration: 0.42, // curve: [0.22, 1, 0.36, 1], // page: [{ // selector: "#chatDetail", // keyframes: [{ transform: "translateX(100%)" }, { transform: "translateX(0)" }], // commitStyle: { transform: "translateX(0)" }, // }], // glass: [ // { id: "back", from: { x: "100%" }, to: { x: 0 } }, // { id: "title", from: { x: "100%" }, to: { x: 0 } }, // { id: "avatar", from: { x: "100%" }, to: { x: 0 } }, // ], // }); // // `page` steps are Web Animations keyframes — anything you can animate in CSS. // `glass` steps take `x`/`y` (points, or a string like "100%" of the screen so a // control can say "off the edge" without knowing the device), `opacity` and // `scale`; `id` is any glass control or scene node, or one of "@blurTop", // "@blurBottom", "@toolbar", "@scene". // // `commitStyle` is written onto the element when its animation finishes, so the // end state becomes the element's own and the fill can be dropped without a // flash. `lead` (default 0.05) is how far ahead the shared start is placed — // it only has to cover one bridge hop, not be guessed accurately. timeline: function (spec) { return call("glass.timeline", spec || {}); }, }, // Native Liquid Glass UI (iOS 26+, falls back to a blur material on older systems). // Every element below accepts a `tintColor` (any hex color) and an `anchor` — // one of: "topLeading" | "topCenter" | "topTrailing" | "center" | // "bottomLeading" | "bottomCenter" | "bottomTrailing" — placing it anywhere on screen. // // ---- Showing more than one of something: `id` ---- // showGlassButton / showGlassBadge / showGlassView / showGlassShape / // showGlassCard / showGlassCluster each take an `id`, naming which instance // you mean. Omit it and you get the implicit one — so a page showing a single // button of a kind never has to think about ids at all. Pass different ids and // you get genuinely separate elements, live at the same time: // // NativeBridge.ui.showGlassView({ id: "avatar", shape: { kind: "circle" }, … }); // NativeBridge.ui.showGlassView({ id: "unread", shape: { kind: "capsule" }, … }); // // This matters most for showGlassView, the general-purpose builder: it exists // so a new design doesn't need an app update, and a one-per-screen limit meant // a chat that spent it on an avatar had none left for anything else. // // The matching hide takes an `id` too. Passing one hides exactly that element; // omitting it hides *every* element of that kind — usually what you want when // leaving a screen, and what makes pages written before ids existed keep // behaving as they always did. updateGlassView addresses the element with // `viewId` (its `id` field is reserved for the content node inside it). // // ---- Nudging an element off its anchor: `offset` ---- // `offset: { x, y }` shifts an element from its anchor in points — positive x // toward the trailing edge, positive y downward. Seven anchors can't express // "just above the composer", and picking a different anchor than the one you // mean to dodge something is worse than saying so. // // Tap events carry the id back, so one handler can serve every instance: // NativeBridge.ui.onGlassViewTap(function (e) { … e.id … }); ui: { // The launcher's own tab bar, at the bottom of the screen, in real glass. // `items` is `[{ id, title, systemName }]` using SF Symbol names; `selected` is // the id to start on. options: { tintColor }. // // Taps do not change the selection on their own — `onToolbarTap` tells you, and // your page decides. That is deliberate: a tab whose content fails to load // should not leave the bar showing a tab the user is not on. showGlassToolbar: function (items, selected, options) { options = options || {}; return call("ui.showGlassToolbar", { items: items || [], selected: selected || null, tintColor: options.tintColor || null }); }, // Takes the tab bar away — for a screen pushed on top of your tabs, say. hideGlassToolbar: function () { return call("ui.hideGlassToolbar", {}); }, // Called with the tapped item's id. Confirm it with `setToolbarSelection` once // the tab has actually switched. onToolbarTap: function (fn) { return on("ui.toolbarTap", fn); }, // Tells the bar which tab the page is really on. The bar moves optimistically when // tapped, so call this to confirm a tab — or to put it back when the tap did not // navigate anywhere, instead of leaving the bar showing a tab you are not on. setToolbarSelection: function (id) { return call("ui.setToolbarSelection", { id: id }); }, // style: "glass" | "prominentGlass" | "clearGlass" | "prominentClearGlass" // options: { title, style, tintColor, anchor, size, slide } — icon // and/or title may be given. size is the button's diameter (icon-only) / // height (with title) in points, default 52. // // `slide` slides the button in from the trailing edge instead of just // fading in — for a control that appears alongside a screen you're // sliding in yourself (a chat header's back button, say, next to the chat // screen itself pushing in). Every mini-app animates its own screens // differently — a fast snap, a slow ease, a custom brand curve — so this // native slide is never one fixed timing; it takes whichever of: // - omitted / `false` — no slide, just the plain fade. // - `true` — slide with a reasonable native default timing. // - `{ duration, curve: [x1, y1, x2, y2] }` — your own duration (seconds) // and cubic-bezier control points, i.e. exactly the numbers already in // your page's own CSS `transition` on the screen this sits alongside, // so the two motions read as one instead of two separately-timed ones. // // A slide is self-contained: it animates this one control and waits for // nothing. If the control has to travel *with* something your page is // animating — a screen sliding in, a panel dropping — do not try to match // this to a CSS transition by giving both the same numbers. Two animations // started by two processes are two clocks, and they drift. Describe the // whole movement once with `glass.timeline` instead: it commits your page // and the glass to one start instant, so they are not kept in step, they // are in step. // // The same `slide` shape works on hideGlassButton/showGlassBadge/ // hideGlassBadge/showGlassView/hideGlassView/showGlassComposer/ // hideGlassComposer below. // // `morph: true` is a different kind of transition from `slide` — a // same-spot crossfade-and-scale swap for a button whose *icon* is // changing, not one appearing alongside something else moving. Matched // against Telegram's own mic → send transition (their // `ChatTextInputPanelNode.updateActionButtons`): showing a button with an // `id` already on screen and `morph: true` fades and scales the old one // out while the new one fades and scales in, in place, over 0.2s — instead // of `slide`'s off-to-the-side motion, or the plain instant swap you'd get // with neither. Use this for a composer's mic circle becoming a send // circle (or back) as typing starts and stops; use `slide` for a control // appearing or leaving alongside a screen transition instead. showGlassButton: function (icon, options) { options = options || {}; return call("ui.showGlassButton", { id: options.id || null, offset: options.offset || null, icon: icon || null, title: options.title || null, style: options.style || "glass", tintColor: options.tintColor || null, anchor: options.anchor || "bottomTrailing", size: options.size || 52, slide: options.slide != null ? options.slide : false, morph: !!options.morph, }); }, // options: { slide } — carries the button off toward the trailing // edge instead of just fading it out in place; see showGlassButton's own // note on `slide` for the full shape. Use this when the button is // part of a screen your own page is sliding away (a chat header's back // button alongside the chat itself sliding off), so the native control // reads as leaving with that screen instead of independently // disappearing. hideGlassButton: function (options) { options = options || {}; return call("ui.hideGlassButton", { id: options.id || null, slide: options.slide != null ? options.slide : false }); }, // Called with `{ id }` for any button shown by `showGlassButton`. onGlassButtonTap: function (fn) { return on("ui.glassButtonTap", fn); }, // options: { tintColor, style } — style: "regular" (default) | "clear" // (UIGlassEffect.Style — a more transparent variant for over rich content). showGlassCard: function (title, message, options) { options = options || {}; return call("ui.showGlassCard", { id: options.id || null, title: title || "", message: message || "", tintColor: options.tintColor || null, style: options.style || "regular" }); }, // Dismisses the card from code, for when whatever it was asking about got // settled somewhere else. The card's own close button does not need this. // options: { id } — omit it to dismiss every card at once. hideGlassCard: function (options) { options = options || {}; return call("ui.hideGlassCard", { id: options.id || null }); }, // options: { tintColor, anchor, size, style } — size is each bubble's diameter, // default 44; style: "regular" (default) | "clear". showGlassCluster: function (items, options) { options = options || {}; return call("ui.showGlassCluster", { id: options.id || null, offset: options.offset || null, items: items || [], tintColor: options.tintColor || null, anchor: options.anchor || "topTrailing", size: options.size || 44, style: options.style || "regular", }); }, // options: { id } — omit it to remove every cluster at once. hideGlassCluster: function (options) { options = options || {}; return call("ui.hideGlassCluster", { id: options.id || null }); }, // Called with `{ id, index }` — which cluster, and which item within it. onClusterTap: function (fn) { return on("ui.clusterTap", fn); }, // A real UIGlassEffect in a shape none of the other elements above can take // — they all require an icon or title (showGlassButton) or are always the // same rectangular card (showGlassCard). Three kinds, via options.shape: // - "polygon" (default): pass `points`, an array of [x, y] pairs normalized // 0..1 within options.width x options.height — a star, a hexagon, a // speech-bubble notch, anything expressible as a closed polygon. // - "circle": a plain, contentless glass circle. `points` is ignored. // - "roundedRect": a plain glass panel with options.cornerRadius (default 16). // options: { shape, points, width, height, cornerRadius, tintColor, style, anchor }. showGlassShape: function (options) { options = options || {}; return call("ui.showGlassShape", { id: options.id || null, offset: options.offset || null, shape: options.shape || "polygon", points: options.points || [], width: options.width || 80, height: options.height || 80, cornerRadius: options.cornerRadius || 16, tintColor: options.tintColor || null, style: options.style || "regular", anchor: options.anchor || "bottomTrailing", }); }, // options: { id } — omit it to remove every shape at once. hideGlassShape: function (options) { options = options || {}; return call("ui.hideGlassShape", { id: options.id || null }); }, // Called with `{ id }` for any shape shown with `gestures`. onGlassShapeTap: function (fn) { return on("ui.glassShapeTap", fn); }, // A compact glass capsule holding a title + optional subtitle, no icon // required — a bot/channel-style header badge ("Split | Купить Звёзды" + // "14 995 пользователей"), which neither showGlassCard (always a big // rectangular card) nor showGlassButton (one line of text at most) can // produce. options: { subtitle, tintColor, style, anchor, height }. // `height` fixes the badge's height (content centers inside it) instead of // auto-sizing to its own text — set it to match neighboring fixed-size // header elements (a back button, an avatar circle) so a header row lines // up at one consistent height instead of each element being its own size. // `slide: true` — see showGlassButton's own note; same meaning here. showGlassBadge: function (title, options) { options = options || {}; return call("ui.showGlassBadge", { id: options.id || null, offset: options.offset || null, title: title || "", subtitle: options.subtitle || null, tintColor: options.tintColor || null, style: options.style || "regular", anchor: options.anchor || "topLeading", height: options.height || null, slide: options.slide != null ? options.slide : false, }); }, // options: { slide } — see showGlassButton's own note. hideGlassBadge: function (options) { options = options || {}; return call("ui.hideGlassBadge", { id: options.id || null, slide: options.slide != null ? options.slide : false }); }, // Called with `{ id }` for any badge shown by `showGlassBadge`. onGlassBadgeTap: function (fn) { return on("ui.glassBadgeTap", fn); }, // The general-purpose escape hatch: describe *any* glass element — shape + // an arbitrary tree of text/icons/stacks — as plain data, instead of // reaching for one of the fixed methods above. This is what makes a brand // new design (any shape, any arrangement of content) possible without a // launcher update: nothing here is a fixed native component, it's all built // from this one call. // // spec: { // shape: { kind: "capsule" | "roundedRect" | "circle" | "polygon", // cornerRadius, points }, // roundedRect/polygon only // width, height, // omit to auto-size from content (not for "polygon") // tintColor, style, interactive, anchor, // padding: number | { top, left, bottom, right }, // content: node, // see below — omit for a plain, contentless glass shape // } // // A node is one of: // { type: "text", id, text, fontSize, fontWeight, color, textAlign } // { type: "icon", id, systemName, size, color } // { type: "spacer", length } // { type: "vstack" | "hstack", spacing, alignment, children: [node, ...] } // `id` on a text/icon node is optional and only matters for // updateGlassView afterward — give one to anything you'll want to change // live (a counter, a status icon, a balance) without rebuilding the whole // element. // // Example — the exact title+subtitle badge showGlassBadge hardcodes, built // from scratch instead, with its subtitle id'd for a live update later: // NativeBridge.ui.showGlassView({ // shape: { kind: "capsule" }, // content: { type: "vstack", spacing: 1, children: [ // { type: "text", text: "Полярный Банк", fontSize: 15, fontWeight: "semibold" }, // { type: "text", id: "memberCount", text: "128 442 клиента", fontSize: 12, color: "#ffffff99" }, // ]}, // anchor: "topLeading", tintColor: "#2fb5ff", // }); showGlassView: function (spec) { spec = spec || {}; return call("ui.showGlassView", { id: spec.id || null, offset: spec.offset || null, shape: spec.shape || { kind: "roundedRect" }, width: spec.width || null, height: spec.height || null, tintColor: spec.tintColor || null, style: spec.style || "regular", // On (UIGlassEffect.isInteractive — the real, native touch-reactive // material: a specular highlight that tracks your finger, a physical // "give" on press) by default, the same way every other glass element // in this bridge (button, card, cluster, composer) already always is — // pass `interactive: false` to opt out for a purely decorative surface. interactive: spec.interactive !== false, anchor: spec.anchor || "bottomTrailing", padding: spec.padding != null ? spec.padding : null, content: spec.content || null, // `slide: true` — see showGlassButton's own note; same meaning here. slide: spec.slide != null ? spec.slide : false, }); }, // options: { slide } — see showGlassButton's own note. hideGlassView: function (options) { options = options || {}; return call("ui.hideGlassView", { id: options.id || null, slide: options.slide != null ? options.slide : false }); }, // Called with `{ id }` for any view shown by `showGlassView` with // `interactive: true`. onGlassViewTap: function (fn) { return on("ui.glassViewTap", fn); }, // Updates the currently-shown showGlassView *in place* — a real animation // on just what changed, not a teardown-and-rebuild of the whole element // (which would restart every other part of it too, and read as a jump cut // rather than a live update — the same distinction real Liquid Glass // surfaces like the Dynamic Island draw between "this changed" and "this is // a whole new thing"). Any combination of, in one call: // - one id'd child's content: `id` + whichever of // { text, color, systemName, hidden } apply to it (a UILabel only reads // text/color; a UIImageView only reads systemName/color) — crossfaded. // - the whole surface's tint: `tintColor`, no `id` — crossfaded. // - its shape: `shape` (same `{ kind, cornerRadius, points }` showGlassView // takes) — a capsule can become a circle, a rounded rect a star — // crossfaded. // - its size and/or position: `width`/`height`/`anchor`/`offset` — a real // spring animation, since this one is actual motion, not a material // property. // options: { animated } — defaults to true. updateGlassView: function (patch) { patch = patch || {}; return call("ui.updateGlassView", { viewId: patch.viewId || null, id: patch.id || null, text: patch.text != null ? patch.text : null, color: patch.color || null, systemName: patch.systemName || null, hidden: patch.hidden != null ? patch.hidden : null, tintColor: patch.tintColor || null, shape: patch.shape || null, width: patch.width || null, height: patch.height || null, anchor: patch.anchor || null, offset: patch.offset || null, animated: patch.animated !== false, }); }, // The same live resize/reposition `updateGlassView` gives `showGlassView`, // generalized to every other kind this bridge can show: a button, badge, // shape, or cluster can be narrowed, widened, moved to a new anchor, or // nudged with `offset` — animated with the same spring — once it's already // on screen, instead of only ever being fixed at whatever size/position it // was first shown with. // // patch: { kind, id, width, height, anchor, offset, animated }. `kind` is // one of "button" | "badge" | "shape" | "cluster" | "view" (though // updateGlassView above is the richer call for "view", since it can also // touch content/tint/shape, not just layout). Omitting `id` updates every // instance of that kind at once. Whatever you don't mention is left as it // was — updating only `anchor` doesn't reset the width back to auto, for // instance. updateGlass: function (patch) { patch = patch || {}; return call("ui.updateGlass", { kind: patch.kind || null, id: patch.id || null, width: patch.width || null, height: patch.height || null, anchor: patch.anchor || null, offset: patch.offset || null, animated: patch.animated !== false, }); }, // Telegram's chat edge effect — content blurs and darkens into the launcher // header at the top and into the bottom of the screen, instead of being cut at // a hard line. The real thing, ramp and proportions included. // // setSystemBlur(true) // both edges, default placement // setSystemBlur({ top: true }) // top only // setSystemBlur({ bottom: true }) // bottom only // setSystemBlur(false) // off // // Default placement is the top band hanging directly under the header and the // bottom one sitting at the very bottom of the screen. To put a band somewhere // else, give it an `offset` — how far inwards from its own edge it moves, down // from the header for the top, up from the bottom of the screen for the bottom: // // setSystemBlur({ top: { offset: 44 }, bottom: { offset: 96 } }) // // `offset` and the default placement are mutually exclusive: passing one *is* // choosing custom placement, so there is nothing to turn off first. Also per // edge: `height` (how deep the band covers the page), `blurHeight` (how long the // ramp takes to fall from full strength to nothing — this does not crop the // effect, a shorter value makes the same band go sharp-to-blurred over less // distance so the transition reads faster and harder, a longer one spreads it // out and reads softer), `blur: false` (tint with no blur), `color` and `alpha` // (for a mini-app whose background is not near-black), and `aboveControls: true` // to draw the band over your glass elements instead of behind them. // // Any of those keys placed at the top level rather than inside an edge applies // to both edges, so "same falloff top and bottom" is one value in one place: // // setSystemBlur({ top: true, bottom: true, blurHeight: 48 }) // setSystemBlur({ blurHeight: 48, top: true, bottom: { blurHeight: 96 } }) // // An edge's own keys win over the shared ones. setSystemBlur: function (options) { if (options === true) options = { top: true, bottom: true }; else if (!options) options = {}; return call("ui.setSystemBlur", { top: options.top == null ? false : options.top, bottom: options.bottom == null ? false : options.bottom, }); }, // Pinch and double-tap zoom, per screen. Off everywhere by default, because a // mini-app should feel like an app rather than a web page — turn it on for the // screens where zoom is the point (a photo, a map, a scan) and leave it off for // the rest. Each pushed screen is its own web view with its own answer, so a // gallery can allow it while the list it opened from does not, and going back // restores the list's own setting without the page having to ask again. // // setZoomEnabled(true) // up to 3x // setZoomEnabled(true, { maximumScale: 6 }) // setZoomEnabled(false) setZoomEnabled: function (enabled, options) { options = options || {}; return call("ui.setZoomEnabled", { enabled: !!enabled, maximumScale: options.maximumScale || 3, }); }, // Stacking order for anything on screen, addressed by the id you already use. // Besides your own ids there are four names for the surfaces the launcher owns: // "@blurTop" and "@blurBottom" (the edge bands), "@page" (your web content), // "@toolbar" (the launcher's tab bar) and "@scene" (everything in your scene, // as one). // // setLayer("composer", { above: "@blurBottom" }) // setLayer("@blurTop", { above: "header" }) // frost the header too // setLayer("watermark", { back: true }) // setLayer({ reset: true }) // drop every ordering // // Orderings are remembered and re-applied whenever anything new is mounted, so // one does not quietly revert the next time you add a control. Two elements // with different parents have no order between them — the exception is a scene // node against something outside the scene, where the scene as a whole moves. // The close button always stays on top. setLayer: function (id, options) { if (id && typeof id === "object") return call("ui.setLayer", id); options = options || {}; return call("ui.setLayer", { id: id, above: options.above || null, below: options.below || null, front: !!options.front, back: !!options.back, }); }, // Paints your top edge with an exact color — both the native header region above // your page (where the close button sits) and the WKWebView's own rubber-band // overscroll gutter just below it (what you see if the user pulls down past the // top of the page). Automatic detection for both only ever sees your flat // `background-color`, which for a gradient or image background usually isn't the // color actually touching the top edge; pass the color your background touches // there (e.g. a gradient's first stop) and native chrome, overscroll, and content // all line up. Call again whenever that top color changes (e.g. switching // screens); call with no argument to go back to automatic detection. setHeaderColor: function (hex) { return call("ui.setHeaderColor", { color: hex || null }); }, // Closes the mini-app, the same as the user tapping the native close button // themselves. Useful for a "Done"/"Log out" action inside your own page — if this // mini-app also has Face ID required on open (a launcher-level setting, not // something the page controls), the next open will ask again. close: function () { return call("ui.close", {}); }, // A real UITextView in a real UIGlassEffect capsule, floating over your // page — meant to fully replace your own message/comment input, the same // way showGlassToolbar replaces a bottom nav. Matched against Telegram's // own iOS composer: 42pt tall at rest, 17pt text, and it genuinely grows // as the user types a second and third line — up to five lines, then // scrolls internally — with its corner radius recomputed at every height // so it stays a true capsule throughout, not a fixed shape the text // scrolls sideways inside. Return inserts a newline, the same as // Telegram's own field; there's no way to send except tapping the button. // // There's no way to read what's typed as it's typed; onComposerSend(fn) // fires once with the full text when the user taps send, and the field // clears itself. The send button only appears once there's at least one // character typed — baked into the composer itself, not something you // configure. // options: { tintColor, liftContent, fullWidth, insets }. A real DOM // ``/`contenteditable` in your own page gets its surrounding content // resized around the keyboard automatically, for free — this composer is a // native control the page never actually sees, so that doesn't happen // unless you opt in with `liftContent: true`, which raises your whole page // alongside the composer instead of only the composer itself. // `fullWidth: true` runs the bar edge-to-edge — still a rounded capsule // (real Liquid Glass surfaces stay rounded regardless of width; a flat, // square-cornered bar isn't a look this material uses) — instead of the // default floating pill with side margins. // // `insets: { left, right }` (16pt each by default — the same margin the // header's own elements use) is how you narrow the pill to make room for // your own controls beside it, a paperclip or a mic circle say, instead of // those overlapping a bar that's otherwise always edge-to-edge-minus-16. // updateGlassComposer below changes these live; onComposerHeightChange // tells you the pill's current height (it grows for multi-line text) so // anything you've placed alongside it can track that growth via // updateGlass's own `offset`. // `slide` — see showGlassButton's own note; same meaning here. showGlassComposer: function (placeholder, options) { options = options || {}; return call("ui.showGlassComposer", { placeholder: placeholder || "", tintColor: options.tintColor || null, liftContent: !!options.liftContent, fullWidth: !!options.fullWidth, insets: options.insets || null, slide: options.slide != null ? options.slide : false, }); }, // options: { slide } — see showGlassButton's own note. hideGlassComposer: function (options) { options = options || {}; return call("ui.hideGlassComposer", { slide: options.slide != null ? options.slide : false }); }, // Narrows, widens, or re-centers a composer already on screen. // options: { insets: { left, right }, animated }. Only the sides you // mention change — passing just `{ left: 68 }` leaves the right inset // wherever it already was. updateGlassComposer: function (options) { options = options || {}; return call("ui.updateGlassComposer", { insets: options.insets || null, animated: options.animated !== false }); }, // Called with `{ text }` when the composer's send is triggered — by its own // return key, or by `sendComposer`. onComposerSend: function (fn) { return on("ui.composerSend", fn); }, // There's no button inside the composer itself to tap — matched against // Telegram's own layout, where sending lives entirely in a separate // circle beside the pill (see onComposerHasTextChange's own note). Call // this from that circle's own tap handler once it's showing a send icon; // it fires onComposerSend with whatever text the field currently holds, // exactly as if the field had a send button of its own. sendComposer: function () { return call("ui.sendComposer", {}); }, // Makes the whole page ride up and down with the keyboard, on the // keyboard's own curve and duration — not an approximation of them: the // page is moved inside the very same native animation, so there is // nothing to match. // // Off by default, and you usually don't want it. A page whose input is a // real DOM element already gets this from WebKit for free. Turn it on when // the keyboard is being held open by something WebKit cannot see — a // native composer, or an "input" node in a glass scene — because as far as // the page is concerned nothing is focused and nothing moves. Turning it // on as well as WebKit's own handling would move everything twice. setKeyboardPageLift: function (enabled) { return call("ui.setKeyboardPageLift", { enabled: enabled !== false }); }, // Fires with the composer's current height (points) whenever it changes — // typing past one line, deleting back down, or once at rest right after // showGlassComposer. Use this to keep anything positioned relative to the // composer (updateGlass's `offset`) in step as it grows. onComposerHeightChange: function (fn) { return on("ui.composerHeightChange", fn); }, // Fires with { hasText: true|false } the moment the field crosses between // empty and non-empty — not on every keystroke, only the transition. // Checked against Telegram's own `ChatTextInputPanelNode`: there is no // button inside their pill at all, and the pill's width never changes // between the two states — sending lives entirely in a separate circle // beside it, which crossfades+scales between a mic icon and a send icon // in place (their own values: 0.2s, ease-in-out, scaling toward/from // ~0). Reproduce that by re-showing the same button `id` with the new // icon and `morph: true` on this event, and calling `sendComposer()` from // that circle's own tap handler while it's showing the send icon: // NativeBridge.ui.onComposerHasTextChange(function (e) { // NativeBridge.ui.showGlassButton(e.hasText ? "arrow.up" : "mic.fill", { // id: "mic", anchor: "bottomTrailing", size: 44, morph: true, // style: e.hasText ? "prominentGlass" : "glass", // }); // }); onComposerHasTextChange: function (fn) { return on("ui.composerHasTextChange", fn); }, // A real UIAlertController, not an in-page modal — blocks like a native alert // and reads exactly like one. alert() resolves once dismissed; confirm() // resolves { confirmed: true|false }. alert: function (title, message, options) { options = options || {}; return call("ui.alert", { title: title || null, message: message || null, buttonText: options.buttonText || null }); }, // The system confirmation dialog. options: { confirmText, cancelText, // destructive }. Resolves to `{ confirmed }`. // // A real UIAlertController, not an HTML dialog: it looks and behaves like every // other confirmation on the device, and cannot be styled away. confirm: function (title, message, options) { options = options || {}; return call("ui.confirm", { title: title || null, message: message || null, confirmText: options.confirmText || null, cancelText: options.cancelText || null, destructive: !!options.destructive, }); }, // An unread-count badge on this mini-app's own Home Screen icon. 0 clears it. setBadge: function (count) { return call("ui.setBadge", { count: count || 0 }); }, // Native push/pop navigation: pushScreen loads `url` (relative to your own // mini-app's origin) into a brand-new native screen, slid in with the standard // iOS transition and edge-swipe-to-go-back gesture already wired up. popScreen // goes back one screen, the same as the user swiping or tapping "Назад". pushScreen: function (url) { return call("ui.pushScreen", { url: url }); }, // Goes back one screen, the same as the user swiping from the edge or tapping // the launcher's own back button. popScreen: function () { return call("ui.popScreen", {}); }, }, }; on("glass.event", dispatchGlassEvent); reportClock(); // ========================================================================== // Shared timeline. // ========================================================================== // // A native animation and a CSS one are two clocks. However carefully the launcher // starts them together, each is scheduled by a different process against a // different origin, so they drift — measured, by a few milliseconds, which is // small but is the drift that reads as the header not being part of the screen. // // Both sides are declarative, though: Core Animation and the Web Animations API // are each handed a start time and a curve and then run without anyone talking to // anyone. Give them the *same* start time and they are not kept in step, they are // in step — one timeline, two renderers, no calibration and nothing to drift. // // `performance.timeOrigin` is what makes the start time shareable: it puts this // page's clock on the wall clock, which the launcher can map onto its own without // a round trip in the critical path. // Per element, so a superseded run cannot commit over the one that replaced it. var runToken = new WeakMap(); var running = new WeakMap(); window.__nativeRunTimeline = function (spec) { var startTime = spec.startTime; // in this document's timeline var options = { duration: spec.duration * 1000, easing: spec.easing, fill: spec.fill || "both", }; (spec.steps || []).forEach(function (step) { var nodes = document.querySelectorAll(step.selector); for (var i = 0; i < nodes.length; i++) { // Every run on a given element gets a token, and only the newest one is // allowed to commit. Without this, closing a chat and reopening it before // the close had finished let the *close's* commit land afterwards: it // stripped the "open" class the reopen had just added, so the panel snapped // back off-screen while the glass header — already committed by then — // stayed where it was. From the outside that looks like the header sliding // out and the chat simply not opening. var token = (runToken.get(nodes[i]) || 0) + 1; runToken.set(nodes[i], token); var previous = running.get(nodes[i]); if (previous) { try { previous.cancel(); } catch (e) {} } var animation = nodes[i].animate(step.keyframes, options); running.set(nodes[i], animation); // Committed to the timeline rather than started now. Whatever else is // happening in this turn, the animation begins at that instant. try { animation.startTime = startTime; } catch (e) {} if (step.commit !== false) { (function (node, css, cls, myToken, myAnimation) { animation.finished.then(function () { // Superseded while it was running — a newer run owns this element now. if (runToken.get(node) !== myToken) return; // The animation is `fill: both`, so the element holds its end state // either way; making that state the element's own lets the fill be // dropped without a flash. // // Prefer landing on a class rather than an inline transform. An // element still carrying `translateX(0)` is an element with a // transform: it composites and rasterises on its own terms, and the // subpixel position it lands on need not be the one it would have had // with no transform at all. On a 3x screen that difference reads as // the content settling a pixel sideways once the animation lets go. if (cls) { if (cls.add) node.classList.add.apply(node.classList, [].concat(cls.add)); if (cls.remove) node.classList.remove.apply(node.classList, [].concat(cls.remove)); } if (css) { for (var key in css) { // "" removes the property outright, which is not the same as // setting it to an identity value. if (css[key] === "") node.style.removeProperty(key); else node.style[key] = css[key]; } } running.delete(node); myAnimation.cancel(); }).catch(function () {}); })(nodes[i], step.commitStyle, step.commitClass, token, animation); } } }); }; window.dispatchEvent(new Event("nativebridgeready")); })();