Skip to content

Commit 279bfc4

Browse files
graycreateclaude
andcommitted
feat: add smart URL routing for V2EX internal links
Implemented intelligent link handling for RichView to distinguish between: - Internal V2EX links (topics, members, nodes) - logs navigation intent - External links - opens in Safari Changes: - Created URLRouter utility with comprehensive URL parsing (similar to Android's UrlInterceptor) - Added unit tests for URLRouter covering all URL patterns - Updated NewsContentView with smart link tap handler - Updated ReplyItemView with smart link tap handler - Extracts topic IDs, usernames, and node names from V2EX URLs - Falls back to Safari for now (TODO: implement native navigation) Based on Android implementation for consistency across platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5f4678c commit 279bfc4

File tree

4 files changed

+559
-8
lines changed

4 files changed

+559
-8
lines changed

V2er/General/URLRouter.swift

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
//
2+
// URLRouter.swift
3+
// V2er
4+
//
5+
// Created by RichView on 2025/1/19.
6+
// Copyright © 2025 lessmore.io. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import SwiftUI
11+
12+
/// URL Router for handling V2EX internal and external links
13+
/// Similar to Android's UrlInterceptor.java
14+
class URLRouter {
15+
16+
// MARK: - URL Patterns
17+
18+
private static let v2exHost = "www.v2ex.com"
19+
private static let v2exAltHost = "v2ex.com"
20+
21+
/// Result of URL interception
22+
enum InterceptResult {
23+
case topic(id: String) // /t/123456
24+
case node(name: String) // /go/swift
25+
case member(username: String) // /member/username
26+
case external(url: URL) // External URL
27+
case webview(url: URL) // Internal URL to open in webview
28+
case invalid // Invalid URL
29+
}
30+
31+
// MARK: - URL Parsing
32+
33+
/// Parse and classify URL
34+
/// - Parameter urlString: URL string to parse
35+
/// - Returns: InterceptResult indicating how to handle the URL
36+
static func parse(_ urlString: String) -> InterceptResult {
37+
guard !urlString.isEmpty else {
38+
return .invalid
39+
}
40+
41+
var fullURL = urlString
42+
43+
// Handle relative paths
44+
if urlString.hasPrefix("/") {
45+
fullURL = "https://\(v2exHost)\(urlString)"
46+
} else if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
47+
fullURL = "https://\(v2exHost)/\(urlString)"
48+
}
49+
50+
guard let url = URL(string: fullURL),
51+
let host = url.host else {
52+
return .invalid
53+
}
54+
55+
// Check if it's V2EX domain
56+
let isV2EX = host.contains(v2exHost) || host.contains(v2exAltHost)
57+
58+
if !isV2EX {
59+
// External URL - open in Safari or custom tabs
60+
return .external(url: url)
61+
}
62+
63+
// Parse V2EX internal URLs
64+
let path = url.path
65+
66+
// Topic: /t/123456 or /t/123456#reply123
67+
if path.contains("/t/") {
68+
if let topicId = extractTopicId(from: path) {
69+
return .topic(id: topicId)
70+
}
71+
}
72+
73+
// Node: /go/swift
74+
if path.contains("/go/") {
75+
if let nodeName = extractNodeName(from: path) {
76+
return .node(name: nodeName)
77+
}
78+
}
79+
80+
// Member: /member/username
81+
if path.contains("/member/") {
82+
if let username = extractUsername(from: path) {
83+
return .member(username: username)
84+
}
85+
}
86+
87+
// Other V2EX URLs - open in webview
88+
return .webview(url: url)
89+
}
90+
91+
// MARK: - Extraction Helpers
92+
93+
/// Extract topic ID from path like /t/123456 or /t/123456#reply123
94+
private static func extractTopicId(from path: String) -> String? {
95+
let pattern = "/t/(\\d+)"
96+
guard let regex = try? NSRegularExpression(pattern: pattern),
97+
let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)),
98+
let range = Range(match.range(at: 1), in: path) else {
99+
return nil
100+
}
101+
return String(path[range])
102+
}
103+
104+
/// Extract node name from path like /go/swift
105+
private static func extractNodeName(from path: String) -> String? {
106+
let components = path.components(separatedBy: "/")
107+
guard let goIndex = components.firstIndex(of: "go"),
108+
goIndex + 1 < components.count else {
109+
return nil
110+
}
111+
return components[goIndex + 1]
112+
}
113+
114+
/// Extract username from path like /member/username
115+
private static func extractUsername(from path: String) -> String? {
116+
let components = path.components(separatedBy: "/")
117+
guard let memberIndex = components.firstIndex(of: "member"),
118+
memberIndex + 1 < components.count else {
119+
return nil
120+
}
121+
return components[memberIndex + 1]
122+
}
123+
124+
// MARK: - Navigation Helpers
125+
126+
/// Get NavigationDestination from URL
127+
/// - Parameter urlString: URL string
128+
/// - Returns: Optional NavigationDestination
129+
static func destination(from urlString: String) -> NavigationDestination? {
130+
switch parse(urlString) {
131+
case .topic(let id):
132+
return .feedDetail(id: id)
133+
case .member(let username):
134+
return .userDetail(username: username)
135+
case .node(let name):
136+
return .tagDetail(name: name)
137+
default:
138+
return nil
139+
}
140+
}
141+
}
142+
143+
// MARK: - Navigation Destination
144+
145+
/// Navigation destinations in the app
146+
enum NavigationDestination: Hashable {
147+
case feedDetail(id: String)
148+
case userDetail(username: String)
149+
case tagDetail(name: String)
150+
}
151+
152+
// MARK: - UIApplication Extension
153+
154+
extension UIApplication {
155+
/// Open URL with smart routing
156+
/// - Parameters:
157+
/// - url: URL to open
158+
/// - completion: Optional completion handler
159+
@MainActor
160+
func openURL(_ url: URL, completion: ((Bool) -> Void)? = nil) {
161+
let urlString = url.absoluteString
162+
let result = URLRouter.parse(urlString)
163+
164+
switch result {
165+
case .external(let externalUrl):
166+
// Open external URLs in Safari
167+
open(externalUrl, options: [:], completionHandler: completion)
168+
169+
case .webview(let webviewUrl):
170+
// For now, open in Safari
171+
// TODO: Implement in-app webview
172+
open(webviewUrl, options: [:], completionHandler: completion)
173+
174+
default:
175+
// For topic, node, member URLs - should be handled by navigation
176+
// Fall back to Safari if not handled
177+
open(url, options: [:], completionHandler: completion)
178+
}
179+
}
180+
}
181+
182+
// MARK: - URL Testing Helpers
183+
184+
#if DEBUG
185+
extension URLRouter {
186+
/// Test URL parsing (for debugging)
187+
static func test() {
188+
let testCases = [
189+
"https://www.v2ex.com/t/123456",
190+
"https://v2ex.com/t/123456#reply123",
191+
"/t/123456",
192+
"https://www.v2ex.com/go/swift",
193+
"/go/swift",
194+
"https://www.v2ex.com/member/livid",
195+
"/member/livid",
196+
"https://www.google.com",
197+
"https://www.v2ex.com/about"
198+
]
199+
200+
for testCase in testCases {
201+
let result = parse(testCase)
202+
print("URL: \(testCase) -> \(result)")
203+
}
204+
}
205+
}
206+
#endif

