feat(composer): optimistic feed row while mining / broadcasting#301
Open
dmnyc wants to merge 2 commits into
Open
feat(composer): optimistic feed row while mining / broadcasting#301dmnyc wants to merge 2 commits into
dmnyc wants to merge 2 commits into
Conversation
…casts Tapping Post used to dismiss the composer into a frozen feed — for a PoW-mined note the user stared at unchanged rows for several seconds before the real event arrived. The new `PendingPostStore` captures the in-flight content the instant `ComposeViewModel.publish` hands the draft to `PostPublisher`, and a `PendingPostRow` renders the same content as a dimmed `PostCardView` at the position the real card will land in. When the real signed event arrives via `.nostrEventPublished`, `FeedViewModel.observeOwnPublishes` inserts it into the live feed and the store clears its slot in the same render pass, so the dimmed placeholder dissolves and the real card takes its place without a flicker. On failure, `PostPublisher.fail` flips the slot's state to `.failed`; the row stays visible at full opacity with a red error pill and a dismiss button (the autosaved draft is still on disk so the user can re-open the composer to retry). Routing: - root note → home feed only - reply (event has `e` tags) → thread view only, gated to the thread whose focal / ancestors are referenced PostPublisher is single-slot so there's at most one pending post at a time; the store mirrors that model directly. Closes #289.
Both `.nostrEventPublished` observers (FeedViewModel.observeOwnPublishes
and PendingPostStore.init) wrapped their work in `Task { @mainactor in }`
even though the notification is already delivered on the main queue. That
Task hop yielded to the runloop between the two observers, so SwiftUI
could commit an intermediate frame where the real card was already in the
feed but the dimmed pending placeholder hadn't been cleared yet — the
user saw the optimistic row sitting under the real card for a beat.
Swap both Task hops for `MainActor.assumeIsolated { ... }` so the
observer chain runs synchronously in the same runloop tick, and
additionally call `PendingPostStore.shared.clearIfMatches(realEvent:)`
from inside the feed observer's insert block so the events update and
the pending clear are atomic regardless of observer registration order.
`clearIfMatches` is bumped from private to internal for that call.
c8d806a to
45069c0
Compare
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.
Summary
Tapping Post used to dismiss the composer into an unchanged feed. For a PoW-mined note the user stared at static rows for several seconds before the real event landed, with no signal the post was actually in flight. This PR shows a dimmed placeholder row immediately, in the exact spot the real card will eventually occupy.
How it works
PendingPostStore(@MainActor @Observablesingleton) captures the in-flight content the instantComposeViewModel.publishhands the draft toPostPublisher.submit. Mirrors PostPublisher's single-in-flight model — one slot.PendingPostRowwrapsPostCardViewwith.allowsHitTesting(false)and 50% opacity, with a small status pill overlaid in the top-right corner ("Mining…" / "Publishing…"). Size and layout match the real card exactly so the placeholder doesn't reflow when the real event takes its place..nostrEventPublishednotification,FeedViewModel.observeOwnPublishesinserts it intoeventsand the store clears its slot in the same render pass.PostPublisher.failflips the slot to.failed. The row stays visible at full opacity with a red error pill and a dismiss button. The autosaved draft is still on disk so the user can re-open the composer to retry — explicit Retry button deferred for a follow-up.Routing
etags) → thread view only, and gated to the thread whose focal or ancestors are actually referenced. A pending reply composed from one thread never bleeds into another.Files
PendingPostStore.swift— new, single-slot observable storePendingPostRow.swift— new, dimmedPostCardViewwrapper with status overlayComposeViewModel.swift— callsPendingPostStore.shared.startbefore handing the draft toPostPublisherPostPublisher.swift— flips the slot to.publishingon broadcast start,.failedon failure, clears on cancelMainView.swift— renders the pending row above the feedForEach, gated on!pendingIsReplyThreadView.swift— renders the pending row at the bottom of the replies list, gated onpendingIsReplyand on the reply'setags referencing this threadFollow-up fix (45069c0)
Both
.nostrEventPublishedobservers (FeedViewModel.observeOwnPublishesandPendingPostStore.init) wrapped their work inTask { @MainActor in }even though the notification is already delivered on the main queue. That Task hop yielded to the runloop between the two observers, so SwiftUI could commit an intermediate frame where the real card was already in the feed but the dimmed pending placeholder hadn't been cleared yet — the dimmed row sat under the real card for a beat. Swap both Task hops forMainActor.assumeIsolated { ... }so the observer chain runs synchronously in the same runloop tick, and additionally callPendingPostStore.shared.clearIfMatches(realEvent:)from inside the feed observer's insert block so the events update and the pending clear are atomic regardless of observer registration order.Test plan
Closes #289.