diff --git a/.changeset/shy-papayas-buy.md b/.changeset/shy-papayas-buy.md new file mode 100644 index 000000000..c47f270a6 --- /dev/null +++ b/.changeset/shy-papayas-buy.md @@ -0,0 +1,7 @@ +--- +"@telegram-apps/bridge": minor +--- + +- Add `web_app_set_bottom_bar_color` in `supports` +- Widen type for `web_app_set_background_color.color` +- Add `web_app_setup_secondary_button.position` diff --git a/.changeset/wild-cheetahs-reply.md b/.changeset/wild-cheetahs-reply.md new file mode 100644 index 000000000..870c8a5f9 --- /dev/null +++ b/.changeset/wild-cheetahs-reply.md @@ -0,0 +1,6 @@ +--- +"@telegram-apps/sdk": minor +--- + +- Enhance the Secondary Button with the `position` signal. Export the component +- Add bottom bar related functionality in the Mini App component diff --git a/a.js b/a.js new file mode 100644 index 000000000..2822e08f1 --- /dev/null +++ b/a.js @@ -0,0 +1,2236 @@ +// WebView +(function () { + var eventHandlers = {}; + + var locationHash = ''; + try { + locationHash = location.hash.toString(); + } catch (e) {} + + var initParams = urlParseHashParams(locationHash); + var storedParams = sessionStorageGet('initParams'); + if (storedParams) { + for (var key in storedParams) { + if (typeof initParams[key] === 'undefined') { + initParams[key] = storedParams[key]; + } + } + } + sessionStorageSet('initParams', initParams); + + var isIframe = false, iFrameStyle; + try { + isIframe = (window.parent != null && window != window.parent); + if (isIframe) { + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + try { + var dataParsed = JSON.parse(event.data); + } catch (e) { + return; + } + if (!dataParsed || !dataParsed.eventType) { + return; + } + if (dataParsed.eventType == 'set_custom_style') { + if (event.origin === 'https://web.telegram.org') { + iFrameStyle.innerHTML = dataParsed.eventData; + } + } else if (dataParsed.eventType == 'reload_iframe') { + try { + window.parent.postMessage(JSON.stringify({eventType: 'iframe_will_reload'}), '*'); + } catch (e) {} + location.reload(); + } else { + receiveEvent(dataParsed.eventType, dataParsed.eventData); + } + }); + iFrameStyle = document.createElement('style'); + document.head.appendChild(iFrameStyle); + try { + window.parent.postMessage(JSON.stringify({eventType: 'iframe_ready', eventData: {reload_supported: true}}), '*'); + } catch (e) {} + } + } catch (e) {} + + function urlSafeDecode(urlencoded) { + try { + urlencoded = urlencoded.replace(/\+/g, '%20'); + return decodeURIComponent(urlencoded); + } catch (e) { + return urlencoded; + } + } + + function urlParseHashParams(locationHash) { + locationHash = locationHash.replace(/^#/, ''); + var params = {}; + if (!locationHash.length) { + return params; + } + if (locationHash.indexOf('=') < 0 && locationHash.indexOf('?') < 0) { + params._path = urlSafeDecode(locationHash); + return params; + } + var qIndex = locationHash.indexOf('?'); + if (qIndex >= 0) { + var pathParam = locationHash.substr(0, qIndex); + params._path = urlSafeDecode(pathParam); + locationHash = locationHash.substr(qIndex + 1); + } + var query_params = urlParseQueryString(locationHash); + for (var k in query_params) { + params[k] = query_params[k]; + } + return params; + } + + function urlParseQueryString(queryString) { + var params = {}; + if (!queryString.length) { + return params; + } + var queryStringParams = queryString.split('&'); + var i, param, paramName, paramValue; + for (i = 0; i < queryStringParams.length; i++) { + param = queryStringParams[i].split('='); + paramName = urlSafeDecode(param[0]); + paramValue = param[1] == null ? null : urlSafeDecode(param[1]); + params[paramName] = paramValue; + } + return params; + } + + // Telegram apps will implement this logic to add service params (e.g. tgShareScoreUrl) to game URL + function urlAppendHashParams(url, addHash) { + // url looks like 'https://game.com/path?query=1#hash' + // addHash looks like 'tgShareScoreUrl=' + encodeURIComponent('tgb://share_game_score?hash=very_long_hash123') + + var ind = url.indexOf('#'); + if (ind < 0) { + // https://game.com/path -> https://game.com/path#tgShareScoreUrl=etc + return url + '#' + addHash; + } + var curHash = url.substr(ind + 1); + if (curHash.indexOf('=') >= 0 || curHash.indexOf('?') >= 0) { + // https://game.com/#hash=1 -> https://game.com/#hash=1&tgShareScoreUrl=etc + // https://game.com/#path?query -> https://game.com/#path?query&tgShareScoreUrl=etc + return url + '&' + addHash; + } + // https://game.com/#hash -> https://game.com/#hash?tgShareScoreUrl=etc + if (curHash.length > 0) { + return url + '?' + addHash; + } + // https://game.com/# -> https://game.com/#tgShareScoreUrl=etc + return url + addHash; + } + + function postEvent(eventType, callback, eventData) { + if (!callback) { + callback = function () {}; + } + if (eventData === undefined) { + eventData = ''; + } + console.log('[Telegram.WebView] > postEvent', eventType, eventData); + + if (window.TelegramWebviewProxy !== undefined) { + TelegramWebviewProxy.postEvent(eventType, JSON.stringify(eventData)); + callback(); + } + else if (window.external && 'notify' in window.external) { + window.external.notify(JSON.stringify({eventType: eventType, eventData: eventData})); + callback(); + } + else if (isIframe) { + try { + var trustedTarget = 'https://web.telegram.org'; + // For now we don't restrict target, for testing purposes + trustedTarget = '*'; + window.parent.postMessage(JSON.stringify({eventType: eventType, eventData: eventData}), trustedTarget); + callback(); + } catch (e) { + callback(e); + } + } + else { + callback({notAvailable: true}); + } + }; + + function receiveEvent(eventType, eventData) { + console.log('[Telegram.WebView] < receiveEvent', eventType, eventData); + callEventCallbacks(eventType, function(callback) { + callback(eventType, eventData); + }); + } + + function callEventCallbacks(eventType, func) { + var curEventHandlers = eventHandlers[eventType]; + if (curEventHandlers === undefined || + !curEventHandlers.length) { + return; + } + for (var i = 0; i < curEventHandlers.length; i++) { + try { + func(curEventHandlers[i]); + } catch (e) {} + } + } + + function onEvent(eventType, callback) { + if (eventHandlers[eventType] === undefined) { + eventHandlers[eventType] = []; + } + var index = eventHandlers[eventType].indexOf(callback); + if (index === -1) { + eventHandlers[eventType].push(callback); + } + }; + + function offEvent(eventType, callback) { + if (eventHandlers[eventType] === undefined) { + return; + } + var index = eventHandlers[eventType].indexOf(callback); + if (index === -1) { + return; + } + eventHandlers[eventType].splice(index, 1); + }; + + function openProtoUrl(url) { + if (!url.match(/^(web\+)?tgb?:\/\/./)) { + return false; + } + var useIframe = navigator.userAgent.match(/iOS|iPhone OS|iPhone|iPod|iPad/i) ? true : false; + if (useIframe) { + var iframeContEl = document.getElementById('tgme_frame_cont') || document.body; + var iframeEl = document.createElement('iframe'); + iframeContEl.appendChild(iframeEl); + var pageHidden = false; + var enableHidden = function () { + pageHidden = true; + }; + window.addEventListener('pagehide', enableHidden, false); + window.addEventListener('blur', enableHidden, false); + if (iframeEl !== null) { + iframeEl.src = url; + } + setTimeout(function() { + if (!pageHidden) { + window.location = url; + } + window.removeEventListener('pagehide', enableHidden, false); + window.removeEventListener('blur', enableHidden, false); + }, 2000); + } + else { + window.location = url; + } + return true; + } + + function sessionStorageSet(key, value) { + try { + window.sessionStorage.setItem('__telegram__' + key, JSON.stringify(value)); + return true; + } catch(e) {} + return false; + } + function sessionStorageGet(key) { + try { + return JSON.parse(window.sessionStorage.getItem('__telegram__' + key)); + } catch(e) {} + return null; + } + + if (!window.Telegram) { + window.Telegram = {}; + } + window.Telegram.WebView = { + initParams: initParams, + isIframe: isIframe, + onEvent: onEvent, + offEvent: offEvent, + postEvent: postEvent, + receiveEvent: receiveEvent, + callEventCallbacks: callEventCallbacks + }; + + window.Telegram.Utils = { + urlSafeDecode: urlSafeDecode, + urlParseQueryString: urlParseQueryString, + urlParseHashParams: urlParseHashParams, + urlAppendHashParams: urlAppendHashParams, + sessionStorageSet: sessionStorageSet, + sessionStorageGet: sessionStorageGet + }; + + // For Windows Phone app + window.TelegramGameProxy_receiveEvent = receiveEvent; + + // App backward compatibility + window.TelegramGameProxy = { + receiveEvent: receiveEvent + }; +})(); + +// WebApp +(function () { + var Utils = window.Telegram.Utils; + var WebView = window.Telegram.WebView; + var initParams = WebView.initParams; + var isIframe = WebView.isIframe; + + var WebApp = {}; + var webAppInitData = '', webAppInitDataUnsafe = {}; + var themeParams = {}, colorScheme = 'light'; + var webAppVersion = '6.0'; + var webAppPlatform = 'unknown'; + + if (initParams.tgWebAppData && initParams.tgWebAppData.length) { + webAppInitData = initParams.tgWebAppData; + webAppInitDataUnsafe = Utils.urlParseQueryString(webAppInitData); + for (var key in webAppInitDataUnsafe) { + var val = webAppInitDataUnsafe[key]; + try { + if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || + val.substr(0, 1) == '[' && val.substr(-1) == ']') { + webAppInitDataUnsafe[key] = JSON.parse(val); + } + } catch (e) {} + } + } + if (initParams.tgWebAppThemeParams && initParams.tgWebAppThemeParams.length) { + var themeParamsRaw = initParams.tgWebAppThemeParams; + try { + var theme_params = JSON.parse(themeParamsRaw); + if (theme_params) { + setThemeParams(theme_params); + } + } catch (e) {} + } + var theme_params = Utils.sessionStorageGet('themeParams'); + if (theme_params) { + setThemeParams(theme_params); + } + if (initParams.tgWebAppVersion) { + webAppVersion = initParams.tgWebAppVersion; + } + if (initParams.tgWebAppPlatform) { + webAppPlatform = initParams.tgWebAppPlatform; + } + + function onThemeChanged(eventType, eventData) { + if (eventData.theme_params) { + setThemeParams(eventData.theme_params); + window.Telegram.WebApp.MainButton.setParams({}); + window.Telegram.WebApp.SecondaryButton.setParams({}); + updateHeaderColor(); + updateBackgroundColor(); + updateBottomBarColor(); + receiveWebViewEvent('themeChanged'); + } + } + + var lastWindowHeight = window.innerHeight; + function onViewportChanged(eventType, eventData) { + if (eventData.height) { + window.removeEventListener('resize', onWindowResize); + setViewportHeight(eventData); + } + } + + function onWindowResize(e) { + if (lastWindowHeight != window.innerHeight) { + lastWindowHeight = window.innerHeight; + receiveWebViewEvent('viewportChanged', { + isStateStable: true + }); + } + } + + function linkHandler(e) { + if (e.metaKey || e.ctrlKey) return; + var el = e.target; + while (el.tagName != 'A' && el.parentNode) { + el = el.parentNode; + } + if (el.tagName == 'A' && + el.target != '_blank' && + (el.protocol == 'http:' || el.protocol == 'https:') && + el.hostname == 't.me') { + WebApp.openTgLink(el.href); + e.preventDefault(); + } + } + + function strTrim(str) { + return str.toString().replace(/^\s+|\s+$/g, ''); + } + + function receiveWebViewEvent(eventType) { + var args = Array.prototype.slice.call(arguments); + eventType = args.shift(); + WebView.callEventCallbacks('webview:' + eventType, function(callback) { + callback.apply(WebApp, args); + }); + } + + function onWebViewEvent(eventType, callback) { + WebView.onEvent('webview:' + eventType, callback); + }; + + function offWebViewEvent(eventType, callback) { + WebView.offEvent('webview:' + eventType, callback); + }; + + function setCssProperty(name, value) { + var root = document.documentElement; + if (root && root.style && root.style.setProperty) { + root.style.setProperty('--tg-' + name, value); + } + } + + function setThemeParams(theme_params) { + // temp iOS fix + if (theme_params.bg_color == '#1c1c1d' && + theme_params.bg_color == theme_params.secondary_bg_color) { + theme_params.secondary_bg_color = '#2c2c2e'; + } + var color; + for (var key in theme_params) { + if (color = parseColorToHex(theme_params[key])) { + themeParams[key] = color; + if (key == 'bg_color') { + colorScheme = isColorDark(color) ? 'dark' : 'light' + setCssProperty('color-scheme', colorScheme); + } + key = 'theme-' + key.split('_').join('-'); + setCssProperty(key, color); + } + } + Utils.sessionStorageSet('themeParams', themeParams); + } + + var webAppCallbacks = {}; + function generateCallbackId(len) { + var tries = 100; + while (--tries) { + var id = '', chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', chars_len = chars.length; + for (var i = 0; i < len; i++) { + id += chars[Math.floor(Math.random() * chars_len)]; + } + if (!webAppCallbacks[id]) { + webAppCallbacks[id] = {}; + return id; + } + } + throw Error('WebAppCallbackIdGenerateFailed'); + } + + var viewportHeight = false, viewportStableHeight = false, isExpanded = true; + function setViewportHeight(data) { + if (typeof data !== 'undefined') { + isExpanded = !!data.is_expanded; + viewportHeight = data.height; + if (data.is_state_stable) { + viewportStableHeight = data.height; + } + receiveWebViewEvent('viewportChanged', { + isStateStable: !!data.is_state_stable + }); + } + var height, stable_height; + if (viewportHeight !== false) { + height = (viewportHeight - bottomBarHeight) + 'px'; + } else { + height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; + } + if (viewportStableHeight !== false) { + stable_height = (viewportStableHeight - bottomBarHeight) + 'px'; + } else { + stable_height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; + } + setCssProperty('viewport-height', height); + setCssProperty('viewport-stable-height', stable_height); + } + + var isClosingConfirmationEnabled = false; + function setClosingConfirmation(need_confirmation) { + if (!versionAtLeast('6.2')) { + console.warn('[Telegram.WebApp] Closing confirmation is not supported in version ' + webAppVersion); + return; + } + isClosingConfirmationEnabled = !!need_confirmation; + WebView.postEvent('web_app_setup_closing_behavior', false, {need_confirmation: isClosingConfirmationEnabled}); + } + + var isVerticalSwipesEnabled = true; + function toggleVerticalSwipes(enable_swipes) { + if (!versionAtLeast('7.7')) { + console.warn('[Telegram.WebApp] Changing swipes behavior is not supported in version ' + webAppVersion); + return; + } + isVerticalSwipesEnabled = !!enable_swipes; + WebView.postEvent('web_app_setup_swipe_behavior', false, {allow_vertical_swipe: isVerticalSwipesEnabled}); + } + + var headerColorKey = 'bg_color', headerColor = null; + function getHeaderColor() { + if (headerColorKey == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (headerColorKey == 'bg_color') { + return themeParams.bg_color; + } + return headerColor; + } + function setHeaderColor(color) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] Header color is not supported in version ' + webAppVersion); + return; + } + if (!versionAtLeast('6.9')) { + if (themeParams.bg_color && + themeParams.bg_color == color) { + color = 'bg_color'; + } else if (themeParams.secondary_bg_color && + themeParams.secondary_bg_color == color) { + color = 'secondary_bg_color'; + } + } + var head_color = null, color_key = null; + if (color == 'bg_color' || color == 'secondary_bg_color') { + color_key = color; + } else if (versionAtLeast('6.9')) { + head_color = parseColorToHex(color); + if (!head_color) { + console.error('[Telegram.WebApp] Header color format is invalid', color); + throw Error('WebAppHeaderColorInvalid'); + } + } + if (!versionAtLeast('6.9') && + color_key != 'bg_color' && + color_key != 'secondary_bg_color') { + console.error('[Telegram.WebApp] Header color key should be one of Telegram.WebApp.themeParams.bg_color, Telegram.WebApp.themeParams.secondary_bg_color, \'bg_color\', \'secondary_bg_color\'', color); + throw Error('WebAppHeaderColorKeyInvalid'); + } + headerColorKey = color_key; + headerColor = head_color; + updateHeaderColor(); + } + var appHeaderColorKey = null, appHeaderColor = null; + function updateHeaderColor() { + if (appHeaderColorKey != headerColorKey || + appHeaderColor != headerColor) { + appHeaderColorKey = headerColorKey; + appHeaderColor = headerColor; + if (appHeaderColor) { + WebView.postEvent('web_app_set_header_color', false, {color: headerColor}); + } else { + WebView.postEvent('web_app_set_header_color', false, {color_key: headerColorKey}); + } + } + } + + var backgroundColor = 'bg_color'; + function getBackgroundColor() { + if (backgroundColor == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (backgroundColor == 'bg_color') { + return themeParams.bg_color; + } + return backgroundColor; + } + function setBackgroundColor(color) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] Background color is not supported in version ' + webAppVersion); + return; + } + var bg_color; + if (color == 'bg_color' || color == 'secondary_bg_color') { + bg_color = color; + } else { + bg_color = parseColorToHex(color); + if (!bg_color) { + console.error('[Telegram.WebApp] Background color format is invalid', color); + throw Error('WebAppBackgroundColorInvalid'); + } + } + backgroundColor = bg_color; + updateBackgroundColor(); + } + var appBackgroundColor = null; + function updateBackgroundColor() { + var color = getBackgroundColor(); + if (appBackgroundColor != color) { + appBackgroundColor = color; + WebView.postEvent('web_app_set_background_color', false, {color: color}); + } + } + + var bottomBarColor = 'bottom_bar_bg_color'; + function getBottomBarColor() { + if (bottomBarColor == 'bottom_bar_bg_color') { + return themeParams.bottom_bar_bg_color || themeParams.secondary_bg_color || '#ffffff'; + } else if (bottomBarColor == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (bottomBarColor == 'bg_color') { + return themeParams.bg_color; + } + return bottomBarColor; + } + function setBottomBarColor(color) { + if (!versionAtLeast('7.10')) { + console.warn('[Telegram.WebApp] Bottom bar color is not supported in version ' + webAppVersion); + return; + } + var bg_color; + if (color == 'bg_color' || color == 'secondary_bg_color' || color == 'bottom_bar_bg_color') { + bg_color = color; + } else { + bg_color = parseColorToHex(color); + if (!bg_color) { + console.error('[Telegram.WebApp] Bottom bar color format is invalid', color); + throw Error('WebAppBottomBarColorInvalid'); + } + } + bottomBarColor = bg_color; + updateBottomBarColor(); + window.Telegram.WebApp.SecondaryButton.setParams({}); + } + var appBottomBarColor = null; + function updateBottomBarColor() { + var color = getBottomBarColor(); + if (appBottomBarColor != color) { + appBottomBarColor = color; + WebView.postEvent('web_app_set_bottom_bar_color', false, {color: color}); + } + if (initParams.tgWebAppDebug) { + updateDebugBottomBar(); + } + } + + + function parseColorToHex(color) { + color += ''; + var match; + if (match = /^\s*#([0-9a-f]{6})\s*$/i.exec(color)) { + return '#' + match[1].toLowerCase(); + } + else if (match = /^\s*#([0-9a-f])([0-9a-f])([0-9a-f])\s*$/i.exec(color)) { + return ('#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3]).toLowerCase(); + } + else if (match = /^\s*rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)\s*$/.exec(color)) { + var r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]); + r = (r < 16 ? '0' : '') + r.toString(16); + g = (g < 16 ? '0' : '') + g.toString(16); + b = (b < 16 ? '0' : '') + b.toString(16); + return '#' + r + g + b; + } + return false; + } + + function isColorDark(rgb) { + rgb = rgb.replace(/[\s#]/g, ''); + if (rgb.length == 3) { + rgb = rgb[0] + rgb[0] + rgb[1] + rgb[1] + rgb[2] + rgb[2]; + } + var r = parseInt(rgb.substr(0, 2), 16); + var g = parseInt(rgb.substr(2, 2), 16); + var b = parseInt(rgb.substr(4, 2), 16); + var hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); + return hsp < 120; + } + + function versionCompare(v1, v2) { + if (typeof v1 !== 'string') v1 = ''; + if (typeof v2 !== 'string') v2 = ''; + v1 = v1.replace(/^\s+|\s+$/g, '').split('.'); + v2 = v2.replace(/^\s+|\s+$/g, '').split('.'); + var a = Math.max(v1.length, v2.length), i, p1, p2; + for (i = 0; i < a; i++) { + p1 = parseInt(v1[i]) || 0; + p2 = parseInt(v2[i]) || 0; + if (p1 == p2) continue; + if (p1 > p2) return 1; + return -1; + } + return 0; + } + + function versionAtLeast(ver) { + return versionCompare(webAppVersion, ver) >= 0; + } + + function byteLength(str) { + if (window.Blob) { + try { return new Blob([str]).size; } catch (e) {} + } + var s = str.length; + for (var i=str.length-1; i>=0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s+=2; + if (code >= 0xdc00 && code <= 0xdfff) i--; + } + return s; + } + + var BackButton = (function() { + var isVisible = false; + + var backButton = {}; + Object.defineProperty(backButton, 'isVisible', { + set: function(val){ setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + + var curButtonState = null; + + WebView.onEvent('back_button_pressed', onBackButtonPressed); + + function onBackButtonPressed() { + receiveWebViewEvent('backButtonClicked'); + } + + function buttonParams() { + return {is_visible: isVisible}; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function buttonCheckVersion() { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] BackButton is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent('web_app_setup_back_button', false, btn_params); + } + + function setParams(params) { + if (!buttonCheckVersion()) { + return backButton; + } + if (typeof params.is_visible !== 'undefined') { + isVisible = !!params.is_visible; + } + updateButton(); + return backButton; + } + + backButton.onClick = function(callback) { + if (buttonCheckVersion()) { + onWebViewEvent('backButtonClicked', callback); + } + return backButton; + }; + backButton.offClick = function(callback) { + if (buttonCheckVersion()) { + offWebViewEvent('backButtonClicked', callback); + } + return backButton; + }; + backButton.show = function() { + return setParams({is_visible: true}); + }; + backButton.hide = function() { + return setParams({is_visible: false}); + }; + return backButton; + })(); + + var debugBottomBar = null, debugBottomBarBtns = {}, bottomBarHeight = 0; + if (initParams.tgWebAppDebug) { + debugBottomBar = document.createElement('tg-bottom-bar'); + var debugBottomBarStyle = { + display: 'flex', + gap: '7px', + font: '600 14px/18px sans-serif', + width: '100%', + background: getBottomBarColor(), + position: 'fixed', + left: '0', + right: '0', + bottom: '0', + margin: '0', + padding: '7px', + textAlign: 'center', + boxSizing: 'border-box', + zIndex: '10000' + }; + for (var k in debugBottomBarStyle) { + debugBottomBar.style[k] = debugBottomBarStyle[k]; + } + document.addEventListener('DOMContentLoaded', function onDomLoaded(event) { + document.removeEventListener('DOMContentLoaded', onDomLoaded); + document.body.appendChild(debugBottomBar); + }); + var animStyle = document.createElement('style'); + animStyle.innerHTML = 'tg-bottom-button.shine { position: relative; overflow: hidden; } tg-bottom-button.shine:before { content:""; position: absolute; top: 0; width: 100%; height: 100%; background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .2), transparent); animation: tg-bottom-button-shine 5s ease-in-out infinite; } @-webkit-keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}} @keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}}'; + debugBottomBar.appendChild(animStyle); + } + function updateDebugBottomBar() { + var mainBtn = debugBottomBarBtns.main._bottomButton; + var secondaryBtn = debugBottomBarBtns.secondary._bottomButton; + if (mainBtn.isVisible || secondaryBtn.isVisible) { + debugBottomBar.style.display = 'flex'; + bottomBarHeight = 58; + if (mainBtn.isVisible && secondaryBtn.isVisible) { + if (secondaryBtn.position == 'top') { + debugBottomBar.style.flexDirection = 'column-reverse'; + bottomBarHeight += 51; + } else if (secondaryBtn.position == 'bottom') { + debugBottomBar.style.flexDirection = 'column'; + bottomBarHeight += 51; + } else if (secondaryBtn.position == 'left') { + debugBottomBar.style.flexDirection = 'row-reverse'; + } else if (secondaryBtn.position == 'right') { + debugBottomBar.style.flexDirection = 'row'; + } + } + } else { + debugBottomBar.style.display = 'none'; + bottomBarHeight = 0; + } + debugBottomBar.style.background = getBottomBarColor(); + if (document.documentElement) { + document.documentElement.style.boxSizing = 'border-box'; + document.documentElement.style.paddingBottom = bottomBarHeight + 'px'; + } + setViewportHeight(); + } + + + var BottomButtonConstructor = function(type) { + var isMainButton = (type == 'main'); + if (isMainButton) { + var setupFnName = 'web_app_setup_main_button'; + var tgEventName = 'main_button_pressed'; + var webViewEventName = 'mainButtonClicked'; + var buttonTextDefault = 'Continue'; + var buttonColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; + var buttonTextColorDefault = function(){ return themeParams.button_text_color || '#ffffff'; }; + } else { + var setupFnName = 'web_app_setup_secondary_button'; + var tgEventName = 'secondary_button_pressed'; + var webViewEventName = 'secondaryButtonClicked'; + var buttonTextDefault = 'Cancel'; + var buttonColorDefault = function(){ return getBottomBarColor(); }; + var buttonTextColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; + } + + var isVisible = false; + var isActive = true; + var hasShineEffect = false; + var isProgressVisible = false; + var buttonType = type; + var buttonText = buttonTextDefault; + var buttonColor = false; + var buttonTextColor = false; + var buttonPosition = 'left'; + + var bottomButton = {}; + Object.defineProperty(bottomButton, 'type', { + get: function(){ return buttonType; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'text', { + set: function(val){ bottomButton.setParams({text: val}); }, + get: function(){ return buttonText; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'color', { + set: function(val){ bottomButton.setParams({color: val}); }, + get: function(){ return buttonColor || buttonColorDefault(); }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'textColor', { + set: function(val){ bottomButton.setParams({text_color: val}); }, + get: function(){ return buttonTextColor || buttonTextColorDefault(); }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isVisible', { + set: function(val){ bottomButton.setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isProgressVisible', { + get: function(){ return isProgressVisible; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isActive', { + set: function(val){ bottomButton.setParams({is_active: val}); }, + get: function(){ return isActive; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'hasShineEffect', { + set: function(val){ bottomButton.setParams({has_shine_effect: val}); }, + get: function(){ return hasShineEffect; }, + enumerable: true + }); + if (!isMainButton) { + Object.defineProperty(bottomButton, 'position', { + set: function(val){ bottomButton.setParams({position: val}); }, + get: function(){ return buttonPosition; }, + enumerable: true + }); + } + + var curButtonState = null; + + WebView.onEvent(tgEventName, onBottomButtonPressed); + + var debugBtn = null; + if (initParams.tgWebAppDebug) { + debugBtn = document.createElement('tg-bottom-button'); + var debugBtnStyle = { + display: 'none', + width: '100%', + height: '44px', + borderRadius: '0', + background: 'no-repeat right center', + padding: '13px 15px', + textAlign: 'center', + boxSizing: 'border-box' + }; + for (var k in debugBtnStyle) { + debugBtn.style[k] = debugBtnStyle[k]; + } + debugBottomBar.appendChild(debugBtn); + debugBtn.addEventListener('click', onBottomButtonPressed, false); + debugBtn._bottomButton = bottomButton; + debugBottomBarBtns[type] = debugBtn; + } + + function onBottomButtonPressed() { + if (isActive) { + receiveWebViewEvent(webViewEventName); + } + } + + function buttonParams() { + var color = bottomButton.color; + var text_color = bottomButton.textColor; + if (isVisible) { + var params = { + is_visible: true, + is_active: isActive, + is_progress_visible: isProgressVisible, + text: buttonText, + color: color, + text_color: text_color, + has_shine_effect: hasShineEffect && isActive && !isProgressVisible + }; + if (!isMainButton) { + params.position = buttonPosition; + } + } else { + var params = { + is_visible: false + }; + } + return params; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent(setupFnName, false, btn_params); + if (initParams.tgWebAppDebug) { + updateDebugButton(btn_params); + } + } + + function updateDebugButton(btn_params) { + if (btn_params.is_visible) { + debugBtn.style.display = 'block'; + + debugBtn.style.opacity = btn_params.is_active ? '1' : '0.8'; + debugBtn.style.cursor = btn_params.is_active ? 'pointer' : 'auto'; + debugBtn.disabled = !btn_params.is_active; + debugBtn.innerText = btn_params.text; + debugBtn.className = btn_params.has_shine_effect ? 'shine' : ''; + debugBtn.style.backgroundImage = btn_params.is_progress_visible ? "url('data:image/svg+xml," + encodeURIComponent('') + "')" : 'none'; + debugBtn.style.backgroundColor = btn_params.color; + debugBtn.style.color = btn_params.text_color; + } else { + debugBtn.style.display = 'none'; + } + updateDebugBottomBar(); + } + + function setParams(params) { + if (typeof params.text !== 'undefined') { + var text = strTrim(params.text); + if (!text.length) { + console.error('[Telegram.WebApp] Bottom button text is required', params.text); + throw Error('WebAppBottomButtonParamInvalid'); + } + if (text.length > 64) { + console.error('[Telegram.WebApp] Bottom button text is too long', text); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonText = text; + } + if (typeof params.color !== 'undefined') { + if (params.color === false || + params.color === null) { + buttonColor = false; + } else { + var color = parseColorToHex(params.color); + if (!color) { + console.error('[Telegram.WebApp] Bottom button color format is invalid', params.color); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonColor = color; + } + } + if (typeof params.text_color !== 'undefined') { + if (params.text_color === false || + params.text_color === null) { + buttonTextColor = false; + } else { + var text_color = parseColorToHex(params.text_color); + if (!text_color) { + console.error('[Telegram.WebApp] Bottom button text color format is invalid', params.text_color); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonTextColor = text_color; + } + } + if (typeof params.is_visible !== 'undefined') { + if (params.is_visible && + !bottomButton.text.length) { + console.error('[Telegram.WebApp] Bottom button text is required'); + throw Error('WebAppBottomButtonParamInvalid'); + } + isVisible = !!params.is_visible; + } + if (typeof params.has_shine_effect !== 'undefined') { + hasShineEffect = !!params.has_shine_effect; + } + if (!isMainButton && typeof params.position !== 'undefined') { + if (params.position != 'left' && params.position != 'right' && + params.position != 'top' && params.position != 'bottom') { + console.error('[Telegram.WebApp] Bottom button posiition is invalid', params.position); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonPosition = params.position; + } + if (typeof params.is_active !== 'undefined') { + isActive = !!params.is_active; + } + updateButton(); + return bottomButton; + } + + bottomButton.setText = function(text) { + return bottomButton.setParams({text: text}); + }; + bottomButton.onClick = function(callback) { + onWebViewEvent(webViewEventName, callback); + return bottomButton; + }; + bottomButton.offClick = function(callback) { + offWebViewEvent(webViewEventName, callback); + return bottomButton; + }; + bottomButton.show = function() { + return bottomButton.setParams({is_visible: true}); + }; + bottomButton.hide = function() { + return bottomButton.setParams({is_visible: false}); + }; + bottomButton.enable = function() { + return bottomButton.setParams({is_active: true}); + }; + bottomButton.disable = function() { + return bottomButton.setParams({is_active: false}); + }; + bottomButton.showProgress = function(leaveActive) { + isActive = !!leaveActive; + isProgressVisible = true; + updateButton(); + return bottomButton; + }; + bottomButton.hideProgress = function() { + if (!bottomButton.isActive) { + isActive = true; + } + isProgressVisible = false; + updateButton(); + return bottomButton; + } + bottomButton.setParams = setParams; + return bottomButton; + }; + var MainButton = BottomButtonConstructor('main'); + var SecondaryButton = BottomButtonConstructor('secondary'); + + var SettingsButton = (function() { + var isVisible = false; + + var settingsButton = {}; + Object.defineProperty(settingsButton, 'isVisible', { + set: function(val){ setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + + var curButtonState = null; + + WebView.onEvent('settings_button_pressed', onSettingsButtonPressed); + + function onSettingsButtonPressed() { + receiveWebViewEvent('settingsButtonClicked'); + } + + function buttonParams() { + return {is_visible: isVisible}; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function buttonCheckVersion() { + if (!versionAtLeast('6.10')) { + console.warn('[Telegram.WebApp] SettingsButton is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent('web_app_setup_settings_button', false, btn_params); + } + + function setParams(params) { + if (!buttonCheckVersion()) { + return settingsButton; + } + if (typeof params.is_visible !== 'undefined') { + isVisible = !!params.is_visible; + } + updateButton(); + return settingsButton; + } + + settingsButton.onClick = function(callback) { + if (buttonCheckVersion()) { + onWebViewEvent('settingsButtonClicked', callback); + } + return settingsButton; + }; + settingsButton.offClick = function(callback) { + if (buttonCheckVersion()) { + offWebViewEvent('settingsButtonClicked', callback); + } + return settingsButton; + }; + settingsButton.show = function() { + return setParams({is_visible: true}); + }; + settingsButton.hide = function() { + return setParams({is_visible: false}); + }; + return settingsButton; + })(); + + var HapticFeedback = (function() { + var hapticFeedback = {}; + + function triggerFeedback(params) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] HapticFeedback is not supported in version ' + webAppVersion); + return hapticFeedback; + } + if (params.type == 'impact') { + if (params.impact_style != 'light' && + params.impact_style != 'medium' && + params.impact_style != 'heavy' && + params.impact_style != 'rigid' && + params.impact_style != 'soft') { + console.error('[Telegram.WebApp] Haptic impact style is invalid', params.impact_style); + throw Error('WebAppHapticImpactStyleInvalid'); + } + } else if (params.type == 'notification') { + if (params.notification_type != 'error' && + params.notification_type != 'success' && + params.notification_type != 'warning') { + console.error('[Telegram.WebApp] Haptic notification type is invalid', params.notification_type); + throw Error('WebAppHapticNotificationTypeInvalid'); + } + } else if (params.type == 'selection_change') { + // no params needed + } else { + console.error('[Telegram.WebApp] Haptic feedback type is invalid', params.type); + throw Error('WebAppHapticFeedbackTypeInvalid'); + } + WebView.postEvent('web_app_trigger_haptic_feedback', false, params); + return hapticFeedback; + } + + hapticFeedback.impactOccurred = function(style) { + return triggerFeedback({type: 'impact', impact_style: style}); + }; + hapticFeedback.notificationOccurred = function(type) { + return triggerFeedback({type: 'notification', notification_type: type}); + }; + hapticFeedback.selectionChanged = function() { + return triggerFeedback({type: 'selection_change'}); + }; + return hapticFeedback; + })(); + + var CloudStorage = (function() { + var cloudStorage = {}; + + function invokeStorageMethod(method, params, callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] CloudStorage is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + invokeCustomMethod(method, params, callback); + return cloudStorage; + } + + cloudStorage.setItem = function(key, value, callback) { + return invokeStorageMethod('saveStorageValue', {key: key, value: value}, callback); + }; + cloudStorage.getItem = function(key, callback) { + return cloudStorage.getItems([key], callback ? function(err, res) { + if (err) callback(err); + else callback(null, res[key]); + } : null); + }; + cloudStorage.getItems = function(keys, callback) { + return invokeStorageMethod('getStorageValues', {keys: keys}, callback); + }; + cloudStorage.removeItem = function(key, callback) { + return cloudStorage.removeItems([key], callback); + }; + cloudStorage.removeItems = function(keys, callback) { + return invokeStorageMethod('deleteStorageValues', {keys: keys}, callback); + }; + cloudStorage.getKeys = function(callback) { + return invokeStorageMethod('getStorageKeys', {}, callback); + }; + return cloudStorage; + })(); + + var BiometricManager = (function() { + var isInited = false; + var isBiometricAvailable = false; + var biometricType = 'unknown'; + var isAccessRequested = false; + var isAccessGranted = false; + var isBiometricTokenSaved = false; + var deviceId = ''; + + var biometricManager = {}; + Object.defineProperty(biometricManager, 'isInited', { + get: function(){ return isInited; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isBiometricAvailable', { + get: function(){ return isInited && isBiometricAvailable; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'biometricType', { + get: function(){ return biometricType || 'unknown'; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isAccessRequested', { + get: function(){ return isAccessRequested; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isAccessGranted', { + get: function(){ return isAccessRequested && isAccessGranted; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isBiometricTokenSaved', { + get: function(){ return isBiometricTokenSaved; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'deviceId', { + get: function(){ return deviceId || ''; }, + enumerable: true + }); + + var initRequestState = {callbacks: []}; + var accessRequestState = false; + var authRequestState = false; + var tokenRequestState = false; + + WebView.onEvent('biometry_info_received', onBiometryInfoReceived); + WebView.onEvent('biometry_auth_requested', onBiometryAuthRequested); + WebView.onEvent('biometry_token_updated', onBiometryTokenUpdated); + + function onBiometryInfoReceived(eventType, eventData) { + isInited = true; + if (eventData.available) { + isBiometricAvailable = true; + biometricType = eventData.type || 'unknown'; + if (eventData.access_requested) { + isAccessRequested = true; + isAccessGranted = !!eventData.access_granted; + isBiometricTokenSaved = !!eventData.token_saved; + } else { + isAccessRequested = false; + isAccessGranted = false; + isBiometricTokenSaved = false; + } + } else { + isBiometricAvailable = false; + biometricType = 'unknown'; + isAccessRequested = false; + isAccessGranted = false; + isBiometricTokenSaved = false; + } + deviceId = eventData.device_id || ''; + + if (initRequestState.callbacks.length > 0) { + for (var i = 0; i < initRequestState.callbacks.length; i++) { + var callback = initRequestState.callbacks[i]; + callback(); + } + } + if (accessRequestState) { + var state = accessRequestState; + accessRequestState = false; + if (state.callback) { + state.callback(isAccessGranted); + } + } + receiveWebViewEvent('biometricManagerUpdated'); + } + function onBiometryAuthRequested(eventType, eventData) { + var isAuthenticated = (eventData.status == 'authorized'), + biometricToken = eventData.token || ''; + if (authRequestState) { + var state = authRequestState; + authRequestState = false; + if (state.callback) { + state.callback(isAuthenticated, isAuthenticated ? biometricToken : null); + } + } + receiveWebViewEvent('biometricAuthRequested', isAuthenticated ? { + isAuthenticated: true, + biometricToken: biometricToken + } : { + isAuthenticated: false + }); + } + function onBiometryTokenUpdated(eventType, eventData) { + var applied = false; + if (isBiometricAvailable && + isAccessRequested) { + if (eventData.status == 'updated') { + isBiometricTokenSaved = true; + applied = true; + } + else if (eventData.status == 'removed') { + isBiometricTokenSaved = false; + applied = true; + } + } + if (tokenRequestState) { + var state = tokenRequestState; + tokenRequestState = false; + if (state.callback) { + state.callback(applied); + } + } + receiveWebViewEvent('biometricTokenUpdated', { + isUpdated: applied + }); + } + + function checkVersion() { + if (!versionAtLeast('7.2')) { + console.warn('[Telegram.WebApp] BiometricManager is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function checkInit() { + if (!isInited) { + console.error('[Telegram.WebApp] BiometricManager should be inited before using.'); + throw Error('WebAppBiometricManagerNotInited'); + } + return true; + } + + biometricManager.init = function(callback) { + if (!checkVersion()) { + return biometricManager; + } + if (isInited) { + return biometricManager; + } + if (callback) { + initRequestState.callbacks.push(callback); + } + WebView.postEvent('web_app_biometry_get_info', false); + return biometricManager; + }; + biometricManager.requestAccess = function(params, callback) { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (accessRequestState) { + console.error('[Telegram.WebApp] Access is already requested'); + throw Error('WebAppBiometricManagerAccessRequested'); + } + var popup_params = {}; + if (typeof params.reason !== 'undefined') { + var reason = strTrim(params.reason); + if (reason.length > 128) { + console.error('[Telegram.WebApp] Biometric reason is too long', reason); + throw Error('WebAppBiometricRequestAccessParamInvalid'); + } + if (reason.length > 0) { + popup_params.reason = reason; + } + } + + accessRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_request_access', false, popup_params); + return biometricManager; + }; + biometricManager.authenticate = function(params, callback) { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessGranted) { + console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); + throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); + } + if (authRequestState) { + console.error('[Telegram.WebApp] Authentication request is already in progress.'); + throw Error('WebAppBiometricManagerAuthenticationRequested'); + } + var popup_params = {}; + if (typeof params.reason !== 'undefined') { + var reason = strTrim(params.reason); + if (reason.length > 128) { + console.error('[Telegram.WebApp] Biometric reason is too long', reason); + throw Error('WebAppBiometricRequestAccessParamInvalid'); + } + if (reason.length > 0) { + popup_params.reason = reason; + } + } + + authRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_request_auth', false, popup_params); + return biometricManager; + }; + biometricManager.updateBiometricToken = function(token, callback) { + if (!checkVersion()) { + return biometricManager; + } + token = token || ''; + if (token.length > 1024) { + console.error('[Telegram.WebApp] Token is too long', token); + throw Error('WebAppBiometricManagerTokenInvalid'); + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessGranted) { + console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); + throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); + } + if (tokenRequestState) { + console.error('[Telegram.WebApp] Token request is already in progress.'); + throw Error('WebAppBiometricManagerTokenUpdateRequested'); + } + tokenRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_update_token', false, {token: token}); + return biometricManager; + }; + biometricManager.openSettings = function() { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessRequested) { + console.error('[Telegram.WebApp] Biometric access was not requested yet.'); + throw Error('WebAppBiometricManagerBiometricsAccessNotRequested'); + } + if (isAccessGranted) { + console.warn('[Telegram.WebApp] Biometric access was granted by the user, no need to go to settings.'); + return biometricManager; + } + WebView.postEvent('web_app_biometry_open_settings', false); + return biometricManager; + }; + return biometricManager; + })(); + + var webAppInvoices = {}; + function onInvoiceClosed(eventType, eventData) { + if (eventData.slug && webAppInvoices[eventData.slug]) { + var invoiceData = webAppInvoices[eventData.slug]; + delete webAppInvoices[eventData.slug]; + if (invoiceData.callback) { + invoiceData.callback(eventData.status); + } + receiveWebViewEvent('invoiceClosed', { + url: invoiceData.url, + status: eventData.status + }); + } + } + + var webAppPopupOpened = false; + function onPopupClosed(eventType, eventData) { + if (webAppPopupOpened) { + var popupData = webAppPopupOpened; + webAppPopupOpened = false; + var button_id = null; + if (typeof eventData.button_id !== 'undefined') { + button_id = eventData.button_id; + } + if (popupData.callback) { + popupData.callback(button_id); + } + receiveWebViewEvent('popupClosed', { + button_id: button_id + }); + } + } + + var webAppScanQrPopupOpened = false; + function onQrTextReceived(eventType, eventData) { + if (webAppScanQrPopupOpened) { + var popupData = webAppScanQrPopupOpened; + var data = null; + if (typeof eventData.data !== 'undefined') { + data = eventData.data; + } + if (popupData.callback) { + if (popupData.callback(data)) { + webAppScanQrPopupOpened = false; + WebView.postEvent('web_app_close_scan_qr_popup', false); + } + } + receiveWebViewEvent('qrTextReceived', { + data: data + }); + } + } + function onScanQrPopupClosed(eventType, eventData) { + webAppScanQrPopupOpened = false; + receiveWebViewEvent('scanQrPopupClosed'); + } + + function onClipboardTextReceived(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var data = null; + if (typeof eventData.data !== 'undefined') { + data = eventData.data; + } + if (requestData.callback) { + requestData.callback(data); + } + receiveWebViewEvent('clipboardTextReceived', { + data: data + }); + } + } + + var WebAppWriteAccessRequested = false; + function onWriteAccessRequested(eventType, eventData) { + if (WebAppWriteAccessRequested) { + var requestData = WebAppWriteAccessRequested; + WebAppWriteAccessRequested = false; + if (requestData.callback) { + requestData.callback(eventData.status == 'allowed'); + } + receiveWebViewEvent('writeAccessRequested', { + status: eventData.status + }); + } + } + + function getRequestedContact(callback, timeout) { + var reqTo, fallbackTo, reqDelay = 0; + var reqInvoke = function() { + invokeCustomMethod('getRequestedContact', {}, function(err, res) { + if (res && res.length) { + clearTimeout(fallbackTo); + callback(res); + } else { + reqDelay += 50; + reqTo = setTimeout(reqInvoke, reqDelay); + } + }); + }; + var fallbackInvoke = function() { + clearTimeout(reqTo); + callback(''); + }; + fallbackTo = setTimeout(fallbackInvoke, timeout); + reqInvoke(); + } + + var WebAppContactRequested = false; + function onPhoneRequested(eventType, eventData) { + if (WebAppContactRequested) { + var requestData = WebAppContactRequested; + WebAppContactRequested = false; + var requestSent = eventData.status == 'sent'; + var webViewEvent = { + status: eventData.status + }; + if (requestSent) { + getRequestedContact(function(res) { + if (res && res.length) { + webViewEvent.response = res; + webViewEvent.responseUnsafe = Utils.urlParseQueryString(res); + for (var key in webViewEvent.responseUnsafe) { + var val = webViewEvent.responseUnsafe[key]; + try { + if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || + val.substr(0, 1) == '[' && val.substr(-1) == ']') { + webViewEvent.responseUnsafe[key] = JSON.parse(val); + } + } catch (e) {} + } + } + if (requestData.callback) { + requestData.callback(requestSent, webViewEvent); + } + receiveWebViewEvent('contactRequested', webViewEvent); + }, 3000); + } else { + if (requestData.callback) { + requestData.callback(requestSent, webViewEvent); + } + receiveWebViewEvent('contactRequested', webViewEvent); + } + } + } + + function onCustomMethodInvoked(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var res = null, err = null; + if (typeof eventData.result !== 'undefined') { + res = eventData.result; + } + if (typeof eventData.error !== 'undefined') { + err = eventData.error; + } + if (requestData.callback) { + requestData.callback(err, res); + } + } + } + + function invokeCustomMethod(method, params, callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method invokeCustomMethod is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id, method: method, params: params || {}}; + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent('web_app_invoke_custom_method', false, req_params); + }; + + if (!window.Telegram) { + window.Telegram = {}; + } + + Object.defineProperty(WebApp, 'initData', { + get: function(){ return webAppInitData; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'initDataUnsafe', { + get: function(){ return webAppInitDataUnsafe; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'version', { + get: function(){ return webAppVersion; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'platform', { + get: function(){ return webAppPlatform; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'colorScheme', { + get: function(){ return colorScheme; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'themeParams', { + get: function(){ return themeParams; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isExpanded', { + get: function(){ return isExpanded; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'viewportHeight', { + get: function(){ return (viewportHeight === false ? window.innerHeight : viewportHeight) - bottomBarHeight; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'viewportStableHeight', { + get: function(){ return (viewportStableHeight === false ? window.innerHeight : viewportStableHeight) - bottomBarHeight; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isClosingConfirmationEnabled', { + set: function(val){ setClosingConfirmation(val); }, + get: function(){ return isClosingConfirmationEnabled; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isVerticalSwipesEnabled', { + set: function(val){ toggleVerticalSwipes(val); }, + get: function(){ return isVerticalSwipesEnabled; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'headerColor', { + set: function(val){ setHeaderColor(val); }, + get: function(){ return getHeaderColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'backgroundColor', { + set: function(val){ setBackgroundColor(val); }, + get: function(){ return getBackgroundColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'bottomBarColor', { + set: function(val){ setBottomBarColor(val); }, + get: function(){ return getBottomBarColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'BackButton', { + value: BackButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'MainButton', { + value: MainButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'SecondaryButton', { + value: SecondaryButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'SettingsButton', { + value: SettingsButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'HapticFeedback', { + value: HapticFeedback, + enumerable: true + }); + Object.defineProperty(WebApp, 'CloudStorage', { + value: CloudStorage, + enumerable: true + }); + Object.defineProperty(WebApp, 'BiometricManager', { + value: BiometricManager, + enumerable: true + }); + WebApp.setHeaderColor = function(color_key) { + WebApp.headerColor = color_key; + }; + WebApp.setBackgroundColor = function(color) { + WebApp.backgroundColor = color; + }; + WebApp.setBottomBarColor = function(color) { + WebApp.bottomBarColor = color; + }; + WebApp.enableClosingConfirmation = function() { + WebApp.isClosingConfirmationEnabled = true; + }; + WebApp.disableClosingConfirmation = function() { + WebApp.isClosingConfirmationEnabled = false; + }; + WebApp.enableVerticalSwipes = function() { + WebApp.isVerticalSwipesEnabled = true; + }; + WebApp.disableVerticalSwipes = function() { + WebApp.isVerticalSwipesEnabled = false; + }; + WebApp.isVersionAtLeast = function(ver) { + return versionAtLeast(ver); + }; + WebApp.onEvent = function(eventType, callback) { + onWebViewEvent(eventType, callback); + }; + WebApp.offEvent = function(eventType, callback) {offWebViewEvent(eventType, callback); + }; + WebApp.sendData = function (data) { + if (!data || !data.length) { + console.error('[Telegram.WebApp] Data is required', data); + throw Error('WebAppDataInvalid'); + } + if (byteLength(data) > 4096) { + console.error('[Telegram.WebApp] Data is too long', data); + throw Error('WebAppDataInvalid'); + } + WebView.postEvent('web_app_data_send', false, {data: data}); + }; + WebApp.switchInlineQuery = function (query, choose_chat_types) { + if (!versionAtLeast('6.6')) { + console.error('[Telegram.WebApp] Method switchInlineQuery is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (!initParams.tgWebAppBotInline) { + console.error('[Telegram.WebApp] Inline mode is disabled for this bot. Read more about inline mode: https://core.telegram.org/bots/inline'); + throw Error('WebAppInlineModeDisabled'); + } + query = query || ''; + if (query.length > 256) { + console.error('[Telegram.WebApp] Inline query is too long', query); + throw Error('WebAppInlineQueryInvalid'); + } + var chat_types = []; + if (choose_chat_types) { + if (!Array.isArray(choose_chat_types)) { + console.error('[Telegram.WebApp] Choose chat types should be an array', choose_chat_types); + throw Error('WebAppInlineChooseChatTypesInvalid'); + } + var good_types = {users: 1, bots: 1, groups: 1, channels: 1}; + for (var i = 0; i < choose_chat_types.length; i++) { + var chat_type = choose_chat_types[i]; + if (!good_types[chat_type]) { + console.error('[Telegram.WebApp] Choose chat type is invalid', chat_type); + throw Error('WebAppInlineChooseChatTypeInvalid'); + } + if (good_types[chat_type] != 2) { + good_types[chat_type] = 2; + chat_types.push(chat_type); + } + } + } + WebView.postEvent('web_app_switch_inline_query', false, {query: query, chat_types: chat_types}); + }; + WebApp.openLink = function (url, options) { + var a = document.createElement('A'); + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Url protocol is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + var url = a.href; + options = options || {}; + if (versionAtLeast('6.1')) { + var req_params = {url: url}; + if (versionAtLeast('6.4') && options.try_instant_view) { + req_params.try_instant_view = true; + } + if (versionAtLeast('7.6') && options.try_browser) { + req_params.try_browser = options.try_browser; + } + WebView.postEvent('web_app_open_link', false, req_params); + } else { + window.open(url, '_blank'); + } + }; + WebApp.openTelegramLink = function (url) { + var a = document.createElement('A'); + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Url protocol is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + if (a.hostname != 't.me') { + console.error('[Telegram.WebApp] Url host is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + var path_full = a.pathname + a.search; + if (isIframe || versionAtLeast('6.1')) { + WebView.postEvent('web_app_open_tg_link', false, {path_full: path_full}); + } else { + location.href = 'https://t.me' + path_full; + } + }; + WebApp.openInvoice = function (url, callback) { + var a = document.createElement('A'), match, slug; + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:' || + a.hostname != 't.me' || + !(match = a.pathname.match(/^\/(\$|invoice\/)([A-Za-z0-9\-_=]+)$/)) || + !(slug = match[2])) { + console.error('[Telegram.WebApp] Invoice url is invalid', url); + throw Error('WebAppInvoiceUrlInvalid'); + } + if (!versionAtLeast('6.1')) { + console.error('[Telegram.WebApp] Method openInvoice is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppInvoices[slug]) { + console.error('[Telegram.WebApp] Invoice is already opened'); + throw Error('WebAppInvoiceOpened'); + } + webAppInvoices[slug] = { + url: url, + callback: callback + }; + WebView.postEvent('web_app_open_invoice', false, {slug: slug}); + }; + WebApp.showPopup = function (params, callback) { + if (!versionAtLeast('6.2')) { + console.error('[Telegram.WebApp] Method showPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppPopupOpened) { + console.error('[Telegram.WebApp] Popup is already opened'); + throw Error('WebAppPopupOpened'); + } + var title = ''; + var message = ''; + var buttons = []; + var popup_buttons = {}; + var popup_params = {}; + if (typeof params.title !== 'undefined') { + title = strTrim(params.title); + if (title.length > 64) { + console.error('[Telegram.WebApp] Popup title is too long', title); + throw Error('WebAppPopupParamInvalid'); + } + if (title.length > 0) { + popup_params.title = title; + } + } + if (typeof params.message !== 'undefined') { + message = strTrim(params.message); + } + if (!message.length) { + console.error('[Telegram.WebApp] Popup message is required', params.message); + throw Error('WebAppPopupParamInvalid'); + } + if (message.length > 256) { + console.error('[Telegram.WebApp] Popup message is too long', message); + throw Error('WebAppPopupParamInvalid'); + } + popup_params.message = message; + if (typeof params.buttons !== 'undefined') { + if (!Array.isArray(params.buttons)) { + console.error('[Telegram.WebApp] Popup buttons should be an array', params.buttons); + throw Error('WebAppPopupParamInvalid'); + } + for (var i = 0; i < params.buttons.length; i++) { + var button = params.buttons[i]; + var btn = {}; + var id = ''; + if (typeof button.id !== 'undefined') { + id = button.id.toString(); + if (id.length > 64) { + console.error('[Telegram.WebApp] Popup button id is too long', id); + throw Error('WebAppPopupParamInvalid'); + } + } + btn.id = id; + var button_type = button.type; + if (typeof button_type === 'undefined') { + button_type = 'default'; + } + btn.type = button_type; + if (button_type == 'ok' || + button_type == 'close' || + button_type == 'cancel') { + // no params needed + } else if (button_type == 'default' || + button_type == 'destructive') { + var text = ''; + if (typeof button.text !== 'undefined') { + text = strTrim(button.text); + } + if (!text.length) { + console.error('[Telegram.WebApp] Popup button text is required for type ' + button_type, button.text); + throw Error('WebAppPopupParamInvalid'); + } + if (text.length > 64) { + console.error('[Telegram.WebApp] Popup button text is too long', text); + throw Error('WebAppPopupParamInvalid'); + } + btn.text = text; + } else { + console.error('[Telegram.WebApp] Popup button type is invalid', button_type); + throw Error('WebAppPopupParamInvalid'); + } + buttons.push(btn); + } + } else { + buttons.push({id: '', type: 'close'}); + } + if (buttons.length < 1) { + console.error('[Telegram.WebApp] Popup should have at least one button'); + throw Error('WebAppPopupParamInvalid'); + } + if (buttons.length > 3) { + console.error('[Telegram.WebApp] Popup should not have more than 3 buttons'); + throw Error('WebAppPopupParamInvalid'); + } + popup_params.buttons = buttons; + + webAppPopupOpened = { + callback: callback + }; + WebView.postEvent('web_app_open_popup', false, popup_params); + }; + WebApp.showAlert = function (message, callback) { + WebApp.showPopup({ + message: message + }, callback ? function(){ callback(); } : null); + }; + WebApp.showConfirm = function (message, callback) { + WebApp.showPopup({ + message: message, + buttons: [ + {type: 'ok', id: 'ok'}, + {type: 'cancel'} + ] + }, callback ? function (button_id) { + callback(button_id == 'ok'); + } : null); + }; + WebApp.showScanQrPopup = function (params, callback) { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method showScanQrPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppScanQrPopupOpened) { + console.error('[Telegram.WebApp] Popup is already opened'); + throw Error('WebAppScanQrPopupOpened'); + } + var text = ''; + var popup_params = {}; + if (typeof params.text !== 'undefined') { + text = strTrim(params.text); + if (text.length > 64) { + console.error('[Telegram.WebApp] Scan QR popup text is too long', text); + throw Error('WebAppScanQrPopupParamInvalid'); + } + if (text.length > 0) { + popup_params.text = text; + } + } + + webAppScanQrPopupOpened = { + callback: callback + }; + WebView.postEvent('web_app_open_scan_qr_popup', false, popup_params); + }; + WebApp.closeScanQrPopup = function () { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method closeScanQrPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + + webAppScanQrPopupOpened = false; + WebView.postEvent('web_app_close_scan_qr_popup', false); + }; + WebApp.readTextFromClipboard = function (callback) { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method readTextFromClipboard is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id}; + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent('web_app_read_text_from_clipboard', false, req_params); + }; + WebApp.requestWriteAccess = function (callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method requestWriteAccess is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppWriteAccessRequested) { + console.error('[Telegram.WebApp] Write access is already requested'); + throw Error('WebAppWriteAccessRequested'); + } + WebAppWriteAccessRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_write_access'); + }; + WebApp.requestContact = function (callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method requestContact is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppContactRequested) { + console.error('[Telegram.WebApp] Contact is already requested'); + throw Error('WebAppContactRequested'); + } + WebAppContactRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_phone'); + }; + WebApp.shareToStory = function (media_url, params) { + params = params || {}; + if (!versionAtLeast('7.8')) { + console.error('[Telegram.WebApp] Method shareToStory is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var a = document.createElement('A'); + a.href = media_url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Media url protocol is not supported', url); + throw Error('WebAppMediaUrlInvalid'); + } + var share_params = {}; + share_params.media_url = a.href; + if (typeof params.text !== 'undefined') { + var text = strTrim(params.text); + if (text.length > 2048) { + console.error('[Telegram.WebApp] Text is too long', text); + throw Error('WebAppShareToStoryParamInvalid'); + } + if (text.length > 0) { + share_params.text = text; + } + } + if (typeof params.widget_link !== 'undefined') { + params.widget_link = params.widget_link || {}; + a.href = params.widget_link.url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Link protocol is not supported', url); + throw Error('WebAppShareToStoryParamInvalid'); + } + var widget_link = { + url: a.href + }; + if (typeof params.widget_link.name !== 'undefined') { + var link_name = strTrim(params.widget_link.name); + if (link_name.length > 48) { + console.error('[Telegram.WebApp] Link name is too long', link_name); + throw Error('WebAppShareToStoryParamInvalid'); + } + if (link_name.length > 0) { + widget_link.name = link_name; + } + } + share_params.widget_link = widget_link; + } + + WebView.postEvent('web_app_share_to_story', false, share_params); + }; + WebApp.invokeCustomMethod = function (method, params, callback) { + invokeCustomMethod(method, params, callback); + }; + WebApp.ready = function () { + WebView.postEvent('web_app_ready'); + }; + WebApp.expand = function () { + WebView.postEvent('web_app_expand'); + }; + WebApp.close = function (options) { + options = options || {}; + var req_params = {}; + if (versionAtLeast('7.6') && options.return_back) { + req_params.return_back = true; + } + WebView.postEvent('web_app_close', false, req_params); + }; + + window.Telegram.WebApp = WebApp; + + updateHeaderColor(); + updateBackgroundColor(); + updateBottomBarColor(); + setViewportHeight(); + if (initParams.tgWebAppShowSettings) { + SettingsButton.show(); + } + + window.addEventListener('resize', onWindowResize); + if (isIframe) { + document.addEventListener('click', linkHandler); + } + + WebView.onEvent('theme_changed', onThemeChanged); + WebView.onEvent('viewport_changed', onViewportChanged); + WebView.onEvent('invoice_closed', onInvoiceClosed); + WebView.onEvent('popup_closed', onPopupClosed); + WebView.onEvent('qr_text_received', onQrTextReceived); + WebView.onEvent('scan_qr_popup_closed', onScanQrPopupClosed); + WebView.onEvent('clipboard_text_received', onClipboardTextReceived); + WebView.onEvent('write_access_requested', onWriteAccessRequested); + WebView.onEvent('phone_requested', onPhoneRequested); + WebView.onEvent('custom_method_invoked', onCustomMethodInvoked); + WebView.postEvent('web_app_request_theme'); + WebView.postEvent('web_app_request_viewport'); + +})(); \ No newline at end of file diff --git a/apps/docs/.vitepress/packages.ts b/apps/docs/.vitepress/packages.ts index 5880aa6f0..dd3670405 100644 --- a/apps/docs/.vitepress/packages.ts +++ b/apps/docs/.vitepress/packages.ts @@ -96,6 +96,7 @@ export const packagesSidebar: Sidebar = { scope('mini-app'), scope('popup'), scope('qr-scanner', 'QR Scanner'), + scope('secondary-button'), scope('settings-button'), scope('swipe-behavior'), scope('theme-params'), diff --git a/apps/docs/packages/telegram-apps-sdk/2-x/components/secondary-button.md b/apps/docs/packages/telegram-apps-sdk/2-x/components/secondary-button.md new file mode 100644 index 000000000..fcc0e08af --- /dev/null +++ b/apps/docs/packages/telegram-apps-sdk/2-x/components/secondary-button.md @@ -0,0 +1,205 @@ +# Secondary Button + +The 💠[component](../scopes.md) responsible for the Telegram Mini Apps Secondary Button. + +## Checking Support + +To check if the Secondary Button is supported by the current Telegram Mini Apps version, use the +`isSupported` method: + +::: code-group + +```ts [Variable] +import { secondaryButton } from '@telegram-apps/sdk'; + +secondaryButton.isSupported(); // boolean +``` + +```ts [Functions] +import { isSecondaryButtonSupported } from '@telegram-apps/sdk'; + +isSecondaryButtonSupported(); // boolean +``` + +::: + +## Mounting + +Before using this component, it is necessary to mount it to work with properly configured +properties. To do so, use the `mount` method. It will update the `isMounted` signal property. + +::: code-group + +```ts [Variable] +import { secondaryButton } from '@telegram-apps/sdk'; + +secondaryButton.mount(); +secondaryButton.isMounted(); // true +``` + +```ts [Functions] +import { + mountSecondaryButton, + isSecondaryButtonMounted, +} from '@telegram-apps/sdk'; + +mountSecondaryButton(); +isSecondaryButtonMounted(); // true +``` + +::: + +> [!INFO] +> To extract correctly configured values from theme parameters, this method also mounts +> the [Theme Params](theme-params.md) scope. + +To unmount, use the `unmount` method: + +::: code-group + +```ts [Variable] +secondaryButton.unmount(); +secondaryButton.isMounted(); // false +``` + +```ts [Functions] +import { + unmountSecondaryButton, + isSecondaryButtonMounted, +} from '@telegram-apps/sdk'; + +unmountSecondaryButton(); +isSecondaryButtonMounted(); // false +``` + +::: + +## Settings Properties + +To update the button properties, use the `setParams` method. It accepts an object with optional +properties, each responsible for its own button trait. + +In turn, calling this method updates such signals +as `backgroundColor`, `hasShineEffect`, `isVisible`, `isEnabled`, `isLoaderVisible`, `position`, +`state`, `textColor` and `text`. + +::: code-group + +```ts [Variable] +secondaryButton.setParams({ + backgroundColor: '#000000', + hasShineEffect: true, + isEnabled: true, + isLoaderVisible: true, + isVisible: true, + position: 'top', + text: 'My text', + textColor: '#ffffff' +}); +secondaryButton.backgroundColor(); // '#000000' +secondaryButton.hasShineEffect(); // true +secondaryButton.isEnabled(); // true +secondaryButton.isLoaderVisible(); // true +secondaryButton.isVisible(); // true +secondaryButton.position(); // 'top' +secondaryButton.text(); // 'My text' +secondaryButton.textColor(); // '#ffffff' + +secondaryButton.state(); +// { +// backgroundColor: '#000000', +// hasShineEffect: true, +// isActive: true, +// isLoaderVisible: true, +// isVisible: true, +// position: 'top', +// text: 'My text', +// textColor: '#ffffff' +// } +``` + +```ts [Functions] +import { + setSecondaryButtonParams, + secondaryButtonBackgroundColor, + secondaryButtonHasShineEffect, + isSecondaryButtonVisible, + isSecondaryButtonEnabled, + isSecondaryButtonLoaderVisible, + secondaryButtonState, + secondaryButtonTextColor, + secondaryButtonText, + secondaryButtonPosition, +} from '@telegram-apps/sdk'; + +setSecondaryButtonParams({ + backgroundColor: '#000000', + hasShineEffect: true, + isEnabled: true, + isLoaderVisible: true, + isVisible: true, + position: 'top', + text: 'My text', + textColor: '#ffffff' +}); +secondaryButtonBackgroundColor(); // '#000000' +secondaryButtonHasShineEffect(); // true +isSecondaryButtonEnabled(); // true +isSecondaryButtonLoaderVisible(); // true +isSecondaryButtonVisible(); // true +secondaryButtonPosition(); // 'top' +secondaryButtonText(); // 'My text' +secondaryButtonTextColor(); // '#ffffff' + +secondaryButtonState(); +// { +// backgroundColor: '#000000', +// hasShineEffect: true, +// isActive: true, +// isLoaderVisible: true, +// isVisible: true, +// position: 'top', +// text: 'My text', +// textColor: '#ffffff' +// } +``` + +::: + +## Tracking Click + +To add a button click listener, use the `onClick` method. It returns a function to remove the bound +listener. Alternatively, you can use the `offClick` method. + +::: code-group + +```ts [Variable] +function listener() { + console.log('Clicked!'); +} + +const offClick = secondaryButton.onClick(listener); +offClick(); +// or +secondaryButton.onClick(listener); +secondaryButton.offClick(listener); +``` + +```ts [Functions] +import { + onSecondaryButtonClick, + offSecondaryButtonClick, +} from '@telegram-apps/sdk'; + +function listener() { + console.log('Clicked!'); +} + +const offClick = onSecondaryButtonClick(listener); +offClick(); +// or +onSecondaryButtonClick(listener); +offSecondaryButtonClick(listener); +``` + +::: diff --git a/apps/docs/platform/methods.md b/apps/docs/platform/methods.md index c1308bc62..a141c1f7a 100644 --- a/apps/docs/platform/methods.md +++ b/apps/docs/platform/methods.md @@ -406,9 +406,9 @@ Available since: **v6.1** Updates the Mini App [background color](theming.md#background-and-header-colors). -| Field | Type | Description | -|-------|----------|----------------------------------------------------| -| color | `string` | The Mini App background color in `#RRGGBB` format. | +| Field | Type | Description | +|-------|----------|--------------------------------------------------------------------------------------------------------------| +| color | `string` | The Mini App background color in `#RRGGBB` format, or one of the values: `bg_color` or `secondary_bg_color` | ### `web_app_set_bottom_bar_color` @@ -454,15 +454,15 @@ Updates current [closing behavior](closing-behavior.md). Updates the [Main Button](main-button.md) settings. -| Field | Type | Description | -|---------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Field | Type | Description | Available since | +|---------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| | is_visible | `boolean` | _Optional_. Should the button be displayed. | | is_active | `boolean` | _Optional_. Should the button be enabled. | | is_progress_visible | `boolean` | _Optional_. Should loader inside the button be displayed. Use this property in case, some operation takes time. This loader will make user notified about it. | | text | `string` | _Optional_. Text inside the button. | | color | `string` | _Optional_. The button background color in `#RRGGBB` format. | | text_color | `string` | _Optional_. The button text color in `#RRGGBB` format. | -| has_shine_effect | `boolean` | _Optional_. _Since v7.8_. Should the button have a shining effect. | +| has_shine_effect | `boolean` | _Optional_. Should the button have a shining effect. | `v7.8` | ### `web_app_setup_settings_button` @@ -502,10 +502,97 @@ A method that opens the native story editor. Available since: **v7.10** -The method updates the Secondary Button settings. +The method that updates the Secondary Button settings. -Technically, this button functions the same way as the Main Button and uses the -same [setup method parameters](#web-app-setup-main-button). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
is_visible + boolean + Optional. Should the button be displayed.
is_active + boolean + Optional. Should the button be enabled.
is_progress_visible + boolean + + Optional. Should loader inside the button be displayed. Use this property in case, + some operation takes time. This loader will make user notified about it. +
color + string + Optional. The button background color in #RRGGBB format.
text_color + string + Optional. The button text color in #RRGGBB format.
has_shine_effect + boolean + Optional. Should the button have a shining effect.
position + string + + Optional. Position of the secondary button. It applies only if both the main and + secondary buttons are visible.
Supported values: +
    +
  • + left, displayed to the left of the main button, +
  • +
  • + right, displayed to the right of the main button, +
  • +
  • + top, displayed above the main button, +
  • +
  • + bottom, displayed below the main button. +
  • +
