From 3568c5dbe0ca8445bede9fc2301c2f1ae07e3557 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Fri, 1 Jul 2022 15:43:33 +0200 Subject: [PATCH 1/4] Payments: bubble attachment & service message --- src/components/chat/bubbles.ts | 79 +++++++++++++++++-- .../wrappers/messageActionTextNewUnsafe.ts | 52 ++++++------ src/components/wrappers/messageForReply.ts | 5 ++ src/components/wrappers/photo.ts | 12 +-- src/config/currencies.ts | 18 +++++ src/helpers/mediaSizes.ts | 9 ++- src/helpers/paymentsWrapCurrencyAmount.ts | 70 ++++++++++++++++ src/helpers/setAttachmentSize.ts | 16 ++-- src/lang.ts | 8 ++ src/layer.d.ts | 8 +- src/lib/appManagers/appDownloadManager.ts | 2 +- src/lib/appManagers/appMessagesManager.ts | 19 ++++- src/lib/appManagers/appWebDocsManager.ts | 23 ++++++ src/lib/appManagers/createManagers.ts | 4 +- src/lib/appManagers/manager.ts | 2 + .../utils/download/getDownloadMediaDetails.ts | 11 ++- .../utils/photos/choosePhotoSize.ts | 12 +-- .../webDocs/getWebDocumentDownloadOptions.ts | 15 ++++ .../utils/webDocs/isWebDocument.ts | 5 ++ src/lib/fileManager.ts | 8 ++ src/lib/langPack.ts | 2 +- src/lib/mtproto/apiFileManager.ts | 15 ++-- src/lib/storages/thumbs.ts | 19 +++-- src/scripts/in/schema_additional_params.json | 12 +++ src/scss/partials/_chatBubble.scss | 5 ++ 25 files changed, 361 insertions(+), 70 deletions(-) create mode 100644 src/config/currencies.ts create mode 100644 src/helpers/paymentsWrapCurrencyAmount.ts create mode 100644 src/lib/appManagers/appWebDocsManager.ts create mode 100644 src/lib/appManagers/utils/webDocs/getWebDocumentDownloadOptions.ts create mode 100644 src/lib/appManagers/utils/webDocs/isWebDocument.ts diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index c70924e2..9aaead28 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -49,7 +49,6 @@ import { getMiddleware } from "../../helpers/middleware"; import cancelEvent from "../../helpers/dom/cancelEvent"; import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; import htmlToDocumentFragment from "../../helpers/dom/htmlToDocumentFragment"; -import positionElementByIndex from "../../helpers/dom/positionElementByIndex"; import reflowScrollableElement from "../../helpers/dom/reflowScrollableElement"; import replaceContent from "../../helpers/dom/replaceContent"; import setInnerHTML from "../../helpers/dom/setInnerHTML"; @@ -106,10 +105,10 @@ import { cancelContextMenuOpening } from "../../helpers/dom/attachContextMenuLis import contextMenuController from "../../helpers/contextMenuController"; import { AckedResult } from "../../lib/mtproto/superMessagePort"; import middlewarePromise from "../../helpers/middlewarePromise"; -import findAndSplice from "../../helpers/array/findAndSplice"; import { EmoticonsDropdown } from "../emoticonsDropdown"; import indexOfAndSplice from "../../helpers/array/indexOfAndSplice"; import noop from "../../helpers/noop"; +import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; const USE_MEDIA_TAILS = false; const IGNORE_ACTIONS: Set = new Set([ @@ -3580,7 +3579,7 @@ export default class ChatBubbles { rowDiv.classList.add('reply-markup-row'); buttons.forEach((button) => { - const text = wrapRichText(button.text, {noLinks: true, noLinebreaks: true}); + let text: DocumentFragment | HTMLElement | string = wrapRichText(button.text, {noLinks: true, noLinebreaks: true}); let buttonEl: HTMLButtonElement | HTMLAnchorElement; @@ -3596,14 +3595,14 @@ export default class ChatBubbles { }); buttonEl = htmlToDocumentFragment(r).firstElementChild as HTMLAnchorElement; - buttonEl.classList.add('is-link', 'tgico'); + buttonEl.classList.add('is-link'); break; } case 'keyboardButtonSwitchInline': { buttonEl = document.createElement('button'); - buttonEl.classList.add('is-switch-inline', 'tgico'); + buttonEl.classList.add('is-switch-inline'); attachClickEvent(buttonEl, (e) => { cancelEvent(e); @@ -3637,13 +3636,26 @@ export default class ChatBubbles { break; } + case 'keyboardButtonBuy': { + buttonEl = document.createElement('button'); + buttonEl.classList.add('is-buy'); + + if(messageMedia?._ === 'messageMediaInvoice') { + if(messageMedia.receipt_msg_id) { + text = i18n('Message.ReplyActionButtonShowReceipt'); + } + } + + break; + } + default: { buttonEl = document.createElement('button'); break; } } - buttonEl.classList.add('reply-markup-button', 'rp'); + buttonEl.classList.add('reply-markup-button', 'rp', 'tgico'); if(typeof(text) === 'string') { buttonEl.insertAdjacentHTML('beforeend', text); } else { @@ -4177,6 +4189,61 @@ export default class ChatBubbles { break; } + + case 'messageMediaInvoice': { + const isTest = messageMedia.pFlags.test; + const photo = messageMedia.photo; + + const priceEl = document.createElement(photo ? 'span' : 'div'); + const f = document.createDocumentFragment(); + const l = i18n(messageMedia.receipt_msg_id ? 'PaymentReceipt' : (isTest ? 'PaymentTestInvoice' : 'PaymentInvoice')); + l.classList.add('text-uppercase'); + const joiner = ' ‎'; + const p = document.createElement('span'); + p.classList.add('text-bold'); + p.textContent = paymentsWrapCurrencyAmount(messageMedia.total_amount, messageMedia.currency) + joiner; + f.append(p, l); + if(isTest && messageMedia.receipt_msg_id) { + const a = document.createElement('span'); + a.classList.add('text-uppercase', 'pre-wrap'); + a.append(joiner + '(Test)'); + f.append(a); + } + setInnerHTML(priceEl, f); + + if(photo) { + const mediaSize = mediaSizes.active.invoice; + wrapPhoto({ + photo, + container: attachmentDiv, + withTail: false, + isOut, + lazyLoadQueue: this.lazyLoadQueue, + middleware: this.getMiddleware(), + loadPromises, + boxWidth: mediaSize.width, + boxHeight: mediaSize.height + }); + + bubble.classList.add('photo'); + + priceEl.classList.add('video-time'); + attachmentDiv.append(priceEl); + } else { + attachmentDiv = undefined; + } + + const titleDiv = document.createElement('div'); + titleDiv.classList.add('bubble-primary-color'); + setInnerHTML(titleDiv, wrapRichText(messageMedia.title)); + + const richText = wrapRichText(messageMedia.description); + messageDiv.prepend(...[titleDiv, !photo && priceEl, richText].filter(Boolean)); + + bubble.classList.remove('is-message-empty'); + + break; + } default: attachmentDiv = undefined; diff --git a/src/components/wrappers/messageActionTextNewUnsafe.ts b/src/components/wrappers/messageActionTextNewUnsafe.ts index f1400042..4ee1c032 100644 --- a/src/components/wrappers/messageActionTextNewUnsafe.ts +++ b/src/components/wrappers/messageActionTextNewUnsafe.ts @@ -9,7 +9,8 @@ import { formatTime } from "../../helpers/date"; import htmlToSpan from "../../helpers/dom/htmlToSpan"; import setInnerHTML from "../../helpers/dom/setInnerHTML"; import formatCallDuration from "../../helpers/formatCallDuration"; -import { MessageAction } from "../../layer"; +import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; +import { Message, MessageAction } from "../../layer"; import { MyMessage } from "../../lib/appManagers/appMessagesManager"; import I18n, { FormatterArgument, FormatterArguments, i18n, join, langPack, LangPackKey, _i18n } from "../../lib/langPack"; import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText"; @@ -21,6 +22,14 @@ import getPeerTitle from "./getPeerTitle"; import wrapJoinVoiceChatAnchor from "./joinVoiceChatAnchor"; import wrapMessageForReply from "./messageForReply"; +async function wrapLinkToMessage(message: Message.message | Message.messageService, plain?: boolean) { + const a = document.createElement('i'); + a.dataset.savedFrom = message.peerId + '_' + message.mid; + a.dir = 'auto'; + a.append(await wrapMessageForReply(message, undefined, undefined, plain as any)); + return a; +} + export default async function wrapMessageActionTextNewUnsafe(message: MyMessage, plain?: boolean) { const element: HTMLElement = plain ? undefined : document.createElement('span'); const action = 'action' in message && message.action; @@ -150,29 +159,10 @@ export default async function wrapMessageActionTextNewUnsafe(message: MyMessage, langPackKey = 'ActionPinnedNoText'; if(message.reply_to_mid) { // refresh original message - managers.appMessagesManager.fetchMessageReplyTo(message).then(async(originalMessage) => { - if(originalMessage && message) { - rootScope.dispatchEvent('message_edit', { - storageKey: `${peerId}_history`, - peerId: peerId, - mid: message.mid, - message - }); - - if(managers.appMessagesManager.isMessageIsTopMessage(message)) { - rootScope.dispatchEvent('dialogs_multiupdate', { - [peerId]: await managers.appMessagesManager.getDialogOnly(peerId) - }); - } - } - }); + managers.appMessagesManager.fetchMessageReplyTo(message); } } else { - const a = document.createElement('i'); - a.dataset.savedFrom = pinnedMessage.peerId + '_' + pinnedMessage.mid; - a.dir = 'auto'; - a.append(await wrapMessageForReply(pinnedMessage, undefined, undefined, plain as any)); - args.push(a); + args.push(wrapLinkToMessage(pinnedMessage, plain)); } break; @@ -258,6 +248,24 @@ export default async function wrapMessageActionTextNewUnsafe(message: MyMessage, break; } + case 'messageActionPaymentSent': { + langPackKey = 'PaymentSuccessfullyPaidNoItem'; + const price = paymentsWrapCurrencyAmount(action.total_amount, action.currency); + args = [price, getNameDivHTML(message.peerId, plain)]; + + if(message.reply_to_mid) { + const invoiceMessage = await managers.appMessagesManager.getMessageByPeer(message.peerId, message.reply_to_mid); + if(!invoiceMessage) { + managers.appMessagesManager.fetchMessageReplyTo(message); + } else { + langPackKey = 'PaymentSuccessfullyPaid'; + args.push(wrapLinkToMessage(invoiceMessage, plain)); + } + } + + break; + } + default: langPackKey = (langPack[_] || `[${action._}]`) as any; break; diff --git a/src/components/wrappers/messageForReply.ts b/src/components/wrappers/messageForReply.ts index 5ead3c82..8a0c8c01 100644 --- a/src/components/wrappers/messageForReply.ts +++ b/src/components/wrappers/messageForReply.ts @@ -158,6 +158,11 @@ export default async function wrapMessageForReply(message: MyMessage | MyDraftMe break; } + case 'messageMediaInvoice': { + addPart(undefined, plain ? media.title : wrapEmojiText(media.title)); + break; + } + case 'messageMediaUnsupported': { addPart(UNSUPPORTED_LANG_PACK_KEY); break; diff --git a/src/components/wrappers/photo.ts b/src/components/wrappers/photo.ts index a213535e..3812cb11 100644 --- a/src/components/wrappers/photo.ts +++ b/src/components/wrappers/photo.ts @@ -6,7 +6,7 @@ import renderImageWithFadeIn from "../../helpers/dom/renderImageWithFadeIn"; import mediaSizes from "../../helpers/mediaSizes"; -import { Message, PhotoSize } from "../../layer"; +import { Message, PhotoSize, WebDocument } from "../../layer"; import { MyDocument } from "../../lib/appManagers/appDocsManager"; import { MyPhoto } from "../../lib/appManagers/appPhotosManager"; import rootScope from "../../lib/rootScope"; @@ -19,9 +19,10 @@ import setAttachmentSize from "../../helpers/setAttachmentSize"; import choosePhotoSize from "../../lib/appManagers/utils/photos/choosePhotoSize"; import type { ThumbCache } from "../../lib/storages/thumbs"; import appDownloadManager from "../../lib/appManagers/appDownloadManager"; +import isWebDocument from "../../lib/appManagers/utils/webDocs/isWebDocument"; export default async function wrapPhoto({photo, message, container, boxWidth, boxHeight, withTail, isOut, lazyLoadQueue, middleware, size, withoutPreloader, loadPromises, autoDownloadSize, noBlur, noThumb, noFadeIn, blurAfter, managers = rootScope.managers}: { - photo: MyPhoto | MyDocument, + photo: MyPhoto | MyDocument | WebDocument, message?: Message.message | Message.messageService, container: HTMLElement, boxWidth?: number, @@ -40,7 +41,8 @@ export default async function wrapPhoto({photo, message, container, boxWidth, bo blurAfter?: boolean, managers?: AppManagers, }) { - if(!((photo as MyPhoto).sizes || (photo as MyDocument).thumbs)) { + const isWebDoc = isWebDocument(photo); + if(!((photo as MyPhoto).sizes || (photo as MyDocument).thumbs) && !isWebDoc) { if(boxWidth && boxHeight && !size && photo._ === 'document') { setAttachmentSize(photo, container, boxWidth, boxHeight, undefined, message); } @@ -92,7 +94,7 @@ export default async function wrapPhoto({photo, message, container, boxWidth, bo isFit = set.isFit; cacheContext = await managers.thumbsStorage.getCacheContext(photo, size.type); - if(!isFit) { + if(!isFit && !isWebDoc) { aspecter = document.createElement('div'); aspecter.classList.add('media-container-aspecter'); aspecter.style.width = set.size.width + 'px'; @@ -141,7 +143,7 @@ export default async function wrapPhoto({photo, message, container, boxWidth, bo cacheContext = await managers.thumbsStorage.getCacheContext(photo, size?.type); } - if(!noThumb) { + if(!noThumb && !isWebDoc) { const gotThumb = getStrippedThumbIfNeeded(photo, cacheContext, !noBlur); if(gotThumb) { loadThumbPromise = Promise.all([loadThumbPromise, gotThumb.loadPromise]); diff --git a/src/config/currencies.ts b/src/config/currencies.ts new file mode 100644 index 00000000..b5d594f8 --- /dev/null +++ b/src/config/currencies.ts @@ -0,0 +1,18 @@ +// Taken from https://core.telegram.org/bots/payments/currencies.json +export type Currency = { + code: string, + title: string, + symbol: string, + native: string, + thousands_sep: string, + decimal_sep: string, + symbol_left: boolean, + space_between: boolean, + exp: number, + min_amount: string | number, + max_amount: string | number +}; + +const Currencies: {[currency: string]: Currency} = {"AED":{"code":"AED","title":"United Arab Emirates Dirham","symbol":"AED","native":"د.إ.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"367","max_amount":"3672990"},"AFN":{"code":"AFN","title":"Afghan Afghani","symbol":"AFN","native":"؋","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"8893","max_amount":"88930176"},"ALL":{"code":"ALL","title":"Albanian Lek","symbol":"ALL","native":"Lek","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"11408","max_amount":"114088432"},"AMD":{"code":"AMD","title":"Armenian Dram","symbol":"AMD","native":"դր.","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"41129","max_amount":"411293180"},"ARS":{"code":"ARS","title":"Argentine Peso","symbol":"ARS","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"12503","max_amount":"125036607"},"AUD":{"code":"AUD","title":"Australian Dollar","symbol":"AU$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"145","max_amount":"1451625"},"AZN":{"code":"AZN","title":"Azerbaijani Manat","symbol":"AZN","native":"ман.","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"171","max_amount":"1711164"},"BAM":{"code":"BAM","title":"Bosnia & Herzegovina Convertible Mark","symbol":"BAM","native":"KM","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"187","max_amount":"1873042"},"BDT":{"code":"BDT","title":"Bangladeshi Taka","symbol":"BDT","native":"৳","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"9415","max_amount":"94154281"},"BGN":{"code":"BGN","title":"Bulgarian Lev","symbol":"BGN","native":"лв.","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"186","max_amount":"1869710"},"BND":{"code":"BND","title":"Brunei Dollar","symbol":"BND","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"139","max_amount":"1399458"},"BOB":{"code":"BOB","title":"Bolivian Boliviano","symbol":"BOB","native":"Bs","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"692","max_amount":"6926834"},"BRL":{"code":"BRL","title":"Brazilian Real","symbol":"R$","native":"R$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"518","max_amount":"5182986"},"CAD":{"code":"CAD","title":"Canadian Dollar","symbol":"CA$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"129","max_amount":"1290450"},"CHF":{"code":"CHF","title":"Swiss Franc","symbol":"CHF","native":"CHF","thousands_sep":"'","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"95","max_amount":"954630"},"CLP":{"code":"CLP","title":"Chilean Peso","symbol":"CLP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"926","max_amount":"9268013"},"CNY":{"code":"CNY","title":"Chinese Renminbi Yuan","symbol":"CN¥","native":"CN¥","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"669","max_amount":"6692902"},"COP":{"code":"COP","title":"Colombian Peso","symbol":"COP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"410927","max_amount":"4109270000"},"CRC":{"code":"CRC","title":"Costa Rican Colón","symbol":"CRC","native":"₡","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"69166","max_amount":"691668622"},"CZK":{"code":"CZK","title":"Czech Koruna","symbol":"CZK","native":"Kč","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"2367","max_amount":"23674601"},"DKK":{"code":"DKK","title":"Danish Krone","symbol":"DKK","native":"kr","thousands_sep":"","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"712","max_amount":"7120540"},"DOP":{"code":"DOP","title":"Dominican Peso","symbol":"DOP","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5497","max_amount":"54971796"},"DZD":{"code":"DZD","title":"Algerian Dinar","symbol":"DZD","native":"د.ج.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"14655","max_amount":"146557782"},"EGP":{"code":"EGP","title":"Egyptian Pound","symbol":"EGP","native":"ج.م.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1879","max_amount":"18794601"},"EUR":{"code":"EUR","title":"Euro","symbol":"€","native":"€","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"95","max_amount":"957150"},"GBP":{"code":"GBP","title":"British Pound","symbol":"£","native":"£","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"82","max_amount":"822531"},"GEL":{"code":"GEL","title":"Georgian Lari","symbol":"GEL","native":"GEL","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"293","max_amount":"2939866"},"GTQ":{"code":"GTQ","title":"Guatemalan Quetzal","symbol":"GTQ","native":"Q","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"781","max_amount":"7811180"},"HKD":{"code":"HKD","title":"Hong Kong Dollar","symbol":"HK$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"784","max_amount":"7845675"},"HNL":{"code":"HNL","title":"Honduran Lempira","symbol":"HNL","native":"L","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"2476","max_amount":"24763692"},"HRK":{"code":"HRK","title":"Croatian Kuna","symbol":"HRK","native":"kn","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"721","max_amount":"7210988"},"HUF":{"code":"HUF","title":"Hungarian Forint","symbol":"HUF","native":"Ft","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"37819","max_amount":"378197939"},"IDR":{"code":"IDR","title":"Indonesian Rupiah","symbol":"IDR","native":"Rp","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"1490695","max_amount":"14906950000"},"ILS":{"code":"ILS","title":"Israeli New Sheqel","symbol":"₪","native":"₪","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"348","max_amount":"3480155"},"INR":{"code":"INR","title":"Indian Rupee","symbol":"₹","native":"₹","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"7894","max_amount":"78945050"},"ISK":{"code":"ISK","title":"Icelandic Króna","symbol":"ISK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"133","max_amount":"1336303"},"JMD":{"code":"JMD","title":"Jamaican Dollar","symbol":"JMD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"15175","max_amount":"151753529"},"JPY":{"code":"JPY","title":"Japanese Yen","symbol":"¥","native":"¥","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"136","max_amount":"1362010"},"KES":{"code":"KES","title":"Kenyan Shilling","symbol":"KES","native":"Ksh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"11787","max_amount":"117879251"},"KGS":{"code":"KGS","title":"Kyrgyzstani Som","symbol":"KGS","native":"KGS","thousands_sep":" ","decimal_sep":"-","symbol_left":false,"space_between":true,"exp":2,"min_amount":"7950","max_amount":"79509472"},"KRW":{"code":"KRW","title":"South Korean Won","symbol":"₩","native":"₩","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"1297","max_amount":"12971249"},"KZT":{"code":"KZT","title":"Kazakhstani Tenge","symbol":"KZT","native":"₸","thousands_sep":" ","decimal_sep":"-","symbol_left":true,"space_between":false,"exp":2,"min_amount":"47177","max_amount":"471777437"},"LBP":{"code":"LBP","title":"Lebanese Pound","symbol":"LBP","native":"ل.ل.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"152338","max_amount":"1523381760"},"LKR":{"code":"LKR","title":"Sri Lankan Rupee","symbol":"LKR","native":"රු.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"36271","max_amount":"362713465"},"MAD":{"code":"MAD","title":"Moroccan Dirham","symbol":"MAD","native":"د.م.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1018","max_amount":"10188182"},"MDL":{"code":"MDL","title":"Moldovan Leu","symbol":"MDL","native":"MDL","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1928","max_amount":"19284237"},"MNT":{"code":"MNT","title":"Mongolian Tögrög","symbol":"MNT","native":"MNT","thousands_sep":" ","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"312408","max_amount":"3124087599"},"MUR":{"code":"MUR","title":"Mauritian Rupee","symbol":"MUR","native":"MUR","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"4614","max_amount":"46144273"},"MVR":{"code":"MVR","title":"Maldivian Rufiyaa","symbol":"MVR","native":"MVR","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1534","max_amount":"15349670"},"MXN":{"code":"MXN","title":"Mexican Peso","symbol":"MX$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"2015","max_amount":"20158770"},"MYR":{"code":"MYR","title":"Malaysian Ringgit","symbol":"MYR","native":"RM","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"440","max_amount":"4406499"},"MZN":{"code":"MZN","title":"Mozambican Metical","symbol":"MZN","native":"MTn","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"6383","max_amount":"63830365"},"NGN":{"code":"NGN","title":"Nigerian Naira","symbol":"NGN","native":"₦","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"41513","max_amount":"415132815"},"NIO":{"code":"NIO","title":"Nicaraguan Córdoba","symbol":"NIO","native":"C$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3612","max_amount":"36125609"},"NOK":{"code":"NOK","title":"Norwegian Krone","symbol":"NOK","native":"kr","thousands_sep":" ","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"990","max_amount":"9902585"},"NPR":{"code":"NPR","title":"Nepalese Rupee","symbol":"NPR","native":"नेरू","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"12731","max_amount":"127318435"},"NZD":{"code":"NZD","title":"New Zealand Dollar","symbol":"NZ$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"160","max_amount":"1603695"},"PAB":{"code":"PAB","title":"Panamanian Balboa","symbol":"PAB","native":"B\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"100","max_amount":"1007566"},"PEN":{"code":"PEN","title":"Peruvian Nuevo Sol","symbol":"PEN","native":"S\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"381","max_amount":"3818809"},"PHP":{"code":"PHP","title":"Philippine Peso","symbol":"PHP","native":"₱","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5499","max_amount":"54994501"},"PKR":{"code":"PKR","title":"Pakistani Rupee","symbol":"PKR","native":"₨","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"20651","max_amount":"206515440"},"PLN":{"code":"PLN","title":"Polish Złoty","symbol":"PLN","native":"zł","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"446","max_amount":"4466920"},"PYG":{"code":"PYG","title":"Paraguayan Guaraní","symbol":"PYG","native":"₲","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"6909","max_amount":"69095662"},"QAR":{"code":"QAR","title":"Qatari Riyal","symbol":"QAR","native":"ر.ق.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"364","max_amount":"3640988"},"RON":{"code":"RON","title":"Romanian Leu","symbol":"RON","native":"RON","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"473","max_amount":"4736501"},"RSD":{"code":"RSD","title":"Serbian Dinar","symbol":"RSD","native":"дин.","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"11252","max_amount":"112520089"},"RUB":{"code":"RUB","title":"Russian Ruble","symbol":"RUB","native":"руб.","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"5282","max_amount":"52825030"},"SAR":{"code":"SAR","title":"Saudi Riyal","symbol":"SAR","native":"ر.س.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"375","max_amount":"3752099"},"SEK":{"code":"SEK","title":"Swedish Krona","symbol":"SEK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1022","max_amount":"10224070"},"SGD":{"code":"SGD","title":"Singapore Dollar","symbol":"SGD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"139","max_amount":"1390698"},"THB":{"code":"THB","title":"Thai Baht","symbol":"฿","native":"฿","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3529","max_amount":"35290499"},"TJS":{"code":"TJS","title":"Tajikistani Somoni","symbol":"TJS","native":"TJS","thousands_sep":" ","decimal_sep":";","symbol_left":false,"space_between":true,"exp":2,"min_amount":"977","max_amount":"9773409"},"TRY":{"code":"TRY","title":"Turkish Lira","symbol":"TRY","native":"TL","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1667","max_amount":"16673549"},"TTD":{"code":"TTD","title":"Trinidad and Tobago Dollar","symbol":"TTD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"684","max_amount":"6847347"},"TWD":{"code":"TWD","title":"New Taiwan Dollar","symbol":"NT$","native":"NT$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"2973","max_amount":"29735499"},"TZS":{"code":"TZS","title":"Tanzanian Shilling","symbol":"TZS","native":"TSh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"233200","max_amount":"2332000087"},"UAH":{"code":"UAH","title":"Ukrainian Hryvnia","symbol":"UAH","native":"₴","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"2974","max_amount":"29741945"},"UGX":{"code":"UGX","title":"Ugandan Shilling","symbol":"UGX","native":"USh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"3788","max_amount":"37883728"},"USD":{"code":"USD","title":"United States Dollar","symbol":"$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"100","max_amount":1000000},"UYU":{"code":"UYU","title":"Uruguayan Peso","symbol":"UYU","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3979","max_amount":"39794286"},"UZS":{"code":"UZS","title":"Uzbekistani Som","symbol":"UZS","native":"UZS","thousands_sep":" ","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1094209","max_amount":"10942099215"},"VND":{"code":"VND","title":"Vietnamese Đồng","symbol":"₫","native":"₫","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"23270","max_amount":"232700000"},"YER":{"code":"YER","title":"Yemeni Rial","symbol":"YER","native":"ر.ي.‏","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"25024","max_amount":"250249914"},"ZAR":{"code":"ZAR","title":"South African Rand","symbol":"ZAR","native":"R","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1624","max_amount":"16246189"}}; + +export default Currencies; diff --git a/src/helpers/mediaSizes.ts b/src/helpers/mediaSizes.ts index ad7449b0..807e3c2b 100644 --- a/src/helpers/mediaSizes.ts +++ b/src/helpers/mediaSizes.ts @@ -18,7 +18,8 @@ type MediaTypeSizes = { emojiSticker: MediaSize, poll: MediaSize, round: MediaSize, - documentName: MediaSize + documentName: MediaSize, + invoice: MediaSize }; export type MediaSizeType = keyof MediaTypeSizes; @@ -54,7 +55,8 @@ class MediaSizes extends EventListenerBase<{ emojiSticker: makeMediaSize(112, 112), poll: makeMediaSize(240, 0), round: makeMediaSize(200, 200), - documentName: makeMediaSize(200, 0) + documentName: makeMediaSize(200, 0), + invoice: makeMediaSize(240, 240) }, desktop: { regular: makeMediaSize(420, 340), @@ -66,7 +68,8 @@ class MediaSizes extends EventListenerBase<{ emojiSticker: makeMediaSize(112, 112), poll: makeMediaSize(330, 0), round: makeMediaSize(280, 280), - documentName: makeMediaSize(240, 0) + documentName: makeMediaSize(240, 0), + invoice: makeMediaSize(320, 260) } }; diff --git a/src/helpers/paymentsWrapCurrencyAmount.ts b/src/helpers/paymentsWrapCurrencyAmount.ts new file mode 100644 index 00000000..e6620ec9 --- /dev/null +++ b/src/helpers/paymentsWrapCurrencyAmount.ts @@ -0,0 +1,70 @@ +import Currencies from "../config/currencies"; + +// https://stackoverflow.com/a/34141813 +function number_format(number: any, decimals: any, dec_point: any, thousands_sep: any) { + // Strip all characters but numerical ones. + number = (number + '').replace(/[^0-9+\-Ee.]/g, ''); + var n = !isFinite(+number) ? 0 : +number, + prec = !isFinite(+decimals) ? 0 : Math.abs(decimals), + sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep, + dec = (typeof dec_point === 'undefined') ? '.' : dec_point, + s: any = '', + toFixedFix = function(n: number, prec: number) { + var k = Math.pow(10, prec); + return '' + Math.round(n * k) / k; + }; + // Fix for IE parseFloat(0.55).toFixed(0) = 0; + s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.'); + if (s[0].length > 3) { + s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep); + } + if ((s[1] || '').length < prec) { + s[1] = s[1] || ''; + s[1] += new Array(prec - s[1].length + 1).join('0'); + } + return s.join(dec); +} + +export default function paymentsWrapCurrencyAmount($amount: number | string, $currency: string) { + $amount = +$amount; + + const $currency_data = Currencies[$currency]; // вытащить из json + if(!$currency_data) { + throw new Error('CURRENCY_WRAP_INVALID'); + } + + const $amount_exp = $amount / Math.pow(10, $currency_data['exp']); + + let $decimals = $currency_data['exp']; + if($currency == 'IRR' && + Math.floor($amount_exp) == $amount_exp) { + $decimals = 0; // у иранцев копейки почти всегда = 0 и не показываются в UI + } + + const $formatted = number_format($amount_exp, $decimals, $currency_data['decimal_sep'], $currency_data['thousands_sep']); + + const $splitter = $currency_data['space_between'] ? "\xc2\xa0" : ''; + let $formatted_intern: string; + if($currency_data['symbol_left']) { + $formatted_intern = $currency_data['symbol'] + $splitter + $formatted; + } else { + $formatted_intern = $formatted + $splitter + $currency_data['symbol']; + } + return $formatted_intern; +} + +function paymentsGetCurrencyExp($currency: string) { + if($currency == 'CLF') { + return 4; + } + if(['BHD','IQD','JOD','KWD','LYD','OMR','TND'].includes($currency)) { + return 3; + } + if(['BIF','BYR','CLP','CVE','DJF','GNF','ISK','JPY','KMF','KRW','MGA', 'PYG','RWF','UGX','UYI','VND','VUV','XAF','XOF','XPF'].includes($currency)) { + return 0; + } + if($currency == 'MRO') { + return 1; + } + return 2; +} diff --git a/src/helpers/setAttachmentSize.ts b/src/helpers/setAttachmentSize.ts index 52878956..403d10cc 100644 --- a/src/helpers/setAttachmentSize.ts +++ b/src/helpers/setAttachmentSize.ts @@ -4,15 +4,16 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import { PhotoSize } from "../layer"; +import { PhotoSize, WebDocument } from "../layer"; import { REPLIES_HIDDEN_CHANNEL_ID } from "../lib/mtproto/mtproto_config"; import { MyDocument } from "../lib/appManagers/appDocsManager"; import { MyPhoto } from "../lib/appManagers/appPhotosManager"; import choosePhotoSize from "../lib/appManagers/utils/photos/choosePhotoSize"; import { MediaSize, makeMediaSize } from "./mediaSize"; +import isWebDocument from "../lib/appManagers/utils/webDocs/isWebDocument"; export default function setAttachmentSize( - photo: MyPhoto | MyDocument, + photo: MyPhoto | MyDocument | WebDocument, element: HTMLElement | SVGForeignObjectElement, boxWidth: number, boxHeight: number, @@ -21,6 +22,11 @@ export default function setAttachmentSize( pushDocumentSize?: boolean, photoSize?: ReturnType ) { + const _isWebDocument = isWebDocument(photo); + // if(_isWebDocument && pushDocumentSize === undefined) { + // pushDocumentSize = true; + // } + if(!photoSize) { photoSize = choosePhotoSize(photo, boxWidth, boxHeight, undefined, pushDocumentSize); } @@ -28,8 +34,8 @@ export default function setAttachmentSize( let size: MediaSize; const isDocument = photo._ === 'document'; - if(isDocument) { - size = makeMediaSize((photo as MyDocument).w || (photoSize as PhotoSize.photoSize).w || 512, (photo as MyDocument).h || (photoSize as PhotoSize.photoSize).h || 512); + if(isDocument || _isWebDocument) { + size = makeMediaSize(photo.w || (photoSize as PhotoSize.photoSize).w || 512, photo.h || (photoSize as PhotoSize.photoSize).h || 512); } else { size = makeMediaSize((photoSize as PhotoSize.photoSize).w || 100, (photoSize as PhotoSize.photoSize).h || 100); } @@ -40,7 +46,7 @@ export default function setAttachmentSize( let isFit = true; - if(!isDocument || ['video', 'gif'].includes((photo as MyDocument).type)) { + if(!isDocument || ['video', 'gif'].includes(photo.type) || _isWebDocument) { if(boxSize.width < 200 && boxSize.height < 200) { // make at least one side this big boxSize = size = size.aspectCovered(makeMediaSize(200, 200)); } diff --git a/src/lang.ts b/src/lang.ts index c16232a5..48b641bb 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -695,6 +695,13 @@ const lang = { "ScamMessage": "SCAM", "FakeMessage": "FAKE", "TextCopied": "Text copied to clipboard", + "PaymentInvoice": "INVOICE", + "PaymentTestInvoice": "TEST INVOICE", + "PaymentReceipt": "Receipt", + "PaymentSuccessfullyPaid": "You successfully transferred %1$s to %2$s for %3$s", + "PaymentSuccessfullyPaidNoItem": "You successfully transferred %1$s to %2$s", + // "PaymentSuccessfullyPaidRecurrent": "You successfully transferred %1$s to %2$s for %3$s and allowed future recurring payments", + // "PaymentSuccessfullyPaidNoItemRecurrent": "You successfully transferred %1$s to %2$s and allowed future recurring payments", // * macos "AccountSettings.Filters": "Chat Folders", @@ -913,6 +920,7 @@ const lang = { "Message.Context.Pin": "Pin", "Message.Context.Unpin": "Unpin", "Message.Context.Goto": "Show Message", + "Message.ReplyActionButtonShowReceipt": "Show Receipt", "MessageContext.CopyMessageLink1": "Copy Message Link", "Modal.Send": "Send", "NewPoll.Anonymous": "Anonymous Voting", diff --git a/src/layer.d.ts b/src/layer.d.ts index bb9f06c5..1a906ef6 100644 --- a/src/layer.d.ts +++ b/src/layer.d.ts @@ -5881,7 +5881,9 @@ export namespace WebDocument { access_hash: string | number, size: number, mime_type: string, - attributes: Array + attributes: Array, + h?: number, + w?: number }; export type webDocumentNoProxy = { @@ -5889,7 +5891,9 @@ export namespace WebDocument { url: string, size: number, mime_type: string, - attributes: Array + attributes: Array, + h?: number, + w?: number }; } diff --git a/src/lib/appManagers/appDownloadManager.ts b/src/lib/appManagers/appDownloadManager.ts index 79ef1679..9d64a073 100644 --- a/src/lib/appManagers/appDownloadManager.ts +++ b/src/lib/appManagers/appDownloadManager.ts @@ -6,7 +6,7 @@ import type { ApiFileManager, DownloadMediaOptions, DownloadOptions } from "../mtproto/apiFileManager"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; -import { Document, InputFile, Photo, PhotoSize } from "../../layer"; +import { Document, InputFile, Photo, PhotoSize, WebDocument } from "../../layer"; import { getFileNameByLocation } from "../../helpers/fileName"; import getFileNameForUpload from "../../helpers/getFileNameForUpload"; import { AppManagers } from "./managers"; diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index 47762baf..35978725 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -2759,8 +2759,7 @@ export class AppMessagesManager extends AppManager { break; */ case 'messageMediaInvoice': { - unsupported = true; - message.media = {_: 'messageMediaUnsupported'}; + message.media.photo = this.appWebDocsManager.saveWebDocument(message.media.photo); break; } @@ -5630,6 +5629,22 @@ export class AppMessagesManager extends AppManager { delete message.reply_to_mid; // ! WARNING! } + if(message._ === 'messageService') { + const peerId = message.peerId; + this.rootScope.dispatchEvent('message_edit', { + storageKey: `${peerId}_history`, + peerId: peerId, + mid: message.mid, + message + }); + + if(this.isMessageIsTopMessage(message)) { + this.rootScope.dispatchEvent('dialogs_multiupdate', { + [peerId]: this.getDialogOnly(peerId) + }); + } + } + return originalMessage; }); } diff --git a/src/lib/appManagers/appWebDocsManager.ts b/src/lib/appManagers/appWebDocsManager.ts new file mode 100644 index 00000000..2313596d --- /dev/null +++ b/src/lib/appManagers/appWebDocsManager.ts @@ -0,0 +1,23 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import { DocumentAttribute, WebDocument } from "../../layer"; + +export default class AppWebDocsManager { + public saveWebDocument(webDocument: WebDocument) { + if(!webDocument) { + return; + } + + const attribute: DocumentAttribute.documentAttributeImageSize = webDocument.attributes.find((attribute) => attribute._ === 'documentAttributeImageSize') as any; + if(attribute) { + webDocument.w = attribute.w; + webDocument.h = attribute.h; + } + + return webDocument; + } +} diff --git a/src/lib/appManagers/createManagers.ts b/src/lib/appManagers/createManagers.ts index 471cc5ca..2bad18db 100644 --- a/src/lib/appManagers/createManagers.ts +++ b/src/lib/appManagers/createManagers.ts @@ -43,6 +43,7 @@ import { AppStoragesManager } from "./appStoragesManager"; import cryptoMessagePort from "../crypto/cryptoMessagePort"; import appStateManager from "./appStateManager"; import filterUnique from "../../helpers/array/filterUnique"; +import AppWebDocsManager from "./appWebDocsManager"; export default function createManagers(appStoragesManager: AppStoragesManager, userId: UserId) { const managers = { @@ -82,7 +83,8 @@ export default function createManagers(appStoragesManager: AppStoragesManager, u dcConfigurator: new DcConfigurator, timeManager: new TimeManager, appStoragesManager: appStoragesManager, - appStateManager: appStateManager + appStateManager: appStateManager, + appWebDocsManager: new AppWebDocsManager }; type T = typeof managers; diff --git a/src/lib/appManagers/manager.ts b/src/lib/appManagers/manager.ts index a9a177fb..9ad0b706 100644 --- a/src/lib/appManagers/manager.ts +++ b/src/lib/appManagers/manager.ts @@ -40,6 +40,7 @@ import type { AppStateManager } from "./appStateManager"; import type { AppStickersManager } from "./appStickersManager"; import type { AppStoragesManager } from "./appStoragesManager"; import type { AppUsersManager } from "./appUsersManager"; +import type AppWebDocsManager from "./appWebDocsManager"; import type { AppWebPagesManager } from "./appWebPagesManager"; import type { AppManagers } from "./managers"; @@ -82,6 +83,7 @@ export class AppManager { protected timeManager: TimeManager; protected appStoragesManager: AppStoragesManager; protected appStateManager: AppStateManager; + protected appWebDocsManager: AppWebDocsManager; public clear: (init?: boolean) => void; diff --git a/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts b/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts index 70c5976c..180c9a56 100644 --- a/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts +++ b/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts @@ -4,14 +4,21 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import type { DownloadMediaOptions } from "../../../mtproto/apiFileManager"; +import type { DownloadMediaOptions, DownloadOptions } from "../../../mtproto/apiFileManager"; import getDocumentDownloadOptions from "../docs/getDocumentDownloadOptions"; import getPhotoDownloadOptions from "../photos/getPhotoDownloadOptions"; +import getWebDocumentDownloadOptions from "../webDocs/getWebDocumentDownloadOptions"; +import isWebDocument from "../webDocs/isWebDocument"; import getDownloadFileNameFromOptions from "./getDownloadFileNameFromOptions"; export default function getDownloadMediaDetails(options: DownloadMediaOptions) { const {media, thumb, queueId, onlyCache} = options; - const downloadOptions = media._ === 'document' ? getDocumentDownloadOptions(media, thumb as any, queueId, onlyCache) : getPhotoDownloadOptions(media as any, thumb, queueId, onlyCache); + + let downloadOptions: DownloadOptions; + if(media._ === 'document') downloadOptions = getDocumentDownloadOptions(media, thumb as any, queueId, onlyCache); + else if(media._ === 'photo') downloadOptions = getPhotoDownloadOptions(media, thumb, queueId, onlyCache); + else if(isWebDocument(media)) downloadOptions = getWebDocumentDownloadOptions(media); + const fileName = getDownloadFileNameFromOptions(downloadOptions); return {fileName, downloadOptions}; } diff --git a/src/lib/appManagers/utils/photos/choosePhotoSize.ts b/src/lib/appManagers/utils/photos/choosePhotoSize.ts index 7a11eddd..977c0dae 100644 --- a/src/lib/appManagers/utils/photos/choosePhotoSize.ts +++ b/src/lib/appManagers/utils/photos/choosePhotoSize.ts @@ -6,11 +6,11 @@ import type { MyDocument } from "../../appDocsManager"; import type { MyPhoto } from "../../appPhotosManager"; +import type { PhotoSize, WebDocument } from "../../../../layer"; import calcImageInBox from "../../../../helpers/calcImageInBox"; -import { PhotoSize } from "../../../../layer"; export default function choosePhotoSize( - photo: MyPhoto | MyDocument, + photo: MyPhoto | MyDocument | WebDocument, boxWidth = 0, boxHeight = 0, useBytes = false, @@ -34,12 +34,12 @@ export default function choosePhotoSize( let bestPhotoSize: PhotoSize = {_: 'photoSizeEmpty', type: ''}; let sizes = (photo as MyPhoto).sizes || (photo as MyDocument).thumbs as PhotoSize[]; - if(pushDocumentSize && sizes && photo._ === 'document') { + if(pushDocumentSize && sizes && photo._ !== 'photo') { sizes = sizes.concat({ _: 'photoSize', - w: (photo as MyDocument).w, - h: (photo as MyDocument).h, - size: (photo as MyDocument).size, + w: photo.w, + h: photo.h, + size: photo.size, type: undefined }); } diff --git a/src/lib/appManagers/utils/webDocs/getWebDocumentDownloadOptions.ts b/src/lib/appManagers/utils/webDocs/getWebDocumentDownloadOptions.ts new file mode 100644 index 00000000..16c729f3 --- /dev/null +++ b/src/lib/appManagers/utils/webDocs/getWebDocumentDownloadOptions.ts @@ -0,0 +1,15 @@ +import { WebDocument } from "../../../../layer"; +import { DownloadOptions } from "../../../mtproto/apiFileManager"; + +export default function getWebDocumentDownloadOptions(webDocument: WebDocument): DownloadOptions { + return { + dcId: 4, + location: { + _: 'inputWebFileLocation', + access_hash: (webDocument as WebDocument.webDocument).access_hash, + url: webDocument.url + }, + size: webDocument.size, + mimeType: webDocument.mime_type + }; +} diff --git a/src/lib/appManagers/utils/webDocs/isWebDocument.ts b/src/lib/appManagers/utils/webDocs/isWebDocument.ts new file mode 100644 index 00000000..f64c443c --- /dev/null +++ b/src/lib/appManagers/utils/webDocs/isWebDocument.ts @@ -0,0 +1,5 @@ +import { WebDocument } from "../../../../layer"; + +export default function isWebDocument(webDocument: any): webDocument is WebDocument { + return !!(webDocument && (webDocument._ === 'webDocument' || webDocument._ === 'webDocumentNoProxy')); +} diff --git a/src/lib/fileManager.ts b/src/lib/fileManager.ts index 811d1521..990f7d82 100644 --- a/src/lib/fileManager.ts +++ b/src/lib/fileManager.ts @@ -34,6 +34,14 @@ export class FileManager { throw false; } + // sometimes file size can be bigger than the prov + const endOffset = offset + part.byteLength; + if(endOffset > bytes.byteLength) { + const newBytes = new Uint8Array(endOffset); + newBytes.set(bytes, 0); + bytes = newBytes; + } + bytes.set(part, offset); }, truncate: () => { diff --git a/src/lib/langPack.ts b/src/lib/langPack.ts index 8724ac14..549789d1 100644 --- a/src/lib/langPack.ts +++ b/src/lib/langPack.ts @@ -64,7 +64,7 @@ export const langPack: {[actionType: string]: LangPackKey} = { "messageActionGroupCall.ended_by": "Chat.Service.VoiceChatFinished", "messageActionGroupCall.ended_byYou": "Chat.Service.VoiceChatFinishedYou", - "messageActionBotAllowed": "Chat.Service.BotPermissionAllowed" + "messageActionBotAllowed": "Chat.Service.BotPermissionAllowed", }; export type LangPackKey = /* string | */keyof typeof lang | keyof typeof langSign; diff --git a/src/lib/mtproto/apiFileManager.ts b/src/lib/mtproto/apiFileManager.ts index c1d42aa2..b999612c 100644 --- a/src/lib/mtproto/apiFileManager.ts +++ b/src/lib/mtproto/apiFileManager.ts @@ -14,7 +14,7 @@ import Modes from "../../config/modes"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; import { getFileNameByLocation } from "../../helpers/fileName"; import { randomLong } from "../../helpers/random"; -import { Document, InputFile, InputFileLocation, InputWebFileLocation, Photo, PhotoSize, UploadFile, UploadWebFile } from "../../layer"; +import { Document, InputFile, InputFileLocation, InputWebFileLocation, Photo, PhotoSize, UploadFile, UploadWebFile, WebDocument } from "../../layer"; import { DcId } from "../../types"; import CacheStorageController from "../cacheStorage"; import fileManager from "../fileManager"; @@ -54,7 +54,7 @@ export type DownloadOptions = { }; export type DownloadMediaOptions = { - media: Photo | Document.document, + media: Photo.photo | Document.document | WebDocument, thumb?: PhotoSize, queueId?: number, onlyCache?: boolean @@ -599,14 +599,15 @@ export class ApiFileManager extends AppManager { public downloadMedia(options: DownloadMediaOptions): DownloadPromise { let {media, thumb} = options; const isPhoto = media._ === 'photo'; - if(media._ === 'photoEmpty' || (isPhoto && !thumb)) { + if(isPhoto && !thumb) { return Promise.reject('preloadPhoto photoEmpty!'); } // get original instance with correct file_reference instead of using copies const isDocument = media._ === 'document'; - if(isDocument) media = this.appDocsManager.getDoc(media.id); - else if(isPhoto) media = this.appPhotosManager.getPhoto(media.id); + // const isWebDocument = media._ === 'webDocument'; + if(isDocument) media = this.appDocsManager.getDoc((media as Photo.photo).id); + else if(isPhoto) media = this.appPhotosManager.getPhoto((media as Document.document).id); const {fileName, downloadOptions} = getDownloadMediaDetails(options); @@ -615,9 +616,9 @@ export class ApiFileManager extends AppManager { promise = this.download(downloadOptions); if(isDocument && !thumb) { - this.rootScope.dispatchEvent('document_downloading', media.id); + this.rootScope.dispatchEvent('document_downloading', (media as Document.document).id); promise.catch(noop).finally(() => { - this.rootScope.dispatchEvent('document_downloaded', media.id); + this.rootScope.dispatchEvent('document_downloaded', (media as Document.document).id); }); } } diff --git a/src/lib/storages/thumbs.ts b/src/lib/storages/thumbs.ts index b40b10b8..550939dc 100644 --- a/src/lib/storages/thumbs.ts +++ b/src/lib/storages/thumbs.ts @@ -4,6 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +import type { WebDocument } from "../../layer"; import type { MyDocument } from "../appManagers/appDocsManager"; import type { MyPhoto } from "../appManagers/appPhotosManager"; @@ -21,29 +22,33 @@ export type ThumbsCache = { const thumbFullSize = 'full'; +export type ThumbStorageMedia = MyPhoto | MyDocument | WebDocument; + export default class ThumbsStorage { private thumbsCache: ThumbsCache = {}; - public getCacheContext(media: MyPhoto | MyDocument, thumbSize: string = thumbFullSize): ThumbCache { + private getKey(media: ThumbStorageMedia) { + return media._ + ((media as MyPhoto).id ?? (media as WebDocument).url); + } + + public getCacheContext(media: ThumbStorageMedia, thumbSize: string = thumbFullSize): ThumbCache { /* if(media._ === 'photo' && thumbSize !== 'i') { thumbSize = thumbFullSize; } */ - const key = media._ + media.id; - const cache = this.thumbsCache[key] ??= {}; + const cache = this.thumbsCache[this.getKey(media)] ??= {}; return cache[thumbSize] ??= {downloaded: 0, url: '', type: thumbSize}; } - public setCacheContextURL(media: MyPhoto | MyDocument, thumbSize: string = thumbFullSize, url: string, downloaded: number = 0) { + public setCacheContextURL(media: ThumbStorageMedia, thumbSize: string = thumbFullSize, url: string, downloaded: number = 0) { const cacheContext = this.getCacheContext(media, thumbSize); cacheContext.url = url; cacheContext.downloaded = downloaded; return cacheContext; } - public deleteCacheContext(media: MyPhoto | MyDocument, thumbSize: string = thumbFullSize) { - const key = media._ + media.id; - const cache = this.thumbsCache[key]; + public deleteCacheContext(media: ThumbStorageMedia, thumbSize: string = thumbFullSize) { + const cache = this.thumbsCache[this.getKey(media)]; if(cache) { delete cache[thumbSize]; } diff --git a/src/scripts/in/schema_additional_params.json b/src/scripts/in/schema_additional_params.json index 49053967..3606b279 100644 --- a/src/scripts/in/schema_additional_params.json +++ b/src/scripts/in/schema_additional_params.json @@ -338,4 +338,16 @@ {"name": "file_size_max", "type": "number"}, {"name": "video_size_max", "type": "number"} ] +}, { + "predicate": "webDocument", + "params": [ + {"name": "h", "type": "number"}, + {"name": "w", "type": "number"} + ] +}, { + "predicate": "webDocumentNoProxy", + "params": [ + {"name": "h", "type": "number"}, + {"name": "w", "type": "number"} + ] }] \ No newline at end of file diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index 4afed5d0..ca4a9f79 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -2856,3 +2856,8 @@ $bubble-beside-button-width: 38px; } } } + +.bubble-primary-color { + color: var(--message-primary-color); + font-weight: var(--font-weight-bold); +} From 9969b2b4ea6e0e9f393724407abb6d61eba363cf Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Sat, 2 Jul 2022 01:34:19 +0200 Subject: [PATCH 2/4] Add stickers shimmer --- src/components/wrappers/sticker.ts | 43 +++++++++++++++++++++++++++--- src/index.hbs | 8 ++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/components/wrappers/sticker.ts b/src/components/wrappers/sticker.ts index 77e92be1..b957c170 100644 --- a/src/components/wrappers/sticker.ts +++ b/src/components/wrappers/sticker.ts @@ -168,11 +168,48 @@ export default async function wrapSticker({doc, div, middleware, lazyLoadQueue, if(thumb._ === 'photoPathSize') { if(thumb.bytes.length) { const d = getPathFromBytes(thumb.bytes); - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); svg.classList.add('rlottie-vector', 'media-sticker', 'thumbnail'); svg.setAttributeNS(null, 'viewBox', `0 0 ${doc.w || 512} ${doc.h || 512}`); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + // const defs = document.createElementNS(ns, 'defs'); + // const linearGradient = document.createElementNS(ns, 'linearGradient'); + // linearGradient.setAttributeNS(null, 'id', 'g'); + // linearGradient.setAttributeNS(null, 'x1', '-300%'); + // linearGradient.setAttributeNS(null, 'x2', '-200%'); + // linearGradient.setAttributeNS(null, 'y1', '0'); + // linearGradient.setAttributeNS(null, 'y2', '0'); + // const stops = [ + // ['-10%', '.1'], + // ['30%', '.07'], + // ['70%', '.07'], + // ['110%', '.1'] + // ].map(([offset, stopOpacity]) => { + // const stop = document.createElementNS(ns, 'stop'); + // stop.setAttributeNS(null, 'offset', offset); + // stop.setAttributeNS(null, 'stop-opacity', stopOpacity); + // return stop; + // }); + // const animates = [ + // ['-300%', '1200%'], + // ['-200%', '1300%'] + // ].map(([from, to], idx) => { + // const animate = document.createElementNS(ns, 'animate'); + // animate.setAttributeNS(null, 'attributeName', 'x' + (idx + 1)); + // animate.setAttributeNS(null, 'from', from); + // animate.setAttributeNS(null, 'to', to); + // animate.setAttributeNS(null, 'dur', '3s'); + // animate.setAttributeNS(null, 'repeatCount', 'indefinite'); + // return animate; + // }); + // linearGradient.append(...stops, ...animates); + // defs.append(linearGradient); + // svg.append(defs); + + const path = document.createElementNS(ns, 'path'); path.setAttributeNS(null, 'd', d); + if(rootScope.settings.animationsEnabled) path.setAttributeNS(null, 'fill', 'url(#g)'); svg.append(path); div.append(svg); } else { @@ -237,7 +274,7 @@ export default async function wrapSticker({doc, div, middleware, lazyLoadQueue, loadPromises.push(loadThumbPromise); } - if(onlyThumb) { // for sticker panel + if(onlyThumb/* || true */) { // for sticker panel return; } diff --git a/src/index.hbs b/src/index.hbs index dbf6be10..cb77febf 100644 --- a/src/index.hbs +++ b/src/index.hbs @@ -102,6 +102,14 @@ + + + + + + + +
From d916eb17ea998b8830fed8cf748db80c221e7451 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Sat, 2 Jul 2022 02:53:09 +0200 Subject: [PATCH 3/4] Animated input placeholder --- src/scss/style.scss | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/scss/style.scss b/src/scss/style.scss index e5a26d5d..f193e94a 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -8,6 +8,7 @@ $placeholder-color: #9e9e9e; $border-radius: 8px; $border-radius-medium: 10px; $border-radius-big: 12px; +$border-radius-huge: 16px; $hover-alpha: .08; @@ -65,6 +66,7 @@ $chat-input-inner-padding-handhelds: .25rem; --tabs-transition: .25s ease-in-out; --btn-menu-transition: .2s cubic-bezier(.4, 0, .2, 1); --esg-transition: var(--btn-menu-transition); + --input-transition: .2s ease-out; --popup-transition-function: cubic-bezier(.4, 0, .2, 1); --popup-transition-time: .15s; //--layer-transition: .3s cubic-bezier(.33, 1, .68, 1); @@ -919,11 +921,26 @@ img.emoji { } [contenteditable][data-placeholder] { - &:empty:before { + &:before { content: attr(data-placeholder); color: #a2acb4; display: block; /* For Firefox By Ariel Flesler */ pointer-events: none; + position: absolute; + opacity: 0; + + @include animation-level(2) { + transform: translateX(1.75rem); + transition: opacity var(--input-transition) .01s, transform var(--input-transition) .01s; + } + } + + &:empty:before { + opacity: 1; + + @include animation-level(2) { + transform: translateX(0); + } } } From 86c7640f131b5fc5f3dfda0bbd660d800d6e3f76 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Mon, 18 Jul 2022 15:00:41 +0200 Subject: [PATCH 4/4] Payments --- src/components/call/index.ts | 2 +- src/components/chat/bubbles.ts | 42 +- src/components/chat/input.ts | 9 +- src/components/confirmationPopup.ts | 6 +- src/components/countryInputField.ts | 288 +++++++ src/components/groupCall/index.ts | 5 +- src/components/inputField.ts | 155 ++-- src/components/inputFieldAnimated.ts | 76 ++ src/components/middleEllipsis.ts | 4 +- src/components/popups/avatar.ts | 2 +- src/components/popups/createContact.ts | 4 +- src/components/popups/createPoll.ts | 6 +- src/components/popups/datePicker.ts | 26 +- src/components/popups/index.ts | 66 +- src/components/popups/joinChatInvite.ts | 33 +- src/components/popups/mute.ts | 49 +- src/components/popups/newMedia.ts | 5 +- src/components/popups/payment.ts | 787 ++++++++++++++++++ src/components/popups/paymentCard.ts | 545 ++++++++++++ .../popups/paymentCardConfirmation.ts | 70 ++ src/components/popups/paymentShipping.ts | 231 +++++ .../popups/paymentShippingMethods.ts | 80 ++ src/components/popups/paymentVerification.ts | 64 ++ src/components/popups/peer.ts | 23 +- src/components/popups/pickUser.ts | 2 +- src/components/popups/reactedList.ts | 5 +- src/components/popups/schedule.ts | 3 +- src/components/popups/sponsored.ts | 15 +- src/components/popups/stickers.ts | 57 +- src/components/sidebarLeft/index.ts | 18 +- src/components/sidebarLeft/tabs/2fa/email.ts | 2 +- .../sidebarLeft/tabs/2fa/emailConfirmation.ts | 2 +- .../sidebarLeft/tabs/2fa/enterPassword.ts | 2 +- src/components/sidebarLeft/tabs/2fa/index.ts | 2 +- .../sidebarLeft/tabs/2fa/passwordSet.ts | 2 +- .../sidebarLeft/tabs/privacyAndSecurity.ts | 45 + src/components/usernameInputField.ts | 2 +- .../wrappers/messageActionTextNewUnsafe.ts | 7 +- src/config/app.ts | 2 +- src/config/font.ts | 9 + src/helpers/array/createArray.ts | 5 + src/helpers/cacheCallback.ts | 9 + src/helpers/cards/cardBrands.ts | 112 +++ src/helpers/cards/cardFormattingPatterns.ts | 78 ++ .../cards/formatInputValueByPattern.ts | 25 + src/helpers/cards/formatValueByPattern.ts | 102 +++ src/helpers/cards/patternCharacters.ts | 85 ++ src/helpers/cards/validateCard.ts | 82 ++ src/helpers/dom/loadScript.ts | 17 + src/helpers/dom/placeCaretAtEnd.ts | 8 +- src/helpers/long/longFromInts.ts | 7 - src/helpers/long/ulongFromInts.ts | 7 + src/helpers/paymentsWrapCurrencyAmount.ts | 9 +- src/helpers/scrollSaver.ts | 2 + src/helpers/string/buggedNumbers.ts | 14 + src/helpers/string/nbsp.ts | 2 + src/helpers/string/replaceNonLatin.ts | 3 + src/helpers/string/replaceNonNumber.ts | 3 + src/lang.ts | 58 +- src/langSign.ts | 2 +- src/lib/appManagers/appImManager.ts | 2 +- src/lib/appManagers/appInlineBotsManager.ts | 53 +- src/lib/appManagers/appMessagesManager.ts | 24 +- src/lib/appManagers/appPaymentsManager.ts | 75 ++ src/lib/appManagers/appPeersManager.ts | 2 +- src/lib/appManagers/createManagers.ts | 4 +- src/lib/appManagers/manager.ts | 2 + src/lib/appManagers/uiNotificationsManager.ts | 4 +- src/lib/mtproto/apiFileManager.ts | 4 + src/lib/mtproto/passwordManager.ts | 6 +- src/lib/mtproto/telegramMeWebManager.ts | 12 +- src/lib/mtproto/timeManager.ts | 4 +- src/lib/mtproto/tl_utils.ts | 42 +- src/lib/rootScope.ts | 4 +- src/pages/pageSignIn.ts | 233 +----- src/scss/components/_global.scss | 8 + src/scss/mixins/_hover.scss | 4 +- src/scss/partials/_button.scss | 3 +- src/scss/partials/_chatBubble.scss | 10 + src/scss/partials/_checkbox.scss | 27 +- src/scss/partials/_input.scss | 32 +- src/scss/partials/_row.scss | 27 +- src/scss/partials/_scrollable.scss | 17 + src/scss/partials/popups/_createPoll.scss | 7 +- src/scss/partials/popups/_mediaAttacher.scss | 10 - src/scss/partials/popups/_mute.scss | 15 +- src/scss/partials/popups/_payment.scss | 249 ++++++ src/scss/partials/popups/_paymentCard.scss | 11 + .../popups/_paymentCardConfirmation.scss | 15 + .../popups/_paymentShippingMethods.scss | 15 + .../partials/popups/_paymentVerification.scss | 9 + src/scss/partials/popups/_peer.scss | 38 +- src/scss/partials/popups/_popup.scss | 49 +- src/scss/partials/popups/_sponsored.scss | 10 - src/scss/partials/popups/_stickers.scss | 14 +- src/scss/style.scss | 22 + src/scss/tgico/_style.scss | 21 +- src/scss/tgico/_variables.scss | 325 ++++---- src/tests/cards.test.ts | 59 ++ src/types.d.ts | 25 + 100 files changed, 4042 insertions(+), 834 deletions(-) create mode 100644 src/components/countryInputField.ts create mode 100644 src/components/inputFieldAnimated.ts create mode 100644 src/components/popups/payment.ts create mode 100644 src/components/popups/paymentCard.ts create mode 100644 src/components/popups/paymentCardConfirmation.ts create mode 100644 src/components/popups/paymentShipping.ts create mode 100644 src/components/popups/paymentShippingMethods.ts create mode 100644 src/components/popups/paymentVerification.ts create mode 100644 src/config/font.ts create mode 100644 src/helpers/array/createArray.ts create mode 100644 src/helpers/cacheCallback.ts create mode 100644 src/helpers/cards/cardBrands.ts create mode 100644 src/helpers/cards/cardFormattingPatterns.ts create mode 100644 src/helpers/cards/formatInputValueByPattern.ts create mode 100644 src/helpers/cards/formatValueByPattern.ts create mode 100644 src/helpers/cards/patternCharacters.ts create mode 100644 src/helpers/cards/validateCard.ts create mode 100644 src/helpers/dom/loadScript.ts delete mode 100644 src/helpers/long/longFromInts.ts create mode 100644 src/helpers/long/ulongFromInts.ts create mode 100644 src/helpers/string/buggedNumbers.ts create mode 100644 src/helpers/string/nbsp.ts create mode 100644 src/helpers/string/replaceNonLatin.ts create mode 100644 src/helpers/string/replaceNonNumber.ts create mode 100644 src/lib/appManagers/appPaymentsManager.ts create mode 100644 src/scss/partials/popups/_payment.scss create mode 100644 src/scss/partials/popups/_paymentCard.scss create mode 100644 src/scss/partials/popups/_paymentCardConfirmation.scss create mode 100644 src/scss/partials/popups/_paymentShippingMethods.scss create mode 100644 src/scss/partials/popups/_paymentVerification.scss create mode 100644 src/tests/cards.test.ts diff --git a/src/components/call/index.ts b/src/components/call/index.ts index 227c1d3f..57d47804 100644 --- a/src/components/call/index.ts +++ b/src/components/call/index.ts @@ -77,7 +77,7 @@ export default class PopupCall extends PopupElement { private controlsHover: ControlsHover; constructor(private instance: CallInstance) { - super('popup-call', undefined, { + super('popup-call', { withoutOverlay: true, closable: true }); diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 9aaead28..36d45675 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -109,6 +109,7 @@ import { EmoticonsDropdown } from "../emoticonsDropdown"; import indexOfAndSplice from "../../helpers/array/indexOfAndSplice"; import noop from "../../helpers/noop"; import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; +import PopupPayment from "../popups/payment"; const USE_MEDIA_TAILS = false; const IGNORE_ACTIONS: Set = new Set([ @@ -540,12 +541,15 @@ export default class ChatBubbles { }); }); - this.listenerSetter.add(rootScope)('message_edit', ({storageKey, message}) => { + this.listenerSetter.add(rootScope)('message_edit', async({storageKey, message}) => { if(storageKey !== this.chat.messagesStorageKey) return; const bubble = this.bubbles[message.mid]; if(!bubble) return; + await getHeavyAnimationPromise(); + if(this.bubbles[message.mid] !== bubble) return; + this.safeRenderMessage(message, true, bubble); }); @@ -1467,6 +1471,18 @@ export default class ChatBubbles { return; } + const buyButton: HTMLElement = findUpClassName(target, 'is-buy'); + if(buyButton) { + const message = await this.chat.getMessage(+bubble.dataset.mid); + if(!message) { + return; + } + + new PopupPayment(message as Message.message); + + return; + } + const spoiler: HTMLElement = findUpClassName(target, 'spoiler'); if(spoiler) { const messageDiv = findUpClassName(spoiler, 'message'); @@ -1898,8 +1914,6 @@ export default class ChatBubbles { } public loadMoreHistory(top: boolean, justLoad = false) { - // return; - //this.log('loadMoreHistory', top); if( !this.peerId || @@ -3081,6 +3095,10 @@ export default class ChatBubbles { const processQueue = async(): Promise => { log('start'); + // if(!this.chat.setPeerPromise) { + // await pause(10000000); + // } + const renderQueue = this.messagesQueue.slice(); this.messagesQueue.length = 0; @@ -3164,7 +3182,9 @@ export default class ChatBubbles { this.ejectBubbles(); for(const [bubble, oldBubble] of this.bubblesToReplace) { - scrollSaver.replaceSaved(oldBubble, bubble); + if(scrollSaver) { + scrollSaver.replaceSaved(oldBubble, bubble); + } if(!loadQueue.find((details) => details.bubble === bubble)) { continue; @@ -3674,7 +3694,12 @@ export default class ChatBubbles { let target = e.target as HTMLElement; if(!target.classList.contains('reply-markup-button')) target = findUpClassName(target, 'reply-markup-button'); - if(!target || target.classList.contains('is-link') || target.classList.contains('is-switch-inline')) return; + if( + !target + || target.classList.contains('is-link') + || target.classList.contains('is-switch-inline') + || target.classList.contains('is-buy') + ) return; cancelEvent(e); @@ -4235,12 +4260,13 @@ export default class ChatBubbles { const titleDiv = document.createElement('div'); titleDiv.classList.add('bubble-primary-color'); - setInnerHTML(titleDiv, wrapRichText(messageMedia.title)); + setInnerHTML(titleDiv, wrapEmojiText(messageMedia.title)); - const richText = wrapRichText(messageMedia.description); + const richText = wrapEmojiText(messageMedia.description); messageDiv.prepend(...[titleDiv, !photo && priceEl, richText].filter(Boolean)); bubble.classList.remove('is-message-empty'); + bubble.classList.add('is-invoice'); break; } @@ -4943,7 +4969,7 @@ export default class ChatBubbles { const isSponsored = !!(message as Message.message).pFlags.sponsored; const middleware = this.getMiddleware(); const m = middlewarePromise(middleware); - return this.safeRenderMessage(message, isSponsored ? false : true, undefined, false, async(result) => { + return this.safeRenderMessage(message, isSponsored ? false : true, undefined, isSponsored, async(result) => { const {bubble} = await m(result); if(!bubble) { return result; diff --git a/src/components/chat/input.ts b/src/components/chat/input.ts index ce2ece31..7a60d5ae 100644 --- a/src/components/chat/input.ts +++ b/src/components/chat/input.ts @@ -93,6 +93,7 @@ import { emojiFromCodePoints } from "../../vendor/emoji"; import { modifyAckedPromise } from "../../helpers/modifyAckedResult"; import ChatSendAs from "./sendAs"; import filterAsync from "../../helpers/array/filterAsync"; +import InputFieldAnimated from "../inputFieldAnimated"; const RECORD_MIN_TIME = 500; const POSTING_MEDIA_NOT_ALLOWED = 'Posting media content isn\'t allowed in this group.'; @@ -103,7 +104,7 @@ export default class ChatInput { // private static AUTO_COMPLETE_REG_EXP = /(\s|^)((?::|.)(?!.*[:@]).*|(?:[@\/]\S*))$/; private static AUTO_COMPLETE_REG_EXP = /(\s|^)((?:(?:@|^\/)\S*)|(?::|^[^:@\/])(?!.*[:@\/]).*)$/; public messageInput: HTMLElement; - public messageInputField: InputField; + public messageInputField: InputFieldAnimated; private fileInput: HTMLInputElement; private inputMessageContainer: HTMLDivElement; private btnSend: HTMLButtonElement; @@ -1441,10 +1442,10 @@ export default class ChatInput { private attachMessageInputField() { const oldInputField = this.messageInputField; - this.messageInputField = new InputField({ + this.messageInputField = new InputFieldAnimated({ placeholder: 'Message', name: 'message', - animate: true + withLinebreaks: true }); this.messageInputField.input.classList.replace('input-field-input', 'input-message-input'); @@ -1907,7 +1908,7 @@ export default class ChatInput { //const saveExecuted = this.prepareDocumentExecute(); // can't exec .value here because it will instantly check for autocomplete const value = documentFragmentToHTML(wrapDraftText(newValue, {entities})); - this.messageInputField.setValueSilently(value, true); + this.messageInputField.setValueSilently(value); const caret = this.messageInput.querySelector('.composer-sel'); if(caret) { diff --git a/src/components/confirmationPopup.ts b/src/components/confirmationPopup.ts index bfeab767..8b078b8b 100644 --- a/src/components/confirmationPopup.ts +++ b/src/components/confirmationPopup.ts @@ -8,7 +8,7 @@ import { addCancelButton } from "./popups"; import PopupPeer, { PopupPeerOptions } from "./popups/peer"; // type PopupConfirmationOptions = Pick; -type PopupConfirmationOptions = PopupPeerOptions & { +export type PopupConfirmationOptions = PopupPeerOptions & { button: PopupPeerOptions['buttons'][0], checkbox?: PopupPeerOptions['checkboxes'][0] }; @@ -20,14 +20,14 @@ export default function confirmationPopup(options: PopupConfirmationOptions) { resolve(set ? !!set.size : undefined); }; - const buttons = addCancelButton([button]); + const buttons = addCancelButton(options.buttons || [button]); const cancelButton = buttons.find((button) => button.isCancel); cancelButton.callback = () => { reject(); }; options.buttons = buttons; - options.checkboxes = checkbox && [checkbox]; + options.checkboxes ??= checkbox && [checkbox]; new PopupPeer('popup-confirmation', options).show(); }); diff --git a/src/components/countryInputField.ts b/src/components/countryInputField.ts new file mode 100644 index 00000000..217d7a30 --- /dev/null +++ b/src/components/countryInputField.ts @@ -0,0 +1,288 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import IS_EMOJI_SUPPORTED from "../environment/emojiSupport"; +import cancelEvent from "../helpers/dom/cancelEvent"; +import simulateEvent from "../helpers/dom/dispatchEvent"; +import findUpClassName from "../helpers/dom/findUpClassName"; +import findUpTag from "../helpers/dom/findUpTag"; +import replaceContent from "../helpers/dom/replaceContent"; +import setInnerHTML from "../helpers/dom/setInnerHTML"; +import fastSmoothScroll from "../helpers/fastSmoothScroll"; +import { randomLong } from "../helpers/random"; +import { HelpCountry, HelpCountryCode } from "../layer"; +import I18n, { i18n } from "../lib/langPack"; +import wrapEmojiText from "../lib/richTextProcessor/wrapEmojiText"; +import rootScope from "../lib/rootScope"; +import { getCountryEmoji } from "../vendor/emoji"; +import InputField, { InputFieldOptions } from "./inputField"; +import Scrollable from "./scrollable"; + +let countries: HelpCountry.helpCountry[]; +const setCountries = () => { + countries = I18n.countriesList + .filter((country) => !country.pFlags?.hidden) + .sort((a, b) => (a.name || a.default_name).localeCompare(b.name || b.default_name)); +}; + +let init = () => { + setCountries(); + rootScope.addEventListener('language_change', () => { + setCountries(); + }); +}; + +export default class CountryInputField extends InputField { + private lastCountrySelected: HelpCountry; + private lastCountryCodeSelected: HelpCountryCode; + + private hideTimeout: number; + private selectWrapper: HTMLElement; + + private liMap: Map; + + constructor(public options: InputFieldOptions & { + onCountryChange?: (country: HelpCountry.helpCountry, code: HelpCountryCode.helpCountryCode) => void, + noPhoneCodes?: boolean + } = {}) { + super({ + label: 'Country', + name: randomLong(), + ...options, + }); + + if(init) { + init(); + init = undefined; + } + + this.liMap = new Map(); + + this.container.classList.add('input-select'); + + const selectWrapper = this.selectWrapper = document.createElement('div'); + selectWrapper.classList.add('select-wrapper', 'z-depth-3', 'hide'); + + const arrowDown = document.createElement('span'); + arrowDown.classList.add('arrow', 'arrow-down'); + this.container.append(arrowDown); + + const selectList = document.createElement('ul'); + selectWrapper.appendChild(selectList); + + const scroll = new Scrollable(selectWrapper); + + let initSelect = () => { + initSelect = null; + + countries.forEach((c) => { + const emoji = getCountryEmoji(c.iso2); + + const liArr: Array = []; + for(let i = 0, length = Math.min(c.country_codes.length, options.noPhoneCodes ? 1 : Infinity); i < length; ++i) { + const countryCode = c.country_codes[i]; + const li = document.createElement('li'); + + let wrapped = wrapEmojiText(emoji); + if(IS_EMOJI_SUPPORTED) { + const spanEmoji = document.createElement('span'); + setInnerHTML(spanEmoji, wrapped); + li.append(spanEmoji); + } else { + setInnerHTML(li, wrapped); + } + + const el = i18n(c.default_name as any); + el.dataset.defaultName = c.default_name; + li.append(el); + + if(!options.noPhoneCodes) { + const span = document.createElement('span'); + span.classList.add('phone-code'); + span.innerText = '+' + countryCode.country_code; + li.appendChild(span); + } + + liArr.push(li); + selectList.append(li); + } + + this.liMap.set(c.iso2, liArr); + }); + + selectList.addEventListener('mousedown', (e) => { + if(e.button !== 0) { // other buttons but left shall not pass + return; + } + + const target = findUpTag(e.target, 'LI') + this.selectCountryByTarget(target); + //console.log('clicked', e, countryName, phoneCode); + }); + + this.container.appendChild(selectWrapper); + }; + + initSelect(); + + this.input.addEventListener('focus', (e) => { + if(initSelect) { + initSelect(); + } else { + countries.forEach((c) => { + this.liMap.get(c.iso2).forEach((li) => li.style.display = ''); + }); + } + + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + + selectWrapper.classList.remove('hide'); + void selectWrapper.offsetWidth; // reflow + selectWrapper.classList.add('active'); + + this.select(); + + fastSmoothScroll({ + // container: page.pageEl.parentElement.parentElement, + container: findUpClassName(this.container, 'scrollable-y'), + element: this.input, + position: 'start', + margin: 4 + }); + + setTimeout(() => { + if(!mouseDownHandlerAttached) { + document.addEventListener('mousedown', onMouseDown, {capture: true}); + mouseDownHandlerAttached = true; + } + }, 0); + }); + + let mouseDownHandlerAttached = false; + const onMouseDown = (e: MouseEvent) => { + if(findUpClassName(e.target, 'input-select')) { + return; + } + if(e.target === this.input) { + return; + } + + this.hidePicker(); + document.removeEventListener('mousedown', onMouseDown, {capture: true}); + mouseDownHandlerAttached = false; + }; + + /* false && this.input.addEventListener('blur', function(this: typeof this.input, e) { + hidePicker(); + + e.cancelBubble = true; + }, {capture: true}); */ + + const onKeyPress = (e: KeyboardEvent) => { + const key = e.key; + if(e.ctrlKey || key === 'Control') return false; + + //let i = new RegExp('^' + this.value, 'i'); + let _value = this.value.toLowerCase(); + let matches: HelpCountry[] = []; + countries.forEach((c) => { + const names = [ + c.name, + c.default_name, + c.iso2 + ]; + + names.filter(Boolean).forEach((name) => { + const abbr = name.split(' ').filter((word) => /\w/.test(word)).map((word) => word[0]).join(''); + if(abbr.length > 1) { + names.push(abbr); + } + }); + + let good = !!names.filter(Boolean).find((str) => str.toLowerCase().indexOf(_value) !== -1)/* === 0 */;//i.test(c.name); + + this.liMap.get(c.iso2).forEach((li) => li.style.display = good ? '' : 'none'); + if(good) matches.push(c); + }); + + // Код ниже автоматически выберет страну если она осталась одна при поиске + /* if(matches.length === 1 && matches[0].li.length === 1) { + if(matches[0].name === lastCountrySelected) return false; + //console.log('clicking', matches[0]); + + var clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent('mousedown', true, true); + matches[0].li[0].dispatchEvent(clickEvent); + return false; + } else */if(matches.length === 0) { + countries.forEach((c) => { + this.liMap.get(c.iso2).forEach((li) => li.style.display = ''); + }); + } else if(matches.length === 1 && key === 'Enter') { + cancelEvent(e); + this.selectCountryByTarget(this.liMap.get(matches[0].iso2)[0]); + } + }; + + this.input.addEventListener('keyup', onKeyPress); + this.input.addEventListener('keydown', (e) => { + if(e.key === 'Enter') { + onKeyPress(e); + } + }); + + arrowDown.addEventListener('mousedown', (e) => { + if(this.input.matches(':focus')) { + this.hidePicker(); + this.input.blur(); + } else { + e.cancelBubble = true; + e.preventDefault(); + this.input.focus(); + } + }); + } + + public getSelected() { + return {country: this.lastCountrySelected, code: this.lastCountryCodeSelected}; + } + + public hidePicker = () => { + if(this.hideTimeout !== undefined) return; + this.selectWrapper.classList.remove('active'); + this.hideTimeout = window.setTimeout(() => { + this.selectWrapper.classList.add('hide'); + this.hideTimeout = undefined; + }, 200); + } + + public selectCountryByTarget = (target: HTMLElement) => { + const defaultName = target.querySelector('[data-default-name]').dataset.defaultName; + const phoneCodeEl = target.querySelector('.phone-code'); + const phoneCode = phoneCodeEl?.innerText; + const countryCode = phoneCode && phoneCode.replace(/\D/g, ''); + + replaceContent(this.input, i18n(defaultName as any)); + simulateEvent(this.input, 'input'); + this.lastCountrySelected = countries.find((c) => c.default_name === defaultName); + this.lastCountryCodeSelected = countryCode && this.lastCountrySelected.country_codes.find((_countryCode) => _countryCode.country_code === countryCode); + + this.options.onCountryChange?.(this.lastCountrySelected, this.lastCountryCodeSelected); + this.hidePicker(); + } + + public selectCountryByIso2(iso2: string) { + this.selectCountryByTarget(this.liMap.get(iso2)[0]); + } + + public override(country: HelpCountry, code: HelpCountryCode, countryName?: string) { + replaceContent(this.input, country ? i18n(country.default_name as any) : countryName); + this.lastCountrySelected = country; + this.lastCountryCodeSelected = code; + this.options.onCountryChange?.(this.lastCountrySelected, this.lastCountryCodeSelected); + } +} diff --git a/src/components/groupCall/index.ts b/src/components/groupCall/index.ts index b76fd289..4fd7dced 100644 --- a/src/components/groupCall/index.ts +++ b/src/components/groupCall/index.ts @@ -140,10 +140,11 @@ export default class PopupGroupCall extends PopupElement { private btnScreen: HTMLDivElement; constructor() { - super('popup-group-call', undefined, { + super('popup-group-call', { body: true, withoutOverlay: true, - closable: true + closable: true, + title: true }); this.videosCount = 0; diff --git a/src/components/inputField.ts b/src/components/inputField.ts index dd87ebd7..09a85732 100644 --- a/src/components/inputField.ts +++ b/src/components/inputField.ts @@ -4,6 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +import cancelEvent from "../helpers/dom/cancelEvent"; import simulateEvent from "../helpers/dom/dispatchEvent"; import documentFragmentToHTML from "../helpers/dom/documentFragmentToHTML"; import findUpAttribute from "../helpers/dom/findUpAttribute"; @@ -16,14 +17,15 @@ import { i18n, LangPackKey, _i18n } from "../lib/langPack"; import mergeEntities from "../lib/richTextProcessor/mergeEntities"; import parseEntities from "../lib/richTextProcessor/parseEntities"; import wrapDraftText from "../lib/richTextProcessor/wrapDraftText"; -import SetTransition from "./singleTransition"; let init = () => { document.addEventListener('paste', (e) => { - if(!findUpAttribute(e.target, 'contenteditable="true"')) { + const input = findUpAttribute(e.target, 'contenteditable="true"'); + if(!input) { return; } + const noLinebreaks = !!input.dataset.noLinebreaks; e.preventDefault(); let text: string, entities: MessageEntity[]; @@ -33,6 +35,14 @@ let init = () => { // @ts-ignore let html: string = (e.originalEvent || e).clipboardData.getData('text/html'); + + const filterEntity = (e: MessageEntity) => e._ === 'messageEntityEmoji' || (e._ === 'messageEntityLinebreak' && !noLinebreaks); + if(noLinebreaks) { + const regExp = /[\r\n]/g; + plainText = plainText.replace(regExp, ''); + html = html.replace(regExp, ''); + } + if(html.trim()) { html = html.replace(//, ''); html = html.replace(//, ''); @@ -64,7 +74,7 @@ let init = () => { usePlainText = false; let entities2 = parseEntities(text); - entities2 = entities2.filter((e) => e._ === 'messageEntityEmoji' || e._ === 'messageEntityLinebreak'); + entities2 = entities2.filter(filterEntity); mergeEntities(entities, entities2); } } @@ -72,7 +82,7 @@ let init = () => { if(usePlainText) { text = plainText; entities = parseEntities(text); - entities = entities.filter((e) => e._ === 'messageEntityEmoji' || e._ === 'messageEntityLinebreak'); + entities = entities.filter(filterEntity); } const fragment = wrapDraftText(text, {entities}); @@ -116,16 +126,17 @@ export type InputFieldOptions = { maxLength?: number, showLengthOn?: number, plainText?: true, - animate?: boolean, required?: boolean, canBeEdited?: boolean, - validate?: () => boolean + validate?: () => boolean, + inputMode?: 'tel' | 'numeric', + withLinebreaks?: boolean, + autocomplete?: string }; -class InputField { +export default class InputField { public container: HTMLElement; public input: HTMLElement; - public inputFake: HTMLElement; public label: HTMLLabelElement; public originalValue: string; @@ -133,10 +144,6 @@ class InputField { public required: boolean; public validate: () => boolean; - //public onLengthChange: (length: number, isOverflow: boolean) => void; - // protected wasInputFakeClientHeight: number; - // protected showScrollDebounced: () => void; - constructor(public options: InputFieldOptions = {}) { this.container = document.createElement('div'); this.container.classList.add('input-field'); @@ -148,10 +155,10 @@ class InputField { options.showLengthOn = Math.min(40, Math.round(options.maxLength / 3)); } - const {placeholder, maxLength, showLengthOn, name, plainText, canBeEdited = true} = options; - - let label = options.label || options.labelText; + const {placeholder, maxLength, showLengthOn, name, plainText, canBeEdited = true, autocomplete} = options; + const label = options.label || options.labelText; + const onInputCallbacks: Array<() => void> = []; let input: HTMLElement; if(!plainText) { if(init) { @@ -163,40 +170,26 @@ class InputField { `; input = this.container.firstElementChild as HTMLElement; - const observer = new MutationObserver(() => { - //checkAndSetRTL(input); + // const observer = new MutationObserver(() => { + // //checkAndSetRTL(input); - if(processInput) { - processInput(); - } - }); + // if(processInput) { + // processInput(); + // } + // }); - // * because if delete all characters there will br left - input.addEventListener('input', () => { + onInputCallbacks.push(() => { + // * because if delete all characters there will br left if(isInputEmpty(input)) { - input.innerHTML = ''; - } - - if(this.inputFake) { - this.inputFake.innerHTML = input.innerHTML; - this.onFakeInput(); + input.textContent = ''; } }); - + // ! childList for paste first symbol - observer.observe(input, {characterData: true, childList: true, subtree: true}); - - if(options.animate) { - input.classList.add('scrollable', 'scrollable-y'); - // this.wasInputFakeClientHeight = 0; - // this.showScrollDebounced = debounce(() => this.input.classList.remove('no-scrollbar'), 150, false, true); - this.inputFake = document.createElement('div'); - this.inputFake.setAttribute('contenteditable', 'true'); - this.inputFake.className = input.className + ' input-field-input-fake'; - } + // observer.observe(input, {characterData: true, childList: true, subtree: true}); } else { this.container.innerHTML = ` - + `; input = this.container.firstElementChild as HTMLElement; @@ -204,13 +197,13 @@ class InputField { } input.setAttribute('dir', 'auto'); + + if(options.inputMode) { + input.inputMode = options.inputMode; + } if(placeholder) { _i18n(input, placeholder, undefined, 'placeholder'); - - if(this.inputFake) { - _i18n(this.inputFake, placeholder, undefined, 'placeholder'); - } } if(label || placeholder) { @@ -225,12 +218,11 @@ class InputField { this.container.append(this.label); } - let processInput: () => void; if(maxLength) { const labelEl = this.container.lastElementChild as HTMLLabelElement; let showingLength = false; - processInput = () => { + const onInput = () => { const wasError = input.classList.contains('error'); // * https://stackoverflow.com/a/54369605 #2 to count emoji as 1 symbol const inputLength = plainText ? (input as HTMLInputElement).value.length : [...getRichValue(input, false).value].length; @@ -250,7 +242,24 @@ class InputField { } }; - input.addEventListener('input', processInput); + onInputCallbacks.push(onInput); + } + + const noLinebreaks = !options.withLinebreaks; + if(noLinebreaks && !plainText) { + input.dataset.noLinebreaks = '1'; + input.addEventListener('keypress', (e) => { + if(e.key === 'Enter') { + e.preventDefault(); + return false; + } + }); + } + + if(onInputCallbacks.length) { + input.addEventListener('input', () => { + onInputCallbacks.forEach((callback) => callback()); + }); } this.input = input; @@ -277,60 +286,22 @@ class InputField { } } - public onFakeInput(setHeight = true) { - const {scrollHeight: newHeight/* , clientHeight */} = this.inputFake; - /* if(this.wasInputFakeClientHeight && this.wasInputFakeClientHeight !== clientHeight) { - this.input.classList.add('no-scrollbar'); // ! в сафари может вообще не появиться скролл после анимации, так как ему нужен полный reflow блока с overflow. - this.showScrollDebounced(); - } */ - - const currentHeight = +this.input.style.height.replace('px', ''); - if(currentHeight === newHeight) { - return; - } - - const TRANSITION_DURATION_FACTOR = 50; - const transitionDuration = Math.round( - TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), - ); - - // this.wasInputFakeClientHeight = clientHeight; - this.input.style.transitionDuration = `${transitionDuration}ms`; - - if(setHeight) { - this.input.style.height = newHeight ? newHeight + 'px' : ''; - } - - const className = 'is-changing-height'; - SetTransition(this.input, className, true, transitionDuration, () => { - this.input.classList.remove(className); - }); - } - get value() { return this.options.plainText ? (this.input as HTMLInputElement).value : getRichValue(this.input, false).value; //return getRichValue(this.input); } set value(value: string) { - this.setValueSilently(value, false); + this.setValueSilently(value, true); simulateEvent(this.input, 'input'); } - public setValueSilently(value: string, fireFakeInput = true) { + public setValueSilently(value: string, fromSet?: boolean) { if(this.options.plainText) { (this.input as HTMLInputElement).value = value; } else { this.input.innerHTML = value; - - if(this.inputFake) { - this.inputFake.innerHTML = value; - - if(fireFakeInput) { - this.onFakeInput(); - } - } } } @@ -348,7 +319,7 @@ class InputField { return this.isValid() && this.isChanged(); } - public setDraftValue(value = '', silent = false) { + public setDraftValue(value = '', silent?: boolean) { if(!this.options.plainText) { value = documentFragmentToHTML(wrapDraftText(value)); } @@ -360,7 +331,7 @@ class InputField { } } - public setOriginalValue(value: InputField['originalValue'] = '', silent = false) { + public setOriginalValue(value: InputField['originalValue'] = '', silent?: boolean) { this.originalValue = value; this.setDraftValue(value, silent); } @@ -369,6 +340,8 @@ class InputField { if(label) { this.label.textContent = ''; this.label.append(i18n(label, this.options.labelOptions)); + } else { + this.setLabel(); } this.input.classList.toggle('error', !!(state & InputState.Error)); @@ -379,5 +352,3 @@ class InputField { this.setState(InputState.Error, label); } } - -export default InputField; diff --git a/src/components/inputFieldAnimated.ts b/src/components/inputFieldAnimated.ts new file mode 100644 index 00000000..99e2dd96 --- /dev/null +++ b/src/components/inputFieldAnimated.ts @@ -0,0 +1,76 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import { _i18n } from "../lib/langPack"; +import InputField, { InputFieldOptions } from "./inputField"; +import SetTransition from "./singleTransition"; + +export default class InputFieldAnimated extends InputField { + public inputFake: HTMLElement; + + //public onLengthChange: (length: number, isOverflow: boolean) => void; + // protected wasInputFakeClientHeight: number; + // protected showScrollDebounced: () => void; + + constructor(options?: InputFieldOptions) { + super(options); + + this.input.addEventListener('input', () => { + this.inputFake.innerHTML = this.input.innerHTML; + this.onFakeInput(); + }); + + if(options.placeholder) { + _i18n(this.inputFake, options.placeholder, undefined, 'placeholder'); + } + + this.input.classList.add('scrollable', 'scrollable-y'); + // this.wasInputFakeClientHeight = 0; + // this.showScrollDebounced = debounce(() => this.input.classList.remove('no-scrollbar'), 150, false, true); + this.inputFake = document.createElement('div'); + this.inputFake.setAttribute('contenteditable', 'true'); + this.inputFake.className = this.input.className + ' input-field-input-fake'; + } + + public onFakeInput(setHeight = true) { + const {scrollHeight: newHeight/* , clientHeight */} = this.inputFake; + /* if(this.wasInputFakeClientHeight && this.wasInputFakeClientHeight !== clientHeight) { + this.input.classList.add('no-scrollbar'); // ! в сафари может вообще не появиться скролл после анимации, так как ему нужен полный reflow блока с overflow. + this.showScrollDebounced(); + } */ + + const currentHeight = +this.input.style.height.replace('px', ''); + if(currentHeight === newHeight) { + return; + } + + const TRANSITION_DURATION_FACTOR = 50; + const transitionDuration = Math.round( + TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), + ); + + // this.wasInputFakeClientHeight = clientHeight; + this.input.style.transitionDuration = `${transitionDuration}ms`; + + if(setHeight) { + this.input.style.height = newHeight ? newHeight + 'px' : ''; + } + + const className = 'is-changing-height'; + SetTransition(this.input, className, true, transitionDuration, () => { + this.input.classList.remove(className); + }); + } + + public setValueSilently(value: string, fromSet?: boolean) { + super.setValueSilently(value, fromSet); + + this.inputFake.innerHTML = value; + if(!fromSet) { + this.onFakeInput(); + } + } +} diff --git a/src/components/middleEllipsis.ts b/src/components/middleEllipsis.ts index d74089bd..fac09c06 100644 --- a/src/components/middleEllipsis.ts +++ b/src/components/middleEllipsis.ts @@ -4,6 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +import { FontFamily, FontSize, FontWeight } from "../config/font"; import getTextWidth from "../helpers/canvas/getTextWidth"; import mediaSizes from "../helpers/mediaSizes"; import clamp from "../helpers/number/clamp"; @@ -33,7 +34,6 @@ const map: Map = new Map(); const testQueue: Set = new Set(); -export const fontFamily = 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif'; const fontSize = '16px'; let pendingTest = false; @@ -90,7 +90,7 @@ function testElement(element: HTMLElement) { multiplier = from > 0 && from / 100; //const perf = performance.now(); - font = `${element.dataset.fontWeight || 400} ${fontSize} ${fontFamily}`; + font = `${element.dataset.fontWeight || FontWeight} ${FontSize} ${FontFamily}`; /* const computedStyle = window.getComputedStyle(elm, null); font = `${computedStyle.getPropertyValue('font-weight')} ${computedStyle.getPropertyValue('font-size')} ${computedStyle.getPropertyValue('font-family')}`; */ //console.log('testMiddleEllipsis get computed style:', performance.now() - perf, font); diff --git a/src/components/popups/avatar.ts b/src/components/popups/avatar.ts index 6073a4d9..7bad74f9 100644 --- a/src/components/popups/avatar.ts +++ b/src/components/popups/avatar.ts @@ -30,7 +30,7 @@ export default class PopupAvatar extends PopupElement { private onCrop: (upload: () => ReturnType) => void; constructor() { - super('popup-avatar', null, {closable: true, withConfirm: true}); + super('popup-avatar', {closable: true, withConfirm: true}); this.h6 = document.createElement('h6'); _i18n(this.h6, 'Popup.Avatar.Title'); diff --git a/src/components/popups/createContact.ts b/src/components/popups/createContact.ts index 0248bf29..786aef2b 100644 --- a/src/components/popups/createContact.ts +++ b/src/components/popups/createContact.ts @@ -15,13 +15,11 @@ import { toastNew } from "../toast"; export default class PopupCreateContact extends PopupElement { constructor() { - super('popup-create-contact popup-send-photo popup-new-media', null, {closable: true, withConfirm: 'Add'}); + super('popup-create-contact popup-send-photo popup-new-media', {closable: true, withConfirm: 'Add', title: 'AddContactTitle'}); this.construct(); } private async construct() { - _i18n(this.title, 'AddContactTitle'); - attachClickEvent(this.btnConfirm, () => { const promise = this.managers.appUsersManager.importContact(nameInputField.value, lastNameInputField.value, telInputField.value); diff --git a/src/components/popups/createPoll.ts b/src/components/popups/createPoll.ts index a8ed127f..a1c7477c 100644 --- a/src/components/popups/createPoll.ts +++ b/src/components/popups/createPoll.ts @@ -27,7 +27,7 @@ const MAX_LENGTH_SOLUTION = 200; export default class PopupCreatePoll extends PopupElement { private questionInputField: InputField; private questions: HTMLElement; - private scrollable: Scrollable; + protected scrollable: Scrollable; private tempId = 0; private anonymousCheckboxField: CheckboxField; @@ -39,13 +39,11 @@ export default class PopupCreatePoll extends PopupElement { private optionInputFields: InputField[]; constructor(private chat: Chat) { - super('popup-create-poll popup-new-media', null, {closable: true, withConfirm: 'Create', body: true}); + super('popup-create-poll popup-new-media', {closable: true, withConfirm: 'Create', body: true, title: 'NewPoll'}); this.construct(); } private async construct() { - _i18n(this.title, 'NewPoll'); - this.questionInputField = new InputField({ placeholder: 'AskAQuestion', label: 'AskAQuestion', diff --git a/src/components/popups/datePicker.ts b/src/components/popups/datePicker.ts index 30def186..ab611dff 100644 --- a/src/components/popups/datePicker.ts +++ b/src/components/popups/datePicker.ts @@ -39,17 +39,23 @@ export default class PopupDatePicker extends PopupElement { withTime: true, showOverflowMonths: true }> & PopupOptions = {}) { - super('popup-date-picker', options.noButtons ? [] : [{ - langKey: 'JumpToDate', - callback: () => { - if(this.onPick) { - this.onPick(this.selectedDate.getTime() / 1000 | 0); + super('popup-date-picker', { + body: true, + overlayClosable: true, + buttons: options.noButtons ? [] : [{ + langKey: 'JumpToDate', + callback: () => { + if(this.onPick) { + this.onPick(this.selectedDate.getTime() / 1000 | 0); + } } - } - }, { - langKey: 'Cancel', - isCancel: true - }], {body: true, overlayClosable: true, ...options}); + }, { + langKey: 'Cancel', + isCancel: true + }], + title: true, + ...options + }); this.minDate = options.minDate || new Date('2013-08-01T00:00:00'); diff --git a/src/components/popups/index.ts b/src/components/popups/index.ts index b165d4d5..f1fd9caa 100644 --- a/src/components/popups/index.ts +++ b/src/components/popups/index.ts @@ -4,11 +4,10 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import rootScope from "../../lib/rootScope"; import ripple from "../ripple"; import animationIntersector from "../animationIntersector"; import appNavigationController, { NavigationItem } from "../appNavigationController"; -import { i18n, LangPackKey } from "../../lib/langPack"; +import { i18n, LangPackKey, _i18n } from "../../lib/langPack"; import findUpClassName from "../../helpers/dom/findUpClassName"; import blurActiveElement from "../../helpers/dom/blurActiveElement"; import ListenerSetter from "../../helpers/listenerSetter"; @@ -20,6 +19,7 @@ import { addFullScreenListener, getFullScreenElement } from "../../helpers/dom/f import indexOfAndSplice from "../../helpers/array/indexOfAndSplice"; import { AppManagers } from "../../lib/appManagers/managers"; import overlayCounter from "../../helpers/overlayCounter"; +import Scrollable from "../scrollable"; export type PopupButton = { text?: string, @@ -37,7 +37,10 @@ export type PopupOptions = Partial<{ withConfirm: LangPackKey | boolean, body: boolean, confirmShortcutIsSendShortcut: boolean, - withoutOverlay: boolean + withoutOverlay: boolean, + scrollable: boolean, + buttons: Array, + title: boolean | LangPackKey }>; export interface PopupElementConstructable { @@ -79,22 +82,32 @@ export default class PopupElement extends protected listenerSetter: ListenerSetter; protected confirmShortcutIsSendShortcut: boolean; - protected btnConfirmOnEnter: HTMLButtonElement; + protected btnConfirmOnEnter: HTMLElement; protected withoutOverlay: boolean; protected managers: AppManagers; - constructor(className: string, protected buttons?: Array, options: PopupOptions = {}) { + protected scrollable: Scrollable; + + protected buttons: Array; + + constructor(className: string, options: PopupOptions = {}) { super(false); this.element.classList.add('popup'); this.element.className = 'popup' + (className ? ' ' + className : ''); this.container.classList.add('popup-container', 'z-depth-1'); this.header.classList.add('popup-header'); - this.title.classList.add('popup-title'); - this.header.append(this.title); + if(options.title) { + this.title.classList.add('popup-title'); + if(typeof(options.title) === 'string') { + _i18n(this.title, options.title); + } + + this.header.append(this.title); + } this.listenerSetter = new ListenerSetter(); this.managers = PopupElement.MANAGERS; @@ -140,14 +153,25 @@ export default class PopupElement extends this.container.append(this.body); } + if(options.scrollable) { + const scrollable = this.scrollable = new Scrollable(this.body); + scrollable.onAdditionalScroll = () => { + scrollable.container.classList.toggle('scrolled-top', !scrollable.scrollTop); + scrollable.container.classList.toggle('scrolled-bottom', scrollable.isScrolledDown); + }; + + scrollable.container.classList.add('scrolled-top', 'scrolled-bottom', 'scrollable-y-bordered'); + + if(!this.body) { + this.container.insertBefore(scrollable.container, this.header.nextSibling); + } + } + let btnConfirmOnEnter = this.btnConfirm; + const buttons = this.buttons = options.buttons; if(buttons?.length) { const buttonsDiv = this.buttonsEl = document.createElement('div'); buttonsDiv.classList.add('popup-buttons'); - - if(buttons.length === 2) { - buttonsDiv.classList.add('popup-buttons-row'); - } const buttonsElements = buttons.map((b) => { const button = document.createElement('button'); @@ -187,6 +211,12 @@ export default class PopupElement extends PopupElement.POPUPS.push(this); } + protected onContentUpdate() { + if(this.scrollable) { + this.scrollable.onAdditionalScroll(); + } + } + public show() { this.navigationItem = { type: 'popup', @@ -201,22 +231,32 @@ export default class PopupElement extends void this.element.offsetWidth; // reflow this.element.classList.add('active'); + this.onContentUpdate(); + if(!this.withoutOverlay) { overlayCounter.isOverlayActive = true; animationIntersector.checkAnimations(true); } // cannot add event instantly because keydown propagation will fire it - if(this.btnConfirmOnEnter) { + // if(this.btnConfirmOnEnter) { setTimeout(() => { + if(!this.element.classList.contains('active')) { + return; + } + this.listenerSetter.add(document.body)('keydown', (e) => { + if(PopupElement.POPUPS[PopupElement.POPUPS.length - 1] !== this) { + return; + } + if(this.confirmShortcutIsSendShortcut ? isSendShortcutPressed(e) : e.key === 'Enter') { simulateClickEvent(this.btnConfirmOnEnter); cancelEvent(e); } }); }, 0); - } + // } } public hide = () => { diff --git a/src/components/popups/joinChatInvite.ts b/src/components/popups/joinChatInvite.ts index 6676cc0d..5c909237 100644 --- a/src/components/popups/joinChatInvite.ts +++ b/src/components/popups/joinChatInvite.ts @@ -24,20 +24,25 @@ export default class PopupJoinChatInvite extends PopupElement { private hash: string, private chatInvite: ChatInvite.chatInvite, ) { - super('popup-join-chat-invite', addCancelButton([{ - langKey: chatInvite.pFlags.request_needed ? 'RequestJoin.Button' : (chatInvite.pFlags.broadcast ? 'JoinByPeekChannelTitle' : 'JoinByPeekGroupTitle'), - callback: () => { - this.managers.appChatsManager.importChatInvite(hash) - .then((chatId) => { - const peerId = chatId.toPeerId(true); - appImManager.setInnerPeer({peerId}); - }, (error) => { - if(error.type === 'INVITE_REQUEST_SENT') { - toastNew({langPackKey: 'RequestToJoinSent'}); - } - }); - } - }]), {closable: true, overlayClosable: true, body: true}); + super('popup-join-chat-invite', { + closable: true, + overlayClosable: true, + body: true, + buttons: addCancelButton([{ + langKey: chatInvite.pFlags.request_needed ? 'RequestJoin.Button' : (chatInvite.pFlags.broadcast ? 'JoinByPeekChannelTitle' : 'JoinByPeekGroupTitle'), + callback: () => { + this.managers.appChatsManager.importChatInvite(hash) + .then((chatId) => { + const peerId = chatId.toPeerId(true); + appImManager.setInnerPeer({peerId}); + }, (error) => { + if(error.type === 'INVITE_REQUEST_SENT') { + toastNew({langPackKey: 'RequestToJoinSent'}); + } + }); + } + }]) + }); this.construct(); } diff --git a/src/components/popups/mute.ts b/src/components/popups/mute.ts index 7cc27bd6..76deadc3 100644 --- a/src/components/popups/mute.ts +++ b/src/components/popups/mute.ts @@ -9,9 +9,29 @@ import { LangPackKey } from "../../lib/langPack"; import { MUTE_UNTIL } from "../../lib/mtproto/mtproto_config"; import RadioField from "../radioField"; import Row, { RadioFormFromRows } from "../row"; -import { SettingSection } from "../sidebarLeft"; import PopupPeer from "./peer"; +const ONE_HOUR = 3600; +const times: {time: number, langKey: LangPackKey}[] = [{ + time: ONE_HOUR, + langKey: 'ChatList.Mute.1Hour' +}, { + time: ONE_HOUR * 4, + langKey: 'ChatList.Mute.4Hours' +}, { + time: ONE_HOUR * 8, + langKey: 'ChatList.Mute.8Hours' +}, { + time: ONE_HOUR * 24, + langKey: 'ChatList.Mute.1Day' +}, { + time: ONE_HOUR * 24 * 3, + langKey: 'ChatList.Mute.3Days' +}, { + time: -1, + langKey: 'ChatList.Mute.Forever' +}]; + export default class PopupMute extends PopupPeer { constructor(peerId: PeerId) { super('popup-mute', { @@ -26,27 +46,6 @@ export default class PopupMute extends PopupPeer { body: true }); - const ONE_HOUR = 3600; - const times: {time: number, langKey: LangPackKey}[] = [{ - time: ONE_HOUR, - langKey: 'ChatList.Mute.1Hour' - }, { - time: ONE_HOUR * 4, - langKey: 'ChatList.Mute.4Hours' - }, { - time: ONE_HOUR * 8, - langKey: 'ChatList.Mute.8Hours' - }, { - time: ONE_HOUR * 24, - langKey: 'ChatList.Mute.1Day' - }, { - time: ONE_HOUR * 24 * 3, - langKey: 'ChatList.Mute.3Days' - }, { - time: -1, - langKey: 'ChatList.Mute.Forever' - }]; - const name = 'mute-time'; const rows = times.map((time) => { const row = new Row({ @@ -65,11 +64,9 @@ export default class PopupMute extends PopupPeer { time = +value; }); - rows[rows.length - 1].radioField.checked = true; + this.body.append(radioForm); - const section = new SettingSection({noShadow: true, noDelimiter: true}); - section.content.append(radioForm); - this.body.append(section.container); + rows[rows.length - 1].radioField.checked = true; this.show(); } diff --git a/src/components/popups/newMedia.ts b/src/components/popups/newMedia.ts index e6e40b59..c046713c 100644 --- a/src/components/popups/newMedia.ts +++ b/src/components/popups/newMedia.ts @@ -66,7 +66,7 @@ export default class PopupNewMedia extends PopupElement { private captionLengthMax: number; constructor(private chat: Chat, private files: File[], willAttachType: PopupNewMedia['willAttach']['type']) { - super('popup-send-photo popup-new-media', null, {closable: true, withConfirm: 'Modal.Send', confirmShortcutIsSendShortcut: true, body: true}); + super('popup-send-photo popup-new-media', {closable: true, withConfirm: 'Modal.Send', confirmShortcutIsSendShortcut: true, body: true, title: true}); this.construct(willAttachType); } @@ -112,7 +112,8 @@ export default class PopupNewMedia extends PopupElement { placeholder: 'PreviewSender.CaptionPlaceholder', label: 'Caption', name: 'photo-caption', - maxLength: this.captionLengthMax + maxLength: this.captionLengthMax, + withLinebreaks: true }); this.input = this.inputField.input; diff --git a/src/components/popups/payment.ts b/src/components/popups/payment.ts new file mode 100644 index 00000000..36b861df --- /dev/null +++ b/src/components/popups/payment.ts @@ -0,0 +1,787 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import Currencies from "../../config/currencies"; +import { FontFamily, FontSize } from "../../config/font"; +import accumulate from "../../helpers/array/accumulate"; +import getTextWidth from "../../helpers/canvas/getTextWidth"; +import { detectUnifiedCardBrand } from "../../helpers/cards/cardBrands"; +import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; +import findUpAsChild from "../../helpers/dom/findUpAsChild"; +import findUpClassName from "../../helpers/dom/findUpClassName"; +import loadScript from "../../helpers/dom/loadScript"; +import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; +import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; +import replaceContent from "../../helpers/dom/replaceContent"; +import setInnerHTML from "../../helpers/dom/setInnerHTML"; +import toggleDisability from "../../helpers/dom/toggleDisability"; +import { formatPhoneNumber } from "../../helpers/formatPhoneNumber"; +import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; +import ScrollSaver from "../../helpers/scrollSaver"; +import tsNow from "../../helpers/tsNow"; +import { AccountTmpPassword, InputPaymentCredentials, LabeledPrice, Message, MessageMedia, PaymentRequestedInfo, PaymentSavedCredentials, PaymentsPaymentForm, PaymentsPaymentReceipt, PaymentsValidatedRequestedInfo, PostAddress, ShippingOption } from "../../layer"; +import I18n, { i18n, LangPackKey, _i18n } from "../../lib/langPack"; +import { ApiError } from "../../lib/mtproto/apiManager"; +import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText"; +import rootScope from "../../lib/rootScope"; +import AvatarElement from "../avatar"; +import Button from "../button"; +import PeerTitle from "../peerTitle"; +import { putPreloader } from "../putPreloader"; +import Row from "../row"; +import { toastNew } from "../toast"; +import { wrapPhoto } from "../wrappers"; +import wrapPeerTitle from "../wrappers/peerTitle"; +import PopupPaymentCard, { PaymentCardDetails, PaymentCardDetailsResult } from "./paymentCard"; +import PopupPaymentCardConfirmation from "./paymentCardConfirmation"; +import PopupPaymentShipping, { PaymentShippingAddress } from "./paymentShipping"; +import PopupPaymentShippingMethods from "./paymentShippingMethods"; +import PopupPaymentVerification from "./paymentVerification"; + +const iconPath = 'assets/img/'; +const icons = [ + 'amex', + 'card', + 'diners', + 'discover', + 'jcb', + 'mastercard', + 'visa', + 'unionpay', + 'logo', +]; + +export function getPaymentBrandIconPath(brand: string) { + if(!icons.includes(brand)) { + return; + } + + return `${iconPath}${brand}.svg`; +} + +export function PaymentButton(options: { + onClick: () => Promise | void, + key?: LangPackKey, + textEl?: I18n.IntlElement +}) { + const textEl = options.textEl ?? new I18n.IntlElement({key: options.key ?? 'PaymentInfo.Done'}); + const key = textEl.key; + const payButton = Button('btn-primary btn-color-primary payment-item-pay'); + payButton.append(textEl.element); + attachClickEvent(payButton, async() => { + const result = options.onClick(); + if(!(result instanceof Promise)) { + return; + } + + const d = putPreloader(payButton); + const toggle = toggleDisability([payButton], true); + textEl.compareAndUpdate({key: 'PleaseWait'}); + try { + await result; + } catch(err) { + if(!(err as any).handled) { + console.error('payment button error', err); + } + + toggle(); + textEl.compareAndUpdate({key}); + d.remove(); + } + }); + return payButton; +} + +export type PaymentsCredentialsToken = {type: 'card', token?: string, id?: string}; + +export default class PopupPayment extends PopupElement { + private currency: string; + private tipButtonsMap: Map; + + constructor(private message: Message.message) { + super('popup-payment', { + closable: true, + overlayClosable: true, + body: true, + scrollable: true, + title: true + }); + + this.tipButtonsMap = new Map(); + this.d(); + } + + private async d() { + this.element.classList.add('is-loading'); + this.show(); + + let confirmed = false; + const onConfirmed = () => { + if(confirmed) { + return; + } + + confirmed = true; + if(popupPaymentVerification) { + popupPaymentVerification.hide(); + } + + this.hide(); + showSuccessToast(); + }; + + const showSuccessToast = () => { + toastNew({ + langPackKey: 'PaymentInfoHint', + langPackArguments: [ + paymentsWrapCurrencyAmount(getTotalTotal(), currency), + wrapEmojiText(mediaInvoice.title) + ] + }); + }; + + this.listenerSetter.add(rootScope)('payment_sent', ({peerId, mid}) => { + if(this.message.peerId === peerId && this.message.mid === mid) { + onConfirmed(); + } + }); + + const {message} = this; + const mediaInvoice = message.media as MessageMedia.messageMediaInvoice; + + _i18n(this.title, mediaInvoice.receipt_msg_id ? 'PaymentReceipt' : 'PaymentCheckout'); + if(mediaInvoice.pFlags.test) { + this.title.append(' (Test)'); + } + + const className = 'payment-item'; + + const itemEl = document.createElement('div'); + itemEl.classList.add(className); + + const detailsClassName = className + '-details'; + const details = document.createElement('div'); + details.classList.add(detailsClassName); + + let photoEl: HTMLElement; + if(mediaInvoice.photo) { + photoEl = document.createElement('div'); + photoEl.classList.add(detailsClassName + '-photo', 'media-container-cover'); + wrapPhoto({ + photo: mediaInvoice.photo, + container: photoEl, + boxWidth: 100, + boxHeight: 100, + size: {_: 'photoSizeEmpty', type: ''} + }); + details.append(photoEl); + } + + const linesClassName = detailsClassName + '-lines'; + const lines = document.createElement('div'); + lines.classList.add(linesClassName); + + const title = document.createElement('div'); + title.classList.add(linesClassName + '-title'); + + const description = document.createElement('div'); + description.classList.add(linesClassName + '-description'); + + const botName = document.createElement('div'); + botName.classList.add(linesClassName + '-bot-name'); + + lines.append(title, description, botName); + + setInnerHTML(title, wrapEmojiText(mediaInvoice.title)); + setInnerHTML(description, wrapEmojiText(mediaInvoice.description)); + + const peerTitle = new PeerTitle(); + botName.append(peerTitle.element); + + details.append(lines); + itemEl.append(details); + this.scrollable.append(itemEl); + + const preloaderContainer = document.createElement('div'); + preloaderContainer.classList.add(className + '-preloader-container'); + const preloader = putPreloader(preloaderContainer, true); + this.scrollable.container.append(preloaderContainer); + + let paymentForm: PaymentsPaymentForm | PaymentsPaymentReceipt; + const isReceipt = !!mediaInvoice.receipt_msg_id; + + if(isReceipt) paymentForm = await this.managers.appPaymentsManager.getPaymentReceipt(message.peerId, mediaInvoice.receipt_msg_id); + else paymentForm = await this.managers.appPaymentsManager.getPaymentForm(message.peerId, message.mid); + + let savedInfo = (paymentForm as PaymentsPaymentForm).saved_info || (paymentForm as PaymentsPaymentReceipt).info; + const savedCredentials = (paymentForm as PaymentsPaymentForm).saved_credentials; + let [lastRequestedInfo, passwordState, providerPeerTitle] = await Promise.all([ + !isReceipt && savedInfo && this.managers.appPaymentsManager.validateRequestedInfo(message.peerId, message.mid, savedInfo), + savedCredentials && this.managers.passwordManager.getState(), + wrapPeerTitle({peerId: paymentForm.provider_id.toPeerId()}) + ]); + + console.log(paymentForm, lastRequestedInfo); + + await peerTitle.update({peerId: paymentForm.bot_id.toPeerId()}); + preloaderContainer.remove(); + this.element.classList.remove('is-loading'); + + const wrapAmount = (amount: string | number, skipSymbol?: boolean) => { + return paymentsWrapCurrencyAmount(amount, currency, skipSymbol); + }; + + const {invoice} = paymentForm; + const currency = this.currency = invoice.currency; + + const makeLabel = () => { + const labelEl = document.createElement('div'); + labelEl.classList.add(pricesClassName + '-price'); + + const left = document.createElement('span'); + const right = document.createElement('span'); + labelEl.append(left, right); + return {label: labelEl, left, right}; + }; + + const pricesClassName = className + '-prices'; + const prices = document.createElement('div'); + prices.classList.add(pricesClassName); + const makePricesElements = (prices: LabeledPrice[]) => { + return prices.map((price) => { + const {amount, label} = price; + + const _label = makeLabel(); + _label.left.textContent = label; + + const wrappedAmount = wrapAmount(Math.abs(+amount)); + _label.right.textContent = (amount < 0 ? '-' : '') + wrappedAmount; + + return _label.label; + }); + }; + + const pricesElements = makePricesElements(invoice.prices); + + let getTipsAmount = (): number => 0; + let shippingAmount = 0; + + const getTotalTotal = () => totalAmount + getTipsAmount() + shippingAmount; + const setTotal = () => { + const wrapped = wrapAmount(getTotalTotal()); + totalLabel.right.textContent = wrapped; + payI18n.compareAndUpdate({ + key: 'PaymentCheckoutPay', + args: [wrapped] + }); + }; + + const payI18n = new I18n.IntlElement(); + + const totalLabel = makeLabel(); + totalLabel.label.classList.add('is-total'); + _i18n(totalLabel.left, 'PaymentTransactionTotal'); + const totalAmount = accumulate(invoice.prices.map(({amount}) => +amount), 0); + + const canTip = invoice.max_tip_amount !== undefined; + if(canTip) { + const tipsClassName = className + '-tips'; + + const currencyData = Currencies[currency]; + + getTipsAmount = () => +getInputValue().replace(/\D/g, ''); + + const getInputValue = () => { + // return input.textContent; + return input.value; + }; + + const setInputWidth = () => { + const width = getTextWidth(getInputValue(), `500 ${FontSize} ${FontFamily}`); + input.style.width = width + 'px'; + }; + + const setInputValue = (amount: string | number) => { + amount = Math.min(+amount, +invoice.max_tip_amount); + const wrapped = wrapAmount(amount, true); + + input.value = wrapped; + // input.textContent = wrapped; + if(document.activeElement === input) { + placeCaretAtEnd(input); + } + + unsetActiveTip(); + const tipEl = this.tipButtonsMap.get(amount); + if(tipEl) { + tipEl.classList.add('active'); + } + + setInputWidth(); + setTotal(); + }; + + const tipsLabel = makeLabel(); + _i18n(tipsLabel.left, mediaInvoice.receipt_msg_id ? 'PaymentTip' : 'PaymentTipOptional'); + const input = document.createElement('input'); + input.type = 'tel'; + // const input: HTMLElement = document.createElement('div'); + // input.contentEditable = 'true'; + input.classList.add('input-clear', tipsClassName + '-input'); + tipsLabel.right.append(input); + + tipsLabel.label.style.cursor = 'text'; + tipsLabel.label.addEventListener('mousedown', (e) => { + if(!findUpAsChild(e.target, input)) { + placeCaretAtEnd(input); + } + }); + + const haveToIgnoreEvents = input instanceof HTMLInputElement ? 1 : 2; + const onSelectionChange = () => { + if(ignoreNextSelectionChange) { + --ignoreNextSelectionChange; + return; + } + + // setTimeout(() => { + ignoreNextSelectionChange = haveToIgnoreEvents; + placeCaretAtEnd(input); + // }, 0); + }; + + const onFocus = () => { + + // cancelEvent(e); + setTimeout(() => { + ignoreNextSelectionChange = haveToIgnoreEvents; + placeCaretAtEnd(input); + document.addEventListener('selectionchange', onSelectionChange); + }, 0); + }; + + const onFocusOut = () => { + input.addEventListener('focus', onFocus, {once: true}); + document.removeEventListener('selectionchange', onSelectionChange); + }; + + let ignoreNextSelectionChange: number; + input.addEventListener('focusout', onFocusOut); + onFocusOut(); + + input.addEventListener('input', () => { + setInputValue(getTipsAmount()); + }); + + let s = [currencyData.symbol, currencyData.space_between ? ' ' : '']; + if(!currencyData.symbol_left) s.reverse(); + tipsLabel.right[currencyData.symbol_left ? 'prepend' : 'append'](s.join('')); + + pricesElements.push(tipsLabel.label); + + /// + const tipsEl = document.createElement('div'); + tipsEl.classList.add(tipsClassName); + + const tipClassName = tipsClassName + '-tip'; + const tipButtons = invoice.suggested_tip_amounts.map((tipAmount) => { + const button = Button(tipClassName, {noRipple: true}); + button.textContent = wrapAmount(tipAmount); + + this.tipButtonsMap.set(+tipAmount, button); + return button; + }); + + const unsetActiveTip = () => { + const prevTipEl = tipsEl.querySelector('.active'); + if(prevTipEl) { + prevTipEl.classList.remove('active'); + } + }; + + attachClickEvent(tipsEl, (e) => { + const tipEl = findUpClassName(e.target, tipClassName); + if(!tipEl) { + return; + } + + let tipAmount = 0; + if(tipEl.classList.contains('active')) { + tipEl.classList.remove('active'); + } else { + unsetActiveTip(); + tipEl.classList.add('active'); + + for(const [amount, el] of this.tipButtonsMap) { + if(el === tipEl) { + tipAmount = amount; + break; + } + } + } + + setInputValue(tipAmount); + }); + + setInputValue(0); + + tipsEl.append(...tipButtons); + pricesElements.push(tipsEl); + } else { + setTotal(); + } + + pricesElements.push(totalLabel.label); + + prices.append(...pricesElements); + itemEl.append(prices); + + /// + + const setRowIcon = async(row: Row, icon?: string) => { + const img = document.createElement('img'); + img.classList.add('media-photo'); + await renderImageFromUrlPromise(img, getPaymentBrandIconPath(icon)); + let container = row.media; + if(!container) { + container = row.createMedia('small'); + container.classList.add('media-container-cover'); + container.append(img); + } else { + replaceContent(container, img); + } + }; + + const createRow = (options: ConstructorParameters[0]) => { + if(options.titleLangKey) { + options.subtitleLangKey = options.titleLangKey; + } + + const row = new Row(options); + row.container.classList.add(className + '-row'); + + if(options.titleLangKey) { + row.subtitle.classList.add('hide'); + } + + return row; + }; + + const setRowTitle = (row: Row, textContent: string) => { + row.title.textContent = textContent; + if(!textContent) { + const e = I18n.weakMap.get(row.subtitle) as I18n.IntlElement; + row.title.append(i18n(e.key)); + } + + row.subtitle.classList.toggle('hide', !textContent); + }; + + const setCardSubtitle = (card: PaymentCardDetailsResult) => { + let brand: string; + let str: string; + let icon: string; + if('title' in card) { + brand = card.title.split(' ').shift(); + str = card.title; + icon = card.icon; + } else { + brand = detectUnifiedCardBrand(card.cardNumber); + str = brand + ' *' + card.cardNumber.split(' ').pop(); + } + + methodRow.title.classList.remove('tgico', 'tgico-card_outline'); + setRowIcon(methodRow, icon || brand.toLowerCase()); + setRowTitle(methodRow, str); + }; + + const onMethodClick = () => { + new PopupPaymentCard(paymentForm as PaymentsPaymentForm, previousCardDetails as PaymentCardDetails).addEventListener('finish', ({token, card}) => { + previousToken = token, previousCardDetails = card; + + setCardSubtitle(card); + }); + }; + + let previousCardDetails: PaymentCardDetailsResult, previousToken: PaymentsCredentialsToken; + const methodRow = createRow({ + titleLangKey: 'PaymentCheckoutMethod', + clickable: isReceipt ? undefined : onMethodClick, + icon: 'card_outline' + }); + + methodRow.container.classList.add(className + '-method-row'); + + if(savedCredentials) { + setCardSubtitle(savedCredentials); + } else if((paymentForm as PaymentsPaymentReceipt).credentials_title) { + setCardSubtitle({title: (paymentForm as PaymentsPaymentReceipt).credentials_title}); + } + + const providerRow = createRow({ + title: providerPeerTitle, + subtitleLangKey: 'PaymentCheckoutProvider' + }); + + const providerAvatar = new AvatarElement(); + providerAvatar.classList.add('avatar-32'); + providerRow.createMedia('small').append(providerAvatar); + /* await */ providerAvatar.updateWithOptions({peerId: paymentForm.provider_id.toPeerId()}); + + let shippingAddressRow: Row, shippingNameRow: Row, shippingEmailRow: Row, shippingPhoneRow: Row, shippingMethodRow: Row; + let lastShippingOption: ShippingOption, onShippingAddressClick: (focus?: ConstructorParameters[2]) => void, onShippingMethodClick: () => void; + const setShippingTitle = invoice.pFlags.shipping_address_requested ? (shippingAddress?: PaymentShippingAddress) => { + if(!shippingAddress) { + shippingMethodRow.subtitle.classList.add('hide'); + replaceContent(shippingMethodRow.title, i18n('PaymentShippingAddress')); + return; + } + + const postAddress = shippingAddress.shipping_address; + setRowTitle(shippingAddressRow, [postAddress.city, postAddress.street_line1, postAddress.street_line2].filter(Boolean).join(', ')); + shippingMethodRow.container.classList.remove('hide'); + } : undefined; + + const setShippingInfo = (info: PaymentRequestedInfo) => { + setShippingTitle && setShippingTitle(info); + shippingNameRow && setRowTitle(shippingNameRow, info.name); + shippingEmailRow && setRowTitle(shippingEmailRow, info.email); + shippingPhoneRow && setRowTitle(shippingPhoneRow, info.phone && ('+' + formatPhoneNumber(info.phone).formatted)); + }; + + if(!isReceipt) { + onShippingAddressClick = (focus) => { + new PopupPaymentShipping(paymentForm as PaymentsPaymentForm, message, focus).addEventListener('finish', ({shippingAddress, requestedInfo}) => { + lastRequestedInfo = requestedInfo; + savedInfo = (paymentForm as PaymentsPaymentForm).saved_info = shippingAddress; + setShippingInfo(shippingAddress); + }); + }; + } + + if(invoice.pFlags.shipping_address_requested) { + const setShippingOption = (shippingOption?: ShippingOption) => { + const scrollSaver = new ScrollSaver(this.scrollable, undefined, true); + scrollSaver.save(); + if(lastShippingPricesElements) { + lastShippingPricesElements.forEach((node) => node.remove()); + } + + if(!shippingOption) { + shippingAmount = 0; + + setTotal(); + scrollSaver.restore(); + this.onContentUpdate(); + return; + } + + lastShippingOption = shippingOption; + setRowTitle(shippingMethodRow, shippingOption.title); + + shippingAmount = accumulate(shippingOption.prices.map(({amount}) => +amount), 0); + lastShippingPricesElements = makePricesElements(shippingOption.prices); + let l = totalLabel.label; + if(canTip) l = l.previousElementSibling.previousElementSibling as any; + lastShippingPricesElements.forEach((element) => l.parentElement.insertBefore(element, l)); + + setTotal(); + scrollSaver.restore(); + this.onContentUpdate(); + }; + + shippingAddressRow = createRow({ + icon: 'location', + titleLangKey: 'PaymentShippingAddress', + clickable: !isReceipt && onShippingAddressClick.bind(null, undefined) + }); + + let lastShippingPricesElements: HTMLElement[]; + shippingMethodRow = createRow({ + icon: 'car', + titleLangKey: 'PaymentCheckoutShippingMethod', + clickable: !isReceipt && (onShippingMethodClick = () => { + new PopupPaymentShippingMethods(paymentForm as PaymentsPaymentForm, lastRequestedInfo, lastShippingOption).addEventListener('finish', (shippingOption) => { + setShippingOption(shippingOption); + }); + }) + }); + + shippingMethodRow.container.classList.add('hide'); + + const shippingOption = (paymentForm as PaymentsPaymentReceipt).shipping; + if(shippingOption) { + setShippingOption(shippingOption); + } + } + + if(invoice.pFlags.name_requested) { + shippingNameRow = createRow({ + icon: 'newprivate', + titleLangKey: 'PaymentCheckoutName', + clickable: !isReceipt && onShippingAddressClick.bind(null, 'name') + }); + } + + if(invoice.pFlags.email_requested) { + shippingEmailRow = createRow({ + icon: 'mention', + titleLangKey: 'PaymentShippingEmailPlaceholder', + clickable: !isReceipt && onShippingAddressClick.bind(null, 'email') + }); + } + + if(invoice.pFlags.phone_requested) { + shippingPhoneRow = createRow({ + icon: 'phone', + titleLangKey: 'PaymentCheckoutPhoneNumber', + clickable: !isReceipt && onShippingAddressClick.bind(null, 'phone') + }); + } + + if(savedInfo) { + setShippingInfo(savedInfo); + } + + const rows = [ + methodRow, + providerRow, + shippingAddressRow, + shippingMethodRow, + shippingNameRow, + shippingEmailRow, + shippingPhoneRow, + ].filter(Boolean); + this.scrollable.append(...[ + document.createElement('hr'), + ...rows.map((row) => row.container) + ].filter(Boolean)); + + /// + let popupPaymentVerification: PopupPaymentVerification, lastTmpPasword: AccountTmpPassword; + const onClick = () => { + const missingInfo = invoice.pFlags.name_requested && !savedInfo?.name ? 'name' : (invoice.pFlags.email_requested && !savedInfo?.email ? 'email' : (invoice.pFlags.phone_requested && !savedInfo?.phone ? 'phone' : undefined)); + if(invoice.pFlags.shipping_address_requested) { + if(!lastRequestedInfo) { + onShippingAddressClick(); + return; + } else if(!lastShippingOption) { + onShippingMethodClick(); + return; + } + } else if(missingInfo) { + onShippingAddressClick(missingInfo); + return; + } + + if(!previousCardDetails && !lastTmpPasword) { + if(!savedCredentials) { + onMethodClick(); + return; + } + + Promise.resolve(passwordState ?? this.managers.passwordManager.getState()).then((_passwordState) => { + new PopupPaymentCardConfirmation(savedCredentials.title, _passwordState).addEventListener('finish', (tmpPassword) => { + passwordState = undefined; + lastTmpPasword = tmpPassword; + simulateClickEvent(payButton); + + // * reserve 5 seconds + const diff = tmpPassword.valid_until - tsNow(true) - 5; + setTimeout(() => { + if(lastTmpPasword === tmpPassword) { + lastTmpPasword = undefined; + } + }, diff * 1000); + }); + }); + + return; + } + + return Promise.resolve().then(async() => { + const credentials: InputPaymentCredentials = lastTmpPasword ? { + _: 'inputPaymentCredentialsSaved', + id: savedCredentials.id, + tmp_password: lastTmpPasword.tmp_password + } : { + _: 'inputPaymentCredentials', + data: { + _: 'dataJSON', + data: JSON.stringify(previousToken.token ? previousToken : {type: previousToken.type, id: previousToken.id}) + }, + pFlags: { + save: previousCardDetails.save || undefined + } + }; + + try { + const paymentResult = await this.managers.appPaymentsManager.sendPaymentForm( + message.peerId, + message.mid, + (paymentForm as PaymentsPaymentForm).form_id, + lastRequestedInfo?.id, + lastShippingOption?.id, + credentials, + getTipsAmount() + ); + + if(paymentResult._ === 'payments.paymentResult') { + onConfirmed(); + } else { + popupPaymentVerification = new PopupPaymentVerification(paymentResult.url); + await new Promise((resolve, reject) => { + popupPaymentVerification.addEventListener('close', () => { + if(confirmed) { + resolve(); + } else { + const err = new Error('payment not finished'); + (err as ApiError).handled = true; + reject(err); + } + }); + }); + + popupPaymentVerification.addEventListener('finish', () => { + onConfirmed(); + }); + } + + this.hide(); + } catch(err) { + if((err as ApiError).type === 'BOT_PRECHECKOUT_TIMEOUT') { + toastNew({langPackKey: 'Error.AnError'}); + (err as ApiError).handled = true; + } else if((err as ApiError).type === 'TMP_PASSWORD_INVALID') { + passwordState = lastTmpPasword = undefined; + simulateClickEvent(payButton); + (err as ApiError).handled = true; + } + + throw err; + } + }); + }; + + let payButton: HTMLElement; + if(isReceipt) { + payButton = PaymentButton({ + onClick: () => this.hide(), + key: 'Done' + }); + } else { + payButton = PaymentButton({ + onClick: onClick, + textEl: payI18n + }); + } + + this.body.append(this.btnConfirmOnEnter = payButton); + + this.onContentUpdate(); + } +} diff --git a/src/components/popups/paymentCard.ts b/src/components/popups/paymentCard.ts new file mode 100644 index 00000000..3ba557ce --- /dev/null +++ b/src/components/popups/paymentCard.ts @@ -0,0 +1,545 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import cardFormattingPatterns from "../../helpers/cards/cardFormattingPatterns"; +import { detectUnifiedCardBrand } from "../../helpers/cards/cardBrands"; +import formatInputValueByPattern from "../../helpers/cards/formatInputValueByPattern"; +import { validateAnyIncomplete, validateCardExpiry, validateCardNumber } from "../../helpers/cards/validateCard"; +import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; +import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; +import noop from "../../helpers/noop"; +import { PaymentsPaymentForm } from "../../layer"; +import { LangPackKey, _i18n } from "../../lib/langPack"; +import { TelegramWebviewEvent } from "../../types"; +import CheckboxField from "../checkboxField"; +import confirmationPopup from "../confirmationPopup"; +import CountryInputField from "../countryInputField"; +import InputField, { InputFieldOptions, InputState } from "../inputField"; +import Row from "../row"; +import { SettingSection } from "../sidebarLeft"; +import { getPaymentBrandIconPath, PaymentButton, PaymentsCredentialsToken } from "./payment"; +import { createVerificationIframe } from "./paymentVerification"; + +export type PaymentCardDetails = { + cardNumber: string; + cardholderName: string; + expiryFull: string; + expiryMonth: string; + expiryYear: string; + cvc: string; + country: string; + zip: string; + save?: boolean; +}; + +export type PaymentCardDetailsShort = { + title: string, + save?: boolean; + icon?: string; +}; + +export type PaymentCardDetailsResult = PaymentCardDetails | PaymentCardDetailsShort; + +export class InputFieldCorrected extends InputField { + private lastKeyDown: string; + private lastTransformed: ReturnType; + + constructor(public options: InputFieldOptions & { + formatMethod: typeof cardFormattingPatterns['cardNumber'], + validateMethod?: typeof validateCardNumber, + errorKeys?: {[code: string]: LangPackKey}, + optional?: boolean, + onChange?: (transformed: InputFieldCorrected['lastTransformed']) => void, + onKeyDown?: (e: KeyboardEvent) => void + }) { + super(options); + + // const handleIncomplete = (t?: any) => { + // if( + // (!lastTransformed.value && t) || + // lastTransformed.meta.autocorrectComplete || + // lastTransformed.meta.error || + // optional + // ) { + // return; + // } + + + // }; + + this.input.addEventListener('keydown', this.onKeyDown); + this.input.addEventListener('input', this.onInput); + this.input.addEventListener('blur', this.onBlur); + } + + private onKeyDown = (e: KeyboardEvent) => { + this.lastKeyDown = e.key; + this.options.onKeyDown?.(e); + }; + + private onInput = () => { + const value = this.value; + const deleting = this.lastKeyDown === 'Backspace' && (((this.lastTransformed && this.lastTransformed.value.length) || 0) - value.length) === 1; + const result = this.lastTransformed = formatInputValueByPattern({ + value: value, + getPattern: this.options.formatMethod, + deleting, + input: this.input + }); + + const transformedValue = result.value; + if(transformedValue !== value) { + this.setValueSilently(transformedValue); + + if(result.selection) { + (this.input as HTMLInputElement).selectionStart = result.selection.selectionStart; + (this.input as HTMLInputElement).selectionEnd = result.selection.selectionEnd; + } + } + + this.validateNew(transformedValue, {ignoreIncomplete: true/* !result.meta.autocorrectComplete */}); + + this.options.onChange?.(result); + }; + + private onBlur = () => { + const value = this.lastTransformed?.value; + if(value) { + this.validateNew(value); + } + }; + + public update() { + this.onInput(); + } + + public validate = () => { + return this.validateNew(); + }; + + public validateNew( + value = this.lastTransformed?.value ?? '', + t: any = {}, + justReturn?: boolean + ) { + let result: ReturnType; + if(this.options.validateMethod) { + result = this.options.validateMethod?.(value, t); + } else { + result = validateAnyIncomplete(this.lastTransformed, value, t); + } + + if(result?.code) { + const langPackKey: LangPackKey = this.options.errorKeys?.[result.code]; + !justReturn && this.setState(InputState.Error, langPackKey); + return false; + } + + !justReturn && this.setState(InputState.Neutral); + return true; + } +} + +export function handleInputFieldsOnChange(inputFields: (CountryInputField | InputField | InputFieldCorrected)[], _onChange: (valid: boolean) => void) { + const onChange = () => { + const valid = inputFields.every((inputField) => { + return 'validateNew' in inputField ? inputField.validateNew(undefined, undefined, true) : inputField.isValid(); + }); + + _onChange(valid); + }; + + inputFields.forEach((inputField) => { + if(inputField instanceof InputFieldCorrected) { + const original = inputField.options.onChange; + inputField.options.onChange = (...args: any[]) => { + // @ts-ignore + original?.(...args); + onChange(); + }; + + if('update' in inputField) { + inputField.update(); + } + } else { + inputField.input.addEventListener('input', onChange); + } + }); + + return {validate: onChange}; +} + +export function createCountryZipFields(country?: boolean, zip?: boolean) { + let countryInputField: CountryInputField, postcodeInputField: InputFieldCorrected; + if(country || zip) { + if(country) countryInputField = new CountryInputField({ + noPhoneCodes: true, + onCountryChange: () => { + postcodeInputField?.update(); + }, + required: true, + autocomplete: 'country', + }); + if(zip) postcodeInputField = new InputFieldCorrected({ + label: 'PaymentShippingZipPlaceholder', + plainText: true, + inputMode: 'numeric', + autocomplete: 'postal-code', + formatMethod: (/* ...args */) => { + const {country} = countryInputField.getSelected(); + const iso2 = country?.iso2; + return cardFormattingPatterns.postalCodeFromCountry(iso2 && iso2.toUpperCase()); + } + }); + } + + return {countryInputField, postcodeInputField}; +} + +export type PaymentsNativeProvider = 'stripe' | 'smartglocal'; +export type PaymentsNativeParams = { + need_country?: boolean, + need_zip?: boolean, + need_cardholder_name?: boolean, + publishable_key?: string, // stripe + public_token?: string, // smartglocal + gpay_params: string, +}; +const SUPPORTED_NATIVE_PROVIDERS: Set = new Set(['stripe', 'smartglocal']); + +export default class PopupPaymentCard extends PopupElement<{ + finish: (obj: {token: any, card: PaymentCardDetailsResult}) => void +}> { + constructor(private paymentForm: PaymentsPaymentForm, private savedCard?: PaymentCardDetails) { + super('popup-payment popup-payment-card', { + closable: true, + overlayClosable: true, + body: true, + scrollable: SUPPORTED_NATIVE_PROVIDERS.has(paymentForm.native_provider as PaymentsNativeProvider), + title: 'PaymentCardInfo' + }); + + if(SUPPORTED_NATIVE_PROVIDERS.has(paymentForm.native_provider as PaymentsNativeProvider)) { + this.d(); + } else { + const iframe = createVerificationIframe(paymentForm.url, (event) => { + if(event.eventType !== 'payment_form_submit') { + return; + } + + const data = event.eventData; + + const cardOut = {title: data.title, save: false} as any as PaymentCardDetails; + this.dispatchEvent('finish', { + token: data.credentials, + card: cardOut + }); + + this.hide(); + + if(paymentForm.pFlags.can_save_credentials) { + confirmationPopup({ + titleLangKey: 'PaymentCardSavePaymentInformation', + descriptionLangKey: 'PaymentCardSavePaymentInformationInfoLine1', + button: { + langKey: 'Save' + } + }).then(() => { + cardOut.save = true; + }, noop); + } + }); + + this.body.append(iframe); + this.show(); + } + } + + private d() { + const savedCard = this.savedCard; + const cardSection = new SettingSection({name: 'PaymentInfo.Card.Title', noDelimiter: true, noShadow: true}); + + const nativeParams: PaymentsNativeParams = JSON.parse(this.paymentForm.native_params.data); + + let lastBrand: string, brandIconTempId = 0, lastBrandImg: HTMLImageElement; + const setBrandIcon = (brand: string) => { + if(lastBrand === brand) { + return; + } + + const tempId = ++brandIconTempId; + lastBrand = brand; + + const path = getPaymentBrandIconPath(brand); + if(!path) { + if(lastBrandImg) { + lastBrandImg.remove(); + lastBrandImg = undefined; + } + + return; + } + + const img = new Image(); + img.classList.add('input-field-icon'); + renderImageFromUrlPromise(img, path, false).then(() => { + if(brandIconTempId !== tempId) { + return; + } + + if(lastBrandImg) { + lastBrandImg.replaceWith(img); + } else { + cardInputField.container.append(img); + } + + lastBrandImg = img; + }); + }; + const cardInputField = new InputFieldCorrected({ + label: 'PaymentCardNumber', + plainText: true, + inputMode: 'numeric', + autocomplete: 'cc-number', + formatMethod: cardFormattingPatterns.cardNumber, + validateMethod: validateCardNumber, + errorKeys: { + invalid: 'PaymentCard.Error.Invalid', + incomplete: 'PaymentCard.Error.Incomplete' + }, + onChange: (transformed) => { + setBrandIcon(detectUnifiedCardBrand(transformed.value)); + cvcInputField.update(); // format cvc + } + }); + + let nameInputField: InputField; + if(nativeParams.need_cardholder_name) nameInputField = new InputField({ + label: 'Checkout.NewCard.CardholderNamePlaceholder', + maxLength: 255, + required: true, + autocomplete: 'cc-name', + }); + + const expireInputField = new InputFieldCorrected({ + label: 'SecureId.Identity.Placeholder.ExpiryDate', + plainText: true, + inputMode: 'numeric', + autocomplete: 'cc-exp', + formatMethod: cardFormattingPatterns.cardExpiry, + validateMethod: validateCardExpiry + }); + + const cvcInputField = new InputFieldCorrected({ + labelText: 'CVC', + plainText: true, + inputMode: 'numeric', + autocomplete: 'cc-csc', + formatMethod: () => cardFormattingPatterns.cardCvc(cardInputField.value), + // validateMethod: (...args) => _5AH3.a.cardCvc(cardInputField.value)(...args) + }); + + const switchFocusOrder: (InputFieldCorrected | InputField)[] = [ + cardInputField, + expireInputField, + cvcInputField, + nameInputField + ].filter(Boolean); + switchFocusOrder.forEach((inputField) => { + const onKeyDown = (e: KeyboardEvent) => { + if(!inputField.value && e.key === 'Backspace') { + const previousInputField = switchFocusOrder[switchFocusOrder.indexOf(inputField) - 1]; + if(previousInputField) { + // previousInputField.value = previousInputField.value.slice(0, -1); + placeCaretAtEnd(previousInputField.input); + } + } + }; + + if(inputField instanceof InputFieldCorrected) { + inputField.options.onKeyDown = onKeyDown; + + const original = inputField.options.onChange; + inputField.options.onChange = (transformed) => { + original?.(transformed); + + if(document.activeElement === inputField.input && transformed.meta.autocorrectComplete) { + for(let i = switchFocusOrder.indexOf(inputField), length = switchFocusOrder.length; i < length; ++i) { + const nextInputField = switchFocusOrder[i]; + if( + nextInputField instanceof InputFieldCorrected ? + !nextInputField.validateNew(undefined, undefined, true) : + !nextInputField.value + ) { + placeCaretAtEnd(nextInputField.input); + break; + } + } + } + }; + } else { + inputField.input.addEventListener('keydown', onKeyDown); + } + }); + + const inputFieldsRow = document.createElement('div'); + inputFieldsRow.classList.add('input-fields-row'); + inputFieldsRow.append(expireInputField.container, cvcInputField.container); + + cardSection.content.append(...[ + cardInputField.container, + inputFieldsRow, + nameInputField?.container + ].filter(Boolean)); + + let billingSection: SettingSection; + let saveCheckboxField: CheckboxField; + const {countryInputField, postcodeInputField} = createCountryZipFields(nativeParams.need_country, nativeParams.need_zip); + if(nativeParams.need_country || nativeParams.need_zip) { + billingSection = new SettingSection({name: 'PaymentInfo.Billing.Title', noDelimiter: true, noShadow: true}); + + // const inputFieldsRow2 = inputFieldsRow.cloneNode() as HTMLElement; + // inputFieldsRow2.append(countryInputField.container, postcodeInputField.container); + // billingSection.content.append(inputFieldsRow2); + billingSection.content.append(...[countryInputField, postcodeInputField].filter(Boolean).map((i) => i.container)); + } + + const canSave = !!this.paymentForm.pFlags.can_save_credentials; + saveCheckboxField = new CheckboxField({ + text: 'PaymentCardSavePaymentInformation', + checked: !!canSave + }); + const saveRow = new Row({ + checkboxField: saveCheckboxField, + subtitleLangKey: canSave ? 'PaymentCardSavePaymentInformationInfoLine1' : 'Checkout.2FA.Text', + noCheckboxSubtitle: true + }); + + if(!canSave) { + saveRow.container.classList.add('is-disabled'); + } + + (billingSection || cardSection).content.append(saveRow.container); + + this.scrollable.append(...[cardSection, billingSection].filter(Boolean).map((s) => s.container)); + + const payButton = PaymentButton({ + key: 'PaymentInfo.Done', + onClick: async() => { + const data: PaymentCardDetails = { + cardNumber: cardInputField.value, + expiryFull: expireInputField.value, + expiryMonth: expireInputField.value.split('/')[0], + expiryYear: expireInputField.value.split('/')[1], + cvc: cvcInputField.value, + + cardholderName: nameInputField?.value, + country: countryInputField?.value, + zip: postcodeInputField?.value, + + save: saveCheckboxField?.checked + }; + + const nativeProvider: PaymentsNativeProvider = this.paymentForm.native_provider as any; + let out: PaymentsCredentialsToken; + if(nativeProvider === 'stripe') { + const url = new URL('https://api.stripe.com/v1/tokens'); + url.search = new URLSearchParams({ + 'card[number]': data.cardNumber, + 'card[exp_month]': data.expiryMonth, + 'card[exp_year]': data.expiryYear, + 'card[cvc]': data.cvc, + 'card[address_zip]': data.zip, + 'card[address_country]': data.country, + 'card[name]': data.cardholderName, + }).toString(); + + const response = await fetch(url.toString(), { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${nativeParams.publishable_key}`, + }, + }); + + out = await response.json(); + } else if(nativeProvider === 'smartglocal') { + const params = { + card: { + number: data.cardNumber.replace(/[^\d]+/g, ''), + expiration_month: data.expiryMonth, + expiration_year: data.expiryYear, + security_code: data.cvc.replace(/[^\d]+/g, ''), + }, + }; + + const url = /* DEBUG_PAYMENT_SMART_GLOCAL */false + ? 'https://tgb-playground.smart-glocal.com/cds/v1/tokenize/card' + : 'https://tgb.smart-glocal.com/cds/v1/tokenize/card'; + + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-PUBLIC-TOKEN': nativeParams.public_token, + }, + body: JSON.stringify(params), + }); + + const json: { // smartglocal + data: { + info: { + card_network: string, + card_type: string, + masked_card_number: string + }, + token: string + }, + status: 'ok' + } = await response.json(); + + out = {type: 'card', token: json.data.token} + } + + this.dispatchEvent('finish', {token: out, card: data}); + this.hide(); + } + }); + + const inputFields = ([ + cardInputField, + nameInputField, + expireInputField, + cvcInputField, + countryInputField, + postcodeInputField + ] as const).filter(Boolean); + handleInputFieldsOnChange(inputFields, (valid) => { + payButton.disabled = !valid; + // payButton.classList.toggle('btn-disabled', !valid); + }); + + if(savedCard) { + cardInputField.value = savedCard.cardNumber; + expireInputField.value = savedCard.expiryFull; + cvcInputField.value = savedCard.cvc; + nameInputField && (nameInputField.value = savedCard.cardholderName); + countryInputField && (countryInputField.value = savedCard.country); + postcodeInputField && (postcodeInputField.value = savedCard.zip); + } + + this.body.append(this.btnConfirmOnEnter = payButton); + + this.show(); + + if(!cardInputField.validateNew(undefined, undefined, true)) { + placeCaretAtEnd(cardInputField.input); + } + } +} diff --git a/src/components/popups/paymentCardConfirmation.ts b/src/components/popups/paymentCardConfirmation.ts new file mode 100644 index 00000000..8b4e76af --- /dev/null +++ b/src/components/popups/paymentCardConfirmation.ts @@ -0,0 +1,70 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; +import { AccountPassword, AccountTmpPassword } from "../../layer"; +import { ApiError } from "../../lib/mtproto/apiManager"; +import { InputState } from "../inputField"; +import PasswordInputField from "../passwordInputField"; +import { SettingSection } from "../sidebarLeft"; +import { PaymentButton } from "./payment"; + +export default class PopupPaymentCardConfirmation extends PopupElement<{ + finish: (tmpPassword: AccountTmpPassword) => void +}> { + constructor(card: string, passwordState: AccountPassword) { + super('popup-payment popup-payment-card-confirmation', { + closable: true, + overlayClosable: true, + body: true, + scrollable: true, + title: 'Checkout.PasswordEntry.Title' + }); + + const section = new SettingSection({noDelimiter: true, noShadow: true, caption: 'Checkout.PasswordEntry.Text', captionArgs: [card]}); + const passwordInputField = new PasswordInputField({labelText: passwordState.hint}); + section.content.append(passwordInputField.container); + this.scrollable.append(section.container); + + const onInput = () => { + payButton.disabled = !passwordInputField.value; + passwordInputField.setState(InputState.Neutral); + }; + + passwordInputField.input.addEventListener('input', onInput); + + const payButton = PaymentButton({ + key: 'Checkout.PasswordEntry.Pay', + onClick: async() => { + try { + const inputCheckPassword = await this.managers.passwordManager.getInputCheckPassword(passwordInputField.value, passwordState); + const tmpPassword = await this.managers.apiManager.invokeApi('account.getTmpPassword', { + password: inputCheckPassword, + period: 60 + }); + + this.dispatchEvent('finish', tmpPassword); + this.hide(); + } catch(err) { + if((err as ApiError).type === 'PASSWORD_HASH_INVALID') { + (err as ApiError).handled = true; + passwordInputField.setError('PASSWORD_HASH_INVALID'); + } + + throw err; + } + } + }); + this.body.append(this.btnConfirmOnEnter = payButton); + + onInput(); + + this.show(); + + placeCaretAtEnd(passwordInputField.input); + } +} diff --git a/src/components/popups/paymentShipping.ts b/src/components/popups/paymentShipping.ts new file mode 100644 index 00000000..9e014dc2 --- /dev/null +++ b/src/components/popups/paymentShipping.ts @@ -0,0 +1,231 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import { attachClickEvent } from "../../helpers/dom/clickEvent"; +import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; +import toggleDisability from "../../helpers/dom/toggleDisability"; +import { Message, PaymentRequestedInfo, PaymentsPaymentForm, PaymentsValidatedRequestedInfo } from "../../layer"; +import getServerMessageId from "../../lib/appManagers/utils/messageId/getServerMessageId"; +import { ApiError } from "../../lib/mtproto/apiManager"; +import matchEmail from "../../lib/richTextProcessor/matchEmail"; +import Button from "../button"; +import CheckboxField from "../checkboxField"; +import CountryInputField from "../countryInputField"; +import InputField from "../inputField"; +import Row from "../row"; +import { SettingSection } from "../sidebarLeft"; +import TelInputField from "../telInputField"; +import { PaymentButton } from "./payment"; +import { createCountryZipFields, handleInputFieldsOnChange, InputFieldCorrected } from "./paymentCard"; + +export type PaymentShippingAddress = PaymentRequestedInfo; + +type ShippingFocusField = 'name' | 'email' | 'phone'; + +export default class PopupPaymentShipping extends PopupElement<{ + finish: (o: {shippingAddress: PaymentShippingAddress, requestedInfo: PaymentsValidatedRequestedInfo}) => void +}> { + constructor( + private paymentForm: PaymentsPaymentForm, + private message: Message.message, + private focus?: ShippingFocusField + ) { + super('popup-payment popup-payment-shipping', { + closable: true, + overlayClosable: true, + body: true, + scrollable: true, + title: 'PaymentShippingInfo' + }); + + this.d(); + } + + private d() { + const paymentForm = this.paymentForm; + const invoice = paymentForm.invoice; + const savedInfo = this.paymentForm.saved_info; + + let addressSection: SettingSection, + address1InputField: InputField, + address2InputField: InputField, + cityInputField: InputField, + stateInputField: InputField, + countryInputField: CountryInputField, + postcodeInputField: InputFieldCorrected; + if(invoice.pFlags.shipping_address_requested) { + addressSection = new SettingSection({name: 'PaymentShippingAddress', noDelimiter: true, noShadow: true}); + address1InputField = new InputField({label: 'PaymentShippingAddress1Placeholder', maxLength: 64, required: true}); + address2InputField = new InputField({label: 'PaymentShippingAddress2Placeholder', maxLength: 64}); + cityInputField = new InputField({label: 'PaymentShippingCityPlaceholder', maxLength: 64, required: true}); + stateInputField = new InputField({label: 'PaymentShippingStatePlaceholder', maxLength: 64}); + const res = createCountryZipFields(true, true); + countryInputField = res.countryInputField; + postcodeInputField = res.postcodeInputField; + + addressSection.content.append(...[ + address1InputField, + address2InputField, + cityInputField, + stateInputField, + countryInputField, + postcodeInputField + ].filter(Boolean).map((inputField) => inputField.container)); + } + + let receiverSection: SettingSection; + let nameInputField: InputField, emailInputField: InputField, telInputField: TelInputField; + if([invoice.pFlags.name_requested, invoice.pFlags.email_requested, invoice.pFlags.phone_requested].includes(true)) { + receiverSection = new SettingSection({name: 'PaymentShippingReceiver', noDelimiter: true, noShadow: true}); + + const validateEmail = () => { + const value = emailInputField.value; + const match = matchEmail(value); + if(!match || match[0].length !== value.length) { + return false; + } + + return true; + }; + + const validatePhone = () => { + return !!telInputField.value.match(/\d/); + }; + + if(invoice.pFlags.name_requested) nameInputField = new InputField({label: 'PaymentShippingName', maxLength: 256, required: true}); + if(invoice.pFlags.email_requested) emailInputField = new InputField({label: 'PaymentShippingEmailPlaceholder', maxLength: 64, required: true, validate: validateEmail}); + if(invoice.pFlags.phone_requested) telInputField = new TelInputField({required: true, validate: validatePhone}); + + receiverSection.content.append(...[ + nameInputField, + emailInputField, + telInputField, + ].filter(Boolean).map((inputField) => inputField.container)); + } + + const saveCheckboxField = new CheckboxField({ + text: 'PaymentShippingSave', + checked: true + }); + const saveRow = new Row({ + checkboxField: saveCheckboxField, + subtitleLangKey: 'PaymentShippingSaveInfo', + noCheckboxSubtitle: true + }); + + (receiverSection || addressSection).content.append(saveRow.container); + + this.scrollable.append(...[addressSection, receiverSection].filter(Boolean).map((section) => section.container)); + + const payButton = PaymentButton({ + key: 'PaymentInfo.Done', + onClick: async() => { + const selectedCountry = countryInputField && countryInputField.getSelected().country; + const data: PaymentShippingAddress = { + _: 'paymentRequestedInfo', + shipping_address: selectedCountry && { + _: 'postAddress', + street_line1: address1InputField.value, + street_line2: address2InputField.value, + city: cityInputField.value, + state: stateInputField.value, + // country: countryInputField.value, + country_iso2: selectedCountry?.iso2, + post_code: postcodeInputField.value, + }, + name: nameInputField?.value, + email: emailInputField?.value, + phone: telInputField?.value + }; + + try { + const requestedInfo = await this.managers.appPaymentsManager.validateRequestedInfo(this.message.peerId, this.message.mid, data, saveCheckboxField?.checked); + + this.dispatchEvent('finish', { + shippingAddress: data, + requestedInfo + }); + + this.hide(); + } catch(err: any) { + const errorMap: {[err: string]: InputField} = { + ADDRESS_STREET_LINE1_INVALID: address1InputField, + ADDRESS_STREET_LINE2_INVALID: address2InputField, + ADDRESS_COUNTRY_INVALID: countryInputField, + ADDRESS_CITY_INVALID: cityInputField, + ADDRESS_STATE_INVALID: stateInputField, + ADDRESS_POSTCODE_INVALID: postcodeInputField, + + REQ_INFO_NAME_INVALID: nameInputField, + REQ_INFO_EMAIL_INVALID: emailInputField, + REQ_INFO_PHONE_INVALID: telInputField + }; + + const inputField = errorMap[(err as ApiError).type]; + if(inputField) { + inputField.setError(); + (err as any).handled = true; + } + + throw err; + } + } + }); + this.body.append(this.btnConfirmOnEnter = payButton); + + if(savedInfo) { + const shippingAddress = savedInfo.shipping_address; + if(shippingAddress) { + address1InputField.value = shippingAddress.street_line1; + address2InputField.value = shippingAddress.street_line2; + cityInputField.value = shippingAddress.city; + stateInputField.value = shippingAddress.state; + countryInputField.selectCountryByIso2(shippingAddress.country_iso2); + postcodeInputField.value = shippingAddress.post_code; + } + + savedInfo.name && nameInputField && (nameInputField.value = savedInfo.name); + savedInfo.email && emailInputField && (emailInputField.value = savedInfo.email); + savedInfo.phone && telInputField && (telInputField.value = savedInfo.phone); + } + + const {validate} = handleInputFieldsOnChange([ + address1InputField, + address2InputField, + cityInputField, + stateInputField, + countryInputField, + postcodeInputField, + nameInputField, + emailInputField, + telInputField + ].filter(Boolean), (valid) => { + payButton.disabled = !valid; + }); + + validate(); + + this.show(); + + let focusField: InputField; + if(this.focus) { + const focusMap: {[field in ShippingFocusField]?: InputField} = { + name: nameInputField, + email: emailInputField, + phone: telInputField + }; + + focusField = focusMap[this.focus]; + } else { + focusField = address1InputField; + } + + if(focusField) { + placeCaretAtEnd(focusField.input); + } + } +} diff --git a/src/components/popups/paymentShippingMethods.ts b/src/components/popups/paymentShippingMethods.ts new file mode 100644 index 00000000..c570d7e4 --- /dev/null +++ b/src/components/popups/paymentShippingMethods.ts @@ -0,0 +1,80 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import accumulate from "../../helpers/array/accumulate"; +import { attachClickEvent } from "../../helpers/dom/clickEvent"; +import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; +import { PaymentsPaymentForm, PaymentsValidatedRequestedInfo, ShippingOption } from "../../layer"; +import Button from "../button"; +import RadioField from "../radioField"; +import Row, { RadioFormFromRows } from "../row"; +import { SettingSection } from "../sidebarLeft"; +import { PaymentButton } from "./payment"; + +export default class PopupPaymentShippingMethods extends PopupElement<{ + finish: (shippingOption: ShippingOption) => void +}> { + constructor( + private paymentForm: PaymentsPaymentForm, + private requestedInfo: PaymentsValidatedRequestedInfo, + private shippingOption: ShippingOption + ) { + super('popup-payment popup-payment-shipping-methods', { + closable: true, + overlayClosable: true, + body: true, + scrollable: true, + title: 'PaymentShippingMethod' + }); + + this.d(); + } + + private d() { + const section = new SettingSection({name: 'PaymentCheckoutShippingMethod', noDelimiter: true, noShadow: true}); + + const rows = this.requestedInfo.shipping_options.map((shippingOption) => { + return new Row({ + radioField: new RadioField({ + text: shippingOption.title, + name: 'shipping-method', + value: shippingOption.id + }), + subtitle: paymentsWrapCurrencyAmount( + accumulate(shippingOption.prices.map(({amount}) => +amount), 0), + this.paymentForm.invoice.currency + ) + }); + }); + + let lastShippingId: string; + const form = RadioFormFromRows(rows, (value) => { + lastShippingId = value; + }); + + if(this.shippingOption) { + rows.find((row) => row.radioField.input.value === this.shippingOption.id).radioField.checked = true; + } else { + rows[0].radioField.checked = true; + } + + section.content.append(form); + + this.scrollable.append(section.container); + + const payButton = PaymentButton({ + key: 'PaymentInfo.Done', + onClick: () => { + this.dispatchEvent('finish', this.requestedInfo.shipping_options.find((option) => option.id === lastShippingId)); + this.hide(); + } + }); + this.body.append(this.btnConfirmOnEnter = payButton); + + this.show(); + } +} diff --git a/src/components/popups/paymentVerification.ts b/src/components/popups/paymentVerification.ts new file mode 100644 index 00000000..0fb0e5b0 --- /dev/null +++ b/src/components/popups/paymentVerification.ts @@ -0,0 +1,64 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import PopupElement from "."; +import appImManager from "../../lib/appManagers/appImManager"; +import { TelegramWebviewEventCallback } from "../../types"; + +const weakMap: WeakMap = new WeakMap(); +window.addEventListener('message', (e) => { + const callback = weakMap.get(e.source as Window); + if(!callback) { + return; + } + + callback(JSON.parse(e.data)); +}); + +export function createVerificationIframe(url: string, callback: TelegramWebviewEventCallback) { + const iframe = document.createElement('iframe'); + // iframe.title = 'Complete Payment'; + iframe.allow = 'payment'; + iframe.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin allow-top-navigation allow-modals'); + iframe.classList.add('payment-verification'); + iframe.src = url; + + iframe.addEventListener('load', () => { + weakMap.set(iframe.contentWindow, callback); + }, {once: true}); + + return iframe; +} + +export default class PopupPaymentVerification extends PopupElement<{ + finish: () => void +}> { + constructor(private url: string) { + super('popup-payment popup-payment-verification', { + closable: true, + overlayClosable: true, + body: true, + title: 'Checkout.WebConfirmation.Title' + }); + + this.d(); + } + + private d() { + const iframe = createVerificationIframe(this.url, (event) => { + if(event.eventType !== 'web_app_open_tg_link') { + return; + } + + this.dispatchEvent('finish'); + this.hide(); + appImManager.openUrl('https://t.me' + event.eventData.path_full); + }); + + this.body.append(iframe); + this.show(); + } +} diff --git a/src/components/popups/peer.ts b/src/components/popups/peer.ts index 39a43057..8e5b314a 100644 --- a/src/components/popups/peer.ts +++ b/src/components/popups/peer.ts @@ -15,23 +15,28 @@ export type PopupPeerButtonCallbackCheckboxes = Set; export type PopupPeerButtonCallback = (checkboxes?: PopupPeerButtonCallbackCheckboxes) => void; export type PopupPeerCheckboxOptions = CheckboxFieldOptions & {checkboxField?: CheckboxField}; -export type PopupPeerOptions = PopupOptions & Partial<{ +export type PopupPeerOptions = Omit & Partial<{ peerId: PeerId, title: string | HTMLElement, - titleLangKey?: LangPackKey, - titleLangArgs?: any[], - noTitle?: boolean, + titleLangKey: LangPackKey, + titleLangArgs: any[], + noTitle: boolean, description: string | DocumentFragment, - descriptionLangKey?: LangPackKey, - descriptionLangArgs?: any[], - buttons?: Array, + descriptionLangKey: LangPackKey, + descriptionLangArgs: any[], + buttons: Array, checkboxes: Array }>; export default class PopupPeer extends PopupElement { protected description: HTMLParagraphElement; constructor(private className: string, options: PopupPeerOptions = {}) { - super('popup-peer' + (className ? ' ' + className : ''), options.buttons && addCancelButton(options.buttons), {overlayClosable: true, ...options}); + super('popup-peer' + (className ? ' ' + className : ''), { + overlayClosable: true, + ...options, + title: true, + buttons: options.buttons && addCancelButton(options.buttons), + }); if(options.peerId) { const avatarEl = new AvatarElement(); @@ -65,7 +70,7 @@ export default class PopupPeer extends PopupElement { this.container.classList.add('have-checkbox'); options.checkboxes.forEach((o) => { - o.withRipple = false; + o.withRipple = true; const checkboxField = new CheckboxField(o); o.checkboxField = checkboxField; fragment.append(checkboxField.label); diff --git a/src/components/popups/pickUser.ts b/src/components/popups/pickUser.ts index e7f5c7f7..d55dbe0e 100644 --- a/src/components/popups/pickUser.ts +++ b/src/components/popups/pickUser.ts @@ -20,7 +20,7 @@ export default class PopupPickUser extends PopupElement { peerId?: number, selfPresence?: LangPackKey }) { - super('popup-forward', null, {closable: true, overlayClosable: true, body: true}); + super('popup-forward', {closable: true, overlayClosable: true, body: true, title: true}); this.selector = new AppSelectPeers({ appendTo: this.body, diff --git a/src/components/popups/reactedList.ts b/src/components/popups/reactedList.ts index 997b3398..d716dc49 100644 --- a/src/components/popups/reactedList.ts +++ b/src/components/popups/reactedList.ts @@ -21,10 +21,7 @@ export default class PopupReactedList extends PopupElement { constructor( private message: Message.message ) { - super('popup-reacted-list', /* [{ - langKey: 'Close', - isCancel: true - }] */null, {closable: true, overlayClosable: true, body: true}); + super('popup-reacted-list', {closable: true, overlayClosable: true, body: true}); this.init(); } diff --git a/src/components/popups/schedule.ts b/src/components/popups/schedule.ts index 46d4a97e..0d419a6a 100644 --- a/src/components/popups/schedule.ts +++ b/src/components/popups/schedule.ts @@ -38,7 +38,8 @@ export default class PopupSchedule extends PopupDatePicker { maxDate: getMaxDate(), withTime: true, showOverflowMonths: true, - confirmShortcutIsSendShortcut: true + confirmShortcutIsSendShortcut: true, + title: true }); this.element.classList.add('popup-schedule'); diff --git a/src/components/popups/sponsored.ts b/src/components/popups/sponsored.ts index ce58eb3a..63d69137 100644 --- a/src/components/popups/sponsored.ts +++ b/src/components/popups/sponsored.ts @@ -5,7 +5,6 @@ */ import I18n, { i18n } from "../../lib/langPack"; -import Scrollable from "../scrollable"; import PopupPeer from "./peer"; export default class PopupSponsored extends PopupPeer { @@ -23,19 +22,11 @@ export default class PopupSponsored extends PopupPeer { window.open(I18n.format('Chat.Message.Sponsored.Link', true)); }, isCancel: true - }] + }], + scrollable: true }); - const scrollable = new Scrollable(undefined); - scrollable.onAdditionalScroll = () => { - scrollable.container.classList.toggle('scrolled-top', !scrollable.scrollTop); - scrollable.container.classList.toggle('scrolled-bottom', scrollable.isScrolledDown); - }; - - this.description.replaceWith(scrollable.container); - - scrollable.container.append(this.description); - scrollable.container.classList.add('scrolled-top'); + this.scrollable.append(this.description); this.show(); } diff --git a/src/components/popups/stickers.ts b/src/components/popups/stickers.ts index 06e9fa2b..519626de 100644 --- a/src/components/popups/stickers.ts +++ b/src/components/popups/stickers.ts @@ -6,13 +6,11 @@ import PopupElement from "."; import type { AppStickersManager } from "../../lib/appManagers/appStickersManager"; -import Scrollable from "../scrollable"; import { wrapSticker } from "../wrappers"; import LazyLoadQueue from "../lazyLoadQueue"; import { putPreloader } from "../putPreloader"; import animationIntersector from "../animationIntersector"; import appImManager from "../../lib/appManagers/appImManager"; -import { StickerSet } from "../../layer"; import mediaSizes from "../../helpers/mediaSizes"; import { i18n } from "../../lib/langPack"; import Button from "../button"; @@ -28,17 +26,11 @@ const ANIMATION_GROUP = 'STICKERS-POPUP'; export default class PopupStickers extends PopupElement { private stickersFooter: HTMLElement; private stickersDiv: HTMLElement; - private h6: HTMLElement; - - private set: StickerSet.stickerSet; constructor(private stickerSetInput: Parameters[0]) { - super('popup-stickers', null, {closable: true, overlayClosable: true, body: true}); - - this.h6 = document.createElement('h6'); - this.h6.append(i18n('Loading')); + super('popup-stickers', {closable: true, overlayClosable: true, body: true, scrollable: true, title: true}); - this.header.append(this.h6); + this.title.append(i18n('Loading')); this.addEventListener('close', () => { animationIntersector.setOnlyOnePlayableGroup(''); @@ -62,8 +54,7 @@ export default class PopupStickers extends PopupElement { const btn = Button('btn-primary btn-primary-transparent disable-hover', {noRipple: true, text: 'Loading'}); this.stickersFooter.append(btn); - this.body.append(div); - const scrollable = new Scrollable(this.body); + this.scrollable.append(div); this.body.append(this.stickersFooter); // const editButton = document.createElement('button'); @@ -87,37 +78,29 @@ export default class PopupStickers extends PopupElement { }; private loadStickerSet() { - return this.managers.appStickersManager.getStickerSet(this.stickerSetInput).then((set) => { + return this.managers.appStickersManager.getStickerSet(this.stickerSetInput).then(async(set) => { if(!set) { toastNew({langPackKey: 'StickerSet.DontExist'}); this.hide(); return; } - //console.log('PopupStickers loadStickerSet got set:', set); - - this.set = set.set; animationIntersector.setOnlyOnePlayableGroup(ANIMATION_GROUP); - setInnerHTML(this.h6, wrapEmojiText(set.set.title)); - this.stickersFooter.classList.toggle('add', !set.set.installed_date); - let button: HTMLElement; + const s = i18n('Stickers', [set.set.count]); if(set.set.installed_date) { button = Button('btn-primary btn-primary-transparent danger', {noRipple: true}); - button.append(i18n('RemoveStickersCount', [i18n('Stickers', [set.set.count])])); + button.append(i18n('RemoveStickersCount', [s])); } else { button = Button('btn-primary btn-color-primary', {noRipple: true}); - button.append(i18n('AddStickersCount', [i18n('Stickers', [set.set.count])])); + button.append(i18n('AddStickersCount', [s])); } - this.stickersFooter.textContent = ''; - this.stickersFooter.append(button); - attachClickEvent(button, () => { const toggle = toggleDisability([button], true); - this.managers.appStickersManager.toggleStickerSet(this.set).then(() => { + this.managers.appStickersManager.toggleStickerSet(set.set).then(() => { this.hide(); }).catch(() => { toggle(); @@ -125,12 +108,9 @@ export default class PopupStickers extends PopupElement { }); const lazyLoadQueue = new LazyLoadQueue(); - - this.stickersDiv.classList.remove('is-loading'); - this.stickersDiv.innerHTML = ''; - for(let doc of set.documents) { + const divs = await Promise.all(set.documents.map(async(doc) => { if(doc._ === 'documentEmpty') { - continue; + return; } const div = document.createElement('div'); @@ -138,7 +118,7 @@ export default class PopupStickers extends PopupElement { const size = mediaSizes.active.esgSticker.width; - wrapSticker({ + await wrapSticker({ doc, div, lazyLoadQueue, @@ -149,8 +129,19 @@ export default class PopupStickers extends PopupElement { height: size }); - this.stickersDiv.append(div); - } + return div; + })); + + setInnerHTML(this.title, wrapEmojiText(set.set.title)); + this.stickersFooter.classList.toggle('add', !set.set.installed_date); + this.stickersFooter.textContent = ''; + this.stickersFooter.append(button); + + this.stickersDiv.classList.remove('is-loading'); + this.stickersDiv.innerHTML = ''; + this.stickersDiv.append(...divs.filter(Boolean)); + + this.scrollable.onAdditionalScroll(); }); } } diff --git a/src/components/sidebarLeft/index.ts b/src/components/sidebarLeft/index.ts index d89d021e..d3e502a5 100644 --- a/src/components/sidebarLeft/index.ts +++ b/src/components/sidebarLeft/index.ts @@ -664,6 +664,8 @@ export type SettingSectionOptions = { name?: LangPackKey, nameArgs?: FormatterArguments, caption?: LangPackKey | true, + captionArgs?: FormatterArguments, + captionOld?: SettingSectionOptions['caption'], noDelimiter?: boolean, fakeGradientDelimiter?: boolean, noShadow?: boolean, @@ -721,13 +723,17 @@ export class SettingSection { container.append(innerContainer); - if(options.caption) { - const caption = this.caption = this.generateContentElement(); - caption.classList.add(className + '-caption'); - container.append(caption); + const caption = options.caption ?? options.captionOld; + if(caption) { + const el = this.caption = this.generateContentElement(); + el.classList.add(className + '-caption'); - if(options.caption !== true) { - i18n_({element: caption, key: options.caption}); + if(!options.captionOld) { + container.append(el); + } + + if(caption !== true) { + i18n_({element: el, key: caption, args: options.captionArgs}); } } } diff --git a/src/components/sidebarLeft/tabs/2fa/email.ts b/src/components/sidebarLeft/tabs/2fa/email.ts index e09cd774..e4b76644 100644 --- a/src/components/sidebarLeft/tabs/2fa/email.ts +++ b/src/components/sidebarLeft/tabs/2fa/email.ts @@ -32,7 +32,7 @@ export default class AppTwoStepVerificationEmailTab extends SliderSuperTab { this.setTitle('RecoveryEmailTitle'); const section = new SettingSection({ - caption: true, + captionOld: true, noDelimiter: true }); diff --git a/src/components/sidebarLeft/tabs/2fa/emailConfirmation.ts b/src/components/sidebarLeft/tabs/2fa/emailConfirmation.ts index 6f716776..0bb8e1ee 100644 --- a/src/components/sidebarLeft/tabs/2fa/emailConfirmation.ts +++ b/src/components/sidebarLeft/tabs/2fa/emailConfirmation.ts @@ -31,7 +31,7 @@ export default class AppTwoStepVerificationEmailConfirmationTab extends SliderSu this.setTitle('TwoStepAuth.RecoveryTitle'); const section = new SettingSection({ - caption: true, + captionOld: true, noDelimiter: true }); diff --git a/src/components/sidebarLeft/tabs/2fa/enterPassword.ts b/src/components/sidebarLeft/tabs/2fa/enterPassword.ts index a11ca101..1379faf9 100644 --- a/src/components/sidebarLeft/tabs/2fa/enterPassword.ts +++ b/src/components/sidebarLeft/tabs/2fa/enterPassword.ts @@ -130,7 +130,7 @@ export default class AppTwoStepVerificationEnterPasswordTab extends SliderSuperT switch(err.type) { default: //btnContinue.innerText = err.type; - textEl.key = 'TwoStepAuth.InvalidPassword'; + textEl.key = 'PASSWORD_HASH_INVALID'; textEl.update(); preloader.remove(); passwordInputField.select(); diff --git a/src/components/sidebarLeft/tabs/2fa/index.ts b/src/components/sidebarLeft/tabs/2fa/index.ts index 333b5360..06c6a478 100644 --- a/src/components/sidebarLeft/tabs/2fa/index.ts +++ b/src/components/sidebarLeft/tabs/2fa/index.ts @@ -25,7 +25,7 @@ export default class AppTwoStepVerificationTab extends SliderSuperTab { this.setTitle('TwoStepVerificationTitle'); const section = new SettingSection({ - caption: true, + captionOld: true, noDelimiter: true }); diff --git a/src/components/sidebarLeft/tabs/2fa/passwordSet.ts b/src/components/sidebarLeft/tabs/2fa/passwordSet.ts index 8ca6b495..e15f35e5 100644 --- a/src/components/sidebarLeft/tabs/2fa/passwordSet.ts +++ b/src/components/sidebarLeft/tabs/2fa/passwordSet.ts @@ -17,7 +17,7 @@ export default class AppTwoStepVerificationSetTab extends SliderSuperTab { this.setTitle('TwoStepVerificationPasswordSet'); const section = new SettingSection({ - caption: 'TwoStepVerificationPasswordSetInfo', + captionOld: 'TwoStepVerificationPasswordSetInfo', noDelimiter: true }); diff --git a/src/components/sidebarLeft/tabs/privacyAndSecurity.ts b/src/components/sidebarLeft/tabs/privacyAndSecurity.ts index 2e6570bb..a8147e20 100644 --- a/src/components/sidebarLeft/tabs/privacyAndSecurity.ts +++ b/src/components/sidebarLeft/tabs/privacyAndSecurity.ts @@ -29,6 +29,9 @@ import toggleDisability from "../../../helpers/dom/toggleDisability"; import convertKeyToInputKey from "../../../helpers/string/convertKeyToInputKey"; import getPrivacyRulesDetails from "../../../lib/appManagers/utils/privacy/getPrivacyRulesDetails"; import PrivacyType from "../../../lib/appManagers/utils/privacy/privacyType"; +import confirmationPopup, { PopupConfirmationOptions } from "../../confirmationPopup"; +import noop from "../../../helpers/noop"; +import { toastNew } from "../../toast"; export default class AppPrivacyAndSecurityTab extends SliderSuperTabEventable { private activeSessionsRow: Row; @@ -315,6 +318,48 @@ export default class AppPrivacyAndSecurityTab extends SliderSuperTabEventable { this.scrollable.append(section.container); } + { + const section = new SettingSection({name: 'PrivacyPayments', caption: 'PrivacyPaymentsClearInfo'}); + + const onClearClick = () => { + const options: PopupConfirmationOptions = { + titleLangKey: 'PrivacyPaymentsClearAlertTitle', + descriptionLangKey: 'PrivacyPaymentsClearAlertText', + button: { + langKey: 'Clear' + }, + checkboxes: [{ + text: 'PrivacyClearShipping', + checked: true + }, { + text: 'PrivacyClearPayment', + checked: true + }] + }; + + confirmationPopup(options).then(() => { + const [info, payment] = options.checkboxes.map((c) => c.checkboxField.checked); + const toggle = toggleDisability([clearButton], true); + this.managers.appPaymentsManager.clearSavedInfo(info, payment).then(() => { + if(!info && !payment) { + return; + } + + toggle(); + toastNew({ + langPackKey: info && payment ? 'PrivacyPaymentsPaymentShippingCleared' : (info ? 'PrivacyPaymentsShippingInfoCleared' : 'PrivacyPaymentsPaymentInfoCleared') + }); + }); + }, noop); + }; + + const clearButton = Button('btn-primary btn-transparent', {icon: 'delete', text: 'PrivacyPaymentsClear'}); + this.listenerSetter.add(clearButton)('click', onClearClick); + section.content.append(clearButton); + + this.scrollable.append(section.container); + } + return Promise.all(promises); } diff --git a/src/components/usernameInputField.ts b/src/components/usernameInputField.ts index be47984e..d42ffe50 100644 --- a/src/components/usernameInputField.ts +++ b/src/components/usernameInputField.ts @@ -37,7 +37,7 @@ export class UsernameInputField extends InputField { //console.log('userNameInput:', value); if(value === this.originalValue || !value.length) { - this.setState(InputState.Neutral, this.options.label); + this.setState(InputState.Neutral); this.options.onChange && this.options.onChange(); return; } else if(!isUsernameValid(value)) { // does not check the last underscore diff --git a/src/components/wrappers/messageActionTextNewUnsafe.ts b/src/components/wrappers/messageActionTextNewUnsafe.ts index 4ee1c032..71f302a1 100644 --- a/src/components/wrappers/messageActionTextNewUnsafe.ts +++ b/src/components/wrappers/messageActionTextNewUnsafe.ts @@ -12,6 +12,7 @@ import formatCallDuration from "../../helpers/formatCallDuration"; import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; import { Message, MessageAction } from "../../layer"; import { MyMessage } from "../../lib/appManagers/appMessagesManager"; +import getPeerId from "../../lib/appManagers/utils/peers/getPeerId"; import I18n, { FormatterArgument, FormatterArguments, i18n, join, langPack, LangPackKey, _i18n } from "../../lib/langPack"; import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText"; import wrapPlainText from "../../lib/richTextProcessor/wrapPlainText"; @@ -254,7 +255,11 @@ export default async function wrapMessageActionTextNewUnsafe(message: MyMessage, args = [price, getNameDivHTML(message.peerId, plain)]; if(message.reply_to_mid) { - const invoiceMessage = await managers.appMessagesManager.getMessageByPeer(message.peerId, message.reply_to_mid); + const invoiceMessage = await managers.appMessagesManager.getMessageByPeer( + message.reply_to?.reply_to_peer_id ? getPeerId(message.reply_to.reply_to_peer_id) : message.peerId, + message.reply_to_mid + ); + if(!invoiceMessage) { managers.appMessagesManager.fetchMessageReplyTo(message); } else { diff --git a/src/config/app.ts b/src/config/app.ts index 70a6da52..1b1c39e0 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -19,7 +19,7 @@ const App = { version: process.env.VERSION, versionFull: process.env.VERSION_FULL, build: +process.env.BUILD, - langPackVersion: '0.4.1', + langPackVersion: '0.4.4', langPack: 'macos', langPackCode: 'en', domains: [MAIN_DOMAIN] as string[], diff --git a/src/config/font.ts b/src/config/font.ts new file mode 100644 index 00000000..96985a8b --- /dev/null +++ b/src/config/font.ts @@ -0,0 +1,9 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +export const FontFamily = 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif'; +export const FontSize = '16px'; +export const FontWeight = '400'; diff --git a/src/helpers/array/createArray.ts b/src/helpers/array/createArray.ts new file mode 100644 index 00000000..10503085 --- /dev/null +++ b/src/helpers/array/createArray.ts @@ -0,0 +1,5 @@ +export default function createArray(length: number, fill?: T1, map?: any): T1[] { + const arr = new Array(length); + arr.fill(fill); + return map ? arr.map(map) : arr; +} diff --git a/src/helpers/cacheCallback.ts b/src/helpers/cacheCallback.ts new file mode 100644 index 00000000..f65d6d7e --- /dev/null +++ b/src/helpers/cacheCallback.ts @@ -0,0 +1,9 @@ +function cacheCallback(callback: (str: A) => T) { + const stringResults: any = {}, numberResults: any = {}; + return (value: A): T => { + const key = '_' + value; + return (typeof(value) === 'string' ? stringResults : numberResults)[key] ??= callback(value); + }; +} + +export default cacheCallback; diff --git a/src/helpers/cards/cardBrands.ts b/src/helpers/cards/cardBrands.ts new file mode 100644 index 00000000..57bd54c3 --- /dev/null +++ b/src/helpers/cards/cardBrands.ts @@ -0,0 +1,112 @@ +import cacheCallback from "../cacheCallback"; +import replaceNonNumber from "../string/replaceNonNumber"; + +const CARD_BRAND_REGEXP: {[brand: string]: RegExp} = { + visa: /^4/, + mastercard: /^(51|52|53|54|55|22|23|24|25|26|27)/, + amex: /^(34|37)/, + discover: /^(60|64|65)/, + diners: /^(30|38|39)/, + diners14: /^(36)/, + jcb: /^(35)/, + unionpay: /^(62[0-6,8-9]|627[0-6,8-9]|6277[0-7,9]|62778[1-9]|81)/, + elo: /^(5067|509|636368|627780)/ +}; + +// * taken from Stripe +export const CARD_BRANDS: {[b: string]: { + minLength: number, + maxLength: number, + cvcMaxLength: number, + cvcMinLength: number | null +}} = { + visa: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + mastercard: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + amex: { + minLength: 15, + maxLength: 15, + cvcMaxLength: 4, + cvcMinLength: 3 + }, + unionpay: { + minLength: 13, + maxLength: 19, + cvcMaxLength: 3, + cvcMinLength: null + }, + diners: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + diners14: { + minLength: 14, + maxLength: 14, + cvcMaxLength: 3, + cvcMinLength: null + }, + discover: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + jcb: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + elo: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 3, + cvcMinLength: null + }, + unknown: { + minLength: 16, + maxLength: 16, + cvcMaxLength: 4, + cvcMinLength: 3 + } +}; + +export const detectCardBrand = cacheCallback((card: string = '') => { + const keys = Object.keys(CARD_BRAND_REGEXP); + const sanitizedCard = replaceNonNumber(card); + let brand: string; + let last = 0; + keys.forEach((key) => { + const regExp = CARD_BRAND_REGEXP[key]; + const match = sanitizedCard.match(regExp); + if(match) { + const result = match[0]; + if(result && result.length > last) { + brand = key; + last = result.length; + } + } + }); + + return brand || 'unknown'; +}); + +export function cardBrandToUnifiedBrand(brand: string) { + return brand === 'diners14' ? 'diners' : brand; +} + +export function detectUnifiedCardBrand(card = '') { + const brand = detectCardBrand(card); + return cardBrandToUnifiedBrand(brand); +} diff --git a/src/helpers/cards/cardFormattingPatterns.ts b/src/helpers/cards/cardFormattingPatterns.ts new file mode 100644 index 00000000..c9882712 --- /dev/null +++ b/src/helpers/cards/cardFormattingPatterns.ts @@ -0,0 +1,78 @@ +import { IS_ANDROID } from "../../environment/userAgent"; +import createArray from "../array/createArray"; +import cacheCallback from "../cacheCallback"; +import replaceNonNumber from "../string/replaceNonNumber"; +import { CARD_BRANDS, detectCardBrand } from "./cardBrands"; +import patternCharacters from "./patternCharacters"; + +const digit = patternCharacters.digit; +const capitalCharacter = patternCharacters.capitalCharacter; +const spaceCharacter = patternCharacters.formattingCharacter(' '); +const yearOptionalPattern = patternCharacters.optionalPattern(/\d\d/); +const sixteenPattern = [digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit]; +const fifteenPattern = [digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit]; + +const requiredPostcodes = new Set(['DZ', 'AR', 'AM', 'AU', 'AT', 'AZ', 'PT', 'BD', 'BY', 'BE', 'BA', 'BR', 'BN', 'BG', 'CA', 'IC', 'CN', 'CO', 'HR', 'CY', 'CZ', 'DK', 'EC', 'GB', 'EE', 'FO', 'FI', 'FR', 'GE', 'DE', 'GR', 'GL', 'GU', 'GG', 'NL', 'HU', 'IN', 'ID', 'IL', 'IT', 'JP', 'JE', 'KZ', 'KR', 'FM', 'KG', 'LV', 'LI', 'LT', 'LU', 'MK', 'MG', 'PT', 'MY', 'MH', 'MQ', 'YT', 'MX', 'MN', 'ME', 'NL', 'NZ', 'GB', 'NO', 'PK', 'PH', 'PL', 'FM', 'PT', 'PR', 'RE', 'RU', 'SA', 'SF', 'RS', 'SG', 'SK', 'SI', 'ZA', 'ES', 'LK', 'SX', 'VI', 'VI', 'SE', 'CH', 'TW', 'TJ', 'TH', 'TU', 'TN', 'TR', 'TM', 'VI', 'UA', 'GB', 'US', 'UY', 'UZ', 'VA', 'VN', 'GB', 'FM']); + +const generateFourPattern = cacheCallback((length: number) => { + const out: Array = []; + + for(let i = 0, k = 0; i < length;) { + if(k === 4) { + out.push(spaceCharacter); + k = 0; + } else { + out.push(digit); + ++i; + ++k; + } + } + + return out; +}); + +function generateCardNumberPattern(card: string) { + const brand = detectCardBrand(card); + if(brand === 'amex') return sixteenPattern; + if(brand === 'diners14') return fifteenPattern; + const {minLength, maxLength} = CARD_BRANDS[brand]; + const s = replaceNonNumber(card).length; + const d = Math.min(Math.max(minLength, s), maxLength); + return generateFourPattern(d); +} + +const cardFormattingPatterns = { + cardNumber: generateCardNumberPattern, + cardExpiry: () => [patternCharacters.month, patternCharacters.formattingCharacter('/'), digit, digit, yearOptionalPattern], + cardCvc: (card?: string) => cardFormattingPatterns.cardCvcFromBrand(detectCardBrand(card)), + cardCvcFromBrand: cacheCallback((brand: string) => { + const info = CARD_BRANDS[brand]; + const {cvcMinLength, cvcMaxLength} = info; + const pattern = createArray(cvcMinLength || cvcMaxLength, digit); + if(cvcMinLength && cvcMinLength < cvcMaxLength) { + const i = cvcMaxLength - cvcMinLength; + const h = patternCharacters.optionalPattern(/\d/); + if(i) { + pattern.push(...createArray(i, h)); + } + } + + return pattern; + }), + postalCodeFromCountry: cacheCallback((iso2: string) => { + switch(iso2) { + case 'US': + return createArray(5, digit); + case 'CA': + return IS_ANDROID ? null : [capitalCharacter, capitalCharacter, capitalCharacter, spaceCharacter, capitalCharacter, capitalCharacter, capitalCharacter]; + default: + const optionalDigits = createArray(10, patternCharacters.optionalPattern(/\d/)); + if(requiredPostcodes.has(iso2)) { + optionalDigits[0] = digit; + } + return optionalDigits; + } + }) +}; + +export default cardFormattingPatterns; diff --git a/src/helpers/cards/formatInputValueByPattern.ts b/src/helpers/cards/formatInputValueByPattern.ts new file mode 100644 index 00000000..592936dc --- /dev/null +++ b/src/helpers/cards/formatInputValueByPattern.ts @@ -0,0 +1,25 @@ +import formatValueByPattern from "./formatValueByPattern"; + +export default function formatInputValueByPattern(options: { + value: string, + getPattern: Parameters[0], + deleting?: boolean, + input?: HTMLElement +}) { + const {value: originalValue, getPattern, deleting, input} = options; + const pushRest = !deleting && !!originalValue.length; + const result = formatValueByPattern(getPattern, originalValue, { + selectionStart: input ? (input as HTMLInputElement).selectionStart : 0, + selectionEnd: input ? (input as HTMLInputElement).selectionEnd : 0 + }, pushRest) + const {value, selection} = result; + + return { + value, + meta: { + autocorrectComplete: result.autocorrectComplete, + empty: !value + }, + selection + }; +} diff --git a/src/helpers/cards/formatValueByPattern.ts b/src/helpers/cards/formatValueByPattern.ts new file mode 100644 index 00000000..f4807b92 --- /dev/null +++ b/src/helpers/cards/formatValueByPattern.ts @@ -0,0 +1,102 @@ +import accumulate from "../array/accumulate"; +import { PatternFunction } from "./patternCharacters"; + +function accumulateLengths(strs: string[]) { + return accumulate(strs.map((str) => str.length), 0); +} + +function formatValueByPattern( + getPattern: PatternFunction, + value: string, + options: Partial<{ + selectionStart: number, + selectionEnd: number + }> = {}, + pushRest?: boolean +) { + const pattern = getPattern(value); + + if(!pattern) { + return { + value: value, + selection: null as typeof options, + autocorrectComplete: !!value + }; + } + + const length = pattern.length; + const c: string[] = []; + const s: string[] = []; + + let l = 0; + let i = 0; + let f = options.selectionStart === 0 ? 0 : null; + let d = options.selectionEnd === 0 ? 0 : null; + const p = () => { + if(f === null && (i + 1) >= options.selectionStart) f = accumulateLengths(c) + (pushRest ? s.length : 0); + if(d === null && (i + 1) >= options.selectionEnd) d = accumulateLengths(c) + (pushRest ? s.length : 0); + }; + const m = (e: number) => { + if(e > 0) { + p(); + i += e; + } + }; + + for(; l < length;) { + const getCharacter = pattern[l]; + const character = getCharacter(value.slice(i)); + const {type, result, consumed} = character; + if(type === 'required') { + if(result) { + c.push(...s, result); + s.length = 0; + ++l; + + if(character.partial) { + m(value.length - i); + break; + } + + m(consumed); + } else { + if(!consumed) { + break; + } + + m(1); + } + } else if(type === 'optional') { + if(result) { + c.push(...s, result); + s.length = 0; + m(consumed); + } + + ++l; + } else if(type === 'formatting') { + if(!pushRest && i >= value.length) { + break; + } + + s.push(result); + ++l; + m(consumed); + } + } + + if(pushRest) { + c.push(...s); + } + + return { + value: c.join(''), + selection: { + selectionStart: f === null || value.length && options.selectionStart === value.length ? accumulateLengths(c) : f, + selectionEnd: d === null || value.length && options.selectionEnd === value.length ? accumulateLengths(c) : d + }, + autocorrectComplete: l === length + }; +} + +export default formatValueByPattern; diff --git a/src/helpers/cards/patternCharacters.ts b/src/helpers/cards/patternCharacters.ts new file mode 100644 index 00000000..b08935bd --- /dev/null +++ b/src/helpers/cards/patternCharacters.ts @@ -0,0 +1,85 @@ +import { fixBuggedNumbers } from "../string/buggedNumbers"; +import replaceNonNumber from "../string/replaceNonNumber"; + +export type PatternCharacter = { + type: 'optional', + result: string, + consumed: number +} | { + type: 'required', + result: string, + consumed: number, + partial?: boolean +} | { + type: 'formatting', + result: string, + consumed: number +}; + +export type PatternFunction = (str: string) => ((str: string) => PatternCharacter)[]; + +function makeOptionalCharacter(result: string, consumed: number): PatternCharacter { + return {type: 'optional', result, consumed}; +} + +function makeRequiredCharacter(result: string, consumed: number, partial?: boolean): PatternCharacter { + return {type: 'required', result, consumed, partial}; +} + +function makeFormattingCharacter(result: string, consumed: number): PatternCharacter { + return {type: 'formatting', result, consumed}; +} + +function wrapCharacterRegExpFactory(regExp: RegExp, optional?: boolean) { + return (str: string) => { + const _regExp = new RegExp('^'.concat(regExp.source.replace(/^\^/, ''))); + const match = str.match(_regExp); + const makeCharacter = optional ? makeOptionalCharacter : makeRequiredCharacter; + if(match) { + const result = match[0]; + return makeCharacter(result, match.index + result.length); + } + + return makeCharacter('', str.length); + }; +} + +function makeCapitalPatternCharacter(str: string) { + const char = wrapCharacterRegExpFactory(/\w/)(str); + return char.result ? makeRequiredCharacter(char.result.toUpperCase(), char.consumed) : char; +} + +const makeMonthDigitPatternCharacter = wrapCharacterRegExpFactory(/1[0-2]|0?[1-9]|0/); + +function digit(str: string) { + return wrapCharacterRegExpFactory(/[0-9]/)(fixBuggedNumbers(str)); +} + +const patternCharacters = { + digit, + capitalCharacter: makeCapitalPatternCharacter, + month: (str: string) => { + const char = makeMonthDigitPatternCharacter(fixBuggedNumbers(str)); + const cleanedResult = replaceNonNumber(char.result); + const isPartial = ['0', '1'].includes(char.result) && str.length === 1; + if(isPartial || (char.result === '0' && str.length >= 2)) { + return makeRequiredCharacter(char.result, str.length, true); + } + + return makeRequiredCharacter(cleanedResult.length === 1 ? '0' + cleanedResult : cleanedResult, char.consumed); + }, + formattingCharacter: (str: string) => { + return (str1: string) => { + const consumed = str === str1[0] ? 1 : 0; + return makeFormattingCharacter(str, consumed); + } + }, + optionalPattern: (regExp: RegExp) => { + return (str: string) => { + const char = wrapCharacterRegExpFactory(regExp, true)(str); + return char.result ? char : makeOptionalCharacter('', 0); + }; + } +}; + +export default patternCharacters; diff --git a/src/helpers/cards/validateCard.ts b/src/helpers/cards/validateCard.ts new file mode 100644 index 00000000..271757f0 --- /dev/null +++ b/src/helpers/cards/validateCard.ts @@ -0,0 +1,82 @@ +import { CARD_BRANDS, detectCardBrand } from "./cardBrands"; +import formatInputValueByPattern from './formatInputValueByPattern'; +import NBSP from "../string/nbsp"; +import replaceNonNumber from "../string/replaceNonNumber"; + +export type PatternValidationOptions = Partial<{ + ignoreIncomplete: boolean +}>; + +const nbspRegExp = new RegExp(NBSP, 'g'); + +function makeValidationError(code?: string) { + return code ? { + type: 'invalid', + code + } : null; +} + +function validateCompleteCardNumber(card: string) { + const t = '0'.charCodeAt(0); + const n = card.length % 2; + let a = 0; + for(let i = card.length - 1; i >= 0; --i) { + const c = n === (i % 2); + let o = card.charCodeAt(i) - t; + if(c) o *= 2; + if(o > 9) o -= 9; + a += o; + } + return !(a % 10); +} + +function validateExpiry(year: number, month: number, options?: PatternValidationOptions) { + const date = new Date(Date.now()); + const _year = year < 100 ? date.getFullYear() % 100 : date.getFullYear(); + const nextMonth = date.getMonth() + 1; + + if(isNaN(year) || isNaN(month)) { + return options?.ignoreIncomplete ? null : 'incomplete'; + } + + if((year - _year) < 0) { + return 'invalid_expiry_year_past'; + } + + if((year - _year) > 50) { + return 'invalid_expiry_year'; + } + + return !(year - _year) && month < nextMonth ? 'invalid_expiry_month_past' : null; +} + +function getCardInfoByNumber(card: string) { + const sanitized = replaceNonNumber(card); + const brand = detectCardBrand(card); + return { + sanitized, + brand, + minLength: CARD_BRANDS[brand].minLength + }; +} + +function makeCardNumberError(str: string, length: number, ignoreIncomplete: boolean) { + return str.length >= length ? (validateCompleteCardNumber(str) ? null : makeValidationError('invalid')) : (ignoreIncomplete ? null : makeValidationError('incomplete')); +} + +export function validateCardNumber(str: string, options: PatternValidationOptions = {}) { + const {sanitized, minLength} = getCardInfoByNumber(str); + return makeCardNumberError(sanitized, minLength, options.ignoreIncomplete); +} + +export function validateCardExpiry(str: string, options: PatternValidationOptions = {}) { + const sanitized = str.replace(nbspRegExp, '').split(/ ?\/ ?/); + const [monthStr, yearStr = ''] = sanitized; + const [month, year] = [monthStr, yearStr].map((str) => +str); + const s = yearStr.length === 2 ? year % 100 : year; + return yearStr.length < 2 || yearStr.length === 3 ? (options.ignoreIncomplete ? null : makeValidationError('incomplete')) : makeValidationError(validateExpiry(s, month, options)); +} + +export function validateAnyIncomplete(formatted: ReturnType, str: string, options: PatternValidationOptions = {}) { + return formatted.meta.autocorrectComplete || options.ignoreIncomplete ? null : makeValidationError('incomplete'); +} diff --git a/src/helpers/dom/loadScript.ts b/src/helpers/dom/loadScript.ts new file mode 100644 index 00000000..528ae792 --- /dev/null +++ b/src/helpers/dom/loadScript.ts @@ -0,0 +1,17 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +export default function loadScript(url: string) { + const script = document.createElement('script'); + const promise = new Promise((resolve) => { + script.onload = script.onerror = () => { + resolve(script); + }; + }); + script.src = url; + document.body.appendChild(script); + return promise; +} diff --git a/src/helpers/dom/placeCaretAtEnd.ts b/src/helpers/dom/placeCaretAtEnd.ts index 57e5dc03..943ddcfc 100644 --- a/src/helpers/dom/placeCaretAtEnd.ts +++ b/src/helpers/dom/placeCaretAtEnd.ts @@ -17,7 +17,11 @@ export default function placeCaretAtEnd(el: HTMLElement, ignoreTouchCheck = fals } el.focus(); - if(typeof window.getSelection !== "undefined" && typeof document.createRange !== "undefined") { + if(el instanceof HTMLInputElement) { + const length = el.value.length; + el.selectionStart = length; + el.selectionEnd = length; + } else if(typeof window.getSelection !== "undefined" && typeof document.createRange !== "undefined") { var range = document.createRange(); range.selectNodeContents(el); range.collapse(false); @@ -33,3 +37,5 @@ export default function placeCaretAtEnd(el: HTMLElement, ignoreTouchCheck = fals textRange.select(); } } + +(window as any).placeCaretAtEnd = placeCaretAtEnd; diff --git a/src/helpers/long/longFromInts.ts b/src/helpers/long/longFromInts.ts deleted file mode 100644 index be066edb..00000000 --- a/src/helpers/long/longFromInts.ts +++ /dev/null @@ -1,7 +0,0 @@ -import bigInt from "big-integer"; -import intToUint from "../number/intToUint"; - -export default function longFromInts(high: number, low: number): string { - high = intToUint(high), low = intToUint(low); - return bigInt(high).shiftLeft(32).add(bigInt(low)).toString(10); -} diff --git a/src/helpers/long/ulongFromInts.ts b/src/helpers/long/ulongFromInts.ts new file mode 100644 index 00000000..b3dec563 --- /dev/null +++ b/src/helpers/long/ulongFromInts.ts @@ -0,0 +1,7 @@ +import bigInt from "big-integer"; +import intToUint from "../number/intToUint"; + +export default function ulongFromInts(high: number, low: number) { + high = intToUint(high), low = intToUint(low); + return bigInt(high).shiftLeft(32).add(bigInt(low)); +} diff --git a/src/helpers/paymentsWrapCurrencyAmount.ts b/src/helpers/paymentsWrapCurrencyAmount.ts index e6620ec9..7b72b978 100644 --- a/src/helpers/paymentsWrapCurrencyAmount.ts +++ b/src/helpers/paymentsWrapCurrencyAmount.ts @@ -1,7 +1,7 @@ import Currencies from "../config/currencies"; // https://stackoverflow.com/a/34141813 -function number_format(number: any, decimals: any, dec_point: any, thousands_sep: any) { +function number_format(number: any, decimals: any, dec_point: any, thousands_sep: any): string { // Strip all characters but numerical ones. number = (number + '').replace(/[^0-9+\-Ee.]/g, ''); var n = !isFinite(+number) ? 0 : +number, @@ -25,7 +25,7 @@ function number_format(number: any, decimals: any, dec_point: any, thousands_sep return s.join(dec); } -export default function paymentsWrapCurrencyAmount($amount: number | string, $currency: string) { +export default function paymentsWrapCurrencyAmount($amount: number | string, $currency: string, $skipSymbol?: boolean) { $amount = +$amount; const $currency_data = Currencies[$currency]; // вытащить из json @@ -42,8 +42,11 @@ export default function paymentsWrapCurrencyAmount($amount: number | string, $cu } const $formatted = number_format($amount_exp, $decimals, $currency_data['decimal_sep'], $currency_data['thousands_sep']); + if($skipSymbol) { + return $formatted; + } - const $splitter = $currency_data['space_between'] ? "\xc2\xa0" : ''; + const $splitter = $currency_data['space_between'] ? " " : ''; let $formatted_intern: string; if($currency_data['symbol_left']) { $formatted_intern = $currency_data['symbol'] + $splitter + $formatted; diff --git a/src/helpers/scrollSaver.ts b/src/helpers/scrollSaver.ts index fa02cf0b..c4166f5f 100644 --- a/src/helpers/scrollSaver.ts +++ b/src/helpers/scrollSaver.ts @@ -43,6 +43,8 @@ export default class ScrollSaver { } public findElements() { + if(!this.query) return []; + const {container} = this; const containerRect = container.getBoundingClientRect(); const bubbles = Array.from(container.querySelectorAll(this.query)) as HTMLElement[]; diff --git a/src/helpers/string/buggedNumbers.ts b/src/helpers/string/buggedNumbers.ts new file mode 100644 index 00000000..5b7161ad --- /dev/null +++ b/src/helpers/string/buggedNumbers.ts @@ -0,0 +1,14 @@ +const delta = '0'.charCodeAt(0) - '0'.charCodeAt(0); +const buggedRegExp = /[0-9]/g; + +// function hasBuggedNumbers(str: string) { +// return !!str.match(a); +// } + +function getDistanceFromBuggedToNormal(char: string) { + return String.fromCharCode(char.charCodeAt(0) - delta); +} + +export function fixBuggedNumbers(str: string) { + return str.replace(buggedRegExp, getDistanceFromBuggedToNormal); +} diff --git a/src/helpers/string/nbsp.ts b/src/helpers/string/nbsp.ts new file mode 100644 index 00000000..d9f554c0 --- /dev/null +++ b/src/helpers/string/nbsp.ts @@ -0,0 +1,2 @@ +const NBSP = '‎'; +export default NBSP; diff --git a/src/helpers/string/replaceNonLatin.ts b/src/helpers/string/replaceNonLatin.ts new file mode 100644 index 00000000..f366d13c --- /dev/null +++ b/src/helpers/string/replaceNonLatin.ts @@ -0,0 +1,3 @@ +export default function replaceNonLatin(str: string) { + return str.replace(/[^A-Za-z0-9]/g, ""); +} diff --git a/src/helpers/string/replaceNonNumber.ts b/src/helpers/string/replaceNonNumber.ts new file mode 100644 index 00000000..21ddce7b --- /dev/null +++ b/src/helpers/string/replaceNonNumber.ts @@ -0,0 +1,3 @@ +export default function replaceNonNumber(str: string) { + return str.replace(/\D/g, ''); +} diff --git a/src/lang.ts b/src/lang.ts index 48b641bb..3aeacdfb 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -111,7 +111,6 @@ const lang = { "Popup.Unpin.HideTitle": "Hide pinned messages", "Popup.Unpin.HideDescription": "Do you want to hide the pinned message bar? It wil stay hidden until a new message is pinned.", "Popup.Unpin.Hide": "Hide", - "TwoStepAuth.InvalidPassword": "Invalid password", "TwoStepAuth.EmailCodeChangeEmail": "Change Email", "MarkupTooltip.LinkPlaceholder": "Enter URL...", "MediaViewer.Context.Download": "Download", @@ -137,6 +136,12 @@ const lang = { //"PushNotification.Action.Mute1d.Success": "Notification settings were successfully saved.", //it is from iOS "VoiceChat.DiscussionGroup": "discussion group", + "PaymentInfo.CVV": "CVV Code", + "PaymentInfo.Card.Title": "Enter your card information", + "PaymentInfo.Billing.Title": "Enter your billing address", + "PaymentInfo.Done": "PROCEED TO CHECKOUT", + "PaymentCard.Error.Invalid": "Invalid card number", + "PaymentCard.Error.Incomplete": "Incomplete card number", // * android "AccDescrEditing": "Editing", @@ -702,6 +707,49 @@ const lang = { "PaymentSuccessfullyPaidNoItem": "You successfully transferred %1$s to %2$s", // "PaymentSuccessfullyPaidRecurrent": "You successfully transferred %1$s to %2$s for %3$s and allowed future recurring payments", // "PaymentSuccessfullyPaidNoItemRecurrent": "You successfully transferred %1$s to %2$s and allowed future recurring payments", + "PaymentCheckout": "Checkout", + "PaymentTransactionTotal": "Total", + "PaymentTip": "Tip", + "PaymentTipOptional": "Tip (Optional)", + "PaymentCheckoutPay": "PAY %1$s", + "PaymentCheckoutMethod": "Payment method", + "PaymentCheckoutProvider": "Payment provider", + "PaymentCardNumber": "Card Number", + "PaymentCardSavePaymentInformation": "Save Payment Information", + "PaymentCardInfo": "Payment info", + "PaymentCardSavePaymentInformationInfoLine1": "You can save your payment info for future use. It will be stored directly with the payment provider. Telegram has no access to your credit card data.", + "Done": "Done", + "PaymentShippingMethod": "Shipping methods", + "PaymentNoShippingMethod": "Sorry, it is not possible to deliver to your address.", + "PaymentShippingInfo": "Shipping Information", + "PaymentShippingAddress": "Shipping address", + "PaymentShippingAddress1Placeholder": "Address 1 (Street)", + "PaymentShippingAddress2Placeholder": "Address 2 (Street)", + "PaymentShippingCityPlaceholder": "City", + "PaymentShippingStatePlaceholder": "State", + "PaymentShippingCountry": "Country", + "PaymentShippingZipPlaceholder": "Postcode", + "PaymentShippingReceiver": "Receiver", + "PaymentShippingName": "Full Name", + "PaymentShippingEmailPlaceholder": "Email", + "PaymentCheckoutPhoneNumber": "Phone number", + "PaymentCheckoutShippingMethod": "Shipping method", + "PaymentShippingSave": "Save Shipping Information", + "PaymentShippingSaveInfo": "You can save your shipping info for future use.", + "PaymentInfoHint": "You paid **%1$s** for **%2$s**.", + "PrivacyPayments": "Payments", + "PrivacyPaymentsClearInfo": "You can delete your shipping info and instruct all payment providers to remove your saved credit cards. Note that Telegram never stores your credit card data.", + "PrivacyPaymentsClear": "Clear Payment and Shipping Info", + "PrivacyPaymentsClearAlertTitle": "Clear payment info", + "PrivacyPaymentsClearAlertText": "Are you sure you want to clear your payment and shipping info?", + "PrivacyPaymentsPaymentInfoCleared": "Payment info cleared.", + "PrivacyPaymentsShippingInfoCleared": "Shipping info cleared.", + "PrivacyPaymentsPaymentShippingCleared": "Payment and shipping info cleared.", + "PrivacyClearShipping": "Shipping info", + "PrivacyClearPayment": "Payment info", + "Clear": "Clear", + "Save": "Save", + "PaymentCheckoutName": "Name", // * macos "AccountSettings.Filters": "Chat Folders", @@ -848,6 +896,12 @@ const lang = { "Chat.Message.ViewGroup": "VIEW GROUP", "Chat.Message.Sponsored.What": "What are sponsored messages?", "Chat.Message.Sponsored.Link": "https://promote.telegram.org", + "Checkout.2FA.Text": "Saving payment details is only available with 2-Step Verification.", + "Checkout.NewCard.CardholderNamePlaceholder": "Cardholder Name", + "Checkout.PasswordEntry.Title": "Payment Confirmation", + "Checkout.PasswordEntry.Pay": "Pay", + "Checkout.PasswordEntry.Text": "Your card %@ is on file. To pay with this card, please enter your 2-Step-Verification password.", + "Checkout.WebConfirmation.Title": "Complete Payment", "ChatList.Context.Mute": "Mute", "ChatList.Context.Unmute": "Unmute", "ChatList.Context.Pin": "Pin", @@ -906,6 +960,7 @@ const lang = { "Emoji.Objects": "Objects", //"Emoji.Symbols": "Symbols", "Emoji.Flags": "Flags", + "Error.AnError": "An error occurred. Please try again later.", "FileSize.B": "%@ B", "FileSize.KB": "%@ KB", "FileSize.MB": "%@ MB", @@ -1052,6 +1107,7 @@ const lang = { "GeneralSettings.EmojiPrediction": "Suggest Emoji", "GroupPermission.Delete": "Delete Exception", "Search.Confirm.ClearHistory": "Are you sure you want to clear your search history?", + "SecureId.Identity.Placeholder.ExpiryDate": "Expiry Date", "Separator.ShowMore": "show more", "Separator.ShowLess": "show less", "ScheduleController.at": "at", diff --git a/src/langSign.ts b/src/langSign.ts index 098b8a4f..5a9ea4e6 100644 --- a/src/langSign.ts +++ b/src/langSign.ts @@ -1,6 +1,5 @@ const lang = { "Login.Title": "Sign in to Telegram", - "Login.CountrySelectorLabel": "Country", "Login.PhoneLabel": "Phone Number", "Login.PhoneLabelInvalid": "Phone Number Invalid", "Login.KeepSigned": "Keep me signed in", @@ -21,6 +20,7 @@ const lang = { "FirstName": "First name (required)", "LastName": "Last name (optional)", "StartMessaging": "Start Messaging", + "Country": "Country", // * macos "Contacts.PhoneNumber.Placeholder": "Phone Number", diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index c80b7dbb..03f5f6f6 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -294,7 +294,7 @@ export class AppImManager extends EventListenerBase<{ const onInstanceDeactivated = (reason: InstanceDeactivateReason) => { const isUpdated = reason === 'version'; - const popup = new PopupElement('popup-instance-deactivated', undefined, {overlayClosable: true}); + const popup = new PopupElement('popup-instance-deactivated', {overlayClosable: true}); const c = document.createElement('div'); c.classList.add('instance-deactivated-container'); (popup as any).container.replaceWith(c); diff --git a/src/lib/appManagers/appInlineBotsManager.ts b/src/lib/appManagers/appInlineBotsManager.ts index a616bcac..bd18fbf0 100644 --- a/src/lib/appManagers/appInlineBotsManager.ts +++ b/src/lib/appManagers/appInlineBotsManager.ts @@ -12,13 +12,14 @@ import type { MyDocument } from "./appDocsManager"; import type { MyPhoto } from "./appPhotosManager"; import type { MyTopPeer } from "./appUsersManager"; -import { BotInlineResult, GeoPoint, InputGeoPoint, InputMedia, MessageEntity, MessagesBotResults, ReplyMarkup } from "../../layer"; +import { BotInlineResult, GeoPoint, InputGeoPoint, InputMedia, MessageEntity, MessageMedia, MessagesBotResults, ReplyMarkup } from "../../layer"; import insertInDescendSortedArray from "../../helpers/array/insertInDescendSortedArray"; import { AppManager } from "./manager"; import getPhotoMediaInput from "./utils/photos/getPhotoMediaInput"; import getServerMessageId from "./utils/messageId/getServerMessageId"; import generateQId from "./utils/inlineBots/generateQId"; import getDocumentMediaInput from "./utils/docs/getDocumentMediaInput"; +import { AppMessagesManager } from "./appMessagesManager"; export class AppInlineBotsManager extends AppManager { private inlineResults: {[queryAndResultIds: string]: BotInlineResult} = {}; @@ -286,7 +287,7 @@ export class AppInlineBotsManager extends AppManager { this.appMessagesManager.sendText(peerId, inlineResult.send_message.message, options); } else { let caption = ''; - let inputMedia: InputMedia; + let inputMedia: Parameters[1], messageMedia: MessageMedia; const sendMessage = inlineResult.send_message; switch(sendMessage._) { case 'botInlineMessageMediaAuto': { @@ -342,18 +343,50 @@ export class AppInlineBotsManager extends AppManager { break; } + + case 'botInlineMessageMediaInvoice': { + // const photo = sendMessage.photo; + // inputMedia = { + // _: 'inputMediaInvoice', + // description: sendMessage.description, + // title: sendMessage.title, + // photo: photo && { + // _: 'inputWebDocument', + // attributes: photo.attributes, + // mime_type: photo.mime_type, + // size: photo.size, + // url: photo.url + // }, + // invoice: undefined, + // payload: undefined, + // provider: undefined, + // provider_data: undefined, + // start_param: undefined + // }; + + messageMedia = { + _: 'messageMediaInvoice', + title: sendMessage.title, + description: sendMessage.description, + photo: sendMessage.photo, + currency: sendMessage.currency, + total_amount: sendMessage.total_amount, + pFlags: { + shipping_address_requested: sendMessage.pFlags.shipping_address_requested, + test: sendMessage.pFlags.test + }, + start_param: undefined + }; + + break; + } } - if(!inputMedia) { + if(!inputMedia && messageMedia) { inputMedia = { _: 'messageMediaPending', - type: inlineResult.type, - file_name: inlineResult.title || - (inlineResult as BotInlineResult.botInlineResult).content?.url || - (inlineResult as BotInlineResult.botInlineResult).url, - size: 0, - progress: {percent: 30, total: 0} - } as any; + messageMedia + }; } this.appMessagesManager.sendOther(peerId, inputMedia, options); diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index 35978725..1c229d69 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -1250,7 +1250,7 @@ export class AppMessagesManager extends AppManager { return this.sendOther(peerId, this.appUsersManager.getContactMediaInput(contactPeerId)); } - public sendOther(peerId: PeerId, inputMedia: InputMedia, options: Partial<{ + public sendOther(peerId: PeerId, inputMedia: InputMedia | {_: 'messageMediaPending', messageMedia: MessageMedia}, options: Partial<{ replyToMsgId: number, threadId: number, viaBotId: BotId, @@ -1345,9 +1345,8 @@ export class AppMessagesManager extends AppManager { break; } - // @ts-ignore case 'messageMediaPending': { - media = inputMedia; + media = (inputMedia as any).messageMedia; break; } } @@ -1393,7 +1392,7 @@ export class AppMessagesManager extends AppManager { } else { apiPromise = this.apiManager.invokeApiAfter('messages.sendMedia', { peer: this.appPeersManager.getInputPeerById(peerId), - media: inputMedia, + media: inputMedia as InputMedia, random_id: message.random_id, reply_to_msg_id: replyToMsgId || undefined, message: '', @@ -4032,6 +4031,13 @@ export class AppMessagesManager extends AppManager { this.onUpdateNewMessage(update); } + if(message._ === 'messageService' && message.action._ === 'messageActionPaymentSent') { + this.rootScope.dispatchEvent('payment_sent', { + peerId: message.reply_to.reply_to_peer_id ? getPeerId(message.reply_to.reply_to_peer_id) : message.peerId, + mid: message.reply_to_mid + }); + } + if(!dialog && !isLocalThreadUpdate) { let good = true; if(peerId.isAnyChat()) { @@ -4971,6 +4977,16 @@ export class AppMessagesManager extends AppManager { const tempMessage = this.getMessageFromStorage(storage, tempId); storage.delete(tempId); + + if(!(tempMessage as Message.message).reply_markup && (message as Message.message).reply_markup) { + setTimeout(() => { // TODO: refactor it to normal buttons adding + if(!this.getMessageFromStorage(storage, message.mid)) { + return; + } + + this.rootScope.dispatchEvent('message_edit', {storageKey: storage.key, peerId: message.peerId, mid: message.mid, message}); + }, 0); + } this.handleReleasingMessage(tempMessage, storage); diff --git a/src/lib/appManagers/appPaymentsManager.ts b/src/lib/appManagers/appPaymentsManager.ts new file mode 100644 index 00000000..2053aff9 --- /dev/null +++ b/src/lib/appManagers/appPaymentsManager.ts @@ -0,0 +1,75 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import { InputPaymentCredentials, PaymentRequestedInfo, PaymentsPaymentForm } from "../../layer"; +import { AppManager } from "./manager"; +import getServerMessageId from "./utils/messageId/getServerMessageId"; + +export default class AppPaymentsManager extends AppManager { + public getPaymentForm(peerId: PeerId, mid: number) { + return this.apiManager.invokeApi('payments.getPaymentForm', { + peer: this.appPeersManager.getInputPeerById(peerId), + msg_id: getServerMessageId(mid) + }).then((paymentForm) => { + this.appUsersManager.saveApiUsers(paymentForm.users); + + return paymentForm; + }); + } + + public getPaymentReceipt(peerId: PeerId, mid: number) { + return this.apiManager.invokeApi('payments.getPaymentReceipt', { + peer: this.appPeersManager.getInputPeerById(peerId), + msg_id: getServerMessageId(mid) + }).then((paymentForm) => { + this.appUsersManager.saveApiUsers(paymentForm.users); + + return paymentForm; + }); + } + + public validateRequestedInfo(peerId: PeerId, mid: number, info: PaymentRequestedInfo, save?: boolean) { + return this.apiManager.invokeApi('payments.validateRequestedInfo', { + save, + peer: this.appPeersManager.getInputPeerById(peerId), + msg_id: getServerMessageId(mid), + info + }); + } + + public sendPaymentForm( + peerId: PeerId, + mid: number, + formId: PaymentsPaymentForm['form_id'], + requestedInfoId: string, + shippingOptionId: string, + credentials: InputPaymentCredentials, + tipAmount?: number + ) { + return this.apiManager.invokeApi('payments.sendPaymentForm', { + form_id: formId, + peer: this.appPeersManager.getInputPeerById(peerId), + msg_id: getServerMessageId(mid), + requested_info_id: requestedInfoId, + shipping_option_id: shippingOptionId, + credentials, + tip_amount: tipAmount || undefined + }).then((result) => { + if(result._ === 'payments.paymentResult') { + this.apiUpdatesManager.processUpdateMessage(result.updates); + } + + return result; + }); + } + + public clearSavedInfo(info?: boolean, credentials?: boolean) { + return this.apiManager.invokeApi('payments.clearSavedInfo', { + info, + credentials + }); + } +} diff --git a/src/lib/appManagers/appPeersManager.ts b/src/lib/appManagers/appPeersManager.ts index 7095188a..4b26dd03 100644 --- a/src/lib/appManagers/appPeersManager.ts +++ b/src/lib/appManagers/appPeersManager.ts @@ -242,7 +242,7 @@ export class AppPeersManager extends AppManager { } } - public getDeleteButtonText(peerId: PeerId): LangPackKey { + public getDeleteButtonText(peerId: PeerId): Extract { switch(this.getDialogType(peerId)) { case 'channel': return this.appChatsManager.hasRights(peerId.toChatId(), 'delete_chat') ? 'ChannelDelete' : 'ChatList.Context.LeaveChannel'; diff --git a/src/lib/appManagers/createManagers.ts b/src/lib/appManagers/createManagers.ts index 2bad18db..5c6e569e 100644 --- a/src/lib/appManagers/createManagers.ts +++ b/src/lib/appManagers/createManagers.ts @@ -44,6 +44,7 @@ import cryptoMessagePort from "../crypto/cryptoMessagePort"; import appStateManager from "./appStateManager"; import filterUnique from "../../helpers/array/filterUnique"; import AppWebDocsManager from "./appWebDocsManager"; +import AppPaymentsManager from "./appPaymentsManager"; export default function createManagers(appStoragesManager: AppStoragesManager, userId: UserId) { const managers = { @@ -84,7 +85,8 @@ export default function createManagers(appStoragesManager: AppStoragesManager, u timeManager: new TimeManager, appStoragesManager: appStoragesManager, appStateManager: appStateManager, - appWebDocsManager: new AppWebDocsManager + appWebDocsManager: new AppWebDocsManager, + appPaymentsManager: new AppPaymentsManager }; type T = typeof managers; diff --git a/src/lib/appManagers/manager.ts b/src/lib/appManagers/manager.ts index 9ad0b706..25554395 100644 --- a/src/lib/appManagers/manager.ts +++ b/src/lib/appManagers/manager.ts @@ -30,6 +30,7 @@ import type { AppInlineBotsManager } from "./appInlineBotsManager"; import type { AppMessagesIdsManager } from "./appMessagesIdsManager"; import type { AppMessagesManager } from "./appMessagesManager"; import type { AppNotificationsManager } from "./appNotificationsManager"; +import type AppPaymentsManager from "./appPaymentsManager"; import type { AppPeersManager } from "./appPeersManager"; import type { AppPhotosManager } from "./appPhotosManager"; import type { AppPollsManager } from "./appPollsManager"; @@ -84,6 +85,7 @@ export class AppManager { protected appStoragesManager: AppStoragesManager; protected appStateManager: AppStateManager; protected appWebDocsManager: AppWebDocsManager; + protected appPaymentsManager: AppPaymentsManager; public clear: (init?: boolean) => void; diff --git a/src/lib/appManagers/uiNotificationsManager.ts b/src/lib/appManagers/uiNotificationsManager.ts index 1994ce34..4bcb31e2 100644 --- a/src/lib/appManagers/uiNotificationsManager.ts +++ b/src/lib/appManagers/uiNotificationsManager.ts @@ -4,10 +4,10 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import { fontFamily } from "../../components/middleEllipsis"; import getPeerTitle from "../../components/wrappers/getPeerTitle"; import wrapMessageForReply from "../../components/wrappers/messageForReply"; import { MOUNT_CLASS_TO } from "../../config/debug"; +import { FontFamily } from "../../config/font"; import { IS_MOBILE } from "../../environment/userAgent"; import IS_VIBRATE_SUPPORTED from "../../environment/vibrateSupport"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; @@ -343,7 +343,7 @@ export class UiNotificationsManager { fontSize *= window.devicePixelRatio; - ctx.font = `700 ${fontSize}px ${fontFamily}`; + ctx.font = `700 ${fontSize}px ${FontFamily}`; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillStyle = 'white'; diff --git a/src/lib/mtproto/apiFileManager.ts b/src/lib/mtproto/apiFileManager.ts index b999612c..fdd402f1 100644 --- a/src/lib/mtproto/apiFileManager.ts +++ b/src/lib/mtproto/apiFileManager.ts @@ -275,6 +275,10 @@ export class ApiFileManager extends AppManager { } private getLimitPart(size: number): number { + if(!size) { // * sometimes size can be 0 (e.g. avatars, webDocuments) + return 512 * 1024; + } + let bytes = 128 * 1024; while((size / bytes) > 2000) { diff --git a/src/lib/mtproto/passwordManager.ts b/src/lib/mtproto/passwordManager.ts index d9388644..a365d9ae 100644 --- a/src/lib/mtproto/passwordManager.ts +++ b/src/lib/mtproto/passwordManager.ts @@ -72,8 +72,12 @@ export class PasswordManager extends AppManager { }); } + public getInputCheckPassword(password: string, state: AccountPassword) { + return this.cryptoWorker.invokeCrypto('computeSRP', password, state, false) as Promise; + } + public check(password: string, state: AccountPassword, options: any = {}) { - return this.cryptoWorker.invokeCrypto('computeSRP', password, state, false).then((inputCheckPassword) => { + return this.getInputCheckPassword(password, state).then((inputCheckPassword) => { //console.log('SRP', inputCheckPassword); return this.apiManager.invokeApi('auth.checkPassword', { password: inputCheckPassword as InputCheckPasswordSRP.inputCheckPasswordSRP diff --git a/src/lib/mtproto/telegramMeWebManager.ts b/src/lib/mtproto/telegramMeWebManager.ts index 27a529c3..b44b7df4 100644 --- a/src/lib/mtproto/telegramMeWebManager.ts +++ b/src/lib/mtproto/telegramMeWebManager.ts @@ -12,6 +12,7 @@ import App from "../../config/app"; import { MOUNT_CLASS_TO } from "../../config/debug"; import Modes from "../../config/modes"; +import loadScript from "../../helpers/dom/loadScript"; import tsNow from "../../helpers/tsNow"; import sessionStorage from '../sessionStorage'; @@ -47,16 +48,9 @@ export class TelegramMeWebManager { ]; const promises = urls.map((url) => { - const script = document.createElement('script'); - const promise = new Promise((resolve) => { - script.onload = script.onerror = () => { - script.remove(); - resolve(); - }; + return loadScript(url).then((script) => { + script.remove(); }); - script.src = url; - document.body.appendChild(script); - return promise; }); return Promise.all(promises); diff --git a/src/lib/mtproto/timeManager.ts b/src/lib/mtproto/timeManager.ts index c3c1aaa6..7705bdd3 100644 --- a/src/lib/mtproto/timeManager.ts +++ b/src/lib/mtproto/timeManager.ts @@ -12,7 +12,7 @@ import sessionStorage from '../sessionStorage'; import { nextRandomUint } from '../../helpers/random'; import { WorkerTaskVoidTemplate } from '../../types'; -import longFromInts from '../../helpers/long/longFromInts'; +import ulongFromInts from '../../helpers/long/ulongFromInts'; import { AppManager } from '../appManagers/manager'; /* @@ -85,7 +85,7 @@ export class TimeManager extends AppManager { this.lastMessageId = messageId; - const ret = longFromInts(messageId[0], messageId[1]); + const ret = ulongFromInts(messageId[0], messageId[1]).toString(10); // if(lol[ret]) { // console.error('[TimeManager]: Generated SAME msg id', messageId, this.timeOffset, ret); diff --git a/src/lib/mtproto/tl_utils.ts b/src/lib/mtproto/tl_utils.ts index b603db06..2c8de448 100644 --- a/src/lib/mtproto/tl_utils.ts +++ b/src/lib/mtproto/tl_utils.ts @@ -16,25 +16,16 @@ import bytesToHex from '../../helpers/bytes/bytesToHex'; import isObject from '../../helpers/object/isObject'; import gzipUncompress from '../../helpers/gzipUncompress'; import bigInt from 'big-integer'; -import longFromInts from '../../helpers/long/longFromInts'; - -// @ts-ignore -/* import {BigInteger} from 'jsbn'; - -export function bigint(num: number) { - return new BigInteger(num.toString(16), 16); -} - -function bigStringInt(strNum: string) { - return new BigInteger(strNum, 10) -} */ +import ulongFromInts from '../../helpers/long/ulongFromInts'; const boolFalse = +Schema.API.constructors.find((c) => c.predicate === 'boolFalse').id; const boolTrue = +Schema.API.constructors.find((c) => c.predicate === 'boolTrue').id; const vector = +Schema.API.constructors.find((c) => c.predicate === 'vector').id; const gzipPacked = +Schema.MTProto.constructors.find((c) => c.predicate === 'gzip_packed').id; -//console.log('boolFalse', boolFalse === 0xbc799737); +const safeBigInt = bigInt(Number.MAX_SAFE_INTEGER); +const ulongBigInt = bigInt(bigInt[2]).pow(64); +const longBigInt = ulongBigInt.divide(bigInt[2]); class TLSerialization { private maxLength = 2048; // 2Kb @@ -159,12 +150,13 @@ class TLSerialization { return this.storeIntBytes(sLong, 64, field); } } - - if(typeof sLong !== 'string') { - sLong = sLong ? sLong.toString() : '0'; + + let _bigInt = bigInt(sLong as string); + if(_bigInt.isNegative()) { // make it unsigned + _bigInt = ulongBigInt.add(_bigInt); } - const {quotient, remainder} = bigInt(sLong).divmod(0x100000000); + const {quotient, remainder} = _bigInt.divmod(0x100000000); const high = quotient.toJSNumber(); const low = remainder.toJSNumber(); @@ -506,23 +498,25 @@ class TLDeserialization { return doubleView[0]; } + // ! it should've been signed public fetchLong(field?: string): FetchLongAs { const iLow = this.readInt((field || '') + ':long[low]'); const iHigh = this.readInt((field || '') + ':long[high]'); - - //const longDec = bigint(iHigh).shiftLeft(32).add(bigint(iLow)).toString(); - const longDec = longFromInts(iHigh, iLow); + + let ulong = ulongFromInts(iHigh, iLow); + if(/* !unsigned && */!this.mtproto && ulong.greater(longBigInt)) { // make it signed + ulong = ulong.minus(ulongBigInt); + } if(!this.mtproto) { - const num = +longDec; - if(Number.isSafeInteger(num)) { + if(safeBigInt.greaterOrEquals(ulong.abs())) { // @ts-ignore - return num; + return ulong.toJSNumber(); } } // @ts-ignore - return longDec; + return ulong.toString(10); } public fetchBool(field?: string): boolean { diff --git a/src/lib/rootScope.ts b/src/lib/rootScope.ts index 393f5dc6..fa3e5f28 100644 --- a/src/lib/rootScope.ts +++ b/src/lib/rootScope.ts @@ -136,7 +136,9 @@ export type BroadcastEvents = { 'service_notification': Update.updateServiceNotification, - 'logging_out': void + 'logging_out': void, + + 'payment_sent': {peerId: PeerId, mid: number} }; export type BroadcastEventsListeners = { diff --git a/src/pages/pageSignIn.ts b/src/pages/pageSignIn.ts index 2d351f51..80071521 100644 --- a/src/pages/pageSignIn.ts +++ b/src/pages/pageSignIn.ts @@ -19,8 +19,6 @@ import ripple from "../components/ripple"; import findUpTag from "../helpers/dom/findUpTag"; import findUpClassName from "../helpers/dom/findUpClassName"; import { randomLong } from "../helpers/random"; -import AppStorage from "../lib/storage"; -import CacheStorageController from "../lib/cacheStorage"; import pageSignQR from "./pageSignQR"; import getLanguageChangeButton from "../components/languageChangeButton"; import cancelEvent from "../helpers/dom/cancelEvent"; @@ -40,6 +38,7 @@ import IS_EMOJI_SUPPORTED from "../environment/emojiSupport"; import setInnerHTML from "../helpers/dom/setInnerHTML"; import wrapEmojiText from "../lib/richTextProcessor/wrapEmojiText"; import apiManagerProxy from "../lib/mtproto/mtprotoworker"; +import CountryInputField from "../components/countryInputField"; //import _countries from '../countries_pretty.json'; let btnNext: HTMLButtonElement = null, btnQr: HTMLButtonElement; @@ -63,231 +62,27 @@ let onFirstMount = () => { //const countries: Country[] = _countries.default.filter((c) => c.emoji); // const countries: Country[] = Countries.filter((c) => c.emoji).sort((a, b) => a.name.localeCompare(b.name)); // const countries = I18n.countriesList.filter((country) => !country.pFlags?.hidden); - const setCountries = () => { - countries = I18n.countriesList - .filter((country) => !country.pFlags?.hidden) - .sort((a, b) => (a.name || a.default_name).localeCompare(b.name || b.default_name)); - }; - let countries: HelpCountry.helpCountry[]; - - setCountries(); - - rootScope.addEventListener('language_change', () => { - setCountries(); - }); - - const liMap: Map = new Map(); - - let lastCountrySelected: HelpCountry, lastCountryCodeSelected: HelpCountryCode; const inputWrapper = document.createElement('div'); inputWrapper.classList.add('input-wrapper'); - const countryInputField = new InputField({ - label: 'Login.CountrySelectorLabel', - name: randomLong() - }); - - countryInputField.container.classList.add('input-select'); - - const countryInput = countryInputField.input; - // countryInput.autocomplete = randomLong(); - - const selectWrapper = document.createElement('div'); - selectWrapper.classList.add('select-wrapper', 'z-depth-3', 'hide'); - - const arrowDown = document.createElement('span'); - arrowDown.classList.add('arrow', 'arrow-down'); - countryInputField.container.append(arrowDown); - - const selectList = document.createElement('ul'); - selectWrapper.appendChild(selectList); - - const scroll = new Scrollable(selectWrapper); - - let initSelect = () => { - initSelect = null; - - countries.forEach((c) => { - const emoji = getCountryEmoji(c.iso2); - - const liArr: Array = []; - c.country_codes.forEach((countryCode) => { - const li = document.createElement('li'); - - let wrapped = wrapEmojiText(emoji); - if(IS_EMOJI_SUPPORTED) { - const spanEmoji = document.createElement('span'); - setInnerHTML(spanEmoji, wrapped); - li.append(spanEmoji); - } else { - setInnerHTML(li, wrapped); - } - - const el = i18n(c.default_name as any); - el.dataset.defaultName = c.default_name; - li.append(el); - - const span = document.createElement('span'); - span.classList.add('phone-code'); - span.innerText = '+' + countryCode.country_code; - li.appendChild(span); - - liArr.push(li); - selectList.append(li); - }); + let lastCountrySelected: HelpCountry, lastCountryCodeSelected: HelpCountryCode; + const countryInputField = new CountryInputField({ + onCountryChange: (country, code) => { + lastCountrySelected = country, lastCountryCodeSelected = code; - liMap.set(c.iso2, liArr); - }); - - selectList.addEventListener('mousedown', (e) => { - if(e.button !== 0) { // other buttons but left shall not pass + if(!code) { return; } - - const target = findUpTag(e.target, 'LI') - selectCountryByTarget(target); - //console.log('clicked', e, countryName, phoneCode); - }); - - countryInputField.container.appendChild(selectWrapper); - }; - - const selectCountryByTarget = (target: HTMLElement) => { - const defaultName = (target.childNodes[1] as HTMLElement).dataset.defaultName; - const phoneCode = target.querySelector('.phone-code').innerText; - const countryCode = phoneCode.replace(/\D/g, ''); - - replaceContent(countryInput, i18n(defaultName as any)); - simulateEvent(countryInput, 'input'); - lastCountrySelected = countries.find((c) => c.default_name === defaultName); - lastCountryCodeSelected = lastCountrySelected.country_codes.find((_countryCode) => _countryCode.country_code === countryCode); - - telInputField.value = telInputField.lastValue = phoneCode; - hidePicker(); - setTimeout(() => { - telEl.focus(); - placeCaretAtEnd(telEl, true); - }, 0); - }; - - initSelect(); - - let hideTimeout: number; - - countryInput.addEventListener('focus', function(this: typeof countryInput, e) { - if(initSelect) { - initSelect(); - } else { - countries.forEach((c) => { - liMap.get(c.iso2).forEach((li) => li.style.display = ''); - }); - } - - clearTimeout(hideTimeout); - hideTimeout = undefined; - - selectWrapper.classList.remove('hide'); - void selectWrapper.offsetWidth; // reflow - selectWrapper.classList.add('active'); - - countryInputField.select(); - - fastSmoothScroll({ - container: page.pageEl.parentElement.parentElement, - element: countryInput, - position: 'start', - margin: 4 - }); - - setTimeout(() => { - if(!mouseDownHandlerAttached) { - document.addEventListener('mousedown', onMouseDown, {capture: true}); - mouseDownHandlerAttached = true; - } - }, 0); - }); - let mouseDownHandlerAttached = false; - const onMouseDown = (e: MouseEvent) => { - if(findUpClassName(e.target, 'input-select')) { - return; - } - if(e.target === countryInput) { - return; - } - - hidePicker(); - document.removeEventListener('mousedown', onMouseDown, {capture: true}); - mouseDownHandlerAttached = false; - }; - - const hidePicker = () => { - if(hideTimeout !== undefined) return; - selectWrapper.classList.remove('active'); - hideTimeout = window.setTimeout(() => { - selectWrapper.classList.add('hide'); - hideTimeout = undefined; - }, 200); - }; - /* false && countryInput.addEventListener('blur', function(this: typeof countryInput, e) { - hidePicker(); - - e.cancelBubble = true; - }, {capture: true}); */ - - countryInput.addEventListener('keyup', (e) => { - const key = e.key; - if(e.ctrlKey || key === 'Control') return false; - - //let i = new RegExp('^' + this.value, 'i'); - let _value = countryInputField.value.toLowerCase(); - let matches: HelpCountry[] = []; - countries.forEach((c) => { - const names = [ - c.name, - c.default_name, - c.iso2 - ]; - - names.filter(Boolean).forEach((name) => { - const abbr = name.split(' ').filter((word) => /\w/.test(word)).map((word) => word[0]).join(''); - if(abbr.length > 1) { - names.push(abbr); - } - }); - - let good = !!names.filter(Boolean).find((str) => str.toLowerCase().indexOf(_value) !== -1)/* === 0 */;//i.test(c.name); - - liMap.get(c.iso2).forEach((li) => li.style.display = good ? '' : 'none'); - if(good) matches.push(c); - }); - - // Код ниже автоматически выберет страну если она осталась одна при поиске - /* if(matches.length === 1 && matches[0].li.length === 1) { - if(matches[0].name === lastCountrySelected) return false; - //console.log('clicking', matches[0]); - - var clickEvent = document.createEvent('MouseEvents'); - clickEvent.initEvent('mousedown', true, true); - matches[0].li[0].dispatchEvent(clickEvent); - return false; - } else */if(matches.length === 0) { - countries.forEach((c) => { - liMap.get(c.iso2).forEach((li) => li.style.display = ''); - }); - } else if(matches.length === 1 && key === 'Enter') { - selectCountryByTarget(liMap.get(matches[0].iso2)[0]); + telInputField.value = telInputField.lastValue = '+' + code.country_code; + setTimeout(() => { + telEl.focus(); + placeCaretAtEnd(telEl, true); + }, 0); } }); - arrowDown.addEventListener('mousedown', function(this: typeof arrowDown, e) { - e.cancelBubble = true; - e.preventDefault(); - if(countryInput.matches(':focus')) countryInput.blur(); - else countryInput.focus(); - }); - const telInputField = new TelInputField({ onInput: (formatted) => { lottieLoader.loadLottieWorkers(); @@ -303,9 +98,7 @@ let onFirstMount = () => { ) ) ) { - replaceContent(countryInput, country ? i18n(country.default_name as any) : countryName); - lastCountrySelected = country; - lastCountryCodeSelected = code; + countryInputField.override(country, code, countryName); } //if(country && (telInputField.value.length - 1) >= (country.pattern ? country.pattern.length : 9)) { @@ -485,7 +278,7 @@ let onFirstMount = () => { return nearestDcResult; }).then((nearestDcResult) => { if(!countryInputField.value.length && !telInputField.value.length) { - selectCountryByTarget(liMap.get(nearestDcResult.country)[0]); + countryInputField.selectCountryByIso2(nearestDcResult.country); } //console.log('woohoo', nearestDcResult, country); diff --git a/src/scss/components/_global.scss b/src/scss/components/_global.scss index b71f366a..d78621ec 100644 --- a/src/scss/components/_global.scss +++ b/src/scss/components/_global.scss @@ -25,6 +25,14 @@ a { -webkit-tap-highlight-color: transparent; } +button { + background: none; + outline: none; + border: none; + cursor: pointer; + padding: 0; +} + img, video { -webkit-user-drag: none; diff --git a/src/scss/mixins/_hover.scss b/src/scss/mixins/_hover.scss index 07764770..02890dd4 100644 --- a/src/scss/mixins/_hover.scss +++ b/src/scss/mixins/_hover.scss @@ -45,7 +45,9 @@ @mixin btn-hoverable { @include hover-background-effect(); - &.primary, &.blue, &.active { + &.primary, + &.blue, + &.active { @include hover-background-effect(primary); } diff --git a/src/scss/partials/_button.scss b/src/scss/partials/_button.scss index 1dd0f435..79068f64 100644 --- a/src/scss/partials/_button.scss +++ b/src/scss/partials/_button.scss @@ -4,7 +4,8 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -.btn, .btn-icon { +.btn, +.btn-icon { background: none; outline: none; border: none; diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index ca4a9f79..42982232 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -2147,6 +2147,12 @@ $bubble-beside-button-width: 38px; code { cursor: pointer; } + + &.is-invoice { + .attachment { + background-color: inherit !important; + } + } } // * fix scroll with only 1 bubble @@ -2851,6 +2857,10 @@ $bubble-beside-button-width: 38px; transform: rotate(-45deg); } + &.is-buy:before { + content: $tgico-card; + } + &.is-switch-inline:before { content: $tgico-forward_filled; } diff --git a/src/scss/partials/_checkbox.scss b/src/scss/partials/_checkbox.scss index 60ec1237..58959884 100644 --- a/src/scss/partials/_checkbox.scss +++ b/src/scss/partials/_checkbox.scss @@ -6,6 +6,7 @@ .checkbox-field { --size: 1.25rem; + --offset-left: 0px; margin: 1.5rem 1.1875rem; display: block; text-align: left; @@ -29,19 +30,21 @@ .checkbox-box { position: absolute; - left: 0; + left: var(--offset-left); top: 50%; transform: translateY(-50%); width: var(--size); height: var(--size); - border-radius: .25rem; + border-radius: .3125rem; overflow: hidden; html.is-safari & { -webkit-mask-image: -webkit-radial-gradient(circle, white 100%, black 100%); // fix safari overflow } - &-check, &-background, &-border { + &-check, + &-background, + &-border { position: absolute; top: 0; left: 0; @@ -73,7 +76,7 @@ } &-check { - --offset: 3px; + --offset: 7px; width: calc(var(--size) - var(--offset)); height: calc(var(--size) - var(--offset)); top: 50%; @@ -82,7 +85,7 @@ use { stroke: #fff; - stroke-width: 2.75; + stroke-width: 3.75; stroke-linecap: round; stroke-dasharray: 24.19, 24.19; stroke-dashoffset: 0; @@ -99,15 +102,10 @@ .checkbox-caption { position: relative; padding-left: 3.375rem; - cursor: pointer; - display: inline-block; - min-height: 24px; - margin-top: 1px; - line-height: 26px; - user-select: none; transition: .2s opacity; - // color: var(--primary-text-color); color: inherit; + pointer-events: none; + line-height: var(--line-height); @include animation-level(0) { transition: none; @@ -168,6 +166,7 @@ .radio-field { --size: 1.375rem; + --offset-left: 0px; position: relative; text-align: left; margin: 1.25rem 0; @@ -210,7 +209,7 @@ content: ''; display: block; position: absolute; - left: 0; + left: var(--offset-left); top: 50%; width: var(--size); height: var(--size); @@ -228,7 +227,7 @@ } &::after { - left: .3125rem; + left: calc(var(--offset-left) + .3125rem); width: .75rem; height: .75rem; border-radius: 50%; diff --git a/src/scss/partials/_input.scss b/src/scss/partials/_input.scss index b0366fa6..5931ff8b 100644 --- a/src/scss/partials/_input.scss +++ b/src/scss/partials/_input.scss @@ -81,6 +81,18 @@ transition: opacity .2s; } } + + &-icon { + position: absolute; + right: 1rem; + z-index: 1; + top: 50%; + transform: translateY(-50%); + width: 1.5rem; + height: 1.5rem; + border-radius: .375rem; + pointer-events: none; + } &-input { --padding: 1rem; @@ -90,7 +102,7 @@ border-radius: var(--border-radius); background-color: var(--surface-color); //padding: 0 1rem; - padding: calc(var(--padding) - var(--border-width)); + padding: calc(var(--padding) - var(--border-width)) calc(var(--padding-horizontal) - var(--border-width)); box-sizing: border-box; width: 100%; min-height: var(--height); @@ -123,6 +135,7 @@ @include respond-to(handhelds) { --padding: .9375rem; + --padding-horizontal: .9375rem; } @include animation-level(0) { @@ -215,8 +228,8 @@ &:valid ~ label, &:not(:empty) ~ label, &:disabled ~ label { - transform: translate(-.25rem, calc(var(--height) / -2 + .125rem)) scale(.75); - padding: 0 6px; + transform: translate(-.1875rem, calc(var(--height) / -2 + .0625rem)) scale(.75); + padding: 0 .3125rem; opacity: 1; } } @@ -237,6 +250,19 @@ } } +.input-fields-row { + display: flex; + + .input-field { + flex: 1 1 auto; + width: 1%; // fix width because of contenteditable + + // &:not(:first-child) { + // margin-left: 1rem; + // } + } +} + .input-wrapper > * + * { margin-top: 1.5rem; } diff --git a/src/scss/partials/_row.scss b/src/scss/partials/_row.scss index b21e344c..6614c594 100644 --- a/src/scss/partials/_row.scss +++ b/src/scss/partials/_row.scss @@ -4,10 +4,12 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ +$row-border-radius: $border-radius-medium; + .row { min-height: 3.5rem; position: relative; - padding: .6875rem 1rem; + padding: .4375rem 1rem; display: flex; flex-direction: column; justify-content: center; @@ -80,7 +82,7 @@ margin-top: -.125rem; } - .row-subtitle:not(:empty) + .row-title.tgico:before { + .row-subtitle:not(:empty):not(.hide) + .row-title.tgico:before { margin-top: .25rem; } } @@ -90,7 +92,7 @@ overflow: hidden; @include respond-to(not-handhelds) { - border-radius: $border-radius-medium; + border-radius: $row-border-radius; } } @@ -100,6 +102,22 @@ margin-left: -3.375rem; } + .radio-field, + .radio-field-main, + .checkbox-field { + position: unset; + } + + .radio-field, + .checkbox-field { + --offset-left: 1rem; + } + + .radio-field { + margin-top: 0; + margin-bottom: 0; + } + .checkbox-field { margin-right: 0; height: auto; @@ -134,6 +152,9 @@ position: absolute !important; margin: 0 !important; left: .5rem; + display: flex; + align-items: center; + justify-content: center; &-small { width: 2rem !important; diff --git a/src/scss/partials/_scrollable.scss b/src/scss/partials/_scrollable.scss index c3555904..75e39883 100644 --- a/src/scss/partials/_scrollable.scss +++ b/src/scss/partials/_scrollable.scss @@ -96,6 +96,23 @@ html:not(.is-safari):not(.is-ios) { scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); -ms-overflow-style: none; transform: translateZ(0); + + &.scrollable-y-bordered { + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + + @include animation-level(2) { + transition: border-top-color var(--transition-standard-in), border-bottom-color var(--transition-standard-in); + } + + &:not(.scrolled-top) { + border-top-color: var(--border-color); + } + + &:not(.scrolled-bottom) { + border-bottom-color: var(--border-color); + } + } // html.is-firefox & { // transition: scrollbar-color .3s ease; diff --git a/src/scss/partials/popups/_createPoll.scss b/src/scss/partials/popups/_createPoll.scss index 7c0ef0c3..ce0555bd 100644 --- a/src/scss/partials/popups/_createPoll.scss +++ b/src/scss/partials/popups/_createPoll.scss @@ -29,7 +29,8 @@ } } - .hidden-widget, .radio-field:first-child:last-child { + .hidden-widget, + .radio-field:first-child:last-child { .btn-icon { pointer-events: none; opacity: 0 !important; @@ -97,4 +98,8 @@ top: 54px; right: 20px; } + + hr:not(.hide) { + display: block !important; + } } diff --git a/src/scss/partials/popups/_mediaAttacher.scss b/src/scss/partials/popups/_mediaAttacher.scss index ce78408d..58e36bc4 100644 --- a/src/scss/partials/popups/_mediaAttacher.scss +++ b/src/scss/partials/popups/_mediaAttacher.scss @@ -37,7 +37,6 @@ font-size: 14px; font-weight: normal; padding: 0 1.375rem; - margin-top: -3px; border-radius: $border-radius-medium; text-transform: uppercase; } @@ -48,15 +47,6 @@ margin: -1px 0 0 -4px; } - &-title { - flex: 1; - padding-left: 1.5rem; - margin: 0; - margin-top: -3px; - font-size: 1.25rem; - font-weight: var(--font-weight-bold); - } - &-photo { max-width: 380px; overflow: hidden; diff --git a/src/scss/partials/popups/_mute.scss b/src/scss/partials/popups/_mute.scss index 9b8d299a..0e330730 100644 --- a/src/scss/partials/popups/_mute.scss +++ b/src/scss/partials/popups/_mute.scss @@ -6,19 +6,6 @@ .popup-mute { .popup-container { - width: 16rem; - } - - .popup-body { - margin: 0 -.625rem; - } - - .sidebar-left-section { - margin-bottom: 0 !important; - padding: 0 !important; - - &-content { - margin: 0 !important; - } + min-width: 16rem; } } diff --git a/src/scss/partials/popups/_payment.scss b/src/scss/partials/popups/_payment.scss new file mode 100644 index 00000000..38a45463 --- /dev/null +++ b/src/scss/partials/popups/_payment.scss @@ -0,0 +1,249 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.popup-payment { + $parent: ".popup"; + + #{$parent} { + &-container { + padding: 0; + width: 26.25rem; + max-width: 26.25rem; + + max-height: unquote('min(100%, 43.5rem)'); + border-radius: $border-radius-huge; + } + + &-header { + height: 3.5rem; + margin: 0; + padding: 0 1rem; + } + } + + &.is-loading .popup-container { + min-height: 26.25rem; + } + + .scrollable { + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + hr { + display: block !important; + } + + .input-field { + --height: 3rem; + margin: 1.25rem .5rem 0; + + &-input { + --padding: .75rem; + } + } + + .sidebar-left-section { + padding: 0 !important; + + &-name + .input-field, + &-name + .input-fields-row .input-field { + margin-top: .75rem; + } + + .row { + margin-top: .5rem; + } + + &-content { + margin: 0 .5rem !important; + } + + // &-container { + // &:last-child .sidebar-left-section { + // margin-bottom: 0; + // } + // } + } + + .select-wrapper { + max-height: 10rem; + box-shadow: var(--menu-box-shadow); + + li { + grid-template-columns: calc(26px + 2rem) 1fr; + height: 3rem; + } + } + + .payment-verification { + width: 100%; + min-height: 30rem; + border: none; + flex: 1 1 auto; + } + + .row { + border-radius: $row-border-radius; + } +} + +.payment-item { + width: 100%; + padding: 0 1.25rem; + + &-details { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + // height: 6.25rem; + + // max-height: 100px; + overflow: hidden; + flex: 0 0 auto; + + &:last-child { + margin-bottom: 0; + } + + &-photo { + width: 6.25rem; + height: 6.25rem; + flex: 0 0 auto; + border-radius: $border-radius-medium; + margin-right: 1rem; + // background-color: var(--secondary-color); + + .media-photo { + border-radius: inherit; + } + } + + &-lines { + flex: 1 1 auto; + display: flex; + flex-direction: column; + + &-title { + font-weight: var(--font-weight-bold); + // font-size: var(--font-size-20); + // line-height: var(--line-height-20); + // margin: 5px 0; + + font-size: var(--font-size-16); + line-height: var(--line-height-16); + } + + &-description, + &-bot-name { + // flex: 1 1 auto; + font-size: var(--font-size-14); + line-height: 1.25rem; + // color: var(--secondary-text-color); + + // @include text-overflow(false); + } + + &-bot-name { + color: var(--secondary-text-color); + // flex: 0 0 auto; + } + } + } + + &-prices { + display: flex; + flex-direction: column; + margin: 1rem .25rem; + + &-price { + color: var(--secondary-text-color); + font-weight: 500; + display: flex; + justify-content: space-between; + line-height: 1.1875rem; + + & + & { + margin-top: 1.5rem; + } + + &.is-total { + color: var(--primary-text-color); + } + } + } + + &-tips { + display: flex; + justify-content: space-between; + margin: .75rem -.5rem 1.5rem; + + &-tip { + --background-intensity: .1; + flex: 1 1 auto; + text-align: center; + height: 2.5rem; + border-radius: 1.25rem; + background-color: rgba(84, 190, 97, var(--background-intensity)); + color: #3ba748; + font-weight: var(--font-weight-bold); + font-size: var(--font-size-16); + line-height: 2.5rem; + + @include animation-level(2) { + transition: color .1s ease-in-out, background-color .1s ease-in-out; + } + + & + & { + margin-left: .5rem; + } + + &:not(.active) { + @include hover() { + --background-intensity: .3; + } + } + + &.active { + --background-intensity: 1; + color: #fff; + } + } + + &-input { + color: inherit !important; + font-weight: inherit !important; + // text-align: right; + display: inline; + } + } + + &-row { + margin: 0 .5rem; + padding-top: 0; + padding-bottom: 0; + } + + &-method-row { + .media-photo { + border-radius: $border-radius-medium; + } + } + + &-pay { + flex: 0 0 auto; + width: auto; + height: 3rem; + margin: 1rem; + text-transform: uppercase; + } + + &-preloader-container { + position: relative; + flex: 1 1 auto; + } +} diff --git a/src/scss/partials/popups/_paymentCard.scss b/src/scss/partials/popups/_paymentCard.scss new file mode 100644 index 00000000..4b641cbd --- /dev/null +++ b/src/scss/partials/popups/_paymentCard.scss @@ -0,0 +1,11 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +// .popup-payment-card { +// .popup-container { +// max-height: 26.25rem; +// } +// } diff --git a/src/scss/partials/popups/_paymentCardConfirmation.scss b/src/scss/partials/popups/_paymentCardConfirmation.scss new file mode 100644 index 00000000..d772a889 --- /dev/null +++ b/src/scss/partials/popups/_paymentCardConfirmation.scss @@ -0,0 +1,15 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.popup-payment-card-confirmation { + .popup-container { + min-height: auto; + } + + .input-field-password { + margin-top: .5rem !important; + } +} diff --git a/src/scss/partials/popups/_paymentShippingMethods.scss b/src/scss/partials/popups/_paymentShippingMethods.scss new file mode 100644 index 00000000..0bd88adb --- /dev/null +++ b/src/scss/partials/popups/_paymentShippingMethods.scss @@ -0,0 +1,15 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.popup-payment-shipping-methods { + .popup-container { + min-height: auto; + } + + .row { + margin-top: 0 !important; + } +} diff --git a/src/scss/partials/popups/_paymentVerification.scss b/src/scss/partials/popups/_paymentVerification.scss new file mode 100644 index 00000000..4d29f368 --- /dev/null +++ b/src/scss/partials/popups/_paymentVerification.scss @@ -0,0 +1,9 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +// .popup-payment-verification { + +// } diff --git a/src/scss/partials/popups/_peer.scss b/src/scss/partials/popups/_peer.scss index 8ab73209..76125b9a 100644 --- a/src/scss/partials/popups/_peer.scss +++ b/src/scss/partials/popups/_peer.scss @@ -10,28 +10,24 @@ #{$parent} { &-header { display: flex; - margin-bottom: .625rem; align-items: center; + padding: 0 1rem; + height: 2.5rem; + margin: 0; } &-container { - padding: 1rem 1.5rem .8125rem; + padding: .75rem .5rem; max-width: unquote('min(400px, 100%)'); - - &.have-checkbox { - .popup-buttons { - margin-top: .5625rem; - } - } } &-title { font-size: 1.25rem; font-weight: var(--font-weight-bold); - margin-bottom: .125rem; + // margin-bottom: .125rem; &:not(:first-child) { - padding-left: .6875rem; + padding-left: 1rem; } } @@ -44,30 +40,16 @@ overflow: hidden; word-break: break-word; line-height: var(--line-height); - } - - &-buttons { - margin-top: 1.625rem; - margin-right: -.5rem; - - .btn { - font-weight: var(--font-weight-bold); - - & + .btn { - margin-top: .625rem; - } - } + padding: .625rem 1rem .5rem; } } .checkbox-field { display: flex; align-items: center; - height: 3.5rem; - /* padding: 0 .9375rem; - margin: 0 -.8125rem; */ - padding: 0 1.1875rem; - margin: 0 -1.0625rem; + height: 3rem; + margin: 0; + padding: 0 1.125rem; .checkbox-box { left: auto; diff --git a/src/scss/partials/popups/_popup.scss b/src/scss/partials/popups/_popup.scss index ff164ff2..7a6a9160 100644 --- a/src/scss/partials/popups/_popup.scss +++ b/src/scss/partials/popups/_popup.scss @@ -53,6 +53,19 @@ } } + &-title { + flex: 1; + padding: 0 2rem 0 1.5rem; + margin: 0; + font-size: 1.25rem; + font-weight: var(--font-weight-bold); + line-height: 1; + + &:first-child { + padding-left: 0; + } + } + &-container { --translateX: 0; position: relative; @@ -109,38 +122,24 @@ &-buttons { display: flex; - flex-direction: column; - justify-content: flex-end; - align-items: flex-end; - - &-row { - flex-direction: row-reverse; - justify-content: flex-start; - - .btn { - & + .btn { - margin-top: 0 !important; - margin-right: 1.125rem; - } - } - } - + flex-direction: row-reverse; + justify-content: flex-start; + align-items: center; + height: 3rem; + padding: 0 .5rem; + .btn { font-weight: var(--font-weight-bold); - padding: .5rem; + padding: 0 1rem; text-transform: uppercase; - border-radius: $border-radius; + border-radius: $border-radius-medium; position: relative; - overflow: hidden; max-width: 100%; - white-space: nowrap; - text-overflow: ellipsis; + height: 2.5rem; + @include text-overflow(); & + .btn { - margin-top: .5rem; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; + margin-right: .625rem; } } } diff --git a/src/scss/partials/popups/_sponsored.scss b/src/scss/partials/popups/_sponsored.scss index e17c26ce..7961a4f5 100644 --- a/src/scss/partials/popups/_sponsored.scss +++ b/src/scss/partials/popups/_sponsored.scss @@ -12,15 +12,5 @@ width: calc(100% + 3rem); padding: .5rem 1.5rem; user-select: text; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - - &:not(.scrolled-top) { - border-top: 1px solid var(--border-color); - } - - &:not(.scrolled-bottom) { - border-bottom: 1px solid var(--border-color); - } } } diff --git a/src/scss/partials/popups/_stickers.scss b/src/scss/partials/popups/_stickers.scss index 8544eef1..dbac23f0 100644 --- a/src/scss/partials/popups/_stickers.scss +++ b/src/scss/partials/popups/_stickers.scss @@ -8,16 +8,7 @@ $parent: ".popup"; user-select: none; - h6 { - padding: 0 2rem 0 1.5rem; - margin: 0; - font-size: 1.25rem; - font-weight: var(--font-weight-bold); - line-height: var(--line-height); - } - .sticker-set-footer { - border-top: 1px solid var(--border-color); text-align: center; color: var(--primary-color); @@ -48,14 +39,13 @@ } &-header { - margin-bottom: 12px; + margin: .625rem 0; flex: 0 0 auto; - margin-top: 10px; } } .sticker-set { - margin-bottom: 8px; + margin: .0625rem 0; &-stickers { padding: 0 5px; diff --git a/src/scss/style.scss b/src/scss/style.scss index f193e94a..6d7c8f9f 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -86,9 +86,11 @@ $chat-input-inner-padding-handhelds: .25rem; --bubble-transition-in: transform var(--transition-standard-in), opacity var(--transition-standard-in); --bubble-transition-out: transform var(--transition-standard-out), opacity var(--transition-standard-out); --line-height: 1.3125; + --line-height-20: 23px; --line-height-16: 21px; --line-height-14: 18px; --line-height-12: 16px; + --font-size-20: 20px; --font-size-16: 16px; --font-size-14: 14px; --font-size-12: 12px; @@ -369,6 +371,11 @@ $chat-input-inner-padding-handhelds: .25rem; @import "partials/popups/sponsored"; @import "partials/popups/mute"; @import "partials/popups/reactedList"; +@import "partials/popups/payment"; +@import "partials/popups/paymentCard"; +@import "partials/popups/paymentShippingMethods"; +@import "partials/popups/paymentVerification"; +@import "partials/popups/paymentCardConfirmation"; @import "partials/pages/pages"; @import "partials/pages/authCode"; @@ -550,6 +557,11 @@ input::-webkit-credentials-auto-fill-button { right: 0; } +input:-webkit-autofill::first-line { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, Arial, sans-serif; + font-size: 16px +} + /* input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, @@ -1256,6 +1268,16 @@ middle-ellipsis-element { // } // } +.media-container-cover { + position: relative; + + .media-photo { + object-fit: cover; + width: 100%; + height: 100%; + } +} + .media-photo, .media-video, .media-sticker, diff --git a/src/scss/tgico/_style.scss b/src/scss/tgico/_style.scss index 3af6e199..de007133 100644 --- a/src/scss/tgico/_style.scss +++ b/src/scss/tgico/_style.scss @@ -3,9 +3,9 @@ @font-face { font-family: '#{$tgico-font-family}'; src: - url('#{$tgico-font-path}/#{$tgico-font-family}.ttf?5o4186') format('truetype'), - url('#{$tgico-font-path}/#{$tgico-font-family}.woff?5o4186') format('woff'), - url('#{$tgico-font-path}/#{$tgico-font-family}.svg?5o4186##{$tgico-font-family}') format('svg'); + url('#{$tgico-font-path}/#{$tgico-font-family}.ttf?js2svd') format('truetype'), + url('#{$tgico-font-path}/#{$tgico-font-family}.woff?js2svd') format('woff'), + url('#{$tgico-font-path}/#{$tgico-font-family}.svg?js2svd##{$tgico-font-family}') format('svg'); font-weight: normal; font-style: normal; font-display: block; @@ -167,6 +167,16 @@ content: $tgico-car; } } +.tgico-card { + &:before { + content: $tgico-card; + } +} +.tgico-card_outline { + &:before { + content: $tgico-card_outline; + } +} .tgico-channel { &:before { content: $tgico-channel; @@ -827,6 +837,11 @@ content: $tgico-sharescreen_filled; } } +.tgico-shipping { + &:before { + content: $tgico-shipping; + } +} .tgico-shuffle { &:before { content: $tgico-shuffle; diff --git a/src/scss/tgico/_variables.scss b/src/scss/tgico/_variables.scss index 8dcc0ac3..7731ed19 100644 --- a/src/scss/tgico/_variables.scss +++ b/src/scss/tgico/_variables.scss @@ -25,165 +25,168 @@ $tgico-calendarfilter: "\e917"; $tgico-camera: "\e918"; $tgico-cameraadd: "\e919"; $tgico-car: "\e91a"; -$tgico-channel: "\e91b"; -$tgico-channelviews: "\e91c"; -$tgico-chatspinned: "\e91d"; -$tgico-chatsplaceholder: "\e91e"; -$tgico-check1: "\e91f"; -$tgico-checkbox: "\e920"; -$tgico-checkboxblock: "\e921"; -$tgico-checkboxempty: "\e922"; -$tgico-checkboxon: "\e923"; -$tgico-checkretract: "\e924"; -$tgico-checkround: "\e925"; -$tgico-close: "\e926"; -$tgico-clouddownload: "\e927"; -$tgico-colorize: "\e928"; -$tgico-comments: "\e929"; -$tgico-commentssticker: "\e92a"; -$tgico-copy: "\e92b"; -$tgico-darkmode: "\e92c"; -$tgico-data: "\e92d"; -$tgico-delete: "\e92e"; -$tgico-delete_filled: "\e92f"; -$tgico-deletedaccount: "\e930"; -$tgico-deleteleft: "\e931"; -$tgico-deleteuser: "\e932"; -$tgico-devices: "\e933"; -$tgico-document: "\e934"; -$tgico-down: "\e935"; -$tgico-download: "\e936"; -$tgico-dragfiles: "\e937"; -$tgico-dragmedia: "\e938"; -$tgico-eats: "\e939"; -$tgico-edit: "\e93a"; -$tgico-email: "\e93b"; -$tgico-endcall_filled: "\e93c"; -$tgico-enter: "\e93d"; -$tgico-eye1: "\e93e"; -$tgico-eye2: "\e93f"; -$tgico-fast_forward: "\e940"; -$tgico-fast_rewind: "\e941"; -$tgico-favourites: "\e942"; -$tgico-flag: "\e943"; -$tgico-flip: "\e944"; -$tgico-folder: "\e945"; -$tgico-fontsize: "\e946"; -$tgico-forward: "\e947"; -$tgico-forward_filled: "\e948"; -$tgico-fullscreen: "\e949"; -$tgico-gc_microphone: "\e94a"; -$tgico-gc_microphoneoff: "\e94b"; -$tgico-gifs: "\e94c"; -$tgico-group: "\e94d"; -$tgico-help: "\e94e"; -$tgico-image: "\e94f"; -$tgico-info: "\e950"; -$tgico-info2: "\e951"; -$tgico-italic: "\e952"; -$tgico-keyboard: "\e953"; -$tgico-lamp: "\e954"; -$tgico-language: "\e955"; -$tgico-largepause: "\e956"; -$tgico-largeplay: "\e957"; -$tgico-left: "\e958"; -$tgico-link: "\e959"; -$tgico-listscreenshare: "\e95a"; -$tgico-livelocation: "\e95b"; -$tgico-location: "\e95c"; -$tgico-lock: "\e95d"; -$tgico-lockoff: "\e95e"; -$tgico-loginlogodesktop: "\e95f"; -$tgico-loginlogomobile: "\e960"; -$tgico-logout: "\e961"; -$tgico-mention: "\e962"; -$tgico-menu: "\e963"; -$tgico-message: "\e964"; -$tgico-messageunread: "\e965"; -$tgico-microphone: "\e966"; -$tgico-microphone_crossed: "\e967"; -$tgico-microphone_crossed_filled: "\e968"; -$tgico-microphone_filled: "\e969"; -$tgico-minus: "\e96a"; -$tgico-monospace: "\e96b"; -$tgico-more: "\e96c"; -$tgico-mute: "\e96d"; -$tgico-muted: "\e96e"; -$tgico-newchannel: "\e96f"; -$tgico-newchat_filled: "\e970"; -$tgico-newgroup: "\e971"; -$tgico-newprivate: "\e972"; -$tgico-next: "\e973"; -$tgico-noncontacts: "\e974"; -$tgico-nosound: "\e975"; -$tgico-passwordoff: "\e976"; -$tgico-pause: "\e977"; -$tgico-permissions: "\e978"; -$tgico-phone: "\e979"; -$tgico-pin: "\e97a"; -$tgico-pinlist: "\e97b"; -$tgico-pinned_filled: "\e97c"; -$tgico-pinnedchat: "\e97d"; -$tgico-pip: "\e97e"; -$tgico-play: "\e97f"; -$tgico-playback_05: "\e980"; -$tgico-playback_15: "\e981"; -$tgico-playback_1x: "\e982"; -$tgico-playback_2x: "\e983"; -$tgico-plus: "\e984"; -$tgico-poll: "\e985"; -$tgico-previous: "\e986"; -$tgico-radiooff: "\e987"; -$tgico-radioon: "\e988"; -$tgico-reactions: "\e989"; -$tgico-readchats: "\e98a"; -$tgico-recent: "\e98b"; -$tgico-replace: "\e98c"; -$tgico-reply: "\e98d"; -$tgico-reply_filled: "\e98e"; -$tgico-rightpanel: "\e98f"; -$tgico-rotate_left: "\e990"; -$tgico-rotate_right: "\e991"; -$tgico-saved: "\e992"; -$tgico-savedmessages: "\e993"; -$tgico-schedule: "\e994"; -$tgico-scheduled: "\e995"; -$tgico-search: "\e996"; -$tgico-select: "\e997"; -$tgico-send: "\e998"; -$tgico-send2: "\e999"; -$tgico-sending: "\e99a"; -$tgico-sendingerror: "\e99b"; -$tgico-settings: "\e99c"; -$tgico-settings_filled: "\e99d"; -$tgico-sharescreen_filled: "\e99e"; -$tgico-shuffle: "\e99f"; -$tgico-smallscreen: "\e9a0"; -$tgico-smile: "\e9a1"; -$tgico-spoiler: "\e9a2"; -$tgico-sport: "\e9a3"; -$tgico-stickers: "\e9a4"; -$tgico-stop: "\e9a5"; -$tgico-strikethrough: "\e9a6"; -$tgico-textedit: "\e9a7"; -$tgico-tip: "\e9a8"; -$tgico-tools: "\e9a9"; -$tgico-unarchive: "\e9aa"; -$tgico-underline: "\e9ab"; -$tgico-unmute: "\e9ac"; -$tgico-unpin: "\e9ad"; -$tgico-unread: "\e9ae"; -$tgico-up: "\e9af"; -$tgico-user: "\e9b0"; -$tgico-username: "\e9b1"; -$tgico-videocamera: "\e9b2"; -$tgico-videocamera_crossed_filled: "\e9b3"; -$tgico-videocamera_filled: "\e9b4"; -$tgico-videochat: "\e9b5"; -$tgico-volume_down: "\e9b6"; -$tgico-volume_mute: "\e9b7"; -$tgico-volume_off: "\e9b8"; -$tgico-volume_up: "\e9b9"; -$tgico-zoomin: "\e9ba"; -$tgico-zoomout: "\e9bb"; +$tgico-card: "\e91b"; +$tgico-card_outline: "\e91c"; +$tgico-channel: "\e91d"; +$tgico-channelviews: "\e91e"; +$tgico-chatspinned: "\e91f"; +$tgico-chatsplaceholder: "\e920"; +$tgico-check1: "\e921"; +$tgico-checkbox: "\e922"; +$tgico-checkboxblock: "\e923"; +$tgico-checkboxempty: "\e924"; +$tgico-checkboxon: "\e925"; +$tgico-checkretract: "\e926"; +$tgico-checkround: "\e927"; +$tgico-close: "\e928"; +$tgico-clouddownload: "\e929"; +$tgico-colorize: "\e92a"; +$tgico-comments: "\e92b"; +$tgico-commentssticker: "\e92c"; +$tgico-copy: "\e92d"; +$tgico-darkmode: "\e92e"; +$tgico-data: "\e92f"; +$tgico-delete: "\e930"; +$tgico-delete_filled: "\e931"; +$tgico-deletedaccount: "\e932"; +$tgico-deleteleft: "\e933"; +$tgico-deleteuser: "\e934"; +$tgico-devices: "\e935"; +$tgico-document: "\e936"; +$tgico-down: "\e937"; +$tgico-download: "\e938"; +$tgico-dragfiles: "\e939"; +$tgico-dragmedia: "\e93a"; +$tgico-eats: "\e93b"; +$tgico-edit: "\e93c"; +$tgico-email: "\e93d"; +$tgico-endcall_filled: "\e93e"; +$tgico-enter: "\e93f"; +$tgico-eye1: "\e940"; +$tgico-eye2: "\e941"; +$tgico-fast_forward: "\e942"; +$tgico-fast_rewind: "\e943"; +$tgico-favourites: "\e944"; +$tgico-flag: "\e945"; +$tgico-flip: "\e946"; +$tgico-folder: "\e947"; +$tgico-fontsize: "\e948"; +$tgico-forward: "\e949"; +$tgico-forward_filled: "\e94a"; +$tgico-fullscreen: "\e94b"; +$tgico-gc_microphone: "\e94c"; +$tgico-gc_microphoneoff: "\e94d"; +$tgico-gifs: "\e94e"; +$tgico-group: "\e94f"; +$tgico-help: "\e950"; +$tgico-image: "\e951"; +$tgico-info: "\e952"; +$tgico-info2: "\e953"; +$tgico-italic: "\e954"; +$tgico-keyboard: "\e955"; +$tgico-lamp: "\e956"; +$tgico-language: "\e957"; +$tgico-largepause: "\e958"; +$tgico-largeplay: "\e959"; +$tgico-left: "\e95a"; +$tgico-link: "\e95b"; +$tgico-listscreenshare: "\e95c"; +$tgico-livelocation: "\e95d"; +$tgico-location: "\e95e"; +$tgico-lock: "\e95f"; +$tgico-lockoff: "\e960"; +$tgico-loginlogodesktop: "\e961"; +$tgico-loginlogomobile: "\e962"; +$tgico-logout: "\e963"; +$tgico-mention: "\e964"; +$tgico-menu: "\e965"; +$tgico-message: "\e966"; +$tgico-messageunread: "\e967"; +$tgico-microphone: "\e968"; +$tgico-microphone_crossed: "\e969"; +$tgico-microphone_crossed_filled: "\e96a"; +$tgico-microphone_filled: "\e96b"; +$tgico-minus: "\e96c"; +$tgico-monospace: "\e96d"; +$tgico-more: "\e96e"; +$tgico-mute: "\e96f"; +$tgico-muted: "\e970"; +$tgico-newchannel: "\e971"; +$tgico-newchat_filled: "\e972"; +$tgico-newgroup: "\e973"; +$tgico-newprivate: "\e974"; +$tgico-next: "\e975"; +$tgico-noncontacts: "\e976"; +$tgico-nosound: "\e977"; +$tgico-passwordoff: "\e978"; +$tgico-pause: "\e979"; +$tgico-permissions: "\e97a"; +$tgico-phone: "\e97b"; +$tgico-pin: "\e97c"; +$tgico-pinlist: "\e97d"; +$tgico-pinned_filled: "\e97e"; +$tgico-pinnedchat: "\e97f"; +$tgico-pip: "\e980"; +$tgico-play: "\e981"; +$tgico-playback_05: "\e982"; +$tgico-playback_15: "\e983"; +$tgico-playback_1x: "\e984"; +$tgico-playback_2x: "\e985"; +$tgico-plus: "\e986"; +$tgico-poll: "\e987"; +$tgico-previous: "\e988"; +$tgico-radiooff: "\e989"; +$tgico-radioon: "\e98a"; +$tgico-reactions: "\e98b"; +$tgico-readchats: "\e98c"; +$tgico-recent: "\e98d"; +$tgico-replace: "\e98e"; +$tgico-reply: "\e98f"; +$tgico-reply_filled: "\e990"; +$tgico-rightpanel: "\e991"; +$tgico-rotate_left: "\e992"; +$tgico-rotate_right: "\e993"; +$tgico-saved: "\e994"; +$tgico-savedmessages: "\e995"; +$tgico-schedule: "\e996"; +$tgico-scheduled: "\e997"; +$tgico-search: "\e998"; +$tgico-select: "\e999"; +$tgico-send: "\e99a"; +$tgico-send2: "\e99b"; +$tgico-sending: "\e99c"; +$tgico-sendingerror: "\e99d"; +$tgico-settings: "\e99e"; +$tgico-settings_filled: "\e99f"; +$tgico-sharescreen_filled: "\e9a0"; +$tgico-shipping: "\e9a1"; +$tgico-shuffle: "\e9a2"; +$tgico-smallscreen: "\e9a3"; +$tgico-smile: "\e9a4"; +$tgico-spoiler: "\e9a5"; +$tgico-sport: "\e9a6"; +$tgico-stickers: "\e9a7"; +$tgico-stop: "\e9a8"; +$tgico-strikethrough: "\e9a9"; +$tgico-textedit: "\e9aa"; +$tgico-tip: "\e9ab"; +$tgico-tools: "\e9ac"; +$tgico-unarchive: "\e9ad"; +$tgico-underline: "\e9ae"; +$tgico-unmute: "\e9af"; +$tgico-unpin: "\e9b0"; +$tgico-unread: "\e9b1"; +$tgico-up: "\e9b2"; +$tgico-user: "\e9b3"; +$tgico-username: "\e9b4"; +$tgico-videocamera: "\e9b5"; +$tgico-videocamera_crossed_filled: "\e9b6"; +$tgico-videocamera_filled: "\e9b7"; +$tgico-videochat: "\e9b8"; +$tgico-volume_down: "\e9b9"; +$tgico-volume_mute: "\e9ba"; +$tgico-volume_off: "\e9bb"; +$tgico-volume_up: "\e9bc"; +$tgico-zoomin: "\e9bd"; +$tgico-zoomout: "\e9be"; diff --git a/src/tests/cards.test.ts b/src/tests/cards.test.ts new file mode 100644 index 00000000..6a68014b --- /dev/null +++ b/src/tests/cards.test.ts @@ -0,0 +1,59 @@ +import cardFormattingPatterns from "../helpers/cards/cardFormattingPatterns"; +import formatValueByPattern from "../helpers/cards/formatValueByPattern"; +import { validateCardExpiry, validateCardNumber } from "../helpers/cards/validateCard"; + +describe('Card number', () => { + test('Format', () => { + const data = [ + ['4242424242424242', '4242 4242 4242 4242'], + ['371758885524003', '3717 588855 24003'] + ]; + + data.forEach(([plain, formatted]) => { + const result = formatValueByPattern(cardFormattingPatterns.cardNumber, plain); + expect(result.value).toEqual(formatted); + }); + }); + + test('Validate', () => { + const data = [ + ['4242424242424242', null], + ['4242424242424241', 'invalid'], + ['424242424242424', 'incomplete'] + ]; + + data.forEach(([cardNumber, code]) => { + const result = validateCardNumber(cardNumber); + if(code) { + expect(result.code).toEqual(code); + } else { + expect(result).toEqual(null); + } + }); + }); +}); + +describe('Expiry date', () => { + const joiner = '/'; + const getExpiryDate = (date: Date) => `${date.getMonth()}${joiner}${date.getFullYear() % 100}`; + + test('Format', () => { + const month = 10; + const year = 20; + + const {value} = formatValueByPattern(cardFormattingPatterns.cardExpiry, `${month}${year}`); + expect(value).toEqual(`${month}${joiner}${year}`); + }); + + test('Expired', () => { + const date = new Date(); + date.setMonth(date.getMonth() - 1); + expect(validateCardExpiry(getExpiryDate(date))).toBeTruthy(); + }); + + test('Nonexpired', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + expect(validateCardExpiry(getExpiryDate(date))).toEqual(null); + }); +}); diff --git a/src/types.d.ts b/src/types.d.ts index 4785fcb0..51446fff 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -120,3 +120,28 @@ export type SendMessageEmojiInteractionData = { a: {t: number, i: 1}[], v: 1 }; + +/** + * @link https://core.telegram.org/api/web-events#postmessage-api + */ + export type TelegramWebviewEventMap = { + payment_form_submit: { + credentials: any, + title: string + }, + web_app_open_tg_link: { + path_full: string // '/username' + } +}; + +export type TelegramWebviewSerializedEvent = { + eventType: T, + eventData: TelegramWebviewEventMap[T] +}; + +export type TelegramWebviewSerializedEvents = { + [type in keyof TelegramWebviewEventMap]: TelegramWebviewSerializedEvent +}; + +export type TelegramWebviewEvent = TelegramWebviewSerializedEvents[keyof TelegramWebviewEventMap]; +export type TelegramWebviewEventCallback = (event: TelegramWebviewEvent) => void;