Skip to content

Commit 9c679a9

Browse files
authored
Merge pull request #325 from weiran/claude/add-unvote-feature-011CUinTLLuo9DZYLLs1SUmw
Unvoting
2 parents 82c8015 + 9a09fce commit 9c679a9

File tree

18 files changed

+622
-97
lines changed

18 files changed

+622
-97
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: 134 additions & 23 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

@@ -25,6 +26,7 @@ public struct PostDisplayView: View {
2526
@State private var displayedScore: Int
2627
@State private var displayedUpvoted: Bool
2728
@State private var displayedBookmarked: Bool
29+
@State private var displayedVoteLinks: VoteLinks?
2830

2931
public init(
3032
post: Post,
@@ -33,6 +35,7 @@ public struct PostDisplayView: View {
3335
showThumbnails: Bool = true,
3436
onThumbnailTap: (() -> Void)? = nil,
3537
onUpvoteTap: (() async -> Bool)? = nil,
38+
onUnvoteTap: (() async -> Bool)? = nil,
3639
onBookmarkTap: (() async -> Bool)? = nil,
3740
onCommentsTap: (() -> Void)? = nil
3841
) {
@@ -42,11 +45,13 @@ public struct PostDisplayView: View {
4245
self.showThumbnails = showThumbnails
4346
self.onThumbnailTap = onThumbnailTap
4447
self.onUpvoteTap = onUpvoteTap
48+
self.onUnvoteTap = onUnvoteTap
4549
self.onBookmarkTap = onBookmarkTap
4650
self.onCommentsTap = onCommentsTap
4751
_displayedScore = State(initialValue: post.score)
4852
_displayedUpvoted = State(initialValue: post.upvoted)
4953
_displayedBookmarked = State(initialValue: post.isBookmarked)
54+
_displayedVoteLinks = State(initialValue: post.voteLinks)
5055
}
5156

5257
public var body: some View {
@@ -98,6 +103,7 @@ public struct PostDisplayView: View {
98103
displayedScore = post.score
99104
displayedUpvoted = post.upvoted
100105
displayedBookmarked = post.isBookmarked
106+
displayedVoteLinks = post.voteLinks
101107
}
102108
.onChange(of: post.score) { newValue in
103109
displayedScore = newValue
@@ -108,6 +114,9 @@ public struct PostDisplayView: View {
108114
.onChange(of: post.isBookmarked) { newValue in
109115
displayedBookmarked = newValue
110116
}
117+
.onChange(of: post.voteLinks) { newValue in
118+
displayedVoteLinks = newValue
119+
}
111120
.onChange(of: votingState?.score) { newValue in
112121
if let newValue {
113122
displayedScore = newValue
@@ -124,8 +133,10 @@ public struct PostDisplayView: View {
124133
let score = displayedScore
125134
let isUpvoted = displayedUpvoted
126135
let isLoading = isSubmittingUpvote
127-
let canVote = post.voteLinks?.upvote != nil
128-
let canInteract = canVote && !isUpvoted && !isLoading
136+
let currentVoteLinks = displayedVoteLinks ?? post.voteLinks
137+
let canVote = currentVoteLinks?.upvote != nil
138+
let canUnvote = currentVoteLinks?.unvote != nil
139+
let canInteract = ((canVote && !isUpvoted) || (canUnvote && isUpvoted)) && !isLoading
129140
// Avoid keeping a disabled Button so the upvoted state retains the bright tint
130141
let (backgroundColor, textColor): (Color, Color) = {
131142
let style = AppColors.PillStyle.upvote(isActive: isUpvoted)
@@ -135,12 +146,19 @@ public struct PostDisplayView: View {
135146
}()
136147
let iconName = isUpvoted ? "arrow.up.circle.fill" : "arrow.up"
137148
let accessibilityLabel: String
149+
let accessibilityHint: String
138150
if isLoading {
139151
accessibilityLabel = "Submitting vote"
152+
accessibilityHint = ""
153+
} else if isUpvoted && canUnvote {
154+
accessibilityLabel = "\(score) points, upvoted"
155+
accessibilityHint = "Double tap to unvote"
140156
} else if isUpvoted {
141157
accessibilityLabel = "\(score) points, upvoted"
158+
accessibilityHint = ""
142159
} else {
143160
accessibilityLabel = "\(score) points"
161+
accessibilityHint = "Double tap to upvote"
144162
}
145163

146164
return pillView(
@@ -149,7 +167,7 @@ public struct PostDisplayView: View {
149167
textColor: textColor,
150168
backgroundColor: backgroundColor,
151169
accessibilityLabel: accessibilityLabel,
152-
accessibilityHint: "Double tap to upvote",
170+
accessibilityHint: accessibilityHint,
153171
isHighlighted: isUpvoted,
154172
isLoading: isLoading,
155173
isEnabled: canInteract,
@@ -171,6 +189,7 @@ public struct PostDisplayView: View {
171189
accessibilityLabel: "\(post.commentsCount) comments",
172190
isHighlighted: false,
173191
isLoading: false,
192+
isEnabled: true,
174193
numericValue: post.commentsCount,
175194
action: onCommentsTap
176195
)
@@ -202,27 +221,81 @@ public struct PostDisplayView: View {
202221
}
203222

204223
private func makeUpvoteAction() -> (() -> Void)? {
205-
guard let onUpvoteTap else { return nil }
206224
return {
207225
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
226+
227+
let isCurrentlyUpvoted = displayedUpvoted
228+
let currentVoteLinks = displayedVoteLinks ?? post.voteLinks
229+
let canUnvote = currentVoteLinks?.unvote != nil
230+
231+
// If already upvoted and can unvote, perform unvote
232+
if isCurrentlyUpvoted && canUnvote {
233+
guard let onUnvoteTap else { return }
234+
isSubmittingUpvote = true
235+
let previousScore = displayedScore
236+
let previousUpvoted = displayedUpvoted
237+
let previousVoteLinks = currentVoteLinks
238+
displayedUpvoted = false
239+
displayedScore -= 1
240+
displayedVoteLinks = VoteLinks(upvote: previousVoteLinks?.upvote, unvote: nil)
241+
Task {
242+
let success = await onUnvoteTap()
243+
await MainActor.run {
244+
if !success {
245+
displayedScore = previousScore
246+
displayedUpvoted = previousUpvoted
247+
displayedVoteLinks = previousVoteLinks
248+
}
249+
isSubmittingUpvote = false
250+
}
251+
}
252+
} else {
253+
// Perform upvote
254+
guard let onUpvoteTap else { return }
255+
isSubmittingUpvote = true
256+
let previousScore = displayedScore
257+
let previousUpvoted = displayedUpvoted
258+
let previousVoteLinks = currentVoteLinks
259+
displayedUpvoted = true
260+
displayedScore += 1
261+
displayedVoteLinks = derivedVoteLinks(afterUpvoteFrom: previousVoteLinks)
262+
Task {
263+
let success = await onUpvoteTap()
264+
await MainActor.run {
265+
if !success {
266+
displayedScore = previousScore
267+
displayedUpvoted = previousUpvoted
268+
displayedVoteLinks = previousVoteLinks
269+
}
270+
isSubmittingUpvote = false
219271
}
220-
isSubmittingUpvote = false
221272
}
222273
}
223274
}
224275
}
225276

277+
private func derivedVoteLinks(afterUpvoteFrom voteLinks: VoteLinks?) -> VoteLinks? {
278+
guard let voteLinks else { return nil }
279+
if voteLinks.unvote != nil {
280+
return voteLinks
281+
}
282+
guard let upvoteURL = voteLinks.upvote else {
283+
return voteLinks
284+
}
285+
let absolute = upvoteURL.absoluteString
286+
if absolute.contains("how=up"),
287+
let unvoteURL = URL(string: absolute.replacingOccurrences(of: "how=up", with: "how=un"))
288+
{
289+
return VoteLinks(upvote: upvoteURL, unvote: unvoteURL)
290+
}
291+
if absolute.contains("how%3Dup"),
292+
let unvoteURL = URL(string: absolute.replacingOccurrences(of: "how%3Dup", with: "how%3Dun"))
293+
{
294+
return VoteLinks(upvote: upvoteURL, unvote: unvoteURL)
295+
}
296+
return voteLinks
297+
}
298+
226299
private func makeBookmarkAction() -> (() -> Void)? {
227300
guard let onBookmarkTap else { return nil }
228301
return {
@@ -284,45 +357,83 @@ public struct PostDisplayView: View {
284357
Capsule()
285358
.fill(backgroundColor)
286359
)
287-
Button(action: action ?? {}) {
288-
content
360+
.overlay {
361+
if isLoading {
362+
Capsule()
363+
.fill(backgroundColor.opacity(0.6))
364+
}
365+
}
366+
.overlay {
367+
if isLoading {
368+
ProgressView()
369+
.scaleEffect(0.6)
370+
.tint(textColor)
371+
}
289372
}
290-
.buttonStyle(.plain)
291-
.accessibilityElement(children: .combine)
292-
.accessibilityLabel(accessibilityLabel)
293-
.accessibilityHint(accessibilityHint ?? "")
294373

374+
let shouldDisable = !isEnabled || isLoading
375+
let shouldBeInteractive = isEnabled && !isLoading && action != nil
376+
377+
// If enabled but no action, render as static view to avoid disabled styling
378+
if isEnabled && !isLoading && action == nil {
379+
content
380+
.accessibilityElement(children: .combine)
381+
.accessibilityLabel(accessibilityLabel)
382+
.accessibilityHint(accessibilityHint ?? "")
383+
} else {
384+
Button(action: action ?? {}) {
385+
content
386+
}
387+
.buttonStyle(.plain)
388+
.disabled(!shouldBeInteractive)
389+
.allowsHitTesting(shouldBeInteractive)
390+
.opacity(shouldDisable ? 0.6 : 1.0)
391+
.accessibilityElement(children: .combine)
392+
.accessibilityLabel(accessibilityLabel)
393+
.accessibilityHint(accessibilityHint ?? "")
394+
}
295395
}
296396
}
297397

298398
public struct PostContextMenu: View {
299399
let post: Post
300400
let onVote: () -> Void
401+
let onUnvote: () -> Void
301402
let onOpenLink: () -> Void
302403
let onShare: () -> Void
303404

304405
public init(
305406
post: Post,
306407
onVote: @escaping () -> Void,
408+
onUnvote: @escaping () -> Void = {},
307409
onOpenLink: @escaping () -> Void,
308410
onShare: @escaping () -> Void,
309411
) {
310412
self.post = post
311413
self.onVote = onVote
414+
self.onUnvote = onUnvote
312415
self.onOpenLink = onOpenLink
313416
self.onShare = onShare
314417
}
315418

316419
public var body: some View {
317420
Group {
318-
if post.voteLinks?.upvote != nil {
421+
if post.voteLinks?.upvote != nil, !post.upvoted {
319422
Button {
320423
onVote()
321424
} label: {
322425
Label("Upvote", systemImage: "arrow.up")
323426
}
324427
}
325428

429+
if post.voteLinks?.unvote != nil, post.upvoted {
430+
Button {
431+
onUnvote()
432+
} label: {
433+
Label("Unvote", systemImage: "arrow.uturn.down")
434+
}
435+
}
436+
326437
Divider()
327438

328439
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 ""

0 commit comments

Comments
 (0)