@ghostlytools/cli 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
- import { $a as readAppRevocations, $i as parseImageMeta, $n as engineError, $o as handoffContextProof, $r as pinNumberHolds, $s as HANDOFF_CAPABILITY, $t as PaymentPreflightError, A as decodeGroupEntryLink, Aa as pairedReplyAuthor, Ac as withRequestOptions, Ai as IDENTITY_REVOCATION_LABEL, Ar as mergeBeacon, B as groupWakes, Ba as statusCardText, Bc as publicKeyFromZ32, Bi as lastSharedWithMe, Bo as HANDOFF_OFFER, Br as FILE_LIMITS, Bs as readEnrollHello, C as COMMUNITY_LIMITS, Ca as nextReactionNumber, Cc as RelayTransport, Ci as readManifest, Co as RTC_CONFIG, Cr as COMMUNITY_TOPOLOGY, Cs as readSetUpdate, D as decodeCommunityLink, Da as readReaction, Dc as traceLink, Di as dialDescriptors, Dr as freshHubs, Ds as verifySetUpdate, E as communityHasFrame, Ea as reactionIsNewer, Ei as capsDescriptors, Er as doorHubs, Es as signTombstone, F as mergeKnocks, Fi as identityRevocationValue, Fo as HANDOFF_ACK, Fr as readBeacon, Fs as enrollDoneFrame, Ga as shownTime, Gc as fromBase64Url, Gi as automaticTransport, Gn as findEndpoint, Go as HANDOFF_VERIFIED, Gr as TypingSender, Gs as OwnInviteError, Gt as didDhtFromPublicKey, Ha as claimedTime, Hi as emptyProofLedger, Hn as ENDPOINT, Ho as HANDOFF_PIECE_BYTES, Hr as transferStalled, Hs as createDeviceInvite, I as readKnocks, Ia as STATUS_CARD_LIMITS, Ii as identityStatement, Io as HANDOFF_CANCEL, Ir as readBeaconHead, Is as enrollGrantFrame, J as carryMentions, Ja as appFingerprint, Jc as toZ32, Jn as parseFedimintRequestPayload, Jo as handoffAttemptFailed, Js as encodeInviteCode, Kc as randomBytes, Kn as isFederationId, Ko as filePartName, Ks as createChatInvite, Kt as encodeDidDhtDeactivated, Lc as createIdentity, Li as newIdentityBinding, Lo as HANDOFF_DONE, Lr as readLobby, Ls as enrollHelloFrame, M as entryParams, Ma as replySnippet, Mi as IdentityExchange, Mo as HTTP_SERVICE_PROTO, Mr as newerHead, Ms as enrollCancelFrame, N as knockIdentity, Na as BUTTON_ID, Nc as createRelayPayload, No as LEGACY_SERVICES, Nr as pickHubs, Ns as enrollCancelReason, O as encodeCommunityLink, Oa as validReactionNumber, Oc as budgetRetryMs, Or as lobbyKeys, P as knockRecords, Pi as emptyIdentityLedger, Po as serviceIdFromName, Pr as rankHubs, Ps as enrollDigits, Qa as isAppRef, Qi as dhtFileId, Qn as pairedMessageFrame, Qo as handoffContext, Qr as pinIsNewer, Qs as ENROLL_CAPABILITY, Qt as ONCHAIN_PROVIDER, R as GroupWakeLimiter, Ra as checkStatusCard, Rc as identityFromSeed, Ri as receivedIdentityStatus, Ro as HANDOFF_HAVE, Rr as shouldBeHub, Rs as enrollProofFrame, S as sealGroupTyping, Sa as ReactionWindow, Sc as DEFAULT_RELAYS, Si as newMailbox, Sr as pickMeshHubs, T as GROUP_READ_NOTE_COMMUNITY, Ta as reactionEmoji, Tc as normalizeRelayUrl, Ti as DHT_TEXT_CAPABILITY, Tr as beaconRecords, Ts as setUpdateFrame, U as GROUP_READ_NOTE, Ua as heardTime, Uc as sanitizeAvatar, Ui as validExternalKey, Un as cashuRequestPayload, Uo as HANDOFF_REQUEST, Us as deviceInviteJoinerParams, V as parseGroupWakeFrame, Va as arrivalKey, Vc as verify, Vi as PeerProofs, Vn as decodeBolt11, Vo as HANDOFF_PAKE, Vr as transferEnded, Vs as verifyEnrollProof, W as GroupSession, Wa as receivedTimestamp, Wc as bytesEqual, Wi as TRANSPORTS, Wn as fedimintRequestPayload, Wo as HANDOFF_TIMINGS, Ws as readDeviceInvite, Wt as didDhtDocument, X as mentionsMember, Xc as utf8Encode, Xi as peekDhtMailbox, Xn as INVITE_TAKEN, Xo as handoffBusyFrame, Xr as mayPin, Xs as inviteOwnership, Xt as ETHEREUM_USDT, Y as parseCommunityEdit, Ya as appRef, Yc as utf8Decode, Yi as DHT_MESSAGE_TTL, Yn as GhostLink, Yo as handoffAttemptSucceeded, Yr as PIN_LIMITS, Yt as signDidDhtPacket, Z as groupName, Za as isAppKey, Zi as DHT_FILE_CAPABILITY, Zn as LIVENESS_PING_MS, Zo as handoffCancelFrame, Zr as nextPinNumber, Zs as DEVICES_CAPABILITY, Zt as EVM_TEST_CHAINS, _ as readAppBundle, _a as forwardedAgain, _c as readTurnPacket, _i as HOLD_CAPABILITY, _o as parseLocalTarget, _r as groupSignalFrame, _s as readHandoffPake, _t as isAtprotoDid, a as planAppUpdate, ac as newDeviceSetSecret, ai as parseWakeFrame, an as assertTokenUnits, as as handoffOfferFrame, b as groupTypingEpoch, ba as GROUP_REACTION_FRAME, bc as turnKeys, bi as HoldKeys, bo as decodeControl, br as mayBeHub, bs as sealHandoffPiece, ca as fileMessageText, ci as checkPushEndpoint, cn as validatePaymentTarget, co as verifyCommitSignature, cr as readGroupFileMeta, cs as handoffRetryAfter, da as parseVideoMeta, dc as classifyTurnRead, di as isAppVersion, dn as SPARK_PROVIDER, do as DHT_POLL_INTERVALS, ds as newHandoffId, ec as deviceEchoFrame, ei as readPin, en as SEPOLIA_TEST_USDT, er as engineText, es as handoffDataFrame, f as appDigest, fa as EDIT_BUFFER_MS, fc as turnPutSummary, fi as APPS_CAPABILITY, fn as sparkAddressKind, fr as CarriedTransport, fs as readHandoffAck, g as isAppUrl, ga as parseLinkPreview, gc as nextTurnPosition, gi as UPGRADE_CAPABILITY, gn as isPubkyKey, go as formatLocalTarget, gs as readHandoffHello, ha as dhtEditId, hc as firstDeviceSetSecret, hi as STATUS_CARD_CAPABILITY, ho as GhostlyHttpError, hs as readHandoffHave, i as checkAppBeforeRun, ia as sanitizeMime, ic as devicePingFrame, ii as newWakeToken, in as WALLET_NETWORKS, io as GROUP_MEMBER_CAP, is as handoffManifestFrames, j as encodeGroupEntryLink, ja as readReply, jc as PacketTooLargeError, jo as emptyLinkRecords, jr as mergeLobby, ka as wireReaction, kc as isDiscoveryBudgetError, ko as parseCallSignal, kr as lobbyRecords, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, lc as TURN_RAISE_READ_TIMEOUT_MS, li as vapidKeysMatch, ln as walletNetworkOf, lo as edgeParams, ls as handoffStreamKey, ma as RateWindow, mc as TURN_MAX, mi as EDIT_CAPABILITY, mn as isBitcoinAddress, mo as presenceSeenAt, ms as readHandoffDone, n as appStoreDecision, na as safeBlobType, nc as deviceKeyZ32, ni as WakeLimiter, nn as TEST_USDT_FAUCET_AMOUNT, no as canonicalJson, ns as handoffEphemeral, oc as seedSigner, oi as relayRequest, on as assertWholeSats, oo as MEMBER_KEY, or as groupFileFallback, os as handoffPakeFrame, p as appViewOf, pa as EDIT_RATE_WINDOW_MS, pc as TURN_LAST_SEQUENCE, pi as BUTTONS_CAPABILITY, pn as sparkInvoiceDetails, po as RELAY_POLL_INTERVALS, ps as readHandoffCancel, q as GROUP_EDIT_TEXT_BYTES, qc as toBase64Url, qn as parseCashuRequestPayload, qo as handoffAttemptAllowed, qr as typingActivity, qs as decodeInviteCode, qt as encodeDidDhtPacket, r as appUpdateDecision, ra as sanitizeFileName, rc as deviceLinkPairing, ri as deviceWakeRequest, rn as USDT_PUBLIC_RPC, rs as handoffHelloFrame, s as readAppStore, sc as webCryptoSigner, si as wakeRequest, sn as formatPaymentAmount, so as rosterHas, ss as handoffReleaseFrame, t as appRevocationFor, ta as formatFileSize, tc as deviceEchoNonce, ti as WAKE_CALL_INTERVAL_MS, tr as GROUP_FILE_LIMITS, ts as handoffDigest, u as APP_BUNDLE_MAGIC, uc as TURN_SETTLE_MS, ui as chatAppId, un as SPARK_NETWORKS, uo as DirectPathWatch, us as handoffVersions, va as readForwarded, vc as signTurnPacket, vi as HOLD_DATED_BACK_MS, vo as webLocalFetch, vr as readGroupSignal, vs as readHandoffTurnFrame, vt as normalizeAtprotoHandle, w as CommunitySession, wa as queueReaction, wc as currentRelays, wi as CapsExchange, wr as beaconKeys, ws as setAckFrame, x as openGroupTyping, xa as REACTION_LIMITS, xc as turnName, xi as HoldRefusedError, xo as encodeControl, xr as meshHubs, y as GroupTypingBoard, ya as COMMUNITY_REACTION_FRAME, yc as signTurnRelease, yi as HOLD_LIMITS, yo as LIMITS, yr as MESH_HUBS, ys as readHandoffVerified, z as groupWakeFrame, za as sameButtonText, zc as identityFromSeedB64, zi as revokesIdentity, zo as HANDOFF_HELLO, zr as ChatFiles, zs as readEnrollGrant } from "./src-BMUcYQ7b.js";
2
+ import { $ as groupName, $c as sanitizeAvatar, $i as peekDhtMailbox, $o as HANDOFF_REQUEST, $r as isAppVersion, $s as deviceInviteJoinerParams, $t as EVM_TEST_CHAINS, A as encodeCommunityLink, Aa as readReaction, Ac as turnKeys, Ai as dialDescriptors, An as ENDPOINT, Ao as decodeControl, Ar as transferStalled, As as sealHandoffPiece, B as GroupWakeLimiter, Bc as withRequestOptions, Bi as newIdentityBinding, Bn as pairedMessageFrame, Br as pinIsNewer, C as openGroupTyping, Ca as GROUP_REACTION_FRAME, Cc as TURN_LAST_SEQUENCE, Co as RELAY_POLL_INTERVALS, Cr as readBeacon, Cs as readHandoffCancel, D as GROUP_READ_NOTE_COMMUNITY, Da as queueReaction, Dc as readTurnPacket, Di as CapsExchange, Do as parseLocalTarget, Dr as ChatFiles, Ds as readHandoffPake, E as CommunitySession, Ea as nextReactionNumber, Ec as nextTurnPosition, Eo as formatLocalTarget, Er as shouldBeHub, Es as readHandoffHello, F as knockIdentity, Fa as replySnippet, Fc as normalizeRelayUrl, Fi as IdentityExchange, Fn as parseCashuRequestPayload, Fr as typingActivity, Fs as setUpdateFrame, G as GROUP_READ_NOTE, Ga as checkStatusCard, Gi as emptyProofLedger, Go as HANDOFF_ACK, Gr as deviceWakeRequest, Gs as enrollDoneFrame, H as groupWakes, Hi as revokesIdentity, Hn as engineText, Ho as HTTP_SERVICE_PROTO, Hr as readPin, Hs as enrollCancelFrame, I as knockRecords, Ia as BUTTON_ID, In as parseFedimintRequestPayload, Is as signTombstone, Ja as arrivalKey, Jc as identityFromSeed, Ji as automaticTransport, Jn as groupFileFallback, Jo as HANDOFF_HAVE, Jr as relayRequest, Js as enrollProofFrame, Jt as encodeDidDhtDeactivated, K as GroupSession, Ka as sameButtonText, Ki as validExternalKey, Ko as HANDOFF_CANCEL, Kr as newWakeToken, Ks as enrollGrantFrame, Kt as didDhtDocument, L as mergeKnocks, Lc as traceLink, Li as emptyIdentityLedger, Ln as GhostLink, Lr as PIN_LIMITS, Ls as verifySetUpdate, M as decodeGroupEntryLink, Ma as wireReaction, Mc as DEFAULT_RELAYS, Mn as fedimintRequestPayload, N as encodeGroupEntryLink, Na as pairedReplyAuthor, Nc as RelayTransport, Ni as IDENTITY_REVOCATION_LABEL, Nn as findEndpoint, No as RTC_CONFIG, Nr as TypingSender, Ns as readSetUpdate, O as communityHasFrame, Oa as reactionEmoji, Oc as signTurnPacket, Oi as DHT_TEXT_CAPABILITY, Oo as webLocalFetch, Or as FILE_LIMITS, Os as readHandoffTurnFrame, P as entryParams, Pa as readReply, Pc as currentRelays, Pn as isFederationId, Ps as setAckFrame, Q as mentionsMember, Qa as shownTime, Qi as DHT_MESSAGE_TTL, Qo as HANDOFF_PIECE_BYTES, Qr as chatAppId, Qs as createDeviceInvite, Qt as ETHEREUM_USDT, R as readKnocks, Rc as budgetRetryMs, Ri as identityRevocationValue, Rn as INVITE_TAKEN, Rr as mayPin, S as groupTypingEpoch, Sa as COMMUNITY_REACTION_FRAME, Sc as turnPutSummary, Sr as rankHubs, Ss as readHandoffAck, T as COMMUNITY_LIMITS, Ta as ReactionWindow, Tc as firstDeviceSetSecret, To as GhostlyHttpError, Tr as readLobby, Ts as readHandoffHave, U as parseGroupWakeFrame, Uc as createRelayPayload, Ui as lastSharedWithMe, Un as GROUP_FILE_LIMITS, Uo as LEGACY_SERVICES, Ur as WAKE_CALL_INTERVAL_MS, Us as enrollCancelReason, V as groupWakeFrame, Va as STATUS_CARD_LIMITS, Vc as PacketTooLargeError, Vi as receivedIdentityStatus, Vn as engineError, Vo as emptyLinkRecords, Vr as pinNumberHolds, Wi as PeerProofs, Wo as serviceIdFromName, Wr as WakeLimiter, Ws as enrollDigits, X as carryMentions, Xa as heardTime, Xc as publicKeyFromZ32, Xn as readGroupFileMeta, Xo as HANDOFF_OFFER, Xr as checkPushEndpoint, Xs as readEnrollHello, Y as GROUP_EDIT_TEXT_BYTES, Ya as claimedTime, Yc as identityFromSeedB64, Yo as HANDOFF_HELLO, Yr as wakeRequest, Ys as readEnrollGrant, Yt as encodeDidDhtPacket, Z as parseCommunityEdit, Za as receivedTimestamp, Zc as verify, Zo as HANDOFF_PAKE, Zr as vapidKeysMatch, Zs as verifyEnrollProof, Zt as signDidDhtPacket, _a as RateWindow, _c as webCryptoSigner, _o as rosterHas, _r as lobbyRecords, _s as handoffReleaseFrame, a as planAppUpdate, aa as safeBlobType, ai as HOLD_CAPABILITY, al as utf8Decode, an as USDT_PUBLIC_RPC, ao as isAppRef, ar as groupSignalFrame, as as handoffAttemptSucceeded, ba as forwardedAgain, bc as TURN_SETTLE_MS, bo as DirectPathWatch, br as newerHead, bs as handoffVersions, bt as normalizeAtprotoHandle, cc as ENROLL_CAPABILITY, ci as HoldKeys, cn as assertWholeSats, cr as mayBeHub, cs as handoffContext, d as APP_BUNDLE_LIMITS, da as fileMessageText, dc as deviceEchoNonce, di as newMailbox, dn as walletNetworkOf, dr as COMMUNITY_TOPOLOGY, ds as handoffDigest, ea as DHT_FILE_CAPABILITY, ec as readDeviceInvite, ei as APPS_CAPABILITY, el as bytesEqual, en as ONCHAIN_PROVIDER, eo as APP_STATEMENT_LIMITS, er as CarriedTransport, es as HANDOFF_TIMINGS, f as APP_BUNDLE_MAGIC, fa as parseVoiceMeta, fc as deviceKeyZ32, fi as readManifest, fn as SPARK_NETWORKS, fo as readCanonicalJson, fr as beaconKeys, fs as handoffEphemeral, ga as EDIT_RATE_WINDOW_MS, gc as seedSigner, gn as isBitcoinAddress, go as commitHash, gr as lobbyKeys, gs as handoffPakeFrame, h as appViewOf, ha as EDIT_BUFFER_MS, hc as newDeviceSetSecret, hn as sparkInvoiceDetails, ho as MEMBER_KEY, hr as freshHubs, hs as handoffOfferFrame, i as checkAppBeforeRun, ia as formatFileSize, ic as encodeInviteCode, ii as UPGRADE_CAPABILITY, il as toZ32, in as TEST_USDT_FAUCET_AMOUNT, io as isAppKey, is as handoffAttemptFailed, ja as validReactionNumber, jc as turnName, jn as cashuRequestPayload, jo as encodeControl, k as decodeCommunityLink, ka as reactionIsNewer, kc as signTurnRelease, ki as capsDescriptors, kn as decodeBolt11, ko as LIMITS, kr as transferEnded, ks as readHandoffVerified, l as readAppStoreSteps, lc as HANDOFF_CAPABILITY, li as HoldRefusedError, ln as formatPaymentAmount, lr as meshHubs, ls as handoffContextProof, m as appDigest, ma as parseVideoMeta, mc as devicePingFrame, mn as sparkAddressKind, mr as doorHubs, ms as handoffManifestFrames, n as appStoreDecision, na as parseImageMeta, nc as createChatInvite, ni as EDIT_CAPABILITY, nl as randomBytes, nn as SEPOLIA_TEST_USDT, no as appRef, ns as filePartName, o as APP_STORE_LIMITS, oa as sanitizeFileName, oc as inviteOwnership, oi as HOLD_DATED_BACK_MS, ol as utf8Encode, on as WALLET_NETWORKS, oo as readAppRevocation, or as readGroupSignal, os as handoffBusyFrame, pc as deviceLinkPairing, pn as SPARK_PROVIDER, po as GROUP_MEMBER_CAP, pr as beaconRecords, ps as handoffHelloFrame, qa as statusCardText, qc as createIdentity, qi as TRANSPORTS, qo as HANDOFF_DONE, qr as parseWakeFrame, qs as enrollHelloFrame, qt as didDhtFromPublicKey, r as appUpdateDecision, rc as decodeInviteCode, ri as STATUS_CARD_CAPABILITY, rl as toBase64Url, rs as handoffAttemptAllowed, sa as sanitizeMime, sc as DEVICES_CAPABILITY, si as HOLD_LIMITS, sn as assertTokenUnits, sr as MESH_HUBS, ss as handoffCancelFrame, t as appRevocationFor, ta as dhtFileId, tc as OwnInviteError, ti as BUTTONS_CAPABILITY, tl as fromBase64Url, tn as PaymentPreflightError, to as appFingerprint, ts as HANDOFF_VERIFIED, uc as deviceEchoFrame, ui as isHoldUrl, un as validatePaymentTarget, uo as canonicalJson, ur as pickMeshHubs, us as handoffDataFrame, v as isAppUrl, va as dhtEditId, vn as isPubkyKey, vo as verifyCommitSignature, vr as mergeBeacon, vs as handoffRetryAfter, w as sealGroupTyping, wa as REACTION_LIMITS, wc as TURN_MAX, wo as presenceSeenAt, wr as readBeaconHead, ws as readHandoffDone, x as GroupTypingBoard, xa as readForwarded, xc as classifyTurnRead, xo as DHT_POLL_INTERVALS, xr as pickHubs, xs as newHandoffId, y as readAppBundle, ya as parseLinkPreview, yc as TURN_RAISE_READ_TIMEOUT_MS, yo as edgeParams, yr as mergeLobby, ys as handoffStreamKey, yt as isAtprotoDid, zc as isDiscoveryBudgetError, zi as identityStatement, zn as LIVENESS_PING_MS, zo as parseCallSignal, zr as nextPinNumber } from "./src-XzcHzneo.js";
3
3
  import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-CTF6LF5B.js";
