Upgrade to welshman 0.12.0 and publish through Publisher, renaming thunk code and components to publications

This commit is contained in:
Jon Staab 2026-09-28 11:45:09 -07:00
parent 4d4e736c5e
commit c7669e9cfc
59 changed files with 335 additions and 319 deletions

View file

@ -86,7 +86,7 @@ imports. `flotilla-views` covers component conventions.
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs - `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings - `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event - `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 - `env.ts`: every `VITE_` value, parsed
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a - `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
device id) device id)
@ -350,7 +350,7 @@ renders `ArticleForm`.
- `flotilla-views`: routes and layouts, components, modals, loading data from components - `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 - `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
- `welshman`: overview of the packages - `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-domain`: readers, writers, and adding a kind
- `welshman-net`: the pool, sockets and socket policies - `welshman-net`: the pool, sockets and socket policies
- `welshman-util`: kind constants, tag specs, `RelaySelection` - `welshman-util`: kind constants, tag specs, `RelaySelection`

View file

@ -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 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 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, 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 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 a room) and read results with `$events.forUrl(url, filters)`, which uses the tracker to keep

View file

@ -1,6 +1,6 @@
--- ---
name: flotilla-state 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 # 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 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 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 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. seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app. 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` | | `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws | | `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe | | `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 | | `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` | | `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 | | `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 ## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing `repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher`
optimistically. The tracker records which relays each event was seen on, which is what lets publishing optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay. space content be keyed by relay.
Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`. 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 - `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 than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
profiles index. profiles index.
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array - `publicationsByEventId` (`publications.ts`) indexes publish history once, and hands back the
wherever an event's thunks are unchanged so rows don't churn. 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 - `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`. settings behind `throttled(1000, …)`.
- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every - `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
@ -357,7 +357,7 @@ components; see flotilla-views.
## Mutations ## 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`: `ThreadCreate.svelte`:
```typescript ```typescript
@ -371,8 +371,8 @@ if (room) {
eventWriter.setRoom(url, room) eventWriter.setRoom(url, room)
} }
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: 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 `forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`. `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 honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in (`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through `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`). `wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return
`{result, error}`, and the caller handles `error`. `{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status ### 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 relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
unsigned event for the signed one. unsigned event for the signed one, and `publication.event` follows it.
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from - **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts, the event from the repository and from `history`. When `send_delay` is set, room chat shows a
and a comment carries the same Cancel in the `ThunkPending` row under it. `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 - **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`). `RoomChat.svelte`).
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass - **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay. pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the relay. A retry drops the publications it replaces from `history`, so the row shows the retry's
whole `history` per row instead. outcome. `PublicationStatusOrDeleted` combines publish status with deletion.
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt - **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt
above. above.
## Other app-level stores ## 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-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-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 - `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-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command` - `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app - `welshman-net`: the repository, tracker and socket policies under the app

View file

@ -214,7 +214,7 @@ Svelte's delegated `onclick` on the `<ul>` would never see the click. On mobile,
```typescript ```typescript
pushToast({message: "Role created!"}) pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}}) 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 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 ## Reading state in a component
Components read plugins through the `usePlugin` stores exported from `src/app/core.ts` 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`, `$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. `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 ## 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 `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 refusing, a relay unreachable) is logged and toasted in a `catch`, so a bare `try`/`finally` never
lets it escape the handler: lets it escape the handler:
@ -408,8 +408,8 @@ const submit = async () => {
loading = true loading = true
try { try {
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -428,13 +428,13 @@ const submit = async () => {
Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators
return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then
`.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` 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`. `$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`, 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 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` 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 ## Forms
@ -481,9 +481,9 @@ Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
## Related skills ## Related skills
- `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement - `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 - `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-store`: `deriveEventsById`, `deriveEventsAsc`, the repository stores under `Events`
- `welshman-domain`: the readers and writers behind `reader` and `writer` - `welshman-domain`: the readers and writers behind `reader` and `writer`
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed` - `welshman-feeds`: `FeedController`, still used by `ProfileFeed`

View file

@ -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` | | `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` | | `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
| `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` | | `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` | | `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` |
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` | | `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` | | `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 - `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one
`NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`. `NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`.
- `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`. - `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`). publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`, Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,

View file

@ -1,6 +1,6 @@
--- ---
name: welshman-app 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 # 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. 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<T>` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths. 3. **`Projection<T>` 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. 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 ## 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(...)`. 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 | | 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 // Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example") 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 // or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await: // 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) await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
``` ```
## Publishing (optimistic thunks) ## Publishing (optimistic pipeline)
```typescript ```typescript
import {Thunks, Router} from "@welshman/app" import {Publisher, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util" import {makeEvent, NOTE, userOutbox} from "@welshman/util"
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the // Publisher has no outbox helper. Resolve write relays with the Router's Resolver and the
// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the // RelaySelection DSL, as Command.publish() does for every data-plugin mutation:
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes): const publication = app.use(Publisher).publish({
const thunk = app.use(Thunks).publish({
event: makeEvent(NOTE, {content: "hi"}), event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]> relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
delay: 3000, // abortable soft-undo window (ms) delay: 3000, // abortable soft-undo window (ms), before signing
}) })
// To specific relays: // 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: // A Publication is a Svelte store. `state` is a union keyed by `stage`:
thunk.subscribe(t => console.log(t.results)) // queued → delayed → wrapping → pow → signing → sending → sent | failed | aborted
thunk.abort() // effective only before `delay` elapses publication.subscribe(p => console.log(p.state.stage, p.results))
await thunk.waitForCompletion() publication.abort() // unwinds the optimistic write; does nothing once settled
thunk.getError() // string | undefined publication.canAbort() // true until any relay has been sent to
app.use(Thunks).history // writable<Thunk[]> — optimistic log await publication.settled() // resolves on a terminal stage
app.use(Thunks).retry(thunk) publication.getError() // string | undefined
app.use(Thunks).retry(thunk, [url]) // only to these relays; a merged thunk retries only children that targeted them publication.event // current event (mined/signed as it goes); the rumor for a wrap
app.use(Publisher).history // writable<Publication[]> — 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: // Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps:
app.use(Thunks).publish({event, relays, recipient: theirPubkey}) app.use(Publisher).publish({event, relays, recipient: theirPubkey})
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]}) const group = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13): // 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) ## Commands (deferred publishing)
@ -202,11 +206,11 @@ command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it 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 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: `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` | | `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` |
| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` | | `deriveProfile(pk)` | `app.use(Profiles).one(pk)` |
| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(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) | | `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({...})` | | `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)])`) | | `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) |

View file

