From 4d030b629ba8330b7bd7dfcfd24bd34b5c60255e Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 10:12:36 +0800 Subject: [PATCH 1/4] feat: enhance feed detail menu with share, sticky and fade options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix topic content text color to match reply list (use .primary instead of gray #555555) - Add share button to share topic title and URL - Add sticky topic option (10 min, 200 coins) for own topics - Add fade topic option (1 day) for own topics - Menu items for sticky/fade only visible when available (own topics) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../RichView/Models/RenderStylesheet.swift | 5 +- .../DataFlow/Actions/FeedDetailActions.swift | 92 +++++++++++++++++++ V2er/View/FeedDetail/FeedDetailPage.swift | 45 +++++++++ V2er/View/FeedDetail/FeedDetailReducer.swift | 10 ++ 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/V2er/Sources/RichView/Models/RenderStylesheet.swift b/V2er/Sources/RichView/Models/RenderStylesheet.swift index c7d18cd..f1cef10 100644 --- a/V2er/Sources/RichView/Models/RenderStylesheet.swift +++ b/V2er/Sources/RichView/Models/RenderStylesheet.swift @@ -461,10 +461,7 @@ extension RenderStylesheet { fontWeight: .regular, lineSpacing: 2, paragraphSpacing: 2, - color: Color.adaptive( - light: Color(hex: "#555555"), - dark: Color.white.opacity(0.9) - ) + color: .primary ), heading: HeadingStyle( h1Size: 22, diff --git a/V2er/State/DataFlow/Actions/FeedDetailActions.swift b/V2er/State/DataFlow/Actions/FeedDetailActions.swift index 5f590d7..2c6d7cf 100644 --- a/V2er/State/DataFlow/Actions/FeedDetailActions.swift +++ b/V2er/State/DataFlow/Actions/FeedDetailActions.swift @@ -241,4 +241,96 @@ struct FeedDetailActions { let result: APIResult } + struct StickyTopic: AwaitAction { + var target: Reducer = R + var id: String + + func execute(in store: Store) async { + // Check if user is logged in + guard AccountState.hasSignIn() else { + Toast.show("请先登录") + dispatch(LoginActions.ShowLoginPageAction(reason: "需要登录才能置顶主题")) + return + } + + Toast.show("置顶中") + guard let state = store.appState.feedDetailStates[id], + let stickyStr = state.model.stickyStr, + stickyStr.notEmpty() else { + Toast.show("操作失败,请刷新页面") + return + } + + // Parse the onclick string to get the URL + // Format: "if (confirm('...')) { location.href = '/sticky/topic/123456?once=xxx'; }" + guard let sIndex = stickyStr.index(of: "/sticky/topic/"), + let eIndex = stickyStr.lastIndex(of: "'") else { + Toast.show("操作失败,无法解析链接") + return + } + let stickyLink = String(stickyStr[sIndex.. = await APIService.shared + .htmlGet(endpoint: .general(url: stickyLink), + requestHeaders: Headers.topicReferer(id)) + var success = false + if case let .success(result) = result { + success = result?.isValid() ?? false + } + dispatch(StickyTopicDone(id: id, success: success)) + } + } + + struct StickyTopicDone: Action { + var target: Reducer = R + var id: String + let success: Bool + } + + struct FadeTopic: AwaitAction { + var target: Reducer = R + var id: String + + func execute(in store: Store) async { + // Check if user is logged in + guard AccountState.hasSignIn() else { + Toast.show("请先登录") + dispatch(LoginActions.ShowLoginPageAction(reason: "需要登录才能下沉主题")) + return + } + + Toast.show("下沉中") + guard let state = store.appState.feedDetailStates[id], + let fadeStr = state.model.fadeStr, + fadeStr.notEmpty() else { + Toast.show("操作失败,请刷新页面") + return + } + + // Parse the onclick string to get the URL + // Format: "if (confirm('...')) { location.href = '/fade/topic/123456?once=xxx'; }" + guard let sIndex = fadeStr.index(of: "/fade/topic/"), + let eIndex = fadeStr.lastIndex(of: "'") else { + Toast.show("操作失败,无法解析链接") + return + } + let fadeLink = String(fadeStr[sIndex.. = await APIService.shared + .htmlGet(endpoint: .general(url: fadeLink), + requestHeaders: Headers.topicReferer(id)) + var success = false + if case let .success(result) = result { + success = result?.isValid() ?? false + } + dispatch(FadeTopicDone(id: id, success: success)) + } + } + + struct FadeTopicDone: Action { + var target: Reducer = R + var id: String + let success: Bool + } + } diff --git a/V2er/View/FeedDetail/FeedDetailPage.swift b/V2er/View/FeedDetail/FeedDetailPage.swift index b905a5a..006e42e 100644 --- a/V2er/View/FeedDetail/FeedDetailPage.swift +++ b/V2er/View/FeedDetail/FeedDetailPage.swift @@ -67,6 +67,23 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { || (!isContentEmpty && !self.rendered) } + private func shareTopicContent() { + let title = state.model.headerInfo?.title ?? "V2EX 话题" + let url = APIService.baseUrlString + "/t/\(id)" + let activityItems: [Any] = [title, URL(string: url)!] + + let activityVC = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + + // For iPad, we need to provide a source view + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + activityVC.popoverPresentationController?.sourceView = rootVC.view + activityVC.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 2, width: 0, height: 0) + rootVC.present(activityVC, animated: true) + } + } + var body: some View { contentView .sheet(isPresented: $showingSafari) { @@ -315,6 +332,34 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { Divider() + // Owner-only actions + if let stickyStr = state.model.stickyStr, stickyStr.notEmpty() { + Button { + dispatch(FeedDetailActions.StickyTopic(id: id)) + } label: { + Label("置顶 10 分钟 (200 铜币)", systemImage: "pin") + } + } + + if let fadeStr = state.model.fadeStr, fadeStr.notEmpty() { + Button { + dispatch(FeedDetailActions.FadeTopic(id: id)) + } label: { + Label("下沉 1 天", systemImage: "arrow.down.to.line") + } + } + + if state.model.stickyStr?.notEmpty() == true || state.model.fadeStr?.notEmpty() == true { + Divider() + } + + // Share button + Button { + shareTopicContent() + } label: { + Label("分享", systemImage: "square.and.arrow.up") + } + Button { // Use MobileWebView with mobile User-Agent for better mobile experience if let url = URL(string: APIService.baseUrlString + "/t/\(id)") { diff --git a/V2er/View/FeedDetail/FeedDetailReducer.swift b/V2er/View/FeedDetail/FeedDetailReducer.swift index 4af9ace..bba7756 100644 --- a/V2er/View/FeedDetail/FeedDetailReducer.swift +++ b/V2er/View/FeedDetail/FeedDetailReducer.swift @@ -88,6 +88,16 @@ func feedDetailStateReducer(_ states: FeedDetailStates, _ action: Action) -> (Fe case let action as FeedDetailActions.ReportTopicDone: state.model.hasReported = action.reported Toast.show(action.reported ? "举报成功" : "举报失败") + case let action as FeedDetailActions.StickyTopicDone: + if action.success { + state.model.stickyStr = nil // Disable sticky button after success + } + Toast.show(action.success ? "置顶成功" : "置顶失败") + case let action as FeedDetailActions.FadeTopicDone: + if action.success { + state.model.fadeStr = nil // Disable fade button after success + } + Toast.show(action.success ? "下沉成功" : "下沉失败") default: break; } From 35bf40f2a8cb18fafdf2c5edc2e3c1d3f90e16aa Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 10:24:13 +0800 Subject: [PATCH 2/4] fix: address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use safe URL unwrapping with guard statement instead of force unwrap - Add error toast when share URL generation fails - Add error toast when rootViewController cannot be found - Remove redundant divider visibility check 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/View/FeedDetail/FeedDetailPage.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/V2er/View/FeedDetail/FeedDetailPage.swift b/V2er/View/FeedDetail/FeedDetailPage.swift index 006e42e..d5e577f 100644 --- a/V2er/View/FeedDetail/FeedDetailPage.swift +++ b/V2er/View/FeedDetail/FeedDetailPage.swift @@ -69,8 +69,12 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { private func shareTopicContent() { let title = state.model.headerInfo?.title ?? "V2EX 话题" - let url = APIService.baseUrlString + "/t/\(id)" - let activityItems: [Any] = [title, URL(string: url)!] + let urlString = APIService.baseUrlString + "/t/\(id)" + guard let shareURL = URL(string: urlString) else { + Toast.show("分享链接生成失败") + return + } + let activityItems: [Any] = [title, shareURL] let activityVC = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) @@ -81,6 +85,8 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { activityVC.popoverPresentationController?.sourceView = rootVC.view activityVC.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 2, width: 0, height: 0) rootVC.present(activityVC, animated: true) + } else { + Toast.show("无法打开分享") } } @@ -349,10 +355,6 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { } } - if state.model.stickyStr?.notEmpty() == true || state.model.fadeStr?.notEmpty() == true { - Divider() - } - // Share button Button { shareTopicContent() From a329c340596983f78cc086ebd6f0c536779a75ba Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 14:16:49 +0800 Subject: [PATCH 3/4] fix: improve sticky/fade success detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check HTTP request success status instead of response content validity. The sticky/fade endpoints return redirect pages, not full topic info, so isValid() was incorrectly returning false. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/State/DataFlow/Actions/FeedDetailActions.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/V2er/State/DataFlow/Actions/FeedDetailActions.swift b/V2er/State/DataFlow/Actions/FeedDetailActions.swift index 2c6d7cf..b2adb32 100644 --- a/V2er/State/DataFlow/Actions/FeedDetailActions.swift +++ b/V2er/State/DataFlow/Actions/FeedDetailActions.swift @@ -273,9 +273,10 @@ struct FeedDetailActions { let result: APIResult = await APIService.shared .htmlGet(endpoint: .general(url: stickyLink), requestHeaders: Headers.topicReferer(id)) + // Success if HTTP request succeeded (regardless of response content) var success = false - if case let .success(result) = result { - success = result?.isValid() ?? false + if case .success = result { + success = true } dispatch(StickyTopicDone(id: id, success: success)) } @@ -319,9 +320,10 @@ struct FeedDetailActions { let result: APIResult = await APIService.shared .htmlGet(endpoint: .general(url: fadeLink), requestHeaders: Headers.topicReferer(id)) + // Success if HTTP request succeeded (regardless of response content) var success = false - if case let .success(result) = result { - success = result?.isValid() ?? false + if case .success = result { + success = true } dispatch(FadeTopicDone(id: id, success: success)) } From b1a143629e333f4dcbd348b2219860409af102e8 Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 14:20:51 +0800 Subject: [PATCH 4/4] fix: update sticky success message to match Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed "置顶成功" to "置顶 10 分钟成功" for consistency with Android version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/View/FeedDetail/FeedDetailReducer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/V2er/View/FeedDetail/FeedDetailReducer.swift b/V2er/View/FeedDetail/FeedDetailReducer.swift index bba7756..df5c4a0 100644 --- a/V2er/View/FeedDetail/FeedDetailReducer.swift +++ b/V2er/View/FeedDetail/FeedDetailReducer.swift @@ -92,7 +92,7 @@ func feedDetailStateReducer(_ states: FeedDetailStates, _ action: Action) -> (Fe if action.success { state.model.stickyStr = nil // Disable sticky button after success } - Toast.show(action.success ? "置顶成功" : "置顶失败") + Toast.show(action.success ? "置顶 10 分钟成功" : "置顶失败") case let action as FeedDetailActions.FadeTopicDone: if action.success { state.model.fadeStr = nil // Disable fade button after success