4
4
  import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-BcYZhNgF.js";
5
5
  import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
@@ -180,7 +180,7 @@ async function migrateWalletNetworks(now = Date.now()) {
180
180
  //#endregion
181
181
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
182
182
  /** The Ethereum libraries, a large part of the app: loaded once a USDT wallet is made, opened or restored, not with the engine. */
183
- var loadUsdt = () => import("./usdt-DklGBSeq.js");
183
+ var loadUsdt = () => import("./usdt-Deo3g0Nq.js");
184
184
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
185
185
  var DEFAULT_USDT = {
186
186
  network: "ethereum",
@@ -642,7 +642,9 @@ async function restoreArkDatabase(walletId, snapshot) {
642
642
  //#endregion
643
643
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
644
644
  /** The Ark SDK, a large part of the app: loaded once an Ark wallet is made, opened or restored, not with the engine. */
645
- var loadArkade = () => import("./arkade-3szZ1IpP.js");
645
+ var loadArkade = () => import("./arkade-_gS9wShF.js");
646
+ /** How long a Testnet wallet about to make way for another waits for its server to say what it holds. */
647
+ var RETIRE_READ_MS = 1e4;
646
648
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
647
649
  var DEFAULT_ARK = {
648
650
  network: "bitcoin",
@@ -1048,10 +1050,23 @@ var ArkWallet = class {
1048
1050
  async retirable(refusal) {
1049
1051
  const saved = this.saved;
1050
1052
  if ((await wrap((await store(STORES$1.intents, "readonly")).getAll())).some((i) => this.ours(i))) throw new Error(refusal);
1051
- if (this.adapter && await this.gate.within(this.adapter.balance()) > 0) throw new Error(refusal);
1053
+ if (this.adapter && await this.gate.within(this.held(this.adapter)) > 0) throw new Error(refusal);
1052
1054
  await this.lock();
1053
1055
  return saved;
1054
1056
  }
1057
+ /**
1058
+ * What the wallet holds, asked of its server. A test network's server that stopped answering must not hold the
1059
+ * wallet on it (one that never answered does not either): past RETIRE_READ_MS the last balance read stands.
1060
+ * Mainnet waits for its server's own word.
1061
+ */
1062
+ held(adapter) {
1063
+ const asked = adapter.balance();
1064
+ if (this.network !== "testnet") return asked;
1065
+ return new Promise((resolve, reject) => {
1066
+ const timer = setTimeout(() => resolve(this.view.balance), RETIRE_READ_MS);
1067
+ asked.then(resolve, reject).finally(() => clearTimeout(timer));
1068
+ });
1069
+ }
1055
1070
  /** A payment of this wallet's network (the other network's wallet has its own). */
1056
1071
  ours(intent) {
1057
1072
  return intent.review.method === "arkade" && arkMode(intent.review.network) === this.network;
@@ -1876,13 +1891,24 @@ var BarkWallet = class {
1876
1891
  async retirable(refusal) {
1877
1892
  const saved = this.saved;
1878
1893
  if ((await intentRepository.list()).some((i) => this.ours(i))) throw new Error(refusal);
1879
- if (this.adapter) {
1880
- const b = await this.gate.within(this.adapter.balance());
1881
- if (b.spendableSats + b.pendingInRoundSats + b.pendingBoardSats + b.pendingExitSats + b.pendingLightningSendSats + b.claimableLightningReceiveSats > 0) throw new Error(refusal);
1882
- }
1894
+ if (this.adapter && await this.gate.within(this.held(this.adapter)) > 0) throw new Error(refusal);
1883
1895
  await this.lock();
1884
1896
  return saved;
1885
1897
  }
1898
+ /**
1899
+ * What the wallet holds, asked of its server: spendable, on its way in and on its way out. A test network's server
1900
+ * that stopped answering must not hold the wallet on it (one that never answered does not either): past
1901
+ * RETIRE_READ_MS the last reading stands, whose three sums are those same six parts. Mainnet waits for its
1902
+ * server's own word.
1903
+ */
1904
+ held(adapter) {
1905
+ const asked = adapter.balance().then((b) => b.spendableSats + b.pendingInRoundSats + b.pendingBoardSats + b.pendingExitSats + b.pendingLightningSendSats + b.claimableLightningReceiveSats);
1906
+ if (this.network !== "testnet") return asked;
1907
+ return new Promise((resolve, reject) => {
1908
+ const timer = setTimeout(() => resolve(this.view.balance + (this.view.pending ?? 0) + (this.view.exiting ?? 0)), RETIRE_READ_MS);
1909
+ asked.then(resolve, reject).finally(() => clearTimeout(timer));
1910
+ });
1911
+ }
1886
1912
  /** A payment of this wallet's network (the other network's wallet has its own). */
1887
1913
  ours(intent) {
1888
1914
  return intent.review.method === "bark" && barkMode(intent.review.network) === this.network;
@@ -11143,6 +11169,15 @@ var APP_CHECK_TIMINGS = {
11143
11169
  /** A check at start is skipped when every app was checked this recently (a page reloaded several times). */
11144
11170
  freshMs: 36e5
11145
11171
  };
11172
+ /** Revocations are checked this long at a time before the page's thread is let go. */
11173
+ var REVOCATIONS_SLICE_MS = 10;
11174
+ /**
11175
+ * Revocations of store indexes whose signature was checked, kept by `appRevocationId` so an index that changes around
11176
+ * them does not have each checked again (the one read longest ago dropped first): four indexes' worth, about 1 MiB.
11177
+ */
11178
+ var STORE_REVOCATIONS_KEPT = 4 * APP_STORE_LIMITS.revoked;
11179
+ /** `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first). */
11180
+ var REVOCATION_FILES_KEPT = 64;
11146
11181
  /** A fetched bundle kept for its install screen: at most this many, for this long. */
11147
11182
  var STAGED = {
11148
11183
  max: 4,
@@ -11227,7 +11262,7 @@ var Apps = class {
11227
11262
  /** Reads of a bundle under way, by digest: icons drawn at once share one read. */
11228
11263
  verifying = /* @__PURE__ */ new Map();
11229
11264
  /**
11230
- * The `icon.png` of each checked bundle, by digest (at most 256 KiB, or null for none): every icon shown on the Apps
11265
+ * The `icon.png` of each checked bundle, by digest (at most 256 KiB and 1024 × 1024 pixels, or null for none): every icon shown on the Apps
11231
11266
  * page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
11232
11267
  */
11233
11268
  icons = /* @__PURE__ */ new Map();
@@ -11240,12 +11275,36 @@ var Apps = class {
11240
11275
  checking = null;
11241
11276
  /** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
11242
11277
  anyway = /* @__PURE__ */ new Set();
11278
+ /** The apps started here (`entry`), for this engine's life: one of them uninstalled since may still have a frame open. */
11279
+ ran = /* @__PURE__ */ new Set();
11280
+ /**
11281
+ * The versions started here (`entry`), by reference: digest to `sequence`, for this engine's life. A frame goes on
11282
+ * running the version it started with whatever is installed since, so that version is the one judged when it asks.
11283
+ */
11284
+ started = /* @__PURE__ */ new Map();
11243
11285
  views = null;
11286
+ held = /* @__PURE__ */ new Map();
11287
+ heldStores = null;
11244
11288
  /**
11245
11289
  * Listing URLs whose peek showed nothing to take (`<store> <ref> <url>` → `<index digest> <listed digest>`): the same
11246
11290
  * index listing the same is not peeked again at every check.
11247
11291
  */
11248
11292
  listingMisses = /* @__PURE__ */ new Map();
11293
+ /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused. */
11294
+ revocationFiles = /* @__PURE__ */ new Map();
11295
+ /** Revocations read in a store's index and verified, by `appRevocationId`: at most `STORE_REVOCATIONS_KEPT`. */
11296
+ storeRevocations = /* @__PURE__ */ new Set();
11297
+ checkedRevocations = {
11298
+ has: (id) => {
11299
+ if (!this.storeRevocations.delete(id)) return false;
11300
+ this.storeRevocations.add(id);
11301
+ return true;
11302
+ },
11303
+ add: (id) => {
11304
+ this.storeRevocations.add(id);
11305
+ if (this.storeRevocations.size > STORE_REVOCATIONS_KEPT) this.storeRevocations.delete(this.storeRevocations.values().next().value);
11306
+ }
11307
+ };
11249
11308
  stopped = false;
11250
11309
  constructor(host) {
11251
11310
  this.host = host;
@@ -11269,6 +11328,9 @@ var Apps = class {
11269
11328
  if (this.sweep) clearTimeout(this.sweep);
11270
11329
  this.sweep = null;
11271
11330
  this.staged.clear();
11331
+ this.held.clear();
11332
+ this.heldStores = null;
11333
+ this.views = null;
11272
11334
  }
11273
11335
  schedule(ms, atStart = false) {
11274
11336
  if (this.timer) clearTimeout(this.timer);
@@ -11300,8 +11362,22 @@ var Apps = class {
11300
11362
  async installed() {
11301
11363
  return wrap((await store(STORES$1.apps, "readonly")).getAll());
11302
11364
  }
11303
- async app(ref) {
11304
- return wrap((await store(STORES$1.apps, "readonly")).get(ref));
11365
+ /**
11366
+ * An installed app's record, read once and again after it is written here (the only writer, as for `heldViews`). An
11367
+ * app's every storage call, file read and chat frame asks for it. A reference not installed is not kept.
11368
+ */
11369
+ app(ref) {
11370
+ const kept = this.held.get(ref);
11371
+ if (kept) return kept;
11372
+ const read = store(STORES$1.apps, "readonly").then((s) => wrap(s.get(ref)));
11373
+ this.held.set(ref, read);
11374
+ const drop = () => {
11375
+ if (this.held.get(ref) === read) this.held.delete(ref);
11376
+ };
11377
+ read.then((app) => {
11378
+ if (!app) drop();
11379
+ }, drop);
11380
+ return read;
11305
11381
  }
11306
11382
  async installedOrFail(ref) {
11307
11383
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
@@ -11312,25 +11388,55 @@ var Apps = class {
11312
11388
  * a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
11313
11389
  * or not the page stops its frame.
11314
11390
  */
11315
- async stillRunnable(app) {
11316
- const run = this.runStatus(app, await this.heldViews(), app.revocations);
11391
+ async stillRunnable(app, digest) {
11392
+ const version = this.versionRunning(app, digest);
11393
+ const run = this.runStatus(version, await this.heldViews(), app.revocations);
11317
11394
  if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
11318
- if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
11395
+ if (run.status === "removed" && !this.anyway.has(`${version.ref} ${version.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
11319
11396
  return app;
11320
11397
  }
11321
- async runningOrFail(ref) {
11322
- return this.stillRunnable(await this.installedOrFail(ref));
11398
+ /**
11399
+ * The version a frame runs: the one it names by `digest` (what `entry` gave it), which an update installed since does
11400
+ * not change. None named: the installed one. One never started here does not run.
11401
+ */
11402
+ versionRunning(app, digest) {
11403
+ if (digest === void 0 || digest === app.digest) return app;
11404
+ const sequence = typeof digest === "string" ? this.started.get(app.ref)?.get(digest) : void 0;
11405
+ if (sequence === void 0) fail("stopped", "This version is not running");
11406
+ return {
11407
+ ref: app.ref,
11408
+ sequence,
11409
+ digest
11410
+ };
11411
+ }
11412
+ /** The versions started here that are not the installed one any more. */
11413
+ startedBefore(app) {
11414
+ return [...this.started.get(app.ref) ?? []].filter(([digest]) => digest !== app.digest).map(([digest, sequence]) => ({
11415
+ ref: app.ref,
11416
+ sequence,
11417
+ digest
11418
+ }));
11419
+ }
11420
+ async runningOrFail(ref, digest) {
11421
+ return this.stillRunnable(await this.installedOrFail(ref), digest);
11323
11422
  }
11324
11423
  /**
11325
11424
  * Before an app's frames go to a contact (`appOpen`, `appSend`): refused with `stopped` as its storage is. A reference
11326
- * not installed here (a bot that speaks an app's messages) is not this store's to refuse.
11425
+ * not installed here (a bot that speaks an app's messages) is not this store's to refuse, unless it ran here and was
11426
+ * uninstalled since: the person removed it, so a frame of it still open says nothing more (`not-installed`). `digest`:
11427
+ * the version the frame runs (`versionRunning`).
11327
11428
  */
11328
- async chatRunnable({ ref }) {
11429
+ async chatRunnable({ ref, digest }) {
11329
11430
  const app = isAppRef(ref) ? await this.app(ref) : void 0;
11330
- if (app) await this.stillRunnable(app);
11431
+ if (!app && this.ran.has(ref)) fail("not-installed", "This app is not installed");
11432
+ if (app) await this.stillRunnable(app, digest);
11331
11433
  }
11332
11434
  async putApp(app) {
11333
- await wrap((await store(STORES$1.apps, "readwrite")).put(app));
11435
+ try {
11436
+ await wrap((await store(STORES$1.apps, "readwrite")).put(app));
11437
+ } finally {
11438
+ this.held.delete(app.ref);
11439
+ }
11334
11440
  }
11335
11441
  /**
11336
11442
  * Writes an app's record back only while that install is still there (the same `installedAt`), read and written in
@@ -11345,33 +11451,58 @@ var Apps = class {
11345
11451
  tx.objectStore(STORES$1.apps).put(app);
11346
11452
  put = true;
11347
11453
  };
11348
- await done(tx);
11454
+ try {
11455
+ await done(tx);
11456
+ } finally {
11457
+ this.held.delete(app.ref);
11458
+ }
11349
11459
  return put;
11350
11460
  }
11461
+ /**
11462
+ * The stores' records by key, read once and kept in step by the writes here (the only writer: pages in other tabs
11463
+ * share this engine). A record holds its store's whole index, up to 4 MiB, and IndexedDB reads and writes it whole:
11464
+ * the Apps page, the update check and an app's every storage call and chat frame ask for them.
11465
+ */
11466
+ records() {
11467
+ return this.heldStores ??= store(STORES$1.appStores, "readonly").then((s) => wrap(s.getAll())).then((all) => new Map(all.map((r) => [r.key, r])), (error) => {
11468
+ this.heldStores = null;
11469
+ throw error;
11470
+ });
11471
+ }
11351
11472
  async storeRecord(key) {
11352
- return wrap((await store(STORES$1.appStores, "readonly")).get(key));
11473
+ return (await this.records()).get(key);
11474
+ }
11475
+ /** A write of the stores' records: what is asked while it runs waits for it, as a read of IndexedDB would; one that fails leaves what was held. */
11476
+ async writeStores(write, apply) {
11477
+ const before = this.records();
11478
+ const written = before.then(async (held) => {
11479
+ await wrap(write(await store(STORES$1.appStores, "readwrite")));
11480
+ apply(held);
11481
+ this.views = null;
11482
+ });
11483
+ const after = written.then(() => before, () => before);
11484
+ this.heldStores = after;
11485
+ after.catch(() => {
11486
+ if (this.heldStores === after) this.heldStores = null;
11487
+ });
11488
+ await written;
11353
11489
  }
11354
- async putStore(record) {
11355
- await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
11356
- this.views = null;
11490
+ putStore(record) {
11491
+ return this.writeStores((records) => records.put(record), (held) => held.set(record.key, record));
11357
11492
  }
11358
- async deleteStore(key) {
11359
- await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
11360
- this.views = null;
11493
+ deleteStore(key) {
11494
+ return this.writeStores((records) => records.delete(key), (held) => held.delete(key));
11361
11495
  }
11362
- /**
11363
- * The stores' views, read once and again after a store is written here (the only writer: pages in other tabs share
11364
- * this engine). An app's every storage call and chat frame checks them, and an index may be 4 MiB.
11365
- */
11496
+ /** The stores' views, made once and again after a store is written here: an app's every storage call and chat frame checks them. */
11366
11497
  heldViews() {
11367
11498
  return this.views ??= this.stores().then((s) => this.storeViews(s), (error) => {
11368
11499
  this.views = null;
11369
11500
  throw error;
11370
11501
  });
11371
11502
  }
11372
- /** The stores the person has (a removed default is not one). */
11503
+ /** The stores the person has (a removed default is not one), in the order of their keys, as IndexedDB lists them. */
11373
11504
  async stores() {
11374
- return (await wrap((await store(STORES$1.appStores, "readonly")).getAll())).filter((s) => !s.removed);
11505
+ return [...(await this.records()).values()].filter((s) => !s.removed).sort((a, b) => a.key < b.key ? -1 : 1);
11375
11506
  }
11376
11507
  storeViews(stores) {
11377
11508
  return stores.filter((s) => s.index).map((s) => ({
@@ -11489,7 +11620,10 @@ var Apps = class {
11489
11620
  }
11490
11621
  /**
11491
11622
  * Reads the index at `url`. With the index held for this store, bytes equal to it (by digest) are the held index: its
11492
- * signature and every revocation in it were checked when it was read, so only its expiry is taken again.
11623
+ * signature and every revocation in it were checked when it was read, so only its expiry is taken again. Any other
11624
+ * index is read with a pause every few ms (the engine shares the page's thread, and an index holds up to 4096
11625
+ * listings and 4096 revocations, each of these a signature check), and a revocation checked in an earlier read, of
11626
+ * this store or another, a preview included, is not checked again.
11493
11627
  */
11494
11628
  async readStoreAt(url, heldKey, held) {
11495
11629
  const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
@@ -11501,7 +11635,16 @@ var Apps = class {
11501
11635
  expired: held.index.expires < this.nowS()
11502
11636
  }
11503
11637
  };
11504
- return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
11638
+ const steps = readAppStoreSteps(indexBytes, sigBytes, this.nowS(), heldKey, this.checkedRevocations);
11639
+ let since = performance.now();
11640
+ for (;;) {
11641
+ const step = steps.next();
11642
+ if (step.done) return step.value;
11643
+ if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11644
+ await new Promise((resolve) => setTimeout(resolve, 0));
11645
+ since = performance.now();
11646
+ }
11647
+ }
11505
11648
  }
11506
11649
  /**
11507
11650
  * The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error.
@@ -11513,12 +11656,43 @@ var Apps = class {
11513
11656
  if (read.has(url)) continue;
11514
11657
  read.add(url);
11515
11658
  try {
11516
- const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
11517
- if (read.ok) (out ??= []).push(...read.revocations.filter((r) => r.statement.app === ref));
11659
+ const file = await this.readRevocationsFile(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
11660
+ if (file) (out ??= []).push(...file.filter((r) => r.statement.app === ref));
11518
11661
  } catch {}
11519
11662
  }
11520
11663
  return out;
11521
11664
  }
11665
+ /**
11666
+ * `readAppRevocations` (one entry that does not verify refuses the file, null here), with a pause every few ms: the
11667
+ * engine shares the page's thread, and each entry is a signature check. The same bytes are not checked again.
11668
+ */
11669
+ async readRevocationsFile(bytes) {
11670
+ const id = toBase64Url(sha256(bytes));
11671
+ const known = this.revocationFiles.get(id);
11672
+ if (known !== void 0) return known;
11673
+ const read = readCanonicalJson(bytes);
11674
+ let out = null;
11675
+ if (read.ok && Array.isArray(read.value) && read.value.length <= APP_STATEMENT_LIMITS.revocations) {
11676
+ out = [];
11677
+ let since = performance.now();
11678
+ for (const entry of read.value) {
11679
+ if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11680
+ await new Promise((resolve) => setTimeout(resolve, 0));
11681
+ since = performance.now();
11682
+ }
11683
+ const one = readAppRevocation(entry);
11684
+ if (!one.ok) {
11685
+ out = null;
11686
+ break;
11687
+ }
11688
+ out.push(one.revocation);
11689
+ }
11690
+ }
11691
+ this.revocationFiles.delete(id);
11692
+ this.revocationFiles.set(id, out);
11693
+ for (const old of this.revocationFiles.keys()) if (this.revocationFiles.size > REVOCATION_FILES_KEPT) this.revocationFiles.delete(old);
11694
+ return out;
11695
+ }
11522
11696
  summary(s) {
11523
11697
  const index = s.index;
11524
11698
  return {
@@ -11675,8 +11849,10 @@ var Apps = class {
11675
11849
  problem: error instanceof AppFetchError ? error.code : "network"
11676
11850
  };
11677
11851
  }
11678
- if (!await this.storeRecord(s.key) || (await this.storeRecord(s.key))?.removed) continue;
11679
- await this.putStore(next);
11852
+ const now = await this.storeRecord(s.key);
11853
+ if (!now || now.removed) continue;
11854
+ if (now === s && next.index === s.index && next.digest === s.digest && next.problem === s.problem && next.equivocation === s.equivocation) (await this.records()).set(s.key, next);
11855
+ else await this.putStore(next);
11680
11856
  out.push(this.summary(next));
11681
11857
  }
11682
11858
  return out;
@@ -12055,7 +12231,11 @@ var Apps = class {
12055
12231
  }
12056
12232
  const after = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
12057
12233
  const revocations = before || after ? [...before ?? [], ...after ?? []] : null;
12058
- const versions = [app, ...app.pending ? [versionOf(app.pending.manifest, app.pending.digest)] : []];
12234
+ const versions = [
12235
+ app,
12236
+ ...app.pending ? [versionOf(app.pending.manifest, app.pending.digest)] : [],
12237
+ ...this.startedBefore(app)
12238
+ ];
12059
12239
  const stays = (app.revocations ?? []).filter((r) => !revocations || versions.some((v) => appRevocationFor(v, [r])));
12060
12240
  const merged = new Map([...stays, ...revocations ?? []].map((r) => [canonicalJson(r), r]));
12061
12241
  if ((await this.app(app.ref))?.store === void 0 && app.store !== void 0) {
@@ -12146,6 +12326,9 @@ var Apps = class {
12146
12326
  if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
12147
12327
  const bundle = await this.verifiedBundle(app);
12148
12328
  const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
12329
+ this.ran.add(app.ref);
12330
+ const versions = this.started.get(app.ref) ?? /* @__PURE__ */ new Map();
12331
+ this.started.set(app.ref, versions.set(app.digest, app.sequence));
12149
12332
  return {
12150
12333
  ref: app.ref,
12151
12334
  digest: app.digest,
@@ -12156,9 +12339,10 @@ var Apps = class {
12156
12339
  entry: utf8Decode(entry)
12157
12340
  };
12158
12341
  }
12159
- /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
12160
- async file({ ref, path }) {
12161
- const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
12342
+ /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). `digest`: the version the frame runs. */
12343
+ async file({ ref, path, digest }) {
12344
+ const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref, digest);
12345
+ if (path !== "icon.png" && digest !== void 0 && digest !== app.digest) fail("no-file", "No such file in this app");
12162
12346
  const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
12163
12347
  if (icon) return icon.slice();
12164
12348
  if (icon === null) fail("no-file", "No such file in this app");
@@ -12169,6 +12353,10 @@ var Apps = class {
12169
12353
  async view(app, stores) {
12170
12354
  const { manifest } = app;
12171
12355
  const listedBy = this.listedBy(app.ref, stores);
12356
+ const stopped = this.startedBefore(app).map((v) => ({
12357
+ digest: v.digest,
12358
+ run: this.runStatus(v, this.storeViews(stores), app.revocations)
12359
+ })).filter((v) => v.run.status !== "ok");
12172
12360
  return {
12173
12361
  ref: app.ref,
12174
12362
  name: app.name,
@@ -12198,7 +12386,8 @@ var Apps = class {
12198
12386
  sequence: app.equivocation.sequence,
12199
12387
  at: app.equivocation.at
12200
12388
  } },
12201
- ...app.checkedAt !== void 0 && { checkedAt: app.checkedAt }
12389
+ ...app.checkedAt !== void 0 && { checkedAt: app.checkedAt },
12390
+ ...stopped.length && { stopped }
12202
12391
  };
12203
12392
  }
12204
12393
  async list() {
@@ -12214,8 +12403,13 @@ var Apps = class {
12214
12403
  const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
12215
12404
  tx.objectStore(STORES$1.apps).delete(app.ref);
12216
12405
  tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
12217
- await done(tx);
12406
+ try {
12407
+ await done(tx);
12408
+ } finally {
12409
+ this.held.delete(app.ref);
12410
+ }
12218
12411
  for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
12412
+ this.started.delete(app.ref);
12219
12413
  await this.removeBundle(app.bytes, app.digest);
12220
12414
  if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
12221
12415
  }
@@ -12246,8 +12440,8 @@ var Apps = class {
12246
12440
  }
12247
12441
  return used;
12248
12442
  }
12249
- async storageGet({ ref, scope, key }) {
12250
- const app = await this.runningOrFail(ref);
12443
+ async storageGet({ ref, scope, key, digest }) {
12444
+ const app = await this.runningOrFail(ref, digest);
12251
12445
  const row = await wrap((await store(STORES$1.appStorage, "readonly")).get([
12252
12446
  app.ref,
12253
12447
  this.scopeOf(scope),
@@ -12256,8 +12450,8 @@ var Apps = class {
12256
12450
  return row ? { value: JSON.parse(row.value) } : null;
12257
12451
  }
12258
12452
  /** Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope. */
12259
- async storageSet({ ref, scope, key, value }) {
12260
- const app = await this.runningOrFail(ref);
12453
+ async storageSet({ ref, scope, key, value, digest }) {
12454
+ const app = await this.runningOrFail(ref, digest);
12261
12455
  const where = this.scopeOf(scope), name = this.keyOf(key);
12262
12456
  let text;
12263
12457
  try {
@@ -12293,8 +12487,8 @@ var Apps = class {
12293
12487
  this.usage.set(`${app.ref}\0${where}`, next);
12294
12488
  });
12295
12489
  }
12296
- async storageDelete({ ref, scope, key }) {
12297
- const app = await this.runningOrFail(ref);
12490
+ async storageDelete({ ref, scope, key, digest }) {
12491
+ const app = await this.runningOrFail(ref, digest);
12298
12492
  const where = this.scopeOf(scope), name = this.keyOf(key);
12299
12493
  await this.serial(`${app.ref}\0${where}`, async () => {
12300
12494
  const used = await this.usageOf(app.ref, where);
@@ -12312,8 +12506,8 @@ var Apps = class {
12312
12506
  this.usage.set(`${app.ref}\0${where}`, used - old.size);
12313
12507
  });
12314
12508
  }
12315
- async storageKeys({ ref, scope }) {
12316
- const app = await this.runningOrFail(ref);
12509
+ async storageKeys({ ref, scope, digest }) {
12510
+ const app = await this.runningOrFail(ref, digest);
12317
12511
  return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
12318
12512
  }
12319
12513
  /** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
@@ -14629,6 +14823,8 @@ var EnrollInviter = class extends Enrollment {
14629
14823
  stopDoneWait = null;
14630
14824
  /** A joiner was seen on the link: from then it has as long as a joiner waits for the proof to open a session. */
14631
14825
  seen = false;
14826
+ /** The person answered the digits: one answer is taken, before the record is read or written for it. */
14827
+ answered = false;
14632
14828
  constructor(options) {
14633
14829
  super(options, {
14634
14830
  role: "inviter",
@@ -14743,7 +14939,8 @@ var EnrollInviter = class extends Enrollment {
14743
14939
  * and nothing was written.
14744
14940
  */
14745
14941
  async confirm(match) {
14746
- if (this.over || this.view.step !== "confirm" || !this.joiner) throw new Error("There are no digits to confirm");
14942
+ if (this.over || this.answered || this.view.step !== "confirm" || !this.joiner) throw new Error("There are no digits to confirm");
14943
+ this.answered = true;
14747
14944
  if (!match) {
14748
14945
  await this.fail("digits", "digits", this.joiner.name);
14749
14946
  return this.view;
@@ -20422,6 +20619,11 @@ var EditQueue = class {
20422
20619
  sent = /* @__PURE__ */ new Map();
20423
20620
  /** When a card that goes again last went (`CARD_RESTORE_GAP_MS`). */
20424
20621
  restoredAt = -Infinity;
20622
+ /** The rows known to have an edit on its way, each with when it was last told (`edited`), by `tick`. */
20623
+ known = /* @__PURE__ */ new Map();
20624
+ tick = 0;
20625
+ /** The chat's history was read for the rows with an edit on its way: `known` has them all, until the next session. */
20626
+ learnt = false;
20425
20627
  timer;
20426
20628
  flushing = null;
20427
20629
  again = false;
@@ -20464,17 +20666,48 @@ var EditQueue = class {
20464
20666
  */
20465
20667
  reopened() {
20466
20668
  this.sent.clear();
20669
+ this.learnt = false;
20670
+ }
20671
+ /** This row has an edit to send (it was just written so): the next look reads it. */
20672
+ edited(id) {
20673
+ this.known.set(id, ++this.tick);
20674
+ }
20675
+ /**
20676
+ * My rows with an edit on its way, by time. `all`: the chat's whole history, when it was read for them (no `row` to
20677
+ * read one by, or nothing learnt yet in this session). Otherwise the known rows are read alone, and one that waits
20678
+ * no more is forgotten, unless it was told again while it was read.
20679
+ */
20680
+ async pending() {
20681
+ const waits = (m) => m.sender === "me" && !!m.edit?.pending;
20682
+ const row = this.deps.row;
20683
+ if (!row || !this.learnt) {
20684
+ const all = await this.deps.read(), rows = all.filter(waits);
20685
+ for (const m of rows) if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
20686
+ this.learnt = true;
20687
+ return {
20688
+ rows,
20689
+ all
20690
+ };
20691
+ }
20692
+ const asOf = this.tick, ids = [...this.known.keys()];
20693
+ const read = await Promise.all(ids.map((id) => row(id)));
20694
+ const rows = [];
20695
+ read.forEach((m, i) => {
20696
+ if (m && waits(m)) rows.push(m);
20697
+ else if ((this.known.get(ids[i]) ?? 0) <= asOf) this.known.delete(ids[i]);
20698
+ });
20699
+ return { rows: rows.sort((a, b) => a.timestamp - b.timestamp) };
20467
20700
  }
20468
20701
  /** The contact confirmed edit `seq` of my message with this wire id. */
20469
20702
  async received(wireId, seq) {
20470
- const row = (await this.deps.read()).find((m) => m.sender === "me" && m.wireId === wireId);
20703
+ const row = (await this.pending()).rows.find((m) => m.wireId === wireId);
20471
20704
  if (!row?.edit?.pending || row.edit.seq > seq) return;
20472
20705
  this.sent.delete(row.id);
20473
20706
  await this.deps.settle(row.id, row.edit.seq);
20474
20707
  }
20475
20708
  /** The contact confirmed, on the DHT floor, the edit that went there under this id (`dhtEditId`). */
20476
20709
  async receivedOnDht(id) {
20477
- const row = (await this.deps.read()).find((m) => m.sender === "me" && m.edit?.pending && m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20710
+ const row = (await this.pending()).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20478
20711
  if (!row) return;
20479
20712
  this.sent.delete(row.id);
20480
20713
  await this.deps.settle(row.id, row.edit.seq);
@@ -20488,13 +20721,13 @@ var EditQueue = class {
20488
20721
  if (this.stopped) return;
20489
20722
  const now = this.now();
20490
20723
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20491
- const rows = await this.deps.read();
20724
+ const { rows, all } = await this.pending();
20725
+ const due = rows.filter((m) => m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20492
20726
  const newest = /* @__PURE__ */ new Map();
20493
- for (const m of rows) {
20727
+ if (due.some((m) => m.edit.restore)) for (const m of all ?? await this.deps.read()) {
20494
20728
  const key = m.sender === "me" && cardKey(m);
20495
20729
  if (key && m.timestamp > (newest.get(key) ?? -Infinity)) newest.set(key, m.timestamp);
20496
20730
  }
20497
- const due = rows.filter((m) => m.sender === "me" && m.edit?.pending && m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20498
20731
  const gap = this.deps.restoreGapMs ?? CARD_RESTORE_GAP_MS;
20499
20732
  let next = Infinity;
20500
20733
  for (const message of due) {
@@ -21297,9 +21530,8 @@ var db = {
21297
21530
  /** The group and its history. */
21298
21531
  async deleteGroup(groupId) {
21299
21532
  await wrap((await store(STORES$1.groups, "readwrite")).delete(groupId));
21300
- const messages = await store(STORES$1.messages, "readwrite");
21301
- const keys = await wrap(messages.index("byLink").getAllKeys(`group:${groupId}`));
21302
- await Promise.all(keys.map((key) => wrap(messages.delete(key))));
21533
+ const link = `group:${groupId}`;
21534
+ await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([link], [link, []])));
21303
21535
  },
21304
21536
  async getServices() {
21305
21537
  return wrap((await store(STORES$1.services, "readonly")).getAll());
@@ -21374,6 +21606,11 @@ function noteAfterChange(current, message) {
21374
21606
  snippet
21375
21607
  };
21376
21608
  }
21609
+ /** The note as its message's row has it now, when that reaction still stands there (not replaced, not taken back). */
21610
+ function noteIfStanding(current, message) {
21611
+ const r = message.reactions?.[current.by];
21612
+ return r?.e && r.e === current.emoji && r.at === current.at ? note(message, current.by, r.e, r.at) : void 0;
21613
+ }
21377
21614
  /**
21378
21615
  * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`,
21379
21616
  * newest first (what the member is likeliest to have missed lands before its pace runs out), of this membership only.
@@ -21392,11 +21629,10 @@ function groupReactionsToResend(messages, limit) {
21392
21629
  }] : [];
21393
21630
  }).sort((a, b) => b.n - a.n).slice(0, limit);
21394
21631
  }
21395
- /** This side's highest number in a chat: the next one goes past it (and past what still waits to be confirmed). */
21396
- function lastMine(messages, pending = []) {
21632
+ /** This side's highest number in a chat's history: the next one goes past it. */
21633
+ function lastMine(messages) {
21397
21634
  let last = 0;
21398
21635
  for (const m of messages) last = Math.max(last, m.reactions?.me?.n ?? 0);
21399
- for (const p of pending) last = Math.max(last, p.n);
21400
21636
  return last;
21401
21637
  }
21402
21638
  var Reactions = class {
@@ -21407,6 +21643,11 @@ var Reactions = class {
21407
21643
  queue = Promise.resolve();
21408
21644
  /** A group member's reactions taken per window (chat and member key), as a paired chat's session limits them. */
21409
21645
  memberPace = /* @__PURE__ */ new Map();
21646
+ /**
21647
+ * This side's highest number per chat: read from its history the first time I react in it, then each number given
21648
+ * out. It only has to grow, so a row deleted since leaves it too high and nothing is wrong.
21649
+ */
21650
+ last = /* @__PURE__ */ new Map();
21410
21651
  constructor(host) {
21411
21652
  this.host = host;
21412
21653
  }
@@ -21428,17 +21669,19 @@ var Reactions = class {
21428
21669
  if (typeof messageId !== "string" || !messageId) return { error: "No message to react to" };
21429
21670
  const e = emoji === "" ? "" : reactionEmoji(emoji);
21430
21671
  if (e === null) return { error: "A reaction is one emoji" };
21431
- const messages = await this.host.messages(chat), group = isGroup$1(chat);
21432
- const target = messages.find((m) => m.id === messageId) ?? reactionTarget(messages, messageId, group);
21433
- const id = target && replyRef(target, group);
21672
+ const target = await this.named(chat, messageId);
21673
+ const id = target && replyRef(target, isGroup$1(chat));
21434
21674
  if (!target || !id) return { error: "That message is not in this chat, or takes no reaction" };
21435
21675
  const current = target.reactions?.me;
21436
21676
  if (!e && !current?.e) return { error: "No reaction of yours to take back" };
21677
+ let last = this.last.get(chat) ?? lastMine(await this.host.messages(chat));
21678
+ for (const p of pending) last = Math.max(last, p.n);
21437
21679
  const reaction = {
21438
21680
  id,
21439
21681
  e,
21440
- n: nextReactionNumber(lastMine(messages, pending), this.now())
21682
+ n: nextReactionNumber(last, this.now())
21441
21683
  };
21684
+ this.last.set(chat, reaction.n);
21442
21685
  return {
21443
21686
  reaction,
21444
21687
  message: await this.write(chat, target, "me", reaction) ?? target
@@ -21477,6 +21720,12 @@ var Reactions = class {
21477
21720
  for (const { by, reaction } of mine) await this.write(message.linkId, message, by, reaction);
21478
21721
  });
21479
21722
  }
21723
+ /** The row I react to: by its id here, as the pages name it, read alone; else by the id both sides know it by. */
21724
+ async named(chat, id) {
21725
+ if (this.host.message) return await this.host.message(chat, id) ?? this.find(chat, id);
21726
+ const messages = await this.host.messages(chat);
21727
+ return messages.find((m) => m.id === id) ?? reactionTarget(messages, id, isGroup$1(chat));
21728
+ }
21480
21729
  /**
21481
21730
  * The row a received reaction names. Most often the row of that id (a group's), or of `peer_` or `me_` and that id (a
21482
21731
  * chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat;
@@ -21493,9 +21742,10 @@ var Reactions = class {
21493
21742
  }
21494
21743
  return reactionTarget(await this.host.messages(chat), id, group);
21495
21744
  }
21496
- /** A chat is gone: nothing waits for it any more. */
21745
+ /** A chat is gone: nothing waits for it any more, and my highest number in it is read again if it comes back. */
21497
21746
  forget(chat) {
21498
21747
  this.waiting.delete(chat);
21748
+ this.last.delete(chat);
21499
21749
  }
21500
21750
  wait(chat, by, reaction) {
21501
21751
  const now = this.now();
@@ -21552,13 +21802,16 @@ function pinOfFormerMember(pin, inRoster) {
21552
21802
  function mayPinIn(group) {
21553
21803
  return !!group?.myKey && group.status === "active" && mayPin(group.profile, group.myKey, group.members.find((m) => m.role === "admin")?.key);
21554
21804
  }
21555
- /** The row a pin names, when it is here: the row of that id (a group's), or of `peer_`/`me_` and that id, or any other. */
21805
+ /** The row a pin names, when it is here: the row of that id (a group's), or of `peer_`/`me_` and that id, or any other of a chat's. */
21556
21806
  async function pinnedRow(host, chat, id) {
21557
21807
  if (!id) return void 0;
21558
21808
  const group = isGroup(chat);
21559
- if (host.message) for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21560
- const row = await host.message(chat, rowId);
21561
- if (row && replyRef(row, group) === id) return row;
21809
+ if (host.message) {
21810
+ for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21811
+ const row = await host.message(chat, rowId);
21812
+ if (row && replyRef(row, group) === id) return row;
21813
+ }
21814
+ if (group) return void 0;
21562
21815
  }
21563
21816
  return reactionTarget(await host.messages(chat), id, group);
21564
21817
  }
@@ -21783,6 +22036,17 @@ var FAREWELL_RETRY_MS = 6e4;
21783
22036
  var FAREWELL_TOLD_MS = 6e5;
21784
22037
  var FAREWELL_SAY_MS = 1e4;
21785
22038
  /**
22039
+ * A key asking in my lobby that no commit I hold names may be a member let in by a commit I have not heard yet: a hub
22040
+ * keeps an edge to it for a moment, on which it says where it is and takes commits (`wantAhead`). At most `AHEAD_EDGES`
22041
+ * at a time, each awaited `AHEAD_WAIT_MS` and closed `AHEAD_LINGER_MS` after it came up unless a commit named the key
22042
+ * by then; the same key is not waited for again for a while (longer once its edge was up).
22043
+ */
22044
+ var AHEAD_EDGES = 4;
22045
+ var AHEAD_WAIT_MS = 12e4;
22046
+ var AHEAD_LINGER_MS = 2e4;
22047
+ var AHEAD_RETRY_MS = 6e4;
22048
+ var AHEAD_HEARD_MS = 6e5;
22049
+ /**
21786
22050
  * A key asking from before the commit that took it out was there and was sent that commit: not waited for. The two
21787
22051
  * times are two devices' clocks (the asker's, the commit's signer's), so "before" allows this much between them:
21788
22052
  * a returning member whose clock runs behind the admin's is told all the same.
@@ -22254,6 +22518,7 @@ var Communities = class {
22254
22518
  await this.publishBeacon(group.id, live, now, false).catch(() => {});
22255
22519
  }
22256
22520
  live.farewells.clear();
22521
+ live.ahead.clear();
22257
22522
  await this.reconcile(group.id, live, now);
22258
22523
  continue;
22259
22524
  }
@@ -22347,6 +22612,7 @@ var Communities = class {
22347
22612
  if (this.leftItself(live, h.key) ? h.ts - FAREWELL_SKEW_MS > out : h.ts + FAREWELL_SKEW_MS > out) this.wantFarewell(groupId, live, h.key, now);
22348
22613
  }
22349
22614
  this.endFarewells(live, now);
22615
+ this.endAhead(groupId, live, now);
22350
22616
  if (live.hub) {
22351
22617
  const busy = this.admitting(groupId, live, now);
22352
22618
  if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
@@ -22425,8 +22691,9 @@ var Communities = class {
22425
22691
  }
22426
22692
  await this.keepLooking(groupId, live, now);
22427
22693
  for (const [key, since] of live.pendingEntries) {
22428
- const linkId = this.host.entries(groupId).get(key);
22429
- if (!(!!linkId && this.host.linkReady(linkId, 2)) && now - since > ENTRY_UNANSWERED_MS) live.pendingEntries.delete(key);
22694
+ const linkId = this.host.entries(groupId).get(key), up = !!linkId && this.host.linkReady(linkId, 2);
22695
+ const elsewhere = rosterHas(s.roster, key) && since < this.addedAt(s, key) + this.timings.knockFallbackMs;
22696
+ if (!up && (elsewhere || now - since > ENTRY_UNANSWERED_MS)) live.pendingEntries.delete(key);
22430
22697
  else if (now - since > ENTRY_TIMEOUT_MS$1) {
22431
22698
  live.pendingEntries.delete(key);
22432
22699
  this.refused.set(key, now + REFUSED_FOR_MS$1);
@@ -22485,7 +22752,7 @@ var Communities = class {
22485
22752
  const edges = this.host.edges(groupId);
22486
22753
  if (now - live.beaconAt <= 2 * this.timings.beaconReadMs) {
22487
22754
  const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
22488
- for (const [key, id] of edges) if (!hubs.has(key) && !live.members.has(key) && this.host.linkReady(id, 2) && !this.gone(live, key)) live.members.set(key, now);
22755
+ for (const [key, id] of edges) if (!hubs.has(key) && !live.members.has(key) && this.host.linkReady(id, 2) && rosterHas(live.session.roster, key) && !this.gone(live, key)) live.members.set(key, now);
22489
22756
  for (const key of [...live.members.keys()]) if (hubs.has(key)) live.members.delete(key);
22490
22757
  }
22491
22758
  for (const [key, since] of live.members) {
@@ -22623,16 +22890,21 @@ var Communities = class {
22623
22890
  for (let i = chain.length - 1; i >= 0; i--) if ((chain[i].k === "leave" || chain[i].k === "remove") && chain[i].s === key) return chain[i].k === "leave";
22624
22891
  return false;
22625
22892
  }
22626
- /** A frame goes on an edge to a member; to someone the chain took out, only the commit that took it out. */
22627
- mayHear(live, key, frame) {
22628
- if (!live?.session.wasRemoved(key)) return true;
22893
+ /**
22894
+ * A frame goes on an edge to a member; to someone the chain took out, only the commit that took it out. What the
22895
+ * group says (`said`: a frame of mine for everyone, or one a hub relays) goes to the members of the roster only:
22896
+ * a key no commit names hears none of it, and one let in on a branch that lost hears the commits.
22897
+ */
22898
+ mayHear(live, key, frame, said = false) {
22629
22899
  const f = frame;
22630
- return f.t === "group-commit" && (f.commit?.k === "remove" || f.commit?.k === "leave") && f.commit.s === key;
22900
+ if (live?.session.wasRemoved(key)) return f.t === "group-commit" && (f.commit?.k === "remove" || f.commit?.k === "leave") && f.commit.s === key;
22901
+ if (!live || !said || rosterHas(live.session.roster, key)) return true;
22902
+ return f.t === "group-commit" && live.session.isRecentMember(key);
22631
22903
  }
22632
- /** The edges of the group that may hear `frame`. */
22904
+ /** The edges of the group that may hear `frame`, said to everyone. */
22633
22905
  hearers(groupId, frame) {
22634
22906
  const live = this.live.get(groupId);
22635
- return [...this.host.edges(groupId)].filter(([key]) => this.mayHear(live, key, frame));
22907
+ return [...this.host.edges(groupId)].filter(([key]) => this.mayHear(live, key, frame, true));
22636
22908
  }
22637
22909
  /** As a hub: someone the chain took out is asking (in my lobby, or listing itself as a hub). An edge to tell it, within bounds. */
22638
22910
  wantFarewell(groupId, live, key, now) {
@@ -22653,6 +22925,34 @@ var Communities = class {
22653
22925
  for (const [key, at] of live.farewellSaid) if (now - at >= FAREWELL_SAY_MS) live.farewellSaid.delete(key);
22654
22926
  }
22655
22927
  /**
22928
+ * As a hub: a key no commit I hold names is asking in my lobby. It may be a member ahead of me on the roster, who needs
22929
+ * the edge for me to find out (its answer to my `group-sync` carries the commit); or nobody, since whoever was once
22930
+ * given the group's rendezvous secret can write a key there. An edge, within bounds, that is no member's: it takes
22931
+ * no place among my members, and nothing the group says goes on it (`mayHear`).
22932
+ */
22933
+ wantAhead(groupId, live, key, now) {
22934
+ if (live.ahead.has(key) || now < (live.aheadQuiet.get(key) ?? 0)) return;
22935
+ if (live.ahead.size >= AHEAD_EDGES || this.capacity(groupId, live, now) <= 0) return;
22936
+ live.ahead.set(key, { since: now });
22937
+ if (!this.host.edges(groupId).has(key)) live.expect.add(key);
22938
+ }
22939
+ /** Those edges end: a commit named the key (a member of mine now, or out), it never came, or its moment is over; and a hub no more keeps none. */
22940
+ endAhead(groupId, live, now) {
22941
+ const edges = this.host.edges(groupId);
22942
+ for (const [key, a] of live.ahead) {
22943
+ const member = rosterHas(live.session.roster, key), named = member || live.session.wasRemoved(key);
22944
+ if (!named && live.hub) {
22945
+ const id = edges.get(key);
22946
+ if (!a.up && id && this.host.linkReady(id, 2)) a.up = now;
22947
+ if (a.up ? now - a.up < AHEAD_LINGER_MS : now - a.since < AHEAD_WAIT_MS) continue;
22948
+ }
22949
+ live.ahead.delete(key);
22950
+ if (!named) live.aheadQuiet.set(key, now + (a.up ? AHEAD_HEARD_MS : AHEAD_RETRY_MS));
22951
+ else if (member && live.hub && live.members.size < this.capacity(groupId, live, now)) live.members.set(key, now);
22952
+ }
22953
+ for (const [key, until] of live.aheadQuiet) if (until <= now) live.aheadQuiet.delete(key);
22954
+ }
22955
+ /**
22656
22956
  * Someone the chain took out says where it is (`group-sync`): it is handed the commits up to the one that took it
22657
22957
  * out, and nothing else (`CommunitySession.farewell`). Nothing else it says is heard.
22658
22958
  */
@@ -22716,6 +23016,10 @@ var Communities = class {
22716
23016
  if (ts + FAREWELL_SKEW_MS > (live.session.outAt(key) ?? Infinity)) this.wantFarewell(groupId, live, key, now);
22717
23017
  continue;
22718
23018
  }
23019
+ if (!rosterHas(live.session.roster, key)) {
23020
+ this.wantAhead(groupId, live, key, now);
23021
+ continue;
23022
+ }
22719
23023
  live.emptySince = now;
22720
23024
  if (!live.members.has(key) && live.members.size >= this.capacity(groupId, live, now)) continue;
22721
23025
  if (!live.members.has(key)) {
@@ -22736,6 +23040,7 @@ var Communities = class {
22736
23040
  for (const h of freshHubs(this.hubs(live), now)) if (h.key !== s.myKey && !this.hubDead(groupId, live, h, now)) wanted.add(h.key);
22737
23041
  for (const key of live.members.keys()) wanted.add(key);
22738
23042
  for (const key of live.farewells.keys()) wanted.add(key);
23043
+ for (const key of live.ahead.keys()) wanted.add(key);
22739
23044
  } else for (const key of live.myHubs) wanted.add(key);
22740
23045
  for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key)) && !s.wasRemoved(key)) wanted.add(key);
22741
23046
  }
@@ -22856,8 +23161,7 @@ var Communities = class {
22856
23161
  if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host || key === s.myKey) continue;
22857
23162
  const stillKnocking = now - ts < 2 * this.timings.slowKnockMs;
22858
23163
  if (rosterHas(s.roster, key)) {
22859
- const added = [...s.state.chain].reverse().find((c) => c.k === "add" && c.s === key)?.ts ?? 0;
22860
- if (!stillKnocking || ts < added + this.timings.knockFallbackMs) continue;
23164
+ if (!stillKnocking || ts < this.addedAt(s, key) + this.timings.knockFallbackMs) continue;
22861
23165
  }
22862
23166
  const seen = live.knocksSeen.get(key);
22863
23167
  const changed = !!seen && seen.doors !== doorSig;
@@ -22887,6 +23191,10 @@ var Communities = class {
22887
23191
  }
22888
23192
  for (const [key, { first }] of live.knocksSeen) if (now - first > 36e4) live.knocksSeen.delete(key);
22889
23193
  }
23194
+ /** When the chain last added this member (0: never). */
23195
+ addedAt(s, key) {
23196
+ return [...s.state.chain].reverse().find((c) => c.k === "add" && c.s === key)?.ts ?? 0;
23197
+ }
22890
23198
  /** Leave requests: the door commits them (see `answerKnocks`); any hub after a while. */
22891
23199
  async commitLeaves(live, otherHubs, now) {
22892
23200
  const s = live.session;
@@ -23133,6 +23441,7 @@ var Communities = class {
23133
23441
  if (f.t === "group-sync" && live.session.isMember) this.sayFarewell(groupId, live, peerKey, f);
23134
23442
  return;
23135
23443
  }
23444
+ if (!live.session.isRecentMember(peerKey) && f.t !== "group-commit" && f.t !== "group-msg" && f.t !== "group-secrets" && !(f.t === "group-secret" && f.to === live.session.myKey)) return;
23136
23445
  if ((f.t === "group-secret" || f.t === "group-entry") && typeof f.to === "string" && f.to !== live.session.myKey) {
23137
23446
  if (!live.hub) return;
23138
23447
  const id = `${String(f.t)}:${f.to}:${String(f.h ?? f.x)}`;
@@ -23187,7 +23496,7 @@ var Communities = class {
23187
23496
  edgeReady(groupId, peerKey, linkId) {
23188
23497
  const live = this.live.get(groupId);
23189
23498
  if (!live || live.session.wasRemoved(peerKey)) return;
23190
- if (live.hub && !this.peerIsHub(live, peerKey)) live.members.set(peerKey, this.now());
23499
+ if (live.hub && !this.peerIsHub(live, peerKey) && rosterHas(live.session.roster, peerKey)) live.members.set(peerKey, this.now());
23191
23500
  this.sendTo(linkId, live.session.syncFrame());
23192
23501
  this.host.emit();
23193
23502
  }
@@ -23302,6 +23611,8 @@ var Communities = class {
23302
23611
  farewells: /* @__PURE__ */ new Map(),
23303
23612
  farewellQuiet: /* @__PURE__ */ new Map(),
23304
23613
  farewellSaid: /* @__PURE__ */ new Map(),
23614
+ ahead: /* @__PURE__ */ new Map(),
23615
+ aheadQuiet: /* @__PURE__ */ new Map(),
23305
23616
  knocksScanned: false,
23306
23617
  knockCursor: 0,
23307
23618
  lastShardPoll: 0,
@@ -24506,6 +24817,8 @@ var Groups = class {
24506
24817
  removedAt = /* @__PURE__ */ new Map();
24507
24818
  /** Who is typing in each private group, and this side's word there (WISP 902 · Group Mesh § Typing). */
24508
24819
  typings;
24820
+ /** A member's pins taken per window in a private group (group and member key). */
24821
+ pinPaces = /* @__PURE__ */ new Map();
24509
24822
  /** Edge signaling through members (WISP 902 · Group Mesh § Signaling through members). */
24510
24823
  signals;
24511
24824
  /** Files and voice messages in every group, private or community (WISP 503 · Group Files). */
@@ -24571,6 +24884,13 @@ var Groups = class {
24571
24884
  if (!asItIs) await this.files?.sweep(all.map((g) => g.id));
24572
24885
  for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
24573
24886
  }
24887
+ /** The pins taken per window from one member's edge in a private group, as a paired chat's session limits them. */
24888
+ pinPace(groupId, member) {
24889
+ const key = `${groupId}\n${member}`;
24890
+ let pace = this.pinPaces.get(key);
24891
+ if (!pace) this.pinPaces.set(key, pace = new ReactionWindow(PIN_LIMITS.receive, PIN_LIMITS.windowMs));
24892
+ return pace;
24893
+ }
24574
24894
  /** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
24575
24895
  pinOf(groupId) {
24576
24896
  return this.record(groupId)?.pin;
@@ -24883,6 +25203,15 @@ var Groups = class {
24883
25203
  if (!groupOfFile(fileId, [...this.stored.keys(), ...this.communities.ids()]) || !this.files) throw new Error("No such file");
24884
25204
  return this.files.download(fileId);
24885
25205
  }
25206
+ /** The person stops a group's file that is coming or would be fetched (Cancel, Decline), or pauses it (`keep`). */
25207
+ haltFile(fileId, keep = false) {
25208
+ if (!groupOfFile(fileId, [...this.stored.keys(), ...this.communities.ids()]) || !this.files) throw new Error("No such file");
25209
+ return this.files.halt(fileId, keep);
25210
+ }
25211
+ /** Automatic downloads were turned off: the groups' files chosen by themselves and not here yet stop coming. */
25212
+ autoDownloadsOff() {
25213
+ return this.files?.autoDownloadsOff() ?? Promise.resolve();
25214
+ }
24886
25215
  /** An edge's files/3 session opened or closed (WISP 503: the bytes of a group's files go over it). */
24887
25216
  fileSession(groupId, peerKey, linkId, open) {
24888
25217
  this.files?.session(groupId, peerKey, linkId, open);
@@ -25181,6 +25510,7 @@ var Groups = class {
25181
25510
  this.gapsAsked.delete(groupId);
25182
25511
  this.gapsLooked.delete(groupId);
25183
25512
  this.typings.forget(groupId);
25513
+ for (const key of this.pinPaces.keys()) if (key.startsWith(`${groupId}\n`)) this.pinPaces.delete(key);
25184
25514
  this.downSince.delete(groupId);
25185
25515
  this.hereHeard.delete(groupId);
25186
25516
  this.hereActed.delete(groupId);
@@ -25569,6 +25899,8 @@ var Groups = class {
25569
25899
  return;
25570
25900
  }
25571
25901
  if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
25902
+ if (existing?.left && (existing.left.admin !== frame.admin || existing.contacts?.[existing.left.admin] !== linkId)) return;
25903
+ if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis) return;
25572
25904
  if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
25573
25905
  if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
25574
25906
  try {
@@ -25584,6 +25916,7 @@ var Groups = class {
25584
25916
  ...existing?.state?.nicks
25585
25917
  };
25586
25918
  const previous = existing?.state ? existing : existing?.previous;
25919
+ const genesis = existing?.state ? commitHash(existing.state.chain[0]) : existing?.invitation?.genesis;
25587
25920
  const invitation = {
25588
25921
  id: g,
25589
25922
  createdAt: Date.now(),
@@ -25592,7 +25925,8 @@ var Groups = class {
25592
25925
  admin: frame.admin,
25593
25926
  linkId,
25594
25927
  ...invitationClaims(frame),
25595
- pieces: []
25928
+ pieces: [],
25929
+ ...genesis ? { genesis } : {}
25596
25930
  },
25597
25931
  ...Object.keys(known).length ? { formerNames: known } : {},
25598
25932
  ...previous ? { previous } : {}
@@ -25676,6 +26010,20 @@ var Groups = class {
25676
26010
  group.invitation.pieces = [];
25677
26011
  return;
25678
26012
  }
26013
+ if (group.invitation.genesis && commitHash(joined.state.chain[0]) !== group.invitation.genesis) {
26014
+ try {
26015
+ this.host.sendOnLink(linkId, {
26016
+ t: "group-decline",
26017
+ g
26018
+ });
26019
+ } catch {}
26020
+ delete group.invitation.seedB64;
26021
+ group.invitation.pieces = [];
26022
+ this.acceptSaid.delete(g);
26023
+ await this.store.putGroup(group);
26024
+ this.host.emit();
26025
+ return;
26026
+ }
25679
26027
  const viaLink = !!group.invitation.entry;
25680
26028
  this.meet(g, group.invitation.admin);
25681
26029
  const member = {
@@ -25791,6 +26139,8 @@ var Groups = class {
25791
26139
  return;
25792
26140
  }
25793
26141
  if (t === "group-pin") {
26142
+ const said = this.pinPace(groupId, peerKey).take() ? readPin(frame) : null;
26143
+ if (!said || !pinIsNewer(this.record(groupId)?.pin, said)) return;
25794
26144
  const signed = session.signedPin(frame);
25795
26145
  if (signed && rosterHas(session.roster, peerKey)) await this.host.groupPin?.(groupId, peerKey, signed);
25796
26146
  return;
@@ -26708,6 +27058,14 @@ var GroupEdits = class {
26708
27058
  receivePace = /* @__PURE__ */ new Map();
26709
27059
  timers = /* @__PURE__ */ new Map();
26710
27060
  flushing = /* @__PURE__ */ new Map();
27061
+ /** Per group: my rows known to have an edit to say, each with when it was last told (`edit`), by `tick`. */
27062
+ known = /* @__PURE__ */ new Map();
27063
+ tick = 0;
27064
+ /**
27065
+ * The groups whose history was read for the rows with an edit to say: `known` has them all, and from then on those
27066
+ * rows alone are read (`host.message`). An edit costs what it does in a short group, however long this one is.
27067
+ */
27068
+ learnt = /* @__PURE__ */ new Set();
26711
27069
  stopped = false;
26712
27070
  constructor(host) {
26713
27071
  this.host = host;
@@ -26758,6 +27116,7 @@ var GroupEdits = class {
26758
27116
  mentions: mentions.length ? mentions : void 0
26759
27117
  };
26760
27118
  });
27119
+ if (this.host.message) this.told(groupId).set(messageId, ++this.tick);
26761
27120
  await this.host.changed(chat, messageId);
26762
27121
  await this.flush(groupId).catch(() => {});
26763
27122
  return {
@@ -26778,7 +27137,7 @@ var GroupEdits = class {
26778
27137
  this.timers.delete(groupId);
26779
27138
  if (this.stopped || !this.host.membership(groupId)) return;
26780
27139
  const chat = chatOf(groupId), now = this.now();
26781
- const due = (await this.host.messages(chat)).filter((m) => m.sender === "me" && m.edit?.pending).sort((a, b) => a.edit.at - b.edit.at);
27140
+ const due = (await this.pending(groupId)).sort((a, b) => a.edit.at - b.edit.at);
26782
27141
  let pace = this.sendPace.get(groupId);
26783
27142
  if (!pace) this.sendPace.set(groupId, pace = new RateWindow(10, EDIT_RATE_WINDOW_MS, this.now));
26784
27143
  let retry = Infinity;
@@ -26809,6 +27168,33 @@ var GroupEdits = class {
26809
27168
  this.flush(groupId).catch(() => {});
26810
27169
  }, Math.max(50, retry)));
26811
27170
  }
27171
+ /**
27172
+ * My rows of a group with an edit to say. The group's whole history is read for them once (always, with no
27173
+ * `host.message` to read one by); after that the known rows alone, and one that waits no more is forgotten, unless
27174
+ * it was told again while it was read.
27175
+ */
27176
+ async pending(groupId) {
27177
+ const waits = (m) => m.sender === "me" && !!m.edit?.pending;
27178
+ const chat = chatOf(groupId), read = this.host.message;
27179
+ if (!read || !this.learnt.has(groupId)) {
27180
+ const rows = (await this.host.messages(chat)).filter(waits);
27181
+ if (!read) return rows;
27182
+ for (const m of rows) if (!this.told(groupId).has(m.id)) this.told(groupId).set(m.id, this.tick);
27183
+ this.learnt.add(groupId);
27184
+ return rows;
27185
+ }
27186
+ const known = this.told(groupId), asOf = this.tick, ids = [...known.keys()];
27187
+ return (await Promise.all(ids.map((id) => read(chat, id)))).filter((m, i) => {
27188
+ if (m && waits(m)) return true;
27189
+ if ((known.get(ids[i]) ?? 0) <= asOf) known.delete(ids[i]);
27190
+ return false;
27191
+ });
27192
+ }
27193
+ told(groupId) {
27194
+ let known = this.known.get(groupId);
27195
+ if (!known) this.known.set(groupId, known = /* @__PURE__ */ new Map());
27196
+ return known;
27197
+ }
26812
27198
  async find(chat, id) {
26813
27199
  return this.host.message ? this.host.message(chat, id) : (await this.host.messages(chat)).find((m) => m.id === id);
26814
27200
  }
@@ -26894,6 +27280,8 @@ var GroupEdits = class {
26894
27280
  forget(groupId) {
26895
27281
  clearTimeout(this.timers.get(groupId));
26896
27282
  this.timers.delete(groupId);
27283
+ this.known.delete(groupId);
27284
+ this.learnt.delete(groupId);
26897
27285
  this.buffer.forget(chatOf(groupId));
26898
27286
  }
26899
27287
  stop() {
@@ -27865,7 +28253,7 @@ var HoldEngine = class {
27865
28253
  this.host.changed();
27866
28254
  return;
27867
28255
  }
27868
- const pointer = packet ? keys.readPointer(packet) : null;
28256
+ const pointer = packet ? keys.readPointer(packet, this.host.loopbackStorage?.()) : null;
27869
28257
  if (!pointer) return;
27870
28258
  let hold = this.state(linkId);
27871
28259
  if (pointer.rev < hold.peerPointerRev) return;
@@ -27904,11 +28292,12 @@ var HoldEngine = class {
27904
28292
  try {
27905
28293
  const manifest = keys.open(await this.fetchBytes(pointer.manifestUrl, HOLD_LIMITS.maxManifestBytes), {
27906
28294
  maxBytes: HOLD_LIMITS.maxManifestBytes,
27907
- now
28295
+ now,
28296
+ loopback: this.host.loopbackStorage?.()
27908
28297
  });
27909
28298
  if (manifest.header.kind !== "manifest") throw new HoldRefusedError("format", "Not a manifest");
27910
28299
  mailbox = manifest.header.mailbox;
27911
- entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq).sort((a, b) => a[0] - b[0]);
28300
+ entries = readManifest(manifest.header.meta, this.host.loopbackStorage?.()).filter(([seq]) => seq > this.state(linkId).inSeq).sort((a, b) => a[0] - b[0]);
27912
28301
  } catch (error) {
27913
28302
  if (error instanceof HoldRefusedError) {
27914
28303
  await this.save(linkId, {
@@ -28050,9 +28439,11 @@ var HoldEngine = class {
28050
28439
  }
28051
28440
  }
28052
28441
  async readBytes(url, max, signal) {
28442
+ if (!isHoldUrl(url, this.host.loopbackStorage?.())) throw new Error("The storage address is not one this app reads");
28053
28443
  const response = await (this.host.fetch ?? ((...args) => fetch(...args)))(url, {
28054
28444
  cache: "no-store",
28055
28445
  credentials: "omit",
28446
+ redirect: "error",
28056
28447
  signal
28057
28448
  });
28058
28449
  if (!response.ok) throw new Error(`The storage answered ${response.status}`);
@@ -30682,6 +31073,9 @@ var CashuWallet = class CashuWallet {
30682
31073
  * taken back.
30683
31074
  */
30684
31075
  var UNIT = "sat";
31076
+ /** A contact's requests in one chat: the new ones taken per minute, and how many may wait to be paid. */
31077
+ var REQUESTS_PER_MINUTE = 20;
31078
+ var OPEN_REQUESTS = 50;
30685
31079
  var satsWord = (n) => `${n} sat${n === 1 ? "" : "s"}`;
30686
31080
  /**
30687
31081
  * A refused send's review, once its ecash came back: why, and exactly what came back. Taking ecash back is a swap at
@@ -30748,10 +31142,32 @@ var allows = (link, method) => method === "arkade" ? link.supportsArkPayments :
30748
31142
  var fedimintSats = (msats) => Math.floor(msats / 1e3);
30749
31143
  /** The distinct wallets of a per-network set (one object may stand for both). */
30750
31144
  var distinct = (each) => [...new Set(WALLET_NETWORKS.map((n) => each[n]))];
31145
+ /**
31146
+ * Every payment this device keeps, with the copy of them the pages get. The copy is made again only after a payment
31147
+ * changed: a state about anything else (typing, a file's progress) carries the one already made.
31148
+ */
31149
+ var PaymentBook = class extends Map {
31150
+ shown = null;
31151
+ set(id, payment) {
31152
+ this.shown = null;
31153
+ return super.set(id, payment);
31154
+ }
31155
+ delete(id) {
31156
+ this.shown = null;
31157
+ return super.delete(id);
31158
+ }
31159
+ clear() {
31160
+ this.shown = null;
31161
+ super.clear();
31162
+ }
31163
+ views() {
31164
+ return this.shown ??= Object.fromEntries([...this].map(([id, { token: _token, ...view }]) => [id, view]));
31165
+ }
31166
+ };
30751
31167
  var PaymentDesk = class {
30752
31168
  wallet;
30753
31169
  host;
30754
- payments = /* @__PURE__ */ new Map();
31170
+ payments = new PaymentBook();
30755
31171
  paying = /* @__PURE__ */ new Map();
30756
31172
  receiving = /* @__PURE__ */ new Map();
30757
31173
  reclaims = /* @__PURE__ */ new Map();
@@ -30813,8 +31229,9 @@ var PaymentDesk = class {
30813
31229
  records() {
30814
31230
  return [...this.payments.values()];
30815
31231
  }
31232
+ /** What the pages see of every payment. The same record until a payment changes: read it, never write to it. */
30816
31233
  views() {
30817
- return Object.fromEntries([...this.payments].map(([id, { token: _token, ...view }]) => [id, view]));
31234
+ return this.payments.views();
30818
31235
  }
30819
31236
  async forgetLink(linkId) {
30820
31237
  const paymentStore = await store(STORES$1.payments, "readwrite");
@@ -30823,6 +31240,7 @@ var PaymentDesk = class {
30823
31240
  this.payments.delete(payment.id);
30824
31241
  wrap(paymentStore.delete(payment.id));
30825
31242
  }
31243
+ this.requestsFrom.delete(linkId);
30826
31244
  }
30827
31245
  /** `confirmedReal`: the person confirmed a Mainnet send as real money; without it one is refused. */
30828
31246
  async send(params) {
@@ -31477,9 +31895,24 @@ var PaymentDesk = class {
31477
31895
  endpoints: [...payment.invoice ? [[ENDPOINT.bolt11, payment.invoice]] : [], ...payment.mints?.length ? [[ENDPOINT.cashu, cashuRequestPayload(payment.mints)]] : []]
31478
31896
  };
31479
31897
  }
31898
+ /** New requests taken from each chat's contact lately. */
31899
+ requestsFrom = /* @__PURE__ */ new Map();
31900
+ /**
31901
+ * Whether this chat has room for one more request of its contact: twenty new ones a minute, and fifty waiting to be
31902
+ * paid at most. One left out is not lost: the contact's app sends its open requests again on every session.
31903
+ */
31904
+ roomForRequest(linkId) {
31905
+ let recent = this.requestsFrom.get(linkId);
31906
+ if (!recent) this.requestsFrom.set(linkId, recent = new RateWindow(REQUESTS_PER_MINUTE, 6e4));
31907
+ if (!recent.take()) return false;
31908
+ let open = 0;
31909
+ for (const p of this.payments.values()) if (p.linkId === linkId && p.kind === "request" && p.direction === "in" && p.state === "pending" && ++open >= OPEN_REQUESTS) return false;
31910
+ return true;
31911
+ }
31480
31912
  /** `held`: picked up from the contact's storage while it was away (WISP 404): only what this device allows counts, and only Cashu or Lightning. */
31481
31913
  async onPaymentRequest(linkId, request, held = false) {
31482
31914
  if (this.find(linkId, request.id)?.linkId === linkId) return;
31915
+ if (!this.roomForRequest(linkId)) return;
31483
31916
  if (held && (findEndpoint(request.endpoints, ENDPOINT.usdt) || findEndpoint(request.endpoints, ENDPOINT.arkade) || findEndpoint(request.endpoints, ENDPOINT.bark) || findEndpoint(request.endpoints, ENDPOINT.bitcoin) || findEndpoint(request.endpoints, ENDPOINT.fedimint) || findEndpoint(request.endpoints, ENDPOINT.spark))) return;
31484
31917
  if (findEndpoint(request.endpoints, ENDPOINT.usdt)) return this.receiveUsdtRequest(linkId, request);
31485
31918
  const amount = parseSats(request.amount.value, request.amount.asset);
@@ -32043,6 +32476,22 @@ var PaymentDesk = class {
32043
32476
  endpoint: [ENDPOINT.usdt, JSON.stringify({ txid: review.txid })]
32044
32477
  });
32045
32478
  }
32479
+ /**
32480
+ * One payment pays one request: a receipt for a request that already has one, under another payment id,
32481
+ * is not another payment. Nothing is stored or shown for it; a paid request is answered again.
32482
+ */
32483
+ repeatedReceipt(link, request, id) {
32484
+ let repeated = false;
32485
+ for (const p of this.payments.values()) if (p.id !== id && p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.linkId === request.linkId && p.state !== "failed") {
32486
+ repeated = true;
32487
+ break;
32488
+ }
32489
+ if (repeated && request.state === "settled") link?.sendPaymentResult({
32490
+ id: request.id,
32491
+ ok: true
32492
+ });
32493
+ return repeated;
32494
+ }
32046
32495
  async receiveUsdt(linkId, payment) {
32047
32496
  const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
32048
32497
  if (!this.host.getLink(linkId)?.supportsUsdtPayments || request?.target?.method !== "usdt" || request.linkId !== linkId || request.direction !== "out" || request.kind !== "request" || payment.amount.asset !== request.unit || payment.amount.value !== String(request.amount)) return;
@@ -32055,6 +32504,7 @@ var PaymentDesk = class {
32055
32504
  return;
32056
32505
  }
32057
32506
  if (typeof txid !== "string" || !/^0x[a-fA-F0-9]{64}$/.test(txid) || existing?.state === "settled") return;
32507
+ if (this.repeatedReceipt(this.host.getLink(linkId), request, payment.id)) return;
32058
32508
  txid = txid.toLowerCase();
32059
32509
  if ([...this.payments.values()].some((p) => p.txid?.toLowerCase() === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
32060
32510
  await this.save({
@@ -32222,7 +32672,7 @@ var PaymentDesk = class {
32222
32672
  }
32223
32673
  if ([...this.payments.values()].some((p) => p.txid === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
32224
32674
  if (typeof txid !== "string" || !/^[a-f0-9]{64}$/.test(txid) || existing?.state === "settled") return;
32225
- if (!existing && [...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.state === "pending")) return;
32675
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32226
32676
  await this.save({
32227
32677
  id: payment.id,
32228
32678
  linkId,
@@ -32340,6 +32790,7 @@ var PaymentDesk = class {
32340
32790
  const existing = this.payments.get(payment.id);
32341
32791
  if (existing && (existing.linkId !== linkId || existing.direction !== "in" || existing.requestId !== request.id)) return;
32342
32792
  if (existing || parseSats(payment.amount.value, payment.amount.asset) !== request.amount) return;
32793
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32343
32794
  await this.save({
32344
32795
  id: payment.id,
32345
32796
  linkId,
@@ -32476,6 +32927,7 @@ var PaymentDesk = class {
32476
32927
  return;
32477
32928
  }
32478
32929
  if (typeof hint !== "string" || !/^[0-9a-f]{64}$/.test(hint)) return;
32930
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32479
32931
  const settled = request.state === "settled";
32480
32932
  await this.save({
32481
32933
  id: payment.id,
@@ -32824,6 +33276,7 @@ var PaymentDesk = class {
32824
33276
  const existing = this.payments.get(payment.id);
32825
33277
  if (existing && (existing.linkId !== linkId || existing.direction !== "in" || existing.requestId !== request.id)) return;
32826
33278
  if (existing || parseSats(payment.amount.value, payment.amount.asset) !== request.amount) return;
33279
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32827
33280
  const settled = request.state === "settled";
32828
33281
  await this.save({
32829
33282
  id: payment.id,
@@ -32947,6 +33400,20 @@ var PaymentDesk = class {
32947
33400
  });
32948
33401
  }
32949
33402
  /**
33403
+ * A contact's receipt that still waits for its money, dropped by hand (its chat line was deleted here): the
33404
+ * request it answers can take another receipt. Money that does arrive still pays the request.
33405
+ */
33406
+ async dropReceipt(id) {
33407
+ const payment = this.payments.get(id), request = payment?.requestId ? this.payments.get(payment.requestId) : void 0;
33408
+ if (payment?.kind !== "payment" || payment.direction !== "in" || payment.state !== "pending" || !payment.target) return;
33409
+ if (request?.kind !== "request" || request.direction !== "out" || request.linkId !== payment.linkId || request.target?.method !== payment.target.method) return;
33410
+ await this.save({
33411
+ ...payment,
33412
+ state: "failed",
33413
+ error: "Removed here"
33414
+ });
33415
+ }
33416
+ /**
32950
33417
  * A request of ours closed for good: the wallet it was paid through was removed. Nothing sends it again (replay,
32951
33418
  * a group edge opening), and the contact is told now when the chat is open (a request to a group: every member
32952
33419
  * reachable), so their app stops offering to pay it. `reason`: shown here; `told`: what the contact reads. Returns
@@ -33847,7 +34314,7 @@ var GhostlyNode = class GhostlyNode {
33847
34314
  cue: "downloaded",
33848
34315
  key: fileId
33849
34316
  }, this.chatOf(linkId));
33850
- this.noteFileEnd(linkId, fileId, record.state === "done" ? void 0 : record.error ?? record.state);
34317
+ this.noteFileEnd(linkId, fileId, record.state === "done" ? void 0 : record.error ?? record.state, !seen);
33851
34318
  }
33852
34319
  });
33853
34320
  stateTimer = null;
@@ -34029,6 +34496,7 @@ var GhostlyNode = class GhostlyNode {
34029
34496
  descriptors: () => this.providers().lightning,
34030
34497
  host: () => this.providerHost(network),
34031
34498
  hasMints: () => this.networkMints(network).length > 0,
34499
+ fetch: (...request) => (this.options.lnurlFetch ?? globalThis.fetch)(...request),
34032
34500
  events: {
34033
34501
  changed: () => void this.refreshWallet(),
34034
34502
  received: (op) => void this.desk.onLightningPaid(op),
@@ -34180,12 +34648,12 @@ var GhostlyNode = class GhostlyNode {
34180
34648
  await this.messagesChanged(linkId, [messageId]);
34181
34649
  this.emitState();
34182
34650
  },
34183
- text: async (linkId, messageId) => (await db.getMessages(linkId)).find((m) => m.id === messageId)?.text ?? null,
34651
+ text: async (linkId, messageId) => (await db.getMessage(linkId, messageId))?.text ?? null,
34184
34652
  reply: async (linkId, messageId) => {
34185
- const message = (await db.getMessages(linkId)).find((m) => m.id === messageId);
34653
+ const message = await db.getMessage(linkId, messageId);
34186
34654
  return message && GhostlyNode.wireReply(message);
34187
34655
  },
34188
- forwarded: async (linkId, messageId) => (await db.getMessages(linkId)).find((m) => m.id === messageId)?.forwarded,
34656
+ forwarded: async (linkId, messageId) => (await db.getMessage(linkId, messageId))?.forwarded,
34189
34657
  file: async (fileId) => {
34190
34658
  const stored = await fileStore.get(fileId);
34191
34659
  if (!stored?.metadata) return null;
@@ -34289,6 +34757,7 @@ var GhostlyNode = class GhostlyNode {
34289
34757
  },
34290
34758
  receivePaymentRequest: (linkId, request) => this.desk.onPaymentRequest(linkId, request, true),
34291
34759
  changed: () => this.emitState(),
34760
+ loopbackStorage: () => this.options.holdLoopback === true,
34292
34761
  ttlMs: () => {
34293
34762
  try {
34294
34763
  const raw = typeof localStorage !== "undefined" ? localStorage.getItem("ghostly-test-hold-ttl") : null;
@@ -34459,6 +34928,8 @@ var GhostlyNode = class GhostlyNode {
34459
34928
  * again those not confirmed after a while.
34460
34929
  */
34461
34930
  reactionNotes = /* @__PURE__ */ new Map();
34931
+ /** Each chat's notes shown before the one shown now, oldest first, the latest `REACTION_NOTES_KEPT` of this run. */
34932
+ reactionNotesBefore = /* @__PURE__ */ new Map();
34462
34933
  reactionsSent = /* @__PURE__ */ new Map();
34463
34934
  reactionPace = /* @__PURE__ */ new Map();
34464
34935
  reactionTimers = /* @__PURE__ */ new Map();
@@ -34480,9 +34951,12 @@ var GhostlyNode = class GhostlyNode {
34480
34951
  message: (chat, id) => db.getMessage(chat, id),
34481
34952
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34482
34953
  changed: async (chat, id, note) => {
34483
- if (note) this.reactionNotes.set(chat, note);
34484
- else if (this.reactionNotes.get(chat)?.message === id) {
34485
- const before = latestReaction(await db.getMessages(chat));
34954
+ const shown = this.reactionNotes.get(chat);
34955
+ if (note) {
34956
+ if (shown && (shown.message !== note.message || shown.by !== note.by)) this.reactionNotesBefore.set(chat, [...this.reactionNotesBefore.get(chat) ?? [], shown].slice(-8));
34957
+ this.reactionNotes.set(chat, note);
34958
+ } else if (shown?.message === id) {
34959
+ const before = await this.reactionNoteNow(chat, shown);
34486
34960
  if (before) this.reactionNotes.set(chat, before);
34487
34961
  else this.reactionNotes.delete(chat);
34488
34962
  }
@@ -34491,6 +34965,19 @@ var GhostlyNode = class GhostlyNode {
34491
34965
  },
34492
34966
  notify: (chat, key) => this.feedback("reaction", `${chat}:${key}`, chat)
34493
34967
  });
34968
+ /**
34969
+ * The chat list's reaction once one to its message was taken back: the same one if it still stands, else the latest
34970
+ * shown before it that does, each read from its own row. Only past those is the whole history read for an older one.
34971
+ */
34972
+ async reactionNoteNow(chat, shown) {
34973
+ const before = this.reactionNotesBefore.get(chat) ?? [];
34974
+ for (let note = shown; note; note = before.pop()) {
34975
+ const row = note.message ? await db.getMessage(chat, note.message) : void 0;
34976
+ const standing = row && noteIfStanding(note, row);
34977
+ if (standing) return standing;
34978
+ }
34979
+ return latestReaction(await db.getMessages(chat));
34980
+ }
34494
34981
  /** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
34495
34982
  groups = new Groups({
34496
34983
  sendOnLink: (linkId, frame) => {
@@ -35507,8 +35994,11 @@ var GhostlyNode = class GhostlyNode {
35507
35994
  appCheckUpdates() {
35508
35995
  return this.appStore.checkUpdates();
35509
35996
  }
35510
- appUninstall(params) {
35511
- return this.appStore.uninstall(params);
35997
+ /** Uninstalls an app. One running then is closed in every chat (the contact hears `close`), and the pages are told, to stop its frames. */
35998
+ async appUninstall(params) {
35999
+ await this.appStore.uninstall(params);
36000
+ this.appsCloseOf(params.ref);
36001
+ this.events.onAppsChecked?.();
35512
36002
  }
35513
36003
  appRunCheck(params) {
35514
36004
  return this.appStore.runCheck(params);
@@ -35594,17 +36084,27 @@ var GhostlyNode = class GhostlyNode {
35594
36084
  ...this.settings.wakeMutedGroups?.includes(group.id) && { wakeMuted: true }
35595
36085
  };
35596
36086
  }
36087
+ /** Per community, the members a payment here names, as read from one copy of the payments (`PaymentDesk.views`). */
36088
+ communityPaid;
35597
36089
  /** Community members payments go to (or came from), as links the payment bubbles and the composer look up by key. */
35598
36090
  communityPayViews(groups) {
35599
- const withPayments = /* @__PURE__ */ new Map();
35600
- for (const p of Object.values(this.desk.views())) {
35601
- const at = parsePayLink(p.linkId);
35602
- if (at?.member) {
35603
- const set = withPayments.get(at.groupId) ?? /* @__PURE__ */ new Set();
35604
- set.add(at.member);
35605
- withPayments.set(at.groupId, set);
36091
+ const payments = this.desk.views();
36092
+ if (this.communityPaid?.of !== payments) {
36093
+ const members = /* @__PURE__ */ new Map();
36094
+ for (const p of Object.values(payments)) {
36095
+ const at = parsePayLink(p.linkId);
36096
+ if (at?.member) {
36097
+ const set = members.get(at.groupId) ?? /* @__PURE__ */ new Set();
36098
+ set.add(at.member);
36099
+ members.set(at.groupId, set);
36100
+ }
35606
36101
  }
36102
+ this.communityPaid = {
36103
+ of: payments,
36104
+ members
36105
+ };
35607
36106
  }
36107
+ const withPayments = this.communityPaid.members;
35608
36108
  return groups.filter((g) => g.profile === "community" && g.status === "active" && g.myKey).flatMap((g) => this.communityPay.views(g.id, g.myKey, g.members.map((m) => m.key), withPayments.get(g.id) ?? /* @__PURE__ */ new Set()));
35609
36109
  }
35610
36110
  /** The payment composer opened on a member of a community: ask what they take, if they did not say lately. */
@@ -36187,6 +36687,7 @@ var GhostlyNode = class GhostlyNode {
36187
36687
  this.newestAt.delete(linkId);
36188
36688
  this.placedAt.delete(linkId);
36189
36689
  this.reactionNotes.delete(linkId);
36690
+ this.reactionNotesBefore.delete(linkId);
36190
36691
  this.events.onMessages(linkId, []);
36191
36692
  }
36192
36693
  async setGroupWakeMuted(groupId, muted) {
@@ -36734,7 +37235,9 @@ var GhostlyNode = class GhostlyNode {
36734
37235
  card: edited.card
36735
37236
  }));
36736
37237
  await this.messagesChanged(linkId, [message.id]);
36737
- this.editsFor(linkId).flush().catch(() => {});
37238
+ const edits = this.editsFor(linkId);
37239
+ edits.edited(message.id);
37240
+ edits.flush().catch(() => {});
36738
37241
  return {
36739
37242
  error: null,
36740
37243
  messageId: message.id
@@ -36855,7 +37358,10 @@ var GhostlyNode = class GhostlyNode {
36855
37358
  },
36856
37359
  ...done
36857
37360
  };
36858
- }))?.edit?.pending) changed.push(message.id);
37361
+ }))?.edit?.pending) {
37362
+ changed.push(message.id);
37363
+ this.editsFor(linkId).edited(message.id);
37364
+ }
36859
37365
  }
36860
37366
  if (changed.length) await this.messagesChanged(linkId, changed);
36861
37367
  }
@@ -36865,6 +37371,7 @@ var GhostlyNode = class GhostlyNode {
36865
37371
  if (!queue) {
36866
37372
  queue = new EditQueue({
36867
37373
  read: () => db.getMessages(linkId),
37374
+ row: (id) => db.getMessage(linkId, id),
36868
37375
  ready: () => {
36869
37376
  const live = this.links.get(linkId), link = live?.link;
36870
37377
  return !!link && (link.supportsEdits || link.textDelivery === "dht" && this.takesDhtEdits(live));
@@ -36891,7 +37398,7 @@ var GhostlyNode = class GhostlyNode {
36891
37398
  async retryMessage({ linkId, messageId }) {
36892
37399
  const live = this.links.get(linkId);
36893
37400
  if (!live?.stored.profile) throw new Error("Retry is only available for paired chat messages");
36894
- const message = (await db.getMessages(linkId)).find((m) => m.id === messageId);
37401
+ const message = await db.getMessage(linkId, messageId);
36895
37402
  if (message?.via === "hold") {
36896
37403
  if (await this.hold.retry(linkId, messageId)) return;
36897
37404
  if (!this.holdingFor(live)) throw new Error("Held messages need S3 storage (Profile → Backups) and a contact that allows them.");
@@ -37011,7 +37518,7 @@ var GhostlyNode = class GhostlyNode {
37011
37518
  */
37012
37519
  async messageDetails({ linkId, messageId }) {
37013
37520
  if (typeof linkId !== "string" || typeof messageId !== "string") throw new Error("Chat not found");
37014
- const message = (await db.getMessages(linkId)).find((m) => m.id === messageId);
37521
+ const message = await db.getMessage(linkId, messageId);
37015
37522
  if (!message) return null;
37016
37523
  const live = this.links.get(linkId), stored = live?.stored;
37017
37524
  const group = linkId.startsWith("group:") ? (await db.getGroups()).find((g) => g.id === linkId.slice(6)) : void 0;
@@ -37168,12 +37675,34 @@ var GhostlyNode = class GhostlyNode {
37168
37675
  }));
37169
37676
  return Promise.resolve();
37170
37677
  }
37171
- /** A file transfer ended: when, on the message that carries the file. */
37172
- async noteFileEnd(linkId, fileId, error) {
37173
- const message = (await db.getMessages(linkId)).find((m) => m.file?.id === fileId);
37678
+ /**
37679
+ * The message that carries a file, read by its id: a file of mine is `me_<its time>`, a received one is named by its
37680
+ * wire id (files/3, a held file) or by its sender's time (files/2). The chat's history is read only when none of
37681
+ * those rows carries it, so a file costs the same in a long chat as in a short one.
37682
+ */
37683
+ async fileMessage(linkId, fileId) {
37684
+ const stored = await fileStore.get(fileId).catch(() => void 0);
37685
+ const at = stored?.metadata?.timestamp, wireId = stored?.wireId;
37686
+ const ids = !stored ? [] : fileId.startsWith(`${linkId}-out-`) ? [`me_${at}`] : [
37687
+ `peer_${wireId}`,
37688
+ `peer_${at}`,
37689
+ `peer_${at}_${wireId}`
37690
+ ];
37691
+ for (const id of ids) {
37692
+ const message = await db.getMessage(linkId, id);
37693
+ if (message?.file?.id === fileId) return message;
37694
+ }
37695
+ return (await db.getMessages(linkId)).find((m) => m.file?.id === fileId);
37696
+ }
37697
+ /**
37698
+ * A file transfer ended: when, on the message that carries the file. `again`: it ended in an earlier run and is shown
37699
+ * again as the chat connects, so what its message says of its end stays (only a receipt it never got is given).
37700
+ */
37701
+ async noteFileEnd(linkId, fileId, error, again = false) {
37702
+ const message = await this.fileMessage(linkId, fileId);
37174
37703
  if (!message) return;
37175
37704
  const now = Date.now();
37176
- await this.noteDetails(linkId, message.id, (details) => error ? message.sender === "me" && details.sends?.length ? {
37705
+ if (!again) await this.noteDetails(linkId, message.id, (details) => error ? message.sender === "me" && details.sends?.length ? {
37177
37706
  ...details,
37178
37707
  sends: details.sends.map((s, i) => i === details.sends.length - 1 ? {
37179
37708
  ...s,
@@ -37196,12 +37725,22 @@ var GhostlyNode = class GhostlyNode {
37196
37725
  }
37197
37726
  }
37198
37727
  /**
37728
+ * The message of mine that carries a payment or a request, read by its id: `me_<the time the desk keeps for it>`.
37729
+ * The chat's history is read only when that row does not carry it, so it costs the same in a long chat as in a short one.
37730
+ */
37731
+ async paymentMessage(linkId, paymentId) {
37732
+ const at = this.desk.payment(paymentId)?.createdAt;
37733
+ const message = at === void 0 ? void 0 : await db.getMessage(linkId, `me_${at}`);
37734
+ if (message?.sender === "me" && message.paymentId === paymentId) return message;
37735
+ return (await db.getMessages(linkId)).find((m) => m.sender === "me" && m.paymentId === paymentId);
37736
+ }
37737
+ /**
37199
37738
  * The contact's app answered for a payment of mine (`pay-res`): its message gets two ticks, as a text gets from its
37200
37739
  * receipt. A payment goes with no delivery state of its own, so its mark read one tick for good. 1:1 chats only: a
37201
37740
  * group's payments live in the group's history, under other ids.
37202
37741
  */
37203
37742
  async notePaymentReceipt(linkId, paymentId) {
37204
- const message = (await db.getMessages(linkId)).find((m) => m.sender === "me" && m.paymentId === paymentId);
37743
+ const message = await this.paymentMessage(linkId, paymentId);
37205
37744
  if (!message || message.delivery === "delivered") return;
37206
37745
  if (await db.patchMessage(linkId, message.id, (m) => m.delivery === "delivered" ? null : {
37207
37746
  delivery: "delivered",
@@ -37223,6 +37762,7 @@ var GhostlyNode = class GhostlyNode {
37223
37762
  (async () => {
37224
37763
  const message = await db.getMessage(linkId, messageId);
37225
37764
  if (message?.paymentId && message.delivery === "waiting") await this.desk.withdraw(message.paymentId).catch(() => {});
37765
+ if (message?.paymentId && message.sender === "peer") await this.desk.dropReceipt(message.paymentId).catch(() => {});
37226
37766
  if (message?.sender === "me" && message.file && message.delivery === "waiting" && (message.fileOnFloor === "sent" || message.fileOnFloor === "seen")) {
37227
37767
  const wireId = message.file.id.slice(`${linkId}-out-`.length);
37228
37768
  live.link?.settleDht(dhtFileId(wireId)).catch(() => {});
@@ -37371,7 +37911,7 @@ var GhostlyNode = class GhostlyNode {
37371
37911
  });
37372
37912
  (async () => {
37373
37913
  if (!reply || !forwarded) {
37374
- const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
37914
+ const message = await db.getMessage(live.stored.id, `me_${timestamp}`);
37375
37915
  reply ??= message && GhostlyNode.wireReply(message);
37376
37916
  forwarded ??= message?.forwarded;
37377
37917
  }
@@ -37418,9 +37958,10 @@ var GhostlyNode = class GhostlyNode {
37418
37958
  }
37419
37959
  fileAction({ linkId, fileId, action }) {
37420
37960
  if (typeof linkId === "string" && linkId.startsWith("group:")) {
37421
- if (action !== "accept" && action !== "request") throw new Error("A group's file is downloaded (accept) or asked for again (request)");
37422
37961
  if (!fileId.startsWith(`group-${linkId.slice(6)}-`)) throw new Error("No such file in this group");
37423
- return this.groups.downloadFile(fileId);
37962
+ if (action === "accept" || action === "request" || action === "resume") return this.groups.downloadFile(fileId);
37963
+ if (action === "cancel" || action === "decline" || action === "pause") return this.groups.haltFile(fileId, action === "pause");
37964
+ throw new Error("A group's file is not sent again from here");
37424
37965
  }
37425
37966
  const live = this.links.get(linkId);
37426
37967
  if (!live) throw new Error("No such chat");
@@ -37509,7 +38050,7 @@ var GhostlyNode = class GhostlyNode {
37509
38050
  const error = await link.announceFile(offer, message.timestamp, GhostlyNode.wireReply(message), message.forwarded);
37510
38051
  const said = !error && link.dhtPendingId === dhtFileId(offer.wireId);
37511
38052
  if (!said && error !== "This file's name is too long to say over the DHT. It is sent when you are live.") return;
37512
- await db.patchMessage(linkId, message.id, (current) => current.delivery !== "waiting" || current.fileOnFloor ? null : {
38053
+ const noted = await db.patchMessage(linkId, message.id, (current) => current.delivery !== "waiting" || current.fileOnFloor ? null : {
37513
38054
  fileOnFloor: said ? "sent" : "unfit",
37514
38055
  ...said && { details: withSend(current.details, {
37515
38056
  at,
@@ -37517,17 +38058,32 @@ var GhostlyNode = class GhostlyNode {
37517
38058
  result: "sent"
37518
38059
  }) }
37519
38060
  });
38061
+ if (said && noted) await live.floorFiles?.then((known) => known.set(dhtFileId(offer.wireId), message.id), () => {});
37520
38062
  await this.messagesChanged(linkId, [message.id]);
37521
38063
  }
37522
38064
  /** A receipt on the DHT floor that names a waiting file's offer (`dhtFileId`): its bubble is in place on the contact's side. */
37523
38065
  async fileSeenOnFloor(linkId, id) {
37524
- const prefix = `${linkId}-out-`;
37525
- const message = (await db.getMessages(linkId)).find((m) => m.sender === "me" && m.file?.id.startsWith(prefix) && m.fileOnFloor === "sent" && dhtFileId(m.file.id.slice(prefix.length)) === id);
37526
- if (!message) return;
37527
- if (await db.patchMessage(linkId, message.id, (current) => current.fileOnFloor !== "sent" ? null : {
38066
+ const live = this.links.get(linkId);
38067
+ if (!live) return;
38068
+ const said = await this.floorFilesOf(live), messageId = said.get(id);
38069
+ if (!messageId) return;
38070
+ said.delete(id);
38071
+ if (await db.patchMessage(linkId, messageId, (current) => current.fileOnFloor !== "sent" ? null : {
37528
38072
  fileOnFloor: "seen",
37529
38073
  ...current.delivery === "waiting" && { deliveryError: "Your contact sees it. The file goes when you are live." }
37530
- })) await this.messagesChanged(linkId, [message.id]);
38074
+ })) await this.messagesChanged(linkId, [messageId]);
38075
+ }
38076
+ /**
38077
+ * The files of mine said on the floor and not seen there yet, their message by their floor id (`dhtFileId`). Read from
38078
+ * the chat's history once in a run, then kept as files are said (`sayFileOnFloor`): a receipt that names none of them
38079
+ * costs the same in a long chat as in a short one.
38080
+ */
38081
+ floorFilesOf(live) {
38082
+ const linkId = live.stored.id, prefix = `${linkId}-out-`;
38083
+ return live.floorFiles ??= db.getMessages(linkId).then((history) => new Map(history.filter((m) => m.sender === "me" && m.file?.id.startsWith(prefix) && m.fileOnFloor === "sent").map((m) => [dhtFileId(m.file.id.slice(prefix.length)), m.id])), (error) => {
38084
+ live.floorFiles = void 0;
38085
+ throw error;
38086
+ });
37531
38087
  }
37532
38088
  /**
37533
38089
  * A file the contact said on the DHT floor (WISP 403 § Files): its bubble takes its place now, waiting for its bytes,
@@ -38121,8 +38677,7 @@ var GhostlyNode = class GhostlyNode {
38121
38677
  error: "No button to press",
38122
38678
  refused: true
38123
38679
  };
38124
- const history = await db.getMessages(linkId);
38125
- const message = history.find((m) => m.id === messageId);
38680
+ const message = await db.getMessage(linkId, messageId);
38126
38681
  if (!message) return {
38127
38682
  error: "That message is not in this chat",
38128
38683
  refused: true
@@ -38131,6 +38686,8 @@ var GhostlyNode = class GhostlyNode {
38131
38686
  error: "Buttons on your own message do nothing",
38132
38687
  refused: true
38133
38688
  };
38689
+ const card = messageButtons(message);
38690
+ const history = card && !card.closed && card.buttons.some((b) => b.once) ? await db.getMessages(linkId) : [];
38134
38691
  const state = buttonsState(message, replyRef(message), history);
38135
38692
  const button = state?.card.buttons.find((b) => b.id === buttonId);
38136
38693
  if (!state || !button) return {
@@ -38179,8 +38736,7 @@ var GhostlyNode = class GhostlyNode {
38179
38736
  if (!targets.length) throw new Error("No chat to forward to");
38180
38737
  if (targets.length > 5) throw new Error(`Forward to at most 5 chats at once`);
38181
38738
  if (linkId.startsWith("group:") ? !this.membership(linkId.slice(6)) : this.links.get(linkId)?.stored.group !== void 0 || !this.links.has(linkId)) throw new Error("Chat not found");
38182
- const history = await db.getMessages(linkId);
38183
- const chosen = ids.map((id) => history.find((m) => m.id === id));
38739
+ const chosen = await Promise.all(ids.map((id) => db.getMessage(linkId, id)));
38184
38740
  if (chosen.some((m) => !m)) throw new Error("That message is not in this chat");
38185
38741
  const messages = chosen.sort((a, b) => a.timestamp - b.timestamp);
38186
38742
  for (const message of messages) {
@@ -38512,11 +39068,11 @@ var GhostlyNode = class GhostlyNode {
38512
39068
  const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
38513
39069
  if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
38514
39070
  if (!isAppRef(ref)) throw new Error("Not an app reference");
38515
- const { participationSeed, pairedPeerKey } = live.stored;
38516
- if (!participationSeed || !pairedPeerKey) throw new Error("This chat is not paired yet");
39071
+ const mine = participationKeyOf(live), { pairedPeerKey } = live.stored;
39072
+ if (!mine || !pairedPeerKey) throw new Error("This chat is not paired yet");
38517
39073
  return {
38518
39074
  live,
38519
- app: chatAppId(identityFromSeedB64(participationSeed).publicKey, publicKeyFromZ32(pairedPeerKey), ref)
39075
+ app: chatAppId(publicKeyFromZ32(mine), publicKeyFromZ32(pairedPeerKey), ref)
38520
39076
  };
38521
39077
  }
38522
39078
  /** The chat app id of an app in a paired chat: how the contact's frames name it. */
@@ -38527,10 +39083,13 @@ var GhostlyNode = class GhostlyNode {
38527
39083
  * This side opened an app in a paired chat, or updated it: `open` goes to the contact now when the session is live,
38528
39084
  * and again on every session that comes back while it stays open.
38529
39085
  */
38530
- async appOpen({ linkId, ref, version }) {
39086
+ async appOpen({ linkId, ref, version, digest }) {
38531
39087
  const { live, app } = this.appChat(linkId, ref);
38532
39088
  if (!isAppVersion(version)) throw new Error("Not an app version");
38533
- await this.appStore.chatRunnable({ ref });
39089
+ await this.appStore.chatRunnable({
39090
+ ref,
39091
+ digest
39092
+ });
38534
39093
  const open = live.appsOpen ??= /* @__PURE__ */ new Map();
38535
39094
  if (live.link) live.link.openApp(app, version);
38536
39095
  else open.set(app, version);
@@ -38543,10 +39102,13 @@ var GhostlyNode = class GhostlyNode {
38543
39102
  else live.appsOpen?.delete(app);
38544
39103
  }
38545
39104
  /** One data frame of an app open on both sides, on the live session only; nothing is kept to send later. */
38546
- async appSend({ linkId, ref, data }) {
39105
+ async appSend({ linkId, ref, data, digest }) {
38547
39106
  const { live, app } = this.appChat(linkId, ref);
38548
39107
  if (!live.appsOpen?.has(app)) return { error: "not-open" };
38549
- await this.appStore.chatRunnable({ ref });
39108
+ await this.appStore.chatRunnable({
39109
+ ref,
39110
+ digest
39111
+ });
38550
39112
  return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
38551
39113
  }
38552
39114
  /** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
@@ -38554,6 +39116,16 @@ var GhostlyNode = class GhostlyNode {
38554
39116
  for (const live of this.links.values()) for (const app of [...live.appsOpen?.keys() ?? []]) if (live.link) live.link.closeApp(app);
38555
39117
  else live.appsOpen?.delete(app);
38556
39118
  }
39119
+ /** An app that went (uninstalled) is closed in every chat it was open in, as `appClose` does. */
39120
+ appsCloseOf(ref) {
39121
+ for (const [linkId, live] of this.links) {
39122
+ if (!live.appsOpen?.size) continue;
39123
+ const { app } = this.appChat(linkId, ref);
39124
+ if (!live.appsOpen.has(app)) continue;
39125
+ if (live.link) live.link.closeApp(app);
39126
+ else live.appsOpen.delete(app);
39127
+ }
39128
+ }
38557
39129
  /** Keeps a pin of a 1:1 chat when it is newer than the chat's; `out`: mine, to say until the contact confirms it. */
38558
39130
  async keepLinkPin(linkId, pin, out) {
38559
39131
  const live = this.links.get(linkId);
@@ -38616,6 +39188,7 @@ var GhostlyNode = class GhostlyNode {
38616
39188
  const view = this.groups.views().find((g) => g.id === groupId);
38617
39189
  if (!pin || !view?.myKey || view.status !== "active" || !view.members.some((m) => m.key === member)) return;
38618
39190
  if (!mayPin(view.profile, member, view.members.find((m) => m.role === "admin")?.key)) return;
39191
+ if (!pinIsNewer(this.groups.pinOf(groupId), pin)) return;
38619
39192
  const kept = await this.pinCame(`group:${groupId}`, member === view.myKey ? "me" : member, pin);
38620
39193
  if (!await this.groups.setPin(groupId, {
38621
39194
  ...kept,
@@ -38903,7 +39476,7 @@ var GhostlyNode = class GhostlyNode {
38903
39476
  async closeRequest(paymentId, reason) {
38904
39477
  const request = this.desk.payment(paymentId);
38905
39478
  if (!request || !await this.desk.close(paymentId, reason, "your contact removed the wallet it was paid to")) return;
38906
- const message = (await db.getMessages(request.linkId)).find((m) => m.paymentId === paymentId && m.sender === "me");
39479
+ const message = await this.paymentMessage(request.linkId, paymentId);
38907
39480
  if (message) await this.hold.forget(request.linkId, message.id).catch(() => {});
38908
39481
  }
38909
39482
  /**
@@ -39742,6 +40315,7 @@ var GhostlyNode = class GhostlyNode {
39742
40315
  if (settings[key] !== false) delete this.settings[key];
39743
40316
  else this.settings[key] = false;
39744
40317
  await db.putSettings(this.settings);
40318
+ if (key === "autoDownloads" && settings[key] === false) await this.groups.autoDownloadsOff();
39745
40319
  }
39746
40320
  if (settings.publicProfiles !== void 0) {
39747
40321
  if (settings.publicProfiles !== false) delete this.settings.publicProfiles;
@@ -41289,10 +41863,11 @@ var GhostlyNode = class GhostlyNode {
41289
41863
  this.notePaymentReceipt(linkId, result.id).catch(() => {});
41290
41864
  },
41291
41865
  onFileStored: async (wire) => {
41292
- const stored = (await fileStore.listForLink(linkId)).find((file) => file.direction === "in" && file.wireId === wire.id);
41293
- if (!stored?.digest || !stored.metadata) return void 0;
41294
- if (stored.metadata.name !== wire.name || stored.metadata.size !== wire.size || stored.metadata.mime !== wire.mime || stored.metadata.timestamp !== wire.timestamp) return void 0;
41295
- return stored.digest;
41866
+ if (!stored.group && !this.links.get(linkId)?.files.wireIds.has(wire.id)) return void 0;
41867
+ const held = (await fileStore.listForLink(linkId)).find((file) => file.direction === "in" && file.wireId === wire.id);
41868
+ if (!held?.digest || !held.metadata) return void 0;
41869
+ if (held.metadata.name !== wire.name || held.metadata.size !== wire.size || held.metadata.mime !== wire.mime || held.metadata.timestamp !== wire.timestamp) return void 0;
41870
+ return held.digest;
41296
41871
  },
41297
41872
  onFileIncoming: (file) => this.receiveFile(linkId, file),
41298
41873
  onFileProgress: (id, transferred, direction) => this.fileProgress(this.localFileId(linkId, id, direction), transferred),
@@ -41431,7 +42006,7 @@ var GhostlyNode = class GhostlyNode {
41431
42006
  get nativeFactories() {
41432
42007
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
41433
42008
  return {
41434
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DmTrpmba.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
42009
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-CgYLaQz3.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41435
42010
  ...this.options.nativeTransports,
41436
42011
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
41437
42012
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -42236,4 +42811,4 @@ function signalKind(signal) {
42236
42811
  //#endregion
42237
42812
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
42238
42813
 
42239
- //# sourceMappingURL=server-BGXmSYx_.js.map
42814
+ //# sourceMappingURL=server-Cy4ox7oD.js.map