+
### `web_app_switch_inline_query` diff --git a/packages/bridge/src/methods/supports.test.ts b/packages/bridge/src/methods/supports.test.ts index baaa2f2e5..4d410e69b 100644 --- a/packages/bridge/src/methods/supports.test.ts +++ b/packages/bridge/src/methods/supports.test.ts @@ -106,6 +106,7 @@ describe.each<[ ]], ['7.10', [ 'web_app_setup_secondary_button', + 'web_app_set_bottom_bar_color', { title: 'web_app_setup_main_button.has_shine_effect', method: 'web_app_setup_main_button', diff --git a/packages/bridge/src/methods/supports.ts b/packages/bridge/src/methods/supports.ts index d0ec0ea28..032f0d14b 100644 --- a/packages/bridge/src/methods/supports.ts +++ b/packages/bridge/src/methods/supports.ts @@ -97,6 +97,7 @@ export function supports( case 'web_app_setup_swipe_behavior': return versionLessOrEqual('7.7', paramOrVersion); case 'web_app_setup_secondary_button': + case 'web_app_set_bottom_bar_color': return versionLessOrEqual('7.10', paramOrVersion); default: return [ diff --git a/packages/bridge/src/methods/types/methods.ts b/packages/bridge/src/methods/types/methods.ts index db581484a..797e0bad5 100644 --- a/packages/bridge/src/methods/types/methods.ts +++ b/packages/bridge/src/methods/types/methods.ts @@ -10,6 +10,8 @@ import { SwitchInlineQueryChatType, OpenLinkBrowser, BottomBarColor, + BackgroundColor, + SecondaryButtonPosition, } from './misc.js'; interface ButtonParams { @@ -19,30 +21,30 @@ interface ButtonParams { */ has_shine_effect?: boolean; /** - * Should the Main Button be displayed. + * Should the button be displayed. */ is_visible?: boolean; /** - * Should the Main Button be enabled. + * Should the button be enabled. */ is_active?: boolean; /** - * Should loader inside the Main Button be displayed. Use this property in case, some + * Should loader inside the button be displayed. Use this property in case, some * operation takes time. This loader will make user notified about it. */ is_progress_visible?: boolean; /** - * Text inside the Main Button. + * Text inside the button. */ text?: string; /** - * The Main Button background color in `#RRGGBB` format. + * The button background color in `#RRGGBB` format. */ - color?: string; + color?: RGB; /** * The Main Button text color in `#RRGGBB` format. */ - text_color?: string; + text_color?: RGB; } /** @@ -303,9 +305,9 @@ export interface Methods { */ web_app_set_background_color: CreateParams<{ /** - * The Mini App background color in `#RRGGBB` format. + * Color to set. */ - color: RGB; + color: BackgroundColor; }>; /** * Updates the mini app bottom bar background color. @@ -367,7 +369,19 @@ export interface Methods { * Updates the secondary button settings. * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-setup-secondary-button */ - web_app_setup_secondary_button: CreateParams; + web_app_setup_secondary_button: CreateParams; /** * Updates the current state of the Settings Button. * @since v6.10 diff --git a/packages/bridge/src/methods/types/misc.ts b/packages/bridge/src/methods/types/misc.ts index 5c87d78c5..25b54579d 100644 --- a/packages/bridge/src/methods/types/misc.ts +++ b/packages/bridge/src/methods/types/misc.ts @@ -1,14 +1,26 @@ import type { RGB } from '@telegram-apps/types'; +type KnownColorKey = 'bg_color' | 'secondary_bg_color'; + /** * Color key which could be used to update header color. */ -export type HeaderColorKey = 'bg_color' | 'secondary_bg_color'; +export type HeaderColorKey = KnownColorKey; + +/** + * Color key which could be used to update Mini App background color. + */ +export type BackgroundColor = KnownColorKey | RGB; /** * Color key which could be used to update bottom bar background color. */ -export type BottomBarColor = 'bg_color' | 'secondary_bg_color' | 'bottom_bar_bg_color' | RGB; +export type BottomBarColor = KnownColorKey | 'bottom_bar_bg_color' | RGB; + +/** + * Position of the secondary button related to the main one. + */ +export type SecondaryButtonPosition = 'left' | 'right' | 'top' | 'bottom'; /** * Values expected by the `web_app_open_link.try_browser` option. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 162574f4c..bdfb7bde7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -104,23 +104,7 @@ export { } from '@/scopes/components/main-button/instance.js'; export * as MainButton from '@/scopes/components/main-button/static.js'; -export * as miniApp from '@/scopes/components/mini-app/instance.js'; -export { - backgroundColor as miniAppBackgroundColor, - bindCssVars as bindMiniAppCssVars, - close as closeMiniApp, - headerColor as miniAppHeaderColor, - isMounted as isMiniAppMounted, - isCssVarsBound as isMiniAppCssVarsBound, - isDark as isMiniAppDark, - mount as mountMiniApp, - ready as miniAppReady, - state as miniAppState, - setHeaderColor as setMiniAppHeaderColor, - setBackgroundColor as setMiniAppBackgroundColor, - unmount as unmountMiniApp, -} from '@/scopes/components/mini-app/instance.js'; -export * as MiniApp from '@/scopes/components/main-button/static.js'; +export * from '@/scopes/components/mini-app/exports.js'; export * as popup from '@/scopes/components/popup/instance.js'; export { @@ -138,6 +122,8 @@ export { open as openQrScanner, } from '@/scopes/components/qr-scanner/qr-scanner.js'; +export * from '@/scopes/components/secondary-button/exports.js'; + export * as settingsButton from '@/scopes/components/settings-button/settings-button.js'; export { hide as hideSettingsButton, diff --git a/packages/sdk/src/scopes/components/mini-app/exports.ts b/packages/sdk/src/scopes/components/mini-app/exports.ts new file mode 100644 index 000000000..c97b6c3e2 --- /dev/null +++ b/packages/sdk/src/scopes/components/mini-app/exports.ts @@ -0,0 +1,27 @@ +export { + bindCssVars as bindMiniAppCssVars, + close as closeMiniApp, + mount as mountMiniApp, + ready as miniAppReady, + setHeaderColor as setMiniAppHeaderColor, + setBackgroundColor as setMiniAppBackgroundColor, + setBottomBarColor as setMiniAppBottomBarColor, + unmount as unmountMiniApp, +} from './methods.js'; +export { + backgroundColor as miniAppBackgroundColor, + bottomBarColor as miniAppBottomBarColor, + bottomBarColorRGB as miniAppBottomBarColorRGB, + headerColor as miniAppHeaderColor, + headerColorRGB as miniAppHeaderColorRGB, + isMounted as isMiniAppMounted, + isCssVarsBound as isMiniAppCssVarsBound, + isDark as isMiniAppDark, + state as miniAppState, +} from './signals.js'; +export type { + HeaderColor as MiniAppHeaderColor, + GetCssVarNameFn as MiniAppGetCssVarNameFn, + State as MiniAppState, +} from './types.js'; +export * as miniApp from './exports.variable.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/secondary-button/instance.ts b/packages/sdk/src/scopes/components/mini-app/exports.variable.ts similarity index 100% rename from packages/sdk/src/scopes/components/secondary-button/instance.ts rename to packages/sdk/src/scopes/components/mini-app/exports.variable.ts diff --git a/packages/sdk/src/scopes/components/mini-app/methods.test.ts b/packages/sdk/src/scopes/components/mini-app/methods.test.ts index cc44471ab..ce0c5f750 100644 --- a/packages/sdk/src/scopes/components/mini-app/methods.test.ts +++ b/packages/sdk/src/scopes/components/mini-app/methods.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it, Mock, MockInstance, vi } from 'vitest'; +import { beforeEach, describe, expect, it, MockInstance, vi } from 'vitest'; import { createWindow } from 'test-utils'; import type { ThemeParams } from '@telegram-apps/bridge'; import { mockPostEvent } from '@test-utils/mockPostEvent.js'; import { resetPackageState, resetSignal } from '@test-utils/reset.js'; import * as themeParams from '@/scopes/components/theme-params/instance.js'; +import { $version } from '@/scopes/globals.js'; import { headerColor, @@ -13,8 +14,20 @@ import { isMounted, isDark, isCssVarsBound, + bottomBarColor, + bottomBarColorRGB, + backgroundColorRGB, + headerColorRGB, } from './signals.js'; -import { bindCssVars, mount, setBackgroundColor, setHeaderColor } from './methods.js'; +import { + bindCssVars, + close, + mount, + ready, + setBackgroundColor, + setBottomBarColor, + setHeaderColor, +} from './methods.js'; type SetPropertyFn = typeof document.documentElement.style.setProperty; let setPropertySpy: MockInstance; @@ -45,6 +58,10 @@ beforeEach(() => { isMounted, isDark, isCssVarsBound, + bottomBarColor, + bottomBarColorRGB, + backgroundColorRGB, + headerColorRGB, ].forEach(resetSignal); createWindow(); @@ -56,101 +73,216 @@ beforeEach(() => { }); describe('bindCssVars', () => { - describe('backgroundColor', () => { - it('should set --tg-bg-color == backgroundColor()', () => { - backgroundColor.set('#abcdef'); + describe('background color', () => { + it('should set --tg-bg-color == backgroundColorRGB()', () => { + backgroundColor.set('#fedcba'); bindCssVars(); - expect(setPropertySpy).toHaveBeenCalledOnce(); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', '#abcdef'); + expect(setPropertySpy).toHaveBeenCalledTimes(3); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', '#fedcba'); }); }); - describe('headerColor', () => { - it('should set --tg-header-color == headerColor() if header color is RGB', () => { + describe('header color', () => { + it('should set --tg-header-color == headerColorRGB()', () => { headerColor.set('#fedcba'); bindCssVars(); - expect(setPropertySpy).toHaveBeenCalledTimes(2); + expect(setPropertySpy).toHaveBeenCalledTimes(3); expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#fedcba'); }); + }); - it('should set --tg-header-color equal to theme params bgColor if headerColor is bg_color', () => { - headerColor.set('bg_color'); - themeParams.state.set({ bgColor: '#aaaaaa' }); - bindCssVars(); - expect(setPropertySpy).toHaveBeenCalledTimes(2); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#aaaaaa'); - }); - - it('should set --tg-header-color equal to theme params secondaryBgColor if headerColor is secondary_bg_color', () => { - headerColor.set('secondary_bg_color'); - themeParams.state.set({ secondaryBgColor: '#dddddd' }); + describe('bottom bar color', () => { + it('should set --tg-bottom-bar-color == bottomBarColorRGB()', () => { + bottomBarColor.set('#fedcba'); bindCssVars(); - expect(setPropertySpy).toHaveBeenCalledTimes(2); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#dddddd'); + expect(setPropertySpy).toHaveBeenCalledTimes(3); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bottom-bar-color', '#fedcba'); }); }); describe('mounted', () => { beforeEach(mount); - describe('backgroundColor', () => { - it('should set --tg-bg-color == backgroundColor() when background color changed', () => { + describe('background color', () => { + it('should set --tg-bg-color == backgroundColorRGB() when theme changes', () => { bindCssVars(); + themeParams.state.set({ secondaryBgColor: '#ddddaa' }); + setPropertySpy.mockClear(); + backgroundColor.set('secondary_bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', '#ddddaa'); + + themeParams.state.set({ bgColor: '#aafedd' }); setPropertySpy.mockClear(); - backgroundColor.set('#aaaaaa'); + backgroundColor.set('bg_color'); expect(setPropertySpy).toHaveBeenCalledOnce(); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', '#aaaaaa'); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', '#aafedd'); + + setPropertySpy.mockClear(); + backgroundColor.set('secondary_bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bg-color', null); }); }); - describe('headerColor', () => { - it('should update --tg-header-color each time headerColor or related theme param changes', () => { + describe('header color', () => { + it('should set --tg-header-color == headerColorRGB() when theme changes', () => { bindCssVars(); - themeParams.state.set({ - bgColor: '#ffffff', - secondaryBgColor: '#eeeeee', - }); + themeParams.state.set({ secondaryBgColor: '#ddddaa' }); setPropertySpy.mockClear(); headerColor.set('secondary_bg_color'); expect(setPropertySpy).toHaveBeenCalledOnce(); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#eeeeee'); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#ddddaa'); + themeParams.state.set({ bgColor: '#aafedd' }); setPropertySpy.mockClear(); headerColor.set('bg_color'); expect(setPropertySpy).toHaveBeenCalledOnce(); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#ffffff'); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#aafedd'); setPropertySpy.mockClear(); - themeParams.state.set({ - bgColor: '#aaaaaa', - }); + headerColor.set('secondary_bg_color'); expect(setPropertySpy).toHaveBeenCalledOnce(); - expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', '#aaaaaa'); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-header-color', null); }); }); - let spy: Mock; + describe('bottom bar color', () => { + it('should set --tg-bottom-bar-color == bottomBarColorRGB() when theme changes', () => { + bindCssVars(); + themeParams.state.set({ bgColor: '#aafedd' }); + setPropertySpy.mockClear(); + bottomBarColor.set('bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bottom-bar-color', '#aafedd'); - beforeEach(() => { - spy = mockPostEvent(); - }); + themeParams.state.set({ secondaryBgColor: '#ddddaa' }); + setPropertySpy.mockClear(); + bottomBarColor.set('secondary_bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bottom-bar-color', '#ddddaa'); + + themeParams.state.set({ bottomBarBgColor: '#ddaacc' }); + setPropertySpy.mockClear(); + bottomBarColor.set('bottom_bar_bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bottom-bar-color', '#ddaacc'); - describe('setBackgroundColor', () => { - it('should call "web_app_set_background_color" method with { color: {{color}} }', () => { - expect(spy).toHaveBeenCalledTimes(0); - setBackgroundColor('#abcdef'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith('web_app_set_background_color', { color: '#abcdef' }); + setPropertySpy.mockClear(); + bottomBarColor.set('bg_color'); + expect(setPropertySpy).toHaveBeenCalledOnce(); + expect(setPropertySpy).toHaveBeenCalledWith('--tg-bottom-bar-color', null); }); }); + }); +}); - describe('setHeaderColor', () => { - it('should call "web_app_set_header_color" method with { color_key: {{color_key}} }', () => { - expect(spy).toHaveBeenCalledTimes(0); - setHeaderColor('secondary_bg_color'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith('web_app_set_header_color', { color_key: 'secondary_bg_color' }); - }); +describe('mounted', () => { + beforeEach(mount); + + describe('setBackgroundColor', () => { + it('should call "web_app_set_background_color" method with { color: {{color}} }', () => { + const spy = mockPostEvent(); + expect(spy).toHaveBeenCalledTimes(0); + setBackgroundColor('#abcdef'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('web_app_set_background_color', { color: '#abcdef' }); + }); + }); + + describe('setBottomBarColor', () => { + it('should call "web_app_set_bottom_bar_color" method with { color: {{color}} }', () => { + const spy = mockPostEvent(); + expect(spy).toHaveBeenCalledTimes(0); + setBottomBarColor('#abcdef'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('web_app_set_bottom_bar_color', { color: '#abcdef' }); + }); + }); + + describe('setHeaderColor', () => { + it('should call "web_app_set_header_color" method with { color_key: {{color_key}} }', () => { + const spy = mockPostEvent(); + expect(spy).toHaveBeenCalledTimes(0); + setHeaderColor('secondary_bg_color'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('web_app_set_header_color', { color_key: 'secondary_bg_color' }); + }); + }); +}); + +describe('close', () => { + it('should call "web_app_close" with "return_back" option', () => { + const spy = mockPostEvent(); + close(false); + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith('web_app_close', { return_back: false }); + }); +}); + +describe('ready', () => { + it('should call "web_app_ready"', () => { + const spy = mockPostEvent(); + ready(); + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith('web_app_ready', undefined); + }); +}); + +describe('setBackgroundColor', () => { + describe('isSupported', () => { + it('should return false if version is less than 6.1. True otherwise', () => { + $version.set('6.0'); + expect(setBackgroundColor.isSupported()).toBe(false); + + $version.set('6.1'); + expect(setBackgroundColor.isSupported()).toBe(true); + + $version.set('6.2'); + expect(setBackgroundColor.isSupported()).toBe(true); + }); + }); +}); + +describe('setBottomBarColor', () => { + describe('isSupported', () => { + it('should return false if version is less than 7.10. True otherwise', () => { + $version.set('7.9'); + expect(setBottomBarColor.isSupported()).toBe(false); + + $version.set('7.10'); + expect(setBottomBarColor.isSupported()).toBe(true); + + $version.set('7.11'); + expect(setBottomBarColor.isSupported()).toBe(true); + }); + }); +}); + +describe('setHeaderColor', () => { + describe('isSupported', () => { + it('should return false if version is less than 6.1. True otherwise', () => { + $version.set('6.0'); + expect(setHeaderColor.isSupported()).toBe(false); + + $version.set('6.1'); + expect(setHeaderColor.isSupported()).toBe(true); + + $version.set('6.2'); + expect(setHeaderColor.isSupported()).toBe(true); + }); + }); + + describe('supports "color"', () => { + it('should return false if version is less than 6.9. True otherwise', () => { + $version.set('6.8'); + expect(setHeaderColor.supports('color')).toBe(false); + + $version.set('6.9'); + expect(setHeaderColor.supports('color')).toBe(true); + + $version.set('6.10'); + expect(setHeaderColor.supports('color')).toBe(true); }); }); }); diff --git a/packages/sdk/src/scopes/components/mini-app/methods.ts b/packages/sdk/src/scopes/components/mini-app/methods.ts index 3d925992d..1ff838005 100644 --- a/packages/sdk/src/scopes/components/mini-app/methods.ts +++ b/packages/sdk/src/scopes/components/mini-app/methods.ts @@ -7,9 +7,11 @@ import { setCssVar, TypedError, type RGB, + type BottomBarColor, BackgroundColor, } from '@telegram-apps/bridge'; import { isRGB } from '@telegram-apps/transformers'; import { isPageReload } from '@telegram-apps/navigation'; +import type { Computed } from '@telegram-apps/signals'; import { postEvent } from '@/scopes/globals.js'; import { withIsSupported } from '@/scopes/withIsSupported.js'; @@ -17,8 +19,6 @@ import { withSupports } from '@/scopes/withSupports.js'; import { ERR_ALREADY_CALLED } from '@/errors.js'; import { mount as tpMount, - state as tpState, - backgroundColor as tpBackgroundColor, headerBackgroundColor as tpHeaderBackgroundColor, } from '@/scopes/components/theme-params/instance.js'; @@ -28,12 +28,17 @@ import { isCssVarsBound, state, isMounted, + bottomBarColor, + headerColorRGB, + bottomBarColorRGB, + backgroundColorRGB, } from './signals.js'; import type { GetCssVarNameFn, HeaderColor, State } from './types.js'; type StorageValue = State; const SET_BG_COLOR_METHOD = 'web_app_set_background_color'; +const SET_BOTTOM_BAR_BG_COLOR_METHOD = 'web_app_set_bottom_bar_color'; const SET_HEADER_COLOR_METHOD = 'web_app_set_header_color'; const STORAGE_KEY = 'miniApp'; @@ -43,12 +48,12 @@ const STORAGE_KEY = 'miniApp'; * Default variables: * - `--tg-bg-color` * - `--tg-header-color` + * - `--tg-bottom-bar-color` * * Variables are being automatically updated if theme parameters were changed. * * @param getCSSVarName - function, returning complete CSS variable name for the specified * mini app key. - * MiniApp property. * @returns Function to stop updating variables. * @throws {TypedError} ERR_ALREADY_CALLED */ @@ -56,45 +61,36 @@ export function bindCssVars(getCSSVarName?: GetCssVarNameFn): VoidFunction { if (isCssVarsBound()) { throw new TypedError(ERR_ALREADY_CALLED); } - getCSSVarName ||= (prop) => `--tg-${camelToKebab(prop)}`; - const bgVar = getCSSVarName('bgColor'); - const headerVar = getCSSVarName('headerColor'); - - function updateHeaderColor() { - const tp = tpState(); - - const h = headerColor(); - if (isRGB(h)) { - return setCssVar(headerVar, h); + const [addCleanup, cleanup] = createCbCollector(); + + /** + * Binds specified CSS variable to a signal. + * @param cssVar - CSS variable name. + * @param signal - signal to listen changes to. + */ + function bind(cssVar: string, signal: Computed) { + function update() { + setCssVar(cssVar, signal() || null); } - const { secondaryBgColor, bgColor } = tp; - if (h === 'bg_color' && bgColor) { - return setCssVar(headerVar, bgColor); - } - if (h === 'secondary_bg_color' && secondaryBgColor) { - setCssVar(headerVar, secondaryBgColor); - } - } + // Instantly set CSS variable. + update(); - function updateBgColor() { - setCssVar(bgVar, backgroundColor()); + // Remember to clean this relation up. + addCleanup(signal.sub(update), deleteCssVar.bind(null, cssVar)); } - updateBgColor(); - updateHeaderColor(); + getCSSVarName ||= (prop) => `--tg-${camelToKebab(prop)}`; + bind(getCSSVarName('bgColor'), backgroundColorRGB); + bind(getCSSVarName('bottomBarColor'), bottomBarColorRGB); + bind(getCSSVarName('headerColor'), headerColorRGB); + addCleanup(() => { + isCssVarsBound.set(false); + }); - const [, cleanup] = createCbCollector( - backgroundColor.sub(updateBgColor), - [headerColor, tpState].map(s => s.sub(updateHeaderColor)), - ); isCssVarsBound.set(true); - return () => { - [headerVar, bgVar].forEach(deleteCssVar); - cleanup(); - isCssVarsBound.set(false); - }; + return cleanup; } /** @@ -110,27 +106,37 @@ export function close(returnBack?: boolean): void { * * This function restores the component state and is automatically saving it in the local storage * if it changed. + * + * Internally, the function mounts the Theme Params component to work with correctly extracted + * theme palette values. */ export function mount(): void { if (!isMounted()) { const s = isPageReload() && getStorageValue(STORAGE_KEY); tpMount(); - backgroundColor.set(s ? s.backgroundColor : tpBackgroundColor() || '#000000'); + backgroundColor.set(s ? s.backgroundColor : 'bg_color'); backgroundColor.sub(onBgColorChanged); + bottomBarColor.set(s ? s.bottomBarColor : 'bottom_bar_bg_color'); + bottomBarColor.sub(onBottomBarBgColorChanged); headerColor.set(s ? s.headerColor : tpHeaderBackgroundColor() || 'bg_color'); headerColor.sub(onHeaderColorChanged); isMounted.set(true); } } -function onHeaderColorChanged(color: HeaderColor): void { +function onBgColorChanged(color: BackgroundColor): void { saveState(); - postEvent(SET_HEADER_COLOR_METHOD, isRGB(color) ? { color } : { color_key: color }); + postEvent(SET_BG_COLOR_METHOD, { color }); } -function onBgColorChanged(color: RGB): void { +function onBottomBarBgColorChanged(color: BottomBarColor): void { saveState(); - postEvent(SET_BG_COLOR_METHOD, { color }); + postEvent(SET_BOTTOM_BAR_BG_COLOR_METHOD, { color }); +} + +function onHeaderColorChanged(color: HeaderColor): void { + saveState(); + postEvent(SET_HEADER_COLOR_METHOD, isRGB(color) ? { color } : { color_key: color }); } /** @@ -158,6 +164,13 @@ export const setBackgroundColor = withIsSupported((color: RGB): void => { backgroundColor.set(color); }, SET_BG_COLOR_METHOD); +/** + * Updates the bottom bar background color. + */ +export const setBottomBarColor = withIsSupported((color: BottomBarColor) => { + bottomBarColor.set(color); +}, SET_BOTTOM_BAR_BG_COLOR_METHOD); + /** * Updates the header color. */ @@ -173,6 +186,7 @@ export const setHeaderColor = withSupports( */ export function unmount(): void { backgroundColor.unsub(onBgColorChanged); + bottomBarColor.unsub(onBottomBarBgColorChanged); headerColor.unsub(onHeaderColorChanged); isMounted.set(false); } diff --git a/packages/sdk/src/scopes/components/mini-app/signals.test.ts b/packages/sdk/src/scopes/components/mini-app/signals.test.ts new file mode 100644 index 000000000..4c61f7060 --- /dev/null +++ b/packages/sdk/src/scopes/components/mini-app/signals.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetPackageState, resetSignal } from '@test-utils/reset.js'; + +import { state as tpState } from '@/scopes/components/theme-params/signals.js'; + +import { + bottomBarColor, + bottomBarColorRGB, + backgroundColor, + backgroundColorRGB, + headerColor, + headerColorRGB, + isMounted, + isDark, + isCssVarsBound, + state, +} from './signals.js'; + +beforeEach(() => { + resetPackageState(); + [ + bottomBarColorRGB, + backgroundColor, + bottomBarColor, + headerColorRGB, + headerColor, + isMounted, + isDark, + isCssVarsBound, + state, + ].forEach(resetSignal); + vi.restoreAllMocks(); +}); + +describe('bottomBarColorRGB', () => { + it('should return value of bottomBarColor signal if it is RGB', () => { + bottomBarColor.set('#ffaabb'); + expect(bottomBarColorRGB()).toBe('#ffaabb'); + }); + + describe('bottomBarColor signal value is "bottom_bar_bg_color"', () => { + beforeEach(() => { + bottomBarColor.set('bottom_bar_bg_color'); + }); + + it('should return value of theme\'s bottomBarBgColor property if it exists', () => { + tpState.set({ bottomBarBgColor: '#abcdef' }); + expect(bottomBarColorRGB()).toBe(tpState().bottomBarBgColor); + }); + + it('should return value of theme\'s secondaryBgColor property if it exists', () => { + tpState.set({ secondaryBgColor: '#ddffea' }); + expect(bottomBarColorRGB()).toBe(tpState().secondaryBgColor); + }); + + it('should return undefined if bottomBarBgColor and secondaryBgColor keys are not presented in theme', () => { + tpState.set({}); + expect(bottomBarColorRGB()).toBeUndefined(); + }); + }); + + it.each([ + { value: 'bg_color', source: 'bgColor' }, + { value: 'secondary_bg_color', source: 'secondaryBgColor' }, + ] as const)('should return value of theme\'s $source property if headerColor signal value is $value', ({ + value, + source, + }) => { + tpState.set({ + bgColor: '#ffffff', + secondaryBgColor: '#000000', + }); + headerColor.set(value); + expect(headerColorRGB()).toBe(tpState()[source]); + }); +}); + +describe('backgroundColorRGB', () => { + it('should return value of backgroundColor signal if it is RGB', () => { + backgroundColor.set('#ffaabb'); + expect(backgroundColorRGB()).toBe('#ffaabb'); + }); + + it.each([ + { value: 'bg_color', source: 'bgColor' }, + { value: 'secondary_bg_color', source: 'secondaryBgColor' }, + ] as const)('should return value of theme\'s $source property if backgroundColor signal value is $value', ({ + value, + source, + }) => { + tpState.set({ + bgColor: '#ffffff', + secondaryBgColor: '#000000', + }); + backgroundColor.set(value); + expect(backgroundColorRGB()).toBe(tpState()[source]); + }); +}); + +describe('headerColorRGB', () => { + it('should return value of headerColor signal if it is RGB', () => { + headerColor.set('#ffaabb'); + expect(headerColorRGB()).toBe('#ffaabb'); + }); + + it.each([ + { value: 'bg_color', source: 'bgColor' }, + { value: 'secondary_bg_color', source: 'secondaryBgColor' }, + ] as const)('should return value of theme\'s $source property if headerColor signal value is $value', ({ + value, + source, + }) => { + tpState.set({ + bgColor: '#ffffff', + secondaryBgColor: '#000000', + }); + headerColor.set(value); + expect(headerColorRGB()).toBe(tpState()[source]); + }); +}); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/mini-app/signals.ts b/packages/sdk/src/scopes/components/mini-app/signals.ts index 3a05b4f66..6f6936ed3 100644 --- a/packages/sdk/src/scopes/components/mini-app/signals.ts +++ b/packages/sdk/src/scopes/components/mini-app/signals.ts @@ -1,25 +1,78 @@ -import { computed, signal } from '@telegram-apps/signals'; -import type { RGB } from '@telegram-apps/bridge'; +import { Computed, computed, signal } from '@telegram-apps/signals'; +import { isRGB } from '@telegram-apps/transformers'; +import type { BackgroundColor, BottomBarColor, RGB } from '@telegram-apps/bridge'; import { isColorDark } from '@/utils/isColorDark.js'; +import { + backgroundColor as themeBgColor, + secondaryBackgroundColor as themeSecondaryBgColor, + bottomBarBgColor as themeBottomBarBgColor, +} from '@/scopes/components/theme-params/signals.js'; import type { HeaderColor, State } from './types.js'; -/* USUAL */ +// #__NO_SIDE_EFFECTS__ +function colorBasedOn(signal: Computed<'bg_color' | 'secondary_bg_color' | RGB>) { + return computed(() => { + const color = signal(); + + return isRGB(color) ? color : color === 'bg_color' + ? themeBgColor() + : themeSecondaryBgColor(); + }); +} /** * The Mini App background color. - * @example "#ffaabb" */ -export const backgroundColor = signal('#000000'); +export const backgroundColor = signal('bg_color'); + +/** + * RGB representation of the background color. + * + * This value requires the Theme Params component to be mounted to extract a valid RGB value + * of the color key. + */ +export const backgroundColorRGB = colorBasedOn(backgroundColor); + + +/** + * The Mini App bottom bar background color. + */ +export const bottomBarColor = signal('bottom_bar_bg_color'); + +/** + * RGB representation of the bottom bar background color. + * + * This value requires the Theme Params component to be mounted to extract a valid RGB value + * of the color key. + */ +export const bottomBarColorRGB = computed(() => { + const color = bottomBarColor(); + return isRGB(color) + ? color + : color === 'bottom_bar_bg_color' + // Following the logic from the Telegram SDK. + // I removed "|| '#ffffff'" because this seems too strange to me. This is just not right. + ? themeBottomBarBgColor() || themeSecondaryBgColor() + : color === 'secondary_bg_color' + ? themeSecondaryBgColor() + : themeBgColor(); +}); /** * The Mini App header color. - * @example "#ffaabb" - * @example "bg_color" */ export const headerColor = signal('bg_color'); +/** + * RGB representation of the header color. + * + * This value requires the Theme Params component to be mounted to extract a valid RGB value + * of the color key. + */ +export const headerColorRGB = colorBasedOn(headerColor); + /** * True if the component is currently mounted. */ @@ -35,12 +88,16 @@ export const isCssVarsBound = signal(false); /** * True if the current Mini App background color is recognized as dark. */ -export const isDark = computed(() => isColorDark(backgroundColor())); +export const isDark = computed(() => { + const color = backgroundColorRGB(); + return color ? isColorDark(color) : false; +}); /** * Complete component state. */ export const state = computed(() => ({ backgroundColor: backgroundColor(), + bottomBarColor: bottomBarColor(), headerColor: headerColor(), })); diff --git a/packages/sdk/src/scopes/components/mini-app/static.ts b/packages/sdk/src/scopes/components/mini-app/static.ts deleted file mode 100644 index 06c33f562..000000000 --- a/packages/sdk/src/scopes/components/mini-app/static.ts +++ /dev/null @@ -1 +0,0 @@ -export type * from './types.js'; diff --git a/packages/sdk/src/scopes/components/mini-app/types.ts b/packages/sdk/src/scopes/components/mini-app/types.ts index d28e3aa5b..56750e7e0 100644 --- a/packages/sdk/src/scopes/components/mini-app/types.ts +++ b/packages/sdk/src/scopes/components/mini-app/types.ts @@ -1,4 +1,4 @@ -import type { HeaderColorKey, RGB } from '@telegram-apps/bridge'; +import type { HeaderColorKey, BottomBarColor, RGB, BackgroundColor } from '@telegram-apps/bridge'; /** * Mini App header color. @@ -6,7 +6,8 @@ import type { HeaderColorKey, RGB } from '@telegram-apps/bridge'; export type HeaderColor = HeaderColorKey | RGB; export interface State { - backgroundColor: RGB; + backgroundColor: BackgroundColor; + bottomBarColor: BottomBarColor; headerColor: HeaderColor; } @@ -15,5 +16,5 @@ export interface GetCssVarNameFn { * @param property - mini app property. * @returns Computed complete CSS variable name. */ - (property: 'bgColor' | 'headerColor'): string; + (property: 'bgColor' | 'bottomBarColor' | 'headerColor'): string; } diff --git a/packages/sdk/src/scopes/components/secondary-button/exports.ts b/packages/sdk/src/scopes/components/secondary-button/exports.ts new file mode 100644 index 000000000..f90ccf062 --- /dev/null +++ b/packages/sdk/src/scopes/components/secondary-button/exports.ts @@ -0,0 +1,22 @@ +export { + isSupported as isSecondaryButtonSupported, + mount as mountSecondaryButton, + onClick as onSecondaryButtonClick, + offClick as offSecondaryButtonClick, + setParams as setSecondaryButtonParams, + unmount as unmountSecondaryButton, +} from './methods.js'; +export { + backgroundColor as secondaryButtonBackgroundColor, + hasShineEffect as secondaryButtonHasShineEffect, + isMounted as isSecondaryButtonMounted, + isVisible as isSecondaryButtonVisible, + isLoaderVisible as isSecondaryButtonLoaderVisible, + isEnabled as isSecondaryButtonEnabled, + position as secondaryButtonPosition, + state as secondaryButtonState, + text as secondaryButtonText, + textColor as secondaryButtonTextColor, +} from './signals.js'; +export type { State as SecondaryButtonState } from './types.js'; +export * as secondaryButton from './exports.variable.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/mini-app/instance.ts b/packages/sdk/src/scopes/components/secondary-button/exports.variable.ts similarity index 50% rename from packages/sdk/src/scopes/components/mini-app/instance.ts rename to packages/sdk/src/scopes/components/secondary-button/exports.variable.ts index 8187e4ee5..d360fbc6b 100644 --- a/packages/sdk/src/scopes/components/mini-app/instance.ts +++ b/packages/sdk/src/scopes/components/secondary-button/exports.variable.ts @@ -1,2 +1,2 @@ export * from './methods.js'; -export * from './signals.js'; \ No newline at end of file +export * from './signals.js'; diff --git a/packages/sdk/src/scopes/components/secondary-button/methods.ts b/packages/sdk/src/scopes/components/secondary-button/methods.ts index d35fdccf3..8f895e86f 100644 --- a/packages/sdk/src/scopes/components/secondary-button/methods.ts +++ b/packages/sdk/src/scopes/components/secondary-button/methods.ts @@ -76,12 +76,13 @@ function onStateChanged(s: State): void { // crash due to the empty value of the text. if (s.text) { postEvent(MINI_APPS_METHOD, { + color: s.backgroundColor, has_shine_effect: s.hasShineEffect, - is_visible: s.isVisible, is_active: s.isEnabled, is_progress_visible: s.isLoaderVisible, + is_visible: s.isVisible, + position: s.position, text: s.text, - color: s.backgroundColor, text_color: s.textColor, }); } diff --git a/packages/sdk/src/scopes/components/secondary-button/signals.ts b/packages/sdk/src/scopes/components/secondary-button/signals.ts index b6325a4b0..be0f9dee7 100644 --- a/packages/sdk/src/scopes/components/secondary-button/signals.ts +++ b/packages/sdk/src/scopes/components/secondary-button/signals.ts @@ -2,8 +2,6 @@ import { computed, type Computed, signal } from '@telegram-apps/signals'; import type { State } from './types.js'; -/* USUAL */ - /** * Complete component state. */ @@ -13,6 +11,7 @@ export const state = signal({ isEnabled: true, isLoaderVisible: false, isVisible: false, + position: 'left', text: 'Cancel', textColor: '#2481cc', }); @@ -22,43 +21,46 @@ export const state = signal({ */ export const isMounted = signal(false); -/* COMPUTED */ - -function createStateComputed(key: K): Computed { +function fromState(key: K): Computed { return computed(() => state()[key]); } /** * @see State.backgroundColor */ -export const backgroundColor = createStateComputed('backgroundColor'); +export const backgroundColor = fromState('backgroundColor'); /** * @see State.hasShineEffect */ -export const hasShineEffect = createStateComputed('hasShineEffect'); +export const hasShineEffect = fromState('hasShineEffect'); /** * @see State.isEnabled */ -export const isEnabled = createStateComputed('isEnabled'); +export const isEnabled = fromState('isEnabled'); /** * @see State.isLoaderVisible */ -export const isLoaderVisible = createStateComputed('isLoaderVisible'); +export const isLoaderVisible = fromState('isLoaderVisible'); /** * @see State.isVisible */ -export const isVisible = createStateComputed('isVisible'); +export const isVisible = fromState('isVisible'); + +/** + * @see State.position + */ +export const position = fromState('position'); /** * @see State.text */ -export const text = createStateComputed('text'); +export const text = fromState('text'); /** * @see State.textColor */ -export const textColor = createStateComputed('textColor'); +export const textColor = fromState('textColor'); diff --git a/packages/sdk/src/scopes/components/secondary-button/static.ts b/packages/sdk/src/scopes/components/secondary-button/static.ts deleted file mode 100644 index 06c33f562..000000000 --- a/packages/sdk/src/scopes/components/secondary-button/static.ts +++ /dev/null @@ -1 +0,0 @@ -export type * from './types.js'; diff --git a/packages/sdk/src/scopes/components/secondary-button/types.ts b/packages/sdk/src/scopes/components/secondary-button/types.ts index fcc10763f..baac64ffb 100644 --- a/packages/sdk/src/scopes/components/secondary-button/types.ts +++ b/packages/sdk/src/scopes/components/secondary-button/types.ts @@ -1,4 +1,4 @@ -import type { RGB } from '@telegram-apps/bridge'; +import type { RGB, SecondaryButtonPosition } from '@telegram-apps/bridge'; export interface State { /** @@ -21,6 +21,10 @@ export interface State { * True if the Secondary Button is visible. */ isVisible: boolean; + /** + * The button position relative to the main one. + */ + position: SecondaryButtonPosition; /** * The Secondary Button text. */