@ -82,7 +82,7 @@ Conventions below.
A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in
callbacks and hot paths. callbacks and hot paths.
**Thunks:** **Publications** (`app.use(Publisher).publish`):
- Reduce UI latency by handling signatures and sending in background - Reduce UI latency by handling signatures and sending in background
- Return status that should be displayed to user - Return status that should be displayed to user
@ -93,7 +93,7 @@ callbacks and hot paths.
**Welshman Library Suite:** **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/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull) - `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
- `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.) - `@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 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)` 2. Wrap it: `const command = await app.use(Domain).command(writer)`
3. Publish it: `command.publish()` or `.publishToRelays(urls)` 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 Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already
return a `Command`, so `.then(publish)` is usually all you need. return a `Command`, so `.then(publish)` is usually all you need.

View file

@ -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 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 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 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. 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 **Internals with no user-visible surface.** The legacy session-storage format

View file

@ -97,16 +97,16 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.11.3", "@welshman/app": "^0.12.0",
"@welshman/content": "^0.11.3", "@welshman/content": "^0.12.0",
"@welshman/domain": "^0.11.3", "@welshman/domain": "^0.12.0",
"@welshman/editor": "^0.11.3", "@welshman/editor": "^0.12.0",
"@welshman/feeds": "^0.11.3", "@welshman/feeds": "^0.12.0",
"@welshman/lib": "^0.11.3", "@welshman/lib": "^0.12.0",
"@welshman/net": "^0.11.3", "@welshman/net": "^0.12.0",
"@welshman/signer": "^0.11.3", "@welshman/signer": "^0.12.0",
"@welshman/store": "^0.11.3", "@welshman/store": "^0.12.0",
"@welshman/util": "^0.11.3", "@welshman/util": "^0.12.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13", "dompurify": "^3.4.13",

Binary file not shown.

View file

@ -16,7 +16,7 @@ import {
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app" import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util" 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 {PLATFORM_URL} from "@app/env"
import {relaysMostlyRestricted} from "@app/policies" import {relaysMostlyRestricted} from "@app/policies"
import {optInSpaceNotifications} from "@app/push" 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. // "duplicate:" and "already a member" both leave us where we wanted to be.
const isMembershipRefusal = (error: string) => 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. // 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) => { 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) eventWriter.setClaim(code)
} }
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (isMembershipRefusal(error)) { if (error && isMembershipRefusal(error)) {
return 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) => { export const leaveRoom = async (url: string, h: string) => {
const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish) const publication = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (isMembershipRefusal(error)) { if (error && isMembershipRefusal(error)) {
return error return error
} }
@ -155,10 +155,8 @@ export const publishRoomInvite = async (url: string, h: string) => {
], ],
}) })
const error = await thunks const publication = publisher.get().publish({event, relays: [url]})
.get() const error = (await publication.settled()).getError()
.publish({event, relays: [url]})
.waitForError()
if (error) { if (error) {
return {error, code: undefined} return {error, code: undefined}
@ -223,23 +221,21 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return formatAuthError(socket.auth.status, socket.auth.details) return formatAuthError(socket.auth.status, socket.auth.details)
} }
const thunk = await publishJoinRequest(url, claim) const publication = await publishJoinRequest(url, claim)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (shouldIgnoreError(error)) { if (error && !shouldIgnoreError(error)) {
return 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) => { export const completeSpaceJoin = async (url: string, notifications: boolean) => {

View file

@ -14,7 +14,7 @@ import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp, TimeEvent} from "@welshman/domain" import {CalendarRsvp, TimeEvent} from "@welshman/domain"
import type {RsvpStatus} from "@welshman/domain" import type {RsvpStatus} from "@welshman/domain"
import {synced} from "@welshman/store" 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 {addressWriterToSpace} from "@app/rooms"
import {kv} from "@app/storage" import {kv} from "@app/storage"
@ -268,7 +268,7 @@ export const publishRsvp = async (url: string, event: TrustedEvent, status: Rsvp
reader(TimeEvent)(event).room(), 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) => { export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.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 EventActivity from "@app/components/EventActivity.svelte"
import EventActionBar from "@app/components/EventActionBar.svelte" import EventActionBar from "@app/components/EventActionBar.svelte"
import {events} from "@app/core" import {events} from "@app/core"
@ -24,7 +24,7 @@
</script> </script>
<div data-component="ArticleActionBar" class="flex w-full min-w-0 flex-col gap-3"> <div data-component="ArticleActionBar" class="flex w-full min-w-0 flex-col gap-3">
<ThunkStatusOrDeleted {event} {context}> <PublicationStatusOrDeleted {event} {context}>
<ReactionSummary <ReactionSummary
{url} {url}
{event} {event}
@ -32,7 +32,7 @@
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tip-top" /> reactionClass="tip-top" />
</ThunkStatusOrDeleted> </PublicationStatusOrDeleted>
{#if !$deleted} {#if !$deleted}
<EventActionBar {url} {event} noun="Article"> <EventActionBar {url} {event} noun="Article">
{#snippet leading()} {#snippet leading()}

View file

@ -131,14 +131,14 @@
eventWriter.setImage(image) eventWriter.setImage(image)
} }
const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat}) const {publication, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} }
draftKey.clear() draftKey.clear()
goto(makeArticlePath(url, getAddress(thunk.options.event))) goto(makeArticlePath(url, getAddress(publication.event)))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
pushToast({theme: "error", message: "Failed to publish your article."}) pushToast({theme: "error", message: "Failed to publish your article."})

View file

