From 812ccc7bb89be80e9695aa3dd31bf51a40cf9170 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Nov 2025 09:07:27 +0000 Subject: [PATCH 01/11] Add unvote feature for posts and comments Implement the ability to unvote posts and comments within the 1-hour window that Hacker News allows. The unvote option appears automatically when an unvote link is available from the server. Changes: - Add unvote methods to VoteUseCase protocol - Implement unvote in PostRepository for posts and comments - Update VotingState to track unvote availability with canUnvote property - Add unvote to VotingStateProvider protocol and DefaultVotingStateProvider - Update VotingViewModel with unvote methods and optimistic UI updates - Update VoteButton to enable interaction when unvote is available - Add unvote options to VotingContextMenuItems for posts and comments - Update PostDisplayView to support unvote with toggle between upvote/unvote - Add unvote swipe actions and context menus in FeedView - Add unvote swipe actions and context menus in CommentsView for posts and comments The feature includes: - Optimistic UI updates with automatic rollback on error - Swipe actions (orange arrow.uturn.down icon for unvote) - Context menu options - Proper accessibility labels and hints - Automatic detection of unvote availability based on server response --- Data/Sources/Data/PostRepository+Voting.swift | 36 +++++- .../Components/PostDisplayView.swift | 79 ++++++++++--- .../DesignSystem/Components/VoteButton.swift | 6 +- .../Components/VotingContextMenuItems.swift | 36 +++++- Domain/Sources/Domain/Models.swift | 3 + Domain/Sources/Domain/VoteUseCase.swift | 2 + .../Sources/Domain/VotingStateProvider.swift | 19 ++++ .../Sources/Comments/CommentsComponents.swift | 107 ++++++++++++++---- Features/Feed/Sources/Feed/FeedView.swift | 72 ++++++++++-- .../Shared/ViewModels/VotingViewModel.swift | 62 +++++++++- 10 files changed, 358 insertions(+), 64 deletions(-) diff --git a/Data/Sources/Data/PostRepository+Voting.swift b/Data/Sources/Data/PostRepository+Voting.swift index e6dab419..c9c965dc 100644 --- a/Data/Sources/Data/PostRepository+Voting.swift +++ b/Data/Sources/Data/PostRepository+Voting.swift @@ -37,7 +37,23 @@ extension PostRepository { if containsLoginForm { throw HackersKitError.unauthenticated } } - // Unvote functionality removed + public func unvote(post: Post) async throws { + guard let voteLinks = post.voteLinks else { throw HackersKitError.unauthenticated } + guard let unvoteURL = voteLinks.unvote else { + throw HackersKitError.scraperError + } + + let fullURLString = unvoteURL.absoluteString.hasPrefix("http") + ? unvoteURL.absoluteString + : urlBase + "/" + unvoteURL.absoluteString + guard let realURL = URL(string: fullURLString) else { throw HackersKitError.scraperError } + + let response = try await networkManager.get(url: realURL) + let containsLoginForm = + response.contains("
Void)? let onUpvoteTap: (() async -> Bool)? + let onUnvoteTap: (() async -> Bool)? let onBookmarkTap: (() async -> Bool)? let onCommentsTap: (() -> Void)? @@ -33,6 +34,7 @@ public struct PostDisplayView: View { showThumbnails: Bool = true, onThumbnailTap: (() -> Void)? = nil, onUpvoteTap: (() async -> Bool)? = nil, + onUnvoteTap: (() async -> Bool)? = nil, onBookmarkTap: (() async -> Bool)? = nil, onCommentsTap: (() -> Void)? = nil ) { @@ -42,6 +44,7 @@ public struct PostDisplayView: View { self.showThumbnails = showThumbnails self.onThumbnailTap = onThumbnailTap self.onUpvoteTap = onUpvoteTap + self.onUnvoteTap = onUnvoteTap self.onBookmarkTap = onBookmarkTap self.onCommentsTap = onCommentsTap _displayedScore = State(initialValue: post.score) @@ -125,7 +128,8 @@ public struct PostDisplayView: View { let isUpvoted = displayedUpvoted let isLoading = isSubmittingUpvote let canVote = post.voteLinks?.upvote != nil - let canInteract = canVote && !isUpvoted && !isLoading + let canUnvote = post.voteLinks?.unvote != nil + let canInteract = ((canVote && !isUpvoted) || (canUnvote && isUpvoted)) && !isLoading // Avoid keeping a disabled Button so the upvoted state retains the bright tint let (backgroundColor, textColor): (Color, Color) = { let style = AppColors.PillStyle.upvote(isActive: isUpvoted) @@ -135,12 +139,19 @@ public struct PostDisplayView: View { }() let iconName = isUpvoted ? "arrow.up.circle.fill" : "arrow.up" let accessibilityLabel: String + let accessibilityHint: String if isLoading { accessibilityLabel = "Submitting vote" + accessibilityHint = "" + } else if isUpvoted && canUnvote { + accessibilityLabel = "\(score) points, upvoted" + accessibilityHint = "Double tap to unvote" } else if isUpvoted { accessibilityLabel = "\(score) points, upvoted" + accessibilityHint = "" } else { accessibilityLabel = "\(score) points" + accessibilityHint = "Double tap to upvote" } return pillView( @@ -149,7 +160,7 @@ public struct PostDisplayView: View { textColor: textColor, backgroundColor: backgroundColor, accessibilityLabel: accessibilityLabel, - accessibilityHint: "Double tap to upvote", + accessibilityHint: accessibilityHint, isHighlighted: isUpvoted, isLoading: isLoading, isEnabled: canInteract, @@ -202,22 +213,47 @@ public struct PostDisplayView: View { } private func makeUpvoteAction() -> (() -> Void)? { - guard let onUpvoteTap else { return nil } return { guard !isSubmittingUpvote else { return } - isSubmittingUpvote = true - let previousScore = displayedScore - let previousUpvoted = displayedUpvoted - displayedUpvoted = true - displayedScore += 1 - Task { - let success = await onUpvoteTap() - await MainActor.run { - if !success { - displayedScore = previousScore - displayedUpvoted = previousUpvoted + + let isCurrentlyUpvoted = displayedUpvoted + let canUnvote = post.voteLinks?.unvote != nil + + // If already upvoted and can unvote, perform unvote + if isCurrentlyUpvoted && canUnvote { + guard let onUnvoteTap else { return } + isSubmittingUpvote = true + let previousScore = displayedScore + let previousUpvoted = displayedUpvoted + displayedUpvoted = false + displayedScore -= 1 + Task { + let success = await onUnvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + } + isSubmittingUpvote = false + } + } + } else { + // Perform upvote + guard let onUpvoteTap else { return } + isSubmittingUpvote = true + let previousScore = displayedScore + let previousUpvoted = displayedUpvoted + displayedUpvoted = true + displayedScore += 1 + Task { + let success = await onUpvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + } + isSubmittingUpvote = false } - isSubmittingUpvote = false } } } @@ -298,24 +334,27 @@ public struct PostDisplayView: View { public struct PostContextMenu: View { let post: Post let onVote: () -> Void + let onUnvote: () -> Void let onOpenLink: () -> Void let onShare: () -> Void public init( post: Post, onVote: @escaping () -> Void, + onUnvote: @escaping () -> Void = {}, onOpenLink: @escaping () -> Void, onShare: @escaping () -> Void, ) { self.post = post self.onVote = onVote + self.onUnvote = onUnvote self.onOpenLink = onOpenLink self.onShare = onShare } public var body: some View { Group { - if post.voteLinks?.upvote != nil { + if post.voteLinks?.upvote != nil, !post.upvoted { Button { onVote() } label: { @@ -323,6 +362,14 @@ public struct PostContextMenu: View { } } + if post.voteLinks?.unvote != nil, post.upvoted { + Button { + onUnvote() + } label: { + Label("Unvote", systemImage: "arrow.uturn.down") + } + } + Divider() if !isHackerNewsItemURL(post.url) { diff --git a/DesignSystem/Sources/DesignSystem/Components/VoteButton.swift b/DesignSystem/Sources/DesignSystem/Components/VoteButton.swift index 91aed5a6..cf8d5377 100644 --- a/DesignSystem/Sources/DesignSystem/Components/VoteButton.swift +++ b/DesignSystem/Sources/DesignSystem/Components/VoteButton.swift @@ -44,11 +44,11 @@ public struct VoteButton: View { } } } - .disabled(!votingState.canVote || votingState.isVoting) + .disabled((!votingState.canVote && !votingState.canUnvote) || votingState.isVoting) .scaleEffect(votingState.isVoting ? 0.95 : 1.0) .animation(.easeInOut(duration: 0.1), value: votingState.isVoting) - .accessibilityLabel(votingState.isUpvoted ? "Upvoted" : "Upvote") - .accessibilityHint(votingState.isUpvoted ? "Already upvoted" : (votingState.canVote ? "Double-tap to upvote" : "Voting unavailable")) + .accessibilityLabel(votingState.isUpvoted && votingState.canUnvote ? "Unvote" : (votingState.isUpvoted ? "Upvoted" : "Upvote")) + .accessibilityHint(votingState.isUpvoted && votingState.canUnvote ? "Double-tap to unvote" : (votingState.isUpvoted ? "Already upvoted" : (votingState.canVote ? "Double-tap to upvote" : "Voting unavailable"))) .accessibilityValue({ () -> String in if let score = votingState.score { return "\(score) points" } return "" diff --git a/DesignSystem/Sources/DesignSystem/Components/VotingContextMenuItems.swift b/DesignSystem/Sources/DesignSystem/Components/VotingContextMenuItems.swift index 3dbf8ce2..435f0423 100644 --- a/DesignSystem/Sources/DesignSystem/Components/VotingContextMenuItems.swift +++ b/DesignSystem/Sources/DesignSystem/Components/VotingContextMenuItems.swift @@ -15,8 +15,9 @@ public enum VotingContextMenuItems { public static func postVotingMenuItems( for post: Post, onVote: @escaping @Sendable () -> Void, + onUnvote: @escaping @Sendable () -> Void = {}, ) -> some View { - // Only show upvote if available and not already upvoted + // Show upvote if available and not already upvoted if post.voteLinks?.upvote != nil, !post.upvoted { Button { onVote() @@ -24,6 +25,14 @@ public enum VotingContextMenuItems { Label("Upvote", systemImage: "arrow.up") } } + // Show unvote if available and already upvoted + if post.voteLinks?.unvote != nil, post.upvoted { + Button { + onUnvote() + } label: { + Label("Unvote", systemImage: "arrow.uturn.down") + } + } } // MARK: - Comment Voting Menu Items @@ -32,8 +41,9 @@ public enum VotingContextMenuItems { public static func commentVotingMenuItems( for comment: Comment, onVote: @escaping @Sendable () -> Void, + onUnvote: @escaping @Sendable () -> Void = {}, ) -> some View { - // Only show upvote if available and not already upvoted + // Show upvote if available and not already upvoted if comment.voteLinks?.upvote != nil, !comment.upvoted { Button { onVote() @@ -41,6 +51,14 @@ public enum VotingContextMenuItems { Label("Upvote", systemImage: "arrow.up") } } + // Show unvote if available and already upvoted + if comment.voteLinks?.unvote != nil, comment.upvoted { + Button { + onUnvote() + } label: { + Label("Unvote", systemImage: "arrow.uturn.down") + } + } } // MARK: - Generic Votable Menu Items @@ -49,8 +67,9 @@ public enum VotingContextMenuItems { public static func votingMenuItems( for item: some Votable, onVote: @escaping @Sendable () -> Void, + onUnvote: @escaping @Sendable () -> Void = {}, ) -> some View { - // Only show upvote if available and not already upvoted + // Show upvote if available and not already upvoted if item.voteLinks?.upvote != nil, !item.upvoted { Button { onVote() @@ -58,6 +77,14 @@ public enum VotingContextMenuItems { Label("Upvote", systemImage: "arrow.up") } } + // Show unvote if available and already upvoted + if item.voteLinks?.unvote != nil, item.upvoted { + Button { + onUnvote() + } label: { + Label("Unvote", systemImage: "arrow.uturn.down") + } + } } } @@ -90,10 +117,11 @@ public extension View { func votingContextMenu( for item: some Votable, onVote: @escaping @Sendable () -> Void, + onUnvote: @escaping @Sendable () -> Void = {}, additionalItems: @escaping () -> some View = { EmptyView() }, ) -> some View { contextMenu { - VotingContextMenuItems.votingMenuItems(for: item, onVote: onVote) + VotingContextMenuItems.votingMenuItems(for: item, onVote: onVote, onUnvote: onUnvote) additionalItems() } } diff --git a/Domain/Sources/Domain/Models.swift b/Domain/Sources/Domain/Models.swift index ba211e7e..c4953e13 100644 --- a/Domain/Sources/Domain/Models.swift +++ b/Domain/Sources/Domain/Models.swift @@ -14,6 +14,7 @@ public struct VotingState: Sendable { public let isUpvoted: Bool public let score: Int? public let canVote: Bool + public let canUnvote: Bool public let isVoting: Bool public let error: Error? @@ -21,12 +22,14 @@ public struct VotingState: Sendable { isUpvoted: Bool, score: Int? = nil, canVote: Bool, + canUnvote: Bool = false, isVoting: Bool = false, error: Error? = nil, ) { self.isUpvoted = isUpvoted self.score = score self.canVote = canVote + self.canUnvote = canUnvote self.isVoting = isVoting self.error = error } diff --git a/Domain/Sources/Domain/VoteUseCase.swift b/Domain/Sources/Domain/VoteUseCase.swift index 97e09d56..e082c3f3 100644 --- a/Domain/Sources/Domain/VoteUseCase.swift +++ b/Domain/Sources/Domain/VoteUseCase.swift @@ -10,4 +10,6 @@ import Foundation public protocol VoteUseCase: Sendable { func upvote(post: Post) async throws func upvote(comment: Comment, for post: Post) async throws + func unvote(post: Post) async throws + func unvote(comment: Comment, for post: Post) async throws } diff --git a/Domain/Sources/Domain/VotingStateProvider.swift b/Domain/Sources/Domain/VotingStateProvider.swift index 80b55691..f721d4a5 100644 --- a/Domain/Sources/Domain/VotingStateProvider.swift +++ b/Domain/Sources/Domain/VotingStateProvider.swift @@ -12,6 +12,7 @@ import Foundation public protocol VotingStateProvider: Sendable { func votingState(for item: any Votable) -> VotingState func upvote(item: any Votable) async throws + func unvote(item: any Votable) async throws } // MARK: - Default Implementation @@ -29,6 +30,7 @@ public final class DefaultVotingStateProvider: VotingStateProvider, Sendable { isUpvoted: item.upvoted, score: score, canVote: item.voteLinks?.upvote != nil, + canUnvote: item.voteLinks?.unvote != nil, isVoting: false, ) } @@ -44,16 +46,33 @@ public final class DefaultVotingStateProvider: VotingStateProvider, Sendable { throw HackersKitError.requestFailure } } + + public func unvote(item: any Votable) async throws { + switch item { + case let post as Post: + try await voteUseCase.unvote(post: post) + case let comment as Comment: + // For comments, we need the parent post - this will be handled by the calling code + throw HackersKitError.requestFailure + default: + throw HackersKitError.requestFailure + } + } } // MARK: - Comment-Specific Voting State Provider public protocol CommentVotingStateProvider: Sendable { func upvoteComment(_ comment: Comment, for post: Post) async throws + func unvoteComment(_ comment: Comment, for post: Post) async throws } extension DefaultVotingStateProvider: CommentVotingStateProvider { public func upvoteComment(_ comment: Comment, for post: Post) async throws { try await voteUseCase.upvote(comment: comment, for: post) } + + public func unvoteComment(_ comment: Comment, for post: Post) async throws { + try await voteUseCase.unvote(comment: comment, for: post) + } } diff --git a/Features/Comments/Sources/Comments/CommentsComponents.swift b/Features/Comments/Sources/Comments/CommentsComponents.swift index 2b41fb19..ed82e47a 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -57,28 +57,51 @@ struct CommentsContentView: View { ) }) .listRowSeparator(.hidden) - .if(post.voteLinks?.upvote != nil && !post.upvoted) { view in + .if((post.voteLinks?.upvote != nil && !post.upvoted) || (post.voteLinks?.unvote != nil && post.upvoted)) { view in view.swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - Task { - var mutablePost = post - await votingViewModel.upvote(post: &mutablePost) - await MainActor.run { - if mutablePost.upvoted, - var currentPost = viewModel.post, - !currentPost.upvoted - { - currentPost.upvoted = true - currentPost.score += 1 - viewModel.post = currentPost + if post.upvoted && post.voteLinks?.unvote != nil { + Button { + Task { + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + await MainActor.run { + if !mutablePost.upvoted, + var currentPost = viewModel.post, + currentPost.upvoted + { + currentPost.upvoted = false + currentPost.score -= 1 + viewModel.post = currentPost + } } } + } label: { + Image(systemName: "arrow.uturn.down") } - } label: { - Image(systemName: "arrow.up") + .tint(.orange) + .accessibilityLabel("Unvote") + } else { + Button { + Task { + var mutablePost = post + await votingViewModel.upvote(post: &mutablePost) + await MainActor.run { + if mutablePost.upvoted, + var currentPost = viewModel.post, + !currentPost.upvoted + { + currentPost.upvoted = true + currentPost.score += 1 + viewModel.post = currentPost + } + } + } + } label: { + Image(systemName: "arrow.up") + } + .tint(AppColors.upvotedColor) + .accessibilityLabel("Upvote") } - .tint(AppColors.upvotedColor) - .accessibilityLabel("Upvote") } } @@ -164,17 +187,29 @@ struct CommentsForEach: View { ) }) .listRowSeparator(.hidden) - .if(comment.voteLinks?.upvote != nil && !comment.upvoted) { view in + .if((comment.voteLinks?.upvote != nil && !comment.upvoted) || (comment.voteLinks?.unvote != nil && comment.upvoted)) { view in view.swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - Task { - await votingViewModel.upvote(comment: comment, in: post) + if comment.upvoted && comment.voteLinks?.unvote != nil { + Button { + Task { + await votingViewModel.unvote(comment: comment, in: post) + } + } label: { + Image(systemName: "arrow.uturn.down") } - } label: { - Image(systemName: "arrow.up") + .tint(.orange) + .accessibilityLabel("Unvote") + } else { + Button { + Task { + await votingViewModel.upvote(comment: comment, in: post) + } + } label: { + Image(systemName: "arrow.up") + } + .tint(AppColors.upvotedColor) + .accessibilityLabel("Upvote") } - .tint(AppColors.upvotedColor) - .accessibilityLabel("Upvote") } } .swipeActions(edge: .trailing) { @@ -207,6 +242,7 @@ struct PostHeader: View { showThumbnails: showThumbnails, onThumbnailTap: { onLinkTap() }, onUpvoteTap: { await handleUpvote() }, + onUnvoteTap: { await handleUnvote() }, onBookmarkTap: { await onBookmarkToggle() } ) .contentShape(Rectangle()) @@ -215,6 +251,7 @@ struct PostHeader: View { VotingContextMenuItems.postVotingMenuItems( for: post, onVote: { Task { await handleUpvote() } }, + onUnvote: { Task { await handleUnvote() } } ) Divider() @@ -244,6 +281,22 @@ struct PostHeader: View { return wasUpvoted } + + private func handleUnvote() async -> Bool { + guard votingViewModel.canUnvote(item: post), post.upvoted else { return true } + + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + let wasUnvoted = !mutablePost.upvoted + + if wasUnvoted { + await MainActor.run { + onUpvoteApplied() + } + } + + return wasUnvoted + } } struct CommentRow: View { @@ -290,6 +343,7 @@ struct CommentRow: View { isUpvoted: comment.upvoted, score: nil, canVote: comment.voteLinks?.upvote != nil, + canUnvote: comment.voteLinks?.unvote != nil, isVoting: votingViewModel.isVoting, error: votingViewModel.lastError, ), @@ -319,6 +373,9 @@ struct CommentRow: View { onVote: { Task { await votingViewModel.upvote(comment: comment, in: post) } }, + onUnvote: { + Task { await votingViewModel.unvote(comment: comment, in: post) } + } ) Button { UIPasteboard.general.string = comment.text.strippingHTML() } label: { Label("Copy", systemImage: "doc.on.doc") diff --git a/Features/Feed/Sources/Feed/FeedView.swift b/Features/Feed/Sources/Feed/FeedView.swift index b57b9145..8cdcad40 100644 --- a/Features/Feed/Sources/Feed/FeedView.swift +++ b/Features/Feed/Sources/Feed/FeedView.swift @@ -200,7 +200,7 @@ public struct FeedView: View { } } } - .if(post.voteLinks?.upvote != nil && !post.upvoted) { view in + .if((post.voteLinks?.upvote != nil && !post.upvoted) || (post.voteLinks?.unvote != nil && post.upvoted)) { view in view.swipeActions(edge: .leading, allowsFullSwipe: true) { voteSwipeAction(for: post) } @@ -212,21 +212,41 @@ public struct FeedView: View { @ViewBuilder private func voteSwipeAction(for post: Domain.Post) -> some View { - Button { - Task { - var mutablePost = post - await votingViewModel.upvote(post: &mutablePost) - await MainActor.run { - if mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + if post.upvoted && post.voteLinks?.unvote != nil { + // Unvote action + Button { + Task { + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + await MainActor.run { + if !mutablePost.upvoted { + viewModel.applyLocalUpvote(to: post.id) + } } } + } label: { + Image(systemName: "arrow.uturn.down") } - } label: { - Image(systemName: "arrow.up") + .tint(.orange) + .accessibilityLabel("Unvote") + } else { + // Upvote action + Button { + Task { + var mutablePost = post + await votingViewModel.upvote(post: &mutablePost) + await MainActor.run { + if mutablePost.upvoted { + viewModel.applyLocalUpvote(to: post.id) + } + } + } + } label: { + Image(systemName: "arrow.up") + } + .tint(AppColors.upvotedColor) + .accessibilityLabel("Upvote") } - .tint(AppColors.upvotedColor) - .accessibilityLabel("Upvote") } @ViewBuilder @@ -244,6 +264,17 @@ public struct FeedView: View { } } }, + onUnvote: { + Task { + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + await MainActor.run { + if !mutablePost.upvoted { + viewModel.applyLocalUpvote(to: post.id) + } + } + } + } ) Divider() @@ -371,6 +402,7 @@ struct PostRowView: View { showThumbnails: showThumbnails, onThumbnailTap: onLinkTap, onUpvoteTap: { await handleUpvoteTap() }, + onUnvoteTap: { await handleUnvoteTap() }, onBookmarkTap: { guard let onBookmarkToggle else { return post.isBookmarked } return await onBookmarkToggle() @@ -405,4 +437,20 @@ struct PostRowView: View { return wasUpvoted } + + private func handleUnvoteTap() async -> Bool { + guard votingViewModel.canUnvote(item: post), post.upvoted else { return true } + + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + let wasUnvoted = !mutablePost.upvoted + + if wasUnvoted { + await MainActor.run { + onUpvoteApplied?(mutablePost.id) + } + } + + return wasUnvoted + } } diff --git a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift index f7d5d92a..9deadfaa 100644 --- a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift +++ b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift @@ -69,7 +69,36 @@ public final class VotingViewModel { isVoting = false } - // Unvote removed + public func unvote(post: inout Post) async { + guard post.upvoted else { return } + + let originalScore = post.score + + // Create a copy of the post with the original state for the voting provider + var postForVoting = post + postForVoting.upvoted = true + postForVoting.score = originalScore + + // Optimistic UI update + post.upvoted = false + post.score -= 1 + + isVoting = true + lastError = nil + + do { + try await votingStateProvider.unvote(item: postForVoting) + + } catch { + // Revert optimistic changes on error + post.upvoted = true + post.score = originalScore + + await handleUnauthenticatedIfNeeded(error) + } + + isVoting = false + } // MARK: - Comment Voting @@ -100,7 +129,31 @@ public final class VotingViewModel { isVoting = false } - // Comment unvote removed + public func unvote(comment: Comment, in post: Post) async { + guard comment.upvoted else { return } + + // Create a copy of the comment with the original state for the voting provider + var commentForVoting = comment + commentForVoting.upvoted = true + + // Optimistic UI update + comment.upvoted = false + + isVoting = true + lastError = nil + + do { + try await commentVotingStateProvider.unvoteComment(commentForVoting, for: post) + } catch { + // Revert optimistic changes on error + comment.upvoted = true + + // Check if error is unauthenticated and show login + await handleUnauthenticatedIfNeeded(error) + } + + isVoting = false + } // MARK: - State Helpers @@ -110,6 +163,7 @@ public final class VotingViewModel { isUpvoted: baseState.isUpvoted, score: baseState.score, canVote: baseState.canVote, + canUnvote: baseState.canUnvote, isVoting: isVoting, error: lastError ) @@ -119,6 +173,10 @@ public final class VotingViewModel { item.voteLinks?.upvote != nil } + public func canUnvote(item: any Votable) -> Bool { + item.voteLinks?.unvote != nil + } + public func clearError() { lastError = nil } From 567f97b306d6c4412f3babcbdb1786cc35477580 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 06:28:51 +0900 Subject: [PATCH 02/11] Fix immediate unvote handling --- .../Sources/Comments/CommentsComponents.swift | 32 ++----- .../CommentsViewModelTests.swift | 12 +++ Features/Feed/Sources/Feed/FeedView.swift | 22 ++--- .../Feed/Sources/Feed/FeedViewModel.swift | 11 +-- .../Tests/FeedTests/FeedViewModelTests.swift | 8 ++ .../Feed/Tests/FeedTests/FeedViewTests.swift | 4 +- .../Shared/ViewModels/VotingViewModel.swift | 35 ++++++++ .../DependencyContainerTests.swift | 6 ++ .../SharedTests/VotingViewModelTests.swift | 84 +++++++++++++++++++ 9 files changed, 171 insertions(+), 43 deletions(-) diff --git a/Features/Comments/Sources/Comments/CommentsComponents.swift b/Features/Comments/Sources/Comments/CommentsComponents.swift index ed82e47a..14ae52ca 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -40,12 +40,8 @@ struct CommentsContentView: View { votingViewModel: votingViewModel, showThumbnails: viewModel.showThumbnails, onLinkTap: { handleLinkTap() }, - onUpvoteApplied: { - if var currentPost = viewModel.post, !currentPost.upvoted { - currentPost.upvoted = true - currentPost.score += 1 - viewModel.post = currentPost - } + onPostUpdated: { updatedPost in + viewModel.post = updatedPost }, onBookmarkToggle: { await viewModel.toggleBookmark() } ) @@ -65,13 +61,8 @@ struct CommentsContentView: View { var mutablePost = post await votingViewModel.unvote(post: &mutablePost) await MainActor.run { - if !mutablePost.upvoted, - var currentPost = viewModel.post, - currentPost.upvoted - { - currentPost.upvoted = false - currentPost.score -= 1 - viewModel.post = currentPost + if !mutablePost.upvoted { + viewModel.post = mutablePost } } } @@ -86,13 +77,8 @@ struct CommentsContentView: View { var mutablePost = post await votingViewModel.upvote(post: &mutablePost) await MainActor.run { - if mutablePost.upvoted, - var currentPost = viewModel.post, - !currentPost.upvoted - { - currentPost.upvoted = true - currentPost.score += 1 - viewModel.post = currentPost + if mutablePost.upvoted { + viewModel.post = mutablePost } } } @@ -231,7 +217,7 @@ struct PostHeader: View { let votingViewModel: VotingViewModel let showThumbnails: Bool let onLinkTap: () -> Void - let onUpvoteApplied: @Sendable () -> Void + let onPostUpdated: @Sendable (Post) -> Void let onBookmarkToggle: @Sendable () async -> Bool var body: some View { @@ -275,7 +261,7 @@ struct PostHeader: View { if wasUpvoted { await MainActor.run { - onUpvoteApplied() + onPostUpdated(mutablePost) } } @@ -291,7 +277,7 @@ struct PostHeader: View { if wasUnvoted { await MainActor.run { - onUpvoteApplied() + onPostUpdated(mutablePost) } } diff --git a/Features/Comments/Tests/CommentsTests/CommentsViewModelTests.swift b/Features/Comments/Tests/CommentsTests/CommentsViewModelTests.swift index 746d2e97..48a60ae6 100644 --- a/Features/Comments/Tests/CommentsTests/CommentsViewModelTests.swift +++ b/Features/Comments/Tests/CommentsTests/CommentsViewModelTests.swift @@ -631,6 +631,18 @@ final class MockVoteUseCase: VoteUseCase, @unchecked Sendable { throw MockError.testError } } + + func unvote(post _: Post) async throws { + if shouldThrowError { + throw MockError.testError + } + } + + func unvote(comment _: Domain.Comment, for _: Post) async throws { + if shouldThrowError { + throw MockError.testError + } + } } final class StubSettingsUseCase: SettingsUseCase, @unchecked Sendable { diff --git a/Features/Feed/Sources/Feed/FeedView.swift b/Features/Feed/Sources/Feed/FeedView.swift index 8cdcad40..7ac1bdb0 100644 --- a/Features/Feed/Sources/Feed/FeedView.swift +++ b/Features/Feed/Sources/Feed/FeedView.swift @@ -182,8 +182,8 @@ public struct FeedView: View { showThumbnails: viewModel.showThumbnails, onLinkTap: { handleLinkTap(post: post) }, onCommentsTap: isSidebar ? nil : { navigationStore.showPost(post) }, - onUpvoteApplied: { postId in - viewModel.applyLocalUpvote(to: postId) + onPostUpdated: { updatedPost in + viewModel.replacePost(updatedPost) }, onBookmarkToggle: { await viewModel.toggleBookmark(for: post) @@ -220,7 +220,7 @@ public struct FeedView: View { await votingViewModel.unvote(post: &mutablePost) await MainActor.run { if !mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + viewModel.replacePost(mutablePost) } } } @@ -237,7 +237,7 @@ public struct FeedView: View { await votingViewModel.upvote(post: &mutablePost) await MainActor.run { if mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + viewModel.replacePost(mutablePost) } } } @@ -259,7 +259,7 @@ public struct FeedView: View { await votingViewModel.upvote(post: &mutablePost) await MainActor.run { if mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + viewModel.replacePost(mutablePost) } } } @@ -270,7 +270,7 @@ public struct FeedView: View { await votingViewModel.unvote(post: &mutablePost) await MainActor.run { if !mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + viewModel.replacePost(mutablePost) } } } @@ -374,7 +374,7 @@ struct PostRowView: View { let onLinkTap: (() -> Void)? let onCommentsTap: (() -> Void)? let showThumbnails: Bool - let onUpvoteApplied: ((Int) -> Void)? + let onPostUpdated: ((Domain.Post) -> Void)? let onBookmarkToggle: (() async -> Bool)? init(post: Domain.Post, @@ -382,7 +382,7 @@ struct PostRowView: View { showThumbnails: Bool = true, onLinkTap: (() -> Void)? = nil, onCommentsTap: (() -> Void)? = nil, - onUpvoteApplied: ((Int) -> Void)? = nil, + onPostUpdated: ((Domain.Post) -> Void)? = nil, onBookmarkToggle: (() async -> Bool)? = nil) { self.post = post @@ -390,7 +390,7 @@ struct PostRowView: View { self.onLinkTap = onLinkTap self.onCommentsTap = onCommentsTap self.showThumbnails = showThumbnails - self.onUpvoteApplied = onUpvoteApplied + self.onPostUpdated = onPostUpdated self.onBookmarkToggle = onBookmarkToggle } @@ -431,7 +431,7 @@ struct PostRowView: View { if wasUpvoted { await MainActor.run { - onUpvoteApplied?(mutablePost.id) + onPostUpdated?(mutablePost) } } @@ -447,7 +447,7 @@ struct PostRowView: View { if wasUnvoted { await MainActor.run { - onUpvoteApplied?(mutablePost.id) + onPostUpdated?(mutablePost) } } diff --git a/Features/Feed/Sources/Feed/FeedViewModel.swift b/Features/Feed/Sources/Feed/FeedViewModel.swift index 4dc8eff2..01aaa97b 100644 --- a/Features/Feed/Sources/Feed/FeedViewModel.swift +++ b/Features/Feed/Sources/Feed/FeedViewModel.swift @@ -237,14 +237,9 @@ public final class FeedViewModel: @unchecked Sendable { if let index = feedLoader.data.firstIndex(where: { $0.id == updatedPost.id }) { feedLoader.data[index] = updatedPost } - } - - @MainActor - public func applyLocalUpvote(to postId: Int) { - guard let index = feedLoader.data.firstIndex(where: { $0.id == postId }) else { return } - guard feedLoader.data[index].upvoted == false else { return } - feedLoader.data[index].upvoted = true - feedLoader.data[index].score += 1 + if let searchIndex = searchResults.firstIndex(where: { $0.id == updatedPost.id }) { + searchResults[searchIndex] = updatedPost + } } @MainActor diff --git a/Features/Feed/Tests/FeedTests/FeedViewModelTests.swift b/Features/Feed/Tests/FeedTests/FeedViewModelTests.swift index 69531959..de042449 100644 --- a/Features/Feed/Tests/FeedTests/FeedViewModelTests.swift +++ b/Features/Feed/Tests/FeedTests/FeedViewModelTests.swift @@ -359,6 +359,14 @@ private final class StubVoteUseCase: VoteUseCase, @unchecked Sendable { func upvote(comment _: Domain.Comment, for _: Post) async throws { if shouldThrow { throw StubError.network } } + + func unvote(post _: Post) async throws { + if shouldThrow { throw StubError.network } + } + + func unvote(comment _: Domain.Comment, for _: Post) async throws { + if shouldThrow { throw StubError.network } + } } private final class StubBookmarksUseCase: BookmarksUseCase, @unchecked Sendable { diff --git a/Features/Feed/Tests/FeedTests/FeedViewTests.swift b/Features/Feed/Tests/FeedTests/FeedViewTests.swift index 8496a0aa..e7d3c326 100644 --- a/Features/Feed/Tests/FeedTests/FeedViewTests.swift +++ b/Features/Feed/Tests/FeedTests/FeedViewTests.swift @@ -113,7 +113,9 @@ struct FeedViewTests { // Not used in feed } - // Unvote removed + func unvote(post _: Post) async throws {} + + func unvote(comment _: Domain.Comment, for _: Post) async throws {} } final class MockBookmarksUseCase: BookmarksUseCase, @unchecked Sendable { diff --git a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift index 9deadfaa..d209ac5c 100644 --- a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift +++ b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift @@ -42,6 +42,7 @@ public final class VotingViewModel { guard !post.upvoted else { return } let originalScore = post.score + let originalVoteLinks = post.voteLinks // Create a copy of the post with the original state for the voting provider var postForVoting = post @@ -51,6 +52,7 @@ public final class VotingViewModel { // Optimistic UI update post.upvoted = true post.score += 1 + post.voteLinks = ensureUnvoteLinkIfPossible(from: originalVoteLinks) isVoting = true lastError = nil @@ -62,6 +64,7 @@ public final class VotingViewModel { // Revert optimistic changes on error post.upvoted = false post.score = originalScore + post.voteLinks = originalVoteLinks await handleUnauthenticatedIfNeeded(error) } @@ -109,9 +112,11 @@ public final class VotingViewModel { // Create a copy of the comment with the original state for the voting provider var commentForVoting = comment commentForVoting.upvoted = false + let originalVoteLinks = comment.voteLinks // Optimistic UI update comment.upvoted = true + comment.voteLinks = ensureUnvoteLinkIfPossible(from: originalVoteLinks) isVoting = true lastError = nil @@ -121,6 +126,7 @@ public final class VotingViewModel { } catch { // Revert optimistic changes on error comment.upvoted = false + comment.voteLinks = originalVoteLinks // Check if error is unauthenticated and show login await handleUnauthenticatedIfNeeded(error) @@ -199,4 +205,33 @@ public final class VotingViewModel { // Prompt login navigationStore?.showLogin() } + + private func ensureUnvoteLinkIfPossible(from voteLinks: VoteLinks?) -> VoteLinks? { + guard let voteLinks else { return nil } + if voteLinks.unvote != nil { + return voteLinks + } + guard let upvoteURL = voteLinks.upvote else { + return voteLinks + } + + guard let derivedUnvoteURL = deriveUnvoteURL(from: upvoteURL) else { + return voteLinks + } + return VoteLinks(upvote: voteLinks.upvote, unvote: derivedUnvoteURL) + } + + private func deriveUnvoteURL(from upvoteURL: URL) -> URL? { + let absoluteString = upvoteURL.absoluteString + + if absoluteString.contains("how=up") { + return URL(string: absoluteString.replacingOccurrences(of: "how=up", with: "how=un")) + } + + if absoluteString.contains("how%3Dup") { + return URL(string: absoluteString.replacingOccurrences(of: "how%3Dup", with: "how%3Dun")) + } + + return nil + } } diff --git a/Shared/Tests/SharedTests/DependencyContainerTests.swift b/Shared/Tests/SharedTests/DependencyContainerTests.swift index 9d51b3ba..a3641eae 100644 --- a/Shared/Tests/SharedTests/DependencyContainerTests.swift +++ b/Shared/Tests/SharedTests/DependencyContainerTests.swift @@ -121,6 +121,8 @@ private final class StubPostRepository: PostUseCase, VoteUseCase, CommentUseCase func getComments(for _: Post) async throws -> [Domain.Comment] { [] } func upvote(post _: Post) async throws {} func upvote(comment _: Domain.Comment, for _: Post) async throws {} + func unvote(post _: Post) async throws {} + func unvote(comment _: Domain.Comment, for _: Post) async throws {} } private final class StubSettingsUseCase: SettingsUseCase, @unchecked Sendable { @@ -145,6 +147,10 @@ private final class StubVotingStateProvider: func upvoteComment(_ comment: Domain.Comment, for _: Post) async throws { comment.upvoted = true } + func unvote(item _: any Votable) async throws {} + func unvoteComment(_ comment: Domain.Comment, for _: Post) async throws { + comment.upvoted = false + } } private final class StubAuthenticationUseCase: AuthenticationUseCase, @unchecked Sendable { diff --git a/Shared/Tests/SharedTests/VotingViewModelTests.swift b/Shared/Tests/SharedTests/VotingViewModelTests.swift index ca8be012..d9084be8 100644 --- a/Shared/Tests/SharedTests/VotingViewModelTests.swift +++ b/Shared/Tests/SharedTests/VotingViewModelTests.swift @@ -42,6 +42,8 @@ struct VotingViewModelTests { upvoteCalled = true if let errorToThrow { throw errorToThrow } } + + func unvote(item _: any Votable) async throws {} } final class MockCommentVotingStateProvider: CommentVotingStateProvider, @unchecked Sendable { @@ -54,6 +56,8 @@ struct VotingViewModelTests { throw HackersKitError.requestFailure } } + + func unvoteComment(_: Domain.Comment, for _: Post) async throws {} } final class MockAuthenticationUseCase: AuthenticationUseCase, @unchecked Sendable { @@ -131,6 +135,42 @@ struct VotingViewModelTests { #expect(post.upvoted == false && post.score == 10, "Optimistic state should be reverted") } + @Test("Post upvote synthesizes unvote URL when missing") + @MainActor + func postUpvoteSynthesizesUnvoteURL() async throws { + mockVotingStateProvider.upvoteCalled = false + + var post = Post( + id: 42, + url: URL(string: "https://example.com")!, + title: "Synth Test", + age: "1h", + commentsCount: 2, + by: "tester", + score: 5, + postType: .news, + upvoted: false, + voteLinks: VoteLinks( + upvote: URL(string: "https://news.ycombinator.com/vote?id=42&how=up&auth=abc123&goto=news")!, + unvote: nil + ) + ) + + await votingViewModel.upvote(post: &post) + + #expect(mockVotingStateProvider.upvoteCalled, "Upvote should be attempted") + + guard let unvoteURL = post.voteLinks?.unvote else { + Issue.record("Expected synthesized unvote URL") + return + } + + let absolute = unvoteURL.absoluteString + #expect(absolute.contains("how=un"), "Unvote URL should set how=un") + #expect(absolute.contains("auth=abc123"), "Unvote URL should preserve auth token") + #expect(absolute.contains("goto=news"), "Unvote URL should preserve goto parameter") + } + // MARK: - Comment Voting Tests @Test("Comment voting with MainActor") @@ -168,6 +208,50 @@ struct VotingViewModelTests { #expect(comment.upvoted == true, "Comment should be marked as upvoted after upvote") } + @Test("Comment upvote synthesizes unvote URL when missing") + @MainActor + func commentUpvoteSynthesizesUnvoteURL() async throws { + mockCommentVotingStateProvider.shouldThrow = false + mockCommentVotingStateProvider.upvoteCommentCalled = false + + let voteLinks = VoteLinks( + upvote: URL(string: "vote?id=123&how=up&auth=xyz&goto=item%3Fid%3D456")!, + unvote: nil + ) + let comment = Domain.Comment( + id: 123, + age: "1h", + text: "Test comment", + by: "user", + level: 0, + upvoted: false, + voteLinks: voteLinks + ) + + let post = Post( + id: 456, + url: URL(string: "https://example.com")!, + title: "Test Post", + age: "2h", + commentsCount: 1, + by: "author", + score: 10, + postType: .news, + upvoted: false + ) + + await votingViewModel.upvote(comment: comment, in: post) + + #expect(mockCommentVotingStateProvider.upvoteCommentCalled, "Upvote should be attempted") + guard let commentUnvote = comment.voteLinks?.unvote else { + Issue.record("Expected synthesized unvote URL for comment") + return + } + let absolute = commentUnvote.absoluteString + #expect(absolute.contains("how=un"), "Unvote URL should use how=un") + #expect(absolute.contains("auth=xyz"), "Auth token should be preserved") + } + @Test("Comment voting error handling") @MainActor func commentVotingErrorHandling() async throws { From 990616eb87d2162ea5c3f07ba7f91cd0b5d23912 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 06:35:48 +0900 Subject: [PATCH 03/11] Preserve synthesized unvote state in UI --- .../Components/PostDisplayView.swift | 42 +++++++++++++++++-- .../Sources/Comments/CommentsComponents.swift | 6 +++ Features/Feed/Sources/Feed/FeedView.swift | 9 ++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 54a43bb0..3bb98ed4 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -26,6 +26,7 @@ public struct PostDisplayView: View { @State private var displayedScore: Int @State private var displayedUpvoted: Bool @State private var displayedBookmarked: Bool + @State private var displayedVoteLinks: VoteLinks? public init( post: Post, @@ -50,6 +51,7 @@ public struct PostDisplayView: View { _displayedScore = State(initialValue: post.score) _displayedUpvoted = State(initialValue: post.upvoted) _displayedBookmarked = State(initialValue: post.isBookmarked) + _displayedVoteLinks = State(initialValue: post.voteLinks) } public var body: some View { @@ -101,6 +103,7 @@ public struct PostDisplayView: View { displayedScore = post.score displayedUpvoted = post.upvoted displayedBookmarked = post.isBookmarked + displayedVoteLinks = post.voteLinks } .onChange(of: post.score) { newValue in displayedScore = newValue @@ -111,6 +114,9 @@ public struct PostDisplayView: View { .onChange(of: post.isBookmarked) { newValue in displayedBookmarked = newValue } + .onChange(of: post.voteLinks) { newValue in + displayedVoteLinks = newValue + } .onChange(of: votingState?.score) { newValue in if let newValue { displayedScore = newValue @@ -127,8 +133,9 @@ public struct PostDisplayView: View { let score = displayedScore let isUpvoted = displayedUpvoted let isLoading = isSubmittingUpvote - let canVote = post.voteLinks?.upvote != nil - let canUnvote = post.voteLinks?.unvote != nil + let currentVoteLinks = displayedVoteLinks ?? post.voteLinks + let canVote = currentVoteLinks?.upvote != nil + let canUnvote = currentVoteLinks?.unvote != nil let canInteract = ((canVote && !isUpvoted) || (canUnvote && isUpvoted)) && !isLoading // Avoid keeping a disabled Button so the upvoted state retains the bright tint let (backgroundColor, textColor): (Color, Color) = { @@ -217,7 +224,8 @@ public struct PostDisplayView: View { guard !isSubmittingUpvote else { return } let isCurrentlyUpvoted = displayedUpvoted - let canUnvote = post.voteLinks?.unvote != nil + let currentVoteLinks = displayedVoteLinks ?? post.voteLinks + let canUnvote = currentVoteLinks?.unvote != nil // If already upvoted and can unvote, perform unvote if isCurrentlyUpvoted && canUnvote { @@ -225,14 +233,17 @@ public struct PostDisplayView: View { isSubmittingUpvote = true let previousScore = displayedScore let previousUpvoted = displayedUpvoted + let previousVoteLinks = currentVoteLinks displayedUpvoted = false displayedScore -= 1 + displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil) Task { let success = await onUnvoteTap() await MainActor.run { if !success { displayedScore = previousScore displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks } isSubmittingUpvote = false } @@ -243,14 +254,17 @@ public struct PostDisplayView: View { isSubmittingUpvote = true let previousScore = displayedScore let previousUpvoted = displayedUpvoted + let previousVoteLinks = currentVoteLinks displayedUpvoted = true displayedScore += 1 + displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks) Task { let success = await onUpvoteTap() await MainActor.run { if !success { displayedScore = previousScore displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks } isSubmittingUpvote = false } @@ -259,6 +273,28 @@ public struct PostDisplayView: View { } } + private func derivedVoteLinks(afterUpvoteFrom voteLinks: VoteLinks?) -> VoteLinks? { + guard let voteLinks else { return nil } + if voteLinks.unvote != nil { + return voteLinks + } + guard let upvoteURL = voteLinks.upvote else { + return voteLinks + } + let absolute = upvoteURL.absoluteString + if absolute.contains("how=up"), + let unvoteURL = URL(string: absolute.replacingOccurrences(of: "how=up", with: "how=un")) + { + return VoteLinks(upvote: upvoteURL, unvote: unvoteURL) + } + if absolute.contains("how%3Dup"), + let unvoteURL = URL(string: absolute.replacingOccurrences(of: "how%3Dup", with: "how%3Dun")) + { + return VoteLinks(upvote: upvoteURL, unvote: unvoteURL) + } + return voteLinks + } + private func makeBookmarkAction() -> (() -> Void)? { guard let onBookmarkTap else { return nil } return { diff --git a/Features/Comments/Sources/Comments/CommentsComponents.swift b/Features/Comments/Sources/Comments/CommentsComponents.swift index 14ae52ca..a88d1017 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -62,6 +62,9 @@ struct CommentsContentView: View { await votingViewModel.unvote(post: &mutablePost) await MainActor.run { if !mutablePost.upvoted { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } viewModel.post = mutablePost } } @@ -276,6 +279,9 @@ struct PostHeader: View { let wasUnvoted = !mutablePost.upvoted if wasUnvoted { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } await MainActor.run { onPostUpdated(mutablePost) } diff --git a/Features/Feed/Sources/Feed/FeedView.swift b/Features/Feed/Sources/Feed/FeedView.swift index 7ac1bdb0..97f11d5d 100644 --- a/Features/Feed/Sources/Feed/FeedView.swift +++ b/Features/Feed/Sources/Feed/FeedView.swift @@ -220,6 +220,9 @@ public struct FeedView: View { await votingViewModel.unvote(post: &mutablePost) await MainActor.run { if !mutablePost.upvoted { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } viewModel.replacePost(mutablePost) } } @@ -270,6 +273,9 @@ public struct FeedView: View { await votingViewModel.unvote(post: &mutablePost) await MainActor.run { if !mutablePost.upvoted { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } viewModel.replacePost(mutablePost) } } @@ -446,6 +452,9 @@ struct PostRowView: View { let wasUnvoted = !mutablePost.upvoted if wasUnvoted { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } await MainActor.run { onPostUpdated?(mutablePost) } From ebdf48580b1197ad32a540da7ea87e82b7121ab3 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 06:46:51 +0900 Subject: [PATCH 04/11] Queue vote actions while requests run --- .../Components/PostDisplayView.swift | 126 ++++++++++++------ 1 file changed, 85 insertions(+), 41 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 3bb98ed4..4cc2e06f 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -27,6 +27,12 @@ public struct PostDisplayView: View { @State private var displayedUpvoted: Bool @State private var displayedBookmarked: Bool @State private var displayedVoteLinks: VoteLinks? + @State private var pendingVoteIntent: VoteIntent? + + private enum VoteIntent { + case upvote + case unvote + } public init( post: Post, @@ -127,6 +133,11 @@ public struct PostDisplayView: View { displayedUpvoted = newValue } } + .onChange(of: votingState?.canUnvote) { _ in + if let voteLinks = post.voteLinks { + displayedVoteLinks = voteLinks + } + } } private var upvotePill: some View { @@ -221,58 +232,91 @@ public struct PostDisplayView: View { private func makeUpvoteAction() -> (() -> Void)? { return { - guard !isSubmittingUpvote else { return } - - let isCurrentlyUpvoted = displayedUpvoted let currentVoteLinks = displayedVoteLinks ?? post.voteLinks let canUnvote = currentVoteLinks?.unvote != nil + let intent: VoteIntent = (displayedUpvoted && canUnvote) ? .unvote : .upvote + Task { @MainActor in + handleVoteIntent(intent) + } + } + } - // If already upvoted and can unvote, perform unvote - if isCurrentlyUpvoted && canUnvote { - guard let onUnvoteTap else { return } - isSubmittingUpvote = true - let previousScore = displayedScore - let previousUpvoted = displayedUpvoted - let previousVoteLinks = currentVoteLinks - displayedUpvoted = false - displayedScore -= 1 - displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil) - Task { - let success = await onUnvoteTap() - await MainActor.run { - if !success { - displayedScore = previousScore - displayedUpvoted = previousUpvoted - displayedVoteLinks = previousVoteLinks - } - isSubmittingUpvote = false + @MainActor + private func handleVoteIntent(_ intent: VoteIntent) { + if isSubmittingUpvote { + pendingVoteIntent = intent + return + } + executeVoteIntent(intent) + } + + @MainActor + private func executeVoteIntent(_ intent: VoteIntent) { + pendingVoteIntent = nil + + let currentVoteLinks = displayedVoteLinks ?? post.voteLinks + let previousScore = displayedScore + let previousUpvoted = displayedUpvoted + let previousVoteLinks = currentVoteLinks + + switch intent { + case .unvote: + guard let onUnvoteTap else { return } + guard displayedUpvoted else { return } + + isSubmittingUpvote = true + displayedUpvoted = false + displayedScore -= 1 + displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil) + + Task { + let success = await onUnvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks } + isSubmittingUpvote = false + processPendingVoteIntentIfNeeded() } - } else { - // Perform upvote - guard let onUpvoteTap else { return } - isSubmittingUpvote = true - let previousScore = displayedScore - let previousUpvoted = displayedUpvoted - let previousVoteLinks = currentVoteLinks - displayedUpvoted = true - displayedScore += 1 - displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks) - Task { - let success = await onUpvoteTap() - await MainActor.run { - if !success { - displayedScore = previousScore - displayedUpvoted = previousUpvoted - displayedVoteLinks = previousVoteLinks - } - isSubmittingUpvote = false + } + + case .upvote: + guard let onUpvoteTap else { return } + guard !displayedUpvoted else { return } + guard currentVoteLinks?.upvote != nil else { return } + + isSubmittingUpvote = true + displayedUpvoted = true + displayedScore += 1 + displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks) + + Task { + let success = await onUpvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks + } else { + displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: displayedVoteLinks ?? previousVoteLinks) } + isSubmittingUpvote = false + processPendingVoteIntentIfNeeded() } } } } + @MainActor + private func processPendingVoteIntentIfNeeded() { + if let nextIntent = pendingVoteIntent { + pendingVoteIntent = nil + handleVoteIntent(nextIntent) + } + } + private func derivedVoteLinks(afterUpvoteFrom voteLinks: VoteLinks?) -> VoteLinks? { guard let voteLinks else { return nil } if voteLinks.unvote != nil { From 5cf830ce512ba80e94c9065218e0412ab94bed2d Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 06:54:09 +0900 Subject: [PATCH 05/11] Revert "Queue vote actions while requests run" This reverts commit ebdf48580b1197ad32a540da7ea87e82b7121ab3. --- .../Components/PostDisplayView.swift | 126 ++++++------------ 1 file changed, 41 insertions(+), 85 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 4cc2e06f..3bb98ed4 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -27,12 +27,6 @@ public struct PostDisplayView: View { @State private var displayedUpvoted: Bool @State private var displayedBookmarked: Bool @State private var displayedVoteLinks: VoteLinks? - @State private var pendingVoteIntent: VoteIntent? - - private enum VoteIntent { - case upvote - case unvote - } public init( post: Post, @@ -133,11 +127,6 @@ public struct PostDisplayView: View { displayedUpvoted = newValue } } - .onChange(of: votingState?.canUnvote) { _ in - if let voteLinks = post.voteLinks { - displayedVoteLinks = voteLinks - } - } } private var upvotePill: some View { @@ -232,91 +221,58 @@ public struct PostDisplayView: View { private func makeUpvoteAction() -> (() -> Void)? { return { + guard !isSubmittingUpvote else { return } + + let isCurrentlyUpvoted = displayedUpvoted let currentVoteLinks = displayedVoteLinks ?? post.voteLinks let canUnvote = currentVoteLinks?.unvote != nil - let intent: VoteIntent = (displayedUpvoted && canUnvote) ? .unvote : .upvote - Task { @MainActor in - handleVoteIntent(intent) - } - } - } - - @MainActor - private func handleVoteIntent(_ intent: VoteIntent) { - if isSubmittingUpvote { - pendingVoteIntent = intent - return - } - executeVoteIntent(intent) - } - - @MainActor - private func executeVoteIntent(_ intent: VoteIntent) { - pendingVoteIntent = nil - - let currentVoteLinks = displayedVoteLinks ?? post.voteLinks - let previousScore = displayedScore - let previousUpvoted = displayedUpvoted - let previousVoteLinks = currentVoteLinks - - switch intent { - case .unvote: - guard let onUnvoteTap else { return } - guard displayedUpvoted else { return } - isSubmittingUpvote = true - displayedUpvoted = false - displayedScore -= 1 - displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil) - - Task { - let success = await onUnvoteTap() - await MainActor.run { - if !success { - displayedScore = previousScore - displayedUpvoted = previousUpvoted - displayedVoteLinks = previousVoteLinks + // If already upvoted and can unvote, perform unvote + if isCurrentlyUpvoted && canUnvote { + guard let onUnvoteTap else { return } + isSubmittingUpvote = true + let previousScore = displayedScore + let previousUpvoted = displayedUpvoted + let previousVoteLinks = currentVoteLinks + displayedUpvoted = false + displayedScore -= 1 + displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil) + Task { + let success = await onUnvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks + } + isSubmittingUpvote = false } - isSubmittingUpvote = false - processPendingVoteIntentIfNeeded() } - } - - case .upvote: - guard let onUpvoteTap else { return } - guard !displayedUpvoted else { return } - guard currentVoteLinks?.upvote != nil else { return } - - isSubmittingUpvote = true - displayedUpvoted = true - displayedScore += 1 - displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks) - - Task { - let success = await onUpvoteTap() - await MainActor.run { - if !success { - displayedScore = previousScore - displayedUpvoted = previousUpvoted - displayedVoteLinks = previousVoteLinks - } else { - displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: displayedVoteLinks ?? previousVoteLinks) + } else { + // Perform upvote + guard let onUpvoteTap else { return } + isSubmittingUpvote = true + let previousScore = displayedScore + let previousUpvoted = displayedUpvoted + let previousVoteLinks = currentVoteLinks + displayedUpvoted = true + displayedScore += 1 + displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks) + Task { + let success = await onUpvoteTap() + await MainActor.run { + if !success { + displayedScore = previousScore + displayedUpvoted = previousUpvoted + displayedVoteLinks = previousVoteLinks + } + isSubmittingUpvote = false } - isSubmittingUpvote = false - processPendingVoteIntentIfNeeded() } } } } - @MainActor - private func processPendingVoteIntentIfNeeded() { - if let nextIntent = pendingVoteIntent { - pendingVoteIntent = nil - handleVoteIntent(nextIntent) - } - } - private func derivedVoteLinks(afterUpvoteFrom voteLinks: VoteLinks?) -> VoteLinks? { guard let voteLinks else { return nil } if voteLinks.unvote != nil { From dfde73b8b69df8b97f67b86d18d37ece4f0c6020 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 06:59:06 +0900 Subject: [PATCH 06/11] Disable vote pill while submission is in flight --- .../Components/PostDisplayView.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 3bb98ed4..8f288214 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -356,14 +356,25 @@ public struct PostDisplayView: View { Capsule() .fill(backgroundColor) ) - Button(action: action ?? {}) { - content + return Button(action: action ?? {}) { + ZStack { + content + if isLoading { + Capsule() + .fill(backgroundColor.opacity(0.6)) + ProgressView() + .scaleEffect(0.6) + .tint(textColor) + } + } } .buttonStyle(.plain) + .disabled(!isEnabled || isLoading || action == nil) + .allowsHitTesting(isEnabled && !isLoading && action != nil) + .opacity(isEnabled && !isLoading ? 1.0 : 0.6) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel) .accessibilityHint(accessibilityHint ?? "") - } } From fd9cf06819ea0325c3f4ae524283835dc2547601 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Tue, 4 Nov 2025 07:07:53 +0900 Subject: [PATCH 07/11] Keep vote pill layout while disabled --- .../Components/PostDisplayView.swift | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 8f288214..2d40dcec 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -356,18 +356,23 @@ public struct PostDisplayView: View { Capsule() .fill(backgroundColor) ) - return Button(action: action ?? {}) { - ZStack { - content - if isLoading { - Capsule() - .fill(backgroundColor.opacity(0.6)) - ProgressView() - .scaleEffect(0.6) - .tint(textColor) - } + .overlay { + if isLoading { + Capsule() + .fill(backgroundColor.opacity(0.6)) + } + } + .overlay { + if isLoading { + ProgressView() + .scaleEffect(0.6) + .tint(textColor) } } + + return Button(action: action ?? {}) { + content + } .buttonStyle(.plain) .disabled(!isEnabled || isLoading || action == nil) .allowsHitTesting(isEnabled && !isLoading && action != nil) From 0854af3d187b5797036613b35e8163dc149f3319 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Sat, 15 Nov 2025 12:40:24 +0900 Subject: [PATCH 08/11] Fix comments pill showing disabled appearance in comments view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments pill was appearing disabled in the comments view because it had no action handler. Updated the pill rendering logic to show static content (instead of a disabled button) when enabled but with no action, preventing the disabled styling from being applied. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Components/PostDisplayView.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift index 2d40dcec..7764ff10 100644 --- a/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift +++ b/DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift @@ -189,6 +189,7 @@ public struct PostDisplayView: View { accessibilityLabel: "\(post.commentsCount) comments", isHighlighted: false, isLoading: false, + isEnabled: true, numericValue: post.commentsCount, action: onCommentsTap ) @@ -370,16 +371,27 @@ public struct PostDisplayView: View { } } - return Button(action: action ?? {}) { + let shouldDisable = !isEnabled || isLoading + let shouldBeInteractive = isEnabled && !isLoading && action != nil + + // If enabled but no action, render as static view to avoid disabled styling + if isEnabled && !isLoading && action == nil { content + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(accessibilityHint ?? "") + } else { + Button(action: action ?? {}) { + content + } + .buttonStyle(.plain) + .disabled(!shouldBeInteractive) + .allowsHitTesting(shouldBeInteractive) + .opacity(shouldDisable ? 0.6 : 1.0) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(accessibilityHint ?? "") } - .buttonStyle(.plain) - .disabled(!isEnabled || isLoading || action == nil) - .allowsHitTesting(isEnabled && !isLoading && action != nil) - .opacity(isEnabled && !isLoading ? 1.0 : 0.6) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(accessibilityHint ?? "") } } From 6f90ebeca4eeb4f574a59614361c6b2639dd4579 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Sat, 15 Nov 2025 12:47:47 +0900 Subject: [PATCH 09/11] Disable voting when comments are loading --- .../Comments/Sources/Comments/CommentsComponents.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Features/Comments/Sources/Comments/CommentsComponents.swift b/Features/Comments/Sources/Comments/CommentsComponents.swift index a88d1017..a8133e67 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -38,6 +38,7 @@ struct CommentsContentView: View { PostHeader( post: post, votingViewModel: votingViewModel, + isLoadingComments: viewModel.isLoading, showThumbnails: viewModel.showThumbnails, onLinkTap: { handleLinkTap() }, onPostUpdated: { updatedPost in @@ -57,6 +58,7 @@ struct CommentsContentView: View { view.swipeActions(edge: .leading, allowsFullSwipe: true) { if post.upvoted && post.voteLinks?.unvote != nil { Button { + guard !viewModel.isLoading else { return } Task { var mutablePost = post await votingViewModel.unvote(post: &mutablePost) @@ -74,8 +76,10 @@ struct CommentsContentView: View { } .tint(.orange) .accessibilityLabel("Unvote") + .disabled(viewModel.isLoading) } else { Button { + guard !viewModel.isLoading else { return } Task { var mutablePost = post await votingViewModel.upvote(post: &mutablePost) @@ -90,6 +94,7 @@ struct CommentsContentView: View { } .tint(AppColors.upvotedColor) .accessibilityLabel("Upvote") + .disabled(viewModel.isLoading) } } } @@ -218,6 +223,7 @@ struct CommentsForEach: View { struct PostHeader: View { let post: Post let votingViewModel: VotingViewModel + let isLoadingComments: Bool let showThumbnails: Bool let onLinkTap: () -> Void let onPostUpdated: @Sendable (Post) -> Void @@ -256,6 +262,7 @@ struct PostHeader: View { } private func handleUpvote() async -> Bool { + guard !isLoadingComments else { return false } guard votingViewModel.canVote(item: post), !post.upvoted else { return false } var mutablePost = post @@ -272,6 +279,7 @@ struct PostHeader: View { } private func handleUnvote() async -> Bool { + guard !isLoadingComments else { return true } guard votingViewModel.canUnvote(item: post), post.upvoted else { return true } var mutablePost = post From 445d55dd095a2c13b6222682e2e5f69a3bf98740 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Sat, 15 Nov 2025 12:49:50 +0900 Subject: [PATCH 10/11] Sync post updates from comments view back to feed view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When post data changes in the comments view (e.g., votes, title, comment count), those changes are now reflected in the feed view when navigating back. This is achieved by updating the NavigationStore's selectedPost property whenever the post changes in CommentsViewModel, and having FeedView observe and apply those updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Features/Comments/Sources/Comments/CommentsView.swift | 6 ++++++ .../Comments/Sources/Comments/CommentsViewModel.swift | 8 +++++++- Features/Feed/Sources/Feed/FeedView.swift | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Features/Comments/Sources/Comments/CommentsView.swift b/Features/Comments/Sources/Comments/CommentsView.swift index 24714686..e98d7142 100644 --- a/Features/Comments/Sources/Comments/CommentsView.swift +++ b/Features/Comments/Sources/Comments/CommentsView.swift @@ -114,6 +114,12 @@ public struct CommentsView: View { } .task { votingViewModel.navigationStore = navigationStore + // Set up callback to update navigation store when post changes + viewModel.onPostUpdated = { [weak navigationStore] updatedPost in + if let updatedPost { + navigationStore?.selectedPost = updatedPost + } + } await viewModel.loadComments() if let targetID = pendingCommentID { _ = await viewModel.revealComment(withId: targetID) diff --git a/Features/Comments/Sources/Comments/CommentsViewModel.swift b/Features/Comments/Sources/Comments/CommentsViewModel.swift index 2d95a66a..cacc87c3 100644 --- a/Features/Comments/Sources/Comments/CommentsViewModel.swift +++ b/Features/Comments/Sources/Comments/CommentsViewModel.swift @@ -14,12 +14,18 @@ import SwiftUI @Observable public final class CommentsViewModel: @unchecked Sendable { public let postID: Int - public var post: Post? + public var post: Post? { + didSet { + onPostUpdated?(post) + } + } public var visibleComments: [Comment] = [] public var showThumbnails: Bool // Callback for when comments are loaded (used for HTML parsing in the view layer) public var onCommentsLoaded: (([Comment]) -> Void)? + // Callback for when post is updated + public var onPostUpdated: ((Post?) -> Void)? private let postUseCase: any PostUseCase private let commentUseCase: any CommentUseCase diff --git a/Features/Feed/Sources/Feed/FeedView.swift b/Features/Feed/Sources/Feed/FeedView.swift index 97f11d5d..26d3574a 100644 --- a/Features/Feed/Sources/Feed/FeedView.swift +++ b/Features/Feed/Sources/Feed/FeedView.swift @@ -97,6 +97,13 @@ public struct FeedView: View { votingViewModel.navigationStore = navigationStore await viewModel.loadFeed() } + .onChange(of: navigationStore.selectedPost) { oldPost, newPost in + // When selectedPost changes in navigation store (e.g., from comments view), + // update it in the feed + if let updatedPost = newPost { + viewModel.replacePost(updatedPost) + } + } .alert( "Vote Error", isPresented: Binding( From bad6a2ecf7e3d0488ea04935479e9da5b41fec55 Mon Sep 17 00:00:00 2001 From: Weiran Zhang Date: Sat, 15 Nov 2025 12:57:28 +0900 Subject: [PATCH 11/11] Consolidate unvote link clearing logic into VotingViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the logic to clear the unvote link after a successful unvote operation from the UI layer into VotingViewModel.unvote(). This eliminates duplicate code that was present in both the swipe action and handleUnvote() method. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Comments/Sources/Comments/CommentsComponents.swift | 10 +--------- Shared/Sources/Shared/ViewModels/VotingViewModel.swift | 5 +++++ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Features/Comments/Sources/Comments/CommentsComponents.swift b/Features/Comments/Sources/Comments/CommentsComponents.swift index a8133e67..5b0c8800 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -63,12 +63,7 @@ struct CommentsContentView: View { var mutablePost = post await votingViewModel.unvote(post: &mutablePost) await MainActor.run { - if !mutablePost.upvoted { - if let existingLinks = mutablePost.voteLinks { - mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) - } - viewModel.post = mutablePost - } + viewModel.post = mutablePost } } } label: { @@ -287,9 +282,6 @@ struct PostHeader: View { let wasUnvoted = !mutablePost.upvoted if wasUnvoted { - if let existingLinks = mutablePost.voteLinks { - mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) - } await MainActor.run { onPostUpdated(mutablePost) } diff --git a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift index d209ac5c..6c430d0f 100644 --- a/Shared/Sources/Shared/ViewModels/VotingViewModel.swift +++ b/Shared/Sources/Shared/ViewModels/VotingViewModel.swift @@ -86,6 +86,11 @@ public final class VotingViewModel { post.upvoted = false post.score -= 1 + // Clear unvote link after successful unvote + if let existingLinks = post.voteLinks { + post.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } + isVoting = true lastError = nil