diff --git a/V2er.xcodeproj/project.pbxproj b/V2er.xcodeproj/project.pbxproj index 0855afd..d06f9a2 100644 --- a/V2er.xcodeproj/project.pbxproj +++ b/V2er.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; }; 4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; }; 4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */; }; + 411D26B534F24940938F91F4 /* InAppBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934528345C354D5F9B11C816 /* InAppBrowserView.swift */; }; 54E545A18422D6843B082DBB /* MyUploadsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5D85D42DE8EAFC4D1D8577 /* MyUploadsState.swift */; }; 594E4B3FFA5E7AFD238CC1E3 /* RichView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64C3C5B4EEA7DB8198A04E9 /* RichView.swift */; }; 5D04BF9726C9FB6E0005F7E3 /* FeedInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */; }; @@ -88,6 +89,7 @@ 5D6871862718761800329E73 /* FeedbackHelperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6871852718761800329E73 /* FeedbackHelperView.swift */; }; 5D6871882718764E00329E73 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6871872718764E00329E73 /* AboutView.swift */; }; 5D6AAAAC2691851100F42A13 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6AAAAB2691851100F42A13 /* Utils.swift */; }; + E6F855004AFD402D95495FC4 /* URLRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E07C501559C4957B0DEF9D4 /* URLRouter.swift */; }; 5D6AAAAF2692036100F42A13 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6AAAAE2692036100F42A13 /* FeedItemView.swift */; }; 5D6B67B726D54BA1003A246B /* UA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6B67B626D54BA1003A246B /* UA.swift */; }; 5D6EE28B2693DC470053B637 /* FeedDetailPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6EE28A2693DC470053B637 /* FeedDetailPage.swift */; }; @@ -167,7 +169,8 @@ E212778C30ED41F39D51D70B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AB393F14CD383FE0EA98A9 /* MarkdownRenderer.swift */; }; E6BD52539035CEA6C56D3BDF /* HTMLToMarkdownConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C732E2652C92E5D553656A9 /* HTMLToMarkdownConverter.swift */; }; EC3A2A13EC68ED3A8DFA764A /* RenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3CC744901D9A994CF6ABE7 /* RenderError.swift */; }; - F090B4D9D3B115551BEF05B4 /* AsyncImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E707C08835B71223A7A3359 /* AsyncImageAttachment.swift */; }; + F090B4D9D3B115551BEF05B4 /* AsyncImageAttachment.swift in Sources */ = {isa = PBXBuildFile; + A1B2C3D4E5F60002 /* InAppBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60001 /* InAppBrowserView.swift */; }; fileRef = 8E707C08835B71223A7A3359 /* AsyncImageAttachment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -202,6 +205,7 @@ 42D7F9F8E0B5EA32CC951FD5 /* CodeBlockAttachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CodeBlockAttachment.swift; path = V2er/Sources/RichView/Models/CodeBlockAttachment.swift; sourceTree = ""; }; 44998D879D9842BBB61D639E /* MentionParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MentionParser.swift; path = V2er/Sources/RichView/Utils/MentionParser.swift; sourceTree = ""; }; 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = ""; }; + 934528345C354D5F9B11C816 /* InAppBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppBrowserView.swift; sourceTree = ""; }; 547AFEBDC601FEDCE3364643 /* RenderConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderConfiguration.swift; path = V2er/Sources/RichView/Models/RenderConfiguration.swift; sourceTree = ""; }; 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = ""; }; 5D0A513626E0CBFC006F3D9B /* ExploreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreActions.swift; sourceTree = ""; }; @@ -271,6 +275,7 @@ 5D6871852718761800329E73 /* FeedbackHelperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackHelperView.swift; sourceTree = ""; }; 5D6871872718764E00329E73 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 5D6AAAAB2691851100F42A13 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; + 0E07C501559C4957B0DEF9D4 /* URLRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRouter.swift; sourceTree = ""; }; 5D6AAAAE2692036100F42A13 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = ""; }; 5D6B67B626D54BA1003A246B /* UA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UA.swift; sourceTree = ""; }; 5D6EE28A2693DC470053B637 /* FeedDetailPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedDetailPage.swift; sourceTree = ""; }; @@ -351,7 +356,8 @@ CB3CC744901D9A994CF6ABE7 /* RenderError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderError.swift; path = V2er/Sources/RichView/Models/RenderError.swift; sourceTree = ""; }; D6356D706913919766FD0EA5 /* RenderActor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderActor.swift; path = V2er/Sources/RichView/Renderers/RenderActor.swift; sourceTree = ""; }; E205F350A3537A3E41B1AFC3 /* RichContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RichContentView.swift; path = V2er/Sources/RichView/Views/RichContentView.swift; sourceTree = ""; }; - E43141D64D5C4A65B1700BF8 /* PostscriptItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostscriptItemView.swift; sourceTree = ""; }; + E43141D64D5C4A65B1700BF8 /* PostscriptItemView.swift */ = {isa = PBXFileReference; + A1B2C3D4E5F60001 /* InAppBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppBrowserView.swift; sourceTree = ""; }; lastKnownFileType = sourcecode.swift; path = PostscriptItemView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -744,6 +750,8 @@ 5DE5B4C826845F4F00569684 /* View */ = { isa = PBXGroup; children = ( + 934528345C354D5F9B11C816 /* InAppBrowserView.swift */, + A1B2C3D4E5F60001 /* InAppBrowserView.swift */, 5D436FEC24791C2C00FFA37E /* MainPage.swift */, 5D5DA26924A4939B005BC60F /* ViewExtension.swift */, 5DD440D2269F1F5E00EE0FAB /* Syles.swift */, @@ -766,6 +774,7 @@ 5DE5C577249D90690004DCC6 /* General */ = { isa = PBXGroup; children = ( + 0E07C501559C4957B0DEF9D4 /* URLRouter.swift */, 5DA69A4824ACBC8D00F8B77A /* V2erApp.swift */, 5DE5C578249D90AD0004DCC6 /* Color.swift */, 5D6AAAAB2691851100F42A13 /* Utils.swift */, @@ -1025,6 +1034,7 @@ 5DF417742712DA7500E6D135 /* MyRecentState.swift in Sources */, 28B24CA92EA3460D00F82B2A /* BalanceView.swift in Sources */, 5D6AAAAC2691851100F42A13 /* Utils.swift in Sources */, + E6F855004AFD402D95495FC4 /* URLRouter.swift in Sources */, 5D612FA126C7C34E0009B8F9 /* NetworkException.swift in Sources */, 5DE5B4CA2684601A00569684 /* TopBar.swift in Sources */, 5D2B2B3E26FF797600446F93 /* AccountState.swift in Sources */, @@ -1043,6 +1053,7 @@ 5D8FAA38272D26200067766E /* RootView.swift in Sources */, 5D04BF9726C9FB6E0005F7E3 /* FeedInfo.swift in Sources */, 4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */, + 411D26B534F24940938F91F4 /* InAppBrowserView.swift in Sources */, 5D0A513926E26473006F3D9B /* FeedDetailReducer.swift in Sources */, 5D2DD00826FB353C0001C85A /* DefaultReducer.swift in Sources */, 5D0CFA8026B992FC001A8A7F /* MyRecentPage.swift in Sources */, @@ -1450,6 +1461,7 @@ buildConfigurations = ( 5D43701624791C2D00FFA37E /* Debug */, 5D43701724791C2D00FFA37E /* Release */, + A1B2C3D4E5F60002 /* InAppBrowserView.swift in Sources */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/V2er/General/Extentions.swift b/V2er/General/Extentions.swift index 735fd29..29366bf 100644 --- a/V2er/General/Extentions.swift +++ b/V2er/General/Extentions.swift @@ -278,9 +278,15 @@ extension URL { struct SafariView: UIViewControllerRepresentable { let url: URL @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { let safariVC = SFSafariViewController(url: url) + safariVC.delegate = context.coordinator updateAppearance(safariVC) return safariVC } @@ -316,6 +322,18 @@ struct SafariView: UIViewControllerRepresentable { safariVC.preferredBarTintColor = UIColor.systemBackground } } + + class Coordinator: NSObject, SFSafariViewControllerDelegate { + let parent: SafariView + + init(_ parent: SafariView) { + self.parent = parent + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + parent.dismiss() + } + } } // MARK: - Mobile Web View (with mobile User-Agent) diff --git a/V2er/General/URLRouter.swift b/V2er/General/URLRouter.swift index a549348..cfb1611 100644 --- a/V2er/General/URLRouter.swift +++ b/V2er/General/URLRouter.swift @@ -19,13 +19,23 @@ class URLRouter { private static let v2exAltHost = "v2ex.com" /// Result of URL interception - enum InterceptResult { + enum InterceptResult: Equatable { case topic(id: String) // /t/123456 case node(name: String) // /go/swift case member(username: String) // /member/username case external(url: URL) // External URL case webview(url: URL) // Internal URL to open in webview case invalid // Invalid URL + + /// Whether this result should use native navigation + var isNativeNavigation: Bool { + switch self { + case .topic, .node, .member: + return true + default: + return false + } + } } // MARK: - URL Parsing @@ -149,32 +159,44 @@ enum NavigationDestination: Hashable { case tagDetail(name: String) } -// MARK: - UIApplication Extension +// MARK: - Link Action -extension UIApplication { - /// Open URL with smart routing +/// Represents the action to take when a link is tapped +enum LinkAction { + case navigateToTopic(id: String) + case navigateToUser(username: String) + case navigateToNode(name: String) + case openInAppBrowser(url: URL) + case openInSafariViewController(url: URL) +} + +/// Determines the appropriate action for a URL based on settings +class LinkHandler { + /// Determine what action to take for a given URL /// - Parameters: - /// - url: URL to open - /// - completion: Optional completion handler - @MainActor - func openURL(_ url: URL, completion: ((Bool) -> Void)? = nil) { - let urlString = url.absoluteString - let result = URLRouter.parse(urlString) + /// - url: The URL to handle + /// - useBuiltinBrowser: Whether to use the builtin browser for external links + /// - Returns: The action to take + static func action(for url: URL, useBuiltinBrowser: Bool) -> LinkAction { + let result = URLRouter.parse(url.absoluteString) switch result { - case .external(let externalUrl): - // Open external URLs in Safari - open(externalUrl, options: [:], completionHandler: completion) - - case .webview(let webviewUrl): - // For now, open in Safari - // TODO: Implement in-app webview - open(webviewUrl, options: [:], completionHandler: completion) - - default: - // For topic, node, member URLs - should be handled by navigation - // Fall back to Safari if not handled - open(url, options: [:], completionHandler: completion) + case .topic(let id): + return .navigateToTopic(id: id) + case .member(let username): + return .navigateToUser(username: username) + case .node(let name): + return .navigateToNode(name: name) + case .external(let externalUrl), .webview(let externalUrl): + // When builtin browser is enabled, use InAppBrowser + // When disabled, use SafariViewController to stay in app + if useBuiltinBrowser { + return .openInAppBrowser(url: externalUrl) + } else { + return .openInSafariViewController(url: externalUrl) + } + case .invalid: + return .openInSafariViewController(url: url) } } } diff --git a/V2er/Sources/RichView/Views/RichContentView.swift b/V2er/Sources/RichView/Views/RichContentView.swift index c9d9411..47e7f1f 100644 --- a/V2er/Sources/RichView/Views/RichContentView.swift +++ b/V2er/Sources/RichView/Views/RichContentView.swift @@ -77,6 +77,10 @@ public struct RichContentView: View { .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) + .environment(\.openURL, OpenURLAction { url in + onLinkTapped?(url) + return .handled + }) case .codeBlock(let code, let language): CodeBlockAttachment( diff --git a/V2er/State/DataFlow/Reducers/SettingReducer.swift b/V2er/State/DataFlow/Reducers/SettingReducer.swift index c3e3766..4fedf59 100644 --- a/V2er/State/DataFlow/Reducers/SettingReducer.swift +++ b/V2er/State/DataFlow/Reducers/SettingReducer.swift @@ -29,6 +29,11 @@ func settingStateReducer(_ state: SettingState, _ action: Action) -> (SettingSta UserDefaults.standard.set(action.enabled, forKey: "autoCheckin") followingAction = nil + case let action as SettingActions.ToggleBuiltinBrowserAction: + state.useBuiltinBrowser = action.enabled + UserDefaults.standard.set(action.enabled, forKey: SettingState.useBuiltinBrowserKey) + followingAction = nil + case _ as SettingActions.StartAutoCheckinAction: state.isCheckingIn = true state.checkinError = nil diff --git a/V2er/State/DataFlow/State/SettingState.swift b/V2er/State/DataFlow/State/SettingState.swift index c7ddf3a..a05a1b6 100644 --- a/V2er/State/DataFlow/State/SettingState.swift +++ b/V2er/State/DataFlow/State/SettingState.swift @@ -11,10 +11,12 @@ import SwiftUI struct SettingState: FluxState { static let imgurClientIdKey = "imgurClientId" + static let useBuiltinBrowserKey = "useBuiltinBrowser" var appearance: AppearanceMode = .system var autoCheckin: Bool = false var imgurClientId: String = "" + var useBuiltinBrowser: Bool = false // Checkin state var isCheckingIn: Bool = false @@ -38,6 +40,8 @@ struct SettingState: FluxState { self.checkinDays = UserDefaults.standard.integer(forKey: "checkinDays") // Load Imgur client ID self.imgurClientId = UserDefaults.standard.string(forKey: Self.imgurClientIdKey) ?? "" + // Load builtin browser preference + self.useBuiltinBrowser = UserDefaults.standard.bool(forKey: Self.useBuiltinBrowserKey) } static func saveImgurClientId(_ clientId: String) { @@ -128,6 +132,11 @@ struct SettingActions { let enabled: Bool } + struct ToggleBuiltinBrowserAction: Action { + var target: Reducer = R + let enabled: Bool + } + struct StartAutoCheckinAction: AwaitAction { var target: Reducer = R diff --git a/V2er/View/FeedDetail/NewsContentView.swift b/V2er/View/FeedDetail/NewsContentView.swift index 3a1c0c9..bd101ed 100644 --- a/V2er/View/FeedDetail/NewsContentView.swift +++ b/V2er/View/FeedDetail/NewsContentView.swift @@ -12,8 +12,15 @@ struct NewsContentView: View { var contentInfo: FeedDetailInfo.ContentInfo? @EnvironmentObject var store: Store @Environment(\.colorScheme) var colorScheme - @State private var showingSafari = false - @State private var safariURL: URL? + @State private var navigateToTopic: String? = nil + @State private var navigateToUser: String? = nil + @State private var navigateToNode: String? = nil + @State private var navigateToBrowserURL: URL? = nil + @State private var navigateToSafariURL: URL? = nil + + private var useBuiltinBrowser: Bool { + store.appState.settingState.useBuiltinBrowser + } init(_ contentInfo: FeedDetailInfo.ContentInfo?) { self.contentInfo = contentInfo @@ -37,85 +44,97 @@ struct NewsContentView: View { Divider() } - .sheet(isPresented: $showingSafari) { - if let url = safariURL { - SafariView(url: url) + .background( + Group { + NavigationLink( + destination: FeedDetailPage(id: navigateToTopic ?? ""), + isActive: Binding( + get: { navigateToTopic != nil }, + set: { if !$0 { navigateToTopic = nil } } + ) + ) { + EmptyView() + } + .hidden() + + NavigationLink( + destination: UserDetailPage(userId: navigateToUser ?? ""), + isActive: Binding( + get: { navigateToUser != nil }, + set: { if !$0 { navigateToUser = nil } } + ) + ) { + EmptyView() + } + .hidden() + + NavigationLink( + destination: TagDetailPage(tagId: navigateToNode ?? ""), + isActive: Binding( + get: { navigateToNode != nil }, + set: { if !$0 { navigateToNode = nil } } + ) + ) { + EmptyView() + } + .hidden() + + // Use NavigationLink instead of fullScreenCover for InAppBrowser (iOS 26 bug workaround) + NavigationLink( + destination: Group { + if let url = navigateToBrowserURL { + InAppBrowserView(url: url) + } + }, + isActive: Binding( + get: { navigateToBrowserURL != nil }, + set: { if !$0 { navigateToBrowserURL = nil } } + ) + ) { + EmptyView() + } + .hidden() + + // Use NavigationLink for SafariView (iOS 26 bug workaround) + NavigationLink( + destination: Group { + if let url = navigateToSafariURL { + SafariView(url: url) + .ignoresSafeArea() + .navigationBarHidden(true) + } + }, + isActive: Binding( + get: { navigateToSafariURL != nil }, + set: { if !$0 { navigateToSafariURL = nil } } + ) + ) { + EmptyView() + } + .hidden() } - } + ) } private func handleLinkTap(_ url: URL) { - // Smart URL routing - parse V2EX URLs and route accordingly - let path = url.path - - // Check if it's a V2EX internal link - if let host = url.host, (host.contains("v2ex.com")) { - // Topic: /t/123456 - if path.contains("/t/"), let topicId = extractTopicId(from: path) { - print("Navigate to topic: \(topicId)") - // TODO: Use proper navigation to FeedDetailPage(id: topicId) - // For now, open in SafariView - openInSafari(url) - return - } - - // Member: /member/username - if path.contains("/member/"), let username = extractUsername(from: path) { - print("Navigate to user: \(username)") - // TODO: Use proper navigation to UserDetailPage(userId: username) - // For now, open in SafariView - openInSafari(url) - return - } - - // Node: /go/nodename - if path.contains("/go/"), let nodeName = extractNodeName(from: path) { - print("Navigate to node: \(nodeName)") - // TODO: Use proper navigation to TagDetailPage - // For now, open in SafariView - openInSafari(url) - return - } - - // Other V2EX pages - open in SafariView - openInSafari(url) - } else { - // External link - open in SafariView (stays in app) - openInSafari(url) + let action = LinkHandler.action(for: url, useBuiltinBrowser: useBuiltinBrowser) + + switch action { + case .navigateToTopic(let id): + navigateToTopic = id + case .navigateToUser(let username): + navigateToUser = username + case .navigateToNode(let name): + navigateToNode = name + case .openInAppBrowser(let browserUrl): + navigateToBrowserURL = browserUrl + case .openInSafariViewController(let webviewUrl): + openInSafari(webviewUrl) } } private func openInSafari(_ url: URL) { - safariURL = url - showingSafari = true - } - - private func extractTopicId(from path: String) -> String? { - let pattern = "/t/(\\d+)" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), - let range = Range(match.range(at: 1), in: path) else { - return nil - } - return String(path[range]) - } - - private func extractUsername(from path: String) -> String? { - let components = path.components(separatedBy: "/") - guard let memberIndex = components.firstIndex(of: "member"), - memberIndex + 1 < components.count else { - return nil - } - return components[memberIndex + 1] - } - - private func extractNodeName(from path: String) -> String? { - let components = path.components(separatedBy: "/") - guard let goIndex = components.firstIndex(of: "go"), - goIndex + 1 < components.count else { - return nil - } - return components[goIndex + 1] + navigateToSafariURL = url } private func configurationForAppearance() -> RenderConfiguration { diff --git a/V2er/View/FeedDetail/ReplyItemView.swift b/V2er/View/FeedDetail/ReplyItemView.swift index 9fd5dcc..32b7470 100644 --- a/V2er/View/FeedDetail/ReplyItemView.swift +++ b/V2er/View/FeedDetail/ReplyItemView.swift @@ -15,9 +15,15 @@ struct ReplyItemView: View { var topicId: String @EnvironmentObject var store: Store @Environment(\.colorScheme) var colorScheme - @State private var showingSafari = false - @State private var safariURL: URL? @State private var navigateToUser: String? = nil + @State private var navigateToTopic: String? = nil + @State private var navigateToNode: String? = nil + @State private var navigateToBrowserURL: URL? = nil + @State private var navigateToSafariURL: URL? = nil + + private var useBuiltinBrowser: Bool { + store.appState.settingState.useBuiltinBrowser + } var body: some View { HStack(alignment: .top) { @@ -91,98 +97,97 @@ struct ReplyItemView: View { Label("回复", systemImage: "arrowshape.turn.up.left") } } - .sheet(isPresented: $showingSafari) { - if let url = safariURL { - SafariView(url: url) - } - } .background( - NavigationLink( - destination: Group { - if let username = navigateToUser { - UserDetailPage(userId: username) - } - }, - isActive: Binding( - get: { navigateToUser != nil }, - set: { if !$0 { navigateToUser = nil } } - ) - ) { - EmptyView() + Group { + NavigationLink( + destination: UserDetailPage(userId: navigateToUser ?? ""), + isActive: Binding( + get: { navigateToUser != nil }, + set: { if !$0 { navigateToUser = nil } } + ) + ) { + EmptyView() + } + .hidden() + + NavigationLink( + destination: FeedDetailPage(id: navigateToTopic ?? ""), + isActive: Binding( + get: { navigateToTopic != nil }, + set: { if !$0 { navigateToTopic = nil } } + ) + ) { + EmptyView() + } + .hidden() + + NavigationLink( + destination: TagDetailPage(tagId: navigateToNode ?? ""), + isActive: Binding( + get: { navigateToNode != nil }, + set: { if !$0 { navigateToNode = nil } } + ) + ) { + EmptyView() + } + .hidden() + + // Use NavigationLink instead of fullScreenCover for InAppBrowser (iOS 26 bug workaround) + NavigationLink( + destination: Group { + if let url = navigateToBrowserURL { + InAppBrowserView(url: url) + } + }, + isActive: Binding( + get: { navigateToBrowserURL != nil }, + set: { if !$0 { navigateToBrowserURL = nil } } + ) + ) { + EmptyView() + } + .hidden() + + // Use NavigationLink for SafariView (iOS 26 bug workaround) + NavigationLink( + destination: Group { + if let url = navigateToSafariURL { + SafariView(url: url) + .ignoresSafeArea() + .navigationBarHidden(true) + } + }, + isActive: Binding( + get: { navigateToSafariURL != nil }, + set: { if !$0 { navigateToSafariURL = nil } } + ) + ) { + EmptyView() + } + .hidden() } - .hidden() ) } private func handleLinkTap(_ url: URL) { - // Smart URL routing - parse V2EX URLs and route accordingly - let path = url.path - - // Check if it's a V2EX internal link - if let host = url.host, (host.contains("v2ex.com")) { - // Topic: /t/123456 - if path.contains("/t/"), let topicId = extractTopicId(from: path) { - print("Navigate to topic: \(topicId)") - // TODO: Use proper navigation to FeedDetailPage(id: topicId) - openInSafari(url) - return - } - - // Member: /member/username - if path.contains("/member/"), let username = extractUsername(from: path) { - print("Navigate to user: \(username)") - // TODO: Use proper navigation to UserDetailPage(userId: username) - openInSafari(url) - return - } - - // Node: /go/nodename - if path.contains("/go/"), let nodeName = extractNodeName(from: path) { - print("Navigate to node: \(nodeName)") - // TODO: Use proper navigation to TagDetailPage - openInSafari(url) - return - } - - // Other V2EX pages - open in SafariView - openInSafari(url) - } else { - // External link - open in SafariView (stays in app) - openInSafari(url) + let action = LinkHandler.action(for: url, useBuiltinBrowser: useBuiltinBrowser) + + switch action { + case .navigateToTopic(let id): + navigateToTopic = id + case .navigateToUser(let username): + navigateToUser = username + case .navigateToNode(let name): + navigateToNode = name + case .openInAppBrowser(let browserUrl): + navigateToBrowserURL = browserUrl + case .openInSafariViewController(let webviewUrl): + openInSafari(webviewUrl) } } private func openInSafari(_ url: URL) { - safariURL = url - showingSafari = true - } - - private func extractTopicId(from path: String) -> String? { - let pattern = "/t/(\\d+)" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), - let range = Range(match.range(at: 1), in: path) else { - return nil - } - return String(path[range]) - } - - private func extractUsername(from path: String) -> String? { - let components = path.components(separatedBy: "/") - guard let memberIndex = components.firstIndex(of: "member"), - memberIndex + 1 < components.count else { - return nil - } - return components[memberIndex + 1] - } - - private func extractNodeName(from path: String) -> String? { - let components = path.components(separatedBy: "/") - guard let goIndex = components.firstIndex(of: "go"), - goIndex + 1 < components.count else { - return nil - } - return components[goIndex + 1] + navigateToSafariURL = url } private func compactConfigurationForAppearance() -> RenderConfiguration { diff --git a/V2er/View/InAppBrowserView.swift b/V2er/View/InAppBrowserView.swift new file mode 100644 index 0000000..665f9fd --- /dev/null +++ b/V2er/View/InAppBrowserView.swift @@ -0,0 +1,331 @@ +// +// InAppBrowserView.swift +// V2er +// +// Created by Claude on 2025/1/28. +// Copyright © 2025 lessmore.io. All rights reserved. +// + +import SwiftUI +import WebKit + +struct InAppBrowserView: View { + let url: URL + @Environment(\.dismiss) var dismiss + @StateObject private var webViewState = InAppBrowserWebViewState() + @State private var showShareSheet = false + + var body: some View { + ZStack(alignment: .top) { + // WebView - extends under navigation bar and bottom + InAppBrowserWebViewController(url: url, state: webViewState) + .ignoresSafeArea(edges: [.top, .bottom]) + + // Progress bar at top (below status bar) + if webViewState.isLoading { + VStack { + Spacer().frame(height: 0) + ProgressView(value: webViewState.estimatedProgress) + .progressViewStyle(.linear) + .tint(.tintColor) + } + .frame(maxWidth: .infinity) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.body.weight(.medium)) + } + } + + ToolbarItem(placement: .principal) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(webViewState.title ?? "") + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + Text(webViewState.currentURL?.host ?? url.host ?? "") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + Button { + webViewState.goBack() + } label: { + Image(systemName: "chevron.left") + .font(.body) + } + .disabled(!webViewState.canGoBack) + + Button { + webViewState.goForward() + } label: { + Image(systemName: "chevron.right") + .font(.body) + } + .disabled(!webViewState.canGoForward) + + Menu { + Button { + webViewState.reload() + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + + Button { + showShareSheet = true + } label: { + Label("分享", systemImage: "square.and.arrow.up") + } + + Button { + copyToClipboard() + } label: { + Label("复制链接", systemImage: "doc.on.doc") + } + + Button { + openInExternalBrowser() + } label: { + Label("在浏览器中打开", systemImage: "safari") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.body) + } + } + } + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(items: [webViewState.currentURL ?? url]) + } + } + + private func copyToClipboard() { + let urlString = webViewState.currentURL?.absoluteString ?? url.absoluteString + UIPasteboard.general.string = urlString + Toast.show("已复制链接") + } + + private func openInExternalBrowser() { + let urlToOpen = webViewState.currentURL ?? url + UIApplication.shared.open(urlToOpen) + } +} + +// MARK: - ShareSheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: - WebView State + +class InAppBrowserWebViewState: ObservableObject { + @Published var title: String? + @Published var isLoading: Bool = false + @Published var canGoBack: Bool = false + @Published var canGoForward: Bool = false + @Published var currentURL: URL? + @Published var estimatedProgress: Double = 0 + + weak var webView: WKWebView? + + func goBack() { + webView?.goBack() + } + + func goForward() { + webView?.goForward() + } + + func reload() { + webView?.reload() + } +} + +// MARK: - WebView Controller (UIViewControllerRepresentable) + +struct InAppBrowserWebViewController: UIViewControllerRepresentable { + let url: URL + @ObservedObject var state: InAppBrowserWebViewState + + func makeUIViewController(context: Context) -> WebViewHostController { + let controller = WebViewHostController(url: url, state: state) + return controller + } + + func updateUIViewController(_ uiViewController: WebViewHostController, context: Context) { + // No updates needed + } +} + +// MARK: - WebView Host Controller + +class WebViewHostController: UIViewController, WKNavigationDelegate, WKUIDelegate { + private let url: URL + private let state: InAppBrowserWebViewState + private var webView: WKWebView! + private var progressObserver: NSKeyValueObservation? + private var titleObserver: NSKeyValueObservation? + private var canGoBackObserver: NSKeyValueObservation? + private var canGoForwardObserver: NSKeyValueObservation? + private var urlObserver: NSKeyValueObservation? + private var hasLoadedURL = false + + init(url: URL, state: InAppBrowserWebViewState) { + self.url = url + self.state = state + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + + // Create WebView with zero frame initially, will be updated by Auto Layout + webView = WKWebView(frame: .zero, configuration: configuration) + webView.translatesAutoresizingMaskIntoConstraints = false + webView.navigationDelegate = self + webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.backgroundColor = .systemBackground + + // Allow content to scroll under navigation bar + webView.scrollView.contentInsetAdjustmentBehavior = .always + + view.addSubview(webView) + + // Use Auto Layout - extend to edges for full screen effect + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + state.webView = webView + setupObservers() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Load URL in viewDidAppear + if !hasLoadedURL { + hasLoadedURL = true + webView.load(URLRequest(url: url)) + } + } + + private func setupObservers() { + progressObserver = webView.observe(\.estimatedProgress, options: .new) { [weak self] webView, _ in + DispatchQueue.main.async { + self?.state.estimatedProgress = webView.estimatedProgress + } + } + + titleObserver = webView.observe(\.title, options: .new) { [weak self] webView, _ in + DispatchQueue.main.async { + self?.state.title = webView.title + } + } + + canGoBackObserver = webView.observe(\.canGoBack, options: .new) { [weak self] webView, _ in + DispatchQueue.main.async { + self?.state.canGoBack = webView.canGoBack + } + } + + canGoForwardObserver = webView.observe(\.canGoForward, options: .new) { [weak self] webView, _ in + DispatchQueue.main.async { + self?.state.canGoForward = webView.canGoForward + } + } + + urlObserver = webView.observe(\.url, options: .new) { [weak self] webView, _ in + DispatchQueue.main.async { + self?.state.currentURL = webView.url + } + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + DispatchQueue.main.async { + self.state.isLoading = true + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { + self.state.isLoading = false + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + DispatchQueue.main.async { + self.state.isLoading = false + } + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DispatchQueue.main.async { + self.state.isLoading = false + } + print("WebView failed to load: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(.allow) + } + + // MARK: - WKUIDelegate + + // Handle links that open in new window (target="_blank") + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + // Load the URL in the current webview instead of opening a new one + if let url = navigationAction.request.url { + webView.load(URLRequest(url: url)) + } + return nil + } + + deinit { + progressObserver?.invalidate() + titleObserver?.invalidate() + canGoBackObserver?.invalidate() + canGoForwardObserver?.invalidate() + urlObserver?.invalidate() + } +} diff --git a/V2er/View/Settings/OtherSettingsView.swift b/V2er/View/Settings/OtherSettingsView.swift index 2abe717..3fef975 100644 --- a/V2er/View/Settings/OtherSettingsView.swift +++ b/V2er/View/Settings/OtherSettingsView.swift @@ -27,6 +27,10 @@ struct OtherSettingsView: View { AccountState.hasSignIn() } + private var useBuiltinBrowser: Bool { + store.appState.settingState.useBuiltinBrowser + } + var body: some View { formView .navBar("通用设置") @@ -99,6 +103,26 @@ struct OtherSettingsView: View { .padding(.top, 4) .padding(.bottom, 12) + // Builtin Browser Toggle + SectionView("内置浏览器", showDivider: true) { + Toggle("", isOn: Binding( + get: { useBuiltinBrowser }, + set: { newValue in + dispatch(SettingActions.ToggleBuiltinBrowserAction(enabled: newValue)) + } + )) + .labelsHidden() + .padding(.trailing, 16) + } + + Text("开启后站外链接将在内置浏览器中打开") + .font(.caption) + .foregroundColor(.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 12) + // Cache Clear Button { ImageCache.default.clearDiskCache {