@ -39,7 +39,7 @@
const command = await $deletes.deleteEvent(board.event, writer => const command = await $deletes.deleteEvent(board.event, writer =>
writer.setProtected(protect), writer.setProtected(protect),
) )
const error = await command.publishToRelays([url]).waitForError() const error = (await command.publishToRelays([url]).settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -17,7 +17,7 @@
import type {TrustedEvent, EventTemplate} from "@welshman/util" import type {TrustedEvent, EventTemplate} from "@welshman/util"
import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util" import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content" 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 Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl" import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -34,7 +34,7 @@
import ChatTitle from "@app/components/ChatTitle.svelte" import ChatTitle from "@app/components/ChatTitle.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.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 {app, deletes, router, user, wraps} from "@app/core"
import {loadSendDelay} from "@app/settings" import {loadSendDelay} from "@app/settings"
import {chats, makeChatId} from "@app/chats" import {chats, makeChatId} from "@app/chats"
@ -120,7 +120,7 @@
const delay = await loadSendDelay() const delay = await loadSendDelay()
// Kind 15 carries one image each per nip 17, and a second's sleep keeps the timestamps distinct. // 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]) => Array.from(enumerate(templates)).map(([i, event]) =>
$wraps.publish({ $wraps.publish({
event, event,
@ -134,8 +134,8 @@
pushToast({ pushToast({
timeout: 30_000, timeout: 30_000,
children: { children: {
component: ThunkToast, component: PublicationToast,
props: {thunk: $app.use(Thunks).merge(thunks)}, props: {publication: $app.use(Publisher).merge(groups)},
}, },
}) })
} }

View file

@ -36,7 +36,7 @@
writer.setReadUrls(DEFAULT_RELAYS).setWriteUrls(DEFAULT_RELAYS), writer.setReadUrls(DEFAULT_RELAYS).setWriteUrls(DEFAULT_RELAYS),
) )
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -45,7 +45,7 @@
} }
const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS) const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS)
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -15,12 +15,12 @@
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds" 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 ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {profiles, thunks, user} from "@app/core" import {profiles, publisher, user} from "@app/core"
import {noThunks, thunksByEventId} from "@app/thunks" import {noPublications, publicationsByEventId} from "@app/publications"
import {highlightedEvent} from "@app/routes" import {highlightedEvent} from "@app/routes"
import {colorFor} from "@app/theme" import {colorFor} from "@app/theme"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -39,7 +39,9 @@
const isOwn = event.pubkey === $user.pubkey const isOwn = event.pubkey === $user.pubkey
const profileDisplay = $profiles.display(event.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 colorValue = colorFor(event.pubkey)
const reply = () => replyTo(event) const reply = () => replyTo(event)
@ -58,7 +60,7 @@
let tippy: Maybe<TippyController> = $state() let tippy: Maybe<TippyController> = $state()
</script> </script>
<ThunkFailure showToastOnRetry {thunk} class="mt-1" /> <PublicationFailure showToastOnRetry {publication} class="mt-1" />
<div <div
data-event={event.id} data-event={event.id}
class={cx("group flex items-center justify-end gap-1 px-2", { class={cx("group flex items-center justify-end gap-1 px-2", {

View file

@ -3,7 +3,7 @@
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {publishReaction, retractReaction} from "@app/reactions" import {publishReaction, retractReaction} from "@app/reactions"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import PublicationStatusOrDeleted from "@app/components/PublicationStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
@ -23,7 +23,7 @@
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap justify-end gap-2">
<ThunkStatusOrDeleted {event} {context}> <PublicationStatusOrDeleted {event} {context}>
<ReactionSummary <ReactionSummary
{url} {url}
{event} {event}
@ -35,6 +35,6 @@
<EventActivity {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Comment" /> <EventActions {url} {event} noun="Comment" />
</ThunkStatusOrDeleted> </PublicationStatusOrDeleted>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.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 {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {publishComment} from "@app/rooms" import {publishComment} from "@app/rooms"
@ -42,7 +42,7 @@
return publishComment({url, event, parent, content, tags}) return publishComment({url, event, parent, content, tags})
} }
return $thunks.publish({ return $publisher.publish({
...(await writer(Note) ...(await writer(Note)
.setContent(content) .setContent(content)
.addTags(...tags) .addTags(...tags)
@ -67,8 +67,8 @@
loading = true loading = true
try { try {
const thunk = await publishReply(content, ed.storage.nostr.getEditorTags()) const publication = await publishReply(content, ed.storage.nostr.getEditorTags())
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})

View file

@ -8,7 +8,7 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.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 EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import {user} from "@app/core" import {user} from "@app/core"
@ -66,7 +66,7 @@
</div> </div>
<span class="h-6 w-px shrink-0 bg-line"></span> <span class="h-6 w-px shrink-0 bg-line"></span>
{/if} {/if}
<ThunkStatusOrDeleted {event} {context} {status}> <PublicationStatusOrDeleted {event} {context} {status}>
<ReactionSummary <ReactionSummary
{url} {url}
{event} {event}
@ -90,5 +90,5 @@
{/if} {/if}
{/snippet} {/snippet}
</EventActions> </EventActions>
</ThunkStatusOrDeleted> </PublicationStatusOrDeleted>
</div> </div>

View file

@ -31,8 +31,8 @@
loading = true loading = true
try { try {
const thunk = await setFeaturedContent(url, content) const publication = await setFeaturedContent(url, content)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -22,7 +22,7 @@
if ($adminDelete === AdminDelete.Room) { if ($adminDelete === AdminDelete.Room) {
const command = await $rooms.deleteEvent(url, {h}, event.id) const command = await $rooms.deleteEvent(url, {h}, event.id)
return command.publish().waitForError() return (await command.publish().settled()).getError()
} }
const {error} = await $relayManagement.forUrl(url).banEvent(event.id) const {error} = await $relayManagement.forUrl(url).banEvent(event.id)

View file

@ -17,7 +17,7 @@
const confirm = async () => { const confirm = async () => {
const protect = await shouldProtect const protect = await shouldProtect
const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect)) const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect))
const error = await command.publishToRelays([url]).waitForError() const error = (await command.publishToRelays([url]).settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})

View file

@ -25,7 +25,7 @@
parent?: TrustedEvent parent?: TrustedEvent
onClose: () => void onClose: () => void
onClearParent?: () => void onClearParent?: () => void
onSubmit: (thunk: unknown) => void onSubmit: (publication: unknown) => void
} }
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props() const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
@ -54,14 +54,14 @@
const tags = ed.storage.nostr.getEditorTags() const tags = ed.storage.nostr.getEditorTags()
try { try {
const thunk = await publishComment({ const publication = await publishComment({
url, url,
event, event,
...(await prependParent(parent, {content, tags}, url)), ...(await prependParent(parent, {content, tags}, url)),
}) })
draftKey.clear() draftKey.clear()
onSubmit(thunk) onSubmit(publication)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
pushToast({theme: "error", message: "Failed to send your reply."}) pushToast({theme: "error", message: "Failed to send your reply."})

View file

@ -33,7 +33,7 @@
const save = async (plan: HealthCheckPlan) => { const save = async (plan: HealthCheckPlan) => {
const command = await plan.apply() const command = await plan.apply()
const error = command && (await publish(command).waitForError()) const error = command && (await publish(command).settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`}) pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`})

View file

@ -33,7 +33,7 @@
try { try {
const protect = await $relays.hasNip(url, 70) const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(pin.event, writer => writer.setProtected(protect)) const command = await $deletes.deleteEvent(pin.event, writer => writer.setProtected(protect))
const error = await command.publishToRelays([url]).waitForError() const error = (await command.publishToRelays([url]).settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -62,9 +62,9 @@
.setTopics(topics) .setTopics(topics)
.setContent(ed.getText({blockSeparator: "\n"}).trim()) .setContent(ed.getText({blockSeparator: "\n"}).trim())
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
return thunk.waitForError() return (await publication.settled()).getError()
} }
const onSubmit = async () => { const onSubmit = async () => {

View file

@ -43,8 +43,8 @@
eventWriter.setIdentifier().setCollaborative(true) eventWriter.setIdentifier().setCollaborative(true)
} }
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -53,7 +53,7 @@
back() back()
} else { } else {
pushToast({message: "Shelf created!"}) pushToast({message: "Shelf created!"})
navigate(makeLibraryPath(url, getAddress(thunk.options.event))) navigate(makeLibraryPath(url, getAddress(publication.event)))
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View file

@ -4,9 +4,9 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE, tagSpec, tagValues, relay} from "@welshman/util" import {POLL_RESPONSE, tagSpec, tagValues, relay} from "@welshman/util"
import {Poll, PollResponse} from "@welshman/domain" import {Poll, PollResponse} from "@welshman/domain"
import type {Thunk} from "@welshman/app" import type {Publication} from "@welshman/app"
import PollOption from "@app/components/PollOption.svelte" import PollOption from "@app/components/PollOption.svelte"
import {command, events, reader, thunks, user, writer} from "@app/core" import {command, events, publisher, reader, user, writer} from "@app/core"
type Props = { type Props = {
url: string url: string
@ -33,8 +33,8 @@
} }
const publishSelection = async (selection: string[]) => { const publishSelection = async (selection: string[]) => {
activeThunk?.abort() activePublication?.abort()
activeThunk = undefined activePublication = undefined
if (selection.length > 0) { if (selection.length > 0) {
const eventWriter = writer(PollResponse).forceRoutes(relay(url)).setPollId(event.id) const eventWriter = writer(PollResponse).forceRoutes(relay(url)).setPollId(event.id)
@ -46,7 +46,7 @@
const responseCommand = await command(eventWriter) const responseCommand = await command(eventWriter)
// Give the user time to check more boxes before the vote actually goes out. // Give the user time to check more boxes before the vote actually goes out.
activeThunk = $thunks.publish({ activePublication = $publisher.publish({
event: responseCommand.event, event: responseCommand.event,
relays: responseCommand.relays, relays: responseCommand.relays,
delay: pollType === "multiplechoice" ? 1000 : undefined, delay: pollType === "multiplechoice" ? 1000 : undefined,
@ -72,7 +72,7 @@
const poll = reader(Poll)(event) const poll = reader(Poll)(event)
let selectedIds = $state<string[]>([]) let selectedIds = $state<string[]>([])
let activeThunk: Thunk | undefined let activePublication: Publication | undefined
const pollType = poll.pollType() const pollType = poll.pollType()
const results = $derived(poll.results($responses)) const results = $derived(poll.results($responses))
@ -87,7 +87,7 @@
}) })
onDestroy(() => { onDestroy(() => {
activeThunk?.abort() activePublication?.abort()
}) })
</script> </script>

View file

@ -18,7 +18,7 @@
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {logout} from "@app/session" import {logout} from "@app/session"
import {app, relayLists, thunks, user, writer} from "@app/core" import {app, publisher, relayLists, user, writer} from "@app/core"
let progress: number | undefined = $state(undefined) let progress: number | undefined = $state(undefined)
let confirmText = $state("") let confirmText = $state("")
@ -51,12 +51,12 @@
} }
// First, blank out their profile in case relays don't support deletion by address // First, blank out their profile in case relays don't support deletion by address
await $thunks.publish({relays, event: profileEvent}) await $publisher.publish({relays, event: profileEvent})
await incrementProgress() await incrementProgress()
// Next, send a "right to vanish" event to all relays // Next, send a "right to vanish" event to all relays
await $thunks.publish({relays, event: vanishEvent}) await $publisher.publish({relays, event: vanishEvent})
await incrementProgress() await incrementProgress()
@ -72,7 +72,7 @@
} }
} }
await $thunks.publish({relays, event: makeEvent(DELETE, {tags})}) await $publisher.publish({relays, event: makeEvent(DELETE, {tags})})
await incrementProgress() await incrementProgress()
} }

View file

@ -19,7 +19,7 @@
try { try {
const command = await $profiles.update(writer => writer.update(profile)) const command = await $profiles.update(writer => writer.update(profile))
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({ pushToast({

View file

@ -86,7 +86,7 @@
if (result?.url) { if (result?.url) {
const command = await $profiles.update(w => w.setBanner(result.url)) const command = await $profiles.update(w => w.setBanner(result.url))
await command.publish().waitForCompletion() await command.publish().settled()
pushToast({message: "Banner updated."}) pushToast({message: "Banner updated."})
} else { } else {

View file

@ -2,36 +2,40 @@
import cx from "classnames" import cx from "classnames"
import {stopPropagation} from "svelte/legacy" import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib" import {noop} from "@welshman/lib"
import type {BaseThunk} from "@welshman/app" import {PublicationGroup} from "@welshman/app"
import type {Publishable} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import PublicationToast from "@app/components/PublicationToast.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte" import PublicationStatusDetail from "@app/components/PublicationStatusDetail.svelte"
import {thunks} from "@app/core" import {publisher} from "@app/core"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
thunk: BaseThunk publication: Publishable
showToastOnRetry?: boolean showToastOnRetry?: boolean
// See ThunkPending's `inline` — same trade-off, same default. // See PublicationPending's `inline` — same trade-off, same default.
inline?: boolean inline?: boolean
class?: string class?: string
} }
const {thunk, showToastOnRetry, inline = false, ...restProps}: Props = $props() const {publication, showToastOnRetry, inline = false, ...restProps}: Props = $props()
const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0) const showFailure = $derived($publication.isComplete() && $publication.getFailedUrls().length > 0)
const retry = (url: string) => { const retry = (url: string) => {
const retried = $thunks.retry(thunk, [url]) const retried =
publication instanceof PublicationGroup
? $publisher.retry(publication, [url])
: $publisher.retry(publication, [url])
if (showToastOnRetry) { if (showToastOnRetry) {
pushToast({ pushToast({
timeout: 30_000, timeout: 30_000,
children: { children: {
component: ThunkToast, component: PublicationToast,
props: {thunk: retried}, props: {publication: retried},
}, },
}) })
} }
@ -44,8 +48,8 @@
onclick={stopPropagation(noop)}> onclick={stopPropagation(noop)}>
<Tippy <Tippy
class="flex items-center" class="flex items-center"
component={ThunkStatusDetail} component={PublicationStatusDetail}
props={{thunk, retry}} props={{publication, retry}}
params={{interactive: true, maxWidth: "none", trigger: "click"}}> params={{interactive: true, maxWidth: "none", trigger: "click"}}>
<span class="flex cursor-pointer items-center gap-1 opacity-75"> <span class="flex cursor-pointer items-center gap-1 opacity-75">
<Icon icon={Danger} class="text-error" size={3} /> <Icon icon={Danger} class="text-error" size={3} />

View file

@ -1,21 +1,20 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {stopPropagation} from "svelte/legacy" import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net" import type {Publishable} from "@welshman/app"
import type {BaseThunk} from "@welshman/app"
type Props = { type Props = {
thunk: BaseThunk publication: Publishable
// A standalone usage fills its own row, and forcing that inside a flex row costs that row a line. // A standalone usage fills its own row, and forcing that inside a flex row costs that row a line.
inline?: boolean inline?: boolean
class?: string class?: string
} }
const {thunk, inline = false, ...restProps}: Props = $props() const {publication, inline = false, ...restProps}: Props = $props()
const abort = () => thunk.abort() const abort = () => publication.abort()
const isSending = $derived($thunk.hasStatus(PublishStatus.Sending)) const canAbort = $derived($publication.canAbort())
</script> </script>
<div class={cx("flex px-1 text-xs", {"w-full justify-end": !inline}, restProps.class)}> <div class={cx("flex px-1 text-xs", {"w-full justify-end": !inline}, restProps.class)}>
@ -25,8 +24,8 @@
<button <button
type="button" type="button"
class={cx("underline transition-all", { class={cx("underline transition-all", {
link: isSending, link: canAbort,
"pointer-events-none opacity-25": !isSending, "pointer-events-none opacity-25": !canAbort,
})} })}
onclick={stopPropagation(abort)}> onclick={stopPropagation(abort)}>
Cancel Cancel

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type {Publishable} from "@welshman/app"
import PublicationFailure from "@app/components/PublicationFailure.svelte"
import PublicationPending from "@app/components/PublicationPending.svelte"
type Props = {
publication: Publishable
showToastOnRetry?: boolean
inline?: boolean
class?: string
}
const {publication, showToastOnRetry, inline = false, ...restProps}: Props = $props()
const showFailure = $derived($publication.isComplete() && $publication.getFailedUrls().length > 0)
const showPending = $derived(!$publication.isComplete())
</script>
{#if showFailure}
<PublicationFailure class={restProps.class} {publication} {showToastOnRetry} {inline} />
{:else if showPending}
<PublicationPending class={restProps.class} {publication} {inline} />
{/if}

View file

@ -2,21 +2,21 @@
import {stopPropagation} from "svelte/legacy" import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {BaseThunk} from "@welshman/app" import type {Publishable} from "@welshman/app"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl" import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
type Props = { type Props = {
thunk: BaseThunk publication: Publishable
retry: (url: string) => void retry: (url: string) => void
} }
const {thunk, retry}: Props = $props() const {publication, retry}: Props = $props()
const successUrls = $derived($thunk.getUrlsWithStatus(PublishStatus.Success)) const successUrls = $derived($publication.getUrlsWithStatus(PublishStatus.Success))
const failedUrls = $derived($thunk.getFailedUrls()) const failedUrls = $derived($publication.getFailedUrls())
const total = $derived(successUrls.length + failedUrls.length) const total = $derived(successUrls.length + failedUrls.length)
const isPartial = $derived(successUrls.length > 0 && failedUrls.length > 0) const isPartial = $derived(successUrls.length > 0 && failedUrls.length > 0)
@ -53,7 +53,7 @@
</div> </div>
{/each} {/each}
{#each failedUrls as url (url)} {#each failedUrls as url (url)}
{@const {detail, status} = $thunk.results[url] || {}} {@const {detail, status} = $publication.results[url] || {}}
<div class="grid grid-cols-[1rem_1fr_auto] items-start gap-x-3 gap-y-1 text-sm"> <div class="grid grid-cols-[1rem_1fr_auto] items-start gap-x-3 gap-y-1 text-sm">
<Icon icon={Danger} class="mt-0.5 text-error" size={4} /> <Icon icon={Danger} class="mt-0.5 text-error" size={4} />
<div class="min-w-0"> <div class="min-w-0">

View file

@ -2,10 +2,10 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import ThunkStatus from "@app/components/ThunkStatus.svelte" import PublicationStatus from "@app/components/PublicationStatus.svelte"
import {thunks} from "@app/core" import {publisher} from "@app/core"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {noThunks, thunksByEventId} from "@app/thunks" import {noPublications, publicationsByEventId} from "@app/publications"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
@ -16,15 +16,15 @@
const {event, context, status, children}: Props = $props() const {event, context, status, children}: Props = $props()
const deleted = $derived(context.deleted(event)) const deleted = $derived(context.deleted(event))
const pending = $derived($thunksByEventId.get(event.id) ?? noThunks) const pending = $derived($publicationsByEventId.get(event.id) ?? noPublications)
const thunk = $derived($thunks.merge(pending)) const group = $derived($publisher.merge(pending))
</script> </script>
{#if $deleted} {#if $deleted}
<div class="button button-error button-xs rounded-full">Deleted</div> <div class="button button-error button-xs rounded-full">Deleted</div>
{:else} {:else}
{#if $thunk.thunks.length > 0 && !$thunk.hasStatus(PublishStatus.Success)} {#if $group.publications.length > 0 && !$group.hasStatus(PublishStatus.Success)}
<ThunkStatus {thunk} inline /> <PublicationStatus publication={group} inline />
{:else} {:else}
{@render status?.()} {@render status?.()}
{/if} {/if}

View file

@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import type {BaseThunk} from "@welshman/app" import type {Publishable} from "@welshman/app"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import ThunkPending from "@app/components/ThunkPending.svelte" import PublicationPending from "@app/components/PublicationPending.svelte"
import type {Toast} from "@app/toast" import type {Toast} from "@app/toast"
import {popToast} from "@app/toast" import {popToast} from "@app/toast"
type Props = { type Props = {
toast: Toast toast: Toast
thunk: BaseThunk publication: Publishable
} }
const {toast, ...props}: Props = $props() const {toast, ...props}: Props = $props()
const id = toast.id const id = toast.id
const thunk = props.thunk const publication = props.publication
const {Aborted, Timeout, Failure} = PublishStatus const {Aborted, Timeout, Failure} = PublishStatus
const isFailure = $derived($thunk.hasStatus([Aborted, Timeout, Failure])) const isFailure = $derived($publication.hasStatus([Aborted, Timeout, Failure]))
const isComplete = $derived($thunk.isComplete()) const isComplete = $derived($publication.isComplete())
$effect(() => { $effect(() => {
if (isFailure) { if (isFailure) {
@ -32,7 +32,7 @@
</script> </script>
{#if !isComplete} {#if !isComplete}
<ThunkPending {thunk} /> <PublicationPending {publication} />
{:else if !isFailure} {:else if !isFailure}
<p class="text-xs opacity-75">Message sent!</p> <p class="text-xs opacity-75">Message sent!</p>
{/if} {/if}

View file

@ -4,7 +4,7 @@
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {tryCatch, uniq} from "@welshman/lib" import {tryCatch, uniq} from "@welshman/lib"
import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util" import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util"
import type {Thunk} from "@welshman/app" import type {Publication} from "@welshman/app"
import {Relays} from "@welshman/app" import {Relays} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {errorMessage} from "@lib/util" import {errorMessage} from "@lib/util"
@ -22,7 +22,7 @@
type Props = { type Props = {
relays: Readable<string[]> relays: Readable<string[]>
addRelay: (url: string) => Promise<Thunk> addRelay: (url: string) => Promise<Publication>
matchRelay?: (url: string) => boolean matchRelay?: (url: string) => boolean
} }
@ -36,8 +36,8 @@
loading.add(url) loading.add(url)
try { try {
const thunk = await addRelay(url) const publication = await addRelay(url)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
pushToast({ pushToast({

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {SvelteSet} from "svelte/reactivity" import {SvelteSet} from "svelte/reactivity"
import type {Thunk} from "@welshman/app" import type {Publication} from "@welshman/app"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
@ -23,8 +23,8 @@
title: string title: string
subtitle: string subtitle: string
relays: Readable<string[]> relays: Readable<string[]>
addRelay: (url: string) => Promise<Thunk> addRelay: (url: string) => Promise<Publication>
removeRelay: (url: string) => Promise<Thunk> removeRelay: (url: string) => Promise<Publication>
matchRelay?: (url: string) => boolean matchRelay?: (url: string) => boolean
} }
@ -38,8 +38,8 @@
loading.add(url) loading.add(url)
try { try {
const thunk = await removeRelay(url) const publication = await removeRelay(url)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
pushToast({ pushToast({

View file

@ -44,7 +44,7 @@
.setContent(content) .setContent(content)
const reportCommand = await command(eventWriter) const reportCommand = await command(eventWriter)
const error = await reportCommand.publishToRelays([url]).waitForError() const error = (await reportCommand.publishToRelays([url]).settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})

View file

@ -16,9 +16,9 @@
import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte" import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte" import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
import RoomTranscript from "@app/components/RoomTranscript.svelte" import RoomTranscript from "@app/components/RoomTranscript.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import PublicationToast from "@app/components/PublicationToast.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte" import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user, writer} from "@app/core" import {deletes, publisher, relays, rooms, user, writer} from "@app/core"
import { import {
CallState, CallState,
callState, callState,
@ -131,7 +131,7 @@
const delay = await loadSendDelay() const delay = await loadSendDelay()
const thunk = $thunks.publish({ const publication = $publisher.publish({
relays: [url], relays: [url],
event: stamp(await eventWriter.renderTemplate(), edit?.created_at), event: stamp(await eventWriter.renderTemplate(), edit?.created_at),
delay, delay,
@ -141,8 +141,8 @@
pushToast({ pushToast({
timeout: 30_000, timeout: 30_000,
children: { children: {
component: ThunkToast, component: PublicationToast,
props: {thunk}, props: {publication},
}, },
}) })
} }

View file

@ -56,11 +56,11 @@
"This room will no longer be accessible to space members, and all messages posted to it will be deleted.", "This room will no longer be accessible to space members, and all messages posted to it will be deleted.",
confirm: async () => { confirm: async () => {
const command = await $rooms.deleteRoom(url, {h}) const command = await $rooms.deleteRoom(url, {h})
const thunk = command.publish() const publication = command.publish()
const message = await thunk.waitForError() const message = (await publication.settled()).getError()
if (message) { if (message) {
$app.repository.removeEvent(thunk.options.event.id) $app.repository.removeEvent(publication.event.id)
pushToast({theme: "error", message}) pushToast({theme: "error", message})
} else { } else {
await $roomLists.removeRoom(h, url).then(publish) await $roomLists.removeRoom(h, url).then(publish)

View file

@ -48,14 +48,14 @@
room.picture = await resolveImageInput(imageFile, imagePreview, compressOptions) room.picture = await resolveImageInput(imageFile, imagePreview, compressOptions)
const createCommand = await $rooms.createRoom(url, room) const createCommand = await $rooms.createRoom(url, room)
const createMessage = await createCommand.publish().waitForError() const createMessage = (await createCommand.publish().settled()).getError()
if (createMessage && !createMessage.includes("already")) { if (createMessage && !createMessage.includes("already")) {
return pushToast({theme: "error", message: createMessage}) return pushToast({theme: "error", message: createMessage})
} }
const editCommand = await $rooms.editRoom(url, room) const editCommand = await $rooms.editRoom(url, room)
const editMessage = await editCommand.publish().waitForError() const editMessage = (await editCommand.publish().settled()).getError()
if (editMessage) { if (editMessage) {
return pushToast({theme: "error", message: editMessage}) return pushToast({theme: "error", message: editMessage})

View file

@ -15,7 +15,7 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte" import MenuButton from "@lib/components/MenuButton.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte" import PublicationFailure from "@app/components/PublicationFailure.svelte"
import {publishReaction, retractReaction} from "@app/reactions" import {publishReaction, retractReaction} from "@app/reactions"
import {deriveDisplaysByPubkey} from "@app/social" import {deriveDisplaysByPubkey} from "@app/social"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
@ -26,8 +26,8 @@
import RoomItemMenu from "@app/components/RoomItemMenu.svelte" import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte" import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte" import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {events, profiles, reader, thunks, user} from "@app/core" import {events, profiles, publisher, reader, user} from "@app/core"
import {noThunks, thunksByEventId} from "@app/thunks" import {noPublications, publicationsByEventId} from "@app/publications"
import {colorFor} from "@app/theme" import {colorFor} from "@app/theme"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
@ -56,7 +56,9 @@
const today = formatTimestampAsDate(now()) const today = formatTimestampAsDate(now())
const profileDisplay = $profiles.display(event.pubkey, [url]).$ const profileDisplay = $profiles.display(event.pubkey, [url]).$
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 colorValue = colorFor(event.pubkey)
// A message that is nothing but a reference renders as the event it quotes. // A message that is nothing but a reference renders as the event it quotes.
@ -123,8 +125,8 @@
{/if} {/if}
<div class={cx({"mt-2": showPubkey && event.kind !== MESSAGE})}> <div class={cx({"mt-2": showPubkey && event.kind !== MESSAGE})}>
<RoomItemContent {url} event={$innerEvent ?? event} /> <RoomItemContent {url} event={$innerEvent ?? event} />
{#if thunk} {#if publication}
<ThunkFailure showToastOnRetry {thunk} class="mt-1 flex justify-end" /> <PublicationFailure showToastOnRetry {publication} class="mt-1 flex justify-end" />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -22,7 +22,7 @@
message: "Are you sure you want to remove this user from the room?", message: "Are you sure you want to remove this user from the room?",
confirm: async () => { confirm: async () => {
const command = await $rooms.removeMember(url, {h}, pubkey) const command = await $rooms.removeMember(url, {h}, pubkey)
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -4,7 +4,7 @@
import type {ClientOptions} from "@pomade/core" import type {ClientOptions} from "@pomade/core"
import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util" import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {Profile} from "@welshman/domain" import {Profile} from "@welshman/domain"
import {Thunks, nip01, publish, toSession} from "@welshman/app" import {Publisher, nip01, publish, toSession} from "@welshman/app"
import Key from "@assets/icons/key-minimalistic.svg?dataurl" import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import {getKey, setKey} from "@lib/implicit" import {getKey, setKey} from "@lib/implicit"
@ -45,16 +45,16 @@
await Promise.all(DEFAULT_SPACES.map(url => attemptRelayAccess(url))) await Promise.all(DEFAULT_SPACES.map(url => attemptRelayAccess(url)))
// Add default outbox/inbox/messaging relays, profile, spaces // Add default outbox/inbox/messaging relays, profile, spaces
const thunks = await Promise.all([ const publications = await Promise.all([
$app.use(Thunks).publish({ $app.use(Publisher).publish({
event: makeEvent(RELAYS, {tags: DEFAULT_RELAYS.map(url => ["r", url])}), event: makeEvent(RELAYS, {tags: DEFAULT_RELAYS.map(url => ["r", url])}),
relays: [...INDEXER_RELAYS, ...DEFAULT_RELAYS, ...DEFAULT_SPACES], relays: [...INDEXER_RELAYS, ...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}), }),
$app.use(Thunks).publish({ $app.use(Publisher).publish({
event: makeEvent(MESSAGING_RELAYS, {tags: DEFAULT_MESSAGING_RELAYS.map(url => ["r", url])}), event: makeEvent(MESSAGING_RELAYS, {tags: DEFAULT_MESSAGING_RELAYS.map(url => ["r", url])}),
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES], relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}), }),
$app.use(Thunks).publish({ $app.use(Publisher).publish({
event: await $domain event: await $domain
.writer(Profile) .writer(Profile)
.update(getKey<ProfileValues>("signup.profile")!) .update(getKey<ProfileValues>("signup.profile")!)
@ -64,8 +64,8 @@
$roomLists.setRelays(DEFAULT_SPACES).then(publish), $roomLists.setRelays(DEFAULT_SPACES).then(publish),
]) ])
// Wait for all the thunks to complete // Wait for all the publications to settle
await Promise.all(thunks.map(thunk => thunk.waitForCompletion())) await Promise.all(publications.map(publication => publication.settled()))
// Go to the dashboard // Go to the dashboard
clearModals() clearModals()

View file

@ -1,23 +0,0 @@
<script lang="ts">
import {BaseThunk} from "@welshman/app"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ThunkPending from "@app/components/ThunkPending.svelte"
type Props = {
thunk: BaseThunk
showToastOnRetry?: boolean
inline?: boolean
class?: string
}
const {thunk, showToastOnRetry, inline = false, ...restProps}: Props = $props()
const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0)
const showPending = $derived(!$thunk.isComplete())
</script>
{#if showFailure}
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} {inline} />
{:else if showPending}
<ThunkPending class={restProps.class} {thunk} {inline} />
{/if}

View file

@ -23,7 +23,7 @@
try { try {
const command = await $profiles.update(writer => writer.update({lud06: undefined, lud16})) const command = await $profiles.update(writer => writer.update({lud06: undefined, lud16}))
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: `Failed to update profile: ${errorMessage(error)}`}) pushToast({theme: "error", message: `Failed to update profile: ${errorMessage(error)}`})

View file

@ -37,7 +37,7 @@
writer.update({lud06: undefined, lud16: address.trim() || undefined}), writer.update({lud06: undefined, lud16: address.trim() || undefined}),
) )
const error = await command.publish().waitForError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: `Failed to update profile: ${errorMessage(error)}`}) pushToast({theme: "error", message: `Failed to update profile: ${errorMessage(error)}`})

View file

@ -19,6 +19,7 @@ import {
Network, Network,
PinLists, PinLists,
Profiles, Profiles,
Publisher,
Reactions, Reactions,
RelayLists, RelayLists,
RelayManagement, RelayManagement,
@ -31,7 +32,6 @@ import {
Rooms, Rooms,
Router, Router,
SearchRelayLists, SearchRelayLists,
Thunks,
User, User,
Wot, Wot,
Wraps, Wraps,
@ -140,6 +140,7 @@ export const muteLists = usePlugin(MuteLists)
export const network = usePlugin(Network) export const network = usePlugin(Network)
export const pinLists = usePlugin(PinLists) export const pinLists = usePlugin(PinLists)
export const profiles = usePlugin(Profiles) export const profiles = usePlugin(Profiles)
export const publisher = usePlugin(Publisher)
export const reactions = usePlugin(Reactions) export const reactions = usePlugin(Reactions)
export const relayLists = usePlugin(RelayLists) export const relayLists = usePlugin(RelayLists)
export const relayManagement = usePlugin(RelayManagement) export const relayManagement = usePlugin(RelayManagement)
@ -152,7 +153,6 @@ export const roomPinLists = usePlugin(RoomPinLists)
export const rooms = usePlugin(Rooms) export const rooms = usePlugin(Rooms)
export const router = usePlugin(Router) export const router = usePlugin(Router)
export const searchRelayLists = usePlugin(SearchRelayLists) export const searchRelayLists = usePlugin(SearchRelayLists)
export const thunks = usePlugin(Thunks)
export const wot = usePlugin(Wot) export const wot = usePlugin(Wot)
export const wraps = usePlugin(Wraps) export const wraps = usePlugin(Wraps)

View file

@ -18,7 +18,13 @@ import {
matchReason, matchReason,
RelayReasonPrefix, RelayReasonPrefix,
} from "@welshman/net" } from "@welshman/net"
import {BlockedRelayLists, MessagingRelayLists, RelayLists, RoomLists, Thunks} from "@welshman/app" import {
BlockedRelayLists,
MessagingRelayLists,
RelayLists,
Publisher,
RoomLists,
} from "@welshman/app"
import type {AppPolicy, IApp} from "@welshman/app" import type {AppPolicy, IApp} from "@welshman/app"
import {merged} from "@welshman/store" import {merged} from "@welshman/store"
import {logger, appPolicies} from "@app/core" import {logger, appPolicies} from "@app/core"
@ -75,7 +81,7 @@ const shouldAuth = (socket: Socket, $app: IApp) => {
if ($app.use(RelayLists).urls($pubkey).get().includes(socket.url)) { if ($app.use(RelayLists).urls($pubkey).get().includes(socket.url)) {
return true return true
} }
if (get($app.use(Thunks).history).some(t => t.options.relays.includes(socket.url))) { if (get($app.use(Publisher).history).some(p => p.relays.includes(socket.url))) {
return true return true
} }
if ($app.use(MessagingRelayLists).urls($pubkey).get().includes(socket.url)) { if ($app.use(MessagingRelayLists).urls($pubkey).get().includes(socket.url)) {
@ -92,7 +98,7 @@ const makeAuthInputs = ($app: IApp, pubkey: string) =>
$app.use(MessagingRelayLists).urls(pubkey).$, $app.use(MessagingRelayLists).urls(pubkey).$,
$app.use(RelayLists).urls(pubkey).$, $app.use(RelayLists).urls(pubkey).$,
$app.use(RoomLists).urls(pubkey).$, $app.use(RoomLists).urls(pubkey).$,
$app.use(Thunks).history, $app.use(Publisher).history,
userSettingsValues, userSettingsValues,
]) ])

36
src/app/publications.ts Normal file
View file

@ -0,0 +1,36 @@
import {derived} from "svelte/store"
import {pushToMapKey} from "@welshman/lib"
import {Publisher} from "@welshman/app"
import type {Publication} from "@welshman/app"
import {fromApp} from "@app/core"
let previous = new Map<string, Publication[]>()
// Indexed once, since each row filtering the whole history is O(rows x history) per publish.
export const publicationsByEventId = derived(
fromApp($app => $app.use(Publisher).history),
$history => {
const byId = new Map<string, Publication[]>()
for (const publication of $history) {
pushToMapKey(byId, publication.event.id, publication)
}
// Hand back last time's array where an event's publications are unchanged, so the merge below is stable.
for (const [id, publications] of byId) {
const before = previous.get(id)
if (before?.length === publications.length && before.every((p, i) => p === publications[i])) {
byId.set(id, before)
}
}
previous = byId
return byId
},
new Map<string, Publication[]>(),
)
// Shared, so a row with nothing in flight keeps the same value across every publish.
export const noPublications: Publication[] = []

View file

@ -4,7 +4,7 @@ import {assoc, hash, maybe, ms, poll} from "@welshman/lib"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {Address, DELETE, makeEvent} from "@welshman/util" import {Address, DELETE, makeEvent} from "@welshman/util"
import {Relays, User} from "@welshman/app" import {Relays, User} from "@welshman/app"
import {app, messagingRelayLists, roomLists, thunks} from "@app/core" import {app, messagingRelayLists, publisher, roomLists} from "@app/core"
import {device} from "@app/device" import {device} from "@app/device"
import {PUSH_BRIDGE} from "@app/env" import {PUSH_BRIDGE} from "@app/env"
import {pushState} from "@app/push/adapters/common" import {pushState} from "@app/push/adapters/common"
@ -123,7 +123,7 @@ export class CapacitorNotifications implements IPushAdapter {
const identifier = this._getSubscriptionIdentifier(relay, key) const identifier = this._getSubscriptionIdentifier(relay, key)
const thunk = thunks.get().publish({ const publication = publisher.get().publish({
relays: [url], relays: [url],
event: makeEvent(PUSH_SUBSCRIPTION, { event: makeEvent(PUSH_SUBSCRIPTION, {
tags: [ tags: [
@ -137,7 +137,7 @@ export class CapacitorNotifications implements IPushAdapter {
}), }),
}) })
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error) console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
@ -160,7 +160,8 @@ export class CapacitorNotifications implements IPushAdapter {
identifier, identifier,
).toString() ).toString()
const event = makeEvent(DELETE, {tags: [["a", address]]}) const event = makeEvent(DELETE, {tags: [["a", address]]})
const error = await thunks.get().publish({relays, event}).waitForError() const publication = publisher.get().publish({relays, event})
const error = (await publication.settled()).getError()
if (error) { if (error) {
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error) console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)

View file

@ -59,7 +59,7 @@ export const toggleRoomPin = async (url: string, h: string, id: string) => {
const command = await roomPinLists const command = await roomPinLists
.get() .get()
.setPins(url, h, wasPinned ? pins.filter(pin => pin !== id) : [...pins, id]) .setPins(url, h, wasPinned ? pins.filter(pin => pin !== id) : [...pins, id])
const error = await command.publishToRelays([url]).waitForError() const error = (await command.publishToRelays([url]).settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -15,7 +15,7 @@ import {
roomLists, roomLists,
rooms, rooms,
router, router,
thunks, publisher,
relays, relays,
user, user,
writer, writer,
@ -59,7 +59,8 @@ export const addRoomMembers = async (url: string, room: RoomMeta, pubkeys: strin
rooms rooms
.get() .get()
.addMember(url, room, pk) .addMember(url, room, pk)
.then(command => command.publish().waitForError()), .then(command => command.publish().settled())
.then(publication => publication.getError()),
), ),
) )
@ -117,7 +118,7 @@ export const publishRoomQuote = async ({
}) => { }) => {
const eventWriter = await addressWriterToSpace(writer(Message).setParent(parent), url, h) const eventWriter = await addressWriterToSpace(writer(Message).setParent(parent), url, h)
return thunks.get().publish({ return publisher.get().publish({
relays: [url], relays: [url],
event: await eventWriter.renderTemplate(), event: await eventWriter.renderTemplate(),
delay, delay,
@ -145,7 +146,7 @@ export const publishComment = async ({
tagValue(tagSpec("h"), event.tags), tagValue(tagSpec("h"), event.tags),
) )
return thunks.get().publish({ return publisher.get().publish({
relays: [url], relays: [url],
event: await eventWriter.renderTemplate(), event: await eventWriter.renderTemplate(),
delay: await loadSendDelay(), delay: await loadSendDelay(),
@ -156,14 +157,14 @@ export const publishToSpace = async (
eventWriter: EventWriter<BaseEventReader>, eventWriter: EventWriter<BaseEventReader>,
{url, h, shareToChat}: {url: string; h?: string; shareToChat?: boolean}, {url, h, shareToChat}: {url: string; h?: string; shareToChat?: boolean},
) => { ) => {
const thunk = await command(await addressWriterToSpace(eventWriter, url, h)).then(publish) const publication = await command(await addressWriterToSpace(eventWriter, url, h)).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (shareToChat && !error) { if (shareToChat && !error) {
publishRoomQuote({url, h, parent: thunk.options.event}) publishRoomQuote({url, h, parent: publication.event})
} }
return {thunk, error} return {publication, error}
} }
// User // User

View file

@ -1,36 +0,0 @@
import {derived} from "svelte/store"
import {pushToMapKey} from "@welshman/lib"
import {Thunks} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {fromApp} from "@app/core"
let previous = new Map<string, Thunk[]>()
// Indexed once, since each row filtering the whole history is O(rows x history) per publish.
export const thunksByEventId = derived(
fromApp($app => $app.use(Thunks).history),
$history => {
const byId = new Map<string, Thunk[]>()
for (const thunk of $history) {
pushToMapKey(byId, thunk.options.event.id, thunk)
}
// Hand back last time's array where an event's thunks are unchanged, so the merge below is stable.
for (const [id, thunks] of byId) {
const before = previous.get(id)
if (before?.length === thunks.length && before.every((thunk, i) => thunk === thunks[i])) {
byId.set(id, before)
}
}
previous = byId
return byId
},
new Map<string, Thunk[]>(),
)
// Shared, so a row with nothing in flight keeps the same value across every publish.
export const noThunks: Thunk[] = []

View file

@ -33,8 +33,8 @@
sendingLogs = true sendingLogs = true
try { try {
const thunk = await sendLogs() const publication = await sendLogs()
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})