Skip to content

Commit 812ccc7

Browse files
committed
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
1 parent a725036 commit 812ccc7

File tree

10 files changed

+358
-64
lines changed

10 files changed

+358
-64
lines changed

Data/Sources/Data/PostRepository+Voting.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,23 @@ extension PostRepository {
3737
if containsLoginForm { throw HackersKitError.unauthenticated }
3838
}
3939

40-
// Unvote functionality removed
40+
public func unvote(post: Post) async throws {
41+
guard let voteLinks = post.voteLinks else { throw HackersKitError.unauthenticated }
42+
guard let unvoteURL = voteLinks.unvote else {
43+
throw HackersKitError.scraperError
44+
}
45+
46+
let fullURLString = unvoteURL.absoluteString.hasPrefix("http")
47+
? unvoteURL.absoluteString
48+
: urlBase + "/" + unvoteURL.absoluteString
49+
guard let realURL = URL(string: fullURLString) else { throw HackersKitError.scraperError }
50+
51+
let response = try await networkManager.get(url: realURL)
52+
let containsLoginForm =
53+
response.contains("<form action=\"/login") ||
54+
response.contains("You have to be logged in")
55+
if containsLoginForm { throw HackersKitError.unauthenticated }
56+
}
4157

4258
public func upvote(comment: Domain.Comment, for _: Post) async throws {
4359
guard let voteLinks = comment.voteLinks else { throw HackersKitError.unauthenticated }
@@ -58,7 +74,23 @@ extension PostRepository {
5874
await MainActor.run { comment.upvoted = true }
5975
}
6076

61-
// Unvote functionality removed
77+
public func unvote(comment: Domain.Comment, for _: Post) async throws {
78+
guard let voteLinks = comment.voteLinks else { throw HackersKitError.unauthenticated }
79+
guard let unvoteURL = voteLinks.unvote else {
80+
throw HackersKitError.scraperError
81+
}
82+
83+
let fullURLString = unvoteURL.absoluteString.hasPrefix("http")
84+
? unvoteURL.absoluteString
85+
: urlBase + "/" + unvoteURL.absoluteString
86+
guard let realURL = URL(string: fullURLString) else { throw HackersKitError.scraperError }
87+
88+
let response = try await networkManager.get(url: realURL)
89+
let containsLoginForm = response.contains("<form action=\"/login")
90+
if containsLoginForm { throw HackersKitError.unauthenticated }
91+
92+
await MainActor.run { comment.upvoted = false }
93+
}
6294

6395
// MARK: - Vote link extraction
6496

DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public struct PostDisplayView: View {
1616
let showThumbnails: Bool
1717
let onThumbnailTap: (() -> Void)?
1818
let onUpvoteTap: (() async -> Bool)?
19+
let onUnvoteTap: (() async -> Bool)?
1920
let onBookmarkTap: (() async -> Bool)?
2021
let onCommentsTap: (() -> Void)?
2122

@@ -33,6 +34,7 @@ public struct PostDisplayView: View {
3334
showThumbnails: Bool = true,
3435
onThumbnailTap: (() -> Void)? = nil,
3536
onUpvoteTap: (() async -> Bool)? = nil,
37+
onUnvoteTap: (() async -> Bool)? = nil,
3638
onBookmarkTap: (() async -> Bool)? = nil,
3739
onCommentsTap: (() -> Void)? = nil
3840
) {
@@ -42,6 +44,7 @@ public struct PostDisplayView: View {
4244
self.showThumbnails = showThumbnails
4345
self.onThumbnailTap = onThumbnailTap
4446
self.onUpvoteTap = onUpvoteTap
47+
self.onUnvoteTap = onUnvoteTap
4548
self.onBookmarkTap = onBookmarkTap
4649
self.onCommentsTap = onCommentsTap
4750
_displayedScore = State(initialValue: post.score)
@@ -125,7 +128,8 @@ public struct PostDisplayView: View {
125128
let isUpvoted = displayedUpvoted
126129
let isLoading = isSubmittingUpvote
127130
let canVote = post.voteLinks?.upvote != nil
128-
let canInteract = canVote && !isUpvoted && !isLoading
131+
let canUnvote = post.voteLinks?.unvote != nil
132+
let canInteract = ((canVote && !isUpvoted) || (canUnvote && isUpvoted)) && !isLoading
129133
// Avoid keeping a disabled Button so the upvoted state retains the bright tint
130134
let (backgroundColor, textColor): (Color, Color) = {
131135
let style = AppColors.PillStyle.upvote(isActive: isUpvoted)
@@ -135,12 +139,19 @@ public struct PostDisplayView: View {
135139
}()
136140
let iconName = isUpvoted ? "arrow.up.circle.fill" : "arrow.up"
137141
let accessibilityLabel: String
142+
let accessibilityHint: String
138143
if isLoading {
139144
accessibilityLabel = "Submitting vote"
145+
accessibilityHint = ""
146+
} else if isUpvoted && canUnvote {
147+
accessibilityLabel = "\(score) points, upvoted"
148+
accessibilityHint = "Double tap to unvote"
140149
} else if isUpvoted {
141150
accessibilityLabel = "\(score) points, upvoted"
151+
accessibilityHint = ""
142152
} else {
143153
accessibilityLabel = "\(score) points"
154+
accessibilityHint = "Double tap to upvote"
144155
}
145156

146157
return pillView(
@@ -149,7 +160,7 @@ public struct PostDisplayView: View {
149160
textColor: textColor,
150161
backgroundColor: backgroundColor,
151162
accessibilityLabel: accessibilityLabel,
152-
accessibilityHint: "Double tap to upvote",
163+
accessibilityHint: accessibilityHint,
153164
isHighlighted: isUpvoted,
154165
isLoading: isLoading,
155166
isEnabled: canInteract,
@@ -202,22 +213,47 @@ public struct PostDisplayView: View {
202213
}
203214

204215
private func makeUpvoteAction() -> (() -> Void)? {
205-
guard let onUpvoteTap else { return nil }
206216
return {
207217
guard !isSubmittingUpvote else { return }
208-
isSubmittingUpvote = true
209-
let previousScore = displayedScore
210-
let previousUpvoted = displayedUpvoted
211-
displayedUpvoted = true
212-
displayedScore += 1
213-
Task {
214-
let success = await onUpvoteTap()
215-
await MainActor.run {
216-
if !success {
217-
displayedScore = previousScore
218-
displayedUpvoted = previousUpvoted
218+
219+
let isCurrentlyUpvoted = displayedUpvoted
220+
let canUnvote = post.voteLinks?.unvote != nil
221+
222+
// If already upvoted and can unvote, perform unvote
223+
if isCurrentlyUpvoted && canUnvote {
224+
guard let onUnvoteTap else { return }
225+
isSubmittingUpvote = true
226+
let previousScore = displayedScore
227+
let previousUpvoted = displayedUpvoted
228+
displayedUpvoted = false
229+
displayedScore -= 1
230+
Task {
231+
let success = await onUnvoteTap()
232+
await MainActor.run {
233+
if !success {
234+
displayedScore = previousScore
235+
displayedUpvoted = previousUpvoted
236+
}
237+
isSubmittingUpvote = false
238+
}
239+
}
240+
} else {
241+
// Perform upvote
242+
guard let onUpvoteTap else { return }
243+
isSubmittingUpvote = true
244+
let previousScore = displayedScore
245+
let previousUpvoted = displayedUpvoted
246+
displayedUpvoted = true
247+
displayedScore += 1
248+
Task {
249+
let success = await onUpvoteTap()
250+
await MainActor.run {
251+
if !success {
252+
displayedScore = previousScore
253+
displayedUpvoted = previousUpvoted
254+
}
255+
isSubmittingUpvote = false
219256
}
220-
isSubmittingUpvote = false
221257
}
222258
}
223259
}
@@ -298,31 +334,42 @@ public struct PostDisplayView: View {
298334
public struct PostContextMenu: View {
299335
let post: Post
300336
let onVote: () -> Void
337+
let onUnvote: () -> Void
301338
let onOpenLink: () -> Void
302339
let onShare: () -> Void
303340

304341
public init(
305342
post: Post,
306343
onVote: @escaping () -> Void,
344+
onUnvote: @escaping () -> Void = {},
307345
onOpenLink: @escaping () -> Void,
308346
onShare: @escaping () -> Void,
309347
) {
310348
self.post = post
311349
self.onVote = onVote
350+
self.onUnvote = onUnvote
312351
self.onOpenLink = onOpenLink
313352
self.onShare = onShare
314353
}
315354

316355
public var body: some View {
317356
Group {
318-
if post.voteLinks?.upvote != nil {
357+
if post.voteLinks?.upvote != nil, !post.upvoted {
319358
Button {
320359
onVote()
321360
} label: {
322361
Label("Upvote", systemImage: "arrow.up")
323362
}
324363
}
325364

365+
if post.voteLinks?.unvote != nil, post.upvoted {
366+
Button {
367+
onUnvote()
368+
} label: {
369+
Label("Unvote", systemImage: "arrow.uturn.down")
370+
}
371+
}
372+
326373
Divider()
327374

328375
if !isHackerNewsItemURL(post.url) {

DesignSystem/Sources/DesignSystem/Components/VoteButton.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ public struct VoteButton: View {
4444
}
4545
}
4646
}
47-
.disabled(!votingState.canVote || votingState.isVoting)
47+
.disabled((!votingState.canVote && !votingState.canUnvote) || votingState.isVoting)
4848
.scaleEffect(votingState.isVoting ? 0.95 : 1.0)
4949
.animation(.easeInOut(duration: 0.1), value: votingState.isVoting)
50-
.accessibilityLabel(votingState.isUpvoted ? "Upvoted" : "Upvote")
51-
.accessibilityHint(votingState.isUpvoted ? "Already upvoted" : (votingState.canVote ? "Double-tap to upvote" : "Voting unavailable"))
50+
.accessibilityLabel(votingState.isUpvoted && votingState.canUnvote ? "Unvote" : (votingState.isUpvoted ? "Upvoted" : "Upvote"))
51+
.accessibilityHint(votingState.isUpvoted && votingState.canUnvote ? "Double-tap to unvote" : (votingState.isUpvoted ? "Already upvoted" : (votingState.canVote ? "Double-tap to upvote" : "Voting unavailable")))
5252
.accessibilityValue({ () -> String in
5353
if let score = votingState.score { return "\(score) points" }
5454
return ""

DesignSystem/Sources/DesignSystem/Components/VotingContextMenuItems.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@ public enum VotingContextMenuItems {
1515
public static func postVotingMenuItems(
1616
for post: Post,
1717
onVote: @escaping @Sendable () -> Void,
18+
onUnvote: @escaping @Sendable () -> Void = {},
1819
) -> some View {
19-
// Only show upvote if available and not already upvoted
20+
// Show upvote if available and not already upvoted
2021
if post.voteLinks?.upvote != nil, !post.upvoted {
2122
Button {
2223
onVote()
2324
} label: {
2425
Label("Upvote", systemImage: "arrow.up")
2526
}
2627
}
28+
// Show unvote if available and already upvoted
29+
if post.voteLinks?.unvote != nil, post.upvoted {
30+
Button {
31+
onUnvote()
32+
} label: {
33+
Label("Unvote", systemImage: "arrow.uturn.down")
34+
}
35+
}
2736
}
2837

2938
// MARK: - Comment Voting Menu Items
@@ -32,15 +41,24 @@ public enum VotingContextMenuItems {
3241
public static func commentVotingMenuItems(
3342
for comment: Comment,
3443
onVote: @escaping @Sendable () -> Void,
44+
onUnvote: @escaping @Sendable () -> Void = {},
3545
) -> some View {
36-
// Only show upvote if available and not already upvoted
46+
// Show upvote if available and not already upvoted
3747
if comment.voteLinks?.upvote != nil, !comment.upvoted {
3848
Button {
3949
onVote()
4050
} label: {
4151
Label("Upvote", systemImage: "arrow.up")
4252
}
4353
}
54+
// Show unvote if available and already upvoted
55+
if comment.voteLinks?.unvote != nil, comment.upvoted {
56+
Button {
57+
onUnvote()
58+
} label: {
59+
Label("Unvote", systemImage: "arrow.uturn.down")
60+
}
61+
}
4462
}
4563

4664
// MARK: - Generic Votable Menu Items
@@ -49,15 +67,24 @@ public enum VotingContextMenuItems {
4967
public static func votingMenuItems(
5068
for item: some Votable,
5169
onVote: @escaping @Sendable () -> Void,
70+
onUnvote: @escaping @Sendable () -> Void = {},
5271
) -> some View {
53-
// Only show upvote if available and not already upvoted
72+
// Show upvote if available and not already upvoted
5473
if item.voteLinks?.upvote != nil, !item.upvoted {
5574
Button {
5675
onVote()
5776
} label: {
5877
Label("Upvote", systemImage: "arrow.up")
5978
}
6079
}
80+
// Show unvote if available and already upvoted
81+
if item.voteLinks?.unvote != nil, item.upvoted {
82+
Button {
83+
onUnvote()
84+
} label: {
85+
Label("Unvote", systemImage: "arrow.uturn.down")
86+
}
87+
}
6188
}
6289
}
6390

@@ -90,10 +117,11 @@ public extension View {
90117
func votingContextMenu(
91118
for item: some Votable,
92119
onVote: @escaping @Sendable () -> Void,
120+
onUnvote: @escaping @Sendable () -> Void = {},
93121
additionalItems: @escaping () -> some View = { EmptyView() },
94122
) -> some View {
95123
contextMenu {
96-
VotingContextMenuItems.votingMenuItems(for: item, onVote: onVote)
124+
VotingContextMenuItems.votingMenuItems(for: item, onVote: onVote, onUnvote: onUnvote)
97125
additionalItems()
98126
}
99127
}

Domain/Sources/Domain/Models.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,22 @@ public struct VotingState: Sendable {
1414
public let isUpvoted: Bool
1515
public let score: Int?
1616
public let canVote: Bool
17+
public let canUnvote: Bool
1718
public let isVoting: Bool
1819
public let error: Error?
1920

2021
public init(
2122
isUpvoted: Bool,
2223
score: Int? = nil,
2324
canVote: Bool,
25+
canUnvote: Bool = false,
2426
isVoting: Bool = false,
2527
error: Error? = nil,
2628
) {
2729
self.isUpvoted = isUpvoted
2830
self.score = score
2931
self.canVote = canVote
32+
self.canUnvote = canUnvote
3033
self.isVoting = isVoting
3134
self.error = error
3235
}

Domain/Sources/Domain/VoteUseCase.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ import Foundation
1010
public protocol VoteUseCase: Sendable {
1111
func upvote(post: Post) async throws
1212
func upvote(comment: Comment, for post: Post) async throws
13+
func unvote(post: Post) async throws
14+
func unvote(comment: Comment, for post: Post) async throws
1315
}

0 commit comments

Comments
 (0)