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
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event
- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
- `publications.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
- `env.ts`: every `VITE_` value, parsed
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
device id)
@ -350,7 +350,7 @@ renders `ArticleForm`.
- `flotilla-views`: routes and layouts, components, modals, loading data from components
- `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
- `welshman`: overview of the packages
- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks
- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and the `Publisher`
- `welshman-domain`: readers, writers, and adding a kind
- `welshman-net`: the pool, sockets and socket policies
- `welshman-util`: kind constants, tag specs, `RelaySelection`

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
relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every
space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory,
so `RoomChat` and `publishRoomQuote` publish raw templates through `Thunks`.
so `RoomChat` and `publishRoomQuote` publish raw templates through `Publisher`.
Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for
a room) and read results with `$events.forUrl(url, filters)`, which uses the tracker to keep

View file

@ -1,6 +1,6 @@
---
name: flotilla-state
description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)."
description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → publication, optimistic updates, undo, showing publish status)."
---
# Flotilla state
@ -8,7 +8,7 @@ description: "Use this skill when deciding where a piece of state belongs in Flo
State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in
the current app's repository. Plugin indexes and derived stores read the repository, and
components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the
command becomes a thunk, and the thunk writes its event into the repository before any relay has
command becomes a publication, and the `Publisher` writes its event into the repository before any relay has
seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
@ -36,7 +36,7 @@ The app is therefore stable for as long as anything under the login gate is moun
| `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe |
| `profiles`, `rooms`, `relays`, `events`, `thunks`, … | `usePlugin` for 28 welshman plugins | safe |
| `profiles`, `rooms`, `relays`, `events`, `publisher`, … | `usePlugin` for 28 welshman plugins | safe |
| `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe |
| `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` |
| `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults |
@ -177,8 +177,8 @@ app, and `app.get()` recurses into building another one.
## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing
optimistically. The tracker records which relays each event was seen on, which is what lets
`repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher`
publishing optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay.
Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`.
@ -233,8 +233,8 @@ A derivation that every row subscribes to, or that joins large sets, is built by
- `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather
than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
profiles index.
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array
wherever an event's thunks are unchanged so rows don't churn.
- `publicationsByEventId` (`publications.ts`) indexes publish history once, and hands back the
previous array wherever an event's publications are unchanged so rows don't churn.
- `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`.
- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
@ -357,7 +357,7 @@ components; see flotilla-views.
## Mutations
The prevailing path runs from a domain writer to a command to a thunk, adapted from
The prevailing path runs from a domain writer to a command to a publication, adapted from
`ThreadCreate.svelte`:
```typescript
@ -371,8 +371,8 @@ if (room) {
eventWriter.setRoom(url, room)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
const publication = await command(eventWriter).then(publish)
const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
@ -389,29 +389,30 @@ Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(
`forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`.
Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that
Some call sites call `publisher.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
`wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return
`{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status
- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its
- **Optimistic writes.** `Publisher` writes the event into the repository and tracks it against its
relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
unsigned event for the signed one.
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts,
and a comment carries the same Cancel in the `ThunkPending` row under it.
unsigned event for the signed one, and `publication.event` follows it.
- **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes
the event from the repository and from `history`. When `send_delay` is set, room chat shows a
`PublicationToast` whose Cancel button aborts, and a comment carries the same Cancel in the
`PublicationPending` row under it.
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`).
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
whole `history` per row instead.
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
- **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and
pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per
relay. A retry drops the publications it replaces from `history`, so the row shows the retry's
outcome. `PublicationStatusOrDeleted` combines publish status with deletion.
- **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt
above.
## Other app-level stores
@ -466,7 +467,7 @@ Take the first answer that fits:
- `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance
- `flotilla-views`: routes, components, and how components load data and show state
- `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds
- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events`
- `welshman-app`: `App`, plugins, `Command`, `Publisher`, `Network`/`Sync`, `Events`
- `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app

View file

@ -214,7 +214,7 @@ Svelte's delegated `onclick` on the `<ul>` would never see the click. On mobile,
```typescript
pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}})
pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}})
pushToast({timeout: 30_000, children: {component: PublicationToast, props: {publication}}})
```
A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies
@ -293,7 +293,7 @@ param change and lists key by id. A prop that does change while mounted needs `$
## Reading state in a component
Components read plugins through the `usePlugin` stores exported from `src/app/core.ts`
(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$events`, `$network`, `$thunks`, `$deletes`,
(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$events`, `$network`, `$publisher`, `$deletes`,
`$relayManagement`, …). `$app.use(X)` covers plugins with no export, such as `Zappers`,
`Pinboards` and `Feeds`; flotilla-state has the rules about which to use where.
@ -398,7 +398,7 @@ ahead. All ten `*Item` components that appear in a list use it.
## Mutations from a component
flotilla-state covers the writer → command → thunk path. The component around it owns a
flotilla-state covers the writer → command → publication path. The component around it owns a
`loading` flag, awaits the first error, toasts it, and closes. Anything thrown on the way (a signer
refusing, a relay unreachable) is logged and toasted in a `catch`, so a bare `try`/`finally` never
lets it escape the handler:
@ -408,8 +408,8 @@ const submit = async () => {
loading = true
try {
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
const publication = await command(eventWriter).then(publish)
const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
@ -428,13 +428,13 @@ const submit = async () => {
Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators
return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then
`.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` then
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a publication:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
wrap their contents in `PublicationStatusOrDeleted`, and chat pushes a `PublicationToast`.
## Forms
@ -481,9 +481,9 @@ Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
## Related skills
- `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement
- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, thunks and commands
- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, publications and commands
- `flotilla-model`: spaces, rooms, NIP-43/29/86, content kinds, and where events are published
- `welshman-app`: plugins, projections, `Command`, thunks, `Events`, `Feeds`
- `welshman-app`: plugins, projections, `Command`, `Publisher`, `Events`, `Feeds`
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the repository stores under `Events`
- `welshman-domain`: the readers and writers behind `reader` and `writer`
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed`

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` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
| `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` |
| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `ThunkStatus` |
| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `PublicationStatus` |
| `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` |
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
@ -41,7 +41,7 @@ per content kind). Four are families instead:
- `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one
`NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`.
- `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`.
- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Thunk*` show
- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Publication*` show
publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,

View file

@ -1,6 +1,6 @@
---
name: welshman-app
description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search."
description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing through a staged pipeline, outbox-model requests, routing, web of trust, feeds, and search."
---
# welshman/app — Instance-Based Nostr App
@ -26,7 +26,7 @@ Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade
2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app.
3. **`Projection<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.
5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
5. **Writes are optimistic.** Publishing goes through the `Publisher` pipeline: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
## Creating an app
@ -96,7 +96,7 @@ setNip55Plugin(NostrSignerPlugin)
All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`.
Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Publication` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
| Plugin | Data | Notable accessors |
|---|---|---|
@ -144,7 +144,7 @@ const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[]
// Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example")
command.publish() // normal outbox/relays flow via Thunks
command.publish() // normal outbox/relays flow via Publisher
// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await:
@ -152,42 +152,46 @@ import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
```
## Publishing (optimistic thunks)
## Publishing (optimistic pipeline)
```typescript
import {Thunks, Router} from "@welshman/app"
import {Publisher, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes):
const thunk = app.use(Thunks).publish({
// Publisher has no outbox helper. Resolve write relays with the Router's Resolver and the
// RelaySelection DSL, as Command.publish() does for every data-plugin mutation:
const publication = app.use(Publisher).publish({
event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
delay: 3000, // abortable soft-undo window (ms)
delay: 3000, // abortable soft-undo window (ms), before signing
})
// To specific relays:
app.use(Thunks).publish({event, relays: ["wss://relay.example"]})
app.use(Publisher).publish({event, relays: ["wss://relay.example"]})
// A thunk is a Svelte store with per-relay status:
thunk.subscribe(t => console.log(t.results))
thunk.abort() // effective only before `delay` elapses
await thunk.waitForCompletion()
thunk.getError() // string | undefined
app.use(Thunks).history // writable<Thunk[]> — optimistic log
app.use(Thunks).retry(thunk)
app.use(Thunks).retry(thunk, [url]) // only to these relays; a merged thunk retries only children that targeted them
// A Publication is a Svelte store. `state` is a union keyed by `stage`:
// queued → delayed → wrapping → pow → signing → sending → sent | failed | aborted
publication.subscribe(p => console.log(p.state.stage, p.results))
publication.abort() // unwinds the optimistic write; does nothing once settled
publication.canAbort() // true until any relay has been sent to
await publication.settled() // resolves on a terminal stage
publication.getError() // string | undefined
publication.event // current event (mined/signed as it goes); the rumor for a wrap
app.use(Publisher).history // writable<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:
app.use(Thunks).publish({event, relays, recipient: theirPubkey})
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
app.use(Publisher).publish({event, relays, recipient: theirPubkey})
const group = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13):
app.use(Thunks).publish({event, relays, pow: 20})
app.use(Publisher).publish({event, relays, pow: 20})
```
`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message.
`PublishRequest`: `{event, relays, recipient?, delay?, pow?, priority?, timeout?}`. The stages (`STAGES` in `plugins/publisher.ts`) only transform the event. The publisher keeps the repository, tracker and `WrapManager` in step by reacting to state changes. Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message.
## Commands (deferred publishing)
@ -202,11 +206,11 @@ command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it
command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays})
command.publish() // normal path: app.use(Publisher).publish({event, relays: command.relays})
command.publishToRelays(urls) // publish to a specific relay set instead of command.relays
```
This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `PublicationGroup` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
`publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting:
@ -441,7 +445,7 @@ const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]})
| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` |
| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` |
| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` |
| `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
| `publishThunk({...})` | `app.use(Publisher).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) |
| `load({...})` / `request({...})` | `app.use(Network).loadComplete({...})` / `request({...})` |
| `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) |

View file

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

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
must keep the text in the composer and say so, rather than clearing as though it went. It is the
shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving
empty, so a failure there aborts a publish before its thunk is made and nothing reaches the
empty, so a failure there aborts a publish before its publication is made and nothing reaches the
timeline to carry a status. Testing it needs a seam for making a relay unusable.
**Internals with no user-visible surface.** The legacy session-storage format

View file

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

Binary file not shown.

View file

@ -16,7 +16,7 @@ import {
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util"
import {app, command, roomLists, thunks, writer} from "@app/core"
import {app, command, publisher, roomLists, writer} from "@app/core"
import {PLATFORM_URL} from "@app/env"
import {relaysMostlyRestricted} from "@app/policies"
import {optInSpaceNotifications} from "@app/push"
@ -109,7 +109,7 @@ export const publishLeaveRequest = (url: string) =>
// "duplicate:" and "already a member" both leave us where we wanted to be.
const isMembershipRefusal = (error: string) =>
Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already")
!error.startsWith("duplicate:") && !error.includes("already")
// Joining takes two publishes: the NIP-29 request the relay can refuse, and the user's room list.
export const joinRoom = async (url: string, h: string, code?: string) => {
@ -119,10 +119,10 @@ export const joinRoom = async (url: string, h: string, code?: string) => {
eventWriter.setClaim(code)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
const publication = await command(eventWriter).then(publish)
const error = (await publication.settled()).getError()
if (isMembershipRefusal(error)) {
if (error && isMembershipRefusal(error)) {
return error
}
@ -130,10 +130,10 @@ export const joinRoom = async (url: string, h: string, code?: string) => {
}
export const leaveRoom = async (url: string, h: string) => {
const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
const error = await thunk.waitForError()
const publication = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
const error = (await publication.settled()).getError()
if (isMembershipRefusal(error)) {
if (error && isMembershipRefusal(error)) {
return error
}
@ -155,10 +155,8 @@ export const publishRoomInvite = async (url: string, h: string) => {
],
})
const error = await thunks
.get()
.publish({event, relays: [url]})
.waitForError()
const publication = publisher.get().publish({event, relays: [url]})
const error = (await publication.settled()).getError()
if (error) {
return {error, code: undefined}
@ -223,23 +221,21 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return formatAuthError(socket.auth.status, socket.auth.details)
}
const thunk = await publishJoinRequest(url, claim)
const error = await thunk.waitForError()
const publication = await publishJoinRequest(url, claim)
const error = (await publication.settled()).getError()
if (shouldIgnoreError(error)) {
return
if (error && !shouldIgnoreError(error)) {
if (error.includes("invite code")) {
return "join request rejected"
}
// A space that isn't open to the public refuses a join carrying no claim at all
if (error.includes("claim")) {
return "This space requires an invite code"
}
return stripPrefix(error)
}
if (error.includes("invite code")) {
return "join request rejected"
}
// A space that isn't open to the public refuses a join carrying no claim at all
if (error.includes("claim")) {
return "This space requires an invite code"
}
return stripPrefix(error)
}
export const completeSpaceJoin = async (url: string, notifications: boolean) => {

View file

@ -14,7 +14,7 @@ import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp, TimeEvent} from "@welshman/domain"
import type {RsvpStatus} from "@welshman/domain"
import {synced} from "@welshman/store"
import {deletes, events, reader, relays, thunks, writer} from "@app/core"
import {deletes, events, publisher, reader, relays, writer} from "@app/core"
import {addressWriterToSpace} from "@app/rooms"
import {kv} from "@app/storage"
@ -268,7 +268,7 @@ export const publishRsvp = async (url: string, event: TrustedEvent, status: Rsvp
reader(TimeEvent)(event).room(),
)
return thunks.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
return publisher.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
}
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@
if ($adminDelete === AdminDelete.Room) {
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)

View file

@ -17,7 +17,7 @@
const confirm = async () => {
const protect = await shouldProtect
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) {
return pushToast({theme: "error", message: error})

View file

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

View file

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

View file

@ -33,7 +33,7 @@
try {
const protect = await $relays.hasNip(url, 70)
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) {
pushToast({theme: "error", message: error})

View file

@ -62,9 +62,9 @@
.setTopics(topics)
.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 () => {

View file

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

View file

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

View file

@ -18,7 +18,7 @@
import {userSpaceUrls} from "@app/rooms"
import {pushToast} from "@app/toast"
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 confirmText = $state("")
@ -51,12 +51,12 @@
}
// 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()
// Next, send a "right to vanish" event to all relays
await $thunks.publish({relays, event: vanishEvent})
await $publisher.publish({relays, event: vanishEvent})
await incrementProgress()
@ -72,7 +72,7 @@
}
}
await $thunks.publish({relays, event: makeEvent(DELETE, {tags})})
await $publisher.publish({relays, event: makeEvent(DELETE, {tags})})
await incrementProgress()
}

View file

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

View file

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

View file

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

View file

@ -1,21 +1,20 @@
<script lang="ts">
import cx from "classnames"
import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net"
import type {BaseThunk} from "@welshman/app"
import type {Publishable} from "@welshman/app"
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.
inline?: boolean
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>
<div class={cx("flex px-1 text-xs", {"w-full justify-end": !inline}, restProps.class)}>
@ -25,8 +24,8 @@
<button
type="button"
class={cx("underline transition-all", {
link: isSending,
"pointer-events-none opacity-25": !isSending,
link: canAbort,
"pointer-events-none opacity-25": !canAbort,
})}
onclick={stopPropagation(abort)}>
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 {PublishStatus} from "@welshman/net"
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 Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
thunk: BaseThunk
publication: Publishable
retry: (url: string) => void
}
const {thunk, retry}: Props = $props()
const {publication, retry}: Props = $props()
const successUrls = $derived($thunk.getUrlsWithStatus(PublishStatus.Success))
const failedUrls = $derived($thunk.getFailedUrls())
const successUrls = $derived($publication.getUrlsWithStatus(PublishStatus.Success))
const failedUrls = $derived($publication.getFailedUrls())
const total = $derived(successUrls.length + failedUrls.length)
const isPartial = $derived(successUrls.length > 0 && failedUrls.length > 0)
@ -53,7 +53,7 @@
</div>
{/each}
{#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">
<Icon icon={Danger} class="mt-0.5 text-error" size={4} />
<div class="min-w-0">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,9 +16,9 @@
import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.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 {deletes, relays, rooms, thunks, user, writer} from "@app/core"
import {deletes, publisher, relays, rooms, user, writer} from "@app/core"
import {
CallState,
callState,
@ -131,7 +131,7 @@
const delay = await loadSendDelay()
const thunk = $thunks.publish({
const publication = $publisher.publish({
relays: [url],
event: stamp(await eventWriter.renderTemplate(), edit?.created_at),
delay,
@ -141,8 +141,8 @@
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
component: PublicationToast,
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.",
confirm: async () => {
const command = await $rooms.deleteRoom(url, {h})
const thunk = command.publish()
const message = await thunk.waitForError()
const publication = command.publish()
const message = (await publication.settled()).getError()
if (message) {
$app.repository.removeEvent(thunk.options.event.id)
$app.repository.removeEvent(publication.event.id)
pushToast({theme: "error", message})
} else {
await $roomLists.removeRoom(h, url).then(publish)

View file

@ -48,14 +48,14 @@
room.picture = await resolveImageInput(imageFile, imagePreview, compressOptions)
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")) {
return pushToast({theme: "error", message: createMessage})
}
const editCommand = await $rooms.editRoom(url, room)
const editMessage = await editCommand.publish().waitForError()
const editMessage = (await editCommand.publish().settled()).getError()
if (editMessage) {
return pushToast({theme: "error", message: editMessage})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,13 @@ import {
matchReason,
RelayReasonPrefix,
} 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 {merged} from "@welshman/store"
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)) {
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
}
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(RelayLists).urls(pubkey).$,
$app.use(RoomLists).urls(pubkey).$,
$app.use(Thunks).history,
$app.use(Publisher).history,
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 {Address, DELETE, makeEvent} from "@welshman/util"
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 {PUSH_BRIDGE} from "@app/env"
import {pushState} from "@app/push/adapters/common"
@ -123,7 +123,7 @@ export class CapacitorNotifications implements IPushAdapter {
const identifier = this._getSubscriptionIdentifier(relay, key)
const thunk = thunks.get().publish({
const publication = publisher.get().publish({
relays: [url],
event: makeEvent(PUSH_SUBSCRIPTION, {
tags: [
@ -137,7 +137,7 @@ export class CapacitorNotifications implements IPushAdapter {
}),
})
const error = await thunk.waitForError()
const error = (await publication.settled()).getError()
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
@ -160,7 +160,8 @@ export class CapacitorNotifications implements IPushAdapter {
identifier,
).toString()
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) {
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
.get()
.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) {
pushToast({theme: "error", message: error})

View file

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