feat: optimistic vote updates - #388
Open
MufanQiu wants to merge 1 commit into
Open
Conversation
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.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR makes post voting feel instantaneous by applying an optimistic UI update immediately on tap, then reconciling the vote state/score with the server response (rolling back on failure). This improves perceived responsiveness in the SwiftUI UI module while still keeping the Rust-backed service as the source of truth.
Changes:
- Added
VotesModel.predictVote(from:operation:)to mirror NGA vote toggle semantics for accurate optimistic updates. - Updated
PostRowView.doVoteto apply the predicted vote immediately, then reconcile against the pre-vote baseline using the server’s response (rollback on error). - Added a Simplified Chinese localization entry for the new “Vote Failed” toast.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| app/Shared/Views/PostRowView.swift | Applies optimistic vote updates and reconciles/rolls back based on server result. |
| app/Shared/Models/VotesModel.swift | Introduces a pure vote prediction helper matching server toggle semantics. |
| app/Shared/Localization/zh-Hans.lproj/Localizable.strings | Adds translation for the new vote-failure toast message. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
385
to
402
| 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")) | ||
| } |
Comment on lines
+370
to
395
| // 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Make voting feel instant. Currently tapping upvote/downvote waits for the full
network round-trip before the UI reflects the change, so there's a noticeable
delay between the tap and the score/icon updating. This switches voting to an
optimistic update: the state and score change immediately on tap, the
request is sent in the background, and the result is reconciled with the server
(rolling back on failure).
How
VotesModel.predictVote(from:operation:)mirrors NGA's togglesemantics so the optimistic value matches what the server returns:
UP+ upvote →NONE, −1),DOWN+ upvote →UP, +2),PostRowView.doVotenow applies the prediction immediately (with the existinghaptic), then sends
postVotein the background. On success it reconciles thescore against the pre-vote baseline using the server's authoritative
state/delta, so the server always wins regardless of the prediction. Onfailure it restores the previous state and shows an error toast.
The prediction table was cross-checked against the existing Rust
post_votetest sequence (
logic/service/src/post.rs) and matches it case-for-case, so theoptimistic value and the server result agree (no score flicker).
Testing
up↔down moves the score by 2 with no intermediate flicker; the score settles
to the server value.