@@ -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
298398public 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) {
0 commit comments