Skip to content

feat(composer): optimistic feed row while mining / broadcasting#301

Open
dmnyc wants to merge 2 commits into
mainfrom
feat/optimistic-post-preview
Open

feat(composer): optimistic feed row while mining / broadcasting#301
dmnyc wants to merge 2 commits into
mainfrom
feat/optimistic-post-preview

Conversation

@dmnyc

@dmnyc dmnyc commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

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 @Observable singleton) captures the in-flight content the instant ComposeViewModel.publish hands the draft to PostPublisher.submit. Mirrors PostPublisher's single-in-flight model — one slot.
  • PendingPostRow wraps PostCardView with .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.
  • When the real event arrives via the existing .nostrEventPublished notification, FeedViewModel.observeOwnPublishes inserts it into events and the store clears its slot in the same render pass.
  • On failure: PostPublisher.fail flips 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.
  • User cancels mining via the existing pill: the slot clears too.

Routing

  • Root note → home feed only
  • Reply (event has e tags) → 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 store
  • PendingPostRow.swift — new, dimmed PostCardView wrapper with status overlay
  • ComposeViewModel.swift — calls PendingPostStore.shared.start before handing the draft to PostPublisher
  • PostPublisher.swift — flips the slot to .publishing on broadcast start, .failed on failure, clears on cancel
  • MainView.swift — renders the pending row above the feed ForEach, gated on !pendingIsReply
  • ThreadView.swift — renders the pending row at the bottom of the replies list, gated on pendingIsReply and on the reply's e tags referencing this thread

Follow-up fix (45069c0)

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 dimmed row sat 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.

Test plan

  • Compose a root note from home with PoW on — dimmed full-size card appears instantly at the top, "Mining…" pill, then dissolves and real card takes its place with no overlap
  • Compose with PoW off — dimmed row briefly shows "Publishing…", then real card replaces it cleanly
  • Reply to a thread — dimmed row appears at the bottom of the replies list, not in the home feed; on success the real reply takes its place
  • Cancel mining via the post status pill — dimmed row disappears
  • Build clean for iOS Simulator

Closes #289.

dmnyc added 2 commits June 4, 2026 08:34
…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.
@dmnyc dmnyc force-pushed the feat/optimistic-post-preview branch from c8d806a to 45069c0 Compare June 4, 2026 12:36
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.

feat(composer): optimistic preview of pending post during mining / publish

1 participant