V2er/View/FeedDetail/NewsContentView.swift

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct NewsContentView: View {
1313
@Binding var rendered: Bool
1414
@EnvironmentObject var store: Store
1515
@Environment(\.colorScheme) var colorScheme
16+
@State private var navigationPath = NavigationPath()
1617

1718
init(_ contentInfo: FeedDetailInfo.ContentInfo?, rendered: Binding<Bool>) {
1819
self.contentInfo = contentInfo
@@ -26,9 +27,7 @@ struct NewsContentView: View {
2627
RichView(htmlContent: contentInfo?.html ?? "")
2728
.configuration(configurationForAppearance())
2829
.onLinkTapped { url in
29-
Task {
30-
await UIApplication.shared.openURL(url)
31-
}
30+
handleLinkTap(url)
3231
}
3332
.onRenderCompleted { metadata in
3433
// Mark as rendered after content is ready
@@ -45,6 +44,76 @@ struct NewsContentView: View {
4544
}
4645
}
4746

47+
private func handleLinkTap(_ url: URL) {
48+
// Smart URL routing - parse V2EX URLs and route accordingly
49+
let urlString = url.absoluteString
50+
let path = url.path
51+
52+
// Check if it's a V2EX internal link
53+
if let host = url.host, (host.contains("v2ex.com")) {
54+
// Topic: /t/123456
55+
if path.contains("/t/"), let topicId = extractTopicId(from: path) {
56+
print("Navigate to topic: \(topicId)")
57+
// TODO: Use proper navigation to FeedDetailPage(id: topicId)
58+
// For now, open in Safari
59+
UIApplication.shared.open(url)
60+
return
61+
}
62+
63+
// Member: /member/username
64+
if path.contains("/member/"), let username = extractUsername(from: path) {
65+
print("Navigate to user: \(username)")
66+
// TODO: Use proper navigation to UserDetailPage(userId: username)
67+
// For now, open in Safari
68+
UIApplication.shared.open(url)
69+
return
70+
}
71+
72+
// Node: /go/nodename
73+
if path.contains("/go/"), let nodeName = extractNodeName(from: path) {
74+
print("Navigate to node: \(nodeName)")
75+
// TODO: Use proper navigation to TagDetailPage
76+
// For now, open in Safari
77+
UIApplication.shared.open(url)
78+
return
79+
}
80+
81+
// Other V2EX pages - open in Safari
82+
UIApplication.shared.open(url)
83+
} else {
84+
// External link - open in Safari
85+
UIApplication.shared.open(url)
86+
}
87+
}
88+
89+
private func extractTopicId(from path: String) -> String? {
90+
let pattern = "/t/(\\d+)"
91+
guard let regex = try? NSRegularExpression(pattern: pattern),
92+
let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)),
93+
let range = Range(match.range(at: 1), in: path) else {
94+
return nil
95+
}
96+
return String(path[range])
97+
}
98+
99+
private func extractUsername(from path: String) -> String? {
100+
let components = path.components(separatedBy: "/")
101+
guard let memberIndex = components.firstIndex(of: "member"),
102+
memberIndex + 1 < components.count else {
103+
return nil
104+
}
105+
return components[memberIndex + 1]
106+
}
107+
108+
private func extractNodeName(from path: String) -> String? {
109+
let components = path.components(separatedBy: "/")
110+
guard let goIndex = components.firstIndex(of: "go"),
111+
goIndex + 1 < components.count else {
112+
return nil
113+
}
114+
return components[goIndex + 1]
115+
}
116+
48117
private func configurationForAppearance() -> RenderConfiguration {
49118
var config = RenderConfiguration.default
50119

V2er/View/FeedDetail/ReplyItemView.swift

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,11 @@ struct ReplyItemView: View {
5151
RichView(htmlContent: info.content)
5252
.configuration(compactConfigurationForAppearance())
5353
.onLinkTapped { url in
54-
Task {
55-
await UIApplication.shared.openURL(url)
56-
}
54+
handleLinkTap(url)
5755
}
5856
.onMentionTapped { username in
59-
// TODO: Navigate to user profile
60-
print("Mention tapped: @\(username)")
57+
print("Navigate to mentioned user: @\(username)")
58+
// TODO: Implement proper navigation to UserDetailPage
6159
}
6260

6361
Text("\(info.floor)")
@@ -70,6 +68,72 @@ struct ReplyItemView: View {
7068
.padding(.horizontal, 12)
7169
}
7270

71+
private func handleLinkTap(_ url: URL) {
72+
// Smart URL routing - parse V2EX URLs and route accordingly
73+
let path = url.path
74+
75+
// Check if it's a V2EX internal link
76+
if let host = url.host, (host.contains("v2ex.com")) {
77+
// Topic: /t/123456
78+
if path.contains("/t/"), let topicId = extractTopicId(from: path) {
79+
print("Navigate to topic: \(topicId)")
80+
// TODO: Use proper navigation to FeedDetailPage(id: topicId)
81+
UIApplication.shared.open(url)
82+
return
83+
}
84+
85+
// Member: /member/username
86+
if path.contains("/member/"), let username = extractUsername(from: path) {
87+
print("Navigate to user: \(username)")
88+
// TODO: Use proper navigation to UserDetailPage(userId: username)
89+
UIApplication.shared.open(url)
90+
return
91+
}
92+
93+
// Node: /go/nodename
94+
if path.contains("/go/"), let nodeName = extractNodeName(from: path) {
95+
print("Navigate to node: \(nodeName)")
96+
// TODO: Use proper navigation to TagDetailPage
97+
UIApplication.shared.open(url)
98+
return
99+
}
100+
101+
// Other V2EX pages - open in Safari
102+
UIApplication.shared.open(url)
103+
} else {
104+
// External link - open in Safari
105+
UIApplication.shared.open(url)
106+
}
107+
}
108+
109+
private func extractTopicId(from path: String) -> String? {
110+
let pattern = "/t/(\\d+)"
111+
guard let regex = try? NSRegularExpression(pattern: pattern),
112+
let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)),
113+
let range = Range(match.range(at: 1), in: path) else {
114+
return nil
115+
}
116+
return String(path[range])
117+
}
118+
119+
private func extractUsername(from path: String) -> String? {
120+
let components = path.components(separatedBy: "/")
121+
guard let memberIndex = components.firstIndex(of: "member"),
122+
memberIndex + 1 < components.count else {
123+
return nil
124+
}
125+
return components[memberIndex + 1]
126+
}
127+
128+
private func extractNodeName(from path: String) -> String? {
129+
let components = path.components(separatedBy: "/")
130+
guard let goIndex = components.firstIndex(of: "go"),
131+
goIndex + 1 < components.count else {
132+
return nil
133+
}
134+
return components[goIndex + 1]
135+
}
136+
73137
private func compactConfigurationForAppearance() -> RenderConfiguration {
74138
var config = RenderConfiguration.compact
75139

0 commit comments

Comments
 (0)