diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 792f85ead7..041039351f 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14096,3 +14096,13 @@ Sorry for the inconvenience."; "Gift.Unpin.Unpin" = "Unpin"; "ChatList.Search.Ad" = "Ad"; + +"Login.Fee.Title" = "SMS Fee"; +"Login.Fee.SmsCost.Title" = "High SMS Costs"; +"Login.Fee.SmsCost.Text" = "Telecom providers in your country (%@) charge Telegram very high prices for SMS."; +"Login.Fee.Verification.Title" = "Verification Required"; +"Login.Fee.Verification.Text" = "Telegram needs to send you an SMS with a verification code to confirm your phone number."; +"Login.Fee.Support.Title" = "Support via [Telegram Premium >]()"; +"Login.Fee.Support.Text" = "Sign up for a 1-week Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.SignUp" = "Sign Up for %@"; +"Login.Fee.GetPremiumForAWeek" = "Get Telegram Premium for 1 week"; diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequenceCodeEntryControllerNode.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequenceCodeEntryControllerNode.swift index 87d557f96c..4139082470 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequenceCodeEntryControllerNode.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequenceCodeEntryControllerNode.swift @@ -848,7 +848,13 @@ final class AuthorizationSequenceCodeEntryControllerNode: ASDisplayNode, UITextF let pasteSize = self.pasteButton.measure(layout.size) let pasteButtonSize = CGSize(width: pasteSize.width + 16.0, height: 24.0) - transition.updateFrame(node: self.pasteButton, frame: CGRect(origin: CGPoint(x: layout.size.width - 40.0 - pasteButtonSize.width, y: self.textField.frame.midY - pasteButtonSize.height / 2.0), size: pasteButtonSize)) + let pasteOriginX: CGFloat + if case .compact = layout.metrics.widthClass { + pasteOriginX = layout.size.width - 40.0 - pasteButtonSize.width + } else { + pasteOriginX = self.textField.frame.maxX + 32.0 + } + transition.updateFrame(node: self.pasteButton, frame: CGRect(origin: CGPoint(x: pasteOriginX, y: self.textField.frame.midY - pasteButtonSize.height / 2.0), size: pasteButtonSize)) self.hintArrowNode.isHidden = true } else if case .word = codeType { self.hintButtonNode.alpha = 0.0 diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift index 51354a786d..49b9c5a1f9 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift @@ -198,7 +198,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { let titleSize = self.title.update( transition: transition, component: AnyComponent( - MultilineTextComponent(text: .plain(NSAttributedString(string: "SMS Fee", font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor))) + MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Login_Fee_Title, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor))) ), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) @@ -218,9 +218,9 @@ final class AuthorizationSequencePaymentScreenComponent: Component { AnyComponentWithIdentity( id: "cost", component: AnyComponent(ParagraphComponent( - title: "High SMS Costs", + title: environment.strings.Login_Fee_SmsCost_Title, titleColor: textColor, - text: "Telecom providers in your country (\(countryName)) charge Telegram very high prices for SMS.", + text: environment.strings.Login_Fee_SmsCost_Text(countryName).string, textColor: secondaryTextColor, iconName: "Premium/Authorization/Cost", iconColor: linkColor @@ -231,9 +231,9 @@ final class AuthorizationSequencePaymentScreenComponent: Component { AnyComponentWithIdentity( id: "verification", component: AnyComponent(ParagraphComponent( - title: "Verification Required", + title: environment.strings.Login_Fee_Verification_Title, titleColor: textColor, - text: "Telegram needs to send you an SMS with a verification code to confirm your phone number.", + text: environment.strings.Login_Fee_Verification_Text, textColor: secondaryTextColor, iconName: "Premium/Authorization/Verification", iconColor: linkColor @@ -242,11 +242,11 @@ final class AuthorizationSequencePaymentScreenComponent: Component { ) items.append( AnyComponentWithIdentity( - id: "withdrawal", + id: "support", component: AnyComponent(ParagraphComponent( - title: "Support via [Telegram Premium >]()", + title: environment.strings.Login_Fee_Support_Title, titleColor: textColor, - text: "Sign up for a 1-week Telegram Premium subscription to help cover the SMS costs.", + text: environment.strings.Login_Fee_Support_Text, textColor: secondaryTextColor, iconName: "Premium/Authorization/Support", iconColor: linkColor, @@ -315,7 +315,8 @@ final class AuthorizationSequencePaymentScreenComponent: Component { } else { priceString = "–" } - let buttonString = "Sign up for \(priceString)" + + let buttonString = environment.strings.Login_Fee_SignUp(priceString).string let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: environment.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) let buttonSize = self.button.update( transition: transition, @@ -331,7 +332,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { component: AnyComponent( VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))), - AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: "Get Telegram Premium for 1 week", font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center))))) + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Login_Fee_GetPremiumForAWeek, font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center))))) ], spacing: 1.0) ) ), diff --git a/submodules/TelegramUI/Components/Ads/AdsInfoScreen/Sources/AdsInfoScreen.swift b/submodules/TelegramUI/Components/Ads/AdsInfoScreen/Sources/AdsInfoScreen.swift index 0ad2cb1e8e..9cca0bfd92 100644 --- a/submodules/TelegramUI/Components/Ads/AdsInfoScreen/Sources/AdsInfoScreen.swift +++ b/submodules/TelegramUI/Components/Ads/AdsInfoScreen/Sources/AdsInfoScreen.swift @@ -300,6 +300,8 @@ private final class ScrollContent: CombinedComponent { horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, + highlightColor: linkColor.withAlphaComponent(0.1), + highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) @@ -598,6 +600,7 @@ private final class ParagraphComponent: CombinedComponent { horizontalAlignment: .natural, maximumNumberOfLines: 0, lineSpacing: 0.2, + highlightColor: accentColor.withAlphaComponent(0.1), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) @@ -949,6 +952,7 @@ public class AdsInfoScreen: ViewController { context: controller.context, theme: self.presentationData.theme, title: self.presentationData.strings.AdsInfo_Understood, + showBackground: controller.mode != .search, action: { [weak self] in guard let self else { return @@ -992,7 +996,7 @@ public class AdsInfoScreen: ViewController { } private var defaultTopInset: CGFloat { - guard let layout = self.currentLayout else { + guard let layout = self.currentLayout, let controller = self.controller else { return 210.0 } if case .compact = layout.metrics.widthClass { @@ -1006,9 +1010,13 @@ public class AdsInfoScreen: ViewController { let contentHeight = self.containerExternalState.contentHeight let footerHeight = self.footerHeight if contentHeight > 0.0 { - let delta = (layout.size.height - defaultTopInset - containerTopInset) - contentHeight - footerHeight - 16.0 - if delta > 0.0 { - defaultTopInset += delta + if case .search = controller.mode { + return (layout.size.height - containerTopInset) - contentHeight + } else { + let delta = (layout.size.height - defaultTopInset - containerTopInset) - contentHeight - footerHeight - 16.0 + if delta > 0.0 { + defaultTopInset += delta + } } } return defaultTopInset @@ -1029,7 +1037,7 @@ public class AdsInfoScreen: ViewController { } @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { - guard let layout = self.currentLayout else { + guard let layout = self.currentLayout, let controller = self.controller else { return } @@ -1064,6 +1072,9 @@ public class AdsInfoScreen: ViewController { let contentOffset = scrollView?.contentOffset.y ?? 0.0 var translation = recognizer.translation(in: self.view).y + if case .search = controller.mode { + translation = max(0.0, translation) + } var currentOffset = topInset + translation @@ -1111,9 +1122,13 @@ public class AdsInfoScreen: ViewController { let contentOffset = scrollView?.contentOffset.y ?? 0.0 - let translation = recognizer.translation(in: self.view).y + var translation = recognizer.translation(in: self.view).y var velocity = recognizer.velocity(in: self.view) - + if case .search = controller.mode { + translation = max(0.0, translation) + velocity.y = max(0.0, velocity.y) + } + if self.isExpanded { if contentOffset > 0.1 { velocity = CGPoint() @@ -1435,12 +1450,14 @@ private final class FooterComponent: Component { let context: AccountContext let theme: PresentationTheme let title: String + let showBackground: Bool let action: () -> Void - init(context: AccountContext, theme: PresentationTheme, title: String, action: @escaping () -> Void) { + init(context: AccountContext, theme: PresentationTheme, title: String, showBackground: Bool, action: @escaping () -> Void) { self.context = context self.theme = theme self.title = title + self.showBackground = showBackground self.action = action } @@ -1454,6 +1471,9 @@ private final class FooterComponent: Component { if lhs.title != rhs.title { return false } + if lhs.showBackground != rhs.showBackground { + return false + } return true } @@ -1494,6 +1514,9 @@ private final class FooterComponent: Component { self.separator.backgroundColor = component.theme.rootController.tabBar.separatorColor.cgColor transition.setFrame(layer: self.separator, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: UIScreenPixel))) + self.backgroundView.isHidden = !component.showBackground + self.separator.isHidden = !component.showBackground + let buttonSize = self.button.update( transition: .immediate, component: AnyComponent( diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift index 1131a335d7..8b4021c46c 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift @@ -1868,15 +1868,15 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { guard let self, let navigationController = self.navigationController as? NavigationController else { return } + self.dismissAnimated() + let _ = (context.engine.privacy.requestAccountPrivacySettings() - |> deliverOnMainQueue).start(next: { [weak self, weak navigationController] privacySettings in + |> deliverOnMainQueue).start(next: { [weak navigationController] privacySettings in let controller = context.sharedContext.makeIncomingMessagePrivacyScreen(context: context, value: privacySettings.globalSettings.nonContactChatsPrivacy, exceptions: privacySettings.noPaidMessages, update: { settingValue in let _ = context.engine.privacy.updateNonContactChatsPrivacy(value: settingValue).start() }) - navigationController?.pushViewController(controller) - - Queue.mainQueue().after(1.0) { - self?.dismissAnimated() + Queue.mainQueue().after(0.4) { + navigationController?.pushViewController(controller) } }) } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 894d749af3..781b913aa5 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1558,25 +1558,46 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.parentController()?.lockOrientation = lock } case "web_app_device_storage_save_key": - if let json, let requestId = json["req_id"] as? String, let key = json["key"] as? String, let value = json["value"] { - var effectiveValue: String? - if let stringValue = value as? String { - effectiveValue = stringValue + if let json, let requestId = json["req_id"] as? String { + if let key = json["key"] as? String { + let value = json["value"] + + var effectiveValue: String? + if let stringValue = value as? String { + effectiveValue = stringValue + } else if value is NSNull { + effectiveValue = nil + } else { + let data: JSON = [ + "req_id": requestId, + "error": "VALUE_INVALID" + ] + self.webView?.sendEvent(name: "device_storage_failed", data: data.string) + return + } + let _ = self.context.engine.peers.setBotStorageValue(peerId: controller.botId, key: key, value: effectiveValue).start(error: { [weak self] error in + var errorValue = "UNKNOWN_ERROR" + if case .quotaExceeded = error { + errorValue = "QUOTA_EXCEEDED" + } + let data: JSON = [ + "req_id": requestId, + "error": errorValue + ] + self?.webView?.sendEvent(name: "device_storage_failed", data: data.string) + }, completed: { [weak self] in + let data: JSON = [ + "req_id": requestId + ] + self?.webView?.sendEvent(name: "device_storage_key_saved", data: data.string) + }) } else { - effectiveValue = nil - } - let _ = self.context.engine.peers.setBotStorageValue(peerId: controller.botId, key: key, value: effectiveValue).start(error: { [weak self] _ in let data: JSON = [ "req_id": requestId, - "error": "UNKNOWN_ERROR" + "error": "KEY_INVALID" ] - self?.webView?.sendEvent(name: "device_storage_failed", data: data.string) - }, completed: { [weak self] in - let data: JSON = [ - "req_id": requestId - ] - self?.webView?.sendEvent(name: "device_storage_key_saved", data: data.string) - }) + self.webView?.sendEvent(name: "device_storage_failed", data: data.string) + } } case "web_app_device_storage_get_key": if let json, let requestId = json["req_id"] as? String { @@ -1607,6 +1628,78 @@ public final class WebAppController: ViewController, AttachmentContainable { self?.webView?.sendEvent(name: "device_storage_cleared", data: data.string) }) } + case "web_app_secure_storage_save_key": + if let json, let requestId = json["req_id"] as? String { + if let key = json["key"] as? String { + let value = json["value"] + + var effectiveValue: String? + if let stringValue = value as? String { + effectiveValue = stringValue + } else if value is NSNull { + effectiveValue = nil + } else { + let data: JSON = [ + "req_id": requestId, + "error": "VALUE_INVALID" + ] + self.webView?.sendEvent(name: "secure_storage_failed", data: data.string) + return + } + let _ = (WebAppSecureStorage.setValue(userId: self.context.account.peerId, botId: controller.botId, key: key, value: effectiveValue) + |> deliverOnMainQueue).start(error: { [weak self] error in + var errorValue = "UNKNOWN_ERROR" + if case .quotaExceeded = error { + errorValue = "QUOTA_EXCEEDED" + } + let data: JSON = [ + "req_id": requestId, + "error": errorValue + ] + self?.webView?.sendEvent(name: "secure_storage_failed", data: data.string) + }, completed: { [weak self] in + let data: JSON = [ + "req_id": requestId + ] + self?.webView?.sendEvent(name: "secure_storage_key_saved", data: data.string) + }) + } else { + let data: JSON = [ + "req_id": requestId, + "error": "KEY_INVALID" + ] + self.webView?.sendEvent(name: "secure_storage_failed", data: data.string) + } + } + case "web_app_secure_storage_get_key": + if let json, let requestId = json["req_id"] as? String { + if let key = json["key"] as? String { + let _ = (WebAppSecureStorage.getValue(userId: self.context.account.peerId, botId: controller.botId, key: key) + |> deliverOnMainQueue).start(next: { [weak self] value in + let data: JSON = [ + "req_id": requestId, + "value": value ?? NSNull() + ] + self?.webView?.sendEvent(name: "secure_storage_key_received", data: data.string) + }) + } else { + let data: JSON = [ + "req_id": requestId, + "error": "KEY_INVALID" + ] + self.webView?.sendEvent(name: "secure_storage_failed", data: data.string) + } + } + case "web_app_secure_storage_clear": + if let json, let requestId = json["req_id"] as? String { + let _ = (WebAppSecureStorage.clearStorage(userId: self.context.account.peerId, botId: controller.botId) + |> deliverOnMainQueue).start(completed: { [weak self] in + let data: JSON = [ + "req_id": requestId + ] + self?.webView?.sendEvent(name: "secure_storage_cleared", data: data.string) + }) + } default: break } diff --git a/submodules/WebUI/Sources/WebAppSecureStorage.swift b/submodules/WebUI/Sources/WebAppSecureStorage.swift new file mode 100644 index 0000000000..bc7d1eeb3d --- /dev/null +++ b/submodules/WebUI/Sources/WebAppSecureStorage.swift @@ -0,0 +1,147 @@ +import Foundation +import Security +import SwiftSignalKit +import TelegramCore + +final class WebAppSecureStorage { + enum Error { + case quotaExceeded + case unknown + } + + static private let maxKeyCount = 10 + + private init() { + } + + static private func keyPrefix(userId: EnginePeer.Id, botId: EnginePeer.Id) -> String { + return "A\(UInt64(bitPattern: userId.toInt64()))WebBot\(UInt64(bitPattern: botId.toInt64()))Key_" + } + + static private func makeQuery(userId: EnginePeer.Id, botId: EnginePeer.Id, key: String) -> [String: Any] { + let identifier = self.keyPrefix(userId: userId, botId: botId) + key + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: identifier, + kSecAttrService as String: "TMASecureStorage" + ] + } + + static private func countKeys(userId: EnginePeer.Id, botId: EnginePeer.Id) -> Int { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "TMASecureStorage", + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + let relevantPrefix = self.keyPrefix(userId: userId, botId: botId) + let count = items.filter { + if let account = $0[kSecAttrAccount as String] as? String { + return account.hasPrefix(relevantPrefix) + } + return false + }.count + return count + } + + return 0 + } + + static func setValue(userId: EnginePeer.Id, botId: EnginePeer.Id, key: String, value: String?) -> Signal { + var query = makeQuery(userId: userId, botId: botId, key: key) + if value == nil { + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + return .complete() + } else { + return .fail(.unknown) + } + } + + guard let valueData = value?.data(using: .utf8) else { + return .fail(.unknown) + } + + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + + let status = SecItemCopyMatching(query as CFDictionary, nil) + if status == errSecSuccess { + let updateQuery: [String: Any] = [ + kSecValueData as String: valueData + ] + let updateStatus = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary) + if updateStatus == errSecSuccess { + return .complete() + } else { + return .fail(.unknown) + } + } else if status == errSecItemNotFound { + let currentCount = countKeys(userId: userId, botId: botId) + if currentCount >= maxKeyCount { + return .fail(.quotaExceeded) + } + + query[kSecValueData as String] = valueData + + let createStatus = SecItemAdd(query as CFDictionary, nil) + if createStatus == errSecSuccess { + return .complete() + } else { + return .fail(.unknown) + } + } else { + return .fail(.unknown) + } + } + + static func getValue(userId: EnginePeer.Id, botId: EnginePeer.Id, key: String) -> Signal { + var query = makeQuery(userId: userId, botId: botId, key: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) { + return .single(value) + } else if status == errSecItemNotFound { + return .single(nil) + } else { + return .fail(.unknown) + } + } + + static func clearStorage(userId: EnginePeer.Id, botId: EnginePeer.Id) -> Signal { + let serviceQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "TMASecureStorage", + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(serviceQuery as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + let relevantPrefix = self.keyPrefix(userId: userId, botId: botId) + + for item in items { + if let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(relevantPrefix) { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecAttrService as String: "TMASecureStorage" + ] + + SecItemDelete(deleteQuery as CFDictionary) + } + } + } + return .complete() + } +}