Skip to content
Merged
36 changes: 34 additions & 2 deletions Data/Sources/Data/PostRepository+Voting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<form action=\"/login") ||
response.contains("You have to be logged in")
if containsLoginForm { throw HackersKitError.unauthenticated }
}

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

// Unvote functionality removed
public func unvote(comment: Domain.Comment, for _: Post) async throws {
guard let voteLinks = comment.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("<form action=\"/login")
if containsLoginForm { throw HackersKitError.unauthenticated }

await MainActor.run { comment.upvoted = false }
}

// MARK: - Vote link extraction

Expand Down
79 changes: 63 additions & 16 deletions DesignSystem/Sources/DesignSystem/Components/PostDisplayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public struct PostDisplayView: View {
let showThumbnails: Bool
let onThumbnailTap: (() -> Void)?
let onUpvoteTap: (() async -> Bool)?
let onUnvoteTap: (() async -> Bool)?
let onBookmarkTap: (() async -> Bool)?
let onCommentsTap: (() -> Void)?

Expand All @@ -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
) {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -298,31 +334,42 @@ 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: {
Label("Upvote", systemImage: "arrow.up")
}
}

if post.voteLinks?.unvote != nil, post.upvoted {
Button {
onUnvote()
} label: {
Label("Unvote", systemImage: "arrow.uturn.down")
}
}

Divider()

if !isHackerNewsItemURL(post.url) {
Expand Down
6 changes: 3 additions & 3 deletions DesignSystem/Sources/DesignSystem/Components/VoteButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@ 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()
} label: {
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
Expand All @@ -32,15 +41,24 @@ 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()
} label: {
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
Expand All @@ -49,15 +67,24 @@ 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()
} label: {
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")
}
}
}
}

Expand Down Expand Up @@ -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()
}
}
Expand Down
3 changes: 3 additions & 0 deletions Domain/Sources/Domain/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ 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?

public init(
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
}
Expand Down
2 changes: 2 additions & 0 deletions Domain/Sources/Domain/VoteUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading