Telegram-iOS/submodules/TelegramUI/Sources/Chat/ChatControllerOpenLinkContextMenu.swift
2026-06-02 13:31:23 +02:00

422 lines
19 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import BrowserUI
import ChatMessageItemView
import ChatMessageItemCommon
import MessageUI
import ChatControllerInteraction
import TelegramUIPreferences
import UrlEscaping
import UrlWhitelist
import OpenInExternalAppUI
import SafariServices
private struct ChatLinkOpenMode {
let shouldOpenInApp: Bool
}
private enum ChatLinkReverseOpenTarget {
case inApp
case externalBrowser
var checkboxTitle: String {
switch self {
case .inApp:
return "Always open this site in-app"
case .externalBrowser:
return "Always open this site in browser"
}
}
var openExternalBrowser: Bool {
switch self {
case .inApp:
return false
case .externalBrowser:
return true
}
}
}
private func chatLinkContextMenuCanonicalUrl(from url: String) -> URL? {
var urlWithScheme = url
if !url.contains("://") && !url.hasPrefix("mailto:") {
urlWithScheme = "http://" + url
}
if let parsed = URL(string: urlWithScheme) {
return parsed
} else if let encoded = (urlWithScheme as NSString).addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) {
return URL(string: encoded)
}
return nil
}
private func chatLinkContextMenuOpenMode(context: AccountContext, url: String) -> Signal<ChatLinkOpenMode?, NoError> {
let lowercasedUrl = url.lowercased()
if lowercasedUrl.hasPrefix("mailto:") || lowercasedUrl.hasPrefix("tel:") || lowercasedUrl.hasPrefix("calshow:") {
return .single(nil)
}
guard let parsedUrl = chatLinkContextMenuCanonicalUrl(from: url) else {
return .single(nil)
}
let scheme = (parsedUrl.scheme ?? "").lowercased()
guard scheme == "http" || scheme == "https" || scheme == "tonsite" else {
return .single(nil)
}
let host = parsedUrl.host?.lowercased() ?? ""
if host.isEmpty {
return .single(nil)
}
if host == "t.me" || host == "telegram.me" || host == "telegram.dog" {
return .single(nil)
}
if host.hasSuffix(".ton") || scheme.hasPrefix("tonsite") {
return .single(nil)
}
return context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.webBrowserSettings))
|> take(1)
|> map { accountSettingsEntry -> ChatLinkOpenMode? in
let accountSettings = accountSettingsEntry?.get(AccountWebBrowserSettings.self) ?? AccountWebBrowserSettings.defaultSettings
let normalizedHost = ".\(host)"
let exceptions = accountSettings.openExternalBrowser ? accountSettings.inAppExceptions : accountSettings.externalExceptions
var isExceptedDomain = false
for exception in exceptions {
if normalizedHost.hasSuffix(".\(exception.domain.lowercased())") {
isExceptedDomain = true
break
}
}
let shouldOpenInApp: Bool
if accountSettings.openExternalBrowser {
shouldOpenInApp = isExceptedDomain
} else {
shouldOpenInApp = !isExceptedDomain
}
return ChatLinkOpenMode(shouldOpenInApp: shouldOpenInApp)
}
}
extension ChatControllerImpl {
private func presentOpenLinkConfirmation(_ url: String, target: ChatLinkReverseOpenTarget) {
var exceptionAdded = false
let disposable = self.context.sharedContext.openUserGeneratedUrl(
context: self.context,
peerId: self.contentData?.state.peerView?.peerId,
url: url,
webpage: nil,
concealed: false,
forceConcealed: true,
skipUrlAuth: false,
skipConcealedAlert: false,
forceDark: false,
present: { [weak self] c in
self?.present(c, in: .window(.root))
},
openResolved: { [weak self] result in
guard let self else {
return
}
switch target {
case .inApp:
if case let .externalUrl(resolvedUrl) = result, let navigationController = self.effectiveNavigationController {
self.chatDisplayNode.dismissInput()
let controller = BrowserScreen(context: self.context, subject: .webPage(url: resolvedUrl))
navigationController.pushViewController(controller)
if exceptionAdded {
Queue.mainQueue().after(0.5) {
let tooltipScreen = UndoOverlayController(
presentationData: self.presentationData,
content: .actionSucceeded(title: "Exception Added", text: "This site will always open in-app.", cancel: "", destructive: false),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in
return false
}
)
controller.present(tooltipScreen, in: .current)
}
}
} else {
self.openResolved(result: result, sourceMessageId: nil, forceExternal: false, concealed: true)
}
case .externalBrowser:
if case .externalUrl = result {
let _ = (self.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.webBrowserSettings])
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] sharedData in
guard let self else {
return
}
self.chatDisplayNode.dismissInput()
let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.webBrowserSettings]?.get(WebBrowserSettings.self) ?? WebBrowserSettings.defaultSettings
var defaultWebBrowser = settings.defaultWebBrowser
if defaultWebBrowser == nil || defaultWebBrowser == "inApp" || defaultWebBrowser == "inAppSafari" {
defaultWebBrowser = "safari"
}
let targetUrl = chatLinkContextMenuCanonicalUrl(from: url)?.absoluteString ?? url
let openInOptions = availableOpenInOptions(context: self.context, item: .url(url: targetUrl))
if let option = openInOptions.first(where: { $0.identifier == defaultWebBrowser }) {
if case let .openUrl(openInUrl) = option.action() {
self.context.sharedContext.applicationBindings.openUrl(openInUrl)
} else {
self.context.sharedContext.applicationBindings.openUrl(targetUrl)
}
} else {
self.context.sharedContext.applicationBindings.openUrl(targetUrl)
}
})
} else {
self.openResolved(result: result, sourceMessageId: nil, forceExternal: true, concealed: false)
}
}
},
progress: nil,
alertDisplayUpdated: nil,
concealedAlertOption: OpenUserGeneratedUrlConcealedAlertOption(title: target.checkboxTitle, action: { [weak self] in
guard let self else {
return
}
let _ = toggleWebBrowserSettingsException(
postbox: self.context.account.postbox,
network: self.context.account.network,
openExternalBrowser: target.openExternalBrowser,
delete: false,
url: url
).startStandalone()
exceptionAdded = true
})
)
self.navigationActionDisposable.set(disposable)
}
func openLinkContextMenu(url: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
var (cleanUrl, _) = parseUrl(url: url, wasConcealed: false)
var canAddToReadingList = true
let mailtoString = "mailto:"
let telString = "tel:"
var openText = self.presentationData.strings.Conversation_LinkDialogOpen
var phoneNumber: String?
var isPhoneNumber = false
var isEmail = false
var hasOpenAction = true
if cleanUrl.hasPrefix(mailtoString) {
canAddToReadingList = false
cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])
isEmail = true
} else if cleanUrl.hasPrefix(telString) {
canAddToReadingList = false
phoneNumber = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...])
cleanUrl = phoneNumber!
openText = self.presentationData.strings.UserInfo_PhoneCall
isPhoneNumber = true
if cleanUrl.hasPrefix("+888") {
hasOpenAction = false
}
}
let _ = (chatLinkContextMenuOpenMode(context: self.context, url: url)
|> deliverOnMainQueue).startStandalone(next: { [weak self] openMode in
guard let self else {
return
}
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
items.append(ActionSheetTextItem(title: cleanUrl))
if hasOpenAction {
items.append(ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
self?.openUrl(url, concealed: false)
}))
if let openMode {
let reverseText = openMode.shouldOpenInApp ? self.presentationData.strings.Chat_ContextMenu_OpenInBrowser : self.presentationData.strings.Chat_ContextMenu_OpenInApp
items.append(ActionSheetButtonItem(title: reverseText, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self else {
return
}
if openMode.shouldOpenInApp {
self.presentOpenLinkConfirmation(url, target: .externalBrowser)
} else {
self.presentOpenLinkConfirmation(url, target: .inApp)
}
}))
}
}
if let phoneNumber = phoneNumber {
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddContact, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.controllerInteraction?.addContact(phoneNumber)
}
}))
}
items.append(ActionSheetButtonItem(title: canAddToReadingList ? self.presentationData.strings.ShareMenu_CopyShareLink : self.presentationData.strings.Conversation_ContextMenuCopy, color: .accent, action: { [weak actionSheet, weak self] in
actionSheet?.dismissAnimated()
guard let self else {
return
}
UIPasteboard.general.string = cleanUrl
let content: UndoOverlayContent
if isPhoneNumber {
content = .copy(text: self.presentationData.strings.Conversation_PhoneCopied)
} else if isEmail {
content = .copy(text: self.presentationData.strings.Conversation_EmailCopied)
} else if canAddToReadingList {
content = .linkCopied(title: nil, text: self.presentationData.strings.Conversation_LinkCopied)
} else {
content = .copy(text: self.presentationData.strings.Conversation_TextCopied)
}
self.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
if canAddToReadingList {
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
if let link = URL(string: url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.chatDisplayNode.dismissInput()
self.present(actionSheet, in: .window(.root))
})
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
var (cleanUrl, _) = parseUrl(url: url, wasConcealed: false)
var canAddToReadingList = true
var isEmail = false
let mailtoString = "mailto:"
let openText = self.presentationData.strings.Conversation_LinkDialogOpen
var copyText = self.presentationData.strings.Conversation_ContextMenuCopyLink
if cleanUrl.hasPrefix(mailtoString) {
canAddToReadingList = false
cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])
copyText = self.presentationData.strings.Conversation_ContextMenuCopyEmail
isEmail = true
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = params.gesture
let gesture: ContextGesture? = nil
let source: ContextContentSource = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
let itemsSignal = chatLinkContextMenuOpenMode(context: self.context, url: url)
|> deliverOnMainQueue
|> map { [weak self] openMode -> ContextController.Items in
guard let self else {
return ContextController.Items(content: .list([]))
}
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: openText, icon: { theme in return generateTintedImage(image: openMode?.shouldOpenInApp == true ? UIImage(bundleImageName: "Chat/Context Menu/Browser") : UIImage(bundleImageName: "Chat/Context Menu/Globe"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openUrl(url, concealed: false)
}))
)
if let openMode {
let reverseText = openMode.shouldOpenInApp ? "Open in Browser" : "Open In-App"
items.append(
.action(ContextMenuActionItem(text: reverseText, icon: { theme in return generateTintedImage(image: openMode.shouldOpenInApp ? UIImage(bundleImageName: "Chat/Context Menu/Globe") : UIImage(bundleImageName: "Chat/Context Menu/Browser"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
if openMode.shouldOpenInApp {
self.presentOpenLinkConfirmation(url, target: .externalBrowser)
} else {
self.presentOpenLinkConfirmation(url, target: .inApp)
}
}))
)
}
items.append(
.action(ContextMenuActionItem(text: copyText, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = cleanUrl
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: isEmail ? presentationData.strings.Conversation_EmailCopied : presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
if canAddToReadingList {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_AddToReadingList, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReadingList"), color: theme.contextMenu.primaryColor) }, action: { _, f in
f(.default)
if let link = URL(string: url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
}))
)
}
return ContextController.Items(content: .list(items))
}
self.canReadHistory.set(false)
let controller = makeContextController(presentationData: self.presentationData, source: source, items: itemsSignal, recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
}
}