diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 7ff34e13325..3482d7555af 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -349,12 +349,16 @@ public struct ChatControllerInitialBotAppStart { public let payload: String? public let justInstalled: Bool public let mode: ResolvedStartAppMode - - public init(botApp: BotApp?, payload: String?, justInstalled: Bool, mode: ResolvedStartAppMode) { + // The `start` payload the link carried alongside `startapp`, used for the chat's Start button + // if the Mini App launch confirmation is dismissed. + public let botStartPayload: String? + + public init(botApp: BotApp?, payload: String?, justInstalled: Bool, mode: ResolvedStartAppMode, botStartPayload: String? = nil) { self.botApp = botApp self.payload = payload self.justInstalled = justInstalled self.mode = mode + self.botStartPayload = botStartPayload } } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift index c3bf1880d2f..182de9bd6bd 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift @@ -28,7 +28,8 @@ func openWebAppImpl( source: ChatOpenWebViewSource, skipTermsOfService: Bool, payload: String?, - verifyAgeCompletion: ((Int) -> Void)? + verifyAgeCompletion: ((Int) -> Void)?, + launchDismissedWithoutConfirmation: (() -> Void)? = nil ) { if context.isFrozen { parentController.push(context.sharedContext.makeAccountFreezeInfoScreen(context: context)) @@ -386,6 +387,11 @@ func openWebAppImpl( let controller = webAppLaunchConfirmationController(context: context, updatedPresentationData: updatedPresentationData, peer: botPeer, completion: { _ in let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone() openWebView(false) + }, dismissedWithoutConfirmation: { + // The title panel's progress state is set before the confirmation is presented, and is + // otherwise only cleared once the web view request settles — which never happens here. + updateProgress() + launchDismissedWithoutConfirmation?() }, showMore: nil, openTerms: { if let navigationController = parentController.navigationController as? NavigationController { context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.WebApp_LaunchTermsConfirmation_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) @@ -673,7 +679,7 @@ public extension ChatControllerImpl { navigationController = main } if case let .peer(peer, navigation) = result, case let .withBotApp(botApp) = navigation, let botPeer = peer.flatMap(EnginePeer.init), let parentController = navigationController?.viewControllers.last as? ViewController { - self.presentBotApp(context: context, parentController: parentController, botApp: botApp.botApp, botPeer: botPeer, payload: botApp.payload, mode: botApp.mode) + self.presentBotApp(context: context, parentController: parentController, botApp: botApp.botApp, botPeer: botPeer, payload: botApp.payload, mode: botApp.mode, botStartPayload: botApp.botStartPayload) } else { context.sharedContext.openResolvedUrl(result, context: context, urlContext: .generic, navigationController: navigationController, forceExternal: false, forceUpdate: forceUpdate, openPeer: { peer, navigation in if let navigationController { @@ -691,11 +697,25 @@ public extension ChatControllerImpl { } } - func presentBotApp(botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, concealed: Bool = false, commit: @escaping () -> Void = {}) { - ChatControllerImpl.presentBotApp(context: self.context, parentController: self, botApp: botApp, botPeer: botPeer, payload: payload, mode: mode, concealed: concealed, commit: commit) + // Leaves the `start` payload that a link carried alongside `startapp` on the chat's Start button, + // so that dismissing the Mini App launch confirmation does not discard it. + internal func applyBotStartPayloadFallback(botPeerId: EnginePeer.Id, payload: String?) { + guard let payload, !payload.isEmpty else { + return + } + guard self.chatLocation.peerId == botPeerId else { + return + } + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + return state.updatedBotStartPayload(payload) + }) } - - fileprivate static func presentBotApp(context: AccountContext, parentController: ViewController, botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, concealed: Bool = false, commit: @escaping () -> Void = {}) { + + func presentBotApp(botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, botStartPayload: String? = nil, concealed: Bool = false, commit: @escaping () -> Void = {}) { + ChatControllerImpl.presentBotApp(context: self.context, parentController: self, botApp: botApp, botPeer: botPeer, payload: payload, mode: mode, botStartPayload: botStartPayload, concealed: concealed, commit: commit) + } + + fileprivate static func presentBotApp(context: AccountContext, parentController: ViewController, botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, botStartPayload: String? = nil, concealed: Bool = false, commit: @escaping () -> Void = {}) { let chatController = parentController as? ChatControllerImpl let peerId: EnginePeer.Id let threadId = chatController?.chatLocation.threadId @@ -704,7 +724,13 @@ public extension ChatControllerImpl { } else { peerId = botPeer.id } - + + // If the link carried a `start` payload alongside `startapp` and the user dismisses the Mini App + // launch confirmation, leave that payload on the chat's Start button instead of a bare /start. + let applyBotStartPayloadFallback: () -> Void = { [weak chatController] in + chatController?.applyBotStartPayloadFallback(botPeerId: botPeer.id, payload: botStartPayload) + } + var skipTermsOfService = false if let whiteListedBots = context.currentAppConfiguration.with({ $0 }).data?["whitelisted_bots"] as? [Double] { let botId = botPeer.id.id._internalGetInt64Value() @@ -850,6 +876,8 @@ public extension ChatControllerImpl { let controller = webAppLaunchConfirmationController(context: context, updatedPresentationData: updatedPresentationData, peer: botPeer, requestWriteAccess: botApp.flags.contains(.notActivated) && botApp.flags.contains(.requiresWriteAccess), completion: { allowWrite in let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone() openBotApp(allowWrite, false, appSettings) + }, dismissedWithoutConfirmation: { + applyBotStartPayloadFallback() }, showMore: chatController == nil ? nil : { [weak chatController] in if let chatController { chatController.openResolved(result: .peer(botPeer._asPeer(), .info(nil)), sourceMessageId: nil) @@ -865,7 +893,9 @@ public extension ChatControllerImpl { } }) } else { - context.sharedContext.openWebApp( + // Called directly rather than through SharedAccountContext.openWebApp (a plain forwarder to this + // function) so that the module-internal dismissal callback can be passed. + openWebAppImpl( context: context, parentController: parentController, updatedPresentationData: updatedPresentationData, @@ -878,7 +908,10 @@ public extension ChatControllerImpl { source: .generic, skipTermsOfService: false, payload: payload, - verifyAgeCompletion: nil + verifyAgeCompletion: nil, + launchDismissedWithoutConfirmation: { + applyBotStartPayloadFallback() + } ) } } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 23695c21df0..a18c74dfd33 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -9810,12 +9810,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } if let botApp = botAppStart.botApp { - self.presentBotApp(botApp: botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode, concealed: concealed, commit: { + self.presentBotApp(botApp: botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode, botStartPayload: botAppStart.botStartPayload, concealed: concealed, commit: { dismissWebAppControllers() commit() }) } else { - self.context.sharedContext.openWebApp( + // Called directly rather than through SharedAccountContext.openWebApp (a plain forwarder to + // this function) so that the module-internal dismissal callback can be passed. + openWebAppImpl( context: self.context, parentController: self, updatedPresentationData: self.updatedPresentationData, @@ -9828,7 +9830,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G source: .generic, skipTermsOfService: false, payload: botAppStart.payload, - verifyAgeCompletion: nil + verifyAgeCompletion: nil, + launchDismissedWithoutConfirmation: { [weak self] in + self?.applyBotStartPayloadFallback(botPeerId: peer.id, payload: botAppStart.botStartPayload) + } ) commit() } diff --git a/submodules/TelegramUI/Sources/NavigateToChatController.swift b/submodules/TelegramUI/Sources/NavigateToChatController.swift index 4a2555811b2..6ba0616abbf 100644 --- a/submodules/TelegramUI/Sources/NavigateToChatController.swift +++ b/submodules/TelegramUI/Sources/NavigateToChatController.swift @@ -223,7 +223,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam controller.presentAttachmentBot(botId: attachBotStart.botId, payload: attachBotStart.payload, justInstalled: attachBotStart.justInstalled) } if let botAppStart = params.botAppStart, case let .peer(peer) = params.chatLocation { - controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode) + controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode, botStartPayload: botAppStart.botStartPayload) } params.setupController(controller) found = true @@ -246,7 +246,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam } if let botAppStart = params.botAppStart, case let .peer(peer) = params.chatLocation { Queue.mainQueue().after(0.1) { - controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode) + controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode, botStartPayload: botAppStart.botStartPayload) } } @@ -260,7 +260,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam if let botAppStart = params.botAppStart, case let .peer(peer) = params.chatLocation { Queue.mainQueue().after(0.1) { - controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode) + controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode, botStartPayload: botAppStart.botStartPayload) } } } diff --git a/submodules/UrlHandling/Sources/UrlHandling.swift b/submodules/UrlHandling/Sources/UrlHandling.swift index 5a923565315..480df0d967d 100644 --- a/submodules/UrlHandling/Sources/UrlHandling.swift +++ b/submodules/UrlHandling/Sources/UrlHandling.swift @@ -104,7 +104,7 @@ public enum ParsedInternalPeerUrlParameter { case channelMessage(Int32, Double?) case replyThread(Int32, Int32) case voiceChat(String?) - case appStart(String, String?, ResolvedStartAppMode) + case appStart(String, String?, ResolvedStartAppMode, botStartPayload: String?) case story(Story) case boost case text(String) @@ -161,6 +161,47 @@ private enum ParsedUrl { case internalUrl(ParsedInternalUrl) } +private func startParameterReferrerPrefix(context: AccountContext?) -> String { + var linkRefPrefix = "_tgr_" + if let context { + if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["starref_start_param_prefixes"] as? String { + linkRefPrefix = value + } + } + return linkRefPrefix +} + +private func parsedStartAppMode(queryItems: [URLQueryItem]) -> ResolvedStartAppMode { + for queryItem in queryItems { + if queryItem.name == "mode", let value = queryItem.value { + switch value { + case "compact": + return .compact + case "fullscreen": + return .fullscreen + default: + return .generic + } + } + } + return .generic +} + +// A link may carry a `start` payload alongside `startapp`. The Mini App keeps priority, but the +// `start` payload is preserved so that the chat's Start button can carry it if the Mini App launch +// confirmation is dismissed. Referral payloads are not a bot start payload, so they are ignored here. +private func botStartPayloadFallback(queryItems: [URLQueryItem], context: AccountContext?) -> String? { + for queryItem in queryItems { + if queryItem.name == "start", let value = queryItem.value, !value.isEmpty { + if value.hasPrefix(startParameterReferrerPrefix(context: context)) { + return nil + } + return value + } + } + return nil +} + public func parseInternalUrl(sharedContext: SharedAccountContext, context: AccountContext?, query: String) -> ParsedInternalUrl? { var query = query if query.hasPrefix("s/") { @@ -330,15 +371,13 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } return .peer(.name(peerName), .attachBotStart(value, startAttach)) } else if queryItem.name == "start" { - var linkRefPrefix = "_tgr_" - if let context { - if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["starref_start_param_prefixes"] as? String { - linkRefPrefix = value - } - } + let linkRefPrefix = startParameterReferrerPrefix(context: context) if value.hasPrefix(linkRefPrefix) { let referrer = String(value[value.index(value.startIndex, offsetBy: linkRefPrefix.count)...]) return .peer(.name(peerName), .referrer(referrer)) + } else if let startAppItem = queryItems.first(where: { $0.name == "startapp" }) { + // Both parameters are present: the Mini App takes priority, `start` is kept as a fallback. + return .peer(.name(peerName), .appStart("", startAppItem.value, parsedStartAppMode(queryItems: queryItems), botStartPayload: value)) } else { return .peer(.name(peerName), .botStart(value)) } @@ -374,25 +413,8 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } return .startAttach(peerName, value, choose) } else if queryItem.name == "startapp" { - var mode: ResolvedStartAppMode = .generic - if let queryItems = components.queryItems { - for queryItem in queryItems { - if let value = queryItem.value { - if queryItem.name == "mode" { - switch value { - case "compact": - mode = .compact - case "fullscreen": - mode = .fullscreen - default: - break - } - break - } - } - } - } - return .peer(.name(peerName), .appStart("", queryItem.value, mode)) + let mode = parsedStartAppMode(queryItems: queryItems) + return .peer(.name(peerName), .appStart("", queryItem.value, mode, botStartPayload: botStartPayloadFallback(queryItems: queryItems, context: context))) } else if queryItem.name == "story" { if value == "live" { return .peer(.name(peerName), .story(.live)) @@ -442,25 +464,8 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } else if queryItem.name == "direct" { return .peer(.name(peerName), .direct) } else if queryItem.name == "startapp" { - var mode: ResolvedStartAppMode = .generic - if let queryItems = components.queryItems { - for queryItem in queryItems { - if let value = queryItem.value { - if queryItem.name == "mode" { - switch value { - case "compact": - mode = .compact - case "fullscreen": - mode = .fullscreen - default: - break - } - break - } - } - } - } - return .peer(.name(peerName), .appStart("", queryItem.value, mode)) + let mode = parsedStartAppMode(queryItems: queryItems) + return .peer(.name(peerName), .appStart("", queryItem.value, mode, botStartPayload: botStartPayloadFallback(queryItems: queryItems, context: context))) } else if queryItem.name == "ref", let referrer = queryItem.value { return .peer(.name(peerName), .referrer(referrer)) } @@ -788,6 +793,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou let appName = pathComponents[1] var startApp: String? var mode: ResolvedStartAppMode = .generic + var botStartPayload: String? if let queryItems = components.queryItems { for queryItem in queryItems { if let value = queryItem.value { @@ -806,8 +812,9 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } + botStartPayload = botStartPayloadFallback(queryItems: queryItems, context: context) } - return .peer(.name(peerName), .appStart(appName, startApp, mode)) + return .peer(.name(peerName), .appStart(appName, startApp, mode, botStartPayload: botStartPayload)) } else { return nil } @@ -958,12 +965,19 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) } } } - case let .appStart(name, payload, mode): + case let .appStart(name, payload, mode, botStartPayload): + // If the Mini App can not be launched at all, honour the `start` payload the link carried alongside it. + let appUnavailableResult: ResolveInternalUrlResult + if let botStartPayload { + appUnavailableResult = .result(.botStart(peer: peer._asPeer(), payload: botStartPayload)) + } else { + appUnavailableResult = .result(.peer(peer._asPeer(), .chat(textInputState: nil, subject: nil, peekData: nil))) + } if name.isEmpty { if case let .user(user) = peer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp) { - return .single(.result(.peer(peer._asPeer(), .withBotApp(ChatControllerInitialBotAppStart(botApp: nil, payload: payload, justInstalled: false, mode: mode))))) + return .single(.result(.peer(peer._asPeer(), .withBotApp(ChatControllerInitialBotAppStart(botApp: nil, payload: payload, justInstalled: false, mode: mode, botStartPayload: botStartPayload))))) } else { - return .single(.result(.peer(peer._asPeer(), .chat(textInputState: nil, subject: nil, peekData: nil)))) + return .single(appUnavailableResult) } } else { return .single(.progress) |> then(context.engine.messages.getBotApp(botId: peer.id, shortName: name, cached: false) @@ -973,9 +987,9 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) } |> mapToSignal { botApp -> Signal in if let botApp { - return .single(.result(.peer(peer._asPeer(), .withBotApp(ChatControllerInitialBotAppStart(botApp: botApp, payload: payload, justInstalled: false, mode: mode))))) + return .single(.result(.peer(peer._asPeer(), .withBotApp(ChatControllerInitialBotAppStart(botApp: botApp, payload: payload, justInstalled: false, mode: mode, botStartPayload: botStartPayload))))) } else { - return .single(.result(.peer(peer._asPeer(), .chat(textInputState: nil, subject: nil, peekData: nil)))) + return .single(appUnavailableResult) } }) } diff --git a/submodules/WebUI/Sources/WebAppLaunchConfirmationController.swift b/submodules/WebUI/Sources/WebAppLaunchConfirmationController.swift index 52338148429..3f914834e36 100644 --- a/submodules/WebUI/Sources/WebAppLaunchConfirmationController.swift +++ b/submodules/WebUI/Sources/WebAppLaunchConfirmationController.swift @@ -17,12 +17,17 @@ import MultilineTextComponent import BundleIconComponent import PlainButtonComponent +/// Presents the Mini App launch confirmation. +/// +/// - Parameter dismissedWithoutConfirmation: Called when the alert goes away without the launch having +/// been confirmed: Cancel, a tap outside, Esc, or a programmatic dismissal. public func webAppLaunchConfirmationController( context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, requestWriteAccess: Bool = false, completion: @escaping (Bool) -> Void, + dismissedWithoutConfirmation: (() -> Void)? = nil, showMore: (() -> Void)?, openTerms: @escaping () -> Void ) -> ViewController { @@ -53,17 +58,28 @@ public func webAppLaunchConfirmationController( )) } + // Set before `dismissed` fires: AlertScreen runs an action and only then auto-dismisses. Keep the + // launch action synchronous and auto-dismissing, or this flag stops distinguishing the two paths. + var didConfirm = false let alertController = AlertScreen( context: context, configuration: AlertScreen.Configuration(actionAlignment: .vertical), content: content, actions: [ .init(title: strings.WebApp_LaunchOpenApp, type: .default, action: { + didConfirm = true completion(requestWriteAccess && checkState.value) }), .init(title: strings.Common_Cancel) ] ) + if let dismissedWithoutConfirmation { + alertController.dismissed = { _ in + if !didConfirm { + dismissedWithoutConfirmation() + } + } + } return alertController }