mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
422 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|