diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md
index 6ad03b09..8967adb5 100644
--- a/.agents/skills/flotilla-architecture/SKILL.md
+++ b/.agents/skills/flotilla-architecture/SKILL.md
@@ -86,7 +86,7 @@ imports. `flotilla-views` covers component conventions.
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event
-- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
+- `publications.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
- `env.ts`: every `VITE_` value, parsed
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
device id)
@@ -350,7 +350,7 @@ renders `ArticleForm`.
- `flotilla-views`: routes and layouts, components, modals, loading data from components
- `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
- `welshman`: overview of the packages
-- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks
+- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and the `Publisher`
- `welshman-domain`: readers, writers, and adding a kind
- `welshman-net`: the pool, sockets and socket policies
- `welshman-util`: kind constants, tag specs, `RelaySelection`
diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md
index b859daa4..f0221802 100644
--- a/.agents/skills/flotilla-model/SKILL.md
+++ b/.agents/skills/flotilla-model/SKILL.md
@@ -238,7 +238,7 @@ adds `setRoom` only when it is posting into a room, as `ThreadCreate`, `Classifi
Some routing is built into welshman. `Reactions.react` and `Deletes.deleteEvent` find the target's
relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every
space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory,
-so `RoomChat` and `publishRoomQuote` publish raw templates through `Thunks`.
+so `RoomChat` and `publishRoomQuote` publish raw templates through `Publisher`.
Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for
a room) and read results with `$events.forUrl(url, filters)`, which uses the tracker to keep
diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md
index 88a959d2..46bbdb22 100644
--- a/.agents/skills/flotilla-state/SKILL.md
+++ b/.agents/skills/flotilla-state/SKILL.md
@@ -1,6 +1,6 @@
---
name: flotilla-state
-description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)."
+description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → publication, optimistic updates, undo, showing publish status)."
---
# Flotilla state
@@ -8,7 +8,7 @@ description: "Use this skill when deciding where a piece of state belongs in Flo
State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in
the current app's repository. Plugin indexes and derived stores read the repository, and
components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the
-command becomes a thunk, and the thunk writes its event into the repository before any relay has
+command becomes a publication, and the `Publisher` writes its event into the repository before any relay has
seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
@@ -36,7 +36,7 @@ The app is therefore stable for as long as anything under the login gate is moun
| `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe |
-| `profiles`, `rooms`, `relays`, `events`, `thunks`, … | `usePlugin` for 28 welshman plugins | safe |
+| `profiles`, `rooms`, `relays`, `events`, `publisher`, … | `usePlugin` for 28 welshman plugins | safe |
| `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe |
| `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` |
| `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults |
@@ -177,8 +177,8 @@ app, and `app.get()` recurses into building another one.
## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
-`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing
-optimistically. The tracker records which relays each event was seen on, which is what lets
+`repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher`
+publishing optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay.
Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`.
@@ -233,8 +233,8 @@ A derivation that every row subscribes to, or that joins large sets, is built by
- `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather
than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
profiles index.
-- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array
- wherever an event's thunks are unchanged so rows don't churn.
+- `publicationsByEventId` (`publications.ts`) indexes publish history once, and hands back the
+ previous array wherever an event's publications are unchanged so rows don't churn.
- `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`.
- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
@@ -357,7 +357,7 @@ components; see flotilla-views.
## Mutations
-The prevailing path runs from a domain writer to a command to a thunk, adapted from
+The prevailing path runs from a domain writer to a command to a publication, adapted from
`ThreadCreate.svelte`:
```typescript
@@ -371,8 +371,8 @@ if (room) {
eventWriter.setRoom(url, room)
}
-const thunk = await command(eventWriter).then(publish)
-const error = await thunk.waitForError()
+const publication = await command(eventWriter).then(publish)
+const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
@@ -389,29 +389,30 @@ Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(
`forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`.
-Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that
+Some call sites call `publisher.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
-`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
-NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
+`wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`).
+NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return
`{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status
-- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its
+- **Optimistic writes.** `Publisher` writes the event into the repository and tracks it against its
relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
- unsigned event for the signed one.
-- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from
- `history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts,
- and a comment carries the same Cancel in the `ThunkPending` row under it.
+ unsigned event for the signed one, and `publication.event` follows it.
+- **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes
+ the event from the repository and from `history`. When `send_delay` is set, room chat shows a
+ `PublicationToast` whose Cancel button aborts, and a comment carries the same Cancel in the
+ `PublicationPending` row under it.
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`).
-- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
- `$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
- `ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
- whole `history` per row instead.
-- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
+- **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and
+ pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per
+ relay. A retry drops the publications it replaces from `history`, so the row shows the retry's
+ outcome. `PublicationStatusOrDeleted` combines publish status with deletion.
+- **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt
above.
## Other app-level stores
@@ -466,7 +467,7 @@ Take the first answer that fits:
- `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance
- `flotilla-views`: routes, components, and how components load data and show state
- `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds
-- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events`
+- `welshman-app`: `App`, plugins, `Command`, `Publisher`, `Network`/`Sync`, `Events`
- `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app
diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md
index 24eec9a2..eef3cea1 100644
--- a/.agents/skills/flotilla-views/SKILL.md
+++ b/.agents/skills/flotilla-views/SKILL.md
@@ -214,7 +214,7 @@ Svelte's delegated `onclick` on the `
` would never see the click. On mobile,
```typescript
pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}})
-pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}})
+pushToast({timeout: 30_000, children: {component: PublicationToast, props: {publication}}})
```
A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies
@@ -293,7 +293,7 @@ param change and lists key by id. A prop that does change while mounted needs `$
## Reading state in a component
Components read plugins through the `usePlugin` stores exported from `src/app/core.ts`
-(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$events`, `$network`, `$thunks`, `$deletes`,
+(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$events`, `$network`, `$publisher`, `$deletes`,
`$relayManagement`, …). `$app.use(X)` covers plugins with no export, such as `Zappers`,
`Pinboards` and `Feeds`; flotilla-state has the rules about which to use where.
@@ -398,7 +398,7 @@ ahead. All ten `*Item` components that appear in a list use it.
## Mutations from a component
-flotilla-state covers the writer → command → thunk path. The component around it owns a
+flotilla-state covers the writer → command → publication path. The component around it owns a
`loading` flag, awaits the first error, toasts it, and closes. Anything thrown on the way (a signer
refusing, a relay unreachable) is logged and toasted in a `catch`, so a bare `try`/`finally` never
lets it escape the handler:
@@ -408,8 +408,8 @@ const submit = async () => {
loading = true
try {
- const thunk = await command(eventWriter).then(publish)
- const error = await thunk.waitForError()
+ const publication = await command(eventWriter).then(publish)
+ const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
@@ -428,13 +428,13 @@ const submit = async () => {
Bind the flag to the button with `disabled={loading}` and ``. Plugin mutators
return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then
`.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` then
-`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
+`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a publication:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
-wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
+wrap their contents in `PublicationStatusOrDeleted`, and chat pushes a `PublicationToast`.
## Forms
@@ -481,9 +481,9 @@ Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
## Related skills
- `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement
-- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, thunks and commands
+- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, publications and commands
- `flotilla-model`: spaces, rooms, NIP-43/29/86, content kinds, and where events are published
-- `welshman-app`: plugins, projections, `Command`, thunks, `Events`, `Feeds`
+- `welshman-app`: plugins, projections, `Command`, `Publisher`, `Events`, `Feeds`
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the repository stores under `Events`
- `welshman-domain`: the readers and writers behind `reader` and `writer`
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed`
diff --git a/.agents/skills/flotilla-views/naming.md b/.agents/skills/flotilla-views/naming.md
index 4210ba7d..ceb12555 100644
--- a/.agents/skills/flotilla-views/naming.md
+++ b/.agents/skills/flotilla-views/naming.md
@@ -17,7 +17,7 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
| `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` |
-| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `ThunkStatus` |
+| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `PublicationStatus` |
| `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` |
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
@@ -41,7 +41,7 @@ per content kind). Four are families instead:
- `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one
`NoteContent` per kind, and a compact `NoteContentMinimal`.
- `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`.
-- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Thunk*` show
+- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Publication*` show
publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,
diff --git a/.agents/skills/welshman-app/SKILL.md b/.agents/skills/welshman-app/SKILL.md
index 79ff271a..8e7d6541 100644
--- a/.agents/skills/welshman-app/SKILL.md
+++ b/.agents/skills/welshman-app/SKILL.md
@@ -1,6 +1,6 @@
---
name: welshman-app
-description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search."
+description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing through a staged pipeline, outbox-model requests, routing, web of trust, feeds, and search."
---
# welshman/app — Instance-Based Nostr App
@@ -26,7 +26,7 @@ Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade
2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app.
3. **`Projection` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths.
4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive.
-5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
+5. **Writes are optimistic.** Publishing goes through the `Publisher` pipeline: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
## Creating an app
@@ -96,7 +96,7 @@ setNip55Plugin(NostrSignerPlugin)
All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`.
-Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
+Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Publication` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
| Plugin | Data | Notable accessors |
|---|---|---|
@@ -144,7 +144,7 @@ const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[]
// Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example")
-command.publish() // normal outbox/relays flow via Thunks
+command.publish() // normal outbox/relays flow via Publisher
// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await:
@@ -152,42 +152,46 @@ import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
```
-## Publishing (optimistic thunks)
+## Publishing (optimistic pipeline)
```typescript
-import {Thunks, Router} from "@welshman/app"
+import {Publisher, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
-// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
-// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the
-// hood for every data-plugin mutation, whose `relays` come from the writer's own routes):
-const thunk = app.use(Thunks).publish({
+// Publisher has no outbox helper. Resolve write relays with the Router's Resolver and the
+// RelaySelection DSL, as Command.publish() does for every data-plugin mutation:
+const publication = app.use(Publisher).publish({
event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise
- delay: 3000, // abortable soft-undo window (ms)
+ delay: 3000, // abortable soft-undo window (ms), before signing
})
// To specific relays:
-app.use(Thunks).publish({event, relays: ["wss://relay.example"]})
+app.use(Publisher).publish({event, relays: ["wss://relay.example"]})
-// A thunk is a Svelte store with per-relay status:
-thunk.subscribe(t => console.log(t.results))
-thunk.abort() // effective only before `delay` elapses
-await thunk.waitForCompletion()
-thunk.getError() // string | undefined
-app.use(Thunks).history // writable — optimistic log
-app.use(Thunks).retry(thunk)
-app.use(Thunks).retry(thunk, [url]) // only to these relays; a merged thunk retries only children that targeted them
+// A Publication is a Svelte store. `state` is a union keyed by `stage`:
+// queued → delayed → wrapping → pow → signing → sending → sent | failed | aborted
+publication.subscribe(p => console.log(p.state.stage, p.results))
+publication.abort() // unwinds the optimistic write; does nothing once settled
+publication.canAbort() // true until any relay has been sent to
+await publication.settled() // resolves on a terminal stage
+publication.getError() // string | undefined
+publication.event // current event (mined/signed as it goes); the rumor for a wrap
+app.use(Publisher).history // writable — optimistic log
+app.use(Publisher).retry(publication) // re-sends publication.event, same id
+app.use(Publisher).retry(publication, [url]) // only to these relays; a group retries only members that targeted them
+// retry drops the publications it replaces from `history`
+app.use(Publisher).merge([a, b]) // PublicationGroup; a relay's status is the worst among members
// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps:
-app.use(Thunks).publish({event, relays, recipient: theirPubkey})
-const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
+app.use(Publisher).publish({event, relays, recipient: theirPubkey})
+const group = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13):
-app.use(Thunks).publish({event, relays, pow: 20})
+app.use(Publisher).publish({event, relays, pow: 20})
```
-`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message.
+`PublishRequest`: `{event, relays, recipient?, delay?, pow?, priority?, timeout?}`. The stages (`STAGES` in `plugins/publisher.ts`) only transform the event. The publisher keeps the repository, tracker and `WrapManager` in step by reacting to state changes. Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message.
## Commands (deferred publishing)
@@ -202,11 +206,11 @@ command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it
-command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays})
+command.publish() // normal path: app.use(Publisher).publish({event, relays: command.relays})
command.publishToRelays(urls) // publish to a specific relay set instead of command.relays
```
-This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
+This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `PublicationGroup` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
`publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting:
@@ -441,7 +445,7 @@ const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]})
| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` |
| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` |
| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` |
-| `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
+| `publishThunk({...})` | `app.use(Publisher).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) |
| `load({...})` / `request({...})` | `app.use(Network).loadComplete({...})` / `request({...})` |
| `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) |
diff --git a/AGENTS.md b/AGENTS.md
index 8ee0e52c..cf69a17f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,7 +82,7 @@ Conventions below.
A `Projection` is `{get(): T, $: Readable}` — bind `.$` in markup, call `.get()` in
callbacks and hot paths.
-**Thunks:**
+**Publications** (`app.use(Publisher).publish`):
- Reduce UI latency by handling signatures and sending in background
- Return status that should be displayed to user
@@ -93,7 +93,7 @@ callbacks and hot paths.
**Welshman Library Suite:**
-- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …)
+- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Publisher, Router, …)
- `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
- `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.)
@@ -188,7 +188,7 @@ callbacks and hot paths.
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
2. Wrap it: `const command = await app.use(Domain).command(writer)`
3. Publish it: `command.publish()` or `.publishToRelays(urls)`
-4. Display thunk status to user (for cancel/error handling)
+4. Display publication status to user (for cancel/error handling)
Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already
return a `Command`, so `.then(publish)` is usually all you need.
diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md
index f366c56f..9649d486 100644
--- a/e2e/USER_STORIES.md
+++ b/e2e/USER_STORIES.md
@@ -1897,7 +1897,7 @@ does not serve is answered by an empty relay rather than refused, so no spec can
that fails. That leaves one invariant untested: a send whose reads fail before the message exists
must keep the text in the composer and say so, rather than clearing as though it went. It is the
shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving
-empty, so a failure there aborts a publish before its thunk is made and nothing reaches the
+empty, so a failure there aborts a publish before its publication is made and nothing reaches the
timeline to carry a status. Testing it needs a seam for making a relay unusable.
**Internals with no user-visible surface.** The legacy session-storage format
diff --git a/package.json b/package.json
index 697d8935..a34a82cd 100644
--- a/package.json
+++ b/package.json
@@ -97,16 +97,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
- "@welshman/app": "^0.11.3",
- "@welshman/content": "^0.11.3",
- "@welshman/domain": "^0.11.3",
- "@welshman/editor": "^0.11.3",
- "@welshman/feeds": "^0.11.3",
- "@welshman/lib": "^0.11.3",
- "@welshman/net": "^0.11.3",
- "@welshman/signer": "^0.11.3",
- "@welshman/store": "^0.11.3",
- "@welshman/util": "^0.11.3",
+ "@welshman/app": "^0.12.0",
+ "@welshman/content": "^0.12.0",
+ "@welshman/domain": "^0.12.0",
+ "@welshman/editor": "^0.12.0",
+ "@welshman/feeds": "^0.12.0",
+ "@welshman/lib": "^0.12.0",
+ "@welshman/net": "^0.12.0",
+ "@welshman/signer": "^0.12.0",
+ "@welshman/store": "^0.12.0",
+ "@welshman/util": "^0.12.0",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d4d1e94..f3320023 100644
Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ
diff --git a/src/app/access.ts b/src/app/access.ts
index 3a0f6e66..e13bbf2a 100644
--- a/src/app/access.ts
+++ b/src/app/access.ts
@@ -16,7 +16,7 @@ import {
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util"
-import {app, command, roomLists, thunks, writer} from "@app/core"
+import {app, command, publisher, roomLists, writer} from "@app/core"
import {PLATFORM_URL} from "@app/env"
import {relaysMostlyRestricted} from "@app/policies"
import {optInSpaceNotifications} from "@app/push"
@@ -109,7 +109,7 @@ export const publishLeaveRequest = (url: string) =>
// "duplicate:" and "already a member" both leave us where we wanted to be.
const isMembershipRefusal = (error: string) =>
- Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already")
+ !error.startsWith("duplicate:") && !error.includes("already")
// Joining takes two publishes: the NIP-29 request the relay can refuse, and the user's room list.
export const joinRoom = async (url: string, h: string, code?: string) => {
@@ -119,10 +119,10 @@ export const joinRoom = async (url: string, h: string, code?: string) => {
eventWriter.setClaim(code)
}
- const thunk = await command(eventWriter).then(publish)
- const error = await thunk.waitForError()
+ const publication = await command(eventWriter).then(publish)
+ const error = (await publication.settled()).getError()
- if (isMembershipRefusal(error)) {
+ if (error && isMembershipRefusal(error)) {
return error
}
@@ -130,10 +130,10 @@ export const joinRoom = async (url: string, h: string, code?: string) => {
}
export const leaveRoom = async (url: string, h: string) => {
- const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
- const error = await thunk.waitForError()
+ const publication = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
+ const error = (await publication.settled()).getError()
- if (isMembershipRefusal(error)) {
+ if (error && isMembershipRefusal(error)) {
return error
}
@@ -155,10 +155,8 @@ export const publishRoomInvite = async (url: string, h: string) => {
],
})
- const error = await thunks
- .get()
- .publish({event, relays: [url]})
- .waitForError()
+ const publication = publisher.get().publish({event, relays: [url]})
+ const error = (await publication.settled()).getError()
if (error) {
return {error, code: undefined}
@@ -223,23 +221,21 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return formatAuthError(socket.auth.status, socket.auth.details)
}
- const thunk = await publishJoinRequest(url, claim)
- const error = await thunk.waitForError()
+ const publication = await publishJoinRequest(url, claim)
+ const error = (await publication.settled()).getError()
- if (shouldIgnoreError(error)) {
- return
+ if (error && !shouldIgnoreError(error)) {
+ if (error.includes("invite code")) {
+ return "join request rejected"
+ }
+
+ // A space that isn't open to the public refuses a join carrying no claim at all
+ if (error.includes("claim")) {
+ return "This space requires an invite code"
+ }
+
+ return stripPrefix(error)
}
-
- if (error.includes("invite code")) {
- return "join request rejected"
- }
-
- // A space that isn't open to the public refuses a join carrying no claim at all
- if (error.includes("claim")) {
- return "This space requires an invite code"
- }
-
- return stripPrefix(error)
}
export const completeSpaceJoin = async (url: string, notifications: boolean) => {
diff --git a/src/app/calendar.ts b/src/app/calendar.ts
index dbf080ce..3e5e33ec 100644
--- a/src/app/calendar.ts
+++ b/src/app/calendar.ts
@@ -14,7 +14,7 @@ import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp, TimeEvent} from "@welshman/domain"
import type {RsvpStatus} from "@welshman/domain"
import {synced} from "@welshman/store"
-import {deletes, events, reader, relays, thunks, writer} from "@app/core"
+import {deletes, events, publisher, reader, relays, writer} from "@app/core"
import {addressWriterToSpace} from "@app/rooms"
import {kv} from "@app/storage"
@@ -268,7 +268,7 @@ export const publishRsvp = async (url: string, event: TrustedEvent, status: Rsvp
reader(TimeEvent)(event).room(),
)
- return thunks.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
+ return publisher.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
}
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {
diff --git a/src/app/components/ArticleActionBar.svelte b/src/app/components/ArticleActionBar.svelte
index dcddb2ae..ca98e114 100644
--- a/src/app/components/ArticleActionBar.svelte
+++ b/src/app/components/ArticleActionBar.svelte
@@ -1,7 +1,7 @@
-
+
-
+
{#if !$deleted}
{#snippet leading()}
diff --git a/src/app/components/ArticleForm.svelte b/src/app/components/ArticleForm.svelte
index 10336be4..193fd0ff 100644
--- a/src/app/components/ArticleForm.svelte
+++ b/src/app/components/ArticleForm.svelte
@@ -131,14 +131,14 @@
eventWriter.setImage(image)
}
- const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
+ const {publication, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
- goto(makeArticlePath(url, getAddress(thunk.options.event)))
+ goto(makeArticlePath(url, getAddress(publication.event)))
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to publish your article."})
diff --git a/src/app/components/BoardMenu.svelte b/src/app/components/BoardMenu.svelte
index dfecc855..4b2f2cda 100644
--- a/src/app/components/BoardMenu.svelte
+++ b/src/app/components/BoardMenu.svelte
@@ -39,7 +39,7 @@
const command = await $deletes.deleteEvent(board.event, writer =>
writer.setProtected(protect),
)
- const error = await command.publishToRelays([url]).waitForError()
+ const error = (await command.publishToRelays([url]).settled()).getError()
if (error) {
pushToast({theme: "error", message: error})
diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte
index df393954..84fac32e 100644
--- a/src/app/components/Chat.svelte
+++ b/src/app/components/Chat.svelte
@@ -17,7 +17,7 @@
import type {TrustedEvent, EventTemplate} from "@welshman/util"
import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
- import {MessagingRelayLists, Thunks} from "@welshman/app"
+ import {MessagingRelayLists, Publisher} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -34,7 +34,7 @@
import ChatTitle from "@app/components/ChatTitle.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
- import ThunkToast from "@app/components/ThunkToast.svelte"
+ import PublicationToast from "@app/components/PublicationToast.svelte"
import {app, deletes, router, user, wraps} from "@app/core"
import {loadSendDelay} from "@app/settings"
import {chats, makeChatId} from "@app/chats"
@@ -120,7 +120,7 @@
const delay = await loadSendDelay()
// Kind 15 carries one image each per nip 17, and a second's sleep keeps the timestamps distinct.
- const thunks = await Promise.all(
+ const groups = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
$wraps.publish({
event,
@@ -134,8 +134,8 @@
pushToast({
timeout: 30_000,
children: {
- component: ThunkToast,
- props: {thunk: $app.use(Thunks).merge(thunks)},
+ component: PublicationToast,
+ props: {publication: $app.use(Publisher).merge(groups)},
},
})
}
diff --git a/src/app/components/ChatEnable.svelte b/src/app/components/ChatEnable.svelte
index c3a9ba67..1c8dc9a9 100644
--- a/src/app/components/ChatEnable.svelte
+++ b/src/app/components/ChatEnable.svelte
@@ -36,7 +36,7 @@
writer.setReadUrls(DEFAULT_RELAYS).setWriteUrls(DEFAULT_RELAYS),
)
- const error = await command.publish().waitForError()
+ const error = (await command.publish().settled()).getError()
if (error) {
pushToast({theme: "error", message: error})
@@ -45,7 +45,7 @@
}
const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS)
- const error = await command.publish().waitForError()
+ const error = (await command.publish().settled()).getError()
if (error) {
pushToast({theme: "error", message: error})
diff --git a/src/app/components/ChatMessage.svelte b/src/app/components/ChatMessage.svelte
index dd2ac469..551525a2 100644
--- a/src/app/components/ChatMessage.svelte
+++ b/src/app/components/ChatMessage.svelte
@@ -15,12 +15,12 @@
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
- import ThunkFailure from "@app/components/ThunkFailure.svelte"
+ import PublicationFailure from "@app/components/PublicationFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
- import {profiles, thunks, user} from "@app/core"
- import {noThunks, thunksByEventId} from "@app/thunks"
+ import {profiles, publisher, user} from "@app/core"
+ import {noPublications, publicationsByEventId} from "@app/publications"
import {highlightedEvent} from "@app/routes"
import {colorFor} from "@app/theme"
import {pushModal} from "@app/modal"
@@ -39,7 +39,9 @@
const isOwn = event.pubkey === $user.pubkey
const profileDisplay = $profiles.display(event.pubkey).$
- const thunk = $derived($thunks.merge($thunksByEventId.get(event.id) ?? noThunks))
+ const publication = $derived(
+ $publisher.merge($publicationsByEventId.get(event.id) ?? noPublications),
+ )
const colorValue = colorFor(event.pubkey)
const reply = () => replyTo(event)
@@ -58,7 +60,7 @@
let tippy: Maybe = $state()
-
+
-
+
{/if}
-
+
diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte
index bde6da75..bf034534 100644
--- a/src/app/components/CommentCompose.svelte
+++ b/src/app/components/CommentCompose.svelte
@@ -8,7 +8,7 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
- import {thunks, writer} from "@app/core"
+ import {publisher, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {publishComment} from "@app/rooms"
@@ -42,7 +42,7 @@
return publishComment({url, event, parent, content, tags})
}
- return $thunks.publish({
+ return $publisher.publish({
...(await writer(Note)
.setContent(content)
.addTags(...tags)
@@ -67,8 +67,8 @@
loading = true
try {
- const thunk = await publishReply(content, ed.storage.nostr.getEditorTags())
- const error = await thunk.waitForError()
+ const publication = await publishReply(content, ed.storage.nostr.getEditorTags())
+ const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
diff --git a/src/app/components/ContentActions.svelte b/src/app/components/ContentActions.svelte
index 1c227cba..1261d761 100644
--- a/src/app/components/ContentActions.svelte
+++ b/src/app/components/ContentActions.svelte
@@ -8,7 +8,7 @@
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
- import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
+ import PublicationStatusOrDeleted from "@app/components/PublicationStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {user} from "@app/core"
@@ -66,7 +66,7 @@