From b00ec501bcb08fe2157a52c6c9e72a9e36d0c590 Mon Sep 17 00:00:00 2001 From: Mufan Qiu Date: Tue, 30 Jun 2026 14:21:04 -0700 Subject: [PATCH] feat: optimistic vote updates Update the vote state and score instantly on tap instead of waiting for the network round-trip. A pure `VotesModel.predictVote` mirrors NGA's toggle semantics (re-tapping clears the vote; flipping up/down moves the score by 2), so the optimistic value matches what the server returns. The request is sent in the background and reconciled against the pre-vote baseline on success; on failure the previous state is restored and an error toast is shown. --- .../zh-Hans.lproj/Localizable.strings | 1 + app/Shared/Models/VotesModel.swift | 23 ++++++++++++++ app/Shared/Views/PostRowView.swift | 31 ++++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 51973861..0733792a 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -61,6 +61,7 @@ "Send" = "发送"; "Quote" = "引用"; "Vote Up" = "点赞"; +"Vote Failed" = "操作失败"; "Edit" = "编辑"; "Edit Signature" = "修改签名"; "Share" = "分享"; diff --git a/app/Shared/Models/VotesModel.swift b/app/Shared/Models/VotesModel.swift index 08a82621..1982fadc 100644 --- a/app/Shared/Models/VotesModel.swift +++ b/app/Shared/Models/VotesModel.swift @@ -12,6 +12,29 @@ import SwiftUI class VotesModel: ObservableObject { typealias Vote = (state: VoteState, delta: Int32) + /// Predict the resulting vote after applying `operation`, mirroring NGA's + /// toggle semantics so optimistic updates match what the server returns: + /// tapping the current state again clears it; switching across up/down moves + /// by 2. Used to update the UI instantly before the network round-trip. + static func predictVote(from current: Vote, operation: PostVoteRequest.Operation) -> Vote { + switch (operation, current.state) { + case (.upvote, .up): // toggle off + return (state: .none, delta: current.delta - 1) + case (.upvote, .down): // flip down -> up + return (state: .up, delta: current.delta + 2) + case (.upvote, _): // none -> up + return (state: .up, delta: current.delta + 1) + case (.downvote, .down): // toggle off + return (state: .none, delta: current.delta + 1) + case (.downvote, .up): // flip up -> down + return (state: .down, delta: current.delta - 2) + case (.downvote, _): // none -> down + return (state: .down, delta: current.delta - 1) + default: // unknown operation (e.g. UNRECOGNIZED): no change + return current + } + } + // TODO: check performance cost here @Published private var votes = [PostId: Vote]() diff --git a/app/Shared/Views/PostRowView.swift b/app/Shared/Views/PostRowView.swift index c77b78c0..9653ab8b 100644 --- a/app/Shared/Views/PostRowView.swift +++ b/app/Shared/Views/PostRowView.swift @@ -367,23 +367,38 @@ struct PostRowView: View { return } + // Optimistically update the UI immediately, then send the request in the + // background and reconcile with the server's authoritative result. On + // failure, roll back to the previous state. + let previous = vote + let predicted = VotesModel.predictVote(from: previous, operation: operation) + + withAnimation { + vote = predicted + #if os(iOS) + if vote.state != .none { + HapticUtils.play(style: .light) + } + #endif + } + logicCallAsync(.postVote(.with { $0.postID = post.id $0.operation = operation - })) { (response: PostVoteResponse) in + }), errorToastModel: nil) { (response: PostVoteResponse) in if !response.hasError { + // Reconcile delta against the pre-vote baseline so the server value wins + // regardless of our prediction. withAnimation { vote.state = response.state - vote.delta += response.delta - #if os(iOS) - if vote.state != .none { - HapticUtils.play(style: .light) - } - #endif + vote.delta = previous.delta + response.delta } } else { - // not used + withAnimation { vote = previous } } + } onError: { _ in + withAnimation { vote = previous } + ToastModel.showAuto(.error("Vote Failed")) } }