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)? @@ -25,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, @@ -33,6 +35,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,11 +45,13 @@ 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) _displayedUpvoted = State(initialValue: post.upvoted) _displayedBookmarked = State(initialValue: post.isBookmarked) + _displayedVoteLinks = State(initialValue: post.voteLinks) } public var body: some View { @@ -98,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 @@ -108,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 @@ -124,8 +133,10 @@ public struct PostDisplayView: View { let score = displayedScore let isUpvoted = displayedUpvoted let isLoading = isSubmittingUpvote - let canVote = post.voteLinks?.upvote != nil - let canInteract = canVote && !isUpvoted && !isLoading + 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) = { let style = AppColors.PillStyle.upvote(isActive: isUpvoted) @@ -135,12 +146,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 +167,7 @@ public struct PostDisplayView: View { textColor: textColor, backgroundColor: backgroundColor, accessibilityLabel: accessibilityLabel, - accessibilityHint: "Double tap to upvote", + accessibilityHint: accessibilityHint, isHighlighted: isUpvoted, isLoading: isLoading, isEnabled: canInteract, @@ -171,6 +189,7 @@ public struct PostDisplayView: View { accessibilityLabel: "\(post.commentsCount) comments", isHighlighted: false, isLoading: false, + isEnabled: true, numericValue: post.commentsCount, action: onCommentsTap ) @@ -202,27 +221,81 @@ 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 currentVoteLinks = displayedVoteLinks ?? post.voteLinks + let canUnvote = currentVoteLinks?.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 + 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 + } + } + } 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 } } } } + 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 { @@ -284,38 +357,68 @@ public struct PostDisplayView: View { Capsule() .fill(backgroundColor) ) - Button(action: action ?? {}) { - content + .overlay { + if isLoading { + Capsule() + .fill(backgroundColor.opacity(0.6)) + } + } + .overlay { + if isLoading { + ProgressView() + .scaleEffect(0.6) + .tint(textColor) + } } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(accessibilityHint ?? "") + 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 ?? "") + } } } 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 +426,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 eaaf2490..c874de87 100644 --- a/Features/Comments/Sources/Comments/CommentsComponents.swift +++ b/Features/Comments/Sources/Comments/CommentsComponents.swift @@ -38,14 +38,11 @@ struct CommentsContentView: View { PostHeader( post: post, votingViewModel: votingViewModel, + isLoadingComments: viewModel.isLoading, 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() } ) @@ -57,28 +54,43 @@ 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 { + guard !viewModel.isLoading else { return } + Task { + var mutablePost = post + await votingViewModel.unvote(post: &mutablePost) + await MainActor.run { + viewModel.post = mutablePost } } + } label: { + Image(systemName: "arrow.uturn.down") } - } label: { - Image(systemName: "arrow.up") + .tint(.orange) + .accessibilityLabel("Unvote") + .disabled(viewModel.isLoading) + } else { + Button { + guard !viewModel.isLoading else { return } + Task { + var mutablePost = post + await votingViewModel.upvote(post: &mutablePost) + await MainActor.run { + if mutablePost.upvoted { + viewModel.post = mutablePost + } + } + } + } label: { + Image(systemName: "arrow.up") + } + .tint(AppColors.upvotedColor) + .accessibilityLabel("Upvote") + .disabled(viewModel.isLoading) } - .tint(AppColors.upvotedColor) - .accessibilityLabel("Upvote") } } @@ -163,18 +175,30 @@ struct CommentsForEach: View { value: [comment.id: geometry.frame(in: .global)], ) }) - .listRowSeparator(.visible) - .if(comment.voteLinks?.upvote != nil && !comment.upvoted) { view in + .listRowSeparator(.hidden) + .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) { @@ -194,9 +218,10 @@ struct CommentsForEach: View { struct PostHeader: View { let post: Post let votingViewModel: VotingViewModel + let isLoadingComments: Bool let showThumbnails: Bool let onLinkTap: () -> Void - let onUpvoteApplied: @Sendable () -> Void + let onPostUpdated: @Sendable (Post) -> Void let onBookmarkToggle: @Sendable () async -> Bool var body: some View { @@ -207,6 +232,7 @@ struct PostHeader: View { showThumbnails: showThumbnails, onThumbnailTap: { onLinkTap() }, onUpvoteTap: { await handleUpvote() }, + onUnvoteTap: { await handleUnvote() }, onBookmarkTap: { await onBookmarkToggle() } ) .contentShape(Rectangle()) @@ -215,6 +241,7 @@ struct PostHeader: View { VotingContextMenuItems.postVotingMenuItems( for: post, onVote: { Task { await handleUpvote() } }, + onUnvote: { Task { await handleUnvote() } } ) Divider() @@ -230,6 +257,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 @@ -238,12 +266,29 @@ struct PostHeader: View { if wasUpvoted { await MainActor.run { - onUpvoteApplied() + onPostUpdated(mutablePost) } } return wasUpvoted } + + private func handleUnvote() async -> Bool { + guard !isLoadingComments else { return true } + 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 { + onPostUpdated(mutablePost) + } + } + + return wasUnvoted + } } struct CommentRow: View { @@ -289,6 +334,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 +365,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/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/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 f371e2a4..8d5aa3cf 100644 --- a/Features/Feed/Sources/Feed/FeedView.swift +++ b/Features/Feed/Sources/Feed/FeedView.swift @@ -101,6 +101,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( @@ -187,8 +194,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) @@ -205,7 +212,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) } @@ -217,21 +224,44 @@ 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 { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } + viewModel.replacePost(mutablePost) + } } } + } 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.replacePost(mutablePost) + } + } + } + } label: { + Image(systemName: "arrow.up") + } + .tint(AppColors.upvotedColor) + .accessibilityLabel("Upvote") } - .tint(AppColors.upvotedColor) - .accessibilityLabel("Upvote") } @ViewBuilder @@ -244,11 +274,25 @@ public struct FeedView: View { await votingViewModel.upvote(post: &mutablePost) await MainActor.run { if mutablePost.upvoted { - viewModel.applyLocalUpvote(to: post.id) + viewModel.replacePost(mutablePost) } } } }, + onUnvote: { + Task { + 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.replacePost(mutablePost) + } + } + } + } ) Divider() @@ -348,7 +392,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, @@ -356,7 +400,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 @@ -364,7 +408,7 @@ struct PostRowView: View { self.onLinkTap = onLinkTap self.onCommentsTap = onCommentsTap self.showThumbnails = showThumbnails - self.onUpvoteApplied = onUpvoteApplied + self.onPostUpdated = onPostUpdated self.onBookmarkToggle = onBookmarkToggle } @@ -376,6 +420,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() @@ -404,10 +449,29 @@ struct PostRowView: View { if wasUpvoted { await MainActor.run { - onUpvoteApplied?(mutablePost.id) + onPostUpdated?(mutablePost) } } 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 { + if let existingLinks = mutablePost.voteLinks { + mutablePost.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } + await MainActor.run { + onPostUpdated?(mutablePost) + } + } + + return wasUnvoted + } } diff --git a/Features/Feed/Sources/Feed/FeedViewModel.swift b/Features/Feed/Sources/Feed/FeedViewModel.swift index 628a4a4d..60268121 100644 --- a/Features/Feed/Sources/Feed/FeedViewModel.swift +++ b/Features/Feed/Sources/Feed/FeedViewModel.swift @@ -241,14 +241,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 f7d5d92a..6c430d0f 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) } @@ -69,7 +72,41 @@ 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 + + // Clear unvote link after successful unvote + if let existingLinks = post.voteLinks { + post.voteLinks = VoteLinks(upvote: existingLinks.upvote, unvote: nil) + } + + 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 @@ -80,9 +117,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 @@ -92,6 +131,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) @@ -100,7 +140,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 +174,7 @@ public final class VotingViewModel { isUpvoted: baseState.isUpvoted, score: baseState.score, canVote: baseState.canVote, + canUnvote: baseState.canUnvote, isVoting: isVoting, error: lastError ) @@ -119,6 +184,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 } @@ -141,4 +210,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 {