Skip to content

feat: optimistic vote updates - #388

Open
MufanQiu wants to merge 1 commit into
BugenZhao:mainfrom
MufanQiu:feat/optimistic-vote
Open

feat: optimistic vote updates#388
MufanQiu wants to merge 1 commit into
BugenZhao:mainfrom
MufanQiu:feat/optimistic-vote

Conversation

@MufanQiu

Copy link
Copy Markdown

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

  • A pure VotesModel.predictVote(from:operation:) mirrors NGA's toggle
    semantics so the optimistic value matches what the server returns:
    • tapping the current direction again clears the vote (UP + upvote → NONE, −1),
    • flipping across directions moves the score by 2 (DOWN + upvote → UP, +2),
    • voting from neutral moves by 1.
  • PostRowView.doVote now applies the prediction immediately (with the existing
    haptic), then sends postVote in the background. On success it reconciles the
    score against the pre-vote baseline using the server's authoritative
    state/delta, so the server always wins regardless of the prediction. On
    failure it restores the previous state and shows an error toast.

The prediction table was cross-checked against the existing Rust post_vote
test sequence (logic/service/src/post.rs) and matches it case-for-case, so the
optimistic value and the server result agree (no score flicker).

Testing

  • Built and ran on the iOS Simulator and a physical device.
  • Verified: tapping votes updates instantly; re-tapping toggles off; flipping
    up↔down moves the score by 2 with no intermediate flicker; the score settles
    to the server value.

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.
Copilot AI review requested due to automatic review settings June 30, 2026 21:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.doVote to 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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants