import type {Subscriber} from "svelte/store" import {writable, get} from "svelte/store" import { TaskQueue, ifLet, ensurePlural, dissoc, remove, defer, sleep, assoc, nth, without, } from "@welshman/lib" import {stamp, own, hash} from "@welshman/signer" import { TrustedEvent, HashedEvent, EventTemplate, SignedEvent, StampedEvent, OwnedEvent, isStampedEvent, isOwnedEvent, isHashedEvent, isUnwrappedEvent, isSignedEvent, } from "@welshman/util" import {publish, PublishStatus, PublishOptions, PublishStatusByRelay} from "@welshman/net" import {repository, tracker} from "./core.js" import {pubkey, getSession, getSigner} from "./session.js" export type ThunkEvent = EventTemplate | StampedEvent | OwnedEvent | TrustedEvent export const prepEvent = (event: ThunkEvent) => { if (!isStampedEvent(event as StampedEvent)) { event = stamp(event) } if (!isOwnedEvent(event as OwnedEvent)) { event = own(event as StampedEvent, get(pubkey)!) } if (!isHashedEvent(event as HashedEvent)) { event = hash(event as OwnedEvent) } return event as TrustedEvent } export type ThunkOptions = Omit & { event: ThunkEvent delay?: number } export class Thunk { _subs: Subscriber[] = [] event: TrustedEvent result = defer() status: PublishStatusByRelay = {} details: Record = {} controller = new AbortController() constructor(readonly options: ThunkOptions) { this.event = prepEvent(options.event) for (const relay of options.relays) { this.status[relay] = PublishStatus.Sending } this.controller.signal.addEventListener("abort", () => { for (const relay of options.relays) { this._setAborted(relay) } }) } _notify() { for (const subscriber of this._subs) { subscriber(this) } } _fail(message: string) { for (const relay of this.options.relays) { this.status[relay] = PublishStatus.Failure this.details[relay] = message } this._notify() } _setPending(relay: string) { this.options.onPending?.(relay) this.status[relay] = PublishStatus.Pending this._notify() } _setTimeout(relay: string) { this.options.onTimeout?.(relay) this.status[relay] = PublishStatus.Timeout this.details[relay] = "Publish timed out" this._notify() } _setAborted(relay: string) { this.options.onAborted?.(relay) this.status[relay] = PublishStatus.Aborted this.details[relay] = "Publish was aborted" this._notify() } async publish() { let event = this.event // Handle abort immediately if possible if (this.controller.signal.aborted) return // If we were given a wrapped event, make sure to publish the wrapper, not the rumor if (isUnwrappedEvent(event)) { event = event.wrap } // If the event was already signed, leave it alone. Otherwise, sign it now. This is to // decrease apparent latency in the UI that results from waiting for remote signers if (!isSignedEvent(event)) { const signer = getSigner(getSession(event.pubkey)) if (!signer) { return this._fail(`No signer found for ${event.pubkey}`) } try { event = await signer.sign(event) } catch (e: any) { return this._fail(`Failed to sign event: ${String(e.error || e)}`) } } // We're guaranteed to have a signed event at this point const signedEvent = event as SignedEvent // Copy the signature over since we had deferred signing ifLet(repository.getEvent(signedEvent.id), savedEvent => { savedEvent.sig = signedEvent.sig }) // Wait if the thunk is to be delayed if (this.options.delay) { await sleep(this.options.delay) } // Skip publishing if aborted if (this.controller.signal.aborted) { return } // Send it off this.result.resolve( await publish({ ...this.options, event: signedEvent, onSuccess: (message: string, relay: string) => { tracker.track(signedEvent.id, relay) this.options.onSuccess?.(message, relay) this.status[relay] = PublishStatus.Success this.details[relay] = message this._notify() }, onFailure: (message: string, relay: string) => { this.options.onFailure?.(message, relay) this.status[relay] = PublishStatus.Failure this.details[relay] = message this._notify() }, onPending: (relay: string) => { this._setPending(relay) }, onTimeout: (relay: string) => { this._setTimeout(relay) }, onAborted: (relay: string) => { this._setAborted(relay) }, onComplete: () => { this.options.onComplete?.() this._subs = [] }, }), ) } subscribe(subscriber: Subscriber) { this._subs.push(subscriber) subscriber(this) return () => { this._subs = remove(subscriber, this._subs) } } } export class MergedThunk { _subs: Subscriber[] = [] status: PublishStatusByRelay = {} details: Record = {} constructor(readonly thunks: Thunk[]) { const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus const relays = new Set(thunks.flatMap(thunk => thunk.options.relays)) for (const thunk of thunks) { thunk.subscribe($thunk => { this.status = {} this.details = {} for (const relay of relays) { for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) { const thunk = thunks.find(t => t.status[relay] === status) if (thunk) { this.status[relay] = thunk.status[relay]! this.details[relay] = thunk.details[relay]! } } } this._notify() if (thunks.every(thunkIsComplete)) { this._subs = [] } }) } } _notify() { for (const subscriber of this._subs) { subscriber(this) } } subscribe(subscriber: Subscriber) { this._subs.push(subscriber) subscriber(this) return () => { this._subs = remove(subscriber, this._subs) } } } export type AbstractThunk = Thunk | MergedThunk export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk => thunk instanceof MergedThunk // Thunk status urls export const getThunkUrlsWithStatus = ( statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk, ) => { statuses = ensurePlural(statuses) return Object.entries(thunk.status) .filter(([_, status]) => statuses.includes(status)) .map(nth(0)) } export const getCompleteThunkUrls = (thunk: AbstractThunk) => getThunkUrlsWithStatus( without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)), thunk, ) export const getIncompleteThunkUrls = (thunk: AbstractThunk) => getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk) export const getFailedThunkUrls = (thunk: AbstractThunk) => getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk) // Thunk status checks export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) => getThunkUrlsWithStatus(statuses, thunk).length > 0 export const thunkIsComplete = (thunk: AbstractThunk) => !thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk) // Thunk errors export const getThunkError = (thunk: Thunk) => { for (const [relay, status] of Object.entries(thunk.status)) { if (status === PublishStatus.Failure) { return thunk.details[relay] } } if (thunkIsComplete(thunk)) { return "" } } // Thunk utilities that return promises export const waitForThunkError = (thunk: Thunk) => new Promise(resolve => { thunk.subscribe($thunk => { const error = getThunkError($thunk) if (error !== undefined) { resolve(error) } }) }) // Thunk state export const thunks = writable>({}) export const thunkQueue = new TaskQueue({ batchSize: 50, processItem: (thunk: Thunk) => { thunk.publish() }, }) // Other thunk utilities export const mergeThunks = (thunks: AbstractThunk[]) => new MergedThunk(Array.from(flattenThunks(thunks))) export function* flattenThunks(thunks: AbstractThunk[]): Iterable { for (const thunk of thunks) { if (isMergedThunk(thunk)) { yield* flattenThunks(thunk.thunks) } else { yield thunk } } } export const publishThunk = (options: ThunkOptions) => { const thunk = new Thunk(options) thunkQueue.push(thunk) repository.publish(thunk.event) thunks.update(assoc(thunk.event.id, thunk)) return thunk } export const abortThunk = (thunk: AbstractThunk) => { for (const child of flattenThunks([thunk])) { child.controller.abort() thunks.update(dissoc(child.event.id)) repository.removeEvent(child.event.id) } } export const retryThunk = (thunk: AbstractThunk) => isMergedThunk(thunk) ? mergeThunks(thunk.thunks.map(t => publishThunk(t.options))) : publishThunk(thunk.options)